Monorepo for the nibling.run platform
core packages kernel DESIGN.md
132 kB · 1,678 lines
Markdown
at main

@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 await between 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.

  1. Callbacks the kernel schedules capture when scheduled. These are timers, immediates, nextTick, queueMicrotask, and the callbacks of syscall completions (fs and the other callback APIs). The kernel implements all of them. queueMicrotask is 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 of AsyncContextFrame. The node:test timer wrap that 3415f68 removed did replace a global, and broke util.promisify(setTimeout).
  2. 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 http rows above.
  3. Promise reactions capture at then(). Promise.prototype.then becomes a Proxy over the native then whose apply wraps both callbacks in the frame current at the call. catch, finally and the combinators call then, so they inherit it. The Proxy goes on the first time a worker leaves the root frame (run() or enterWith()). Before that every frame is the root, so every then() would capture the root anyway, and a worker that never uses AsyncLocalStorage keeps the engine's own then.

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 E becomes $nib.r($f, await E), where r enters the boxed frame and returns its second argument;
  • each yield E in an async generator becomes $nib.y($f, yield E), where y stores the current frame in the box, because Node resumes a generator in the frame of the next() call that resumed it;
  • each catch and finally block whose nearest enclosing function is this one starts with $nib.r($f);, because a rejected await resumes by throwing and r never runs;
  • a for await body 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 that await a + b wraps only await a;
  • each yield and yield* in an async generator: the keyword and the end of its AssignmentExpression operand, or the keyword's end when it has none;
  • each catch and finally block 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 using declarations 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 support await using without 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 await or yield, 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 own Error.prepareStackTrace sees 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 node processes.
  • Should web processes (D2) run the transform? They reach node:async_hooks only on purpose, but a process cannot know before its modules load that it will. A global AsyncContext for web processes waits for TC39.
  • unhandledRejection should see the frame of the reject() call. The kernel learns of an unhandled rejection from the browser's unhandledrejection event, which carries no frame. Matching Node needs the frame recorded at reject(), which means wrapping the Promise constructor's resolving functions. The open question is whether that is worth it, or whether the handler should run in the root frame.
  • What enterWith means 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 token boot() draws once per page. The kernel reads it from its script URL (nib-host) and hands it to each runtime in init, so every module URL either builds carries it. The SW keeps bridges: 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 empty clientId, module imports and fetch() 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 MessagePort close event), 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 at Date.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 unknown clientId (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 loadModule syscall 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 misclassifies import.meta as 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); .tsx fails with ERR_UNKNOWN_FILE_EXTENSION; enums fail with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX; node_modules .ts fails with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.
  • Guest http servers route through a kernel port table (Map<port, pid>, next to procs): the kernel is the process-table owner, so it is the only routing authority for /~/@<host>/http/<port>/; listen/unlisten are syscalls and finishProc frees 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 tagged Route, plus the SW's clientPorts Map<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 its resultingClientId; 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 no clientId, 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: a rel=noreferrer or Referrer-Policy: no-referrer navigation 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 carry resultingClientId, worker scripts also carry the creator's clientId, and clients.matchAll({type: "all"}) lists dedicated workers, so pruning dead ids on adopt is safe.
  • Real npm apps arrive as snapshots (src/node/snapshot.ts, CLI scripts/snapshot.mjs), a binary v3 format (.snap): 4-byte magic NIBS, 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 only package.json files and .js .mjs .cjs .json .ts .mts .cts sources are kept, minus declarations and source maps. Every kept file's mtimeMs comes from fs.statSync. runtime.mountSnapshot(buffer) parses the header and builds seed entries whose data are Uint8Array views into buffer (no copies), then transfers buffer to the kernel in the same postMessage, and seeds through the kernel's seed message, 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 fixtures installs each demo/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 writes dist/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.mjs and the Node oracle (scripts/golden-http.mjs) both set every file under demo/apps/<name>/public to 2024-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 VirtualSocket per request carries encrypted: false, remoteAddress 127.0.0.1, an ephemeral remotePort, 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 get host: 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 from server.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, else clientApps, 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, null for 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 an IncomingMessage, keeps the rest as head, and wraps the port in a PortSocket (a Duplex, 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 is text/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, with content-length recomputed; 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 into dist/runtime/sw.js. Limits: WebSockets opened from entry points that are not HTML documents (a worker inside the app, an about:blank or srcdoc frame, a document written by document.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 sends document.cookie. A page that vanishes without pagehide leaves its socket open in the guest until the process exits.
  • node:http2 is refused, not faked. Its classes exist so imports link and instanceof is always false (adapters branch on Http2ServerRequest); every factory throws ERR_NIB_UNSUPPORTED naming http2.
  • The streams subset is Readable, Writable, Duplex and Transform. node:stream exports the legacy Stream constructor and a Readable with flowing and paused modes, read(size), setEncoding, Node's data/end/close order with autoDestroy, destroy(err), the readable* state getters, async iteration, and Readable.toWeb/fromWeb. IncomingMessage is a Readable whose 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 one WritableState shared by Writable, Duplex (a Readable that borrows Writable's methods; instanceof Writable holds, as in Node) and Transform; _writableState and a _readableState view expose the fields npm code reads (ws reads finished, errorEmitted, needDrain, endEmitted, length). ServerResponse buffers until end(): write() always returns true and a streamed body (Hono's ReadableStream) is collected and sent whole.
  • Events keep Node's shape. EventEmitter is a function constructor with listeners on this._events, because npm code calls EventEmitter.call(this) and mixes the prototype into functions (Express's app). 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's setTimeout/setInterval/setImmediate (src/runtime/timers.ts) return Node-shaped Timeout/Immediate objects over those records, so ref()/unref() decide liveness and clearTimeout takes the object or its numeric id. node:timers and node:timers/promises reuse the same functions. The loop captures the browser's setTimeout/clearTimeout at module load, before src/runtime/globals.ts replaces 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 CJS then(a); nextTick(b) → b, a.
  • process is 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 as node:process. Lifecycle: when the loop is idle after the entry, it emits beforeExit; if that schedules nothing, the next idle step exits, and if it does, beforeExit fires again at the next idle point. exit is emitted once, from idle exit, process.exit() and the fatal path, with process.exitCode ?? 0, which an exit listener may still change. process.exit() blocks on Atomics.wait after the never-replying exit syscall, so it does not return into guest code. uncaughtException listeners take throws from loop callbacks, errors that escape to the worker and the entry itself; unhandledRejection listeners take rejections, and without one a rejection goes to uncaughtException with origin unhandledRejection, 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.version is v24.16.0 (the Node the goldens were recorded against), versions is { node, nib }, release.name is node, platform/arch are linux/x64. Packages branch on these, so they name the Node line whose behavior this runtime matches.
  • Dynamic import() is rewritten, never native. A native import() resolves against the importing script's URL: the runtime worker's for CJS (compiled with new 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 each import keyword the lexer marks as dynamic (ESM transform and CJS loadModule alike) with nib.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.mjs starts an app under real node with NODE_ENV=production, replays a requests file and prints status, content-type, ETag, content-length, x-powered-by and body; tests/express.spec.ts asserts 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 (an exports object 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, and string_decoder. Last, Buffer's statics were not enumerable, so safer-buffer's for-in copy lacked isBuffer. Express's Object.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.inspect follows 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 prints Promise { <unknown> }. console formats through util.formatWithOptions, as Node does for a stream that is not a TTY. The runtime bundle keeps names (esbuild keepNames), because guests print builtin class names.
  • Synchronous hashing is a TypeScript stopgap for the residue. createHash, createHmac and crypto.hash cover md5, sha1, sha256 and sha512 in src/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 replace digest.ts for speed with no change in results. Randomness and webcrypto are the browser's.
  • Refused, not faked. node:zlib's *Sync forms (they wait for the residue), brotli and zstd in every form (no CompressionStream format), node:https, net sockets and servers, tty streams, ciphers, signing and key derivation in crypto, path.win32, util.isDeepStrictEqual and async_hooks.createHook load and throw ERR_NIB_UNSUPPORTED naming 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's run() 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 refuses isolation: 'process' (the default — Node spawns node --test-reporter=... children and reads back the v8-serializer reporter's bytes; this kernel has neither a CLI flag parser nor v8.Serializer), coverage: true (needs the inspector) and watch: true (needs a FilesWatcher over fs.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 never markRefused() (src/runtime/refuse.ts), so the surface walker sees a plain function, same as Node's, and compat/divergences.json carries no entry for it; the three refusals are instead asserted directly against the kernel in tests/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/exit swap it for one synchronous call, and AsyncResource (bind, runInAsyncScope) and AsyncLocalStorage.bind/snapshot carry 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 every Promise.prototype.then reaction. It does not yet follow a native await or yield (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 (compileCjs in src/runtime/modules.ts), as Node does. new Function prepended two header lines and shifted every frame by two (loop-stackline.cjs golden).
  • Static files match Node through each framework's own middleware. Express's express.static (send) and @hono/node-server's serveStatic serve public/ unmodified; the oracle records Last-Modified, Cache-Control, Accept-Ranges and Content-Range too, stores non-text bodies as base64, and sends requests over node:http, because fetch() adds cache-control: no-cache to 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), then fs.createReadStream is not a function (send streaming the file), then Hono's import of hono/utils/mime, which exposed that exports subpath patterns never expanded their * (the capture came from the request, not the matched key). Stats with Date fields came from reading send and etag rather than a failure. node:fs (src/runtime/fs.ts) now has: callback forms stat lstat fstat open read close readFile readdir access exists realpath(.native); fs.promises/node:fs/promises with access open readdir lstat stat realpath readFile and a FileHandle (read readFile stat close); sync forms statSync lstatSync fstatSync existsSync accessSync openSync readSync closeSync realpathSync(.native) readFileSync writeFileSync readdirSync unlinkSync mkdirSync rmdirSync renameSync; createReadStream/ReadStream, Stats, Dirent and Linux constants. Callback results arrive through ctx.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 an Io and runs on the sync mailbox or the async port. Errors follow Node's uvException: ENOENT: no such file or directory, stat 'x', negative errno, own keys errno, code, syscall, path, the Node syscall name (lstat, access, scandir) and the path as the caller wrote it. Stats has Node's own numeric fields in Node's order and prototype Date accessors; fields the VFS does not track are constants (dev 1, nlink 1, uid/gid 0, rdev 0, blksize 4096, blocks = ceil(size / 512)), and atime, ctime and birthtime report the mtime. File mode stays the kernel's 0o100755. The kernel gained fstat and 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.js with 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.js is a tombstone that a browser fetches on its next navigation under /demo/, which unregisters itself and reloads its pages; and boot() throws ServiceWorkerShadowedError, naming the controlling script, whenever a worker other than the runtime's controls the page. "The runtime's" means served from dist/runtime/sw.js's path or answering { t: "ping" } with a pong within 1 s, so a site worker that importScripts() the runtime's (the fix ServiceWorkerConflictError names) counts as the runtime's; dist/runtime/sw.js is 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, and tests/loop-golden.spec.ts asserts 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 by data-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 (deleted clears the editor; hidden by collapse preserves 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 gained runtime.onListening(listener(port, url)) and runtime.onUnlistening(listener(port)). We chose an edge pair over one onPorts(listener(ports)) snapshot because every consumer so far reacts to an edge (the supervisor's starting → 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; waitForPort is now a filter over onListening. Restarting a live app needs to stop it, so RunningProcess gained kill(): the kernel finishes the process as it does a crash (worker terminated, ports freed, in-flight requests 502) and exited resolves 143 (128 + SIGTERM). The supervisor (examples/slopbox/supervisor.js) is a state machine with injected spawn and 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 enters failed and 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: listening events 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 binds import.meta on the guest's first line and appends a file:// sourceURL, checked by the loop-esmstack.mjs golden.)
  • 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 a ProcessInfo (pid, ppid, argv as the guest's process.argv, cwd, ports). Processes a guest spawns are listed too, so an IDE can show npm 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 posts procStarted/procExited and names the pid on listening/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, and proc.pid finds a handle's row.
  • Buffer's base64 and hex ride Uint8Array's native codecs. fromBase64/fromHex/ toBase64/toHex are 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, and builtin-buffer-encodings.cjs records both kinds. Recording it against Node also found that Buffer.byteLength for base64 and hex is a length estimate, not a decode, and that the old hex decoder accepted pairs parseInt half-reads ("1g").
  • stream.Transform is 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 _flush then EOF), 'prefinish', 'finish' a tick later, and 'close' once both sides are done. The Readable gained what that order needs: Node's sync flag (a push outside _read while flowing emits 'data' on the caller's stack), _read before taking in read(), maybeReadMore after 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-fed Engine per 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's Error with Z_DATA_ERROR/-3; "Compressed input was truncated." becomes "unexpected end of file", Z_BUF_ERROR/-5; another browser's wording keeps its message under Z_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 after end() finishes the codec, and _final returns 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, memLevel and strategy are 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-default windowBits, flush(), params() and reset() refuse. crc32 is a JS table. body-parser's Content-Encoding: gzip path runs unmodified, checked against Node through the HTTP oracle (bodyBase64 requests).
  • 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 a PtyProcess: pty.write takes keystrokes as a terminal emulator sends them, pty.resize raises SIGWINCH when the size changes, and stdout carries the master side already output-processed, so it can go straight to an emulator. A pipe-stdio process gains proc.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 sync sigaction) 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, and proc.signal settles with exited, 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's lib/; LICENSE-node carries Node's MIT notice and lists every derived file. slopbox runs an app whose package.json says "slopbox": { "kind": "cli" } in a terminal pane on this API. examples/slopbox/terminal.js is 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 gets style-src 'self' 'unsafe-inline'. For a CLI the supervisor enters running at spawn and done on exit 0, with a Run again button; a non-zero exit is a crash with the server backoff. demo/apps/clack is an unmodified @clack/prompts CLI, 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 Proc owns threads: Map<Tid, Thread>, and thread 0 is the main one, which takes signals, http requests and connections. new Worker() gets its threadId from the sync newThread syscall (Node sets it before the constructor returns), creates a port pair, and sends startThread with the child's end and workerData, natively cloned, with a transfer list the kernel passes on. The kernel starts a runtime worker with the same pid, a snapshot of the parent's env (or options.env) and cwd, and its own mailbox. The thread's exit syscall, terminateThread from its parent, or its parent's end finishes that thread and the threads it started, and the parent hears threadExited. 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' unless unref()ed; a port holds its thread's loop while it has a 'message' listener, as Node's do, so a thread with parentPort.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 by src/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's online, 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), so thread-stdout.cjs asserts 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 in Atomics.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_ENV refuses (the kernel holds no live env); stdin: true, resourceLimits, execArgv and heap snapshots are accepted and ignored or refused; ports have no addEventListener or onmessage; 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.js starts node bin/esbuild --service=<v> with child_process.spawn for the async API, and its sync API starts a worker thread that spawns the same child and answers through receiveMessageOnPort. spawn("node", [script]) asks the kernel (sync spawn) 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), inherit writes to the parent process's own fd, its piped stdin is fed by childStdin, 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, fork and the sync forms refuse. Gaps hit running it, in order: the service child's entry bin/esbuild starts with a hashbang, which the CJS wrapper made a syntax error, and the snapshot dropped it anyway (no extension), so shebang scripts and .wasm files are now kept in node_modules (.node addons still are not); Go's wasm_exec reads stdin with fs.read(0, ...), which now reads the pipe directly; os.Open of 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 root tsconfig.json above demo/apps/esbuild, so the fixture carries its own. node:os (Linux on x64, fixed host facts) and node:module's createRequire came from reading esbuild's imports.
  • The Vite 7 dev server runs unmodified, on WebAssembly builds of its native packages. demo/apps/vite pins vite 7.3.7, @vitejs/plugin-react 5.2.0 (Babel, pure JS) and React 19.2, and its package.json overrides replace esbuild with esbuild-wasm 0.27.7 and rollup with @rollup/wasm-node 4.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 same rollup/parseAst export over a wasm-bindgen module it compiles synchronously, which a worker may do. With them no .node addon is needed, so no WASI or napi loader either. server.mjs calls createServer({ server: { port: 5173, host: "localhost", hmr: true } }), listen() and printUrls(). In slopbox the frame renders App.jsx (Babel adds React Refresh, esbuild the JSX transform), the dep optimizer bundles React with esbuild-wasm into node_modules/.vite in the VFS, the stylesheet applies, and /@vite/client logs [vite] connected. once its WebSocket, through the shim, gets the server's greeting; the index.html it serves equals real Node's. Slopbox ran every app with NODE_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 skips node_modules/.vite, a cache from runs under real Node. Gaps in the order they were hit, each found by a failure: vite's own import "#module-sync-enabled" (package imports were unresolved); node:assert, node:dns, node:perf_hooks and node: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's Cannot redefine property: ALIAS_KEYS, which was a CJS cycle evaluating @babel/types/lib/definitions a second time into the same exports. Found by reading rather than failing: http.get among 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. The import loader now takes Node 24's module-sync condition; require did 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 reassigned module.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), mkdir answering the first directory it created in the caller's spelling ("out-w" for mkdir -p out-w/a/b), rm as lstat then unlink or recursive rmdir (a bare directory is the SystemError ERR_FS_EISDIR, and force ignores ENOENT), copyFile reporting every step as the copyfile syscall with src -> dest, and mkdtemp with six random [A-Za-z0-9] characters. node:dns answers from a hosts table: localhost is ::1 then 127.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, and assert(false) without a message cannot quote its call site. perf_hooks is the worker's performance; v8 answers startupSnapshot.isBuildingSnapshot() with 0, which is what Node returns.
  • fs.watch is inotify's model over the kernel's mutations. A watch syscall 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, rename in 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. So writeFileSync of a new file is rename then change, and a rewrite of an existing one is change twice (the truncate, then the write), as Linux reports it. The runtime's FSWatcher holds the loop while ref'd (persistent: false and unref() release it), emits each event as its own poll-phase callback, honours encoding: "buffer" and signal, 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 as rename, reports a rewrite as a rename of the file and a change of the directory, and nothing for an append). Limits: recursive: true refuses; fs.watchFile is not implemented (chokidar uses it only with usePolling); 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) on tests/fixtures/registry/<case>/package.json through 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 --userconfig set to an empty file, because an npmjs auth token in ~/.npmrc makes it ignore --registry for packuments. Packuments are pruned to the fields resolution and the lockfile read; replay is the proof that the pruning changed nothing. Cases: hono and express (the fixtures' dependencies) and conflict, 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 flagged peer), an npm: alias (semver-six) and a nested override (is-core-module pinned under normalize-package-data). hono-stale starts npm from a seed-lock.json (hono's lockfile) under a package.json whose hono range it no longer satisfies, and npm reads only hono's packument. vite is 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> serves tests/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 ci from the lockfile, in the kernel. The kernel owns the filesystem, so it owns the install: runtime.install posts one install message, the kernel reads <cwd>/package-lock.json from MemoryFS and answers one installed. tree.ts loads it with <cwd>/package.json into 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 as require would from the node's folder. v1, link entries and entries without resolved/integrity that are not inBundle refuse, and the rest trusts the Node type. With a package.json, resolution comes first (the next entry). Each tarball comes from its resolved URL, never a URL rebuilt from the name, because an overrides alias (esbuild → npm:esbuild-wasm@…) is locked at node_modules/esbuild with the aliased tarball. The registry answers with access-control-allow-origin: *, so a CORS fetch from the kernel worker passes COEP; the page's CSP must allow it in connect-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 after crypto.subtle confirms the tarball's SRI hash (a mismatch is EINTEGRITY), 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 whose integrity names 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 file keepInPackage keeps. 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 deletes niw-npm-v1, the tarball cache the earlier layout kept by resolved URL, and the niw-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 the cache-control: max-age it 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 with cache: "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 no access-control-expose-headers, so a CORS response's etag reads as null, and If-None-Match is 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's NIB_NPM_GATEWAYS) for dev.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 sends wait lines while its registry is slow, so a stream is abandoned only after 5 quiet seconds. The request carries substitute.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; pickManifest reads 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-gateway runs 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's budget) by budget.ts: an index, one JSON document in nib-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 with cache.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 Lock nib-npm-store, so two kernels' passes take turns: the second sees the first's deletions in cache.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 replaces node_modules whole, as npm ci removes it first. So a failed install leaves the tree it found, and a second install writes the same tree. The pass keeps the files keepInPackage keeps (src/shared/package-files.ts, which scripts/snapshot.mjs imports 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/libc follow npm-install-checks against src/shared/platform.ts, which process.platform/arch and node:os read too; the libc is glibc, so a -gnu binding installs where its -musl twin does not. The proof installs each app from its <name>.src.snap (the v3 snapshot without node_modules) and compares hashes with the npm ci tree 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 have fsevents where linux x64 takes @napi-rs/lzma-linux-x64-gnu (whose .node addon the filter drops, leaving its package.json), and the two hidden lockfiles list different packages. The tar reader handles ustar prefix, pax path and GNU long names, strips the first segment (not always package/), and drops absolute and .. paths. Limits: install scripts do not run (report.scripts lists them), and engines is not checked. An optional package that is skipped (os/cpu/libc) or fails takes arborist's optionalSet with 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, as npm ci --os linux does). A non-optional package for another platform fails the install with EBADPLATFORM, as npm-install-checks' checkPlatform does.
  • npm install resolves what the lockfile does not satisfy, and writes package-lock.json as npm does. resolve.ts is arborist's buildIdealTree, ported in its order (the deps queue, #problemEdges, PlaceDep/CanPlaceDep, peer sets, ERESOLVE, overrides, calc-dep-flags) over the same mutable Tree, 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 as node_modules, after every document and tarball has arrived (report.lockfile is "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 with code before anything is written. With neither file the install fails with npm's own ENOENT (Could not read package.json: …); a lockfile without a package.json installs as it is, as npm ci would. lockfile-write.ts ports 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: bundledDependencies renamed, 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 takes license and funding from Registry#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 with npm-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 through page.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 legacy dependencies tree); a new node's bundled dependencies are not read out of its tarball into the lockfile, as npm's reify does; a root directories.bin without bin links nothing, where npm globs that directory.
  • Guest code runs npm install, npm ci and npm uninstall through a guest npm command and an npm syscall. The command (src/npm-cli) is guest code: it uses only node: builtins and the runtime's nib.runtime hook. 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 does runtime.exec("/usr/local/bin/npm", { argv }). The command parses argv as npm 11.7 does: cmd-list.js's aliases (i, add, rm, un and 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 one InstallRequest (install with specs to add, uninstall with names to remove, or ci) on the async npm syscall. The kernel answers when the install finishes and serves every other process while it waits. The call succeeds with an InstallOutcome, 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 same runInstall. install.ts splits into plan, which decides package.json, the tree and the lockfile before anything is written, and the write pass. save.ts ports 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-prefix on 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 -O leaves npm's dependencies copy there that package.json drops. npm ci refuses 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's diff, 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 is npm-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 spawns npm install and 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 with ENOTSUP.
  • npm run, npm exec and npx run without a shell. A script is a string for sh -c, and the runtime has no sh. src/npm-cli/script.ts reads the subset that needs none. It parses the script, with npm's arguments appended in promise-spawn's quoting, into a Script: either steps, each a SimpleCommand (assignments, argv, the line it starts on) with the When that gates it (; always, && on 0, || on nonzero), or refused with the Construct it met and the text near it. Quoting, backslashes, NAME=value prefixes, comments, line continuations, and $NAME and ${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: node itself, 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's sh: <line>: <name>: not found and is status 127, and env alone prints the environment. run.ts ports run-script.js: pre and post scripts, restart's default, start's node server.js, --if-present, --ignore-scripts, the missing-script error with did-you-mean.js's suggestions (cmd-list.js's descriptions are in command.ts), and the script list. run-script.ts ports run-script-pkg.js: the banner, make-spawn-args.js's environment (package-envs.js, npm_lifecycle_*, npm_package_json, PATH with every node_modules/.bin from 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.ts ports libnpmexec: a bin in a node_modules/.bin up from the project, then /usr/local/bin, else the package installs into ~/.npm/_npx/<sha512 of the sorted specs, 16 hex> through the npm syscall, with _npx.packages written 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-yes refuses. Which packages are missing is the async npmLookup syscall (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 as bin/npx-cli.js, linked from /usr/local/bin/npx, and it rewrites its argv as npx-cli.js does. The proof is the scripts cases of npm-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 exec of 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 exec of 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 exec with nothing to run refuses, where npm opens a shell. Workspaces, --script-shell and --silent are not modelled.
  • A bundled dependency is a package with no tarball of its own. An inBundle lockfile entry has no resolved or integrity, because its files arrive in its bundler's tarball, so it loads as a BundledNode that the store's fetchTarball cannot take. Its bundler is arborist's getBundler: 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 in report.packages, as npm's "added" leaves it out. When the bundler's tarball has no package.json at its location, npm does not fail: reify marks it bundleMissing and extracts it by name@version, and the hidden lockfile records it without inBundle. The installer does the same, taking the tarball and its SRI from the registry's packument (registry.ts), and fails with E404 or ETARGET only when the registry has neither. libc is checked only where the lockfile records it: npm 11.7 writes no libc for the @napi-rs/lzma bindings, because the abbreviated packument omits it, and then npm ci installs both the -gnu and -musl binding on any linux. The proof installs @isaacs/testing-bundledeps from the lockfile npm wrote and from one whose bundled entry its tarball lacks, and compares every file, hidden lockfile included, with what npm ci wrote 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.snap and installs through runtime.install(), so the catalog's files and bytes describe the download, packages the 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 .snap stays 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 what key holds 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 in nib-kept-dirs-index-v1 through budget.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": [...] }, which scripts/fixtures.mjs carries into the catalog. The vite app keeps node_modules/.vite, restored after the install and before server.mjs starts, and saved a second after its frame first loads. Vite checks _metadata.json against its own lockfile and config hashes, so a stale restore costs only the optimize it would have run anyway (the slopbox spec changes NODE_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 on pagehide: 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 is ELOOP. stat, open (a create through a dangling link creates its target; O_EXCL does not follow), readdir and a recursive mkdir follow; lstat, readlink, unlink, rename, rmdir and rm act on the link. The realpath syscall is realpath(3), so realpathSync.native and fs.promises.realpath resolve .. physically, while realpathSync and the callback realpath are 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, __filename and import.meta.url name the target, and node <link> runs it. process.chdir lands 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, 0777 on Linux), the hop limit (32 there) and an empty target (allowed there, ENOENT on Linux) are pinned by kernel tests instead. Limits: no require.main or require.cache to compare, no lchown/lutimes/link, and the host seed and writeTree write files only.
  • .bin links are bin-links' rules, read from npm's source. After the tree is written, each installed package's lockfile bin links from the .bin of the node_modules that holds it, so node_modules/@babel/parser links node_modules/.bin/parser → ../@babel/parser/bin/babel-parser.js. lockfile.ts sanitizes 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, because npm ci reads bin from 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 in en collation 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 \r from a #!…\r\n first line; its chmod has nothing to do, because MemoryFS keeps no modes (every file stats 0755). The proof compares every fixture's links with the ones npm ci made on disk, checks a contested lockfile against what npm ci linked from it, and runs esbuild through its link. Limits: a bin whose file keepInPackage drops (one with no shebang and no runtime extension) links but dangles, and the hidden lockfile copies each bin as 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-wasm over just-bash (98.5% vs. 93.8%) and wasi-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, npm and npx are one table of launchers (examples/slopbox/launchers.js): node resolves a file, -e/-p (written to a .nib-eval-<n>.{cjs,mjs} the caller deletes once the process exits), -v, a bare node (REPL refusal) or an unknown flag; npm and npx are 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/exec and npx with no shell under them, a script that needs one fails ENOTSUP. (Follow-up: this bash could serve as that shell, dropping run-script's own no-shell restriction.) Before a line runs, bash.analyze decides foreground: one command, no redirects, no command substitution, and that command is node, npm or npx gets 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 calls bash.cancel()), foreground (the pty process owns keys) — never three booleans. bash.execute starts $? 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 from bash.analyze on 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 still starting can be taken for the app's own port, because listening events 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 fs and require without 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 via new 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.
  • require of an ES module works as in Node v24.16 (sketches/require-esm.md), and process.features.require_module is true. 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); require takes the module-sync condition. One registry per worker holds both loaders' instances: every served ES module imports its own URL and calls esmBegin/esmEnd, so a require after import gets the native namespace, an import() after require gets the registry's, and a require racing an in-flight import() throws ERR_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 undefined instead of failing to link with a SyntaxError.
    • A JSON import without with { type: "json" } is accepted.
    • A CommonJS dependency's reexports are not followed, so names it re-exports from another module are not named imports.
    • An anonymous export default function loses hoisting.
    • export and default split 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 undefined rather than a TDZ ReferenceError.
    • 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 with scope.
    • A static native import after require of the same module evaluates a second instance, and so does a require of 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-level await, reads as still evaluating, so require throws ERR_REQUIRE_CYCLE_MODULE.
    • The namespace require builds is a Proxy: util.types.isModuleNamespaceObject is false, frames through it are named eval and Proxy., 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.
  • 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 loads node:wasi and 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=1 still loads a native binding that is installed, so a comparison needs the native binding removed.
  • OPFS quota/eviction: does persistent-storage request + storage.estimate meet 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.