@nibling/kernel — synthesized architecture #
Synthesis of arena 1 (candidates: claude-sonnet-5.5 [base], gpt-6.1-sol-pro, grok-4.7).
Design record: sketches/arena-1/. This file is the implementation contract.
Problem #
A browser-run Node-compatible runtime, built from scratch. JavaScript runs in the browser's own engine; the native parts of Node reach browser primitives first, falling through to WASM only where the browser genuinely lacks a Node contract (synchronous incremental hashing, sync zlib, POSIX metadata that OPFS cannot express). We may not inspect WebContainers source; everything here derives from public specs (MDN, Node docs, WASI, OPFS/IDB/Workers).
The non-obvious shape: Node semantics require one shared filesystem across processes, but OPFS sync access handles are exclusive locks and the browser gives no shared memory between the page and workers without cross-origin isolation. The design therefore makes one worker the sole filesystem owner and gives every other worker a typed RPC client with a sync mailbox. Persistence is layered so the first milestone needs none of it.
Usage (caller's view) #
Call site 1: embedder boots a runtime and runs a script #
import { boot } from "@nibling/kernel";
const runtime = await boot(); // options: { base, serviceWorker }
runtime.writeTree({ "/app/main.js": "…", "/app/lib/util.js": "…" });
const proc = runtime.exec("/app/main.js");
proc.stdout.pipeTo(consoleSink);
const code = await proc.exited; // exit code
Call site 2: user JS inside the runtime (unchanged Node code) #
const fs = require("fs");
const { createHash } = require("crypto");
const { spawn, fork } = require("child_process");
const zlib = require("zlib");
const data = fs.readFileSync("in.dat"); // blocks the worker on the kernel mailbox
const h = createHash("sha256").update(data).digest("hex"); // in-thread WASM, no RPC
const gz = zlib.gzipSync(data); // in-thread WASM
fs.writeFileSync("out.txt", gz);
const child = fork("./helper.js");
child.on("message", (m) => console.log("child said", m));
child.send({ hi: true });
process.on("SIGINT", () => child.kill());
readFileSync blocks via Atomics.wait on a mailbox; createHash/gzipSync stay in-thread
through the WASM residue; fork is a Web Worker plus a transferred MessagePort.
Call site 3: contributor adds a builtin or a native algorithm #
// compat/os/index.ts — one file plus one manifest line
export default defineBuiltin({
id: "os",
exports: ({ process }) => ({
EOL: "\n",
platform: () => "linux",
availableParallelism: () => navigator.hardwareConcurrency,
}),
});
// compat/manifest.ts: os: () => import("./os/index.ts"),
// Adding a hash algorithm: add it to native/abi.json, implement in native/src/hash.c,
// add a row to compat/crypto/algorithms.ts. `gen-abi` regenerates Rust + TS bindings.
// CI fails if the built .wasm exports differ from abi.json, or if any node: builtin id
// is neither in the manifest nor in OUT_OF_SCOPE.
Shape #
Actors and ownership #
main thread (host/) kernel worker (kernel/) runtime workers (runtime/), 1 per thread
─────────────────── ────────────────────── ────────────────────────────────────────
boot(), Kernel, Process ◄──► inode table, fd tables, pipes, ◄──► user JS + compat/ + loop + module loader
tty sinks, SW registrar process table, resolver, + residue.wasm instance (sync hash/zlib)
store (memory | OPFS), journal, Web Lock
▲
Service Worker (sw/) ── fetches ESM source for the virtual origin
- The kernel worker is the only writer of the filesystem and the process table. A filesystem
is genuinely shared state, so the sharing is structural: one owner, everyone else RPC clients
(separate-before-serializing-shared-state). All inode-table mutation is synchronous code with no
awaitbetween check and commit. Per-inode async queues serialize only the byte I/O that awaits a handle. OPFS sync handles are exclusive locks, which would force single-writer anyway. - User JS never runs on the main thread, so every sync API can block with
Atomics.wait. - A runtime worker is stateless about the filesystem: module cache, loop, WASM instance, everything else an RPC to the kernel.
- The kernel never synchronously calls into a runtime worker (one-way dependency, from C2). Kernel→runtime messages are only completions/signals, delivered as poll-phase events.
- The Service Worker arrived with the ESM milestone and is now required: guest ESM and guest
HTTP depend on it, so
boot()refuses without one rather than run CJS alone.
Data structures #
All branded at the boundary, trusted inside (kernel/vfs/types.ts):
type Brand<T, B extends string> = T & { readonly [brand]: B };
export type Ino = Brand<number, "Ino">; // monotonic, never reused → blob name is stable
export type Fd = Brand<number, "Fd">; // per-process, lowest-free
export type Pid = Brand<number, "Pid">;
export type Tid = Brand<number, "Tid">;
export type Mode = Brand<number, "Mode">; // 0..0o7777
export type Name = Brand<string, "Name">; // one dentry: no '/', no NUL, not "."/"..", ≤255 bytes
export type FsPath = Brand<string, "FsPath">; // ≤4096 bytes, may be relative, may contain ".."
// ".." is never collapsed lexically; it is walked physically so symlinks resolve like POSIX.
export function parseFsPath(raw: string): Result<FsPath, "EINVAL" | "ENAMETOOLONG">; // only constructor
export function parseName(raw: string): Result<Name, "EINVAL" | "ENAMETOOLONG">;
export const Errno = { ENOENT: 2, EEXIST: 17, ENOTDIR: 20, EISDIR: 21, EBADF: 9, ENOTEMPTY: 39,
ELOOP: 40, ENOSPC: 28, EIO: 5, EINVAL: 22, ENAMETOOLONG: 36, EXDEV: 18, EMFILE: 24,
ENOSYS: 38, EAGAIN: 11, EPERM: 1, EACCES: 13 } as const;
export type ErrnoCode = keyof typeof Errno;
export type Result<T, C extends ErrnoCode = ErrnoCode> =
| { ok: true; val: T }
| { ok: false; err: { code: C; syscall: SyscallName; path?: FsPath; dest?: FsPath } };
interface InodeBase { ino: Ino; mode: Mode; nlink: number; atimeMs: number; mtimeMs: number; ctimeMs: number }
export interface FileInode extends InodeBase { kind: "file"; size: number }
export interface DirInode extends InodeBase { kind: "dir"; entries: Map<Name, Ino>; parent: Ino }
export interface SymlinkInode extends InodeBase { kind: "symlink"; target: FsPath }
export type Inode = FileInode | DirInode | SymlinkInode; // kind-union: a dir cannot have a size
export interface InodeTable { root: Ino; get(ino: Ino): Inode | undefined; nextIno: number; seq: number }
Hard links exist (nlink), unlike C3's names: 0|1; link() is a mutation, unlink of the last
name plus zero open fds retires the inode and GCs its blob.
Persistence is event-sourced (C1), with C2's receipt rule for ambiguous commits:
export type Mutation = { seq: number } & (
| { t: "create"; ino: Ino; kind: "file" | "dir" | "symlink"; parent: Ino; name: Name; mode: Mode; target?: FsPath; at: number }
| { t: "link"; ino: Ino; parent: Ino; name: Name; at: number }
| { t: "unlink"; parent: Ino; name: Name; at: number }
| { t: "rename"; from: [Ino, Name]; to: [Ino, Name]; at: number }
| { t: "setattr"; ino: Ino; mode?: Mode; atimeMs?: number; mtimeMs?: number }
| { t: "resize"; ino: Ino; size: number; at: number } // journaled AFTER the blob write
);
export function applyMutation(t: InodeTable, m: Mutation): void; // pure; skips m.seq <= t.seq
export function encodeFrame(m: Mutation): Uint8Array; // [len u32][crc32 u32][payload]
export function decodeFrames(bytes: Uint8Array): { muts: Mutation[]; validBytes: number };
export interface Store {
readonly meta: MetaLog; readonly data: BlobStore; close(): Promise<void>;
}
export interface MetaLog {
load(): Promise<{ snapshot: Uint8Array | null; journals: Uint8Array[] }>;
append(frame: Uint8Array): void; // sync-handle write, buffered; never awaits
flush(): Promise<void>; // 25 ms group-commit; also on fsync/pagehide
checkpoint(snapshot: Uint8Array, lastSeq: number): Promise<void>;
}
export interface BlobStore {
read(ino: Ino, at: number, len: number): Promise<Uint8Array>;
write(ino: Ino, at: number, data: Uint8Array): Promise<void>;
truncate(ino: Ino, size: number): Promise<void>; remove(ino: Ino): Promise<void>;
flush(ino: Ino): Promise<void>; list(): AsyncIterable<Ino>;
}
export function openStore(cfg: StoreConfig): Promise<Store>; // Web Lock `nib:<name>`; StoreBusyError if held
Two store implementations (store-memory.ts, store-opfs.ts) keep the pure VFS core testable
without a browser. OPFS layout: /nib/<name>/{meta/snap.<gen>, meta/journal.<gen>, data/<ino hex>},
LRU-capped at 64 sync access handles. Recovery: load snapshot, replay journals in gen order
skipping seq <= t.seq, GC orphan blobs. Commit receipts (C2): an ambiguous commit outcome is
resolved by reading its receipt, never by assuming; a mutation whose frame reached the journal
before the ack is idempotent on replay by seq.
Syscall spec — one typed table both sides share; the wire is type-checked end to end. This is the single API boundary between compat and kernel, not a pass-through chain (judge-flagged C1 defect fixed: mailbox spill policy moved out of the spec into rpc.ts).
export interface SyscallSpec {
open: { args: { path: FsPath; flags: OpenFlags; mode: Mode }; ret: Fd };
close: { args: { fd: Fd }; ret: void };
read: { args: { fd: Fd; len: number; pos: number | null }; ret: Bytes };
write: { args: { fd: Fd; data: Bytes; pos: number | null }; ret: number };
stat: { args: { path: FsPath; follow: boolean }; ret: StatRecord };
fstat: { args: { fd: Fd }; ret: StatRecord };
readdir: { args: { path: FsPath }; ret: DirentRecord[] };
mkdir: { args: { path: FsPath; mode: Mode; recursive: boolean }; ret: FsPath | null };
rename: { args: { from: FsPath; to: FsPath }; ret: void };
unlink: { args: { path: FsPath }; ret: void };
rmdir: { args: { path: FsPath }; ret: void };
symlink: { args: { target: FsPath; path: FsPath }; ret: void };
readlink: { args: { path: FsPath }; ret: FsPath };
realpath: { args: { path: FsPath }; ret: FsPath };
utimes: { args: { path: FsPath; atimeMs: number; mtimeMs: number }; ret: void };
truncate: { args: { fd: Fd; size: number }; ret: void };
fsync: { args: { fd: Fd }; ret: void };
watch: { args: { path: FsPath; recursive: boolean }; ret: WatchId }; // events as completions
resolve: { args: { request: string; parent: FsPath; kind: "require" | "import" }; ret: ResolvedModule };
loadModule: { args: { path: FsPath }; ret: { source: Bytes; format: ModuleFormat; pkg: PkgScope | null } };
spawn: { args: SpawnSpec; ret: { pid: Pid } };
spawnSync:{ args: SpawnSpec; ret: SpawnSyncResult }; // reply withheld until child exit
kill: { args: { pid: Pid; sig: Signal }; ret: void };
sigaction:{ args: { sig: Signal; handled: boolean }; ret: void };
exit: { args: { code: number }; ret: never }; // never replies; kernel terminates worker
npm: { args: InstallRequest; ret: InstallOutcome }; // async only; npm's error is the outcome's
npmLookup:{ args: ExecLookupRequest; ret: ExecLookup }; // async only; libnpmexec's missingFromTree
}
type Req = { t: "req"; id: number; op: SyscallName; args: unknown; sync: boolean };
type Res = { t: "res"; id: number } & ({ ok: true; val: unknown } | { ok: false; err: WireErr });
export function decodeRequest(raw: unknown, caller: PortOwner): Result<Req>;
// caller identity comes from the PORT, never the payload. FsPath/Name minted only at edges:
// compat/fs (user input), host (host input), decodeRequest (wire).
Invariants: every Req gets exactly one Res (one try/finally in serve(); unexpected throw
becomes EIO). exit never replies. Requests carry a generation + receipt (C2) so a retried
append/rename never duplicates its effect.
Sync mailbox (one SAB per runtime thread, handed to the kernel at attach):
Int32 header: [0] state (0 IDLE, 1 PENDING, 2 DONE) [1] errno [2] jsonLen [3] bytesLen
[4] pendingSignals bitmask [5] kernel heartbeat (bumped every 500 ms)
arena (1 MiB): [json result][raw bytes]
Protocol (C1 layout + C2 lost-wakeup fix): requester checks state == DONE before setting
state = PENDING and waiting (sequence-check-before-wait prevents a lost wakeup when the reply
lands between postMessage and the wait). Atomics.wait in a 2 s loop; heartbeat staleness > 10 s
throws ERR_NIB_KERNEL_LOST. A reply larger than the arena returns the head plus an open rest: Fd (spill policy lives in rpc.ts, not in the syscall spec).
Module map #
@nibling/kernel/
├─ src/
│ ├─ host/ PUBLIC. main thread. boot()/Kernel/Process hide everything below
│ │ ├─ index.ts boot, Kernel, Process, ExecSpec, HostFs, errors
│ │ ├─ supervisor.ts spawns kernel worker, tty sinks, pagehide→flush, SW registration
│ │ └─ host-fs.ts string-path facade → syscall on Pid 0 (parses to FsPath at the edge)
│ ├─ kernel/ kernel worker. sole owner of fs + process table
│ │ ├─ main.ts openStore → recover → serve ports
│ │ ├─ rpc.ts SyscallSpec, decodeRequest, serve(), mailbox writer, per-inode queues
│ │ ├─ vfs/ types|path|mutation|ops(PURE)|fdtable|recover|store|store-memory|store-opfs
│ │ ├─ modules/ resolve.ts (ONE resolver, CJS+ESM, pure over FsView) | transform.ts
│ │ └─ proc/ table.ts | pipe.ts (bounded rings, backpressure) | worker-factory.ts
│ ├─ runtime/ per-thread worker. user JS lives here
│ │ ├─ main.ts, bootstrap.ts (globals, primordials, entry), syscall.ts (call/callSync)
│ │ ├─ loop/ scheduler.ts | timers.ts | ticks.ts | handles.ts | clock.ts (Real + Virtual)
│ │ ├─ modules/ cjs.ts | esm-bridge.ts | builtins.ts
│ │ └─ native/ abi.gen.ts (GENERATED) | heap.ts | index.ts
│ ├─ compat/ Node API layer. one dir per builtin
│ │ ├─ manifest.ts, define.ts, builtin-names.gen.json
│ │ └─ fs/ path/ process/ console/ util/ events/ buffer/ string_decoder/ url/ os/
│ │ timers/ assert/ crypto/ zlib/ stream/ child_process/ worker_threads/
│ ├─ sw/sw.ts virtual origin: /~/vfs/<path>?v=<ino>.<mtime>, /~/builtin/<id>
├─ native/abi.json SINGLE SOURCE OF TRUTH: functions, enums, alg ids
├─ scripts/ gen-abi.ts | compat-report.ts | serve.ts (sets COOP/COEP)
└─ tests/ unit (vitest) | browser (Playwright) | fixtures/golden-loop-traces
Builtins stay cheap to add: defineBuiltin({ id, exports(ctx) }) with
BuiltinContext { sys, native, loop, process, require }. Lazy manifest imports; unused builtins
never load. OUT_OF_SCOPE ids throw ERR_NIB_UNSUPPORTED at require. compat-report.ts fails CI
on any node: builtin id that is neither supported nor explicitly out of scope.
Browser-first precedence table #
The WASM ABI is derived from the last column only. Native primitive as substrate, Node API shape on top. (Rows merged from C1 + C3; C3's finer crypto/zlib splits win.)
| Node API family | Browser primitive (substrate) | Node-semantics layer | WASM fallback |
|---|---|---|---|
fs bytes |
OPFS sync access handles (read/write/truncate/flush/getSize) in kernel | blob-per-inode | none |
fs metadata (mode, symlink, hardlink, mtime, ino, readdir order) |
none — OPFS expresses none of it | JS inode table + journal | none. Plain JS beats an ABI crossing |
fs exclusivity, fsync, quota |
Web Locks, handle.flush(), storage.estimate/persist, QuotaExceededError→ENOSPC |
— | none |
fs.watch |
none (no OPFS observer) | kernel emits from its own mutation stream | none |
path, querystring |
none | pure JS posix | none |
url |
native URL/URLSearchParams |
fileURLToPath, pathToFileURL, legacy url.parse |
none |
Buffer, string_decoder |
Uint8Array, TextEncoder/TextDecoder, atob/btoa, toBase64/toHex when present |
Buffer extends Uint8Array; latin1 is a JS byte loop (TextDecoder latin1 ≠ Node latin1) | none |
crypto.webcrypto, async digest |
crypto.subtle returned as the web API |
— | none |
randomBytes/randomFill |
crypto.getRandomValues (chunked at its size limit), crypto.randomUUID |
— | none |
| pbkdf2 SHA-256/512 | crypto.subtle.deriveBits |
Node option mapping | none |
other pbkdf2/scrypt, createHmac, createHash, sync hash() |
none — browser has no sync incremental digest | Node API shape; until step 5, md5/sha1/sha256/sha512 in TypeScript (src/runtime/digest.ts), byte-identical |
incremental SHA-256/SHA-512 (+ future algs via abi.json) |
events |
EventTarget | one internal listener per event name walks Node listeners; throw inside a listener stops the walk, rethrown after dispatch (DOM exception reporting must not swallow it) | none |
stream |
Readable/Writable/TransformStream queues | Node stream state machine + backpressure; toWeb/fromWeb adapters | none |
process, timers, nextTick, setImmediate |
Worker global, performance.now, native timers, MessageChannel, native microtasks | owned queues | none |
child_process, worker_threads |
Dedicated Workers, MessagePorts, structured clone/transfer, SAB | PCB, pipes, signals | none |
| zlib async (gzip/deflate/inflate) | CompressionStream/DecompressionStream | Node option mapping | none |
| zlib sync (gzipSync etc.) | none — streams are async | Node API shape | deflate/inflate (deflate64, brotli: out of scope v1) |
console, util |
browser console + captured stdio | inspect/format/promisify | none |
fetch, web streams globals |
browser implementations, CORS preserved | undici-shape facade | none |
| native addons | no browser equivalent | .node rejected with ERR_DLOPEN_FAILED; portable WASM modules are a future extension |
— |
| TypeScript sources | none — the browser has no TS eraser, and CJS require needs it synchronously |
Node's strip-only contract: .ts follows nearest type (syntax-detected on the stripped source when there is none, as .js is), .mts→ESM, .cts→CJS; import type erased before resolution; no extension rewriting; .tsx/enums/node_modules-.ts refused with Node's own codes |
@swc/wasm-typescript transformSync (the engine behind Node's amaro), wasm streamed from dist/runtime/swc.wasm at the first .ts load (a sync require waits on it), failure contained as ERR_NIB_TS_ENGINE |
Browser-native audit #
The precedence table is the plan; this is what the code does today. Each row names the browser primitive a Node concern rides on, or why it is custom.
| Node concern | Primitive used | Custom, and why |
|---|---|---|
| processes | Dedicated Workers (kernel worker, one runtime worker per process), MessagePorts | the process table and its lifecycle (exit codes, kill(), port freeing): the browser has no process model |
| sync syscalls | SharedArrayBuffer mailbox + Atomics.wait |
the mailbox protocol and heartbeat; the browser has no sync worker RPC |
| ESM | the browser's module linker over a Service Worker virtual origin | specifier rewriting (es-module-lexer) and CJS facades: the linker cannot resolve node_modules or bare builtins |
| event loop | MessageChannel tasks, native microtasks, one armed setTimeout |
Node's phases, timer heap and nextTick queue: the browser's task order is not libuv's |
| http transport | Service Worker fetch interception, MessagePort bridge |
IncomingMessage/ServerResponse and a per-request virtual socket; there is no TCP |
| WebSocket upgrades | a MessagePort per connection, transferred page → SW → host → kernel → runtime; crypto.subtle SHA-1 for the handshake check |
the RFC 6455 client in the shim (src/shim/): a Service Worker cannot intercept WebSockets, so the page speaks the protocol over the port |
| web globals | the worker's own fetch, URL, streams, TextEncoder, crypto, queueMicrotask |
only what workers lack: process, Buffer, global, loop-backed timers |
| Buffer | Uint8Array, TextEncoder/Decoder, fromBase64/toBase64/fromHex/toHex when present |
lenient base64/hex decoding (the native decoders are strict), latin1/ascii/utf16le loops (TextDecoder's latin1 is windows-1252) |
| url | URL, URLSearchParams |
legacy url.parse/format, fileURLToPath/pathToFileURL: no browser API has Node's legacy shapes |
| crypto random | crypto.getRandomValues, crypto.randomUUID, crypto.subtle as webcrypto |
none |
| crypto hashing | none: crypto.subtle.digest is async and one-shot |
md5/sha1/sha256/sha512 in TypeScript (digest.ts) until the residue: createHash is sync and incremental |
| zlib | CompressionStream/DecompressionStream (gzip, deflate, deflate-raw) | error mapping, data-after-end recovery, unzip detection, Node's stream event order; crc32 in JS; sync forms wait for the residue; brotli and zstd refuse |
| fs persistence | none yet: in-memory store | OPFS sync access handles are pending (step 4) |
| fs.watch | none: the kernel is the only writer, so its own mutations are the event source | the inotify event model and per-thread delivery; the browser has no file notifications for an in-memory store |
| dns | none: a page cannot run getaddrinfo | a hosts table (localhost, IP literals); queries refuse |
| http client | none yet | http.request/https, over fetch, are pending |
WASM ABI (residue module) #
native/abi.json is the single source of truth; gen-abi.ts generates runtime/native/abi.gen.ts
and the C/Rust declarations. Freestanding C (no Emscripten FS, no WASI, no host imports — the v1
import table is empty), instantiated at process boot so createHash never has an async
first-call path. Private growable memory; views rebuilt after any growing export.
| Export | C-style signature | Contract |
|---|---|---|
memory |
linear memory | private to one process; never exposed to user Buffers |
nib_abi_version |
uint32_t(void) |
reject other major versions |
nib_alloc / nib_free |
uint32_t(uint32_t len) / void(uint32_t,uint32_t) |
exact allocation pair, exactly once |
nib_hash_new |
int32_t(uint32_t alg, uint32_t out_handle_ptr) |
1=SHA-256, 2=SHA-512; streaming handle |
nib_hash_update |
int32_t(uint32_t h, uint32_t ptr, uint32_t len) |
borrows input during call only |
nib_hash_final |
int32_t(uint32_t h, uint32_t out_ptr, uint32_t out_cap) |
consumes handle only on success |
nib_hash_drop |
int32_t(uint32_t h) |
releases abandoned hash; stale handles fail |
nib_z_new / nib_z_run / nib_z_drop |
streaming codec handles | same handle discipline as hash |
Status 0 = success; negative enums for invalid handle/argument/state/allocation — not POSIX errno;
native/index.ts maps them to Node crypto/zlib errors. Handles are index + generation counter.
Traps poison the instance (re-instantiate on next use), never resurface as EINVAL. One copy in,
one small copy out; no borrowed pointers across calls, no shared linear memory, no JS callbacks
into the module.
fs.readFile trace (no WASM hop — OPFS already does the native read):
require('fs').readFile('/app/a.txt', cb) → compat/fs parses path/encoding (sync throw on bad
args), retains a loop resource → async readFile syscall: kernel walks the path in the inode
table, opens/reads the OPFS blob via sync access handle, transfers bytes back → reply lands as a
poll-phase completion → compat constructs the Buffer or decodes UTF-8 → cb(null, data) once.
Errors: kernel emits { code: 'ENOENT', syscall: 'open', path }; compat formats the Node error
with code/errno/syscall/path. No browser exception escapes.
createHash trace (the WASM hop): createHash('sha256') → compat/crypto/hash.ts →
native/index.ts: nib_alloc(64) scratch → nib_hash_new(1, ptr) handle → each update(data)
copies into scratch, calls nib_hash_update, frees scratch → digest(): nib_hash_final writes
32 bytes, adapter copies into a fresh Buffer, hex-encodes in JS, frees → status≠0 or trap throws
NodeError after try/finally cleanup.
Event loop mapping #
runtime/loop/scheduler.ts is a virtual libuv loop running one user callback per browser
task; microtasks drain natively between callbacks (Node ≥ 11 behavior). Wake via
MessagePort.postMessage (no 4 ms clamp); a single armed setTimeout when only timers are
pending; idle otherwise.
| Node phase | Our structure | Browser primitive | Notes |
|---|---|---|---|
| timers | min-heap (expiryMs, seq); clamp ≥1 ms; unref |
armed setTimeout / phase-entry check vs cached loopNow |
our heap decides order; 4 ms clamp and throttling change when, never order |
| pending | FIFO of deferred callbacks (fs arg errors reported async, deferred close errors) | MessageChannel step | |
| idle/prepare | internal hooks only (flush batched stdout) | same step | |
| poll | FIFO of completions: syscall replies, stream data, watch events, child exit/IPC, fetch/WS settle | onmessage/promise-settle handlers only enqueue + wake | |
| check | setImmediate queue, snapshotted at entry | MessageChannel step | immediates scheduled inside run next iteration |
| close | FIFO of 'close' emissions | MessageChannel step | |
| nextTick | drained synchronously after every callback | same stack | called from a microtask (ESM continuation) → schedules a step that starts by draining ticks |
| microtasks | native | browser checkpoint | CJS main: then(a); nextTick(b) → b, a. ESM main: a, b (matches Node) |
| unhandledRejection | worker event → completion | no listener: print + exit 1 | |
| uncaughtException | try/catch around each callback | no listener: print, exit 1 |
Determinism: the loop takes a Clock; tests use a VirtualClock advancing only when idle, so
setTimeout(0) vs setImmediate is deterministic; golden traces recorded from Node docs stored
as fixtures. The one real nondeterminism (1 ms timer vs immediate from main) matches Node.
Sync-op policy: fs.*Sync/require/readSync block on the mailbox (legal because user code only
runs in workers; heartbeat watchdog). crypto.*Sync/zlib.*Sync run in-thread WASM, no RPC.
spawnSync/execSync of node blocks with the kernel withholding the reply until child exit
(child needing its blocked parent deadlocks, as in Node). Sync network refused with
ERR_NIB_UNSUPPORTED_SYNC. Without crossOriginIsolated, boot() refuses (IsolationError) —
no silent degraded mode. CPU-bound loops are interruptible by kill only via
worker.terminate() when no signal handler is registered.
Async context #
Status: proposed 2026-11-03, amended 2026-11-04. Stage 1 built 2026-11-04; stage 2 not started.
AsyncLocalStorage follows a program's async work the way it does in Node 24, so a store
set with run() is still there after an await, in a timer, and in an I/O callback.
Frameworks that keep per-request state this way (Hono's context storage, Next.js,
OpenTelemetry) then work unmodified. So does node:test, which uses the same mechanism
to put a test registered after an await under its async describe().
The contract is the WinterTC portable subset. WinterTC's
portable subset of AsyncLocalStorage
defines the API and the propagation rules. Workers, Deno, Bun and Node implement it, and it
stays forward-compatible with TC39 AsyncContext (Stage 2). The subset is run, exit and
getStore, and AsyncResource's constructor, runInAsyncScope, bind and static bind.
The kernel keeps Node's extras (AsyncLocalStorage.bind and snapshot, enterWith,
disable) because Node code calls them. Neither class goes on globalThis.
Node 24.16.0 is the oracle, and it already uses the subset's model. Node 24 runs
AsyncLocalStorage on AsyncContextFrame by default (the flag is --no-async-context-frame).
Probes under the pinned Node give the subset's answers, so goldens recorded under Node test
the subset:
| Case | The store seen | Stage |
|---|---|---|
.then attached in frame B to a promise created in frame A |
B | 1 |
| EventEmitter listener added in A, emitted in B | B (emit is synchronous) | 1 |
setTimeout, setImmediate, nextTick, queueMicrotask, fs callback |
the frame they were scheduled in | 1 |
http server request handler and req events |
the frame listen() ran in |
1 |
http.request response callback and res events |
the frame request() ran in |
1 |
unhandledRejection for a promise created in A, rejected in B |
B | open |
After await, including await fetch() |
the async function's frame | 2 |
catch after a rejected await |
the async function's frame | 2 |
An async generator's body, and after each yield |
the frame of the next() call that resumed it |
2 |
A for await body and the code after the loop |
the generator-or-function's current frame | 2 |
A module's top level after a top-level await |
the root frame | 2 |
One frame per worker. The frame is the existing immutable map in
src/runtime/async_hooks.ts. Two internal calls drive it: capture() returns the current
frame, and enter(frame) makes a frame current and returns the previous one.
Stage 1: propagation the kernel controls, with no parser.
- Callbacks the kernel schedules capture when scheduled. These are timers, immediates,
nextTick,queueMicrotask, and the callbacks of syscall completions (fsand the other callback APIs). The kernel implements all of them.queueMicrotaskis the one global the kernel installs, as Node does: its own is a plain JS function (lib/internal/process/task_queues.js), not an engine intrinsic, so the kernel installs its own with Node's name, length and validation, wired to the frame instead ofAsyncContextFrame. Thenode:testtimer wrap that 3415f68 removed did replace a global, and brokeutil.promisify(setTimeout). - Handles the kernel creates capture when created. Servers, sockets, client requests,
child processes, watchers and the kernel's streams. Events emitted from their completions
run in that frame, which gives the two
httprows above. - Promise reactions capture at
then().Promise.prototype.thenbecomes aProxyover the nativethenwhoseapplywraps both callbacks in the frame current at the call.catch,finallyand the combinators callthen, so they inherit it. The Proxy goes on the first time a worker leaves the root frame (run()orenterWith()). Before that every frame is the root, so everythen()would capture the root anyway, and a worker that never usesAsyncLocalStoragekeeps the engine's ownthen.
Every stage 1 entry point enters its frame and restores the previous one when the callback
returns, so the frame is the root again between tasks. After an await, a stage 1 kernel
sees the root frame, as it does today. It never sees another task's frame. Letting jobs
leave their frame set would carry a frame across await, but only by accident: a connection
pool settles a waiting request's promise from inside another request's release(), and the
waiter would see that other request's store.
Stage 2: await and yield, through a source transform. The engine gives no hook for
native await, and AsyncContext is a Stage 2 proposal the kernel cannot rely on. In every
async function, async arrow, async method, async generator, and a module's top level:
- the body starts with
const $f = $nib.c();, a box holding the frame current at entry; - each
await Ebecomes$nib.r($f, await E), whererenters the boxed frame and returns its second argument; - each
yield Ein an async generator becomes$nib.y($f, yield E), whereystores the current frame in the box, because Node resumes a generator in the frame of thenext()call that resumed it; - each
catchandfinallyblock whose nearest enclosing function is this one starts with$nib.r($f);, because a rejectedawaitresumes by throwing andrnever runs; - a
for awaitbody and the statement after the loop start with$nib.r($f);.
Between its awaits, an async function's frame changes only through the generator rule above,
because run() only changes it for a synchronous call. So restoring the box is exact. The
restore runs in the resumed code, so it does not matter who settled the promise: the
browser (fetch), a kernel completion, or code in another frame. That is what stops one
request's store from leaking into another's continuation. The transform adds no await,
promise or tick, and r and y are synchronous, so callback and microtask order are
unchanged. It is not the source-level async conversion rejected under
Alternatives considered: no synchronous code becomes async.
r leaves its frame current, so stage 2 also adds a root reset. After a microtask drain in
which any r ran, one microtask queued at the end of the queue enters the root frame. A
callback the browser dispatches directly to guest code, such as the guest's own listener on
a WebSocket, MessagePort or IndexedDB request, then starts in the root frame instead of
the last continuation's. The extra microtask runs after every guest microtask in that drain,
so it changes no guest-visible order.
Where the transform runs. On the kernel side, for both module systems. ESM sources pass
through src/kernel/transform.ts already. CJS sources are compiled in the runtime
(compileCjs), so the kernel transforms the text it returns for a require read, behind the
same cache. A regex pre-scan skips a file with no async or await token. Results are
cached by content hash, so a file in an npm package is parsed once per store, not once per
process. The parser loads lazily, on the first file that needs it, as swc.wasm does for
TypeScript. A per-process option turns the transform off.
What it costs, measured 2026-11-04. Under Node on an Apple M1, over the demo apps'
node_modules. Not yet measured inside the kernel.
| Bytes, min / brotli | Vite demo: 94 of 1467 files, 13.4 of 17.5 MB need a parse | Express + Hono: 165 of 593 files, 0.8 of 1.9 MB | |
|---|---|---|---|
| Regex pre-scan | none | 3 ms | under 1 ms |
| meriyah 6 | 119 KB / 29 KB | 270 ms | 45 ms |
| oxc parser (wasm) | 737 KB / 195 KB | 341 ms | 56 ms |
| acorn 8 | 122 KB / 29 KB | 554 ms | 80 ms |
For scale: runtime.js is 446 KB / 126 KB and kernel.js is 314 KB / 82 KB. Those parse
times cover every matching file in the tree, and a process pays only for the files it loads,
once per content hash. A purpose-built scanner (below) could replace the parser.
What the transform needs from a parser. It needs no AST, scope analysis, code generation or source map. It needs these offsets, in the coordinates of the text the kernel serves (after type stripping, which keeps positions):
- each async function-like (declaration, expression, arrow, method, generator), and the
module's top level when it contains a top-level
await: where its body's statements begin, after{and after any directive prologue such as"use strict". For an arrow with an expression body, the start and end of that expression, so it can become{ const $f = $nib.c(); return (…); }; - each
await: the offset of the keyword and the end of its operand, the UnaryExpression after it, so thatawait a + bwraps onlyawait a; - each
yieldandyield*in an async generator: the keyword and the end of its AssignmentExpression operand, or the keyword's end when it has none; - each
catchandfinallyblock whose nearest enclosing function is async: the offset just inside its{. A block inside a nested synchronous function is excluded, because that function can run later in another frame; - each
for await: its body's start and end, and whether the body is a block, plus the statement's end; - which async function each of these belongs to;
- whether
await usingdeclarations occur, and if so each enclosing block's end, since their disposal awaits when the block exits. (Whether the kernel's engine and Node 24.16.0 supportawait usingwithout a flag is unchecked.)
It must get await and async right where they are identifiers (await in a sloppy script,
async as a name or a call), and it must tokenize regex literals, nested template literals
and class bodies correctly. It reports failure instead of returning partial offsets, and the
kernel then serves the file untransformed. Insertions never contain a newline, so line
numbers are unchanged and the kernel can build the column table from the insertions alone.
To beat meriyah, it needs to exceed about 50 MB/s on the Vite tree above, and to stay well
under 29 KB brotli.
Costs accepted for stage 2.
- Columns shift on a line that holds an
awaitoryield, and line numbers do not. The kernel's stack formatting maps frames in transformed files back through a per-file column table. A guest's ownError.prepareStackTracesees the shifted columns. fn.toString()of an async function shows the inserted calls.- The parse time above, on first load of each file content.
Once a worker uses AsyncLocalStorage, stage 1 makes Promise.prototype.then a Proxy. Its
identity then differs from the engine's, a reference to then saved earlier bypasses it, and
Function.prototype.toString gives function () { [native code] } without the name, as V8
does for any callable Proxy. Each then pays a wrapper call: measured 2026-11-04, in the
kernel, on a 1e6 Promise.resolve().then(f) loop with the root frame current (so every call
takes the fast path below), about 3x a bare native then — above the ~1.5x this design hoped for.
The fast path already skips wrapping onFulfilled/onRejected when the captured frame is the
(empty) root frame, and calls the native then with fn.call(thisArg, a, b) rather than
fn.apply/Reflect.apply for the 0–2-argument case then() is almost always called with
(a bare-Proxy microbenchmark outside the kernel showed .call beating .apply by about a
third); neither closed the gap to 1.5x inside the kernel, so the remaining cost looks like the
Proxy mechanism itself (V8's Proxy apply trap dispatch, unavoidable without giving up
Function.prototype.toString's native look). setTimeout/setImmediate/nextTick, which
wrap a single stored callback once per schedule rather than proxying every call, show no
equivalent regression.
Tests. Goldens recorded under Node 24.16.0 for every row of the table above, each in the
stage that delivers it. Also: two interleaved run() chains that each await fetch() and
must not see each other's store, catch and finally after a rejected await,
AsyncResource.bind on an EventEmitter listener, and node:test registering it() after
an await inside an async describe(). That last test, in stage 2, deletes
currentBuildingParent's stack in src/runtime/test_runner/test.ts. The existing tick-order
goldens (loop-*, builtin-stream-ticks, builtin-test-order) must pass unchanged in both
stages.
Still refused. async_hooks.createHook, executionAsyncResource and the rest of the
resource-lifecycle API. They report resource creation and destruction, which this design does
not track.
Open questions.
- Which parser: meriyah, or a purpose-built scanner meeting the contract above. Stage 2 starts by measuring both inside the kernel on the Vite and Hono demos.
- Whether the transform defaults on for
nodeprocesses. - Should
webprocesses (D2) run the transform? They reachnode:async_hooksonly on purpose, but a process cannot know before its modules load that it will. A globalAsyncContextforwebprocesses waits for TC39. unhandledRejectionshould see the frame of thereject()call. The kernel learns of an unhandled rejection from the browser'sunhandledrejectionevent, which carries no frame. Matching Node needs the frame recorded atreject(), which means wrapping thePromiseconstructor's resolving functions. The open question is whether that is worth it, or whether the handler should run in the root frame.- What
enterWithmeans here. Node documents it as entering the store for the rest of the current synchronous execution and keeping it through the async work scheduled after that. In the frame model, it replaces the current frame until something enters another one.
Processes, threads, signals #
Kernel PCB: Process{ pid, ppid, threads: Map<Tid, Thread>, fds, cwd, env, handled: SignalSet };
a Thread is one Worker, via a WorkerFactory interface (nested workers default, host-proxied
fallback). The kernel keeps one runtime worker spawned ahead of need, idle until init, so the
next process or thread starts with runtime.js already evaluated; only a child process or thread
taking it refills it, so a lone top-level process leaves no idle worker. A process has a
runtime (exec({ runtime }), VISION D2): node, the default, installs Node's globals
and loads the entry as Node does; web installs none of them, gives number-returning timers,
and runs only an ES module entry (ERR_NIB_WEB_ENTRY_NOT_ESM otherwise). The kernel stamps the
Proc's runtime on every thread's init, and a spawned child is always node. spawn/fork/exec* target node or a VFS file with a #!/usr/bin/env node shebang;
stdio is pipe (kernel ring) / inherit (shared OpenFile, shared offset) / ignore; fork adds
a transferred IPC MessageChannel (serialization: 'advanced' = structuredClone). worker_threads
spawns a Thread in the same Pid sharing fds and cwd. process.exit runs exit listeners, flushes
stdio, then the never-replying exit syscall terminates the worker (a throw would be catchable).
Signals: SIGKILL or unhandled → terminate; otherwise posted to the thread's port and delivered
as a poll-phase completion; signaled exit reports { code: 128+n, signal }.
Build order #
Each step ends in a real-browser acceptance proof (Playwright), in the C3/C2 discipline. The kernel and runtime pair run in-memory from step 1; OPFS, SW, and WASM arrive when they're needed.
Progress notes: step 1 ✅. Resolver v2 (package.json exports/main, node_modules walk,
self-reference, nearest-type format) landed early with the demo — it is shared by CJS and the
future ESM path. A .js/.ts whose nearest package.json has no type (or that has none) is
syntax-detected as Node 22+ does: import/export or import.meta (es-module-lexer), or code that
fails only inside the CJS wrapper (top-level await, a top-level const require) makes it ESM,
with Node's MODULE_TYPELESS_PACKAGE_JSON warning once per package.json and none under
node_modules or without a package.json. The warning is emitted when the detected module
evaluates (on the next tick), not while the graph loads as in Node, so output from modules that
run before it can come first. The SW routing probe (open question below) PASSED in Chromium 156: a
controlling Service Worker intercepts module imports inside workers — both page-spawned and
nested workers (kernel→runtime nesting is the shape we actually use). ESM (step 6) has
therefore landed before steps 2–5: guest ESM executes via the browser's native module linker
over the SW virtual origin (/~/vfs|builtin|cjs), with specifier rewriting done by the kernel
(es-module-lexer) and CJS↔ESM interop via facade modules sharing require.cache.
Lessons from the ESM slice, recorded as invariants:
- SW before kernel: nested workers inherit their creator's controller, so the SW must be
registered and controlling before the kernel worker is constructed (host
boot()order). - Metadata versioning must be stable under read paths:
MemoryFS.open()used to bump mtime on reads, which with version-keyed virtual URLs caused an infinite refetch loop on cyclic module graphs. Version inputs may only change on real mutations. - Each host page owns its
/~/URLs, and the SW revives its bridges after a stop. Every virtual URL names its host page:/~/@<host>/{vfs,builtin,cjs,http}/…, where<host>is a 16-hex-digit tokenboot()draws once per page. The kernel reads it from its script URL (nib-host) and hands it to each runtime ininit, so every module URL either builds carries it. The SW keepsbridges: Map<token, { port, client }>: two pages on one origin each reach their own runtime, and an app URL opened in a new tab reaches its host while that page is open. The plan was to resolve owners over a client tree (page → kernel worker → runtime workers, learned from worker-script fetches) and keep module URLs token-free. A probe ruled that out: Chromium 156 gives every fetch from a nested dedicated worker an emptyclientId, module imports andfetch()alike, while pages and direct workers carry theirs. Runtime code runs in nested workers, so only the URL can name the owner. App clients still map by client (clientApps, next item), since they are documents and direct workers. A token with no open page answers 502 for http and 404 for modules, both naming the token and both with COEP/CORP. A closed page's port drops messages silently (Chromium 156 has no MessagePortcloseevent), so each relay asks the Clients API about the registering window in parallel, and a gone window retires its token at once; registration prunes the same way. Revival: the SW's top-level script posts{ t: "rebridge" }to every window client, and so does a fetch for a token the SW does not know (at most one broadcast per 3 s). A new version broadcasts again on activate, past that limit: it inherits the old version's pages then, and its top-level broadcast ran while it was installing and controlled none. A host answers with a new MessageChannel under the same token, wired to the same kernel, and the waiting fetch proceeds; it gives up after 3 s. Bridge ids start atDate.now() * 1024, so a kernel's late reply to a stopped SW's request cannot resolve a new request that reused its id. Limits: requests in flight when the browser stops the SW are lost with it, and their late replies are dropped. A request with no token, no prefixed referrer and an unknownclientId(any app client after a restart until its document makes a request, or a worker nested inside a guest app) goes to the network. Only a live SW remembers retired tokens, so after a restart a closed page's URL waits the full 3 s before its 502. - Module-content failures travel as named-code stubs: the
loadModulesyscall has no error slot beyond errno and the SW channel has none at all, so unstrippable TS / unlexable sources are served as stub source that throws the named error (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,ERR_NIB_TS_ENGINE) at evaluation — the failure surfaces at the right place with zero protocol churn. - The ESM lexer must be es-module-lexer's main build, not
/js: the asm.js build misclassifiesimport.metaas a dynamic import in common shapes (latent bug the TS demo exposed). - The TS extension contract is Node's verified one: extensions are mandatory (
require()does NOT probe.ts— verified against Node v24);.tsxfails withERR_UNKNOWN_FILE_EXTENSION; enums fail withERR_UNSUPPORTED_TYPESCRIPT_SYNTAX; node_modules.tsfails withERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. - Guest http servers route through a kernel port table (
Map<port, pid>, next toprocs): the kernel is the process-table owner, so it is the only routing authority for/~/@<host>/http/<port>/;listen/unlistenare syscalls andfinishProcfrees a dead proc's ports and answers its in-flight requests with 502. The SW's bridge id passes through unchanged SW → host → kernel → runtime and back, so no hop keeps an id-remapping map for http. Bodies are buffered whole; every response carries COEP/CORP because the page is COEP require-corp. - Guest-app requests route by client, not by URL prefix (
src/sw/route.ts, one pure function over request facts returning a taggedRoute, plus the SW'sclientPortsMap<clientId, port>). Real apps use absolute paths (/app.js,fetch("/api"),<a href="/about">, a guest Vite's/@vite/client), which escape/~/@<host>/http/<port>/./~/stays the runtime's only reserved namespace: adopting Vite's URL forms (/@fs/,/@id/) would collide with a guest Vite serving them itself. A/~/@<host>/http/<port>/navigation or worker-script request adopts itsresultingClientId; later same-origin requests from a mapped client go to that port, and a worker spawned by a mapped client inherits the port even from an unprefixed script URL. Navigations carry noclientId, so a navigation whose referrer is inside the prefix gets a 307 back into it (method and body survive, and the new document's own navigations stay attributable). The table is in memory; after an idle kill it is re-learned from referrers inside the prefix. Limits: arel=noreferrerorReferrer-Policy: no-referrernavigation escapes to the real server, and after a SW restart a client whose requests all carry unprefixed referrers (a worker, a stylesheet's@import) is not re-learned until its document makes a request. Verified in Chromium: iframe navigations and worker scripts both carryresultingClientId, worker scripts also carry the creator'sclientId, andclients.matchAll({type: "all"})lists dedicated workers, so pruning dead ids on adopt is safe. - Real npm apps arrive as snapshots (
src/node/snapshot.ts, CLIscripts/snapshot.mjs), a binary v3 format (.snap): 4-byte magicNIBS, a little-endian u32 header length, a UTF-8 JSON header{ "version": 3, "files": [[path, size, mtimeMs], ...] }, then every file's raw bytes concatenated in header order — no text/binary split, no base64. App-owned files are kept whatever their extension (static assets); inside node_modules onlypackage.jsonfiles and.js .mjs .cjs .json .ts .mts .ctssources are kept, minus declarations and source maps. Every kept file'smtimeMscomes fromfs.statSync.runtime.mountSnapshot(buffer)parses the header and builds seed entries whosedataareUint8Arrayviews intobuffer(no copies), then transfersbufferto the kernel in the same postMessage, and seeds through the kernel'sseedmessage, whose{ path, data, mtimeMs? }entries let MemoryFS keep the recorded mtime; it refuses any other version (and a bad magic), and v1/v2 are gone because nothing produces them.npm run fixturesinstalls eachdemo/apps/<name>from its lockfile (skipped when the hidden lockfile matches it, less the optionals the host's os/cpu exclude, which npm leaves out of it) and writesdist/fixtures/<name>.snap. The first fixture, Hono 4.13.13 with @hono/node-server 2.1.5, runs unmodified. - Static assets have fixed mtimes. ETags and Last-Modified derive from size and mtime,
so
scripts/fixtures.mjsand the Node oracle (scripts/golden-http.mjs) both set every file underdemo/apps/<name>/publicto2024-01-01T00:00:00.000Z(scripts/fixed-mtime.mjs) before snapshotting or starting the app. The recorded headers are then the same on every clone. - The builtin set is one table.
BUILTIN_EXPORT_NAMES(shared/) is the kernel's resolvable set and the facade's export list; the runtime registry is a factory table typed against its keys, so a resolvable builtin always has a module. A spec checks every listed name is a defined export. Builtins load once per worker. - A request's socket is a facade over the SW bridge, not a TCP socket. One
VirtualSocketper request carriesencrypted: false,remoteAddress127.0.0.1, an ephemeralremotePort, the listening port, and a lifecycle:destroy()before the response ends the exchange with a 502 (ERR_NIB_SOCKET_DESTROYED), and after the response it closes the exchange (one request per connection, no keep-alive). Browsers never give a Service Worker the Host header, so requests without one gethost: localhost:<port>. - A page's WebSocket reaches its guest server as bytes, not messages. A Service Worker
cannot intercept WebSockets, and guests speak the protocol themselves (
ws, socket.io and Vite's HMR server all take the raw socket fromserver.on("upgrade", (req, socket, head))), so the transport is a byte stream and the page is the RFC 6455 client. Relaying messages would mean a second WebSocket implementation inside the runtime and would break any library that reads the socket itself. A connection is one MessagePort: the shim posts{ t: "connect", socket, path, page }to the controlling SW, which finds the page's app (the URL's own/~/@<host>/http/<port>/prefix, elseclientApps, else the page URL, as route.ts re-learns) and transfers the port over that host's bridge; the host transfers it to the kernel, and the kernel, after a port-table lookup, to the runtime owning the port. Bytes (Uint8Array,nullfor end) then flow page ↔ runtime worker and no hop keeps a part in them, so a stopped SW does not affect an open socket. With no listener the kernel writes an HTTP 502 and ends. A terminated worker cannot close its ports and Chromium 156 has no MessagePort close event, so the kernel first sends the page a watch port and posts{ t: "exited" }on it when the owning process ends; the page sees 1006. The runtime parses the request head into anIncomingMessage, keeps the rest ashead, and wraps the port in aPortSocket(aDuplex, half-open as Node's http sockets are) that holds the loop until it closes. With no'upgrade'listener the request goes to'request'as a plain request, as Node v24 does, and the connection closes after the response. Injection: a guest response to a navigation whose type istext/html(or absent with a body that sniffs as HTML) and that is not content-encoded gets<script src="/~/ws-shim.js"></script>as the first child of<head>, or before the first tag when the document has no<head>tag, withcontent-lengthrecomputed; fetches of HTML are left alone. The shim is a classic script, so it runs before module and classic app scripts alike, and the SW serves it from a string bundled intodist/runtime/sw.js. Limits: WebSockets opened from entry points that are not HTML documents (a worker inside the app, anabout:blankorsrcdocframe, a document written bydocument.write) use the native WebSocket and reach the real origin; a guest page whose CSP forbids same-origin scripts blocks the shim; a gzipped document goes unshimmed; HttpOnly cookies never reach the guest, since the shim sendsdocument.cookie. A page that vanishes withoutpagehideleaves its socket open in the guest until the process exits. node:http2is refused, not faked. Its classes exist so imports link andinstanceofis always false (adapters branch onHttp2ServerRequest); every factory throwsERR_NIB_UNSUPPORTEDnaming http2.- The streams subset is Readable, Writable, Duplex and Transform.
node:streamexports the legacyStreamconstructor and aReadablewith flowing and paused modes,read(size),setEncoding, Node's data/end/close order with autoDestroy,destroy(err), thereadable*state getters, async iteration, andReadable.toWeb/fromWeb.IncomingMessageis aReadablewhose whole body is pushed on arrival, so late'data'listeners get it.pipe()/unpipe()write into any writable-shaped emitter with Node's drain, end and unpipe rules. Node's writable state machine is oneWritableStateshared byWritable,Duplex(a Readable that borrows Writable's methods;instanceof Writableholds, as in Node) andTransform;_writableStateand a_readableStateview expose the fields npm code reads (ws readsfinished,errorEmitted,needDrain,endEmitted,length).ServerResponsebuffers untilend():write()always returns true and a streamed body (Hono'sReadableStream) is collected and sent whole. - Events keep Node's shape.
EventEmitteris a function constructor with listeners onthis._events, because npm code callsEventEmitter.call(this)and mixes the prototype into functions (Express'sapp). Class-based builtins (Readable,ServerResponse) use private fields and cannot be.called. - Stream and http callbacks run on the nextTick queue (
src/runtime/ticks.ts, bound to the loop at boot). A tick queued from a microtask schedules its own drain. - One timer model, in the loop (
src/runtime/loop.ts). Timers ({ id, at, seq, delay, repeat, ref }), immediates and poll completions are the loop's own; the guest'ssetTimeout/setInterval/setImmediate(src/runtime/timers.ts) return Node-shapedTimeout/Immediateobjects over those records, soref()/unref()decide liveness andclearTimeouttakes the object or its numeric id.node:timersandnode:timers/promisesreuse the same functions. The loop captures the browser'ssetTimeout/clearTimeoutat module load, beforesrc/runtime/globals.tsreplaces the globals, and arms one native timer for the earliest expiry. Phases run timers → poll → check on snapshots taken at phase entry, one guest callback per MessageChannel task, so microtasks drain between callbacks. Ticks queued inside a callback drain right after it; ticks queued from a microtask drain in the next step, after the microtask queue. The CJS entry runs as a loop callback (runMain), which gives Node's CJSthen(a); nextTick(b)→b, a. processis a per-worker EventEmitter (src/runtime/process.ts), installed as a global before the entry runs in both module systems, passed to the CJS wrapper, and served asnode:process. Lifecycle: when the loop is idle after the entry, it emitsbeforeExit; if that schedules nothing, the next idle step exits, and if it does,beforeExitfires again at the next idle point.exitis emitted once, from idle exit,process.exit()and the fatal path, withprocess.exitCode ?? 0, which an exit listener may still change.process.exit()blocks onAtomics.waitafter the never-replyingexitsyscall, so it does not return into guest code.uncaughtExceptionlisteners take throws from loop callbacks, errors that escape to the worker and the entry itself;unhandledRejectionlisteners take rejections, and without one a rejection goes touncaughtExceptionwith originunhandledRejection, as in Node. With no listener the fatal report is unchanged.- The version strings are a compat target, not a claim to be Node.
process.versionisv24.16.0(the Node the goldens were recorded against),versionsis{ node, nib },release.nameisnode, platform/arch arelinux/x64. Packages branch on these, so they name the Node line whose behavior this runtime matches. - Dynamic
import()is rewritten, never native. A nativeimport()resolves against the importing script's URL: the runtime worker's for CJS (compiled withnew Function) and a/~/vfs/URL for ESM, so relative specifiers fetched the wrong URL, and nothing held the loop while they failed. The kernel replaces eachimportkeyword the lexer marks as dynamic (ESM transform and CJSloadModulealike) withnib.runtime.importFrom(<file>), which resolves the call's real specifier at run time and holds the loop until the import settles. - Express 5 runs unmodified, checked against real Node's answers.
scripts/golden-http.mjsstarts an app under realnodewithNODE_ENV=production, replays a requests file and prints status, content-type, ETag, content-length, x-powered-by and body;tests/express.spec.tsasserts the runtime's answers equal those literals. Matching ETags prove the SHA-1 is right. Real failures drove the order:tty,util,path,zlib, then a resolver bug (anexportsobject of bare conditions,{ types, default }, is the"."target),url,querystring,crypto,net, then three request-time gaps: a bridged request had no Content-Length (a Service Worker never sees it, so it is now restored from the body, as the Fetch standard sets it),req.unpipe()was missing, andstring_decoder. Last, Buffer's statics were not enumerable, so safer-buffer's for-in copy lackedisBuffer. Express'sObject.setPrototypeOf(req, app.request)needed nothing: private fields live on the object, so they survive the swap. Builtins added from the brief rather than a failure:async_hooks,https. - Builtin fidelity is recorded, not asserted by hand. Each new builtin has a
tests/fixtures/guests/builtin-*guest whose Node output is its golden (tests/builtins-golden.spec.ts).util.inspectfollows Node's algorithm (compact=3 line joining at breakLength 80, array column grouping, circular refs, error stacks with extra keys); it cannot observe Promise state or Proxy targets, so a Promise printsPromise { <unknown> }.consoleformats throughutil.formatWithOptions, as Node does for a stream that is not a TTY. The runtime bundle keeps names (esbuildkeepNames), because guests print builtin class names. - Synchronous hashing is a TypeScript stopgap for the residue.
createHash,createHmacandcrypto.hashcover md5, sha1, sha256 and sha512 insrc/runtime/digest.ts, with round constants derived from their definitions (prime roots, sines). The precedence table still routes sync digests to the WASM residue (step 5); it can replacedigest.tsfor speed with no change in results. Randomness andwebcryptoare the browser's. - Refused, not faked.
node:zlib's*Syncforms (they wait for the residue), brotli and zstd in every form (no CompressionStream format),node:https,netsockets and servers,ttystreams, ciphers, signing and key derivation incrypto,path.win32,util.isDeepStrictEqualandasync_hooks.createHookload and throwERR_NIB_UNSUPPORTEDnaming the module (src/runtime/refuse.ts). An unknown digest throws Node's own "Digest method not supported"; one Node has and this runtime lacks (sha384) refuses.node:test'srun()is one function with a partial refusal, not a wholesale one: it validates every option exactly like Node's regardless of what follows, and only after that refusesisolation: 'process'(the default — Node spawnsnode --test-reporter=...children and reads back the v8-serializer reporter's bytes; this kernel has neither a CLI flag parser norv8.Serializer),coverage: true(needs the inspector) andwatch: true(needs aFilesWatcheroverfs.watch's recursive/rename semantics, not implemented).run({ isolation: 'none' })with neither of the other two set actually runs, in this process — the function itself is nevermarkRefused()(src/runtime/refuse.ts), so the surface walker sees a plain function, same as Node's, andcompat/divergences.jsoncarries no entry for it; the three refusals are instead asserted directly against the kernel intests/builtins.spec.ts, since real Node does not throw for any of them (it spawns children, enables coverage, or starts a watcher) and so none of the three can be golden'd (src/runtime/test_runner/runner.ts). - AsyncLocalStorage follows the kernel's own async work, not yet
await. A context (the "frame") is an immutable map from each store to its value;run/exitswap it for one synchronous call, andAsyncResource(bind,runInAsyncScope) andAsyncLocalStorage.bind/snapshotcarry it to a later callback, as before. Stage 1 of Async context is built: it also follows every callback the kernel itself schedules (timers,nextTick,queueMicrotask, syscall completions), every handle the kernel creates (servers, sockets, child processes, watchers, workers, readline), and everyPromise.prototype.thenreaction. It does not yet follow a nativeawaitoryield(stage 2, not started) or anything the engine alone drives without the kernel in the loop. - CJS stack lines are the guest's own. The CJS wrapper opens on the body's first line and
is compiled with an indirect
eval(compileCjsinsrc/runtime/modules.ts), as Node does.new Functionprepended two header lines and shifted every frame by two (loop-stackline.cjsgolden). - Static files match Node through each framework's own middleware. Express's
express.static(send) and @hono/node-server'sserveStaticservepublic/unmodified; the oracle records Last-Modified, Cache-Control, Accept-Ranges and Content-Range too, stores non-text bodies as base64, and sends requests overnode:http, becausefetch()addscache-control: no-cacheto a conditional request and turns Node's 304 into a 200. Gaps in the order they were hit:fs.stat is not a function(send's index probe), thenfs.createReadStream is not a function(send streaming the file), then Hono's import ofhono/utils/mime, which exposed thatexportssubpath patterns never expanded their*(the capture came from the request, not the matched key).Statswith Date fields came from reading send and etag rather than a failure.node:fs(src/runtime/fs.ts) now has: callback formsstat lstat fstat open read close readFile readdir access exists realpath(.native);fs.promises/node:fs/promiseswithaccess open readdir lstat stat realpath readFileand aFileHandle(read readFile stat close); sync formsstatSync lstatSync fstatSync existsSync accessSync openSync readSync closeSync realpathSync(.native) readFileSync writeFileSync readdirSync unlinkSync mkdirSync rmdirSync renameSync;createReadStream/ReadStream,Stats,Direntand Linuxconstants. Callback results arrive throughctx.submit, an async syscall whose reply runs as a poll-phase loop callback, so an immediate queued in one runs before a timer queued with it, as in Node. Each composite op (readFile, realpath) is written once against anIoand runs on the sync mailbox or the async port. Errors follow Node's uvException:ENOENT: no such file or directory, stat 'x', negativeerrno, own keyserrno, code, syscall, path, the Node syscall name (lstat,access,scandir) and the path as the caller wrote it.Statshas Node's own numeric fields in Node's order and prototype Date accessors; fields the VFS does not track are constants (dev1,nlink1,uid/gid0,rdev0,blksize4096,blocks= ceil(size / 512)), and atime, ctime and birthtime report the mtime. File mode stays the kernel's0o100755. The kernel gainedfstatand numbers fds from 3. - A narrower-scoped Service Worker shadows the runtime's. Chrome gives a page to the
registration with the longest matching scope. The old SW probe registered
demo/sw.jswith scope/demo/, so in any browser that had opened it, that worker controlled every demo page, slopbox included: every/~/module fetch fell through to the static server and 404ed, and the supervisor crash-looped ("Failed to fetch dynamically imported module"). Fresh test profiles never saw it. The probe now lives in/demo/sw-probe/with that scope;demo/sw.jsis a tombstone that a browser fetches on its next navigation under/demo/, which unregisters itself and reloads its pages; andboot()throwsServiceWorkerShadowedError, naming the controlling script, whenever a worker other than the runtime's controls the page. "The runtime's" means served fromdist/runtime/sw.js's path or answering{ t: "ping" }with a pong within 1 s, so a site worker thatimportScripts()the runtime's (the fixServiceWorkerConflictErrornames) counts as the runtime's;dist/runtime/sw.jsis built as an IIFE so it leaks no globals into that worker. - Loop goldens are recorded from Node, not written by hand. Each
tests/fixtures/guests/loop-*guest prints one line per event;node scripts/golden.mjs <guest>prints Node's stdout, stderr and exit code, andtests/loop-golden.spec.tsasserts the runtime's output equals that literal. - The harness (
/demo/harness.html) is the UI contract for the host fs facade: its selector map (tree rows bydata-path, tabs with dirty dots,#inline-row,#confirm-row,#entry,#status, status bar) is the page's de-facto public interface for tests and humans. Two behaviors worth carrying forward: tree rendering only descends into expanded dirs, so "row missing" is disambiguated (deletedclears the editor;hidden by collapsepreserves selection) — the same distinction any future fs.watch consumer will need; and per-tab{saved, buffer}state means tab switches never touch the disk, Save writes the active tab, and rename retargets open tabs in place. - slopbox (
examples/slopbox) runs snapshot apps on the public host API only. Switching apps is a page load (?app=<name>), not a re-boot: one page owns one runtime, so a fresh page gets a clean kernel, port table and VFS. Apps choose their own port, so the host gainedruntime.onListening(listener(port, url))andruntime.onUnlistening(listener(port)). We chose an edge pair over oneonPorts(listener(ports))snapshot because every consumer so far reacts to an edge (the supervisor'sstarting → listening, the port chip going stale), and a snapshot would make each of them diff. Ports already listening are replayed in a microtask so late subscribers are correct and can unsubscribe from their first call;waitForPortis now a filter overonListening. Restarting a live app needs to stop it, soRunningProcessgainedkill(): the kernel finishes the process as it does a crash (worker terminated, ports freed, in-flight requests 502) andexitedresolves 143 (128 + SIGTERM). The supervisor (examples/slopbox/supervisor.js) is a state machine with injectedspawnand clock:booting → starting → listening → crashed | exited,crashed → restarting → starting. A non-zero exit restarts after 250 ms, 1 s, then 4 s; more than three crashes inside 30 s entersfailedand stops. A manual Restart from any state kills the current run, clears the crash history and re-execs in the same runtime, whose VFS persists; the killed run's exit is ignored because the supervisor tracks the current run by identity. A gap found while building it:listeningevents do not name the owning process, so a stale run's late listen could be taken for the new run's. (ESM stack frames printing the virtual URL, also found here, is fixed: the transform bindsimport.metaon the guest's first line and appends afile://sourceURL, checked by theloop-esmstack.mjsgolden.) - The host sees the process table.
runtime.onProcesses(listener(table))replays the live table and calls again after every spawn, exit, listen and unlisten; each row is aProcessInfo(pid,ppid,argvas the guest'sprocess.argv,cwd,ports). Processes a guest spawns are listed too, so an IDE can shownpm run dev's server and the port it owns. Unlike the port edges above, this is a snapshot: a processes view re-renders a tree, and diffing edges would rebuild one anyway. The kernel postsprocStarted/procExitedand names the pid onlistening/unlistened, which closes the gap above; the host keeps a mirror so subscribing needs no round trip.runtime.kill(pid, signal)signals any live process, andproc.pidfinds a handle's row. - Buffer's base64 and hex ride Uint8Array's native codecs.
fromBase64/fromHex/toBase64/toHexare feature-detected at module load. The native decoders are strict where Node is lenient: base64 throws on a character outside the one alphabet it is given (so on mixed base64/base64url, junk, or non-ASCII whitespace), on data or extra=after padding, on partial padding ("QQ=") and on a dangling sextet; hex throws on any non-hex character, where Node keeps the pairs before it. Odd hex length is trimmed first, as Node drops the last nibble. Whenever they accept, the answer is Node's, so a throw falls back to the JS decoder. Strict inputs take the fast path, andbuiltin-buffer-encodings.cjsrecords both kinds. Recording it against Node also found thatBuffer.byteLengthfor base64 and hex is a length estimate, not a decode, and that the old hex decoder accepted pairsparseInthalf-reads ("1g"). stream.Transformis Node's writable state machine over the Readable (builtin-stream-transform.cjs): writes queue while one is in flight, a write whose output fills the readable side holds its callback until the next_read,end()runs_final(by default_flushthen EOF),'prefinish','finish'a tick later, and'close'once both sides are done. The Readable gained what that order needs: Node'ssyncflag (a push outside_readwhile flowing emits'data'on the caller's stack),_readbefore taking inread(),maybeReadMoreafter a push, and Node 22's 64 KiB default highWaterMark.- zlib runs on CompressionStream/DecompressionStream (
src/runtime/zlib.ts): gzip, deflate and deflate-raw, async convenience forms calling back in the poll phase and Transform streams, both over one push-fedEngineper codec. Node's shape where the browser differs: Chromium's TypeError carries zlib's own message ("The compressed data was not valid: incorrect header check."), which becomes Node'sErrorwithZ_DATA_ERROR/-3; "Compressed input was truncated." becomes "unexpected end of file",Z_BUF_ERROR/-5; another browser's wording keeps its message underZ_DATA_ERROR. Data after the end is ignored by Node for deflate/deflate-raw and starts another gzip member unless the next byte is 0x00, while the browser errors and drops that write's output, so the end is found again by bisecting the member's input (a prefix is truncated before the end, clean at it, junk after it) and decoding resumes from there; the member's compressed input is retained for that. unzip tells gzip from zlib by the first two bytes. Streams follow Node's ZlibBase: a write pushes its output only if it succeeds, the last write afterend()finishes the codec, and_finalreturns at once, so'finish'precedes'end'. A codec write settles a task after the browser takes it, because the reader drains output in microtasks that can outrun the write's own settlement.level,memLevelandstrategyare accepted and ignored (compressed bytes may differ from Node's, so goldens record round trips and decodes of Node-made bytes, never compressed bytes);dictionary, non-defaultwindowBits,flush(),params()andreset()refuse.crc32is a JS table. body-parser'sContent-Encoding: gzippath runs unmodified, checked against Node through the HTTP oracle (bodyBase64requests). - Terminals are kernel objects; signals follow one rule. A process spawned with pty
stdio gets a kernel
Pty(src/kernel/pty.ts):{ id, cols, rows, termios, lineBuffer, foreground }, with all three fds on it. The line discipline is a pure function from termios, the unfinished line and the input bytes to what fd 0 reads, what the master echoes and which signals to raise. Its scope is the Linux n_tty subset Node programs meet: canonical editing (DEL and BS erase, ^U kills the line, ^W a word, ^D on an empty line is EOF, a line is delivered on NL), ISIG (^C raises SIGINT, ^\ SIGQUIT, both flush the line), ECHO with caret forms, ICRNL on input and ONLCR on output.setRawMode(true)clears ICANON, ECHO, ISIG and ICRNL and keeps ONLCR, as libuv's raw mode does. IEXTEN, IXON and VMIN/VTIME are not modelled. On the host,exec(entry, { pty: { cols, rows } })returns aPtyProcess:pty.writetakes keystrokes as a terminal emulator sends them,pty.resizeraises SIGWINCH when the size changes, andstdoutcarries the master side already output-processed, so it can go straight to an emulator. A pipe-stdio process gainsproc.stdin, a WritableStream whose close is EOF. Signal delivery has one rule, applied in one kernel function: SIGKILL always terminates the worker; a signal the guest listens for (process.on("SIGINT")reports the first and last listener through a syncsigaction) is set in the mailbox's pending-signal word and runs the listeners in the next poll phase; otherwise the signal's default action applies, so SIGINT, SIGTERM and SIGHUP end the process with 128 + n and report the signal, and SIGWINCH is ignored. A guest that never yields never takes a delivered signal, and SIGKILL is the way out.RunningProcess.kill(signal = "SIGTERM")goes through the same rule, andproc.signalsettles withexited, so callers that read the exit number are unchanged. There is no job control. A stop signal (SIGTSTP, SIGTTIN, SIGTTOU without a listener, SIGSTOP always) is followed at once by SIGCONT, because nothing could ever continue the process. That is what readline's ^Z in raw mode needs: it raises SIGTSTP and waits for SIGCONT to take raw mode back. The line discipline drops ^Z in cooked mode, there are no process groups or background jobs, and the kernel never raises SIGTTIN or SIGTTOU.node:readline(createInterface, question, keypress parsing, the line editor) and the tty streams are ported from Node v24.16'slib/;LICENSE-nodecarries Node's MIT notice and lists every derived file. slopbox runs an app whosepackage.jsonsays"slopbox": { "kind": "cli" }in a terminal pane on this API.examples/slopbox/terminal.jsis the only module that touches the emulator (xterm today), so another xterm-compatible emulator is a one-file swap. xterm writes theme and cell-metric CSS into<style>elements it creates, so the slopbox page alone getsstyle-src 'self' 'unsafe-inline'. For a CLI the supervisor entersrunningat spawn anddoneon exit 0, with a Run again button; a non-zero exit is a crash with the server backoff.demo/apps/clackis an unmodified@clack/promptsCLI, and the slopbox tests answer its prompts through the terminal. - A worker_threads Worker is a kernel thread; its messages never touch the kernel.
A kernel
Procownsthreads: Map<Tid, Thread>, and thread 0 is the main one, which takes signals, http requests and connections.new Worker()gets its threadId from the syncnewThreadsyscall (Node sets it before the constructor returns), creates a port pair, and sendsstartThreadwith the child's end andworkerData, natively cloned, with atransferlist the kernel passes on. The kernel starts a runtime worker with the same pid, a snapshot of the parent's env (oroptions.env) and cwd, and its own mailbox. The thread'sexitsyscall,terminateThreadfrom its parent, or its parent's end finishes that thread and the threads it started, and the parent hearsthreadExited.process.exit()in a thread ends the thread with that code; an uncaught error is cloned to the parent's'error'with name, message, stack and own properties (code), then the thread exits 1;terminate()resolves 1, as in Node. A Worker holds its parent's loop until'exit'unlessunref()ed; a port holds its thread's loop while it has a'message'listener, as Node's do, so a thread withparentPort.on("message")lives until it is terminated or closes the port. Message path (src/runtime/ports.ts): a pair is one native MessageChannel plus one shared queue per direction, a growable SharedArrayBuffer (64 KiB, up to 256 MiB) with a spin lock, records[length][seq][bytes], compacted and grown by the writer. A message with no transferList is cloned into the queue bysrc/runtime/clone.ts, V8's structured clone over the types a message can hold (holes, cycles, boxed primitives, errors, lone surrogates), and the native port carries only a doorbell. A message with transferables, or holding something only a native clone carries (a SharedArrayBuffer, a host object), rides the native port whole, with node ports inside swapped for descriptors and rebuilt on arrival. Every message takes its direction's next sequence number, so the receiver delivers both paths in send order, one per loop callback. The thread'sonline, stdout and stderr and uncaught error are records on the same pair; the parent delivers them ahead of the user messages taken in the same batch, as Node drains a worker's internal port before its public one. Node's order between a worker's stdout and its messages depends on timing (a worker that spins after posting lets its later messages overtake its earlier lines), sothread-stdout.cjsasserts each thread's order and the full set. Unclonable values throw Node's DataCloneError (DOMException code 25, "f() {} could not be cloned."). receiveMessageOnPort takes the next record straight from the shared queue, with no event loop: esbuild-wasm's sync API posts to its worker, blocks inAtomics.wait, and reads the reply that way. A record that came natively (with transferables) is visible only after this thread's loop has seen it arrive. Limits:SHARE_ENVrefuses (the kernel holds no live env);stdin: true,resourceLimits,execArgvand heap snapshots are accepted and ignored or refused; ports have noaddEventListeneroronmessage; a port transferred with deliveries still pending loses them; when a thread dies, a port it held never emits'close'on the other side (Chromium has no MessagePort close event); signals reach thread 0 only; a server listening in a worker thread is routed to thread 0. - esbuild-wasm runs its wasm in a child process, so it needed child_process. Read
before building:
lib/main.jsstartsnode bin/esbuild --service=<v>withchild_process.spawnfor the async API, and its sync API starts a worker thread that spawns the same child and answers throughreceiveMessageOnPort.spawn("node", [script])asks the kernel (syncspawn) for a process owned by the calling thread: a child's piped stdout and stderr and its exit come back to that thread (childStdio,childExited),inheritwrites to the parent process's own fd, its piped stdin is fed bychildStdin, and it reads EOF when its spawning thread or process ends, which is how esbuild's unref'd service child ends after its parent. Pipes reach EOF before'exit', as libuv reads a dead child's pipes first. Other commands fail as a missing binary does (async ENOENT,'close'-2);exec,forkand the sync forms refuse. Gaps hit running it, in order: the service child's entrybin/esbuildstarts with a hashbang, which the CJS wrapper made a syntax error, and the snapshot dropped it anyway (no extension), so shebang scripts and.wasmfiles are now kept innode_modules(.nodeaddons still are not); Go'swasm_execreads stdin withfs.read(0, ...), which now reads the pipe directly;os.Openof a directory failed (the kernel refused to open directories, EEXIST for/), and a directory now opens read-only as on Linux; and Node's golden had"use strict"the guest did not, because esbuild found this repo's roottsconfig.jsonabovedemo/apps/esbuild, so the fixture carries its own.node:os(Linux on x64, fixed host facts) andnode:module'screateRequirecame from reading esbuild's imports. - The Vite 7 dev server runs unmodified, on WebAssembly builds of its native
packages.
demo/apps/vitepins vite 7.3.7, @vitejs/plugin-react 5.2.0 (Babel, pure JS) and React 19.2, and its package.jsonoverridesreplaceesbuildwithesbuild-wasm0.27.7 androllupwith@rollup/wasm-node4.65.1. Both are API drop-ins for what the dev server calls: esbuild-wasm has the same JS API (transform,build,context) over its service child, and @rollup/wasm-node has the samerollup/parseAstexport over a wasm-bindgen module it compiles synchronously, which a worker may do. With them no.nodeaddon is needed, so no WASI or napi loader either.server.mjscallscreateServer({ server: { port: 5173, host: "localhost", hmr: true } }),listen()andprintUrls(). In slopbox the frame rendersApp.jsx(Babel adds React Refresh, esbuild the JSX transform), the dep optimizer bundles React with esbuild-wasm intonode_modules/.vitein the VFS, the stylesheet applies, and/@vite/clientlogs[vite] connected.once its WebSocket, through the shim, gets the server's greeting; theindex.htmlit serves equals real Node's. Slopbox ran every app withNODE_ENV=production, which made plugin-react skip Fast Refresh, so an app's"slopbox": { "env" }now replaces that default and this app passes{}. The snapshot is 32.3 MB over 1595 files (v3's binary format, no base64 inflation); esbuild.wasm alone is 13.6 MB of it, and one Buffer holds the whole snapshot, so snapshot.mjs needs no streaming yet. It skipsnode_modules/.vite, a cache from runs under real Node. Gaps in the order they were hit, each found by a failure: vite's ownimport "#module-sync-enabled"(packageimportswere unresolved);node:assert,node:dns,node:perf_hooksandnode:v8, imported at load (found together, as CSP-refused requests for the missing builtin URLs);fsp.rm(the dep optimizer clearing its cache dir);fs.watch(chokidar); and, once the app ran in development, Babel'sCannot redefine property: ALIAS_KEYS, which was a CJS cycle evaluating@babel/types/lib/definitionsa second time into the same exports. Found by reading rather than failing:http.getamong Vite's named imports (a scan of named builtin imports against the facade lists; it now refuses, as https's does), and the rest of the fs write surface, from the brief. Package imports share key and pattern matching with exports; a target that is not./-relative resolves as a bare specifier from the package. Theimportloader now takes Node 24'smodule-synccondition;requiredid not then, because a module-sync target is ESM and require(esm) was unsupported (it does now, with require(esm); see Open questions). CJS cycles: the module object is the cache entry, so a cycle reads the exports so far (including a reassignedmodule.exports) and a module that throws leaves the cache, as in Node; the guest does not get Node's warning for a missing property read inside a cycle. fs writes are composite ops on the Io, as reads are:writeFile/appendFile(encodings, flags, an fd written at its offset),mkdiranswering the first directory it created in the caller's spelling ("out-w"formkdir -p out-w/a/b),rmas lstat then unlink or recursive rmdir (a bare directory is the SystemErrorERR_FS_EISDIR, andforceignores ENOENT),copyFilereporting every step as thecopyfilesyscall withsrc -> dest, andmkdtempwith six random[A-Za-z0-9]characters. node:dns answers from a hosts table:localhostis::1then127.0.0.1, the verbatim order getaddrinfo gives on macOS (recorded) and, with the usual hosts file, on Linux; IP literals answer on the next tick and names in the poll phase, as in Node; any other name is ENOTFOUND, and the resolver refuses. node:assert has Node 24's messages for primitive operands (a strictEqual message, custom or not, gets the comparison appended); deep comparisons refuse, andassert(false)without a message cannot quote its call site. perf_hooks is the worker'sperformance; v8 answersstartupSnapshot.isBuildingSnapshot()with0, which is what Node returns. - fs.watch is inotify's model over the kernel's mutations. A
watchsyscall names an inode, so a watched directory keeps reporting after a rename. MemoryFS reports each mutation as{ type, dir, name, ino, self }: creating an entry (open with create, mkdir) is IN_CREATE,renamein its directory; a write with bytes, or a truncating open of a non-empty file, is IN_MODIFY,change, for the directory and the file's own watchers; unlink is IN_ATTRIB for the file's watchers only, then IN_DELETE and IN_DELETE_SELF (rename); rmdir is IN_DELETE and IN_DELETE_SELF; a rename is IN_MOVED_FROM (and IN_MOVE_SELF) then IN_MOVED_TO. A directory watch reports its own entries only, a file watch reports under the basename it was watched by, and the kernel sends each thread its watches' events once per task, merging a watch's identical consecutive events as inotify merges an unread duplicate. SowriteFileSyncof a new file isrenamethenchange, and a rewrite of an existing one ischangetwice (the truncate, then the write), as Linux reports it. The runtime's FSWatcher holds the loop while ref'd (persistent: falseandunref()release it), emits each event as its own poll-phase callback, honoursencoding: "buffer"andsignal, and emits'close'on the next tick. These are Linux semantics, picked as errno was, and the spec's lines are derived from inotify's rules, not recorded: the oracle runs on macOS, whose FSEvents answer for the same script is different in kind (it replays the directory's earlier creations asrename, reports a rewrite as arenameof the file and achangeof the directory, and nothing for an append). Limits:recursive: truerefuses;fs.watchFileis not implemented (chokidar uses it only withusePolling); events about the subtree of a recursively removed directory are not reported to watches inside it; across two watches the order is per watch within a task, where Linux interleaves them. - Resolution is checked against real npm, replayed offline.
scripts/npm-oracle.mjs record <case>runs npm 11.7 (install --package-lock-only) ontests/fixtures/registry/<case>/package.jsonthrough an in-process proxy for registry.npmjs.org, and stores what it fetched (recording.json.gz) and the lockfile it wrote.replay <case>serves only that recording and requires npm to write the same lockfile byte for byte, which proves the recording is complete. npm runs with--userconfigset to an empty file, because an npmjs auth token in~/.npmrcmakes it ignore--registryfor packuments. Packuments are pruned to the fields resolution and the lockfile read; replay is the proof that the pruning changed nothing. Cases:honoandexpress(the fixtures' dependencies) andconflict, which forces a nested placement (normalize-package-data 3 needs semver 7 under itself while make-dir keeps semver 6 at the top), an automatic peer (postcss-nested's postcss, with its own dependencies flaggedpeer), annpm:alias (semver-six) and a nested override (is-core-modulepinned under normalize-package-data).hono-stalestarts npm from aseed-lock.json(hono's lockfile) under a package.json whose hono range it no longer satisfies, and npm reads only hono's packument.viteis recorded locally only: even pruned it is 13.7 MB gzipped, so its recording is gitignored. Where no real package reaches a case,fake <case>servestests/fixtures/fake/<case>.json's in-memory registry to npm and stores its verdict in the file: the lockfile's text, or its stderr. - npm install is
npm cifrom the lockfile, in the kernel. The kernel owns the filesystem, so it owns the install:runtime.installposts oneinstallmessage, the kernel reads<cwd>/package-lock.jsonfrom MemoryFS and answers oneinstalled.tree.tsloads it with<cwd>/package.jsoninto one tree at that boundary, as arborist does: each lockfile entry is a node, package.json gives the root's edges (the lockfile's root entry does when there is no package.json, as arborist's loadVirtual falls back to it), and each node's dependency maps give its edges, each resolved asrequirewould from the node's folder. v1,linkentries and entries withoutresolved/integritythat are notinBundlerefuse, and the rest trusts theNodetype. With a package.json, resolution comes first (the next entry). Each tarball comes from itsresolvedURL, never a URL rebuilt from the name, because anoverridesalias (esbuild→npm:esbuild-wasm@…) is locked atnode_modules/esbuildwith the aliased tarball. The registry answers withaccess-control-allow-origin: *, so a CORS fetch from the kernel worker passes COEP; the page's CSP must allow it inconnect-src. Its 404s carry no CORS headers, though (checked 2026-11-06), so the page reads a missing package as a failed fetch. A substitution probe for a wasm build the registry does not have (@img/sharp-wasm32-wasi, which astro reaches) is therefore no substitution and a warning, not a failed install. The store is one Cache Storage cache,nib-npm-packages-v1: Cache Storage is per origin, so every page and kernel on the origin shares it, as projects share pnpm's store, without going through the Service Worker. A tarball's package enters it only aftercrypto.subtleconfirms the tarball's SRI hash (a mismatch isEINTEGRITY), keyed by the strongest hash in that SRI, so it is content addressed as npm's cacache is: a stored package serves only a lockfile whoseintegritynames that hash, and any other lockfile fetches and checks again, so a tampered or republished lockfile fails. The store keeps the package unpacked, not the tarball (unpacked.ts): one blob of a JSON listing of[path, size | null]for every file in the tarball, then the bytes of each filekeepInPackagekeeps. A dropped file is listed without bytes because its presence still decides a bin link. So a warm install neither gunzips nor untars: vite's warm install went from 253 ms to about 60 ms, express's from 51 ms to about 26 ms. Nothing reads the tarball after it is unpacked, and holding both would count each package twice against the budget. The cost is size: the six demo apps' 136 packages take 49.4 MB unpacked where their tarballs took 14.5 MB. The next install deletesniw-npm-v1, the tarball cache the earlier layout kept byresolvedURL, and theniw-npm-*caches this layout wrote before the engine's prefix became nib. With no Cache Storage the install fetches everything. Registry metadata is a second cache,nib-npm-meta-v1. A packument answers with no request for thecache-control: max-ageit came with (300 s from registry.npmjs.org, and 300 s when the header is missing), as npm's HTTP cache would; a per-version document never changes and never expires. Past the max-age the packument is fetched withcache: "no-cache", so the browser revalidates with the ETag its own HTTP cache holds and an unchanged packument costs a 304. The kernel cannot send that request itself: the registry sends noaccess-control-expose-headers, so a CORS response'setagreads as null, andIf-None-Matchis not a safelisted request header, so it needs a preflight, which the registry answers 404 (checked in Chromium). When the registry cannot be reached or answers 5xx, the stale packument answers and the report carries a warning. A stored packument with no version the spec accepts is refetched once, fresh or not. A lockfile install reads no metadata at all. An install without a lockfile can first ask a gateway (boot({ npm: { gateways } }), or a guest'sNIB_NPM_GATEWAYS) fordev.nibling.npm.getClosure: every packument the tree reaches, streamed as NDJSON while the gateway walks, each line saying that a name's versions so far hold every candidate for one raw spec (closure.ts). The resolver runs while lines arrive; a spec waits for its line, or the stream's end, and only then loads from the registry. A gateway sendswaitlines while its registry is slow, so a stream is abandoned only after 5 quiet seconds. The request carriessubstitute.ts's rules, so the closure holds the wasm builds that install will probe for, and no gateway needs a copy of the table. The resolver answers a listed spec from the closure;pickManifestreads only a range's own versions and the default tag's, so it picks what the whole packument would. Anything else loads as before. A gateway is trusted for freshness and nothing more: a closure from another registry, or a packument with a tarball off the registry or a version document under another name or version, is ignored, and tarballs are still checked against their integrity. There is no default gateway, and any implementation of the query works (packages/npm-gatewayruns on Node, Deno, Bun and Workers). Cold and in Node, the closure took express from 130 requests to 1, vite from 145 to 1, and astro from 765 to 1, with identical trees. In Chromium, a cold astro install with its tarballs went from 21.7 s to 6.4 s against a self-hosted gateway. The package cache is held under a budget (500 MB, or the index'sbudget) bybudget.ts: an index, one JSON document innib-npm-index-v1, records each package's size and last use. After its write pass an install records every package it used, reconciles the index withcache.keys()(a key it lacks is adopted at its real size, an entry whose key is gone is dropped, an index that does not parse is rebuilt), and deletes the least recently used packages it did not use until the total is under budget. The pass holds the Web Locknib-npm-store, so two kernels' passes take turns: the second sees the first's deletions incache.keys()and prunes only what is still over. Neither deletes a package its own install used. One may delete a package the other is about to read, and that install then fetches it again. Without Web Locks a lost index write costs accuracy, not correctness, since the next pass reconciles. The six demo apps installed from their lockfiles in one origin store 136 packages, 49.4 MB, with a 24 KB index; resolving express from package.json stores 130 metadata documents, 1.6 MB. Metadata has no budget. Every package is fetched (16 at a time), verified and unpacked before anything is written; then one synchronous pass replacesnode_moduleswhole, asnpm ciremoves it first. So a failed install leaves the tree it found, and a second install writes the same tree. The pass keeps the fileskeepInPackagekeeps (src/shared/package-files.ts, whichscripts/snapshot.mjsimports under Node's type stripping), writes npm's hidden lockfile (node_modules/.package-lock.json, the lockfile minus the root and every package not installed), and notifies fsChanged once.os/cpu/libcfollow npm-install-checks againstsrc/shared/platform.ts, whichprocess.platform/archandnode:osread too; the libc isglibc, so a-gnubinding installs where its-musltwin does not. The proof installs each app from its<name>.src.snap(the v3 snapshot withoutnode_modules) and compares hashes with thenpm citree in its<name>.snap: every app matches except vite, and there the difference is only the platform. The snapshots were made on darwin, so they havefseventswhere linux x64 takes@napi-rs/lzma-linux-x64-gnu(whose.nodeaddon the filter drops, leaving itspackage.json), and the two hidden lockfiles list different packages. The tar reader handles ustarprefix, paxpathand GNU long names, strips the first segment (not alwayspackage/), and drops absolute and..paths. Limits: install scripts do not run (report.scriptslists them), andenginesis not checked. An optional package that is skipped (os/cpu/libc) or fails takes arborist'soptionalSetwith it: the region up to the nearest optional edges, plus every dependency nothing outside that region needs (fsevents 1.2.13 takes nan and leaves bindings, which the root needs, asnpm ci --os linuxdoes). A non-optional package for another platform fails the install withEBADPLATFORM, as npm-install-checks'checkPlatformdoes. - npm install resolves what the lockfile does not satisfy, and writes package-lock.json
as npm does.
resolve.tsis arborist's buildIdealTree, ported in its order (the deps queue,#problemEdges, PlaceDep/CanPlaceDep, peer sets, ERESOLVE, overrides, calc-dep-flags) over the same mutableTree, so a lockfile install is the case where it has nothing to place.runtime.install()loads package.json with the lockfile, resolves, and when anything was placed or pruned, or there was no lockfile, writes package-lock.json in the same synchronous pass asnode_modules, after every document and tarball has arrived (report.lockfileis"written"). A lockfile that satisfies package.json resolves nothing, reads no registry document and is not rewritten ("kept", bytes and mtime unchanged). ERESOLVE and registry errors reject withcodebefore anything is written. With neither file the install fails with npm's ownENOENT(Could not read package.json: …); a lockfile without a package.json installs as it is, asnpm ciwould.lockfile-write.tsports shrinkwrap.js: metaFromNode's fields and flags, commit's head (the loaded lockfile's fields, the root's name and version as #buildLegacyLockfile sets them,lockfileVersion: 3,requires: true), and toString through json-stringify-nice (scalars before objects, swKeyOrder, then "en" collation, in every object). A node's package is normalized first as package-json does it:bundledDependenciesrenamed, a name in both dependency maps kept only as optional, a funding string made{ url }, and bins sanitized for a manifest and the root but not for a lockfile entry, as Node's syncNormalize skips that async step. A node resolve.ts made takeslicenseandfundingfromRegistry#lockMeta(the per-version document), which the abbreviated packument lacks; npm reads them from the full one. Indent and newline are package.json's, read as json-parse-even-better-errors does: a one-line package.json gives 2 spaces and LF, a tab or 4-space one its own, CRLF stays. A failed optional dependency stays in the tree inert, and npm locks it as{ "optional": true }with no version (checked withnpm-oracle.mjs fake inert); such an entry reads back as a failed node that the next install retries, so a lockfile that holds one is rewritten each time, as npm's is. The hidden lockfile is the same writer over the installed nodes, with the written lockfile's head when there is one. The proof is byte equality with npm's own lockfile: hono, express and conflict from their recordings, every fake case npm installed (a REPLACE, an override$ref, a bump from a seed lockfile, a peer warning, inert optionals, every copied field in each normalized form, tab, CRLF and one-line package.json), hono-stale from its seed, each recorded lockfile written back unchanged, and in the browser hono, express and hono-stale throughpage.route, whose node_modules equals a lockfile install of the same lockfile. vite, checked locally, matches too. Limits: a v2 lockfile that needs changes is written as v3 with a warning (npm keeps v2 and its legacydependenciestree); a new node's bundled dependencies are not read out of its tarball into the lockfile, as npm's reify does; a rootdirectories.binwithoutbinlinks nothing, where npm globs that directory. - Guest code runs
npm install,npm ciandnpm uninstallthrough a guest npm command and annpmsyscall. The command (src/npm-cli) is guest code: it uses onlynode:builtins and the runtime'snib.runtimehook. build.mjs bundles it into kernel.js as a string, as the WebSocket shim rides sw.js. At boot the kernel writes it to/usr/local/lib/node_modules/npm/bin/npm-cli.js, with a package.json and the link/usr/local/bin/npm.child_process.spawn("npm" | "/usr/local/bin/npm", args)runs that script under node, and so doesruntime.exec("/usr/local/bin/npm", { argv }). The command parses argv as npm 11.7 does: cmd-list.js's aliases (i,add,rm,unand the rest) and its unique-prefix abbreviations, the save flags (-D,-O,-P,--save-peer,-E,--no-save),-v/--version, and npm's localPrefix (the nearest directory up with a package.json or node_modules). It sends oneInstallRequest(installwith specs to add,uninstallwith names to remove, orci) on the asyncnpmsyscall. The kernel answers when the install finishes and serves every other process while it waits. The call succeeds with anInstallOutcome, so npm's own failure (ERESOLVE, ETARGET, E404, EUSAGE) travels as data with its code, errno, path and pkgid.runtime.install()is the same request with nothing to add, through the samerunInstall.install.tssplits intoplan, which decides package.json, the tree and the lockfile before anything is written, and the write pass.save.tsports add-rm-pkg-deps.js, reify's[_saveIdealTree]and package-json's update and save. Specs go into the root package (normalized as arborist reads it) before the tree is built. Each added spec is then rewritten from the version that answered it:save-prefixon the version, or the range as given when^would widen it, and an alias stays an alias. Every dependency map is sorted, and the file keeps its indent and newline. The lockfile root takes the edited package, so-Oleaves npm'sdependenciescopy there that package.json drops.npm cirefuses a missing lockfile, and refuses a lockfile whose versions differ from the resolved tree's, as validate-lockfile.js does. It writes neither file. The command prints reify-output.js's summary from the report'sdiff, which is arborist's diff by location against the hidden lockfile node_modules held. It prints warnings and errors in display.js's format, with error-message.js's lines for each code, and exits as npm does: an error's errno when it has one (ENOENT is 254), else 1. The proof isnpm-oracle.mjs cli. Real npm runs each case of tests/fixtures/npm-cli.json over a fake registry that serves real stored-gzip tarballs, so the integrity npm locks does not depend on zlib. The fixture stores the exit status, stdout and stderr (timing and temp paths replaced), package.json, package-lock.json and node_modules. In Node, each case's argv goes through the same parser and the kernel's plan, and the files must match byte for byte. In the browser, every case runs through the guest command and must match on all six. A guest script spawnsnpm installand then requires what it installed. While one process's install waits on a held registry answer, another process's timers and HTTP server keep answering. Limits: only registry specs install (no git, file, URL or tarball arguments). Nothing is audited, and no funding count is printed, as with--no-audit --no-fund. Install scripts do not run. An explicit request the lockfile already satisfies is kept, where npm refetches it and takes the newest matching version. Without a hidden lockfile, node_modules counts as empty for the summary. Unknown-command suggestions ("Did you mean") are not printed. Commands other than install, ci, uninstall, run, exec and the lifecycle commands fail withENOTSUP. npm run,npm execand npx run without a shell. A script is a string forsh -c, and the runtime has no sh.src/npm-cli/script.tsreads the subset that needs none. It parses the script, with npm's arguments appended in promise-spawn's quoting, into aScript: eithersteps, each aSimpleCommand(assignments, argv, the line it starts on) with theWhenthat gates it (;always,&&on 0,||on nonzero), orrefusedwith theConstructit met and the text near it. Quoting, backslashes,NAME=valueprefixes, comments, line continuations, and$NAMEand${NAME}(field split outside double quotes) follow POSIX sh. Pipes, redirects, background jobs, subshells, command and arithmetic substitution, other parameter expansions, globs, tilde expansion and reserved words refuse. Each command then runs as sh would find it:nodeitself, a path, or a file on the script's PATH, always under node. A file whose#!names another interpreter refuses. A name found nowhere prints dash'ssh: <line>: <name>: not foundand is status 127, andenvalone prints the environment.run.tsports run-script.js: pre and post scripts,restart's default,start'snode server.js,--if-present,--ignore-scripts, the missing-script error with did-you-mean.js's suggestions (cmd-list.js's descriptions are incommand.ts), and the script list.run-script.tsports run-script-pkg.js: the banner, make-spawn-args.js's environment (package-envs.js,npm_lifecycle_*,npm_package_json, PATH with everynode_modules/.binfrom the package dir to the root in front, then node-gyp-bin), npm's own set-envs.js variables, and the exit. The child's status becomes npm's with nothing printed, as npm suppresses a shellout's error. SIGINT and SIGTERM to npm reach the child, and a child killed by a signal kills npm with it.exec.tsports libnpmexec: a bin in anode_modules/.binup from the project, then/usr/local/bin, else the package installs into~/.npm/_npx/<sha512 of the sorted specs, 16 hex>through thenpmsyscall, with_npx.packageswritten into that package.json. Without--yes, npm's "will be installed" warning prints off a tty, and on a tty npm's prompt asks.--no-yesrefuses. Which packages are missing is the asyncnpmLookupsyscall (src/kernel/npm/exec.ts, missingFromTree): a version or range takes what the project has, and in the npx cache only*or that exact version. A tag, or a bare name in the npx cache, asks the registry and takes what is installed at the version it picks. npx is the same script seeded again asbin/npx-cli.js, linked from/usr/local/bin/npx, and it rewrites its argv as npx-cli.js does. The proof is thescriptscases ofnpm-oracle.mjs cli, which real npm runs with scripts on: run, an alias, test with pre and post, start with args, a missing script and a near miss, a script exiting 3, the npm_* environment, a chain of&&,||,;and a prefix, the list, a dependency's bin,npm execof a local bin and with-c, and npx of a local bin, with--yes(run twice, the second from the cache), without it, and with-p. The guest must match each case's status, stdout, stderr and npx cache. The Node-side spec holds the parser to literal command lists. Limits: no shell features past the subset. A missing command prints dash's text (Linux), not the macOS sh's. The script list is never coloured.npm execof the project's own bin runs it in place, where npm installs the project into the npx cache. Only the top-level node_modules is searched for an installed package. The npx cache's registry lookup is the kernel's, which keeps a packument for its max-age, where npm refetches it (prefer-online).npm execwith nothing to run refuses, where npm opens a shell. Workspaces,--script-shelland--silentare not modelled.- A bundled dependency is a package with no tarball of its own. An
inBundlelockfile entry has noresolvedorintegrity, because its files arrive in its bundler's tarball, so it loads as aBundledNodethat the store'sfetchTarballcannot take. Its bundler is arborist'sgetBundler: the nearest package above it with a tarball of its own. It installs with that bundler and is skipped with it, and is not counted inreport.packages, as npm's "added" leaves it out. When the bundler's tarball has nopackage.jsonat its location, npm does not fail: reify marks itbundleMissingand extracts it byname@version, and the hidden lockfile records it withoutinBundle. The installer does the same, taking the tarball and its SRI from the registry's packument (registry.ts), and fails withE404orETARGETonly when the registry has neither.libcis checked only where the lockfile records it: npm 11.7 writes nolibcfor the@napi-rs/lzmabindings, because the abbreviated packument omits it, and thennpm ciinstalls both the-gnuand-muslbinding on any linux. The proof installs@isaacs/testing-bundledepsfrom the lockfile npm wrote and from one whose bundled entry its tarball lacks, and compares every file, hidden lockfile included, with whatnpm ciwrote from each. Limit: npm drops a package that a bundled one shadows when only the bundle needed it (reify's#pruneBundledMetadeps); the installer writes every lockfile entry. - slopbox installs each app instead of mounting its
node_modules. An app with a lockfile ships as<name>.src.snapand installs throughruntime.install(), so the catalog'sfilesandbytesdescribe the download,packagesthe lockfile, and a second visit takes every tarball from the store. An app without a lockfile has no dependencies and mounts its.snap. The full.snapstays as the install spec's oracle and for the older demo pages. The catalog shows the store's size (packageStoreUsage()) with a Clear package cache button (clearPackageStore()), the run bar has Cold reload (clear, then reload), and the console notes how long the page took to reach the app's first answer, so cold and warm visits can be compared. - Directories are kept across page loads under a caller's key.
runtime.persist({ key, paths })restores whatkeyholds into the FS and returns{ restored, save() };save()writes the paths back under the same key. One call binds the key to its paths, so a save cannot land under a key it was not restored from. The kernel does both halves (kernel/kept-dirs.ts): it walks the paths without following links and puts one blob per key in Cache Storage,nib-kept-dirs-v1(a JSON header of dirs, files and links with their mtimes, then the file bytes), and a restore replaces each saved path whole, links and mtimes included. No file byte reaches the page. Kept dirs have their own budget, 100 MB innib-kept-dirs-index-v1throughbudget.ts, not the package store's: they are a project's derived output, cheap to rebuild and made anew for every key, while a package costs a download and is shared by every project, so one budget would let a stream of new keys evict packages. Staleness is the tool's call. slopbox keys an app's dirs by its name and the SHA-256 of its package-lock.json, and an app opts in with"slopbox": { "persist": [...] }, whichscripts/fixtures.mjscarries into the catalog. The vite app keepsnode_modules/.vite, restored after the install and beforeserver.mjsstarts, and saved a second after its frame first loads. Vite checks_metadata.jsonagainst its own lockfile and config hashes, so a stale restore costs only the optimize it would have run anyway (the slopbox spec changesNODE_ENV, which is in the config hash, and sees "Re-optimizing dependencies because vite config has changed"). A warm visit skips esbuild-wasm's scan and bundle, about 1 s: page load to a rendered Vite app went from about 1.7 s to about 1.2 s. The rest is mostly Vite's first transform of/src/main.jsx. slopbox does not save onpagehide: the navigation ends the kernel before the Cache Storage write lands. Cold reload clears the kept dirs with the package store (clearPersisted()). - Symlinks are walked, as Linux walks them. A MemoryFS entry is a file (bytes), a
directory (children) or a symlink (its target string, verbatim), and only the root
has no parent. No path is normalized before the walk: every component but the last
follows a link, the last one too when the call follows, a relative target resolves
from the link's directory,
..after a followed link leaves the directory the link points into, and the 41st link in one resolution isELOOP.stat,open(a create through a dangling link creates its target;O_EXCLdoes not follow),readdirand a recursivemkdirfollow;lstat,readlink,unlink,rename,rmdirandrmact on the link. Therealpathsyscall is realpath(3), sorealpathSync.nativeandfs.promises.realpathresolve..physically, whilerealpathSyncand the callbackrealpathare Node's JS walk, which resolves the path lexically first. The resolver answers every module with its real path, as Node does without--preserve-symlinks, so a package reached through two links loads once,__filenameandimport.meta.urlname the target, andnode <link>runs it.process.chdirlands on the physical directory. A watch names the inode the path resolves to, so watching a link watches its target. The goldens are recorded on macOS, so a link's permission bits (umask-dependent there,0777on Linux), the hop limit (32 there) and an empty target (allowed there,ENOENTon Linux) are pinned by kernel tests instead. Limits: norequire.mainorrequire.cacheto compare, nolchown/lutimes/link, and the host seed andwriteTreewrite files only. .binlinks are bin-links' rules, read from npm's source. After the tree is written, each installed package's lockfilebinlinks from the.binof thenode_modulesthat holds it, sonode_modules/@babel/parserlinksnode_modules/.bin/parser→../@babel/parser/bin/babel-parser.js.lockfile.tssanitizes the map as npm-normalize-package-bin does (a name keeps its basename, a path resolves inside the package, an emptied entry drops) instead of refusing it, becausenpm cireadsbinfrom the lockfile and links what that leaves. A contested name is not settled by which package is a direct dependency: arborist 9's rebuild links inencollation order of the path, and bin-links 6 lets the first claim win, even when that package lacks the file and so links nothing. bin-links' fixBin also drops the\rfrom a#!…\r\nfirst line; its chmod has nothing to do, because MemoryFS keeps no modes (every file stats0755). The proof compares every fixture's links with the onesnpm cimade on disk, checks a contested lockfile against whatnpm cilinked from it, and runs esbuild through its link. Limits: a bin whose filekeepInPackagedrops (one with no shebang and no runtime extension) links but dangles, and the hidden lockfile copies eachbinas locked, where npm writes the sanitized map.- The slopbox Shell tab is bashkit on the app's own VFS. A bakeoff on a 125-case
corpus weighted by how often agents use each feature, scored against GNU bash 5.2,
picked
@everruns/bashkit-wasmoverjust-bash(98.5% vs. 93.8%) andwasi-sh(75.6%); its live commands stream through pipes, which a WASI-style run-to-completion shell can't do. 7.3 MB of wasm (2.1 MB brotli), loaded only when the tab is first chosen (examples/slopbox/shell.js→dist/demo/shell.js+dist/demo/bashkit.wasm), not at app boot.node,npmandnpxare one table of launchers (examples/slopbox/launchers.js):noderesolves a file,-e/-p(written to a.nib-eval-<n>.{cjs,mjs}the caller deletes once the process exits),-v, a barenode(REPL refusal) or an unknown flag;npmandnpxare fixed entries at/usr/local/bin/{npm,npx}, argv passed through unchanged, because both are now the guest npm (§guest npm) — its behavior and limits are the shell's:npm run/start/test/execand npx with no shell under them, a script that needs one failsENOTSUP. (Follow-up: this bash could serve as that shell, dropping run-script's own no-shell restriction.) Before a line runs,bash.analyzedecides foreground: one command, no redirects, no command substitution, and that command isnode,npmornpxgets a pty (runtime.exec(entry, { pty }), keys go straight to it, SIGINT comes from the pty's own line discipline); anything else runs on pipes, pumped into bash's stdout/stderr sinks. The session is one tagged state —idle(the line editor owns keys),running(a line is executing; keys queue as typeahead for the next prompt, and ^C flushes them, kills every process the line started and callsbash.cancel()),foreground(the pty process owns keys) — never three booleans.bash.executestarts$?at 0 on every call rather than carrying it from the line before, so the shell tracks the last line's status itself and seeds it back in as a leading(exit <n>);. A syntax error is therefore reported frombash.analyzeon the line as typed (the prefix would shift every column on line 1), and the line never runs. Limits: no git, no python; ^C on a non-foreground (piped) line takes effect only between the commands in it, not mid-command; the eval file is briefly visible in the cwd while the process runs; and a server started from the shell while the app itself is stillstartingcan be taken for the app's own port, becauselisteningevents don't name their process.
| Step | Deliverable | Acceptance proof |
|---|---|---|
| 1 ✅ | static serve (COOP/COEP), kernel worker + runtime worker, in-memory store, CJS require of local files | page boots /app/main.cjs, requires ./greet, prints Hello, Ada, exits 0; missing module → MODULE_NOT_FOUND; boot without COI → named IsolationError |
| 2 (partial) | full fs syscall set on in-memory store (reads and writes ✅, symlinks ✅; hard links, utimes, chmod pending); spawn/fork/worker IPC (worker_threads ✅, spawn of node ✅) | parent spawns child; child writes file; parent reads it synchronously after IPC; append interleaving; SIGKILL leaves mount usable |
| 3 (partial) | event loop conformance | golden traces recorded from Node: timer/check/poll/nextTick order, ticks-before-microtasks in CJS, beforeExit/exit, unref ✅ (real clock). async fs completions in poll ✅. Remaining: VirtualClock, pending and close phases |
| 4 | OPFS journal + snapshot persistence | reload reads modified content; injected crash before/after commit gives old/new version, never torn; ambiguous commit resolved by receipt; second mount cannot silently write |
| 5 | WASM residue (hash, zlib sync) | ABI conformance vectors; trap poisons and re-instantiates; ownership pairs freed exactly once |
| 6 | Service Worker + ESM | ESM cycles, live bindings, TLA, CJS↔ESM interop, exports conditions, import.meta.url is file://…, SW revival re-attaches |
| 7 | streams, remaining builtins (crypto adapters, zlib async via CompressionStream, util, buffer, events bridge) | backpressure, cancellation, EventEmitter error rules, encoding vectors |
| 8 (partial) | signals ✅ (one process; no process groups), spawnSync, watch (fs.watch ✅, inotify semantics; not recursive, no watchFile) | SIGINT handler vs terminate; spawnSync returns {status, stdout, stderr}; watch events from kernel mutations |
Tradeoffs accepted #
- Mandatory cross-origin isolation and Worker-only user JS, in exchange for honest sync
fsandrequirewithout main-thread blocking or source-level async conversion. - Event-sourced journal + replay in exchange for O(1) atomic rename, symlinks, hardlinks and
exact
fs.watch, none of which OPFS offers. A 25 ms loss window on unflushed journals. - A Service Worker virtual origin in exchange for native ESM linking (cycles, live bindings, TLA)
with stable virtual URLs; a
file://sourceURL keeps stack traces on the guest's paths. - Trusted-code execution: Workers are not a security sandbox; embedders run their own code.
- More browser tasks (one callback per task) in exchange for native microtask checkpoints between Node callbacks.
- A small WASM residue (~25 functions) in exchange for browser-first behavior: browser-first is a precedence rule, not a name, so facilities the browser can express stay out of WASM.
- CSP requires
'unsafe-eval'(CJS vianew Function) and'wasm-unsafe-eval'.
Alternatives considered #
- Per-process volume ownership (C3's shape). Child gets a snapshot at boot and shares nothing mutable with the parent — radically simpler, and its first milestone needs no COI, no OPFS, no WASM. Rejected as the base: fork in Node shares the filesystem; snapshot-fork makes parent/child writes invisible to each other, so any multi-process program silently diverges from Node. Grafted instead: its milestone-first sequencing and its network:false option.
- Storage worker with IDB-backed chunk store (C2's shape). Strong persistence transaction design (flush-before-reference, receipts) but resolves modules through the same storage worker and defers zlib entirely. Grafted: its receipt rule, lost-wakeup fix, one-way dependency rule, and step-4 acceptance proofs.
- All files preloaded into each process. Loses cross-process reconciliation and runtime writes; dynamic requires become snapshot-consistency problems.
- Asyncify / source-level async conversion of user code. Hides the COI requirement but leaks asynchrony into stack behavior, callback timing, dynamic code and addon contracts.
- A WASM POSIX kernel with host imports. The browser still owns bytes and persistence; it adds a syscall ABI and duplicated memory without supplying a capability missing from JS.
- Transpile ESM into CJS. Avoids the SW but makes live bindings, cycles and TLA our problem, against the browser engine instead of with it.
Open questions and risks #
- Supported browser matrix: OPFS sync handles, COI, SW-controlled worker module fetches. Which minimum versions, and is "no non-isolated mode" acceptable to the owner? (SW interception of worker module imports — direct AND nested — verified in Chromium 156.)
- Service Worker fetches from nested module workers must pass through the controlling SW on every supported browser — step 6 needs a routing probe before committing. (Answered for Chromium: PASS, direct and nested. Other browsers still need the probe.)
- Import attributes vs MIME: JSON modules are currently served attribute-less (wrapped as
export default);with { type: "json" }needs either transform-time stripping or rejection. requireof an ES module works as in Node v24.16 (sketches/require-esm.md), andprocess.features.require_moduleistrue. A required graph is evaluated synchronously in the runtime worker (src/runtime/esm-sync.ts) from the kernel's lexer-only function form of each module (src/kernel/function-form.ts);requiretakes themodule-synccondition. One registry per worker holds both loaders' instances: every served ES module imports its own URL and callsesmBegin/esmEnd, so arequireafterimportgets the native namespace, animport()afterrequiregets the registry's, and arequireracing an in-flightimport()throwsERR_REQUIRE_ESM_RACE_CONDITION. rolldown's wasm32-wasi binding and the Vite 8 dev server run on it. Known divergences:- A named import of a missing export reads
undefinedinstead of failing to link with aSyntaxError. - A JSON import without
with { type: "json" }is accepted. - A CommonJS dependency's
reexportsare not followed, so names it re-exports from another module are not named imports. - An anonymous
export default functionloses hoisting. exportanddefaultsplit across lines shift the rest of the file by one line in stack traces.- The top-level-await refusal is recognised by V8's compile error text, so a V8 that rewords it breaks the check.
- The kernel caches a module's function form by the importer's own stat version, so a dependency's change of format or of its CommonJS export names is not seen until the importer changes.
- The root module costs one more sync round trip than Node's one load.
- A named import read inside an ESM cycle, before its exporter evaluates, is
undefinedrather than a TDZReferenceError. - A named import is a snapshot refreshed as the graph evaluates, so an export its module
reassigns afterwards reads stale through it (the namespace object stays live). The owner chose
this over the 37x-to-100x slower
withscope. - A static native
importafterrequireof the same module evaluates a second instance, and so does arequireof a module a static import graph has fetched but not begun evaluating (Node evaluates that instance synchronously). Per-worker module URLs (sketch step 6) would fix both; deferred until a real case needs it. - A natively imported module that threw is rethrown only when it was the root of the
import()that rejected; a dependency that threw, or a module suspended at a top-levelawait, reads as still evaluating, sorequirethrowsERR_REQUIRE_CYCLE_MODULE. - The namespace
requirebuilds is a Proxy:util.types.isModuleNamespaceObjectisfalse, frames through it are namedevalandProxy., and a strict-mode write throws V8's Proxy message. - Script-only syntax in a required module's body (HTML-like comments, for one) runs instead of failing.
- Node processes have a
self(the global object, writable), which Node does not.
- A named import of a missing export reads
- rolldown's wasm32-wasi binding starts about twice as many WASI thread workers here as under Node:
6 for a Vite 8 dev session that Node serves with 3, with 8 CPUs reported on both sides
(
navigator.hardwareConcurrency,os.availableParallelism()). Each thread loadsnode:wasiand prints its ExperimentalWarning once, as in Node, so the dev log shows 7 warnings to Node's 4. Unverified guess: emnapi's pool (reuseWorker: true) makes a new worker when none is idle, and ours start or come back idle more slowly. Each extra worker costs memory. Under Node,NAPI_RS_FORCE_WASI=1still loads a native binding that is installed, so a comparison needs the native binding removed. - OPFS quota/eviction: does persistent-storage request +
storage.estimatemeet persistence expectations, or is an export/backup API needed? - Blob-URL ESM (C3's approach) was rejected for
fetch(import.meta.url)breakage and Worker-relative resolution fragility — but if the SW proves flaky in step 6's probe, this is the fallback to re-examine, accepting its known breakages. - How much sync-RPC latency can representative packages (e.g. recursive reads) tolerate before the mailbox needs batching or a larger arena?
Next implementation step #
Step 1: scaffold scripts/serve.ts (COOP/COEP headers), src/kernel/ with the in-memory store and
the syscall table, src/runtime/ with bootstrap + minimal loop, and one Playwright test proving
Hello, Ada + exit 0 in a real Chromium page.