// Wire and mailbox protocol shared by kernel and runtime. // This file is the boundary contract; nothing here may appear on the public Kernel API. // Step-1 subset of the SyscallSpec table in DESIGN.md; the table grows per step. import type { HostToken } from "./vurls"; import { SIGNALS, type InstallReport, type KeepReport, type ProcessRuntime, type Signal, type Substitution, type WinSize } from "./public-types"; export { SIGNALS }; export type { InstallReport, KeepReport, ProcessRuntime, Signal, Substitution, WinSize }; export type SyscallName = | "open" | "read" | "write" | "close" | "stat" | "fstat" | "mkdir" | "readdir" | "unlink" | "rmdir" | "rename" | "symlink" | "readlink" | "realpath" | "resolve" | "loadModule" | "loadEsmSync" | "stripTypes" | "exit" | "listen" | "unlisten" | "kill" | "sigaction" | "ttySetRaw" | "ttyGetSize" | "newThread" | "spawn" | "watch" | "unwatch" | "ftruncate" | "utimes" | "futimes" | "npm" | "npmLookup"; export type FsErrno = | "ENOENT" | "EEXIST" | "ENOTDIR" | "EISDIR" | "EBADF" | "EINVAL" | "ENOTEMPTY" | "EMFILE" | "EIO" | "EAGAIN" | "EADDRINUSE" | "ESRCH" | "ENOTTY" | "ELOOP"; export interface FsError { code: FsErrno; syscall: string; path?: string; dest?: string; } export type FsResult = { ok: true; val: T } | { ok: false; err: FsError }; export type OpenFlags = { read: boolean; write: boolean; append: boolean; trunc: boolean; create: boolean; excl: boolean; }; export type FileKind = "file" | "dir" | "symlink"; export type StatRecord = { kind: FileKind; ino: number; size: number; mode: number; mtimeMs: number; }; /** One directory entry: readdir returns names AND their kind so callers can * distinguish files from dirs without a stat per entry. */ export type DirentRecord = { name: string; kind: FileKind }; export type ResolvedModule = | { kind: "builtin"; id: string } | { kind: "file"; path: string; format: "cjs" | "json" | "esm" } | { kind: "missing"; code: "ERR_MODULE_NOT_FOUND" | "ERR_UNKNOWN_BUILTIN_MODULE" | "ERR_PACKAGE_PATH_NOT_EXPORTED" | "ERR_PACKAGE_IMPORT_NOT_DEFINED" | "ERR_INVALID_PACKAGE_CONFIG" | "ERR_INVALID_PACKAGE_TARGET" | "ERR_UNSUPPORTED_DIR_IMPORT" | "ERR_DLOPEN_FAILED" }; /** One export of an ES module in function form: its own binding (`local` is the * identifier in the code), a re-export of a request's export (`imported` null for * `export * as name`), or `export * from` a request. */ export type EsmExport = | { kind: "local"; name: string; local: string } | { kind: "reexport"; name: string; request: number; imported: string | null } | { kind: "star"; request: number }; /** The local an `export default ` binds: "export default" and * "const __nib_d=" are both 14 characters. */ export const ESM_DEFAULT_LOCAL = "__nib_d"; /** The value the code's i-th import binding takes: a request's export, or its * namespace when `imported` is null. */ export interface EsmBinding { request: number; imported: string | null } /** An ES module made synchronously evaluable for require() (kernel esm-sync.ts). */ export interface EsmSyncModule { /** `(function (__nib_meta, __nib_def, __nib_bind, __nibI) { … })`: calling it * declares the import bindings and returns the module body as a function. */ code: Uint8Array; /** Each module request in source order, resolved with the import conditions. A * CommonJS or builtin request carries its named exports (cjs-module-lexer's, as * Node's are, or the builtin's own). */ requests: { specifier: string; resolved: ResolvedModule; names?: readonly string[] }[]; bindings: EsmBinding[]; exports: EsmExport[]; } export interface SyscallSpec { open: { args: { path: string; flags: OpenFlags; mode: number }; ret: { fd: number } }; read: { args: { fd: number; len: number; pos: number | null }; ret: { bytes: Uint8Array; eof: boolean } }; write: { args: { fd: number; data: Uint8Array; pos: number | null }; ret: { written: number } }; close: { args: { fd: number }; ret: null }; stat: { args: { path: string; follow: boolean }; ret: StatRecord }; fstat: { args: { fd: number }; ret: StatRecord }; readdir: { args: { path: string }; ret: DirentRecord[] }; unlink: { args: { path: string }; ret: null }; rmdir: { args: { path: string; recursive: boolean }; ret: null }; rename: { args: { from: string; to: string }; ret: null }; // A link holding `target` verbatim; the target need not exist. symlink: { args: { target: string; path: string }; ret: null }; readlink: { args: { path: string }; ret: { target: string } }; // The path with every symlink in it resolved, as realpath(3) walks it. realpath: { args: { path: string }; ret: { path: string } }; mkdir: { args: { path: string; mode: number; recursive: boolean }; ret: { path: string | null } }; // Set (or grow/shrink) a file's length by fd, as ftruncate(2); fs.truncate(path, …) // opens the path first and runs this on the resulting fd, as Node's does. ftruncate: { args: { fd: number; len: number }; ret: null }; // Set a path's stored time (the one time field behind atime/mtime/ctime/birthtime); // `follow` is false for lutimes. Node's atime has nothing to land in, so it is // accepted and dropped — only mtime is kept. utimes: { args: { path: string; follow: boolean; mtimeMs: number }; ret: null }; futimes: { args: { fd: number; mtimeMs: number }; ret: null }; resolve: { args: { specifier: string; parent: string; kind: "require" | "import" }; ret: ResolvedModule }; loadModule:{ args: { path: string }; ret: { source: Uint8Array; format: "cjs" | "json" | "esm" } }; loadEsmSync:{ args: { path: string }; ret: EsmSyncModule }; // module.stripTypeScriptTypes: the same engine loadModule uses for a .ts path, run // on a caller-supplied string instead of a file (no node_modules/.tsx loader rules — // those are about *which files* strip, not what the engine does to a given string). // The outcome is itself a result (not FsError: a SyntaxError here isn't a POSIX code). stripTypes: { args: { code: string }; ret: { ok: true; js: string } | { ok: false; code: string; message: string } }; exit: { args: { code: number }; ret: never }; listen: { args: { port: number }; ret: null }; unlisten: { args: { port: number }; ret: null }; // A signal to `pid` (the caller may name itself); null only checks that pid exists, as // signal 0 does. Never replies when it ends the caller. kill: { args: { pid: number; sig: Signal | null }; ret: null }; // The caller has (or no longer has) a listener for `sig`: the input to signalAction. sigaction: { args: { sig: Signal; handled: boolean }; ret: null }; // ENOTTY unless the caller's stdio is a pty. ttySetRaw: { args: { raw: boolean }; ret: null }; ttyGetSize:{ args: Record; ret: WinSize }; // A thread id for a worker the caller is about to start (startThread): sync, because // `new Worker()` returns with its threadId set. Ids are per process and never reused. newThread: { args: Record; ret: { tid: number } }; // A child process running `node `, owned by the calling thread: its piped // output and its exit come back to that thread (childStdio, childExited). spawn: { args: { entry: string; argv: string[]; env: Record; cwd: string; stdio: ChildStdio }; ret: { pid: number } }; // An inotify-style watch on one file or directory (not recursive), owned by the // calling thread: its events come back to that thread as fsEvents. watch: { args: { path: string }; ret: { wid: number } }; unwatch: { args: { wid: number }; ret: null }; // An install in the kernel for the caller (the guest npm command). Async only: it // answers once every fetch is done, and the kernel serves other calls meanwhile. The // call itself succeeds; npm's own failure (ERESOLVE, EUSAGE...) is the outcome's. npm: { args: InstallRequest; ret: InstallOutcome }; // libnpmexec's question for `npm exec` and npx, async as `npm` is: does node_modules // under `path` have a package for `spec`, or which version would install for it. npmLookup: { args: ExecLookupRequest; ret: ExecLookup }; } /** One fs.watch event: Node's eventType and the filename it reports. */ export type WatchEvent = [type: "rename" | "change", filename: string]; /** Where each of a child's stdio fds goes. `inherit` is the parent process's own fd; * a child's stdin cannot inherit (nothing routes the parent's input onward). */ export interface ChildStdio { stdin: "pipe" | "ignore"; stdout: "pipe" | "inherit" | "ignore"; stderr: "pipe" | "inherit" | "ignore"; } // ---- threads (node:worker_threads) ---- // A thread is a runtime worker inside its parent's process: same pid, a snapshot of the // parent's env and cwd, its own loop and mailbox. The kernel starts it and reports its // end; everything between the parent and the thread (messages, stdio, its uncaught // error) travels on a port pair the parent creates, never through the kernel. /** One end of a node:worker_threads port pair as it crosses threads: the native port is * the doorbell and carries messages with transferables; the two shared queues carry * everything else, readable synchronously (receiveMessageOnPort). `rx`/`tx` are named * from this end; the counters are this end's position in each direction's sequence. */ export interface PortDescriptor { readonly nibPort: true; native: MessagePort; rx: SharedArrayBuffer; tx: SharedArrayBuffer; rxSeq: number; txSeq: number; } /** What only a thread's init carries. `workerData` and `environmentData` were cloned * natively from the parent, so ports in them arrive as PortDescriptors. */ export interface ThreadInit { tid: number; name: string; /** Source to run as the entry script (`eval: true`); null runs the init's `entry`. */ eval: string | null; workerData: unknown; environmentData: Array<[key: unknown, value: unknown]>; /** The thread's end of its channel to the parent (parentPort and the internal records). */ port: PortDescriptor; } /** How a thread ended, as its parent hears it from the kernel. */ export interface ThreadExit { tid: number; code: number; /** Ended from outside (terminate(), its parent or its process ending): records it sent * natively and still in flight are not waited for. */ terminated: boolean; } // ---- signals ---- // Signal itself (and SIGNALS) lives in shared/public-types.ts — see the comment there. /** What a signal does to a process with no listener for it. There is no job control, so * nothing can keep a stopped process stopped: a stop lasts no time, and the process is * continued (sent SIGCONT) at once. */ export const SIGNAL_DEFAULT: Record = { SIGHUP: "terminate", SIGINT: "terminate", SIGQUIT: "terminate", SIGILL: "terminate", SIGTRAP: "terminate", SIGABRT: "terminate", SIGBUS: "terminate", SIGFPE: "terminate", SIGKILL: "terminate", SIGUSR1: "terminate", SIGSEGV: "terminate", SIGUSR2: "terminate", SIGPIPE: "terminate", SIGALRM: "terminate", SIGTERM: "terminate", SIGCHLD: "ignore", SIGCONT: "ignore", SIGSTOP: "stop", SIGTSTP: "stop", SIGTTIN: "stop", SIGTTOU: "stop", SIGURG: "ignore", SIGXCPU: "terminate", SIGXFSZ: "terminate", SIGVTALRM: "terminate", SIGPROF: "terminate", SIGWINCH: "ignore", SIGIO: "terminate", SIGPWR: "terminate", SIGSYS: "terminate", }; export const isSignal = (name: unknown): name is Signal => typeof name === "string" && Object.hasOwn(SIGNALS, name); /** The delivery rule: SIGKILL always terminates and SIGSTOP always stops; a listener * takes any other signal; without one the default action applies. */ export function signalAction(sig: Signal, handled: boolean): "terminate" | "deliver" | "ignore" | "stop" { if (sig === "SIGKILL") return "terminate"; if (sig === "SIGSTOP") return "stop"; if (handled) return "deliver"; return SIGNAL_DEFAULT[sig]; } /** How a process ended: an exit code, or the signal that terminated it. */ export type ExitStatus = { code: number; signal: null } | { code: null; signal: Signal }; /** The shell's number for an exit status: the code, or 128 + the signal's number. */ export const exitNumber = (s: ExitStatus): number => (s.signal === null ? s.code : 128 + SIGNALS[s.signal]); // ---- terminals ---- // WinSize lives in shared/public-types.ts — see the comment there. /** The termios flags the line discipline honours (src/kernel/pty.ts). */ export interface Termios { /** Canonical mode: line editing, and input delivered a line at a time. */ icanon: boolean; echo: boolean; /** ^C raises SIGINT and ^\ raises SIGQUIT in the foreground process. */ isig: boolean; /** CR becomes NL on input. */ icrnl: boolean; /** NL becomes CR NL on output. */ onlcr: boolean; } /** A process's stdio: pipes to the host (stdin, stdout and stderr apart), or all three * fds on one kernel pty whose master side the host holds. */ export type StdioSpec = { kind: "pipe" } | { kind: "pty"; size: WinSize }; // ---- guest http over the SW virtual origin (/~/@/http//...) ---- export type HttpHeaders = Array<[name: string, value: string]>; // lowercased names, order kept export interface HttpRequestRecord { method: string; url: string /* path+query, starts with "/" */; headers: HttpHeaders; body: Uint8Array | null } export interface HttpResponseRecord { status: number; headers: HttpHeaders; body: Uint8Array } // ---- raw connections (a page WebSocket upgrading to a guest server) ---- // A connection is one MessagePort carrying bytes both ways, end to end between the page // and the runtime worker that owns the port: Uint8Array is data, null is end-of-stream // (a FIN). Nothing between them relays it; each hop only transfers the port. export type ConnectionData = Uint8Array | null; /** What the page reads on a connection: data, or (first, from the kernel) the port the * kernel uses to say the owning process exited, since a dead worker cannot close it. */ export type ConnectionMessage = ConnectionData | { t: "watch"; port: MessagePort }; /** On a watch port: the process that owned the connection is gone (close code 1006). */ export type WatchMessage = { t: "exited" }; /** A whole HTTP/1.1 response that closes the connection, for answers no guest wrote. */ export function rawHttpResponse(status: number, reason: string, text: string): Uint8Array { const body = new TextEncoder().encode(text); const head = `HTTP/1.1 ${status} ${reason}\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: ${body.length}\r\nconnection: close\r\n\r\n`; const out = new Uint8Array(head.length + body.length); out.set(new TextEncoder().encode(head)); out.set(body, head.length); return out; } export type Req = { t: "req"; id: number; op: SyscallName; args?: unknown; sync?: true }; export type Res = { t: "res"; id: number; ok: boolean; val?: unknown; err?: FsError }; // ---- control plane ---- /** One file the host seeds into the VFS. A snapshot's recorded `mtimeMs` becomes the * file's mtime; without one the file gets the seed time. */ export interface SeedEntry { path: string; data: string | Uint8Array; mtimeMs?: number } export type SaveType = "prod" | "dev" | "optional" | "peer"; /** One npm command the kernel's installer runs in `cwd`, as the guest npm parsed it; * runtime.install() is `install` with nothing to add. */ export type InstallRequest = | { command: "install"; cwd: string; /** Package specs as typed: "zod", "zod@3", "z@npm:zod@^3". */ add: string[]; /** false is --no-save: neither package.json nor package-lock.json is written. */ save: false | { /** -D, -O, --save-peer, -P; null saves where package.json has it, else dependencies. */ type: SaveType | null; /** -E: the version without npm's `^` save-prefix. */ exact: boolean; }; } | { command: "uninstall"; cwd: string; remove: string[]; save: boolean } /** Install package-lock.json as it is, which package.json must agree with; writes neither. */ | { command: "ci"; cwd: string }; /** An npm error as npm's error-message.js reads it off the thrown error. */ export interface InstallError { message: string; code?: string; errno?: number; syscall?: string; path?: string; /** "name@spec" for an E404: the package the registry does not have. */ pkgid?: string; } /** A spec as `npm exec --package` or npx was given it, looked up in node_modules under * `path`; `npx` when that is an npx cache dir, which takes no older version. */ export interface ExecLookupRequest { spec: string; path: string; npx: boolean; } /** What libnpmexec reads off a package to run its bin: package.json's, or the manifest's. */ export interface ExecPackage { name: string; version: string; /** Normalized as read-package-json does: a string bin is the unscoped name's. */ bin: Record; } /** `found` is a package node_modules has; otherwise `pkg` is the manifest to install. */ export type ExecLookup = { ok: true; found: boolean; pkg: ExecPackage } | { ok: false; error: InstallError }; /** Restore the directories a key holds into the FS, or save them from it under the key. */ export type KeepOp = "restore" | "save"; // KeepReport lives in shared/public-types.ts: the host API returns it. export type KeepOutcome = { ok: true; report: KeepReport } | { ok: false; error: string }; export type InstallOutcome = { ok: true; report: InstallReport } | { ok: false; error: InstallError }; // InstallReport lives in shared/public-types.ts: the host API returns it. // Host fs ops (DESIGN §host-fs): the page never blocks, so host-side fs is ASYNC // port RPC — not the SAB mailbox, which exists so GUEST code can block. Each op is // answered by exactly one `fsResult` carrying the same id. export type HostFsOp = | "readFile" | "writeFile" | "mkdir" | "readdir" | "stat" | "unlink" | "rmdir" | "rename" | "symlink" | "readlink"; export type HostToKernel = | { t: "admin"; port: MessagePort; runtimeUrl: string } | { t: "spawn"; id: number; entry: string; argv: string[]; env: Record; cwd: string; stdio: StdioSpec; runtime: ProcessRuntime } | { t: "seed"; entries: SeedEntry[] } // Signal the process spawned with this id; a no-op once it has exited. | { t: "kill"; id: number; sig: Signal } // Bytes for a pipe process's stdin; null closes it (EOF). Ignored for a pty process. | { t: "stdin"; id: number; bytes: Uint8Array | null } // Keystrokes into a pty's master side, through the line discipline. | { t: "ptyInput"; id: number; bytes: Uint8Array } | { t: "ptyResize"; id: number; size: WinSize } // Host fs call: op + op-shaped args (shaped by host/index.ts, trusted at the port). | { t: "fs"; id: number; op: HostFsOp; args: unknown } // ESM bridge: the host relays one SW virtual-origin fetch. `id` is host-local and // echoed on the `source` reply; kind/path come from the /~/ URL (shared/vurls.ts). | { t: "moduleSource"; id: number; kind: "vfs" | "builtin" | "cjs"; path: string } // The SW's bridge id rides every hop unchanged (SW → host → kernel → runtime → back). | { t: "httpRequest"; id: number; port: number; req: HttpRequestRecord } // A raw connection to the guest server on `port` (the socket MessagePort is transferred). | { t: "connect"; port: number; socket: MessagePort } // Install node_modules from `${cwd}/package-lock.json`; answered by one `installed`. | { t: "install"; id: number; cwd: string } // Restore or save kept directories (kernel/kept-dirs.ts); answered by one `kept`. | { t: "keep"; id: number; op: KeepOp; key: string; paths: string[] }; export type KernelToHost = | { t: "ready" } | { t: "spawned"; id: number; pid: number } // A pty process's output and its echo arrive on "stdout": the master side. | { t: "stdio"; id: number; stream: "stdout" | "stderr"; bytes: Uint8Array } | { t: "exited"; id: number; status: ExitStatus } // Reply to one `fs` request. Errors travel as FsError; the host maps them to // Node-flavoured Error objects (shared/nodeerr.ts). | { t: "fsResult"; id: number; ok: boolean; val?: unknown; err?: FsError } // Emitted after ANY successful filesystem mutation from ANY writer (guest // syscalls, host fs ops, seed). Sound because the kernel is the single writer. // This is a demo-harness notification piggybacking on that invariant — NOT // fs.watch (per-watcher event delivery stays step-8 scope). | { t: "fsChanged" } // Module bytes for a moduleSource request; ok:false → the SW answers 404. | { t: "source"; id: number; ok: boolean; bytes?: Uint8Array } // res:null → no live listener (or it exited mid-request); the SW answers 502. | { t: "httpResponse"; id: number; res: HttpResponseRecord | null; error?: string } | { t: "listening"; port: number } | { t: "unlistened"; port: number } | ({ t: "installed"; id: number } & InstallOutcome) | ({ t: "kept"; id: number } & KeepOutcome); // SW ↔ host bridge (one MessageChannel per host page). `id` is the SW's; it is echoed back. export type BridgeRequest = | { t: "get"; id: number; url: string } | { t: "http"; id: number; port: number; req: HttpRequestRecord } | { t: "connect"; port: number; socket: MessagePort }; export type BridgeReply = | { t: "source"; id: number; ok: boolean; bytes?: Uint8Array } | { t: "httpResult"; id: number; res: HttpResponseRecord | null; error?: string }; // Control messages on the SW's own channel (navigator.serviceWorker), outside any bridge. export type HostToSw = | { t: "bridge"; host: HostToken; port: MessagePort } /** Take control of this uncontrolled page: a hard reload bypasses the worker while * it is already active, so its activate-time claim never reaches the page. */ | { t: "claim" } /** Are you the runtime's worker? Answered with a pong on `port`: a site worker that * composes dist/runtime/sw.js via importScripts() runs from its own URL, yet is ours. */ | { t: "ping"; port: MessagePort }; /** From an app page (the WebSocket shim): open a raw connection to its guest server. * `path` is the WebSocket URL's pathname, `page` the document URL, both used only to * find the server when the SW has no record of the page. */ export type PageToSw = { t: "connect"; socket: MessagePort; path: string; page: string }; export type SwToHost = | { t: "rebridge" } /** The answer to a ping, on the ping's port rather than the SW's own channel. */ | { t: "pong" }; export type KernelToRuntime = | { t: "init"; /** The owning host page, named by every module URL the runtime builds. */ host: HostToken; port: MessagePort; pid: number; entry: string; argv: string[]; env: Record; cwd: string; /** The pty's size when stdio is a terminal; null for pipes. */ tty: WinSize | null; /** Set when this worker is a worker_threads thread rather than a process's main thread. */ thread: ThreadInit | null; /** The process's, so every thread of a process has the same one. */ runtime: ProcessRuntime; } // A thread this runtime started has ended. | ({ t: "threadExited" } & ThreadExit) // Output a child this thread spawned wrote to a piped fd, and its end. | { t: "childStdio"; pid: number; stream: "stdout" | "stderr"; bytes: Uint8Array } | { t: "childExited"; pid: number; status: ExitStatus } // Events for a watch this thread holds, in the order they happened. | { t: "fsEvents"; wid: number; events: WatchEvent[] } | { t: "httpRequest"; id: number; port: number; req: HttpRequestRecord } // A raw connection; `id` is the kernel's, echoed by connectionClosed. | { t: "connect"; id: number; port: number; socket: MessagePort } // A wake-up: the signals themselves are pending in the mailbox (shared/mailbox.ts). | { t: "signal" } // Input for fd 0, in order; null is EOF. | { t: "stdin"; bytes: Uint8Array | null }; export type KernelToRuntimeInit = Extract; export type RuntimeToKernel = | { t: "attach"; mailbox: SharedArrayBuffer } | Req | { t: "stdio"; stream: "stdout" | "stderr"; bytes: Uint8Array } | { t: "httpResponse"; id: number; res: HttpResponseRecord } | { t: "connectionClosed"; id: number } // Start thread `tid` (from newThread). `transfer` lists every transferable inside // `thread`, so the kernel can pass them on to the new worker. | { t: "startThread"; entry: string; argv: string[]; env: Record; cwd: string; thread: ThreadInit; transfer: Transferable[] } | { t: "terminateThread"; tid: number } // Bytes for the piped stdin of a child this thread spawned; null closes it (EOF). | { t: "childStdin"; pid: number; bytes: Uint8Array | null };