The nib prefix on its error codes, worker names, and internals comes from
node-in-browser, the project's original inspiration.
A browser-run Node.js-compatible runtime. JavaScript runs in the browser's own
engine; the parts of Node the browser can't express fall through to a small WASM
residue. Browser-first is a precedence rule: for every Node API, use a
browser-native primitive when it can meet Node's behavior (OPFS, WebCrypto,
TextEncoder/Decoder, Web Streams, Workers); compile to WASM only what's left.
Not affiliated with and not derived from StackBlitz WebContainers — the
architecture is our own (see DESIGN.md).
// vite.config.tsimport nib from "@nibling/kernel/vite";export default defineConfig({ plugins: [nib()] });
import { getKernel } from "@nibling/kernel";import app from "./examples/express?nib"; // a dir with package.json + installed node_modulesconst runtime = await getKernel(); // one per page; survives HMRconst { root } = await runtime.mount(app);runtime.exec(`${root}/${app.entry}`, { cwd: root });iframe.src = await runtime.waitForPort(3000);
nib() sets COOP/COEP on your pages in vite dev and vite preview, serves the runtime
at <base>__nib/<version>/ and its Service Worker at <base>nib-sw.js, and points the
@nibling/kernel import at them, so getKernel() needs no options. A <dir>?nib import
is a snapshot of that directory, described by its metadata ({ src, root, entry, kind, files, bytes }), never its contents. In dev, edits under it reach the running guest
without a page reload. vite build emits the same layout plus a _headers for Netlify
and Cloudflare Pages; on another host, nib({ host: "none" }) prints the headers to set.
pages: ["/playground/**"] limits COOP/COEP to the pages that boot the runtime (COOP
breaks OAuth and payment popups). For the ?nib types, declare the module:
One kernel worker owns the filesystem and process table (the only writer);
runtime workers run user JS and reach the kernel through a typed syscall
table over a sync mailbox (SharedArrayBuffer + Atomics.wait), which is what
makes require() and readFileSync() honestly synchronous without blocking the
page. Node's event loop is emulated one callback per browser task with native
microtask checkpoints. The WASM residue is derived from the browser-first
precedence table, not designed top-down.
Status: step 1 ✅ · resolver v2 ✅ · ESM (step 6, early) ✅ · TypeScript stripping ✅ · node:http ✅ · Hono from npm ✅ · event loop + process (step 3, partial) ✅ · Express 5 from npm ✅ · static files ✅ · slopbox shell ✅ · zlib + Buffer on browser codecs ✅ · stdin, signals, ptys + readline ✅ · slopbox terminal pane ✅ · WebSockets to guest servers ✅ · worker_threads + esbuild-wasm ✅ · Vite dev server ✅ · npm install from a lockfile ✅ · npm install from package.json ✅ · guest npm install/ci/uninstall ✅ · guest npm run/exec and npx ✅ · slopbox bash Shell tab ✅ #
DESIGN.md §Build order tracks the milestones. Done so far:
Next: Vite HMR (an edit in the VFS reaching the page as a hot update); fs follow-ups
(createWriteStream, fs.watchFile, recursive watches); http follow-ups
(streaming responses, the http.request client, https, fetch interception); the rest
of step 2 (fork IPC, spawnSync); step 4 (OPFS persistence); step 5 (WASM residue:
sync hashing, sync zlib).
npm installnpm run build # esbuild → dist/runtime/ + dist/types/ (shipped), dist/demo/ (demo-only)npm run serve # http://localhost:8931/demo/index.html (COOP/COEP + CSP)npm run fixtures # npm ci each demo/apps/<name>, snapshot to dist/fixtures/<name>.snap (+ <name>.src.snap without node_modules) + index.jsonnpm test # build + fixtures + Playwright acceptance testsPORT=8942 npm test # from another worktree: the suite starts its own server; PW_REUSE_SERVER=1 reuses a running oneTARBALLS_OFFLINE=1 npm test # after one online run: registry tarballs come only from tests/fixtures/tarballsnpm run typechecknpm run pack:check # pack, assert the tarball file list, install it into a scratch consumer, type-check + resolvenode scripts/golden.mjs [--check] [target…] # re-record tests/goldens (guests/, http/, ws/) under the pinned Node; exit 1 on driftnode scripts/golden.mjs --check --node=<bin> # the same against a candidate Node, without moving the pinnode scripts/golden.mjs --write [target…] # re-record; a new guest takes --stdin=<JSON> --deps=a,bnode scripts/surface.mjs record [--node=<bin>] # every builtin's API surface → compat/node-<version>.jsonnode scripts/surface.mjs delta <from> <to> # what a Node release added/changed/removed, from two recordsnode scripts/surface.mjs baseline # rewrite compat/divergences.json from the last surface spec runnode scripts/bump-node.mjs <version> # move the pin to <version>, re-record the manifest and goldens under itnode scripts/node-releases.mjs [--index=<file>] [--json] [extraMajor…] # newest LTS per tracked major vs. the pinnode scripts/node-release-check.mjs [version] [--report=<path>] # fetch + surface delta + golden --check for a candidate
Goldens and surface records refuse to run on any Node but the repo's .node-version.
tests/surface.spec.ts requires every path in the pinned record to exist in the
kernel with the same kind, or to be listed in compat/divergences.json with a tagged reason
(todo, refuse, internal, deprecated, accepted; the spec's header defines them);
it also fails on entries the kernel has since satisfied. To vet a new Node minor,
record it with --node, read delta, then implement or list each new path; and run
golden.mjs --check --node=<bin> for behavior changes the surface can't see.
slopbox, the catalog shell for these demo apps, is its own package now: see
examples/slopbox (pnpm --filter @nibling/example-slopbox serve, on :8932, and
.../screenshot-slopbox.mjs <outDir> for its screenshots).
/demo/index.html — CJS main: require('node:fs'), bare packages via exports
(leftpad) and main fallback (shouty), plus a writeFileSync round-trip
/demo/esm.html — guest ESM: import fs from 'node:fs', ESM graph, CJS via facade,
bare package, import.meta.url — the browser's linker does cycles/live bindings
/demo/esm-cycle.html — live-binding cycle proof (native linking, no emulation)
/demo/ts.html — TypeScript via type stripping: .mts/.ts, import type erased before
resolution, CJS .ts through the facade, Node's refusal codes for .tsx/enums
/demo/http.html — guest node:http server on port 3000 rendered in an iframe; GET /bye
closes it and the process exits 0; ?esm runs the import http from "node:http" variant
/demo/hono.html — an unmodified Hono app from npm, mounted from its snapshot
(npm run fixtures → dist/fixtures/hono.snap → runtime.mountSnapshot), rendered in an
iframe, with public/ under /static/ through serveStatic
/demo/express.html — an unmodified Express 5 app from npm, mounted from its snapshot
(dist/fixtures/express.snap) and run with NODE_ENV=production, rendered in an iframe
whose stylesheet comes from express.static
/demo/ws.html — an unmodified ws WebSocketServer on a node:http server; its page
opens WebSockets through the shim and renders the exchange (?crash runs it through to
the crash the spec records, ?manual leaves the sockets to the caller)
slopbox — lists the demo apps (Clack, Crashy, esbuild-wasm, Express, Hono, Vite + React, WebSocket); choosing one
loads ?app=<name>, which boots a runtime, mounts the app's own files
(<name>.src.snap), installs node_modules from its lockfile, runs the entry and frames
the first port it listens on. Toolbar: reload the frame, restart the process, open the app
in a new tab (works while the shell tab stays open, since it hosts the runtime). A crash
restarts after 250 ms, 1 s, 4 s; a fourth crash within 30 s stops in failed with a
Restart button. ?backoff=0,0,0 replaces the delays (the tests use it). demo/apps/crashy
is a dependency-free server whose page crashes it or exits it cleanly. A CLI app
("slopbox": { "kind": "cli" } in its package.json, like demo/apps/clack) runs on a
pty in a terminal pane instead of the frame: exit 0 stops in done with Run again, and a
non-zero exit restarts as a crash does. A Shell tab next to Console (both server
and CLI apps get it) is a bash prompt — bashkit — on the app's own VFS, where node,
npm and npx run real processes in the same runtime; it loads lazily, only once the
tab is first chosen, and becomes usable once the app's files are mounted. The slopbox
page alone allows inline styles, because xterm injects <style> elements. slopbox is its
own package, examples/slopbox (@nibling/example-slopbox), not part of this one —
see its README. tests/vite.spec.ts and tests/sw-shadow.spec.ts here instead use a
minimal single-app runner, /demo/app.html?app=<name>, that boots one fixture the same
way with no catalog, terminal or shell
/demo/sw-probe/ — does a SW intercept worker module imports — direct and nested? Its worker is
scoped to /demo/sw-probe/ so it never controls another page (see DESIGN on shadowed workers)
/demo/harness.html — the IDE-ish face: VS Code-style Explorer (collapsible tree with
extension-colored icons, context menu), editor tabs with dirty indicators + line gutter,
bottom OUTPUT panel running guests, full VFS CRUD through runtime.fs — guest writes appear in
the tree live via onFsChanged, and page edits are visible to guests
/demo/no-coi.html — served without COOP/COEP on purpose: boot() must refuse
src/host/ page API: boot(), Kernel, exec() — the only public surface
src/kernel/ worker: sole FS/process owner, syscall dispatch, resolver
src/runtime/ worker: user JS, event loop, CJS loader, builtin registry
src/shared/ wire protocol + sync mailbox (both sides import; never exported)
src/sw/ Service Worker: the /~/ virtual origin, app routing, raw connections
src/shim/ the WebSocket shim the SW serves into guest documents (RFC 6455 client)
demo/ browser entrypoints (import map maps @nibling/kernel → dist/runtime/host.js)
tests/ Playwright acceptance proofs, one suite per build step
Note on import maps: they're document-scoped, so they wire the page's bare
specifiers (@nibling/kernel). Guest node:* imports resolve through the
runtime's builtin registry (src/runtime/builtins.ts) — the same role, inside
the worker realm. Guest ESM gets the browser's native linker via a Service
Worker virtual origin in step 6.