Monorepo for the nibling.run platform
core packages kernel
6 folders · 11 files

compat
demo
scripts
sketches
src
tests
.gitignore
build.mjs
DESIGN.md
LICENSE
LICENSE-node
package.json
playwright.config.ts
README.md
tsconfig.build.json
tsconfig.json
tsconfig.node.json
README.md

@nibling/kernel #

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

Use it with Vite #

// vite.config.ts
import 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_modules

const runtime = await getKernel();             // one per page; survives HMR
const { 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:

declare module "*?nib" {
  const app: import("@nibling/kernel").NibApp;
  export default app;
}

Architecture in one paragraph #

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

Run it #

npm install
npm 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.json
npm test               # build + fixtures + Playwright acceptance tests
PORT=8942 npm test     # from another worktree: the suite starts its own server; PW_REUSE_SERVER=1 reuses a running one
TARBALLS_OFFLINE=1 npm test   # after one online run: registry tarballs come only from tests/fixtures/tarballs
npm run typecheck
npm run pack:check     # pack, assert the tarball file list, install it into a scratch consumer, type-check + resolve
node scripts/golden.mjs [--check] [target…]   # re-record tests/goldens (guests/, http/, ws/) under the pinned Node; exit 1 on drift
node scripts/golden.mjs --check --node=<bin>   # the same against a candidate Node, without moving the pin
node scripts/golden.mjs --write [target…]      # re-record; a new guest takes --stdin=<JSON> --deps=a,b
node scripts/surface.mjs record [--node=<bin>] # every builtin's API surface → compat/node-<version>.json
node scripts/surface.mjs delta <from> <to>      # what a Node release added/changed/removed, from two records
node scripts/surface.mjs baseline               # rewrite compat/divergences.json from the last surface spec run
node scripts/bump-node.mjs <version>            # move the pin to <version>, re-record the manifest and goldens under it
node scripts/node-releases.mjs [--index=<file>] [--json] [extraMajor…]   # newest LTS per tracked major vs. the pin
node 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/ts-noengine.html — engine failure containment: plain JS keeps running, .ts fails with ERR_NIB_TS_ENGINE
  • /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/missing.html — missing-module error path, exit 1
  • /demo/not-exported.html — exports package boundary, exit 1
  • /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

Layout #

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.

License #

MIT (see LICENSE). The files derived from Node.js keep the Node.js license (LICENSE-node).