Overview
Checked against e0ebca4 on 2026-11-10 by Capy (after arch-migration-policy). The files it covers are unchanged since.
niso is a set of crates in layers, meant to be composed at link time, so that a
build is the set of crates it links. Each crate is meant to own one thing
behind a written contract (its CONTRACT.md); today the foundation crates and
some web API crates have one, and niso-core, niso-capi, the bindings and the
hosts don’t. A lint (cargo xtask layers, run by tests/check.sh) keeps
the dependencies in the crates’ manifests pointing down. The full design is
Crates and contracts, and much of it
isn’t built yet (below); the
crate table is generated from the workspace on every
build and links each contract that exists.
Layers
Section titled “Layers”flowchart TB
subgraph L3["L3 compositions"]
capi["niso-capi<br/>the full C API"]
app["niso-app<br/>what the Apple host links"]
android["niso-android<br/>what the Android host links"]
end
subgraph L2["L2 document extensions"]
actions["niso-actions"]
canvasel["canvas element, media, surfaces<br/>(in niso-core for now)"]
end
subgraph L1d["L1 the document"]
core["niso-core<br/>DOM, style, layout, render stream, events, input, loading"]
end
subgraph L1w["L1 web APIs (never depend on niso-core)"]
webapi["niso-storage · niso-structured-clone · niso-crypto · niso-xpath · niso-xml · niso-canvas<br/>and, still inside niso-core: fetch, IndexedDB, messaging, wasm, ..."]
end
subgraph L0["L0 foundation"]
sched["niso-sched"] --> host["niso-host"] --> base["niso-base"]
end
subgraph side[" "]
build["build time: niso-idl, niso-compose"]
bindings["bindings: niso-quickjs, niso-porffor, niso-napi, niso-jvm, bindings/js"]
engine["engine builtins: niso-intl"]
end
L3 --> L2 --> L1d --> L0
L3 --> L1w --> L0
L1d -. hooks .-> L1w
The dashed edge is the exception the design is working away from: niso-core
still holds or reaches several web APIs directly, and each such edge is listed
with its reason in xtask/layers-allowlist.txt until the migration moves it
(Migration). The crate table
shows the current list.
A frame, end to end
Section titled “A frame, end to end”sequenceDiagram participant App as Page script or native app participant Core as niso-core participant Host as Host (SwiftUI, Compose, iced) App->>Core: DOM calls (C API or JS binding) Host->>Core: niso_dom_run(now): tasks, timers, microtasks Host->>Core: niso_render_frame() Core-->>Host: render stream (CREATE, FRAME, BOX, TEXT, CONTROL, ...) Host->>Host: update one native view per box Host->>Core: input (niso_dom_tap, niso_dom_key, niso_dom_control_input, ...) Core->>App: events dispatched to listeners
- A page’s script calls the DOM through the JS binding (QuickJS, Porffor or Node-API), which calls the same generated C functions a C, Rust or Java app calls (The document).
- On each display frame the host runs the event loop (
niso-sched) and asks for a frame. The core resolves style with stylo, lays out with Taffy and Parley, and writes what changed as the render stream. - The host applies the ops to its own tree of native views, placing each at the core’s frame, and draws text from the shaped glyph runs (Rendering, Hosts).
- Input goes back through the C API and is dispatched as DOM events.
A tap, coming back
Section titled “A tap, coming back”sequenceDiagram participant User participant Host participant Core as niso-core participant Page as Page listeners User->>Host: tap at a point Host->>Core: niso_dom_tap(x, y) Core->>Core: hit test the laid-out boxes Core->>Page: mousedown, mouseup, click (trusted) Page->>Core: DOM changes (setAttribute, textContent, ...) Note over Core: changes wait for the next frame Host->>Core: next display frame: niso_dom_run, niso_render_frame Core-->>Host: only the boxes that changed
A tap on a native control is different: the control handles its own touch,
and the host reports the result (niso_dom_control_input,
niso_dom_text_edit). The page’s input and change listeners may change or
refuse it, and the control shows whatever state the next CONTROL op sends.
Scrolling is native too: the host scrolls and reports the offset with
niso_dom_scroll, and the next frame fires the scroll events.
The event loop and workers
Section titled “The event loop and workers”flowchart LR
subgraph main["Main thread (the host's UI thread)"]
frame["display frame"] --> run["niso_dom_run(now)<br/>due tasks, timers, network events,<br/>microtask checkpoints"]
run --> rf["niso_render_frame<br/>rAF callbacks, style, layout,<br/>the render stream"]
rf --> idle{"niso_dom_needs_frame?"}
idle -- yes --> frame
idle -- "no: sleep until niso_dom_next_timer,<br/>input or the wake hook" --> frame
end
subgraph w["Worker thread (one per Worker)"]
wrun["its own DOM and engine,<br/>its own loop"]
end
w -- "postMessage, loads,<br/>wake hook" --> run
run -- "postMessage" --> w
The DOM lives on the host’s main thread, and every call into it happens
there. niso_dom_run runs what’s due; niso_render_frame is the rendering
update, where animation frame callbacks run before style and layout. A run uses virtual time when nothing is
waiting on the network or a worker, and returns after about a frame’s work
so it never holds a frame. When nothing needs a frame, the host sleeps until
the next timer, the user’s input, or the wake hook, which another thread (a
worker, the network, a native module) calls to say something arrived. A
worker runs its own loop on its own thread with its own DOM and engine;
only messages cross (Workers).
One call through the C API
Section titled “One call through the C API”Every call into the core, from any language, goes through the same bracket:
flowchart TB caller["caller: JS binding, C, Rust, JNI, Node-API"] --> shim["generated C function<br/>niso_element_set_attribute(...)"] shim --> enter["enter: push the custom element queue"] enter --> body["the call itself,<br/>inside catch_unwind"] body --> deliver["deliver what it queued:<br/>tree moves, connected scripts,<br/>custom element reactions,<br/>the microtask signal, animation promises"] deliver --> safe["safe point: objects freed during the call<br/>get their freed callback"] safe --> status["status code; message in niso_dom_last_error<br/>(a panic becomes an Internal error)"]
The bracket is ffi::call and ffi::call_reentrant in
crates/niso-core/src/ffi.rs; calls into crates outside the core add
niso_host::capi::call (crates/niso-host/src/capi.rs) inside it. The
reentrant form, used when a call runs script (event dispatch), borrows the
DOM only between callbacks, so a listener can call back into the DOM. The
plan is one bracket in niso-host that every binding enters exactly once per
call (Crates and contracts).
What a host plugs in
Section titled “What a host plugs in”The places where a host or binding supplies behavior are Rust traits, each
with a C adapter in the runtime header: ResourceLoader
(crates/niso-core/src/loading.rs, the C loader), DocumentHooks
(crates/niso-core/src/hooks.rs, the binding’s hooks), WorkerHost
(crates/niso-core/src/worker.rs, the worker runner or host) and Wake
(crates/niso-host/src/waker.rs, the wake hook). A Rust binding implements
the trait directly; a C caller sets the hook, and a null hook means none.
Where the API comes from
Section titled “Where the API comes from”The API surface is WebIDL (idl/). niso-idl generates niso.h and the JS
binding’s host functions from it; niso_runtime.h and the JS glue are
written by hand in sections that each belong to a crate, which niso-compose
composes in the order crates/niso-compose/sections.txt gives
(Glue, C API).
Designed, not built yet
Section titled “Designed, not built yet”Crates and contracts and Migration describe more than main has. Checked against main and the boundaries audit:
- No CI. There’s no CI configuration in the repository.
tests/check.shis the only gate, and it runs only when someone runs it; device and host checks are opt-in flags (Testing). - No surface checks. The design’s
cargo public-apidiffs, header diffs andNISO_<CRATE>_CONTRACTversion macros don’t exist, and the C ABI has no version. Contract suites check behaviour, so a removed public item that no test calls passes. Migration now schedules the checks by wave, from the boundaries audit’s list; the first,cargo xtask contractsandcargo xtask gate, isn’t on main. - No
cargo xtask metrics. xtask haslayers,split,resplitanddocs; the size and memory budgets the design and the migration’s baseline step describe have no tool yet. - Contracts on some crates only, as above. The lint checks the rule that a
binding may depend on a web API crate only through its contract by whether
the crate has a
CONTRACT.md, not by what the binding calls. - Edges the lint can’t see. It reads Cargo manifests, so it misses
niso-napi and niso-porffor compiling niso-quickjs’s sources with
#[path], and the bindings composing glue fromcrates/niso-crypto/glue, a crate with no contract.
Boundaries and their checks
Section titled “Boundaries and their checks”Every place one part of niso meets another (a crate’s Rust API, the C ABI, the render stream, a host’s API, a tool’s flags) is a boundary. The boundaries audit lists each one, says whether it has a written contract and a check that fails when it breaks, and orders the checks to add. Before you change a boundary, find it here: where the contract is partial or missing, the code is the only definition, and where nothing checks it, your change can break a caller without any test failing.
Of 34 boundaries, written contract: 5 yes, 23 partial, 6 no; machine-checked: 3 yes, 16 partial, 15 no. From the boundaries audit's verdict, last changed at b39ebe6 (2026-11-10).
| # | Boundary | Contract | Machine-checked | Owner | Worst gap |
|---|---|---|---|---|---|
| 1a | Public Rust API of the contracted crates (base, host, sched, storage, structured-clone, xpath, canvas, compose; xml outside the template) | partial | no (behaviour suites only) | each crate | No surface check at all: a removed or changed pub item that no test calls passes everything |
| 1b | niso-core's public Rust API, used by niso-capi, every binding, niso-android, niso-actions, hosts/iced and niso-wpt | no | no | niso-core | A 4,238-line public surface with no CONTRACT.md, so by architecture.md 2.1 it is all "internals", yet ten crates reach in |
| 2a | C ABI, generated half (niso.h, 1,627 functions) | partial | partial (one source for prototype and shim; no golden on main) | niso-idl and the IDL owners | No per-function docs; whether a callback's drop runs on error depends on argument order |
| 2b | C ABI, hand-written half (niso_runtime.h, 127 functions in 18 sections) | partial | partial (status codes, render version) | per section, sections.txt | niso_dom_last_error never cleared on success; 29 functions crash on a NULL DOM |
| 2c | ABI version and stability | no | no | nobody | No ABI version, SONAME or app/host handshake |
| 3a | Render stream (rendering.h, version 2) | partial | no | niso-core | Ops carry no length so readers cannot skip; the three production hosts never check the version; no shared fixture |
| 3b | Template format (widgets, Live Activities, CarPlay) | yes | partial (encoder only) | niso-core, later niso-surfaces | Already broke once inside version 1; the Swift and Kotlin decoders are never tested against the encoder |
| 4a | Storage Driver and its C vtable; niso-host Wake | yes | yes (suites on every driver and on the C adapter) | niso-storage, niso-host | No host ships a C driver, so the adapter's only implementer is the test vtable |
| 4b | 6d traits: DocumentHooks, ResourceLoader, WorkerHost | partial (rustdoc in niso-core) | partial | niso-core | The suites run a fixed recording implementation, so no binding can run them on its own hooks |
| 5a | niso-host HostServices, Cx, Registrar | yes | yes for behaviour (host, tree, wake suites on FakeHost, SimpleHost and the document) | niso-host | Registrar has no way to register a Source, which wave 1 needs and wave agents may not add |
| 5b | niso-sched Source, LoopHost | yes | partial (source_suite on 2 of the core's 6 sources) | niso-sched | Obligations on sources only partly checkable |
| 6 | Engine binding interface (niso_engine.h, niso_js.h, engine extensions) | partial (header comments) | no | bindings/js | Hook names written six times; no suite runs against every engine |
| 7 | JS glue module boundary | no on main; partial on 6c-glue | no | glue core, each crate | Optional sections are used by required ones: two optional features fail with ReferenceErrors |
| 8 | WebIDL as source of truth | partial | partial | niso-idl | niso-idl has no tests on main; duplicates and unknown extended attributes are accepted silently |
| 9a | niso_app.h | partial | partial (examples compile it) | niso-app | No version handshake on a library loaded with dlopen |
| 9b | Swift host (hosts/apple) | no | no (device runs only) | hosts/apple | No reusable API; decodes the stream and templates by hand |
| 9c | Android JNI (niso-android) and the :niso Kotlin library | partial (KDoc) | no | niso-android, hosts/android | 33 names kept by hand on both sides, no panic boundary, errors dropped |
| 9d | JVM API (dev.niso.dom) | partial (Javadoc) | partial (generated; RegisterNatives at load) | niso-jvm | 34 runtime natives written twice |
| 9e | iced host | no (a program) | partial | hosts/iced | Bypasses the C ABI through niso-core internals |
| 9f | Surface names (widget, island, CarPlay) | partial | no | niso-core and each host | Hosts use names the header does not list (island.expanded, carplay.list) |
| 9g | Platform CSS prelude and --niso-* tokens | partial | partial (two core tests) | niso-core | No list of what each host must set; tvOS, watchOS and macOS leave tokens unset |
| 9h | niso HTML attributes and meta | partial | partial | niso-core | Decided extensions (--niso-text-engine, @media (platform)) are absent from the code |
| 10a | Node Page API (niso.js) | partial | partial (behaviour tests) | niso-napi | No package boundary: no name, exports or types; the Electron shim uses internals |
| 10b | Window API (node-windows) | partial (branch doc) | no | niso-napi window feature | Untested, and named as the Electron shim's future public surface |
| 10c | Electron shim (electron-compat) | partial | partial (7 fixtures) | bindings/js/electron | Export list hand-typed, not compared with Electron's |
| 11a | CLIs: niso-run, niso-iced, niso-node, niso-wpt | partial | no | each binary | Typos become script paths; docs drift from parsers |
| 11b | probe.json and harness payloads | no | no | tests/screens | Four writers, already disagreeing on keys |
| 11c | WPT and test262 expectation files | partial | partial | niso-wpt | A missing file silently means "expect nothing" |
| 12 | Persisted formats (SQLite schema, key encoding, structured-clone bytes) | partial | partial | niso-storage, niso-structured-clone | SQLite files carry no schema version |
| 13 | niso-intl C ABI (niso_intl.h) | partial | no (test262 indirectly) | niso-intl | No contract; status codes of its own |
| 14 | Other byte formats in the ABI (net responses, module values, media actions, surface list, --render files) | partial | no | their sections | Prose only, unversioned |
| 15 | Native modules ([NisoModule], niso_module_*) | partial (docs/modules.md) | partial (tests, examples) | niso-modules (step 15) | The generated JS, .d.ts and Java have no snapshot |
| 16 | Build-time composition (sections.txt, layer metadata, glue-* features) | yes | yes (compose unit tests, layers lint) | niso-compose, xtask | The lint checks rule 6 by the presence of a CONTRACT.md file |
| 17 | Environment variables | partial | no | each script | No index |
Read next
Section titled “Read next”- Crates and contracts: the layer rules, each crate’s ownership, the scheduler, composition at link time.
- Inventory and Migration: where the code is today and the steps that move it into crates.
- Decisions: the decisions every change follows.
- Boundaries: every API boundary, its contract, its checks and the checks to add.
- The contracts: niso-base, niso-host, niso-sched, niso-storage and the others in the crate table.