Appendix: the kernel in the browser
Every Run button in this book resolves in a real ikigai kernel. It is compiled to
WebAssembly and runs in your browser tab, under the same engine as the ikigai command.
This appendix covers how that kernel is put together. It compares what the browser gives a
kernel with what a native host gives one, and explains why the same crates serve both.
It assumes Binding, and a host of your own: a host is the code that builds a kernel, binds its spaces and hands it what it needs. The page is one more host.
The kernel brings nothing from the platform
ikigai-core reads no clock, opens no socket, starts no thread and touches no file.
Everything that comes from outside the program comes from the host that built the kernel,
through a seam the host fills:
- a space to resolve against (
Kernel::new); - a Meta renderer, so
describeand the catalog answer (Kernel::with_meta_renderer); - a clock, so the kernel can say what time it is (
Kernel::with_clock).
The kernel uses only what it was handed. That is what lets one kernel crate run in a command-line tool, in a long-running service, under a test that stops time, and in a web page.
How the page builds its kernel
The page’s host is the book-wasm crate in this repository. The book’s publishing
workflow compiles it for wasm32-unknown-unknown and binds it with
wasm-bindgen --target web. The output lands beside the book in wasm/ and is never
committed. The first time a page needs it, js/run.js loads that module, and the module
builds the page’s kernel:
/// [`page_kernel_with_clock`], over the sheet's inputs in `inputs` and push's registry in
/// `listening`. The caller attaches the kernel to `listening` once it is in an `Arc`.
fn page_kernel_over(
inputs: Arc<InputStore>,
listening: Arc<Listening>,
clock: Option<Arc<dyn Clock>>,
) -> Kernel {
let kernel = Kernel::with_meta_renderer(
Arc::new(page_space_over(inputs, listening)),
Arc::new(ikigai_vocab::TurtleRenderer),
);
match clock {
Some(clock) => kernel.with_clock(clock),
None => kernel,
}
}
A few things follow from this:
- One kernel per page. Every cell on a page runs against the same kernel, so a value cached by one cell is cached for the next. Reloading the page builds a new kernel, so nothing you did on one page is on the next.
- The same spaces the chapters test. The page’s space is Part I’s, the tic-tac-toe game’s, the spreadsheet’s and the rest, in one fallback. These are the same spaces, with the same bindings and renderer, that the chapters’ Rust tests run against. Nothing is mocked and nothing is replayed.
- The CLI’s engine. A cell’s line goes through
ikigai-engine, the REPL grammar theikigaicommand uses, sosource a | b,cache,traceanddescribemean exactly what they mean at a shell. The engine’seval_asyncruns the line on the page’s event loop rather than blocking it.
What the browser provides, and what a native host provides
A browser is a capable host, but a different one. The table compares the two for each thing a kernel or its endpoints might need:
| What it needs | In the browser | On a native host | How the kernel gets it |
|---|---|---|---|
| The time | Date.now() | the system clock | Kernel::with_clock; an endpoint asks Invocation::now |
| A wake-up later (timers) | setTimeout, setInterval | threads and native timers | a timer the host injects |
| Running async work | the page’s event loop | an executor on threads | the host drives the kernel’s futures |
| The network | fetch, WebSocket, WebTransport, under the page’s origin rules | TCP, UDP, Unix sockets, QUIC | an endpoint the host binds |
| Files | none, beyond what the page fetches | the filesystem | a space the host binds (ikigai-fs natively) |
| Keeping data | the page’s memory, until a reload | databases and files on disk | a store the host binds |
| Threads | one for the page’s kernel (the book uses no Web Workers) | many | none needed: the kernel is async |
| Other programs | none | processes (git, cargo) | an endpoint the host binds |
| Platform services | none | the keychain, calendars, contacts | an endpoint the host binds |
None of the right-hand column is something the kernel asks the platform for. A browser
host and a native host fill the same seams, each from what it has. Where the browser has
nothing (files, processes, the keychain), the page binds no space for it, and a name in
those families is simply not found there. A few names under urn:file: are the exception,
and they are not a file system.
One recorded file
The part “Exploring without Rust” runs on one recorded week of earthquakes, as USGS
published it: 178 events of magnitude 4.5 and up, from 2024-01-01 to 2024-01-08, in
GeoJSON. At a shell it is a file you save in the CLI’s file workspace, where it is
urn:file:quakes-2024-01-01.geojson. The page binds that same name to the same bytes,
compiled into the module by the quakes crate, so a cell and a command name one resource.
It answers only reads, and it never changes. The JSON-LD context that
Giving the data names reads beside it is bound the same way, at
urn:file:quakes.context.jsonld, the name the copy you download there has, and so is the
week after that Composites, one layer at a time joins, at
urn:file:quakes-2024-01-08.geojson. A river of feeds reads the files of
its example, examples/rss/ws/ in this book’s repository, and the page binds each one at the
name the example’s own REPL gives it, from urn:file:directory.opml to
urn:file:views/page.xsl, with the bytes compiled in by the rss-river crate. Its river,
urn:file:river.nt, is the copy the example commits, because a cell cannot write the file
the chapter’s build line writes.
The first line below asks urn:json:query, a jq program run as a resource, how many events
the file holds. The query reads the file through the kernel, so the second line finds the
file’s own answer already cached. The third asks the file to describe itself:
178 [computed] cached quakes-window — A recorded week of earthquakes USGS earthquakes of M4.5 and up from 2024-01-01 to 2024-01-08, 178 events, as GeoJSON, recorded once and never changed: the window "Exploring without Rust" runs on. verbs: Source, Meta [computed]
Every other name under urn:file: is not found in this page: a sink to one has nowhere
to go. The CLI binds the whole family to a directory on your disk.
Rust’s standard library cannot see the browser
The browser has a clock and timers, but a Rust crate compiled for
wasm32-unknown-unknown cannot reach them on its own. That target has no operating
system, so the standard library has nothing to call. std::time::Instant::now(),
SystemTime::now() and std::thread::spawn all compile for it, and then fail when
they are called. Everything the browser offers is reached through JavaScript imports,
which is what wasm-bindgen, js-sys and web-sys provide.
The same target also runs outside a browser: under a WebAssembly runtime such as
wasmtime, where there is no JavaScript at all. So a library that wants to run in both
places must not import JavaScript itself. It takes what it needs from its host, the way
the kernel does. That is why a crate in this ecosystem says “the host supplies a clock”
rather than calling Date.now() itself.
The page supplies its clock like this:
/// The browser's clock: `Date.now()`, milliseconds since 1970. `std::time` has no clock on
/// `wasm32-unknown-unknown`, and ikigai-core reads none itself, so the page supplies one,
/// the way `ikigai-web-demo` does.
#[cfg(target_family = "wasm")]
struct BrowserClock;
#[cfg(target_family = "wasm")]
impl Clock for BrowserClock {
fn now(&self) -> ikigai_core::Time {
ikigai_core::Time::from_millis(js_sys::Date::now() as u64)
}
}
Only a page that asks for a clock gets one, as Time is a resource explains, and this page does not ask. Try it: the time endpoint refuses, and a trace has no durations to print.
error: endpoint error: this kernel has no clock: its host did not give it one (Kernel::with_clock)
trace urn:iki:tutorial:title client ikigai repl · capability: root (full authority) transport embedded · in-process urn:iki:tutorial:title title · computed · ThreadId(1) · — → 27b resource oriented computing
The — in the trace is where a duration would go. With no clock, the kernel does not
guess.
Timers work the same way. A timer is not a clock: a clock answers “what time is it”, and a
timer answers “wake me when”. A native host’s timer runs on a thread. In the browser, a
host builds one on setTimeout or setInterval, and hands it to whatever needs to wait.
ikigai-web-demo does this for its scheduled jobs, and the Retry overlay in
ikigai-throttle takes a timer the same way.
Bridging the browser’s async world
The kernel’s futures are Send: they may be moved between threads, which a native host
needs. A future that waits on a browser promise is not, because JavaScript values stay on
the page’s one thread. So the page cannot simply await fetch inside an endpoint.
The bridge is small. The endpoint starts the browser work as a task of the page’s own,
with spawn_local, and waits on a channel that the task completes. The channel’s future
is Send, so the kernel never holds anything that is not. This is how the page fetches the
RDF Toolbox’s build in Checking a resource:
/// Where the toolbox reads its build, `urn:iki:toolbox:dist:{path}` (`ikigai_toolbox::DIST`):
/// in the page, the published copy of `ikigai-toolbox`'s `build.sh` output, which pages.yml
/// puts in `toolbox/` beside the book's root and which the browser fetches only when a name
/// asks for one of its files. So `urn:toolbox:check:{iri}` composes in the page, from the
/// build's own template, and nothing is fetched until a reader opens a check.
///
/// The kernel's futures must be `Send` and a browser fetch is not, so the fetch runs as a task
/// of the page's own (`spawn_local`) and hands its answer back over a channel, which is.
#[cfg(target_family = "wasm")]
fn dist_space() -> Arc<dyn Space> {
use ikigai_core::{AsyncFnEndpoint, Description, EndpointSpace, InvokeFuture, UriTemplate};
let fetched = AsyncFnEndpoint::new(
"book-toolbox-dist",
|inv: &ikigai_core::Invocation<'_>| -> InvokeFuture<'_> {
let path = inv.bindings.get("path").unwrap_or_default().to_string();
let (answer, answered) = futures::channel::oneshot::channel();
wasm_bindgen_futures::spawn_local(async move {
let _ = answer.send(fetch_published(&path).await);
});
Box::pin(async move {
answered
.await
.unwrap_or_else(|_| Err(Error::Endpoint("the fetch was dropped".into())))
})
},
)
.with_description(
Description::new("book-toolbox-dist")
.title("The toolbox build this book publishes")
.summary(
"One file of the RDF Toolbox build published beside this book (`toolbox/`), \
fetched by the browser when it is asked for.",
)
.verb(Verb::Source)
.verb(Verb::Meta)
.output("application/octet-stream"),
);
Arc::new(EndpointSpace::new().bind(
UriTemplate::parse("urn:iki:toolbox:dist:{path}").expect("a constant template"),
fetched,
))
}
/// Built natively there is no published build to fetch, so nothing is bound there: a check
/// page fails on its template exactly as the chapter's own host's does.
#[cfg(not(target_family = "wasm"))]
fn dist_space() -> Arc<dyn Space> {
Arc::new(ikigai_core::EndpointSpace::new())
}
/// The bytes of `toolbox/{path}` beside the book's root, as the browser fetches them. A name
/// that is not one plain file name is not found without a request: the toolbox asks only for
/// the files on its allowlist, and nothing else under the site is the dist's to serve.
#[cfg(target_family = "wasm")]
async fn fetch_published(path: &str) -> Result<Representation, Error> {
use js_sys::{Function, Promise, Reflect, Uint8Array};
use wasm_bindgen_futures::JsFuture;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = fetch)]
fn fetch_url(url: &str) -> Promise;
}
let plain = !path.is_empty()
&& !path.starts_with('.')
&& path
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'));
if !plain {
return Err(Error::NotFound(format!(
"urn:iki:toolbox:dist:{path}: not a file of the published build"
)));
}
let failed = |e: JsValue| Error::Endpoint(format!("toolbox/{path}: {e:?}"));
// Where the build is published, as `js/toolbox.js` says (`window.ikigaiToolboxBase`, the
// absolute path of `toolbox/` beside the book's root). mdbook's `path_to_root` would do,
// but it is a `const` in the page's head, which is not a property of the global object.
let base = Reflect::get(&js_sys::global(), &JsValue::from_str("ikigaiToolboxBase"))
.ok()
.and_then(|v| v.as_string())
.ok_or_else(|| {
Error::NotFound(format!(
"urn:iki:toolbox:dist:{path}: this page does not say where the toolbox build is \
published (js/toolbox.js sets window.ikigaiToolboxBase)"
))
})?;
let url = format!("{base}{path}");
let response = JsFuture::from(fetch_url(&url)).await.map_err(failed)?;
let field = |name: &str| Reflect::get(&response, &JsValue::from_str(name));
if !field("ok").map_err(failed)?.as_bool().unwrap_or(false) {
let status = field("status").map_err(failed)?.as_f64().unwrap_or(0.0);
return Err(Error::NotFound(format!("{url}: HTTP {status}")));
}
let read: Function = field("arrayBuffer")
.map_err(failed)?
.dyn_into()
.map_err(failed)?;
let pending: Promise = read
.call0(&response)
.map_err(failed)?
.dyn_into()
.map_err(failed)?;
let buffer = JsFuture::from(pending).await.map_err(failed)?;
Ok(Representation::new(
ikigai_core::ReprType::new("application/octet-stream"),
Uint8Array::new(&buffer).to_vec(),
))
}
A browser timer uses the same bridge: a task waits on the setTimeout promise, then
completes the channel the endpoint is waiting on.
The other direction never happens: the kernel does not call into the page. In
Spreadsheet, VII: push the page awaits a future that a cut
completes, and then decides on its own turn of the event loop what to redraw. In
A space that changes while it runs the live host’s rebuild loop
is itself a spawn_local task.
What fails, and how
- A call that compiles but cannot run. Reading the platform’s time or starting a
thread compiles for this target and fails when it runs, so no build catches it. A crate
that must run here keeps those calls behind
#[cfg(not(target_arch = "wasm32"))], or takes them from its host, and checks for them in its tests. - A panic stops the page’s kernel. A panic in WebAssembly is a trap: the module stops,
and the page’s kernel with it, until you reload. The page installs
console_error_panic_hook, so the reason appears in the browser’s console. The book’s programs stay far inside every bound for this reason. - The JavaScript and the WebAssembly must agree. The generated JavaScript and the
compiled module share an interface version. The book pins
wasm-bindgenexactly, and pins its command-line tool to the same version, because a mismatch fails in the page, not in any build.
The same code natively
book-wasm also builds as an ordinary Rust library, which is how this repository’s
gates lint and test it. Natively, the page’s clock is the system’s, and the toolbox’s
build is not fetched at all. The chapters’ cells are run by those tests too: each one is
read from its page and resolved against a kernel built exactly as the page builds it. So a
cell whose answer changes fails the build, not your reading.
A kernel behind a socket is the opposite end: a native host that serves a kernel to other processes, with every seam in the table above filled from the operating system.