Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139// Host API: the only public surface. The page imports boot(), gets a Kernel,// and runs scripts. Everything below (workers, SAB, wire types) stays hidden.import type { BridgeReply, BridgeRequest, ExitStatus, HostToSw, HostFsOp, HostToKernel, KeepOp, KernelToHost, SeedEntry, StatRecord, StdioSpec, SwToHost,} from "../shared/protocol";import { exitNumber, isSignal } from "../shared/protocol";import { toNodeError } from "../shared/nodeerr";import { httpUrl, newHostToken, parseVirtualPath, type HostToken } from "../shared/vurls";import { KEPT_DIRS_STORES, LEGACY_PACKAGE_STORES, PACKAGE_STORES } from "../shared/package-store";// Imported with a .js extension (and from this dependency-free module, not// shared/protocol.ts) because this import re-exports: it has to resolve both in this// package's own "bundler" program and, once shipped, in a node16/nodenext consumer's,// which needs an explicit extension on relative specifiers.import type { GuestWrite, InstallReport, KeepReport, ProcessRuntime, ResourcePolicy, Signal, Substitution, WinSize } from "../shared/public-types.js";import { RESOURCE_POLICY_PARAM } from "../shared/public-types.js";
export type { GuestWrite, InstallReport, KeepReport, ProcessRuntime, ResourcePolicy, Signal, Substitution, WinSize };
export interface ExecOptions { argv?: string[]; env?: Record<string, string>; cwd?: string; /** `node` (the default) or `web`. A `web` process has no `process`, `Buffer`, `global` * or `setImmediate`, its timers return numbers, and its entry must be an ES module. * Its worker_threads share its runtime; a `node` child it spawns is `node`. */ runtime?: ProcessRuntime;}
export interface PtyExecOptions extends ExecOptions { /** Run the process on a terminal of this size: fds 0-2 are a kernel pty, and the * environment gains TERM=xterm-256color and COLORTERM=truecolor unless `env` sets them. */ pty: WinSize;}
/** A live process in the kernel's table, as the host sees it. */export interface ProcessInfo { readonly pid: number; /** The process that spawned it; null when the host did. */ readonly ppid: number | null; /** As the process's own `process.argv`: "node", the entry's path, then its arguments. */ readonly argv: readonly string[]; readonly cwd: string; /** Ports it is listening on, in the order it bound them. */ readonly ports: readonly number[];}
interface ProcessBase { /** Its pid in the kernel's table (see `onProcesses`). */ readonly pid: Promise<number>; readonly stdout: ReadableStream<Uint8Array>; readonly stderr: ReadableStream<Uint8Array>; /** The exit code, or 128 + the signal's number when a signal ended the process. */ readonly exited: Promise<number>; /** Settles with `exited`: the signal that ended the process, or null when it exited. */ readonly signal: Promise<Signal | null>; /** Send a signal (SIGTERM by default). A process with a listener for it runs the * listener and lives; without one the signal's default action applies, which for * SIGTERM, SIGINT and SIGHUP ends it with `exited` 128 + n. SIGKILL always ends it. * Its ports close when it ends. A no-op once the process has exited. */ kill(signal?: Signal): void;}
export interface RunningProcess extends ProcessBase { /** The process's fd 0. Closing it is EOF; writes after the process exits are dropped. */ readonly stdin: WritableStream<Uint8Array>;}
/** The host's side of a process's terminal (the pty master). */export interface PtyMaster { /** Keystrokes, as a terminal sends them. They pass through the line discipline: in * cooked mode they are echoed and edited and delivered a line at a time, ^C raises * SIGINT and ^D on an empty line is EOF; in raw mode they reach the process as is. */ write(data: string | Uint8Array): void; /** Resize the terminal. The process gets SIGWINCH when the size changes, and its * stdout and stderr update `columns`/`rows` and emit 'resize'. */ resize(cols: number, rows: number): void;}
/** A process on a terminal: `stdout` is everything the master reads (the process's * stdout and stderr, and the echo), output-processed (NL becomes CR NL), so it can go * straight to a terminal emulator. `stderr` stays empty. */export interface PtyProcess extends ProcessBase { readonly pty: PtyMaster;}
const SNAPSHOT_MAGIC = "NIBS";
/** `src/node/snapshot.ts`'s v3 header: magic + header length already stripped. */interface SnapshotHeader { version: number; files: Array<[path: string, size: number, mtimeMs: number]>;}
/** Parse a v3 `.snap` buffer into seed entries whose `data` are views into `buffer` — * no copies. `buffer` is consumed (its bytes become the entries' data), so the caller * must transfer it to the kernel in the same postMessage. */function snapshotEntries(buffer: ArrayBuffer): SeedEntry[] { if (!(buffer instanceof ArrayBuffer)) throw new Error("not a snapshot: bad magic"); const bytes = new Uint8Array(buffer); const magic = bytes.length >= 4 ? new TextDecoder().decode(bytes.subarray(0, 4)) : ""; if (magic !== SNAPSHOT_MAGIC) throw new Error("not a snapshot: bad magic"); const headerLen = new DataView(buffer).getUint32(4, true); const header = JSON.parse(new TextDecoder().decode(bytes.subarray(8, 8 + headerLen))) as SnapshotHeader; if (header.version !== 3) throw new Error(`unsupported snapshot version ${String(header.version)} (expected 3)`); let offset = 8 + headerLen; return header.files.map(([path, size, mtimeMs]) => { const data = new Uint8Array(buffer, offset, size); offset += size; return { path, data, mtimeMs }; });}
/** The deepest directory holding every path ("/app" for /app/a.js and /app/lib/b.js). */function commonDir(paths: string[]): string { let common: string[] | null = null; for (const path of paths) { const dirs = path.split("/").filter(Boolean).slice(0, -1); if (common === null) common = dirs; let i = 0; while (i < common.length && common[i] === dirs[i]) i++; common.length = i; } return "/" + (common ?? []).join("/");}
/** What a `?nib` import (the Vite plugin) or `nib snapshot` describes: a `.snap` file at * `src`, mounted at `root`. The counts are the snapshot's, for progress and display. */export interface NibApp { src: string; root: string; entry?: string; kind?: "server" | "cli"; files: number; bytes: number;}
export interface MountOptions { /** Make the tree under the app's root exactly the snapshot's: before seeding, delete * every file under the root that the snapshot does not contain, and every directory * left holding none of its files (the root itself stays). Files outside the root are * never touched. Without it, mount() writes over the existing tree and leaves files * the snapshot lacks in place. Rejects with a TypeError for root "/". */ replace?: boolean;}
export interface Dirent { name: string; kind: "file" | "dir" | "symlink" }/** stat follows links, so its kind is never "symlink". */export interface HostStat { kind: "file" | "dir"; size: number; mtimeMs: number; mode: number }
/** Async file operations on the VFS (DESIGN §host-fs). Every method is port RPC * to the kernel's pid-0 host process — the page never blocks. Rejections are * Node-flavoured Errors: `code` (e.g. "ENOENT"), `errno`, `syscall`, `path`. */export interface HostFs { readFile(path: string): Promise<Uint8Array>; writeFile(path: string, data: string | Uint8Array): Promise<void>; // trunc+create mkdir(path: string, opts?: { recursive?: boolean }): Promise<void>; readdir(path: string): Promise<Dirent[]>; // dirs, files and links, unsorted stat(path: string): Promise<HostStat>; unlink(path: string): Promise<void>; rm(path: string, opts?: { recursive?: boolean }): Promise<void>; // rmdir under the hood rename(from: string, to: string): Promise<void>; /** A link at `path` holding `target` verbatim (resolved from the link's directory). */ symlink(target: string, path: string): Promise<void>; readlink(path: string): Promise<string>;}
/** What `runtime.persist()` keeps across page loads. */export interface PersistOptions { /** Names what is kept, origin-wide: a later page restores what an earlier one saved * under the same key. Put in it whatever makes the saved files stale. */ key: string; /** Absolute paths of the directories to keep. */ paths: string[];}
export interface Persisted { /** What the restore wrote. `paths` is empty when the key held none of them. */ readonly restored: KeepReport; /** Save the paths under the key now, replacing what it held. */ save(): Promise<KeepReport>;}
export interface Kernel { exec(entry: string, options: PtyExecOptions): PtyProcess; exec(entry: string, options?: ExecOptions): RunningProcess; /** Seed the in-memory FS (dev convenience; stays alongside fs — existing tests use it). */ writeTree(files: Record<string, string | Uint8Array>): void; /** Seed the FS from a `src/node/snapshot.ts` v3 `.snap` buffer, keeping each file's * recorded mtime. Throws on a bad magic or a snapshot that is not version 3. * `snapshot` is transferred to the kernel (its bytes move uncopied) and is * detached by the time this call returns — do not reuse it after calling. */ mountSnapshot(snapshot: ArrayBuffer): void; /** `npm install` in `<cwd>` (default /app) as npm 11.7 does it on linux x64: resolve * what `<cwd>/package.json` asks for that `<cwd>/package-lock.json` (if any) does not * satisfy, from registry metadata kept in Cache Storage for the registry's max-age, write * package-lock.json byte for byte as npm would when anything changed, and install * node_modules: tarballs checked against their `integrity`, kept unpacked in an * origin-wide store (a later install takes the files from there), the files a snapshot * would keep, and `.bin` links. A lockfile without a package.json installs as it is, * as `npm ci` would. Install scripts do not run. Rejects with an Error carrying * `code` (`ERESOLVE` for a peer conflict, `ETARGET`/`E404` for a package the * registry cannot supply, `EINTEGRITY` when a tarball's hash is wrong), and then * leaves node_modules and package-lock.json as they were. */ install(options?: { cwd?: string }): Promise<InstallReport>; /** Keep `paths` across page loads under `key`: restore what the key holds into the * FS now, replacing what is there, and save them again on `save()`. The kernel reads * and writes the files, so their bytes never reach the page; symlinks and mtimes * survive. Kept directories live in Cache Storage under their own 100 MB budget, * the least recently used key going first. Without Cache Storage nothing is kept and * every restore is empty. */ persist(options: PersistOptions): Promise<Persisted>; /** Seed the FS from a v3 `.snap`: a buffer (transferred, as mountSnapshot), a URL to * fetch it from, or a NibApp (its `src` is fetched). Resolves with the app's root: * `app.root` for a NibApp, otherwise the deepest directory holding every file. * A failed fetch rejects with a NibError, code ERR_NIB_MOUNT. `replace` prunes the * root down to the snapshot's files first (see MountOptions). */ mount(app: NibApp | ArrayBuffer | string | URL, options?: MountOptions): Promise<{ root: string }>; /** Write each file (creating its parent directories) and delete each path mapped to * null (a missing file is not an error). Guests see the changes as their own writes, * fs.watch events included. Resolves once every change has landed. */ applyFiles(files: Record<string, string | Uint8Array | null>): Promise<void>; /** File operations on the VFS — async RPC over the kernel port. */ readonly fs: HostFs; /** Fires after ANY filesystem mutation from ANY writer (guest syscalls, host fs, * seed), coalesced on the host so a burst of writes notifies once. A demo-harness * notification piggybacking on the single-writer invariant — NOT fs.watch * (per-watcher event delivery is step-8 scope). Returns an unsubscribe fn. */ onFsChanged(listener: () => void): () => void; /** Calls `listener` with each finished guest write to a file under `prefixes`: on close of * an fd the process wrote through, or on a rename onto the path. For keeping an outside * copy of the files in step; the host's own writes are not reported. Returns an unsubscribe fn. */ observeWrites(prefixes: string[], listener: (write: GuestWrite) => void): () => void; /** Write a file like `fs.writeFile`, creating its parent directories, and resolve with the * generation of the written state, which `GuestWrite.readGen` names when a guest reads it. */ putFile(path: string, data: string | Uint8Array): Promise<number>; /** Resolves with the page-relative URL of a guest node:http server on `port` * ("/~/@<host>/http/<port>/", where <host> names this page) once it is listening — immediately if it already is. */ waitForPort(port: number): Promise<string>; /** Fires when any guest node:http server starts listening, with its port and * page-relative URL. Ports already listening at subscribe time are replayed in a * microtask, so a listener can unsubscribe from inside its first call. Apps pick * their own port; this is how an embedder finds it. Returns an unsubscribe fn. */ onListening(listener: (port: number, url: string) => void): () => void; /** Fires when a listening port closes: `server.close()`, or the process that owned * it exited (before its exit is reported). Returns an unsubscribe fn. */ onUnlistening(listener: (port: number) => void): () => void; /** Calls `listener` with the live process table, oldest first, now (in a microtask) * and after every change: a spawn, an exit, a listen or an unlisten. Processes a * guest spawns are listed too. Returns an unsubscribe fn. */ onProcesses(listener: (table: readonly ProcessInfo[]) => void): () => void; /** Sends `signal` (SIGTERM by default) to any live process in the table, with the * same effect as its handle's `kill`. A no-op for a pid that is not live. */ kill(pid: number, signal?: Signal): void; close(): Promise<void>;}
/** Base class for every error the runtime's API throws. `code` is stable across releases even * when the message text changes, so embedders can branch on it instead of matching * strings. */export class NibError extends Error { constructor(message: string, readonly code: NibErrorCode) { super(message); this.name = "NibError"; }}
export type NibErrorCode = | "ERR_NIB_NOT_ISOLATED" | "ERR_NIB_SW_UNAVAILABLE" | "ERR_NIB_SW_NOT_CONTROLLING" | "ERR_NIB_SW_SHADOWED" | "ERR_NIB_SW_CONFLICT" | "ERR_NIB_SW_REGISTER" | "ERR_NIB_KERNEL_EXISTS" | "ERR_NIB_MOUNT";
/** boot() refuses to start when another Service Worker controls the page: one * registered with a narrower scope (say "/demo/") outranks the runtime's "/" worker * for pages under it, so the runtime's /~/ URLs would never reach its worker. */export class ServiceWorkerShadowedError extends NibError { constructor(readonly controllerUrl: string) { super( `this page is controlled by another Service Worker (${controllerUrl}), so the runtime's ` + "virtual URLs cannot reach its own worker. Unregister that worker (DevTools → Application → " + "Service Workers, or chrome://serviceworker-internals) or give it a scope that does not cover " + "this page, then reload.", "ERR_NIB_SW_SHADOWED", ); this.name = "ServiceWorkerShadowedError"; }}
/** boot() refuses to replace another Service Worker registered at the runtime's own * scope "/" (a site's PWA worker, say): register() would silently swap it out. The * runtime's worker is a classic script, so the other worker can compose it instead. */export class ServiceWorkerConflictError extends NibError { constructor(readonly foreignUrl: string, readonly runtimeUrl: string) { super( `another Service Worker (${foreignUrl}) is registered at scope "/", and registering the ` + "runtime's worker there would replace it. Unregister that worker, give it a scope that does " + `not cover this page, or compose the two by adding importScripts(${JSON.stringify(runtimeUrl)}) ` + "to the top of that worker.", "ERR_NIB_SW_CONFLICT", ); this.name = "ServiceWorkerConflictError"; }}
/** boot() refuses to start when registering its own Service Worker fails, or when the * worker never goes active after `ready` resolves — both leave guest ESM and guest * HTTP with nothing to serve them. */export class ServiceWorkerRegisterError extends NibError { constructor(message: string, options?: { cause?: unknown }) { super(message, "ERR_NIB_SW_REGISTER"); this.name = "ServiceWorkerRegisterError"; if (options?.cause !== undefined) this.cause = options.cause; }}
/** boot() refuses to start when the runtime's Service Worker never takes control of * the page: workers created while it is uncontrolled stay uncontrolled for life, so * their /~/ module and guest-server fetches would reach the network and 404. */export class ServiceWorkerUncontrolledError extends NibError { constructor() { super( "this page is not controlled by the runtime's Service Worker, so guest ESM and guest HTTP " + "cannot load. A hard reload (Shift+Reload) bypasses Service Workers: reload the page normally.", "ERR_NIB_SW_NOT_CONTROLLING", ); this.name = "ServiceWorkerUncontrolledError"; }}
/** boot() refuses to start without Service Workers (insecure origins, some private * windows): guest ESM and guest HTTP are served by one, so the runtime would come up * half-working. */export class ServiceWorkerUnavailableError extends NibError { constructor() { super( "Service Workers are unavailable on this page, so guest ESM and guest HTTP cannot load. Serve " + "the page from a secure context (https:// or http://localhost) in a window that allows them.", "ERR_NIB_SW_UNAVAILABLE", ); this.name = "ServiceWorkerUnavailableError"; }}
/** getKernel() refuses options that differ from the ones the page's runtime booted * with: it hands every caller the same runtime, so it cannot honour both. */export class KernelExistsError extends NibError { constructor() { super( "this page's runtime already booted with different options. Call getKernel() with no " + "options (or the same ones) to share it, set page-wide options once with configure(), or " + "call boot() for a separate runtime.", "ERR_NIB_KERNEL_EXISTS", ); this.name = "KernelExistsError"; }}
/** boot() refuses to start without cross-origin isolation: the sync mailbox needs SAB. */export class IsolationError extends NibError { constructor() { super( "this page is not cross-origin isolated. SharedArrayBuffer — and with it " + "synchronous require()/readFileSync() — is unavailable. Serve this page with " + "Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp — " + "your dev server or host must send both headers; there is no silently-degraded async mode " + "by design.", "ERR_NIB_NOT_ISOLATED", ); this.name = "IsolationError"; }}
// ---- Service Worker bridge (ESM virtual origin) ----// The SW intercepts /~/ module fetches made by guest code inside the runtime worker// and relays them here; we parse the virtual URL, forward the request to the kernel,// and route the kernel's reply back to the SW port. ORDER MATTERS: the SW must// control this page BEFORE the kernel worker (and, nested inside it, the runtime// worker) is created — a dedicated worker inherits its creator's controller, and// only controlled workers get their module fetches intercepted. This is why// attachServiceWorker() runs before the kernel Worker is constructed, a deliberate// reorder of the brief's "after kernelReady".
/** How long boot() waits for the claimed page to report a controller. */const CONTROL_TIMEOUT_MS = 5000;
/** Register the runtime's SW at scope "/" and hand it this page's bridge MessageChannel. * Returns port1 of that channel (unwired — the caller wires it once the kernel * exists; no /~/ fetch can precede exec(), which only runs after boot() resolves). */async function attachServiceWorker(host: HostToken, swUrl: string): Promise<MessagePort> { // register() at "/" replaces whatever is registered there. Refuse rather than evict // a foreign worker; an older copy of our own (another query, older bytes) updates. // A site worker that composes ours via importScripts() answers the ping: it already // is the runtime's worker, and registering would replace the site's own. const existing = await navigator.serviceWorker.getRegistration("/"); const newest = existing && (existing.installing ?? existing.waiting ?? existing.active); const atRoot = existing && newest && new URL(existing.scope).pathname === "/" ? newest : null; if (!atRoot || isRuntimeScript(atRoot.scriptURL, swUrl)) { try { await navigator.serviceWorker.register(swUrl, { scope: "/" }); } catch (cause) { throw new ServiceWorkerRegisterError( `registering the runtime's Service Worker (${swUrl}, scope "/") failed: ` + `${cause instanceof Error ? cause.message : String(cause)}`, { cause }, ); } } else if (!await isRuntimeWorker(atRoot, swUrl)) { throw new ServiceWorkerConflictError(atRoot.scriptURL, swUrl); } const reg = await navigator.serviceWorker.ready; const active = reg.active; // ready ⇒ an active worker exists for this scope if (!active) { throw new ServiceWorkerRegisterError( `the runtime's Service Worker (${swUrl}, scope "/") is not active after navigator.serviceWorker.ready resolved`, ); } // The page is uncontrolled on the first load after registration, until the SW's // activate-time claim lands, and after a hard reload, which bypasses an already // active SW whose activate never runs again. Workers created while uncontrolled // stay that way for their whole lifetime, so ask the SW to claim the page and wait // (bounded) for control before returning. if (!navigator.serviceWorker.controller) { await new Promise<void>((resolve, reject) => { const timer = setTimeout(() => reject(new ServiceWorkerUncontrolledError()), CONTROL_TIMEOUT_MS); navigator.serviceWorker.addEventListener("controllerchange", () => { clearTimeout(timer); resolve(); }, { once: true }); active.postMessage({ t: "claim" } satisfies HostToSw); }); } return openBridge(active, host);}
/** Ours by path: the query string (cache-busting, flags) does not change the worker. */function isRuntimeScript(scriptUrl: string, swUrl: string): boolean { return new URL(scriptUrl).pathname === new URL(swUrl).pathname;}
/** How long a worker gets to answer a ping. Only a foreign worker ever waits it out (ours * answers as soon as it runs), so the bound is generous: a stopped worker must start and * evaluate its scripts first, and a spurious "foreign" here would refuse a working boot. */const PING_TIMEOUT_MS = 1000;
/** Ours by path, or by answering a ping: a site worker that importScripts() ours runs the * runtime from its own URL. */async function isRuntimeWorker(worker: ServiceWorker, swUrl: string): Promise<boolean> { if (isRuntimeScript(worker.scriptURL, swUrl)) return true; const ch = new MessageChannel(); try { return await new Promise<boolean>((resolve) => { const timer = setTimeout(() => resolve(false), PING_TIMEOUT_MS); ch.port1.onmessage = (ev: MessageEvent<SwToHost>) => { if (ev.data?.t !== "pong") return; clearTimeout(timer); resolve(true); }; worker.postMessage({ t: "ping", port: ch.port2 } satisfies HostToSw, [ch.port2]); }); } finally { ch.port1.close(); }}
function openBridge(worker: ServiceWorker, host: HostToken): MessagePort { const ch = new MessageChannel(); worker.postMessage({ t: "bridge", host, port: ch.port2 } satisfies HostToSw, [ch.port2]); return ch.port1;}
const enc = new TextEncoder();
/** A terminal is at least one cell; sizes are whole cells, as TIOCSWINSZ takes them. */function checkSize(cols: number, rows: number): WinSize { if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || rows < 1 || cols > 0xffff || rows > 0xffff) { throw new RangeError(`pty size must be whole cells in 1..65535, got ${String(cols)}x${String(rows)}`); } return { cols, rows };}
export interface PackageStoreUsage { /** Bytes held by the package store's caches (packages, registry metadata, index). */ bytes: number; /** Stored packages. */ packages: number;}
/** What the origin-wide package store holds. It is shared by every page on the origin, * so this needs no runtime. */export async function packageStoreUsage(): Promise<PackageStoreUsage> { let bytes = 0; let packages = 0; for (const [kind, name] of Object.entries(PACKAGE_STORES)) { if (!(await caches.has(name))) continue; const cache = await caches.open(name); for (const key of await cache.keys()) { const res = await cache.match(key); if (res) bytes += (await res.arrayBuffer()).byteLength; if (kind === "packages") packages++; } } return { bytes, packages };}
/** Empty the origin-wide package store, so the next install fetches everything. Runtimes * already booted keep working; their next install starts cold. */export async function clearPackageStore(): Promise<void> { await Promise.all([...Object.values(PACKAGE_STORES), ...LEGACY_PACKAGE_STORES].map((name) => caches.delete(name)));}
/** Delete every directory `runtime.persist()` kept on the origin. */export async function clearPersisted(): Promise<void> { await Promise.all(Object.values(KEPT_DIRS_STORES).map((name) => caches.delete(name)));}
export interface BootOptions { /** Directory holding host.js's siblings (kernel.js, runtime.js, swc.wasm). Default: host.js's own directory. */ base?: string | URL; /** The runtime's Service Worker script. Default: sw.js in `base`. It registers at scope "/", so a script outside the origin root needs a Service-Worker-Allowed: / header. */ serviceWorker?: string | URL; /** The Cross-Origin-Resource-Policy the Service Worker sends on guest modules and guest * HTTP responses. "cross-origin" lets a coordinator on another site frame this origin's * guest pages. Rides on the Service Worker's script URL. Default: "same-origin". */ resourcePolicy?: ResourcePolicy; npm?: { /** Service endpoints of dev.nibling.npm.getClosure gateways, tried in order before an * install without a lockfile resolves (packages/npm-gateway). Any gateway works, and * anyone can run one; there is none by default, and an install never needs one. A * guest's `NIB_NPM_GATEWAYS` (space-separated, empty for none) overrides this. The * page's CSP must allow them in connect-src: contentSecurityPolicy({ npmGateways }). */ gateways?: readonly string[]; };}
const CONFIG_KEY: unique symbol = Symbol.for("nib.config");const KERNEL_KEY: unique symbol = Symbol.for("nib.runtime");
/** Page-wide state lives on globalThis under registered symbols, so two copies of this * module (a consumer's HMR re-running it, or two installs of the package) share it. */interface NibGlobal { [CONFIG_KEY]?: BootOptions; [KERNEL_KEY]?: { options: BootOptions; runtime: Promise<Kernel> };}const nibGlobal = globalThis as typeof globalThis & NibGlobal;
/** Set page-wide defaults for boot() and getKernel(). Options passed to either win over * these, field by field; these win over the built-in defaults. A later call merges over * an earlier one. */export function configure(options: BootOptions): void { nibGlobal[CONFIG_KEY] = { ...nibGlobal[CONFIG_KEY], ...defined(options) };}
function defined(options: BootOptions): BootOptions { return Object.fromEntries(Object.entries(options).filter(([, v]) => v !== undefined)) as BootOptions;}
function withConfig(options: BootOptions): BootOptions { return { ...nibGlobal[CONFIG_KEY], ...defined(options) };}
/** The page's runtime: booted on the first call, the same promise on every later one, * from any copy of this module. Later calls may pass no options or the same ones; * different ones reject with KernelExistsError. A failed boot is forgotten, so the next * call boots again, and so is a closed runtime. */export function getKernel(options: BootOptions = {}): Promise<Kernel> { const existing = nibGlobal[KERNEL_KEY]; if (existing) { if (Object.keys(defined(options)).length && !sameOptions(existing.options, withConfig(options))) { return Promise.reject(new KernelExistsError()); } return existing.runtime; } const effective = withConfig(options); const entry = { options: effective, runtime: boot(options) }; const forget = (): void => { if (nibGlobal[KERNEL_KEY] === entry) delete nibGlobal[KERNEL_KEY]; }; nibGlobal[KERNEL_KEY] = entry; entry.runtime.then((runtime) => { const close = runtime.close; runtime.close = () => { forget(); return close(); }; }, forget); return entry.runtime;}
/** Options are the same when they locate the same files and agree on everything else. */function sameOptions(a: BootOptions, b: BootOptions): boolean { const ra = resolveAssets(a); const rb = resolveAssets(b); if (ra.base.href !== rb.base.href || ra.serviceWorker !== rb.serviceWorker) return false; const keys = new Set([...Object.keys(a), ...Object.keys(b)] as Array<keyof BootOptions>); keys.delete("base"); keys.delete("serviceWorker"); return [...keys].every((k) => Object.is(a[k], b[k]));}
/** Where the runtime's files live. Relative strings resolve against the page * (document.baseURI); `base` names a directory, so a missing trailing "/" is added. */function resolveAssets(options: BootOptions): { base: URL; serviceWorker: string } { let base: URL; if (options.base === undefined) { // The files ship side by side with host.js. Not new URL(".", import.meta.url): that // shape is the asset pattern Vite and webpack resolve at build time. base = new URL(".", new URL(import.meta.url)); } else { base = new URL(options.base, document.baseURI); if (!base.pathname.endsWith("/")) base.pathname += "/"; } const serviceWorker = options.serviceWorker === undefined ? new URL("sw.js", base).href : new URL(options.serviceWorker, document.baseURI).href; return { base, serviceWorker };}
async function fetchSnapshot(src: string | URL): Promise<ArrayBuffer> { const url = new URL(src, document.baseURI).href; let res: Response; try { res = await fetch(url); } catch (cause) { const err = new NibError(`fetching the snapshot ${url} failed: ${cause instanceof Error ? cause.message : String(cause)}`, "ERR_NIB_MOUNT"); err.cause = cause; throw err; } if (!res.ok) throw new NibError(`fetching the snapshot ${url} failed: ${res.status} ${res.statusText}`.trimEnd(), "ERR_NIB_MOUNT"); return res.arrayBuffer();}
export async function boot(options: BootOptions = {}): Promise<Kernel> { if (!self.crossOriginIsolated) throw new IsolationError(); const effective = withConfig(options); const assets = resolveAssets(effective); const serviceWorker = new URL(assets.serviceWorker); if (effective.resourcePolicy) serviceWorker.searchParams.set(RESOURCE_POLICY_PARAM, effective.resourcePolicy);
// Names this page in every /~/ URL its runtime serves, for the page's whole life. const host = newHostToken(); let bridgePort: MessagePort | null = null; // the newest bridge; replies go here const pendingSources = new Map<number, number>(); // kernel moduleSource id → SW get id let nextSourceId = 1;
// Every SW failure is fatal: without the bridge the runtime would come up // half-working, CJS running while every guest import and server 404s. if (!("serviceWorker" in navigator)) throw new ServiceWorkerUnavailableError(); const firstBridge = await attachServiceWorker(host, serviceWorker.href); // A registration with a narrower scope wins the page even though ours registered: // every /~/ fetch would then miss our worker and every process would crash. const controller = navigator.serviceWorker.controller; if (!controller) throw new ServiceWorkerUncontrolledError(); if (!await isRuntimeWorker(controller, serviceWorker.href)) { throw new ServiceWorkerShadowedError(controller.scriptURL); }
// kernel.js sits in base and finds runtime.js and swc.wasm beside itself. The host's // own query string rides along (cache-busting and the ts-noengine containment demo's // nib-selftest flag are the current users). const kernelQuery = new URLSearchParams(new URL(import.meta.url).search); kernelQuery.set("nib-host", host); for (const gateway of options.npm?.gateways ?? []) kernelQuery.append("nib-npm-gateway", new URL(gateway).href); const kernelUrl = new URL(`kernel.js?${kernelQuery}`, assets.base).href; const kernel = new Worker(kernelUrl, { type: "module", name: "nib-kernel" });
let spawned = 0; const stdioChunks: Array<{ id: number; stream: "stdout" | "stderr"; bytes: Uint8Array }> = []; const exits: Array<{ id: number }> = []; const exitWaiters = new Map<number, Array<(status: ExitStatus) => void>>(); const streams = new Map<number, { stdout: ReadableStream<Uint8Array>; stderr: ReadableStream<Uint8Array>; sc: ReadableStreamDefaultController<Uint8Array>; ec: ReadableStreamDefaultController<Uint8Array>; }>();
// Mirror of the kernel's port table, kept to replay onListening() without a round // trip; the kernel stays the routing authority. const listeningPorts = new Set<number>(); const listenListeners = new Set<(port: number, url: string) => void>(); const unlistenListeners = new Set<(port: number) => void>();
// Mirror of the kernel's process table, for the same reason. Pids only grow, so the // map's insertion order is oldest first. const processes = new Map<number, ProcessInfo>(); const processListeners = new Set<(table: readonly ProcessInfo[]) => void>(); const pidWaiters = new Map<number, (pid: number) => void>();
function setProcess(info: ProcessInfo | null, pid: number): void { if (info) processes.set(pid, info); else processes.delete(pid); const table = [...processes.values()]; for (const listener of [...processListeners]) listener(table); }
function setPorts(pid: number, update: (ports: readonly number[]) => readonly number[]): void { const info = processes.get(pid); if (info) setProcess({ ...info, ports: update(info.ports) }, pid); }
function toBridge(reply: BridgeReply): void { bridgePort?.postMessage(reply); }
function handleHostMessage(ev: MessageEvent<KernelToHost>): void { const msg = ev.data; if (msg.t === "stdio") { stdioChunks.push({ id: msg.id, stream: msg.stream, bytes: msg.bytes }); pump(); } else if (msg.t === "exited") { exits.push({ id: msg.id }); for (const w of exitWaiters.get(msg.id) ?? []) w(msg.status); exitWaiters.delete(msg.id); pump(); } else if (msg.t === "guestWrite") { const { t: _, ...write } = msg; for (const [listener, prefixes] of writeObservers) if (underAny(write.path, prefixes)) listener(write); } else if (msg.t === "fsResult") { // Settle the pending host fs call (one fsResult per fs request, kernel-guaranteed). const waiter = pendingFs.get(msg.id); if (!waiter) return; pendingFs.delete(msg.id); if (msg.ok) waiter.resolve(msg.val); else waiter.reject(toNodeError(msg.err ?? { code: "EIO", syscall: "fs" })); } else if (msg.t === "fsChanged") { noteFsChanged(); } else if (msg.t === "source") { // Route the kernel's module bytes back to the SW fetch that asked for them. const swId = pendingSources.get(msg.id); pendingSources.delete(msg.id); toBridge({ t: "source", id: swId ?? msg.id, ok: msg.ok, bytes: msg.bytes }); } else if (msg.t === "httpResponse") { toBridge({ t: "httpResult", id: msg.id, res: msg.res, error: msg.error }); } else if (msg.t === "spawned") { pidWaiters.get(msg.id)?.(msg.pid); pidWaiters.delete(msg.id); } else if (msg.t === "procStarted") { setProcess({ pid: msg.pid, ppid: msg.ppid, argv: msg.argv, cwd: msg.cwd, ports: [] }, msg.pid); } else if (msg.t === "procExited") { setProcess(null, msg.pid); } else if (msg.t === "listening") { listeningPorts.add(msg.port); setPorts(msg.pid, (ports) => [...ports, msg.port]); for (const listener of [...listenListeners]) listener(msg.port, httpUrl(host, msg.port)); } else if (msg.t === "unlistened") { listeningPorts.delete(msg.port); setPorts(msg.pid, (ports) => ports.filter((p) => p !== msg.port)); for (const listener of [...unlistenListeners]) listener(msg.port); } else if (msg.t === "installed") { const waiter = pendingInstalls.get(msg.id); pendingInstalls.delete(msg.id); if (msg.ok) waiter?.resolve(msg.report); else { const { message, ...fields } = msg.error; waiter?.reject(Object.assign(new Error(message), fields)); } } else if (msg.t === "kept") { const waiter = pendingKeeps.get(msg.id); pendingKeeps.delete(msg.id); if (msg.ok) waiter?.resolve(msg.report); else waiter?.reject(new Error(msg.error)); } }
let nextKeepId = 1; const pendingKeeps = new Map<number, { resolve: (r: KeepReport) => void; reject: (e: Error) => void }>(); const keep = (op: KeepOp, { key, paths }: PersistOptions): Promise<KeepReport> => { const id = nextKeepId++; return new Promise((resolve, reject) => { pendingKeeps.set(id, { resolve, reject }); kernel.postMessage({ t: "keep", id, op, key, paths: [...paths] } satisfies HostToKernel); }); };
let nextInstallId = 1; const pendingInstalls = new Map<number, { resolve: (r: InstallReport) => void; reject: (e: Error) => void }>();
// ---- host fs facade (async port RPC — the page never blocks) ----
let nextFsId = 1; const pendingFs = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
function fsCall(op: HostFsOp, args: unknown): Promise<unknown> { const id = nextFsId++; return new Promise((resolve, reject) => { pendingFs.set(id, { resolve, reject }); kernel.postMessage({ t: "fs", id, op, args } satisfies HostToKernel); }); }
// fsChanged fan-out, coalesced: the kernel emits after each mutating op (a // composed op like writeFile coalesces kernel-side per message); a burst of // messages lands within this window and notifies listeners once. const FS_CHANGED_COALESCE_MS = 25; const fsListeners = new Set<() => void>(); let fsChangedTimer: ReturnType<typeof setTimeout> | null = null; function noteFsChanged(): void { if (fsChangedTimer !== null) return; fsChangedTimer = setTimeout(() => { fsChangedTimer = null; for (const listener of [...fsListeners]) listener(); }, FS_CHANGED_COALESCE_MS); }
const writeObservers = new Map<(write: GuestWrite) => void, string[]>(); const underAny = (path: string, prefixes: string[]) => prefixes.some((p) => path === p || path.startsWith(p.endsWith("/") ? p : `${p}/`)); function syncObserved(): void { const prefixes = [...new Set([...writeObservers.values()].flat())]; kernel.postMessage({ t: "observeWrites", prefixes } satisfies HostToKernel); }
const fs: HostFs = { async readFile(path) { return (await fsCall("readFile", { path })) as Uint8Array; }, async writeFile(path, data) { await fsCall("writeFile", { path, data }); }, async mkdir(path, opts) { await fsCall("mkdir", { path, recursive: opts?.recursive ?? false }); }, async readdir(path) { return (await fsCall("readdir", { path })) as Dirent[]; }, async stat(path) { return (await fsCall("stat", { path })) as HostStat; }, async unlink(path) { await fsCall("unlink", { path }); }, async rm(path, opts) { // Node rm semantics: a file or link unlinks, a directory rmdirs (recursive // removes the subtree). The dir path is the brief's "rmdir under the hood". const st = (await fsCall("stat", { path, follow: false })) as StatRecord; if (st.kind === "dir") await fsCall("rmdir", { path, recursive: opts?.recursive ?? false }); else await fsCall("unlink", { path }); }, async rename(from, to) { await fsCall("rename", { from, to }); }, async symlink(target, path) { await fsCall("symlink", { target, path }); }, async readlink(path) { return ((await fsCall("readlink", { path })) as { target: string }).target; }, };
/** Delete what under `root` is not in `keep` (see MountOptions.replace). */ async function prune(root: string, keep: Set<string>): Promise<void> { const top = root.replace(/\/+$/, ""); if (!top) throw new TypeError("mount() with replace: true needs an app root below \"/\""); // Resolves true when `dir` is left empty and holds none of `keep`. const walk = async (dir: string): Promise<boolean> => { let empty = true; await Promise.all((await fs.readdir(dir)).map(async ({ name, kind }) => { const path = `${dir}/${name}`; if (kind === "dir") { if (await walk(path)) await fs.rm(path); else empty = false; } else if (keep.has(path)) empty = false; else await fs.unlink(path); })); return empty; }; await walk(top).catch((err: unknown) => { if ((err as { code?: string }).code !== "ENOENT") throw err; // nothing mounted there yet }); }
const kernelReady = new Promise<void>((resolve, reject) => { const timer = setTimeout(() => reject(new Error("kernel boot timeout")), 5000); kernel.onmessage = (ev: MessageEvent<KernelToHost>) => { const msg = ev.data; clearTimeout(timer); // Always swap to the main handler, even when the first message is `ready`, // otherwise later stdio/exit messages would hit this boot listener and be dropped. kernel.onmessage = handleHostMessage; if (msg.t === "ready") resolve(); }; kernel.onerror = (e) => { clearTimeout(timer); reject(new Error(`kernel error: ${e.message}`)); }; });
function makeStreams(): { stdout: ReadableStream<Uint8Array>; stderr: ReadableStream<Uint8Array>; sc: ReadableStreamDefaultController<Uint8Array>; ec: ReadableStreamDefaultController<Uint8Array>; } { let sc!: ReadableStreamDefaultController<Uint8Array>; let ec!: ReadableStreamDefaultController<Uint8Array>; const stdout = new ReadableStream<Uint8Array>({ start(c) { sc = c; } }); const stderr = new ReadableStream<Uint8Array>({ start(c) { ec = c; } }); return { stdout, stderr, sc, ec }; }
function pump(): void { for (let i = 0; i < stdioChunks.length; i++) { const c = stdioChunks[i]!; const s = streams.get(c.id); if (s) { (c.stream === "stdout" ? s.sc : s.ec).enqueue(c.bytes); stdioChunks.splice(i, 1); i--; } } for (let i = 0; i < exits.length; i++) { const e = exits[i]!; const s = streams.get(e.id); if (s) { s.sc.close(); s.ec.close(); streams.delete(e.id); exits.splice(i, 1); i--; } } }
await kernelReady; // Post-boot kernel errors must not vanish: surface them on the page console. kernel.onerror = (e) => console.error("nib kernel error:", e.message ?? e);
// Wire the bridge now that the kernel exists. No /~/ fetch can arrive before // exec() — runtime workers (the only module-fetching clients) do not exist yet. function serveBridge(port: MessagePort): void { bridgePort?.close(); bridgePort = port; port.onmessage = (ev: MessageEvent<BridgeRequest>) => { const req = ev.data; if (req?.t === "http") { kernel.postMessage({ t: "httpRequest", id: req.id, port: req.port, req: req.req } satisfies HostToKernel); return; } if (req?.t === "connect") { kernel.postMessage({ t: "connect", port: req.port, socket: req.socket } satisfies HostToKernel, [req.socket]); return; } if (req?.t !== "get" || typeof req.url !== "string") return; const parsed = parseVirtualPath(new URL(req.url).pathname); if (!parsed || parsed.kind === "http") { toBridge({ t: "source", id: req.id, ok: false }); return; } const kernelId = nextSourceId++; pendingSources.set(kernelId, req.id); kernel.postMessage({ t: "moduleSource", id: kernelId, kind: parsed.kind, path: parsed.path } satisfies HostToKernel); }; }
// The SW loses its bridges when the browser stops it. On restart it asks every // window to register again, and this page answers with a new channel and the same // token, wired to the same kernel. function onSwMessage(ev: MessageEvent<SwToHost>): void { if (ev.data?.t === "rebridge" && ev.source instanceof ServiceWorker) serveBridge(openBridge(ev.source, host)); } serveBridge(firstBridge); navigator.serviceWorker.addEventListener("message", onSwMessage); navigator.serviceWorker.startMessages();
function exec(entry: string, options: PtyExecOptions): PtyProcess; function exec(entry: string, options?: ExecOptions): RunningProcess; function exec(entry: string, options: ExecOptions & { pty?: WinSize } = {}): RunningProcess | PtyProcess { const processRuntime = options.runtime ?? "node"; if (processRuntime !== "node" && processRuntime !== "web") throw new TypeError(`Unknown process runtime: ${String(processRuntime)}`); const stdio: StdioSpec = options.pty ? { kind: "pty", size: checkSize(options.pty.cols, options.pty.rows) } : { kind: "pipe" }; spawned++; const id = spawned; const entryPath = entry.startsWith("/") ? entry : `/app/${entry.replace(/^\.\//, "")}`; const s = makeStreams(); streams.set(id, s); const status = new Promise<ExitStatus>((resolve) => { const waiters = exitWaiters.get(id) ?? []; waiters.push(resolve); exitWaiters.set(id, waiters); pump(); // flush if the process already exited }); const pid = new Promise<number>((resolve) => pidWaiters.set(id, resolve)); const msg: HostToKernel = { t: "spawn", id, entry: entryPath, argv: options.argv ?? [], env: options.env ?? {}, cwd: options.cwd ?? "/app", stdio, runtime: processRuntime, }; kernel.postMessage(msg); const kill = (sig: Signal = "SIGTERM"): void => { if (!isSignal(sig)) throw new TypeError(`Unknown signal: ${String(sig)}`); kernel.postMessage({ t: "kill", id, sig } satisfies HostToKernel); }; const base: ProcessBase = { pid, stdout: s.stdout, stderr: s.stderr, exited: status.then(exitNumber), signal: status.then((st) => st.signal), kill, }; if (stdio.kind === "pty") { const pty: PtyMaster = { write(data) { const bytes = typeof data === "string" ? enc.encode(data) : data; if (bytes.length) kernel.postMessage({ t: "ptyInput", id, bytes } satisfies HostToKernel); }, resize(cols, rows) { kernel.postMessage({ t: "ptyResize", id, size: checkSize(cols, rows) } satisfies HostToKernel); }, }; return { ...base, pty }; } const sendStdin = (bytes: Uint8Array | null): void => kernel.postMessage({ t: "stdin", id, bytes } satisfies HostToKernel); const stdin = new WritableStream<Uint8Array>({ write: (chunk) => sendStdin(chunk), close: () => sendStdin(null), abort: () => sendStdin(null), }); return { ...base, stdin }; }
const runtime: Kernel = { exec, writeTree(files) { const entries = Object.entries(files).map(([path, data]) => ({ path, data })); kernel.postMessage({ t: "seed", entries } satisfies HostToKernel); }, mountSnapshot(snapshot) { const entries = snapshotEntries(snapshot); kernel.postMessage({ t: "seed", entries } satisfies HostToKernel, [snapshot]); }, install(options) { const id = nextInstallId++; return new Promise((resolve, reject) => { pendingInstalls.set(id, { resolve, reject }); kernel.postMessage({ t: "install", id, cwd: options?.cwd ?? "/app" } satisfies HostToKernel); }); }, async persist(options) { const kept = { key: options.key, paths: [...options.paths] }; return { restored: await keep("restore", kept), save: () => keep("save", kept) }; }, async mount(app, options = {}) { let buffer: ArrayBuffer; let root: string | undefined; if (app instanceof ArrayBuffer) buffer = app; else if (typeof app === "string" || app instanceof URL) buffer = await fetchSnapshot(app); else { buffer = await fetchSnapshot(app.src); root = app.root; } const entries = snapshotEntries(buffer); root ??= commonDir(entries.map((e) => e.path)); if (options.replace) await prune(root, new Set(entries.map((e) => e.path))); kernel.postMessage({ t: "seed", entries } satisfies HostToKernel, [buffer]); return { root }; }, // Composed from host fs calls, two round trips per file but all in flight at once. // It can become one kernel message (one fsChanged burst) when that matters. async applyFiles(files) { await Promise.all(Object.entries(files).map(async ([path, data]) => { if (data === null) { await fs.unlink(path).catch((err: unknown) => { if ((err as { code?: string }).code !== "ENOENT") throw err; }); return; } const dir = path.slice(0, path.lastIndexOf("/")); if (dir) await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path, data); })); }, fs, onFsChanged(listener) { fsListeners.add(listener); return () => { fsListeners.delete(listener); }; }, observeWrites(prefixes, listener) { writeObservers.set(listener, prefixes); syncObserved(); return () => { writeObservers.delete(listener); syncObserved(); }; }, async putFile(path, data) { const dir = path.slice(0, path.lastIndexOf("/")); if (dir) await fs.mkdir(dir, { recursive: true }); const { gen } = (await fsCall("writeFile", { path, data })) as { gen: number }; return gen; }, waitForPort(port) { return new Promise((resolve) => { const off = runtime.onListening((p, url) => { if (p !== port) return; off(); resolve(url); }); }); }, onListening(listener) { listenListeners.add(listener); const replay = [...listeningPorts]; queueMicrotask(() => { for (const port of replay) { if (listenListeners.has(listener) && listeningPorts.has(port)) listener(port, httpUrl(host, port)); } }); return () => { listenListeners.delete(listener); }; }, onUnlistening(listener) { unlistenListeners.add(listener); return () => { unlistenListeners.delete(listener); }; }, onProcesses(listener) { processListeners.add(listener); queueMicrotask(() => { if (processListeners.has(listener)) listener([...processes.values()]); }); return () => { processListeners.delete(listener); }; }, kill(pid, sig = "SIGTERM") { if (!isSignal(sig)) throw new TypeError(`Unknown signal: ${String(sig)}`); kernel.postMessage({ t: "killPid", pid, sig } satisfies HostToKernel); }, close() { navigator.serviceWorker.removeEventListener("message", onSwMessage); kernel.terminate(); return Promise.resolve(); }, };
return runtime;}