niso architecture: crates, contracts, link-time composition
Generated from docs/architecture/architecture.md when the site was built at 7aa23e2.
Status: proposal. Builds on DECISIONS.md at origin/main 3f373ff (“Modularity comes first … composition happens at link time, so a build is the set of crates it links. Each web API crate is usable on its own, by any Rust program or runtime, against a small host contract (queue a task, wake, report errors); niso’s DOM is one such host”) and on the evidence in inventory.md (cited as “inventory §n”). Code references, file:line references included, are as of 3f373ff (whose code is identical to 226cd3a, since 3f373ff changed only DECISIONS.md) and are not updated as main moves: many are stale, and since step 6b replaced niso_runtime.h and glue.js with section files, their line numbers point into 3f373ff only. migration.md tracks progress since.
0. Decisions in one page
Section titled “0. Decisions in one page”- Scope of the split. The document core (DOM tree, style, layout, render stream, events, input) stays one crate,
niso-core, with its internal structure as is. Everything else that hangs offDomtoday becomes its own crate: structured clone, storage drivers, IndexedDB, Web Storage, fetch and its transport, Blob, WebSocket, messaging and workers, WebAssembly, crypto, performance, URL, encoding, native modules, the Web Animations timing model, media, canvas, surfaces. - Foundation contracts. Every web API crate depends only on three small crates:
niso-base(handles, errors, interface descriptors, C value types),niso-host(the host contract:Cx, extension slots, the object table, the event sink, the waker,Source, hooks, and fakes) and, when it runs its own loop,niso-sched(the run loop contract ofniso_runtime.h). niso’s document is one host. A plain Rust program withniso_sched::SimpleHostis another. - The scheduler pulls; nobody edits it. A subsystem plugs into the run loop by registering a
&'static dyn Source(ready, deadline, wants-frame, live, run). “Queue a task” is a call onCxthat keeps today’s single FIFO.needs_frameandnext_timerare computed from the sameready()predicate, so the contract inniso_runtime.h(“holds exactly when a run or a frame has work”, “now exactly when a run can make progress”) holds by construction, given each source’s own obligation thatready()is true iffrun()makes progress. Dispatch is dynamic: about a dozen indirect calls per check, which is cheaper than the four-plus mutex locksrun_readytakes today. - No god struct, no central match. Subsystem state lives in typed extension slots (
ExtKey<T>, an index resolved once), objects in one shared table ofBox<dyn PlatformObject>, event payloads in typed boxes, and cross-cutting behaviour in typed hook lists (pre-layout, rendering steps, call epilogue, checkpoint end, nodes dropped, render contributors, worker seeding). Crates talk to each other only through each other’s public Rust API, as ordinary Cargo dependencies, which a layering lint checks. - Composition happens in the last crate, from files read by path. Each crate keeps its fragments (its header and glue sections, and from 6c its IDL) as files in its own tree, and
crates/niso-compose/sections.txtnames every section and its owner. niso-compose has no niso dependencies (std only): a composition’s build script calls it with the workspace root (niso-capi’s for the header; niso-quickjs’s, niso-porffor’s and niso-napi’s for the glue), and it reads the section files by path. Crates do not publish fragments as Cargolinksmetadata, and compositions do not readDEP_*variables for them; section 6’slinksdesign is superseded on that point. Cargo features of the composition turn optional parts on and off (the optional glue sections today). Nolinkmeorinventory: neither gives a deterministic order,linkmehas no wasm32 support, and both lose registrations that sit in static-library members nothing references (section 6.3). - Contracts are files and tests. Every crate has a
CONTRACT.mdin the template of section 8, atestingmodule with the fakes its dependents use, and acontracttest suite that any replacement must pass. A change to a crate’s public API, C ABI, IDL or invariants is a contract change and is reviewed as one; a planned check (cargo public-apisnapshots withcargo xtask contracts, boundaries.md item 4; the repository has no CI yet) refuses a public API diff without aCONTRACT.mddiff.
1. What the evidence says the boundaries are
Section titled “1. What the evidence says the boundaries are”- Already standalone: structured_clone.rs (no reference to
Dom), storage/{ffi,key,memory,sqlite}.rs, indexeddb/key_path.rs, wasm.rs (onlyErrorandqueue_wasm_task), media.rs, net.rs (two imports). See inventory §7. - Tangled only through the loop: WebSocket, messaging, workers, net and modules know about each other because
wait_network(websocket.rs:446-513) andrun_ready(event_loop.rs:175-185) must know who can still deliver from another thread. Give the scheduler that knowledge throughSource::liveand one shared waker, and the tangle goes away (inventory §4.2). - Messaging and workers are one unit: worker.rs calls messaging 10 times and messaging calls back into workers (
worker::Shared,worker_failed); a worker’s implicit port, agent and inbox are messaging’s. One crate. - IndexedDB, storage and structured clone are a chain, not a cycle: indexeddb → storage → structured_clone (
js_stringin key encoding), and indexeddb → structured_clone directly (key paths). Three crates. - Animations split along stylo: animation_style.rs uses
self.doc15 times and is the style applier; animation.rs and api/animations.rs are the timing model, with 2 raw-pointer functions and 10ObjectDatauses. The timing model is a crate; the applier stays in the core behind a trait. - Element-bound APIs need the document: canvas (bitmaps keyed by node, sized by attributes), surfaces (template.rs reads the laid-out tree) and the media element. They are document extensions (L2 below) whose standalone part is small (the media session, a 2D rasterizer).
2. Crate graph
Section titled “2. Crate graph”2.1 Layers
Section titled “2.1 Layers”L3 composition niso-capi (full) niso-app (iOS/Android hosts) niso-app-<native app> │ generated register_all, niso.h, glue bundle: the only place crates are named togetherL2 doc extensions niso-media the canvas element niso-surfaces (may depend on niso-core) │L1 the document niso-core (DOM, style, layout, render, events, input, loading, history; implements HostServices) │L1 web APIs niso-structured-clone niso-storage niso-indexeddb niso-webstorage niso-fetch niso-blob niso-websocket niso-messaging niso-wasm niso-crypto niso-performance niso-url niso-encoding niso-modules niso-animations niso-xpath niso-xml niso-canvas (never depend on niso-core) │L0 foundation niso-sched ──► niso-host ──► niso-basebuild time niso-idl (generators) niso-compose (build-script helper: metadata in, fragments out)bindings bindings/js (engine-neutral C and the glue core), niso-quickjs, niso-porffor, niso-napi (pinned), niso-jvmengine builtins niso-intl (ECMA-402 on ICU4X, for an engine with no Intl) (depend on no niso crate)niso-napi is a binding: cargo xtask layers pins it there. Its move to a composition comes with migration.md steps 19 and 20.
Dependency rules, checked by cargo xtask layers (a short script over cargo metadata), which tests/check.sh runs; the repository has no CI workflows yet, so that script is where it is enforced:
- L0 crates depend on no niso crate except lower L0 crates.
niso-basehas no niso dependency and no heavy third-party one. - L1 web API crates depend on L0 and on the specific L1 crates the table below lists, never on
niso-core, an L2 crate, a binding or a composition crate. Edges among L1 crates form a DAG, and each is declared in the dependent’sCONTRACT.mdunder “May assume from dependencies”. niso-coredepends on L0 and on no web API crate. It reaches optional behaviour only through hooks it defines (pre-layout, rendering steps, element interfaces, render contributors, resource transport), which optional crates register into.- L2 crates may depend on
niso-coreand on L1. - Only composition crates (L3) name several optional crates together, and they hold no hand-written logic beyond entry points.
- Bindings depend on crates’ public Rust APIs, which are their contracts (generated from their WebIDL, section 7.1), or on the C ABI generated from the same IDL, and never on a crate’s internals. Each language binds through the safest good interop it has (DECISIONS, Core): JS engines from Rust through their safe embedding crates (rquickjs for QuickJS, the v8 crate for V8), with a thin unsafe adapter only for an engine that exposes nothing but C (Porffor’s generated code); Node-API from Rust; JNI, PyO3 or UniFFI-style bindings for the others; C, Zig and Go through the C ABI. Whatever the language, every entry point keeps the same call discipline: the panic boundary, the borrow checks, the safe point and the turn bookkeeping (section 7.1). In the other direction, every interface a host provides (a driver, a hook, a platform service) is a Rust trait with a contract suite, and its C form is an adapter over the trait (section 7.5).
- Tests may use any crate’s
testingmodule as a dev-dependency; normal code may not. - Engine builtins (niso-intl) are part of the language, not the web platform: they depend on no niso crate, and only a binding, composition or program links them, through their C ABI and the engine-neutral host functions they ship.
cargo xtask layers checks these rules over one order, lowest first: base, host, sched, web API crates, the document, document extensions, the C API composition (niso-capi), engine builtins, bindings, product compositions, programs (hosts, examples, test runners). Each crate names its layer in [package.metadata.niso] layer, and the lint pins the layers of the crates this section names (niso-base, niso-host, niso-sched, niso-core, niso-capi, the bindings, the web API crates of 2.2), so a manifest cannot move one to escape a rule: a pinned crate’s edges are judged by its pinned layer, and one whose manifest names another layer also needs a crate: layer allowlist line, which excuses the manifest and nothing else. The rules have unit tests in xtask (cargo test -p xtask). A downward edge is allowed except those the rules forbid: the document to a web API crate (rule 3), a binding to a web API crate, the document or a document extension that has no CONTRACT.md yet (rule 6), since until a crate has a contract its public items are its internals, and an engine builtin to any niso crate (rule 8). A binding may depend on a crate that has a contract. An edge between web API crates is allowed (rule 2). Generators (build) may be build or dev dependencies of anything. Every other edge is an exception in xtask/layers-allowlist.txt with its reason and the step that removes it: today niso-core’s edges to the web API crates it still holds or re-exports, and the bindings’ edges to niso-core. The list shrinks, with one exception: a new web API crate whose trait the document still has to implement is labelled web-api and may arrive with an entry for the edge that needs, provided the entry names its reason and the step or condition that removes it. An entry goes as soon as its condition holds, and the list is empty after step 19. The first was niso-xpath, which read the document’s tree through a trait of its own; its entry went when niso-host began lending read-only traversal (Cx::tree, between steps 5 and 6).
2.2 The crates
Section titled “2.2 The crates”“Standalone use” says what another Rust project would use the crate for and the minimal host it needs. “From” is where its code is today.
| Crate | Owns | Depends on | From | Standalone use and minimal host |
|---|---|---|---|---|
| niso-base | Obj; ErrorCode, Error, Result (the full WebIDL DOMException table plus TypeError, RangeError, wasm’s three errors, Callback, InvalidHandle and Internal, so no crate ever adds a code); InterfaceInfo, InterfaceId; C value types (NisoStr, NisoBytes, Callback, Held) |
nothing | lib.rs:74-131, ffi.rs | Shared vocabulary. No host. |
| niso-host | Cx, HostServices, ExtKey<T>, Objects and PlatformObject, EventInit, Dispatched, Waker, Source, SourceInfo, TaskSource, hook traits, Registrar, capi::call for C shims, the read-only Tree a host lends (Cx::tree: kinds, names, structure, attributes, data, order, node documents, isConnected), idl/platform.webidl (the shared dictionaries and callback types; planned with 6c-gen, not in niso-host yet), testing::FakeHost and its FakeTree |
niso-base | lib.rs:284-312, modules.rs:43-79, events.rs:52-73 | The contract an embedding implements to host niso’s web APIs. A Deno- or Servo-style runtime implements HostServices. |
| niso-sched | Scheduler: run, needs_frame, next_timer, timers, animation frame bookkeeping, nesting levels, checkpoint bracketing, the real-time wait; SimpleHost (scheduler, objects and a closure event sink: a complete minimal host) |
niso-host | event_loop.rs, lib.rs:1395-1449 | An HTML-shaped event loop for any program: tasks, fair sources, timers with HTML clamping, virtual time for tests. |
| niso-structured-clone | the format, the decoder, graph inspection (own_property, js_string, key injection), niso_structured_clone_*, the SC glue module |
niso-base | structured_clone.rs, capi:src/clone.rs, glue.js:239-880 | Read and write HTML structured serialization bytes from Rust, for example to inspect what a page stored. No host. |
| niso-storage | Driver, the SQLite, memory and C drivers, key encoding, storage configuration (directory, app origin) |
niso-base, niso-structured-clone | storage/*.rs | An IndexedDB-shaped ordered key-value store with transactions and three drivers. No host. |
| niso-indexeddb | factory, connections, transactions, requests, cursors, key paths, the database access task source, its IDL and glue | niso-host, niso-storage, niso-structured-clone | indexeddb/*, api/indexeddb.rs, glue.js:1186-1712 | IndexedDB semantics over any driver, for a runtime or a native app’s offline store. Host: objects, event sink, queue_task. |
| niso-webstorage | localStorage, sessionStorage, StorageEvent | niso-host, niso-storage | api/storage.rs, html.webidl:21-67 | Web Storage over any driver. Host: objects, event sink. |
| niso-fetch | the transport (net.rs: request threads, the embedder’s loader, in-flight counts), the Fetch API objects, the response task source, the ResourceTransport the core loads images, CSS and fonts through, niso_net_* |
niso-host, niso-blob | net.rs, capi lib.rs:351-395, glue.js:2196-2338 | HTTP with fetch semantics and abort. Host: waker, queue_task, AbortSignal through Cx. |
| niso-websocket | connections, connect threads, watchers, the WebSocket objects and events | niso-host | websocket.rs, api.rs:3705-3785, websockets.webidl | A WebSocket client with browser semantics on any event loop. Host: waker, objects, event sink. |
| niso-messaging | agents and inboxes, MessageChannel and MessagePort, BroadcastChannel, window.postMessage, dedicated workers (spawn, terminate, engine-napi’s worker host), the process-wide port and channel registries | niso-host, niso-structured-clone | messaging.rs, api/messaging.rs, worker.rs, glue.js:1713-1880 | Message passing between event loops on threads, with port transfer. Host: a scheduler per agent, a world factory for workers. |
| niso-wasm | the wasmi store, module and extern ids, the wasm task source, niso_wasm_*, the WebAssembly glue and its host functions |
niso-host | wasm.rs, capi:src/wasm.rs, glue.js:3232-4110, niso_js.c:563-760 | A WebAssembly JS-API-shaped runtime with object identity, for any engine. Host: queue_task, an interrupt flag. |
| niso-crypto | AWS-LC primitives (already a crate on plat-blob-abort-crypto), getRandomValues, randomUUID, SubtleCrypto jobs and their task source | niso-host | plat-blob’s niso-crypto and api/crypto.rs | WebCrypto semantics from Rust. Host: waker, queue_task. |
| niso-performance | Performance, marks, measures, the time origin | niso-host | api/timing.rs, performance.webidl | User Timing over any clock. Host: clock, objects. |
| niso-url | URL, URLSearchParams | niso-host | api/url.rs, api/url_search_params.rs, url.webidl | WHATWG URL objects with handle identity. Host: objects. |
| niso-encoding | TextEncoder, TextDecoder | niso-host | api/encoding.rs, encoding.webidl | Streaming decode with WHATWG labels. Host: objects. |
| niso-gamepad | the Gamepad API (navigator.getGamepads, gamepadconnected and gamepaddisconnected, the button and axis snapshot) and the feeder contract a platform input source implements; niso-gamepad-sdl becomes one feeder | niso-host | tv-input’s gamepad.rs, api/gamepads.rs, gamepad.webidl, capi tv.rs (the gamepad part) | Gamepad state with browser semantics for any program with a controller. Host: objects, event sink, a feeder. Focus and spatial navigation (tv-input’s focus.rs, spatnav.rs) are input and stay in the core. |
| niso-modules | native modules: value lists, ports, settle and emit, the module task source with its per-run budget | niso-host | modules.rs, capi lib.rs:586-690, glue.js:3002-3231 | A typed, thread-safe RPC channel between native code and a script engine. Host: waker, a binding hook. |
| niso-animations | the Web Animations timing model: Animation, KeyframeEffect, DocumentTimeline, play states, events, ready and finished; the StyleApplier trait the core implements; Element.animate bindings behind its document feature |
niso-host | animation.rs, api/animations.rs, web-animations.webidl, glue.js:2499-2869 | Animation timing (play, pause, reverse, finish, playbackRate, fill) for any renderer. Host: clock, event sink, optionally a StyleApplier. |
| niso-xpath | the XPath 1.0 parser and evaluator over niso-host’s Tree, with the MAX_DEPTH and MAX_NODES limits; XPathEvaluator, XPathExpression and XPathResult as platform objects |
niso-host | new with document.evaluate; the DOM interfaces from the core’s api/xpath.rs | XPath over any host’s documents. Host: a tree, Attr nodes, objects. |
| niso-xml | the XML 1.0 + Namespaces parser (internal-subset entities, with MAX_ENTITY_DEPTH and Expat’s amplification limit, counting the nodes entities build; the HTML entities for XHTML doctypes; Expat-style errors) over its Builder trait, and DOM Parsing’s XML serialization over its Tree trait; its CONTRACT.md |
markup5ever (the HTML entity table) | new with DOMParser | XML parsing and serialization for any tree. No host. The core implements Builder and Tree for its DOM (api/dom_parsing.rs), an allowlisted edge (niso-core -> niso-xml) until niso-host exposes DOM construction; the serializer could already move onto niso-host’s Tree, but parsing needs construction. The C API reaches it through niso_dom_implementation_parse_document and niso_node_xml_serialization ([NisoNative]); DOMParser and XMLSerializer are glue over those two, the optional dom-parsing glue section (glue-dom-parsing); responseXML calls parseDocument from the xhr section. |
| niso-core | the document: blitz wrapper, tree, handles and lifetime, events and dispatch, style, layout, CSSOM, render stream, input, controls, loading, history, custom elements, mutation observers, AbortController and AbortSignal (DOM, and addEventListener’s signal option uses them; open question 11), CSS animations through stylo, and animation_style as the StyleApplier; implements HostServices, including the abort access crates use |
niso-host, niso-sched | everything else | Not standalone: it is the document. |
| niso-media (L2) | HTMLMediaElement state and events, the bridge to the host’s player, the media session | niso-core, niso-host | media.rs, api.rs:2962-3097, html.webidl:361-384, glue.js:2339-2466 | The media session alone is standalone; the element needs the document. |
| niso-canvas | Canvas 2D without a document: the context’s state, paths, paint, compositing, shadows, filters and text, rasterized on the CPU by vello_cpu; the per-document byte Budget; origin-clean bits on canvases, patterns and bitmaps; Path2D, CanvasGradient, CanvasPattern, ImageBitmap, OffscreenCanvas and its context as platform objects, which the core registers through Dom::install |
niso-host | new | Draw 2D into a pixmap from Rust with no host; the objects need objects from the host. |
| the canvas element (L2) | HTMLCanvasElement and CanvasRenderingContext2D: bitmaps keyed by node, sized by the width and height attributes, shown through the render stream with damage; drawImage’s sources (img, canvas, ImageBitmap, OffscreenCanvas) and their origin-clean bits, from the image loader’s Dom::cors_same_origin |
niso-core, niso-canvas | api/canvas.rs, canvas.rs, canvas.webidl | In niso-core behind its canvas feature (default on; without it canvas members throw NotSupportedError) until step 18 moves it into a crate of its own. Needs the element’s size attributes and the render stream. |
| niso-surfaces (L2) | templates for widgets, Live Activities, notifications | niso-core | template.rs, render_surfaces, render_template |
Needs layout output. |
| niso-intl (engine builtin) | ECMA-402 for engines without Intl: js/intl.js (option processing, locale negotiation, the Intl objects), spliced into the glue, over one native call (niso_intl_call) into ICU4X with curated locale data compiled in and more loadable at run time |
nothing in niso; ICU4X | new | Intl for any embedding of QuickJS or Porffor. No host: one C function in, strings out. |
| niso-idl | the WebIDL model and generators, now per fragment | nothing | crates/niso-idl | Build time. |
| niso-compose | build-script helper: the section manifest, and the composed header and glue from section files read by path | nothing (std only) | new | Build time. |
What is deliberately not split: style, layout, render, events and input (the scoping note), and Blob plus Fetch’s body and stream machinery, which plat-blob-abort-crypto is still shaping (open question 4).
2.3 What we take from Deno and Servo
Section titled “2.3 What we take from Deno and Servo”- Deno’s
ext/*(deno_fetch, deno_websocket, deno_webstorage, deno_crypto): each API is a crate with its ops, its JS and its state inOpState, a type map, and the embedder supplies policy as traits (FetchPermissions). We take the type-map state (as indexedExtKeyslots, cheaper than aTypeIdhash), the per-crate JS (glue modules) and host-supplied traits (HostServices,ResourceTransport, the storageDriver). We do not take the hard dependency on the engine: everyext/*crate depends ondeno_coreand so on V8, which is why using deno_fetch alone pulls in the runtime (denoland/deno#27868). niso’s API crates depend on no engine; the engine sits behind its binding, which reaches the crates through their public APIs (rule 6). - Servo separates implementations from the types other components see (
netandnet_traits,storageandstorage_traits) and keepsscript, the DOM, as one crate, which matches the decision to keepniso-corewhole. We take the traits-crate idea once, at the foundation (niso-host), rather than once per API: a pair of crates per subsystem doubles the count with no consumer for half of them. Servo’s IPC boundaries we do not need; our cross-thread boundary is the structured-clone byte format.
3. The foundation contracts in code
Section titled “3. The foundation contracts in code”These are the exact shapes; names may change in review, the responsibilities should not.
3.1 niso-base
Section titled “3.1 niso-base”/// A handle as the C API sees it. Top bit set: a platform object in the host's object/// table. Clear: a node, whose meaning only the document host knows. Generational:/// a stale handle is an error, never another object (DECISIONS, Core).#[repr(transparent)]#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]pub struct Obj(pub u64);
pub struct InterfaceInfo { pub name: &'static str, /// FNV-1a 32 of the name. Composition fails the build on a collision. pub id: InterfaceId, /// The inherited interface, by name; resolved into a dense table when a world is composed. pub parent: Option<&'static str>, pub exposed: Exposure, // Window, Worker or both, from [Exposed]}
/// HTML's DOMException names, WebIDL's simple exceptions, WebAssembly's three errors and/// niso's own (InvalidHandle, Internal, Callback). Closed: a crate never adds a code.#[repr(i32)]pub enum ErrorCode { /* today's values from lib.rs:82-119, plus every remaining WebIDL name (OperationError, ...) */ }
/// A function a binding or native caller hands over: function pointer, userdata, destructor.#[repr(C)]pub struct Callback { pub func: Option<unsafe extern "C" fn(*mut c_void, u64) -> i32>, pub userdata: *mut c_void, pub drop: Option<unsafe extern "C" fn(*mut c_void)> }3.2 niso-host: what an API crate sees of its host
Section titled “3.2 niso-host: what an API crate sees of its host”/// The host, as API crates reach it. A shared reference: everything behind it is in a/// RefCell, a Cell or atomics, so script may reenter through the C API while a crate's/// function is on the stack. The one rule (today followed by hand around `*mut Dom`) becomes/// checked: no borrow of a slot, an object or an event payload is held across a call that/// can run script (`fire`, a task). Breaking it panics with the borrowed thing's name/// instead of aliasing.#[derive(Clone, Copy)]pub struct Cx<'a> { host: &'a dyn HostServices }
/// What a host implements. Each host keeps its own handle table (the document's is its/// `ObjectData` slab, with crates' objects in `ObjectData::Ext`; FakeHost has a generational/// table of its own), so the trait lends objects through a callback rather than exposing a/// table. Crates call `Cx`, which adds the borrow checks.pub trait HostServices { fn state(&self) -> &HostState; // slots, deferred frees, the borrow record, hooks fn now(&self) -> f64; // ms on the run-loop clock (virtual in tests) fn waker(&self) -> Arc<Waker>; // one per host fn new_object(&self, data: Box<dyn PlatformObject>) -> Obj; fn with_object(&self, h: Obj, f: &mut dyn FnMut(&mut dyn PlatformObject)) -> Result<()>; fn retain(&self, h: Obj) -> Result<()>; fn release(&self, h: Obj) -> Result<()>; // at none: out of the table, to HostState::defer_free fn queue_task(&self, source: TaskSource, task: Task); // HTML "queue a task": one FIFO fn set_timer(&self, ms: f64, task: Task) -> TimerId; // crate timers: own ids, no nesting clamp fn clear_timer(&self, id: TimerId); fn create_event(&self, init: EventInit) -> Result<Obj>; /// Dispatches. May run script. For a node target the host computes the path; for an /// object target the caller passes its parents (IndexedDB: request, transaction, database), /// with a capture phase on them as for nodes. fn dispatch(&self, target: Obj, event: Obj, parents: &[Obj], trusted: bool) -> Result<Dispatched>; fn with_event_payload(&self, event: Obj, f: &mut dyn FnMut(Option<&dyn Any>)) -> Result<()>; /// Lends the host's trees, read-only, for the closure; NotSupported without a document. fn with_tree(&self, f: &mut dyn FnMut(&dyn Tree)) -> Result<()>; fn attribute_node(&self, element: Obj, index: usize) -> Result<Obj>; // made once, then the same // agent() and origin() arrive with the first crate that needs them.}
pub struct Dispatched { pub canceled: bool, pub listener_threw: bool }
pub struct EventInit { pub interface: &'static InterfaceInfo, // Event, CloseEvent, MessageEvent, ... pub ty: Cow<'static, str>, pub bubbles: bool, pub cancelable: bool, /// The interface's own members (CloseEvent's code, MessageEvent's data). Read back by /// the owning crate's IDL getters with `cx.event_payload::<CloseData>(ev)`. pub payload: Option<Box<dyn Any>>,}
/// Typed extension state. One static per crate; the index is assigned when the first world/// installs it and is the same in every world of the process (worlds that do not install/// it see None there).pub struct ExtKey<T: 'static> { index: AtomicU32, name: &'static str, _t: PhantomData<fn() -> T> }
impl<'a> Cx<'a> { pub fn with<T, R>(self, key: &'static ExtKey<T>, f: impl FnOnce(&mut T) -> R) -> R; // panics if not installed pub fn try_with<T, R>(self, key: &'static ExtKey<T>, f: impl FnOnce(&mut T) -> R) -> Option<R>; pub fn new_object<D: PlatformObject>(self, data: D) -> Obj; pub fn object<D: PlatformObject, R>(self, h: Obj, f: impl FnOnce(&mut D) -> R) -> Result<R>; // TypeError if not a D pub fn retain(self, h: Obj) -> Result<()>; pub fn release(self, h: Obj) -> Result<()>; pub fn queue_task(self, source: TaskSource, f: impl FnOnce(Cx<'_>) -> Result<()> + 'static); pub fn set_timer(self, ms: f64, f: impl FnOnce(Cx<'_>) -> Result<()> + 'static) -> TimerId; pub fn clear_timer(self, id: TimerId); pub fn fire(self, target: Obj, init: EventInit, parents: &[Obj]) -> Result<Dispatched>; // create + dispatch + release pub fn event_payload<P: 'static, R>(self, event: Obj, f: impl FnOnce(&P) -> R) -> Result<R>; pub fn tree<R>(self, f: impl FnOnce(&dyn Tree) -> R) -> Result<R>; // only reads inside f pub fn attribute_node(self, element: Obj, index: usize) -> Result<Obj>; pub fn waker(self) -> Arc<Waker>; pub fn now(self) -> f64;}
pub trait PlatformObject: Any { fn interface(&self) -> &'static InterfaceInfo; /// Handles this object keeps alive. They are released after `freed`, and the same list /// is what a binding traces to collect listener cycles (docs/listener-cycles.md). fn held(&self, _visit: &mut dyn FnMut(Obj)) {} /// The last handle went. Runs at the next safe point with nothing borrowed (end of the /// C call or task), may not run script, and a panic in it is reported, not propagated. fn freed(self: Box<Self>, _h: Obj, _cx: Cx<'_>) {} /// Heap bytes beyond the object itself, for memory reports. fn heap_size(&self) -> usize { 0 }}
/// Wakes the agent's loop from any thread: unparks a blocked `run`, else calls the host's/// wake hook. Call it after the delivery is visible to `Source::ready` (release ordering),/// so a wake never arrives before the work it announces. Shared as `Arc<Waker>`.pub struct Waker { /* the hook and its userdata, behind a lock */ }Tree is DOM’s read-only view of the host’s trees, for crates that walk the document without depending on it (XPath first; a serializer or an accessibility export could follow): node checks a handle once, then kind, local_name, namespace, prefix, parent, first_child, last_child, next_sibling, previous_sibling, attribute_count, attribute and attribute_by_name (borrowed names and values), owner_element, data, append_text, compare (document order), node_document, is_html_document, is_connected and version. The borrow rule is checked once per lending rather than per read: nothing that can change the host runs while the tree is lent.
Crates’ objects are reference counted only: the document’s collect has no arm for them, since nothing but retain and release (a binding’s wrapper, another object’s held) keeps one alive, and the last release hands it to the safe point. niso-host’s CONTRACT.md is the full statement of these rules (where panics are caught, timers, event phases, when hooks run on each host).
Registrar is what a crate’s register function receives when a world is composed:
pub fn register(r: &mut Registrar) { r.interfaces(&[&WEB_SOCKET, &CLOSE_EVENT]); r.ext(&SOCKETS, Sockets::default); // cheap: no threads, no I/O at install r.source(&NETWORK_SOURCE); // step 4 r.capi_symbols(bindings::LINK); // step 6; see 6.3: one reference keeps the shims linked}The host installs nothing a register function asks for until it has accepted every interface the crate registered, so a crate whose interfaces the build lacks leaves no slot or hook behind.
Besides sources, a crate may register into a small fixed set of typed hook lists. Each is a trait object list kept in a deterministic order:
| Hook | Called | Replaces |
|---|---|---|
RenderingStep { step: Step, pending, run } |
in update the rendering, ordered by Step (HTML’s step list as niso implements it: Scroll, MediaEvents, MediaQueries, ImageLoads, LoadEvent, Animations, AnimationFrames, ResizeObservers) |
the hard-coded list in lib.rs:1209-1298 |
PreLayout { before_style } |
at the top of flush_layout |
apply_animated_styles (lib.rs:1117) |
CallBracket { enter, pending, exit } |
around every C API call | ffi.rs:194-235 (element queue, moves, scripts, mutation microtask, animation promises) |
CheckpointEnd |
after each microtask checkpoint | idb_cleanup (event_loop.rs:219) |
NodesDropped(&[NodeId]) |
when collect drops nodes |
lib.rs:1057-1079 |
ElementInterface(local_name, &InterfaceInfo) |
when the document makes an element | html_interface arms for canvas, audio, video (lib.rs:199-277) |
RenderContributor |
during render_frame for elements it claims |
media and canvas in render.frame (lib.rs:1305-1309) |
WorkerSeed |
when an agent spawns a worker world | worker.rs:157-178 hard-coding storage and loader setup |
MemoryReport |
on niso_dom_memory_report |
new |
The step list, TaskSource and the hook kinds are specification lists, not subsystem lists: a new crate adds itself to them by registering, never by editing them. Adding a new hook kind is a contract change to niso-host.
4. The scheduler: how a subsystem plugs in without editing it
Section titled “4. The scheduler: how a subsystem plugs in without editing it”4.1 The trait
Section titled “4.1 The trait”/// A producer of tasks the run loop pulls from: one HTML task source, or several that/// share one queue. Registered once per world as `&'static dyn Source`; its state lives in/// the crate's extension slot, so the trait object itself is a zero-sized static.pub trait Source: Sync + 'static { fn info(&self) -> &'static SourceInfo;
/// True iff `run` would run a task now. O(1): atomics or a slot borrow, no lock that a /// producer thread holds while working, no allocation, never runs script. fn ready(&self, cx: Cx<'_>) -> bool;
/// Runs one task, followed by nothing: the scheduler calls the task hook and collects. /// Returns whether it ran one; false only if `ready` was false. Must not hold a borrow of /// its slot across a callback into script. fn run(&self, cx: Cx<'_>) -> Result<bool>;
/// The earliest time on `cx.now()` at which it becomes ready without a wake: a poll /// interval for a connection with no watcher (a TLS WebSocket today), a retry. fn deadline(&self, _cx: Cx<'_>) -> Option<f64> { None }
/// Whether the next display frame has work for it (an animation that runs). fn wants_frame(&self, _cx: Cx<'_>) -> bool { false }
/// Whether another thread may still make it ready: an open connection, a request in /// flight, a running worker, a crypto job. While any source is live, `run` waits in real /// time instead of skipping a virtual clock ahead. fn live(&self, _cx: Cx<'_>) -> bool { false }
/// The start of each `run(until)`: a budgeted source snapshots what it will take this /// run (native modules take only what was queued when the run began, modules.rs:301-306). fn begin_run(&self, _cx: Cx<'_>) {}
/// Called once before the clock moves or the loop waits, if it is not ready but holds /// work over budget: take one more allotment (event_loop.rs:330-336). Returns whether that /// made it ready. fn replenish(&self, _cx: Cx<'_>) -> bool { false }}
pub struct SourceInfo { pub name: &'static str, // "websocket", "modules", "messages", "crypto"; also the trace label pub task_source: TaskSource, // HTML's: Networking, PostedMessage, DatabaseAccess, ..., or Embedder pub class: Class, // Turn (one task per turn) or Rendering}“Queue a task” stays push-based: cx.queue_task(source, f) appends a boxed closure to the scheduler’s single FIFO, which the scheduler runs as a batch (the tasks queued when the batch started, not those they queue), exactly as Dom::run_tasks does with Task today (lib.rs:1426-1449). DOM manipulation, history traversal, IndexedDB’s database access tasks, wasm’s async settlements and binding callbacks keep their current relative order because they keep sharing one queue. Pull-based Sources are for producers whose work arrives from other threads or must be polled (sockets, inboxes, module ports, crypto jobs, fetch responses), which are exactly the clauses of run_ready today.
4.2 How run orders work
Section titled “4.2 How run orders work”The scheduler owns the loop; its algorithm is today’s (event_loop.rs:273-387 as storage-fixes and sched-followups leave it) with every subsystem-specific clause replaced by a pass over the registered sources. Turn sources are ordered by their TaskSource rank (Networking, then Embedder, then PostedMessage, matching today’s “network task, then module task, then message” at event_loop.rs:285-289) and then by name, so the order is deterministic and independent of link order.
run(until): collect; for s in sources: s.begin_run() loop: if agent closing: return (worker.rs:416) busy = run_batch() (the FIFO, as run_tasks) if busy: for s in turn_sources: if s.ready(): s.run(); task_end (one each, storage-fixes 254-265) rendering steps if pending and FRAME_MS since the last (storage-fixes 266-271) if now >= until and FRAME_MS spent and needs_frame: return else if some turn source ready: run the first; task_end; continue else if a rendering step is pending: run steps; continue pick the earliest of: due timer (time, seq), frame (requested or animating + 16 ms) if due > now: if busy: continue if any source over budget and replenish() made it ready: continue (unless due > until: return) if any source live: park on the waker until min(due, until) (wake: continue) else if due > until or due is infinite: return else advance the virtual clock to due if due > until: return run the timer (with its nesting level) or update the rendering; task_end; collect“Waits in real time” becomes one primitive: the agent’s Waker doubles as a parker. Every producer thread already calls the waker (net.rs:119 and 185, messaging.rs:119, modules.rs:65-78); a blocked run parks on it with the timeout instead of wait_network’s libc::poll loop over sockets plus condvar waits plus 2-10 ms re-checks (websocket.rs:446-513). A connection that needs polling (TLS, no watcher) reports a deadline instead.
4.3 Why the niso_runtime.h contract holds by construction
Section titled “4.3 Why the niso_runtime.h contract holds by construction”The contract (rt.h:357-370, event_loop.rs:153-199): needs_frame holds exactly when a run or a frame has work; next_timer is now exactly when a run can make progress, and is never before now.
ready(cx) = fifo_nonempty || any(s.ready() for s in sources) || any(step.pending() for step in rendering_steps)needs_frame(cx) = frame_requested || any(s.wants_frame()) || ready(cx)next_timer(cx) = if ready(cx) { now } else { max(now, min(timers ∪ {s.deadline()} ∪ {frame due if requested})) } // ∞ if emptyBoth functions are written once, in niso-sched, over the same ready. A run makes progress iff ready(cx) or a timer or frame is due, so the iff in the contract reduces to one obligation per source, which its contract tests check: s.ready() is true iff s.run() would return Ok(true), and a source that is not ready but will be at a known time reports it as deadline. Today the same property depends on run_ready, next_timer, wait_network and run agreeing by hand across four files, which is what commits 20b361f (“needs_frame and next_timer agree on every kind of work a run has”), 6dac0c2 and 1897f3c had to repair.
The wake rule closes the other half: a source that becomes ready from another thread calls Waker::wake after ready() is observably true, so a host that saw needs_frame() == false and next_timer() == ∞ is woken whenever that stops being true.
4.4 Cost on the hot path, and static versus dynamic dispatch
Section titled “4.4 Cost on the hot path, and static versus dynamic dispatch”Dynamic dispatch through &'static dyn Source in a Vec the scheduler owns. A full build registers about ten sources (websocket, fetch, modules, messages, crypto, wasm, idb is FIFO-only, media session, plus the core’s own). needs_frame is then about ten indirect calls each doing one atomic load or one RefCell flag check, on the order of tens of nanoseconds once per display frame; today the same call takes the net done and loads mutexes, the messaging inbox mutex and one mutex per native module (inventory §8.3) and may scan the whole tree. Static dispatch would need the scheduler and the core to be generic over a generated tuple of sources, would make each worker world’s subset a different type, and would complicate the fake sources the scheduler’s own tests use, for a saving we could not measure next to a single JS callback. The perf harness reports scheduler overhead per frame (section 10) so the choice is revisited only with numbers.
ready() must be cheap enough that producers make it so: a cross-thread queue pairs its Mutex<VecDeque> with an AtomicUsize length the consumer reads, which niso-host provides as Inbox<T>.
5. How a subsystem reaches the DOM and the other subsystems
Section titled “5. How a subsystem reaches the DOM and the other subsystems”Three mechanisms, and nothing else:
- Explicit Cargo dependencies on public Rust APIs for subsystem-to-subsystem calls (fetch reads a Blob’s bytes through niso-blob; indexeddb calls niso-storage’s
Driverand niso-structured-clone’s graph). The dependency is inCargo.toml, in the dependent’sCONTRACT.md, and checked by the layering lint, so “who may call whom” is reviewable in one place per crate. There is no service locator for crate APIs. Cxfor everything the host provides: time, the waker, the object table, events, the task queue, timers, AbortSignal, and the crate’s own extension slot. A crate never seesDom.- Hook lists (section 3.2) for the few places where the document must call optional code (layout, rendering steps, the render stream, element interfaces, C call brackets).
A crate never reads another crate’s slot: ExtKey statics are private unless the crate’s contract exports one, which is a deliberate API (niso-storage exports none; it exports a trait and functions).
5.1 fetch and AbortSignal
Section titled “5.1 fetch and AbortSignal”// niso-host: AbortSignal through Cx (open question 11). The document owns AbortController// and AbortSignal, since addEventListener's signal option uses them; FakeHost fakes them.impl Cx<'_> { pub fn is_aborted(self, signal: Obj) -> Result<bool>; pub fn add_abort_algorithm(self, signal: Obj, f: impl FnOnce(Cx<'_>, Obj /* reason */) + 'static) -> Result<AlgorithmId>; pub fn remove_abort_algorithm(self, signal: Obj, id: AlgorithmId);}
// niso-fetchpub fn fetch(cx: Cx, request: RequestInit, signal: Option<Obj>, done: Completion) -> Result<FetchId> { if let Some(s) = signal && cx.is_aborted(s)? { return done.reject_abort(cx, s); // settles through the binding callback } let id = cx.with(&FETCHES, |f| f.start(request, cx.waker())); // transport thread; calls waker when done if let Some(s) = signal { let algo = cx.add_abort_algorithm(s, move |cx, reason| abort_fetch(cx, id, reason))?; cx.with(&FETCHES, |f| f.set_abort(id, s, algo)); // removed when the response is taken } Ok(id)}
// The response arrives on the transport thread: it pushes into an Inbox and wakes.// FETCH_SOURCE (TaskSource::Networking) is ready while the inbox is non-empty; its run// takes one response, removes the abort algorithm, and calls the request's Completion// (a niso_base::Callback the binding or native caller passed), outside any slot borrow.What changes against today: the abort edge is a typed call from fetch into the host’s abort access rather than a field on Dom (abort_algorithms: HashMap<u32, Obj> added to Dom by plat-blob-abort-crypto); the response is a task of a Rust source rather than of the glue’s tasks() hook (glue.js:2998), so the run loop’s special branch for fetch (event_loop.rs:299-308) goes away; and a native caller gets fetch with abort through the same C functions the JS glue uses.
5.2 IndexedDB, structured clone and workers
Section titled “5.2 IndexedDB, structured clone and workers”page: store.put(value) glue (niso-structured-clone's SC module) serializes value -> bytes C shim niso_idb_object_store_put(dom, store, bytes, key) generated from niso-indexeddb's IDL -> niso_host::capi::call(dom, |cx| niso_indexeddb::bindings::idb_object_store::put(cx, store, bytes, key)) niso_structured_clone::Graph::decode(bytes) key path evaluation, generated key injection request = cx.new_object(Request { .. }) transaction queues the operation; when it runs: driver.put(txn, store, key, value) niso_storage::Driver, any driver cx.queue_task(TaskSource::DatabaseAccess, fire_success) same FIFO as today's Task::Idb fire_success: cx.fire(request, EventInit { interface: &EVENT, ty: "success", .. }, &[txn, db]) -> Dispatched { canceled, listener_threw } a throwing listener aborts the transaction CheckpointEnd hook: deactivate transactions today's idb_cleanup (event_loop.rs:219)Workers: niso-messaging spawns the worker’s thread and builds its world from the same composition with Agent::Worker. It knows nothing about IndexedDB. Before spawning, it asks every registered WorkerSeed hook to contribute to an opaque Seed (a type map of Arc<dyn Any + Send + Sync>); in the worker world each crate’s register reads what it put there. niso-indexeddb seeds an Arc<IdbShared>: the storage configuration (directory and app origin) and, if we want spec behaviour, a coordinator so a window and a worker connected to the same database see each other’s versionchange and blocked. Only bytes cross threads: values are structured-clone bytes, messages are Message { bytes, ports } (messaging.rs:32-35).
This replaces worker.rs:157-162, where the worker code reaches into storage to configure the worker’s driver. It also makes the open question explicit: storage-fixes makes worker IndexedDB memory-only (b834323) while engine-napi keeps a worker’s IndexedDB in the window’s directory (5ec3340). Under this design that is one decision inside niso-indexeddb’s seed, not a change to workers.
5.3 An event with a payload: WebSocket’s close
Section titled “5.3 An event with a payload: WebSocket’s close”// niso-websocketpub struct CloseData { pub was_clean: bool, pub code: u16, pub reason: String }cx.fire(ws, EventInit { interface: &CLOSE_EVENT, ty: "close".into(), bubbles: false, cancelable: false, payload: Some(Box::new(CloseData { was_clean, code, reason })) }, &[])?;// bindings::close_event::get_code(cx, ev) = cx.event_payload::<CloseData, _>(ev, |d| d.code)EventExtra (events.rs:52-73) keeps the core’s own payloads (pointer, wheel, key, popstate, hashchange, media query) and loses Message, Close, Storage, VersionChange and AnimationPlayback to their crates.
6. Composition at link time
Section titled “6. Composition at link time”This section is the original design. As built (decision 5), niso-compose reads each section from its owner’s tree by path, guided by sections.txt; crates publish no links metadata for fragments, and the DEP_NISO_* variables below are not used for them.
6.1 What each crate publishes
Section titled “6.1 What each crate publishes”Every crate that contributes to a build has links = "niso-<name>" in its manifest and a build script:
fn main() { niso_compose::Fragment::new("websocket") .idl("idl/websockets.webidl") // generates OUT_DIR/bindings.rs, niso_websocket.h, js hosts, glue, JVM classes .glue("glue/websocket.js") .emit();}emit runs niso-idl on the crate’s own IDL, writes the generated files into OUT_DIR, and prints cargo::metadata= lines: crate=niso_websocket, register=niso_websocket::register, idl=<path>, header=<path>, glue=<path>, js_hosts=<path>, jvm=<dir>, deps=niso-host, features=<enabled cargo features>. Cargo passes these to every direct dependent’s build script as DEP_NISO_WEBSOCKET_* (the mechanism niso-capi already uses for its include directory, capi:build.rs:16).
6.2 What a composition crate does
Section titled “6.2 What a composition crate does”# crates/niso-capi/Cargo.toml: the full build. Its dependency list is the build.[lib]crate-type = ["rlib", "staticlib", "cdylib"]
[features]default = ["web"]web = ["fetch", "websocket", "indexeddb", "webstorage", "messaging", "wasm", "crypto", "animations", "media", "canvas", "surfaces", "modules", "performance", "url", "encoding"]fetch = ["dep:niso-fetch", "dep:niso-blob"]websocket = ["dep:niso-websocket"]# ...
[dependencies]niso-core = { path = "../niso-core" }niso-websocket = { path = "../niso-websocket", optional = true }# ...fn main() { niso_compose::compose().emit(); }compose reads every DEP_*_CRATE variable, so it sees exactly the crates that are enabled, and writes:
compose.rs:extern cratefor each (an unreferenced dependency is not linked),pub fn register_all(r: &mut Registrar)calling eachregisterin dependency order then name order, and the feature list;include/niso.h: every header fragment in the same order, each preceded by#define NISO_HAS_<NAME> 1, so C, Swift, Zig and Go callers can test for a crate at compile time;glue.js: the glue core followed by each crate’s glue module (section 7.3);niso_js_hosts.incand the JVM sources, concatenated the same way;- a validation pass over all IDL fragments together: every referenced name defined once, partial targets exist, interface ids do not collide, no core IDL names an optional crate’s type.
The crate’s lib.rs is include!(concat!(env!("OUT_DIR"), "/compose.rs")); plus the entry point niso_dom_new, which builds a world with register_all. Other compositions are the same two files with a different dependency list: niso-app (QuickJS plus the full set, for the iOS and Android hosts), niso-napi (no niso-wasm, since under Node the engine’s own WebAssembly is used, DECISIONS “Web platform APIs”), and per-app ones for native apps (the timer: core and performance; ihatemyphone: core, modules, surfaces, webstorage).
6.3 Why generated registration rather than linkme or inventory
Section titled “6.3 Why generated registration rather than linkme or inventory”generated register_all |
linkme distributed slices |
inventory |
|
|---|---|---|---|
| iOS static library into swiftc/Xcode | register_all is called from niso_dom_new, so every registered crate’s object file is referenced and loaded |
the slice’s section is no_dead_strip (linkme’s Mach-O section spec is __DATA,__linkme<hash>,regular,no_dead_strip), but ld64 loads an archive member only when it resolves an undefined symbol; a member holding only a registration is never loaded unless the app links with -force_load |
same archive-member problem; plus static constructors run before main |
Android cdylib |
works | works when rustc drives the link (its synthetic object keeps #[used] statics, rust-lang/rust#95604) |
works |
| wasm32 | works | not supported (platform table lists Linux, macOS, Windows, BSDs, illumos; dtolnay/linkme#109 shows distributed_slice is not implemented for this platform as of 2025-11) |
supported, but a reactor module must call __wasm_call_ctors itself |
| order | dependency order, then name: deterministic, the same on every target | link order: unspecified | unspecified |
| dead code | a disabled crate is not a dependency, so nothing of it is compiled | needs the crate excluded anyway | same |
Order matters here: source order, rendering steps and hook order must not depend on the linker. And the hosts link today exactly the way that loses registrations: hosts/apple/build.sh:29 builds niso-app with --release (no LTO, many codegen units) and links it with -lniso_app (build.sh:62), without -force_load. The generated call also gives the dead-strip behaviour we want: each crate’s C shims are kept only when the host or the composed JS host table references them.
Per target:
- iOS, iPadOS, watchOS, tvOS, widgets (staticlib into swiftc or Xcode): one composition crate per product (
niso-apptoday; a smaller one for the widget extension if size matters). Nothing special at link time;-dead_stripis safe. - Android (
niso-androidcdylib via rustc and the NDK clang, hosts/android/native.sh): the composition is a dependency ofniso-android; rustc exports every#[no_mangle]shim of the linked crates. - Desktop iced, examples/lang/rust, tests (Rust binaries): depend on a composition crate as an rlib.
- JVM (
niso-jvmcdylib): as Android; its generated classes come from the composed JVM fragments. - Node-API (
niso-napicdylib from engine-napi): its own composition without niso-wasm; the runtime owns threads, so niso-messaging’s worker host hook (engine-napi’sniso_dom_set_worker_host) is how workers start. - wasm32, later: generated registration needs no linker support. The crates that use threads or sockets (fetch’s transport, websocket, sqlite, workers) need drivers there, which the composition chooses by feature; open question 6.
7. The C API, the IDL and the glue
Section titled “7. The C API, the IDL and the glue”7.1 Generated C API per crate
Section titled “7.1 Generated C API per crate”The generator change is small and local: today every shim calls niso_core::api::<snake iface>::<member> (crates/niso-idl/src/emit.rs:504-593). It becomes <owner crate>::bindings::<snake iface>::<member>, where the owner is the crate whose fragment declares the member (a partial interface or mixin included), and the shim body goes through niso_host::capi::call(dom, |cx| ...) instead of ffi::call. niso_dom* is opaque to C; to Rust it points at a header the host fills (a &dyn HostServices and the call brackets), which is how an API crate’s shim gets a Cx without depending on niso-core. Function names, argument types and status codes do not change, so existing hosts and examples/lang/* compile against the composed niso.h unchanged. The hand-written parts of rt.h move with their code (inventory §5.2): the storage driver vtable to niso-storage, niso_net_* to niso-fetch, niso_wasm_* to niso-wasm, niso_structured_clone_* to niso-structured-clone, niso_module_* to niso-modules, the media functions to niso-media, the event loop section to niso-sched.
One call discipline for every entry point (rule 6). The C ABI is one target of the IDL among several, so the discipline a call needs cannot live in the C shims. It lives in niso-host as one entry bracket, niso_host::capi::call generalised: the panic boundary (a panic becomes an Internal error with its message), the borrow checks (the document is borrowed only between callbacks, as ffi::call_reentrant does today), the safe point (objects freed during the call get their freed), and the turn bookkeeping. The bookkeeping is the core’s call epilogue today (ffi.rs: the element queue, then delivering tree moves, connected scripts, custom element reactions, the microtask signal and animation promises); it becomes CallBracket hooks the core registers, so the bracket itself knows no document. Each crate’s generated Rust facade (its public API, <crate>::bindings) enters through the bracket, and the generated C shim is a thin wrapper over the facade that converts arguments, calls it and turns its error into a status and niso_dom_last_error. A binding in any language therefore goes through the same bracket exactly once per call, whether it calls the Rust facade (rquickjs, Node-API, JNI, PyO3) or the C ABI (C, Zig, Go, Porffor’s adapter).
7.2 IDL per crate, and what [NisoFeature] is for
Section titled “7.2 IDL per crate, and what [NisoFeature] is for”Each crate keeps its IDL in crates/<crate>/idl/. Most of today’s files already map one-to-one (indexeddb, websockets, messaging and workers, webcrypto, web-animations, canvas, performance, url, encoding); Storage, StorageEvent and the localStorage partial move out of html.webidl into niso-webstorage, and HTMLMediaElement into niso-media. A crate adds itself to the global the way three already do (indexeddb.webidl:75, webcrypto.webidl:6, performance.webidl:9): partial interface mixin WindowOrWorkerGlobalScope.
Resolution rules, enforced by niso-idl:
- A fragment may define interfaces, dictionaries, enums and callbacks, and may extend (partial, mixin
includes) interfaces of crates it may depend on, or the core’s global and node interfaces. - It may reference interfaces by name from anywhere; the generator treats an unknown name as an interface handle, and the composition step checks it exists. Dictionaries, enums and callbacks it uses must come from its own fragment, a Cargo dependency’s fragment (
DEP_*_IDL), orniso-host/idl/platform.webidl(EventInit, EventHandler, VoidFunction and the other shared types), because their layout is needed to generate the shim. - The core’s IDL never names an optional crate’s interface. Element interfaces that optional crates own (HTMLCanvasElement, HTMLMediaElement) reach
createElementthrough theElementInterfacehook; without the crate,<canvas>is anHTMLElement.
With IDL owned per crate, dropping a crate drops its IDL with it, so [NisoFeature=x] is needed only inside a crate, for members that depend on one of the crate’s own optional Cargo features (niso-storage’s sqlite, niso-fetch’s http transport). The generator wraps those shims in #[cfg(feature = "x")] and leaves them out of the header and glue fragment when the feature is off; the build script knows from CARGO_FEATURE_*.
Interface ids stop being a global enum (core build.rs:2-6, lib.rs:69-72): each InterfaceInfo carries the FNV-1a hash of its name, the composition checks for collisions and builds the dense parent table that is_a walks, and niso_object_interface keeps returning a u32.
7.3 Glue modules
Section titled “7.3 Glue modules”glue.js becomes the glue core (wrappers and identity, WebIDL conversions, the callback table, DOMException, console, custom elements, scripts: about 1,900 of today’s 4,132 lines) plus one module per crate, cut along the sections in inventory §5.3. A module has one shape:
function install(ctx) { const { G, host, W, CB, defineMembers, GLUE, onHook } = ctx; // the glue core's documented API // [NisoGlue] members of WebSocket, if any; the generated classes are already defined}The composed glue.js is the glue core, the generated classes for every enabled fragment, then each module’s install(ctx) in dependency order, and returns the hooks object. Hooks become a name-to-function table that modules add to (onHook('moduleTask', f)); the engine looks them up by name (niso_js.c:43 already has qe_hook_names), so enum qe_hook (bindings/js/niso_engine.h:21-33) stops being one more list every subsystem edits. Engine-side host functions move with their crate the same way: the wasm host functions in niso_js.c:563-760 become niso-wasm’s js/hosts.c, compiled by the engine crate from the composed list. The glue core’s ctx is itself a contract (bindings/js/CONTRACT.md), since every module is written against it. Porffor compiles the composed glue ahead of time as it compiles glue.js today (crates/niso-porffor/build.rs:56-57).
7.4 Native callers are first-class
Section titled “7.4 Native callers are first-class”The Rust API of each crate is public and documented in its contract, and is what examples/lang/rust and a native app’s own Rust code call. C, Swift, Kotlin, Zig and Go get the same crate’s composed header and generated client bindings (DECISIONS, “Native code uses the same API as pages”). Asynchronous results reach native callers through niso_base::Callback (function pointer, userdata, destructor) on the same task sources that settle JS promises, so a Swift fetch with an abort signal follows the same ordering as a page’s. Native-only surfaces (the storage driver vtable, native module ports, the media host bridge, worker hosts) live in the crate that owns them and are versioned by its contract like any other API.
7.5 Host drivers are Rust traits
Section titled “7.5 Host drivers are Rust traits”Every interface a host provides to niso (a driver, a hook, a platform service) is a Rust trait first, owned by the crate that calls it, and the C vtable or function pointer is an adapter over that trait, generated or thin. niso-storage’s Driver is the template: the trait is the contract, CDriver adapts a host’s niso_storage_driver vtable to it (catching panics in the visitor and never unwinding through C), and testing::vtable puts any Driver behind the vtable so the contract suite runs against the adapter too. Other languages implement the trait through their own interop: UniFFI-style callback interfaces for Swift and Kotlin, the JS binding in Rust for the engine-side hooks, JNI for Java. C, Zig, Go and Porffor’s adapter keep using the C typedefs, which become the adapter’s surface rather than the contract. Each trait has a contract suite (contract::<trait>_suite) that runs against every implementation: the crate’s reference or fake implementation, the C adapter over it, and each binding’s own implementation in that binding’s tests.
The hook signatures in niso_runtime.h today and the traits they become:
| Today (niso_runtime.h) | Trait | Owner | When |
|---|---|---|---|
niso_storage_driver, niso_dom_set_storage_driver |
Driver (CDriver adapts the vtable) |
niso-storage | done (step 5) |
niso_loader_fn, niso_dom_set_loader |
ResourceLoader |
niso-core, then niso-fetch’s ResourceTransport |
done (6d); joins the transport in step 11 |
niso_wake_fn, niso_dom_set_wake_hook |
Wake (Send + Sync), held by niso_host::Waker |
niso-host | done (6d) |
niso_tree_hook_fn, niso_task_hook_fn, niso_script_hook_fn, niso_microtask_hook_fn, niso_handler_hook_fn and their setters |
DocumentHooks, the engine side of the binding contract: one method per hook, each with a default that does nothing |
niso-core | done (6d) |
niso_message_hook_fn, niso_dom_set_message_hook |
a DocumentHooks method, then niso-messaging’s |
niso-core, then niso-messaging | done (6d); moves in step 13 |
niso_worker_runner_fn, niso_worker_start_fn, niso_worker_terminate_fn, niso_dom_set_worker_runner, niso_dom_set_worker_host |
WorkerHost (start, terminate; a runner is a WorkerHost that starts a thread) |
niso-core, then niso-messaging | done (6d); moves in step 13 |
niso_module_call_fn, niso_module_hook_fn, niso_dom_define_module, niso_dom_set_module_hook |
NativeModule (a module’s calls) and the module hook as a DocumentHooks-style observer |
niso-modules | step 15 |
niso_wasm_call_fn, niso_wasm_task_fn, niso_wasm_set_hooks |
WasmHost (call, task) |
niso-wasm | step 14 |
the media player bridge (MEDIA render ops and niso_dom_media_update) |
MediaPlayer |
niso-media | step 18 |
a future Shaper, and platform services (clipboard, haptics, accessibility queries) |
one trait each | the crate that calls it | with its first user |
niso_drop_fn stays a C detail of the adapters: a Rust implementation is dropped like any value. The C setters keep their names and signatures, so existing hosts and examples/lang/* compile unchanged; each now builds the adapter and installs it as a trait object.
7.6 UniFFI from the same IDL
Section titled “7.6 UniFFI from the same IDL”UniFFI’s UDL is a WebIDL dialect, and UniFFI also accepts proc-macros, so niso-idl generates the UniFFI layer from the same IDL as the C ABI and the JS glue: #[uniffi::export] on each crate’s generated Rust facade (section 7.1), plus #[uniffi::export(callback_interface)] or foreign-trait exports for the host traits of section 7.5. That gives Swift, Kotlin, Python and Ruby from UniFFI itself, and C#, Go, Dart and React Native from community bindgens, all entering through the same bracket. The facade stays the contract; the UniFFI layer is generated output, like the C shims. Open mapping questions, to settle on the first crate that ships it (open question 10):
- Inheritance. WebIDL interfaces form chains (
HTMLInputElement:HTMLElement:Element:Node:EventTarget); UniFFI objects have no inheritance. Either flatten each interface’s inherited members into its object, which multiplies the generated surface, or export one handle object per interface plus a trait per interface that the derived objects implement, with explicit conversions (as_element()), closer to the C API’s handles. - Callbacks. Event listeners and the host traits become UniFFI callback interfaces; each call crosses with a lowered argument buffer and a foreign-language dispatch, so they go through the bracket’s safe point like any reentrant call, and a listener’s identity for
removeEventListenerneeds a stable handle. - Cost per call. UniFFI lowers arguments into a buffer and lifts results on every call. That suits the API surface, but the hot paths stay on the direct C ABI: the render stream (
niso_render_frame,niso_render_surfaces), per-frame queries (niso_dom_needs_frame,niso_dom_next_timer, hit testing, text input geometry) and input delivery. A host may mix both: UniFFI for the API, C for the frame loop.
8. Contracts
Section titled “8. Contracts”A contract is the part of a crate another crate, a binding or a native app may rely on. It lives in crates/<crate>/CONTRACT.md, next to the code, and is enforced by three things: the public Rust API (to be checked by committed cargo public-api snapshots, planned in boundaries.md item 4; there is no CI yet), the generated C header fragment (checked the same way), and the crate’s contract test suite, which runs against the real implementation and against every replacement or fake the repo ships.
8.1 Template
Section titled “8.1 Template”# <crate> contract version: <n>, bumped on any breaking change
## PurposeOne paragraph: what it owns, what it deliberately does not.
## Public surface- Rust: the modules and items that are API (everything else is `pub(crate)` or `#[doc(hidden)]`).- C: the header fragment and its functions; status codes each may return.- IDL: the fragment's interfaces, and the partials or mixins it adds to others.- Glue: the module's `install(ctx)` and the hooks it registers.
## Host requirementsWhat it needs from `HostServices` (objects, events, queue_task, timers, waker, clock), and whichhooks it registers (sources, rendering steps, worker seeds ...), with the order it assumes.
## May assume from dependenciesEach Cargo dependency on another niso crate, and the items of it used. Anything not listed here is not used.
## GuaranteesNumbered invariants, each with the contract test that checks it.
## ThreadsWhich types are Send, which functions may be called from which thread, and what wakes the loop.
## Performance and memoryBudgets the crate keeps (no allocation in `ready`, bytes per object, size of the crate in niso-app)and the metric that reports each.
## FakesWhat `testing` provides, and which guarantees the fakes keep (every fake passes the contract suiteit fakes, or says which numbered guarantee it skips and why).
## Standalone useThe smallest host it runs on, and the example that shows it.8.2 niso-sched
Section titled “8.2 niso-sched”Purpose. The HTML event loop for one agent: a FIFO of queued tasks, pull sources, timers with HTML’s nesting and clamping, animation frame callbacks, the microtask checkpoint bracket, and a clock that is either real or virtual. It knows no subsystem by name. It does not own script, the document or rendering; it calls them through hooks.
Public surface.
Scheduler::new(clock: Clock),register_source(&'static dyn Source),queue_task(TaskSource, Task),set_timer(ms, Task, repeat) -> TimerIdandclear_timer,request_frameandcancel_frame,run(cx, until: f64) -> Result<()>,needs_frame(cx) -> bool,next_timer(cx) -> f64,now(),set_checkpoint(fn)(the binding’s microtask checkpoint),set_render(fn)(update the rendering),SimpleHost. C: the “Event loop” section of today’s rt.h (niso_dom_run,niso_dom_needs_frame,niso_dom_next_timer,niso_dom_set_waker,niso_dom_set_timer_hook, the task callback), with unchanged names, types and status codes.Host requirements. None beyond niso-host: the scheduler is the part of a host that
HostServices::queue_task,set_timer,nowandwakerforward to.Guarantees.
needs_frameis true exactly when arunor a frame has work;next_timerreturnsnowexactly whenruncan make progress, is never beforenow, and is∞when nothing is scheduled (rt.h:357-370). contract::agreement, run over random interleavings of sources, timers and frames.- Tasks queued with
queue_taskrun in FIFO order, one batch per turn; tasks queued by a task in the batch run in a later batch. contract::fifo.- Turn sources run in
TaskSourcerank then name order, at most one task each per turn, after the batch. contract::fairness.- Timers fire in (due time, sequence) order; nesting above 5 clamps to 4 ms; a cleared timer never fires, even when cleared by an earlier task in the same turn. contract::timers.
- After every task, the checkpoint hook runs once, then
CheckpointEndhooks, then collection. contract::checkpoint.- With a virtual clock and no live source,
run(until)advances time only to the next due item and never waits. With a live source it parks on the waker until a wake ormin(due, until). contract::virtual_time, contract::live_wait.ready,needs_frameandnext_timerallocate nothing and run no script. contract::no_alloc with a counting allocator.runis reentrancy-safe: called from inside a task it returnsInvalidStatewithout running anything.Threads. Not Send.
Wakeris Send + Sync and is the only thing other threads touch.Obligations on sources (checked by
niso_sched::contract::source_suite, which every crate that registers a source runs on its own):readytrue iffrunreturnsOk(true); a source woken from another thread isreadybefore the wake is delivered;deadlineis reported for every time-based readiness;liveis true while another thread may still make it ready, and becomes false within one turn after the last such thread is done.Fakes.
testing::ScriptedSource(becomes ready at given virtual times, counts runs),testing::ThreadSource(a real thread delivers after a delay),testing::Recorder(records the order of every task, source run, checkpoint and frame for assertions).Standalone use.
SimpleHost: a scheduler, an object table and a closure event sink, enough to run every L1 crate from a plain Rust program.
8.3 niso-storage
Section titled “8.3 niso-storage”Purpose. Ordered, transactional storage at the level of databases, object stores, indexes and records, with three drivers (SQLite, memory, C vtable) and IndexedDB’s key encoding. It does not know about requests, events, IndexedDB’s transaction lifetime or Web Storage’s quota and events: those are its dependents’.
Public surface. The
Drivertrait as it is today (storage/mod.rs:229-278:databases,open,close,delete,begin,commit,abort,set_version, store and index DDL,put,put_index,delete_range,clear,scan,count,generator,set_generator),Range,Entry,Mode,Durability,Endpoint,DriverError;MemoryDriver,SqliteDriver(behind thesqlitefeature, default on),FfiDriver;key::{encode, decode, compare};ConfigandStorage(directory, app origin, driver per endpoint,worker_config()). C: theniso_storage_drivervtable andniso_dom_set_storage_directoryandniso_dom_set_storage_driver, unchanged.Host requirements. None. It runs no tasks and fires no events.
May assume from dependencies. niso-structured-clone:
js_string(key encoding of strings), nothing else.Guarantees.
- Records in a store, and entries in an index, are visited in encoded key order, then primary key order;
reversevisits the exact reverse. contract::order.- Writes in a transaction are visible to that transaction at once and to others only after
commit;abortdiscards all of them, DDL and the key generator included. contract::atomicity.readonlytransactions may run concurrently; areadwriteorversionchangetransaction excludes every other one on its stores (the driver may serialize more, never less). contract::isolation.- Keys round-trip:
decode(encode(k)) == kandcompareon encodings matches IndexedDB’s key comparison, for every key type. contract::keys (property test).- After
commitreturns, a new driver on the same directory sees the data;Durability::Strictsurvives power loss as far as SQLite’sFULLdoes. contract::persistence (SQLite only).- Configuration is settable until the first
open, thenInvalidState(docs/storage.md, “Choosing where data goes”).- Errors are
DriverErrorwith a message; a driver never panics on bad input from its caller.Threads. A driver is
Sendand used by one agent at a time; a worker gets its own driver fromStorage::worker_config()(open question 2 decides which).Fakes.
MemoryDriveris the fake, and is exact: it passes guarantees 1-4, 6, 7.testing::FailingDriverwraps any driver and fails the n-th call, for dependents’ error-path tests.Standalone use. An ordered transactional key-value store with IndexedDB’s key semantics, from any Rust program. No host.
Today’s exercise test (storage/mod.rs:408) is already the start of this suite, run against both drivers; it becomes niso_storage::contract::suite(make_driver) and the C driver test in niso-capi-tests runs it through the vtable too.
8.4 How contracts stay kept
Section titled “8.4 How contracts stay kept”- Public API diffs (
cargo public-api diffper crate, the header fragment diffed as text) will fail the gate (planned) unlessCONTRACT.mdchanges in the same PR. The check is mechanical; the reviewer reads the contract diff first. - Contract suites are ordinary public functions in a
contractmodule behind acontract-testsfeature, so a replacement in another repo runsniso_storage::contract::suite(|| MyDriver::new())from its own tests. - Layering (
cargo xtask layers, section 2.1, run bytests/check.sh) fails on an undeclared niso dependency. - C ABI: function names and struct layouts are append-only within a contract version; the composed
niso.hcarriesNISO_<CRATE>_CONTRACTversion macros.
9. Testing each crate alone
Section titled “9. Testing each crate alone”9.1 Fakes that ship with each contract
Section titled “9.1 Fakes that ship with each contract”| Contract | Fake in its testing module |
Used by |
|---|---|---|
| niso-host | FakeHost: object table, virtual clock, a recording event sink (every fired event with target, type, payload and parents), a FIFO, a counting waker, signals a test aborts; FakeHost::fail_dispatch to make a listener throw |
every L1 crate |
| niso-sched | ScriptedSource, ThreadSource, Recorder; SimpleHost for integration |
crates with sources |
| niso-storage | MemoryDriver, FailingDriver |
indexeddb, webstorage |
| niso-structured-clone | none needed: it is pure; testing::values() yields a corpus of encoded graphs (cycles, Maps, typed arrays, transfer) |
storage, indexeddb, messaging |
| niso-fetch | testing::StubTransport: canned responses, delays on a virtual clock, abort observation |
the core’s resource loading tests, anything that fetches |
| niso-messaging | testing::InlineWorldFactory: spawns a worker world on a thread with only the crates the test names |
messaging’s own tests, indexeddb’s worker tests |
| niso-animations | testing::RecordingApplier (a StyleApplier that records computed values per target) |
animations |
| niso-core (as a host) | none: core is tested as today |
9.2 Contract suites
Section titled “9.2 Contract suites”Every crate that implements another’s trait runs that trait’s suite in its own tests: niso-sched’s source_suite in each crate with a source, niso-storage’s suite for the three drivers, niso-host’s host_suite (object identity, generations, release order, fire semantics, the no-borrow-across-script rule) against FakeHost, SimpleHost and niso-core’s Dom. The last one is what makes a fake trustworthy: FakeHost and the real document are held to the same suite, so a crate tested on FakeHost sees the same behaviour on the document.
The JS-level tests stay where they are (niso-quickjs/tests: storage, net, messaging, wasm, animations …) as end-to-end tests of the composed build, and keep the WPT runner. They become the second line, not the only one, for the 13 subsystems whose only tests today are JS tests (inventory §7).
9.3 Time, isolated against workspace
Section titled “9.3 Time, isolated against workspace”Measured today (logs in ~/.capy/work): cargo test --workspace after a change in niso-core rebuilds niso-core and everything above it, 45-56 s to “Finished” on this machine, then runs 48 test binaries in 5.3 s of test time (slowest binary 0.72 s). Of 217 #[test]s, 144 are in niso-quickjs, 68 in niso-core; storage, structured clone and key paths have 14 Rust unit tests between them.
Estimates for one crate, warm target/, after an edit inside that crate (not measured; they follow from crate size, since none of these crates depends on blitz, stylo, taffy or QuickJS):
| Crate | Size today | Incremental cargo test -p |
Run |
|---|---|---|---|
| niso-structured-clone | 804 lines | 2-4 s | < 0.1 s |
| niso-storage | ~1,830 lines + rusqlite (compiled once) | 3-6 s | < 0.5 s |
| niso-indexeddb | ~2,050 lines + api | 4-8 s | < 0.5 s |
| niso-websocket | 589 lines + api | 2-5 s | < 1 s (loopback sockets) |
| niso-messaging | ~1,280 lines + api | 3-6 s | < 1 s (threads) |
| niso-sched | ~500 lines | 2-4 s | < 0.2 s |
Cold, a single L1 crate builds in tens of seconds (rusqlite’s bundled SQLite and AWS-LC dominate where present) against several minutes for the workspace. The gain that matters is the edit loop: a change to IndexedDB no longer recompiles niso-core and the four binding crates before a test can run, so roughly 10x on the loop. A change to niso-host or niso-base still rebuilds everything, which is why those two crates must stay small and change rarely. The first migration step should record real numbers with cargo build --timings and replace this table.
10. Performance and memory
Section titled “10. Performance and memory”The handle table. One generational table in niso-host replaces the ObjectData enum (52 variants, lib.rs:135-196). Each entry is Box<dyn PlatformObject> plus a generation and a refcount; a lookup is index, generation compare, then downcast_mut (a TypeId compare), against today’s index plus a match arm. Creation costs one allocation per platform object. That matters only for objects created per frame: events. Event objects stay in the core’s own typed slab (the core is the host and owns create_event), with the payload box allocated only for events that carry one; pointer and wheel events keep EventExtra inline. Node handles are unchanged.
Listener cycles. PlatformObject::held is the edge list docs/listener-cycles.md asks for in its redesign step 2: the binding asks the table for an object’s held handles and mirrors them as references between wrappers. Today each of those edges would have to be written per type in the glue; under this design every crate’s objects declare them once, in Rust, next to the code that creates them, and host_suite checks that held covers every handle an object releases in freed.
No allocation in the run loop’s checks. Sources and hooks are &'static and registered once per world; the scheduler’s lists are sized at composition. ready, needs_frame and next_timer allocate nothing (niso-sched guarantee 7, checked with a counting allocator). Queuing a task boxes its closure once, as the Task enum’s Boxed payloads do today; if profiles show it, Task gets inline storage for three words.
Locks. Inbox<T> (a mutex for producers, an atomic length for ready) replaces lock-to-check in net, messaging and modules (inventory §8.3), so an idle needs_frame takes no lock at all.
Per-crate metrics, reported by a planned cargo xtask metrics (it does not exist yet, and the repository has no CI) to run on main and on every PR touching a crate:
- Size: each crate’s contribution to the stripped
niso-appstatic library for aarch64-apple-ios, fromcargo bloat --crates, with a budget per crate in its contract. Native-app compositions show the saving directly (the timer without wasmi, SQLite and AWS-LC). - Memory: the
MemoryReporthook: each crate reports its slot’s heap bytes and the sum ofheap_sizeover its live objects;niso_dom_memory_reportreturns the list. The perf harness records it after each scenario. - Loop cost: nanoseconds per
needs_frame,next_timerand emptyrunwith the full composition, and per C call bracket. A regression over 10% fails the job.
11. Standalone use
Section titled “11. Standalone use”11.1 IndexedDB from a plain Rust program
Section titled “11.1 IndexedDB from a plain Rust program”// crates/niso-indexeddb/examples/notes.rs cargo run -p niso-indexeddb --example notesuse niso_indexeddb as idb;use niso_sched::SimpleHost;use niso_structured_clone::Value;
fn main() -> niso_base::Result<()> { let host = SimpleHost::new(); // real clock, object table, closure listeners host.install(niso_indexeddb::register); // installs niso-storage's slot too niso_storage::configure(host.cx(), niso_storage::Config::directory("./notes-data"))?;
let cx = host.cx(); let open = idb::factory::open(cx, "notes", Some(1))?; host.listen(open, "upgradeneeded", |cx, ev| { let db = idb::request::result(cx, ev.target)?; idb::database::create_object_store(cx, db, "notes", idb::StoreOptions { key_path: Some("id".into()), auto_increment: true })?; Ok(()) }); host.listen(open, "success", |cx, ev| { let db = idb::request::result(cx, ev.target)?; let tx = idb::database::transaction(cx, db, &["notes"], idb::Mode::ReadWrite)?; let store = idb::transaction::object_store(cx, tx, "notes")?; let note = Value::object([("text", Value::string("buy milk"))]).encode(); // structured-clone bytes idb::object_store::put(cx, store, ¬e, None)?; let all = idb::object_store::get_all(cx, store, None, None)?; SimpleHost::listen_in(cx, all, "success", |cx, ev| { for v in idb::request::result_values(cx, ev.target)? { println!("{:?}", Value::decode(&v)?); } Ok(()) }) }); host.run_until_idle() // returns when no task, timer or live source is left}The API is the generated IDL bindings (bindings::idb_object_store::put and friends) re-exported under short names; nothing above is niso’s DOM, layout or JS. A Deno-style runtime does the same thing through its own HostServices and wraps the handles in its engine’s objects.
11.2 WebSocket on the fake host
Section titled “11.2 WebSocket on the fake host”use niso_host::testing::FakeHost;use niso_websocket::{self as ws, testing::EchoServer, CloseData};
#[test]fn echoes_and_closes_cleanly() { let server = EchoServer::start(); // loopback, one thread let host = FakeHost::new().with_real_waker(); // virtual clock, but sockets are real threads host.install(niso_websocket::register); let cx = host.cx();
let sock = ws::connect(cx, &server.url(), &[]).unwrap(); host.on(sock, "open", |cx, ev| ws::send_text(cx, ev.target, "hi")); host.on(sock, "message", |cx, ev| ws::close(cx, ev.target, Some(1000), Some("bye"))); host.run_until_idle(); // parks on the waker while the socket is live
let fired = host.events_of(sock); assert_eq!(fired.types(), ["open", "message", "close"]); assert_eq!(fired[1].message_text(), Some("hi")); let close: &CloseData = fired[2].payload().unwrap(); assert!(close.was_clean && close.code == 1000);}The same test with SimpleHost::new() in place of FakeHost is the standalone use: a browser-semantics WebSocket client for any Rust event loop, and the run loop’s wait_network (websocket.rs:446-513) is replaced by the source’s live and the waker.
11.3 What cannot be standalone
Section titled “11.3 What cannot be standalone”- niso-core. Style is stylo traversing blitz’s node tree, layout is taffy over the same tree, and the render stream, events (path computation, hit testing) and input are defined by that tree. There is no smaller unit to hand to another program; another program that wants it wants a document. It is, in turn, a perfectly ordinary host for every L1 crate.
- niso-canvas. A canvas bitmap is keyed by its element, sized by the element’s
widthandheightattributes and drawn through the render stream. The 2D rasterizer it uses (vello or tiny-skia) is the standalone part, and is already someone else’s crate. - niso-surfaces. Templates are produced from laid-out subtrees (template.rs reads layout output); without layout there is nothing to template.
- niso-media’s element half. HTMLMediaElement is an element with attributes, readiness tied to loading and events fired at a node. The media session (the bridge to the platform player) is standalone.
- niso-animations’
Element.animate. The timing model is standalone (with aRecordingApplieror the program’s ownStyleApplier); applying keyframes to computed style needs stylo, which is the core’sanimation_style.
12. Reviews and agents
Section titled “12. Reviews and agents”A PR touches one crate, or one contract, and the template makes both readable without the rest of the repo. For an agent the unit of work is: CONTRACT.md of the crate, the contracts listed under “May assume from dependencies”, and the crate’s source. The agent’s check is cargo test -p <crate> (seconds, section 9.3), and the composed build’s tests run through tests/check.sh as today (there is no CI yet). A reviewer reads the contract diff first: an empty one means “behaviour within the existing promises”, so review is the crate’s diff against its suite; a non-empty one is a contract change, gets its own PR, and names every dependent crate whose suite it affects (the layering lint prints the list).
Cross-crate work becomes a stack: contract PR, then implementation in the owner, then adoption in each dependent, each green on its own. That is the same shape as the migration in migration.md, and it keeps the branches that edit one subsystem from conflicting with branches that edit another, which today they do in lib.rs, api.rs, glue.js and rt.h (inventory §9).
13. Open questions and their decisions
Section titled “13. Open questions and their decisions”Questions 1-5, 7 and 8 were decided on 2026-11-09, before migration step 1, and 11 and 12 on 2026-11-10, with the plan for steps 6c onward. Each decision stands until a change to this section reopens it.
- Borrow checking at runtime. Decided: a state-borrow bug panics.
Cx::withusesRefCell, so the reentrancy rule that is followed by hand around*mut Domtoday becomes a panic with the slot’s name, in release builds too. The C boundary turns the panic into anInternalerror, asffi::calldoes with any panic today (catch_unwind, thenNISO_INTERNAL_ERRORwith the panic message as the last error), andniso_host::capi::callkeeps that behaviour. Release builds do not log and carry on. - Worker IndexedDB. Decided: memory-only until a per-origin coordinator exists. A worker’s IndexedDB is in memory and its own (storage-fixes, b834323; DECISIONS, “Web platform APIs”), so niso-indexeddb’s
WorkerSeedseeds no shared directory. Sharing the window’s directory (engine-napi, 5ec3340) waits for one storage agent per origin per process, the cross-agent coordinator forversionchangeandblocked. - Event objects. Decided: they stay in the core’s typed slab, pending a benchmark. Section 10 keeps events in the core’s slab for speed. Moving them into the generic table like everything else is simpler, and is revisited only with the dispatch benchmark’s numbers.
- Blob, streams and fetch bodies. Decided: Blob becomes
niso-blobafter the crypto branch. Blob, File and the byte store become an L1 crate that fetch depends on, extracted once plat-blob-abort-crypto has landed. Streams stay in the glue until a native consumer needs them. - Interface ids. Decided: hashed ids are accepted and arrive with niso-compose (migration step 6).
niso_object_interfacekeeps returning au32whose values change once; the generated constants keep working, and no frozen table is kept for today’s interfaces. - wasm32. Fetch’s transport, WebSocket’s connect threads, SQLite and workers need wasm32 drivers (the embedder’s fetch,
WebSocketvia the host, IndexedDB or OPFS via the host, Web Workers). Which target first, and is “no threads” a supported configuration of niso-sched (it already works on a virtual clock with no live sources)? Open. - niso-webstorage. Decided: Web Storage stays its own crate, separate from niso-indexeddb, so an app such as the timer can drop IndexedDB.
- tv-input’s split. Decided: Gamepad is a crate (niso-gamepad, migration step 17), while focus and spatial navigation (and the device model) stay in the core as input.
- native-look. It is not on origin, and ~/.capy/work/native-look is a scratch folder rather than a checkout, so its conflicts are assessed from its scratch files only. Open.
- UniFFI mapping (section 7.6). How WebIDL inheritance maps onto UniFFI objects (flattened members, or a handle object per interface with a trait per interface), how listeners and host traits cross as callback interfaces, and which calls stay on the direct C path for cost. Open; settled on the first crate that ships UniFFI bindings.
- AbortSignal. Decided: it stays in niso-core, not a niso-abort crate. AbortController and AbortSignal are part of the DOM Standard, and the core’s
addEventListenertakes a signal (events.rs), which rule 3 would forbid if they lived in a web API crate. Crates reach them throughCx(whether a signal is aborted and its reason, adding and removing an abort algorithm), which migration’s host prep adds, andFakeHostfakes them. Migration step 11 is blob and fetch. - XMLHttpRequest and FormData. Decided: they belong to niso-fetch, both being defined on Fetch, as optional glue sections a composition includes only when it uses them (
glue-xhr,glue-form-data). FormData’s entry lists stay in fetch’s glue, whose bodies read them.