Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 describe and 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 the ikigai command uses, so source a | b, cache, trace and describe mean exactly what they mean at a shell. The engine’s eval_async runs 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 needsIn the browserOn a native hostHow the kernel gets it
The timeDate.now()the system clockKernel::with_clock; an endpoint asks Invocation::now
A wake-up later (timers)setTimeout, setIntervalthreads and native timersa timer the host injects
Running async workthe page’s event loopan executor on threadsthe host drives the kernel’s futures
The networkfetch, WebSocket, WebTransport, under the page’s origin rulesTCP, UDP, Unix sockets, QUICan endpoint the host binds
Filesnone, beyond what the page fetchesthe filesystema space the host binds (ikigai-fs natively)
Keeping datathe page’s memory, until a reloaddatabases and files on diska store the host binds
Threadsone for the page’s kernel (the book uses no Web Workers)manynone needed: the kernel is async
Other programsnoneprocesses (git, cargo)an endpoint the host binds
Platform servicesnonethe keychain, calendars, contactsan 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-bindgen exactly, 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.