Skip to content

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.

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.

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
  1. 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).
  2. 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.
  3. 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).
  4. Input goes back through the C API and is dispatched as DOM events.
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.

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).

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).

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.

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).

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.sh is 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-api diffs, header diffs and NISO_<CRATE>_CONTRACT version 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 contracts and cargo xtask gate, isn’t on main.
  • No cargo xtask metrics. xtask has layers, split, resplit and docs; 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 from crates/niso-crypto/glue, a crate with no contract.

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).

#BoundaryContractMachine-checkedOwnerWorst gap
1aPublic Rust API of the contracted crates (base, host, sched, storage, structured-clone, xpath, canvas, compose; xml outside the template)partialno (behaviour suites only)each crateNo surface check at all: a removed or changed pub item that no test calls passes everything
1bniso-core's public Rust API, used by niso-capi, every binding, niso-android, niso-actions, hosts/iced and niso-wptnononiso-coreA 4,238-line public surface with no CONTRACT.md, so by architecture.md 2.1 it is all "internals", yet ten crates reach in
2aC ABI, generated half (niso.h, 1,627 functions)partialpartial (one source for prototype and shim; no golden on main)niso-idl and the IDL ownersNo per-function docs; whether a callback's drop runs on error depends on argument order
2bC ABI, hand-written half (niso_runtime.h, 127 functions in 18 sections)partialpartial (status codes, render version)per section, sections.txtniso_dom_last_error never cleared on success; 29 functions crash on a NULL DOM
2cABI version and stabilitynononobodyNo ABI version, SONAME or app/host handshake
3aRender stream (rendering.h, version 2)partialnoniso-coreOps carry no length so readers cannot skip; the three production hosts never check the version; no shared fixture
3bTemplate format (widgets, Live Activities, CarPlay)yespartial (encoder only)niso-core, later niso-surfacesAlready broke once inside version 1; the Swift and Kotlin decoders are never tested against the encoder
4aStorage Driver and its C vtable; niso-host Wakeyesyes (suites on every driver and on the C adapter)niso-storage, niso-hostNo host ships a C driver, so the adapter's only implementer is the test vtable
4b6d traits: DocumentHooks, ResourceLoader, WorkerHostpartial (rustdoc in niso-core)partialniso-coreThe suites run a fixed recording implementation, so no binding can run them on its own hooks
5aniso-host HostServices, Cx, Registraryesyes for behaviour (host, tree, wake suites on FakeHost, SimpleHost and the document)niso-hostRegistrar has no way to register a Source, which wave 1 needs and wave agents may not add
5bniso-sched Source, LoopHostyespartial (source_suite on 2 of the core's 6 sources)niso-schedObligations on sources only partly checkable
6Engine binding interface (niso_engine.h, niso_js.h, engine extensions)partial (header comments)nobindings/jsHook names written six times; no suite runs against every engine
7JS glue module boundaryno on main; partial on 6c-gluenoglue core, each crateOptional sections are used by required ones: two optional features fail with ReferenceErrors
8WebIDL as source of truthpartialpartialniso-idlniso-idl has no tests on main; duplicates and unknown extended attributes are accepted silently
9aniso_app.hpartialpartial (examples compile it)niso-appNo version handshake on a library loaded with dlopen
9bSwift host (hosts/apple)nono (device runs only)hosts/appleNo reusable API; decodes the stream and templates by hand
9cAndroid JNI (niso-android) and the :niso Kotlin librarypartial (KDoc)noniso-android, hosts/android33 names kept by hand on both sides, no panic boundary, errors dropped
9dJVM API (dev.niso.dom)partial (Javadoc)partial (generated; RegisterNatives at load)niso-jvm34 runtime natives written twice
9eiced hostno (a program)partialhosts/icedBypasses the C ABI through niso-core internals
9fSurface names (widget, island, CarPlay)partialnoniso-core and each hostHosts use names the header does not list (island.expanded, carplay.list)
9gPlatform CSS prelude and --niso-* tokenspartialpartial (two core tests)niso-coreNo list of what each host must set; tvOS, watchOS and macOS leave tokens unset
9hniso HTML attributes and metapartialpartialniso-coreDecided extensions (--niso-text-engine, @media (platform)) are absent from the code
10aNode Page API (niso.js)partialpartial (behaviour tests)niso-napiNo package boundary: no name, exports or types; the Electron shim uses internals
10bWindow API (node-windows)partial (branch doc)noniso-napi window featureUntested, and named as the Electron shim's future public surface
10cElectron shim (electron-compat)partialpartial (7 fixtures)bindings/js/electronExport list hand-typed, not compared with Electron's
11aCLIs: niso-run, niso-iced, niso-node, niso-wptpartialnoeach binaryTypos become script paths; docs drift from parsers
11bprobe.json and harness payloadsnonotests/screensFour writers, already disagreeing on keys
11cWPT and test262 expectation filespartialpartialniso-wptA missing file silently means "expect nothing"
12Persisted formats (SQLite schema, key encoding, structured-clone bytes)partialpartialniso-storage, niso-structured-cloneSQLite files carry no schema version
13niso-intl C ABI (niso_intl.h)partialno (test262 indirectly)niso-intlNo contract; status codes of its own
14Other byte formats in the ABI (net responses, module values, media actions, surface list, --render files)partialnotheir sectionsProse only, unversioned
15Native 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
16Build-time composition (sections.txt, layer metadata, glue-* features)yesyes (compose unit tests, layers lint)niso-compose, xtaskThe lint checks rule 6 by the presence of a CONTRACT.md file
17Environment variablespartialnoeach scriptNo index