From a99bff79ecf707cb74e8829a4757ffb05c2bb372 Mon Sep 17 00:00:00 2001 From: Pierre Carrier Date: Fri, 12 Jun 2026 01:29:37 +0000 Subject: [PATCH] Support server-side Node/Bun clients: exit status, /node entry, nodejs wasm Addresses gaps that force a server-side consumer (a Node/Bun client driving a local `blit server` over a unix socket) to re-implement library internals. @blit-sh/core: - Parse `exit_status` from S2C_EXITED (was discarded) and expose it as `BlitSession.exitStatus`. Export `exitCodeFromStatus`/`formatExitStatus`/ `EXIT_STATUS_UNKNOWN`, ported from crates/cli/src/agent.rs so JS consumers no longer re-derive the `128 + signal` convention. - Add a non-browser `@blit-sh/core/node` subpath re-exporting the existing Node/Bun/Deno unix-socket transports plus `loadBlitWasm()`, a helper that initializes @blit-sh/browser off-browser (reads the colocated blit_browser_bg.wasm and feeds init({module_or_path})) so consumers never touch raw wasm bytes. Kept out of the package root so node:net never leaks into browser bundles. @blit-sh/browser (nix): - Build a self-initializing wasm-bindgen `--target nodejs` artifact and publish it under the `@blit-sh/browser/node` subpath (CommonJS, reads its .wasm from disk on import). The root `.` export keeps the `--target web` build, so existing browser consumers and the `.wasm` deep-import are unchanged. Docs: - EMBEDDING.md gains a server-side Node/Bun section covering the unix transport, loadBlitWasm, exitStatus and nullLogger. All @blit-sh/core typechecks + 332 tests pass; the nodejs wasm artifact and loadBlitWasm are validated end-to-end (Node + Bun) against the published @blit-sh/browser@0.34.0. --- EMBEDDING.md | 63 +++++++++++++ js/core/package.json | 8 ++ js/core/src/BlitConnection.ts | 14 ++- js/core/src/__tests__/exit-status.test.ts | 108 ++++++++++++++++++++++ js/core/src/__tests__/mock-transport.ts | 17 ++++ js/core/src/exit-status.ts | 38 ++++++++ js/core/src/index.ts | 7 ++ js/core/src/node-wasm.ts | 48 ++++++++++ js/core/src/node.ts | 18 ++++ js/core/src/types.ts | 9 ++ nix/packages.nix | 45 +++++++++ nix/tasks.nix | 27 +++++- 12 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 js/core/src/__tests__/exit-status.test.ts create mode 100644 js/core/src/exit-status.ts create mode 100644 js/core/src/node-wasm.ts create mode 100644 js/core/src/node.ts diff --git a/EMBEDDING.md b/EMBEDDING.md index b777aa0b..68265e29 100644 --- a/EMBEDDING.md +++ b/EMBEDDING.md @@ -235,6 +235,69 @@ interface BlitTransport { } ``` +## Server-side: a Node/Bun client over a unix socket + +You can also run a `@blit-sh/core` client **server-side** (Node/Bun/Deno) to drive a +local `blit server` over its unix-domain socket — e.g. to script terminals or run +headless commands. The non-browser building blocks live under the +`@blit-sh/core/node` subpath (kept out of the package root so `node:net` and +runtime globals never leak into browser bundles): + +```ts +import { BlitWorkspace, exitCodeFromStatus, nullLogger } from "@blit-sh/core"; +import { NodeUnixSocketTransport, loadBlitWasm } from "@blit-sh/core/node"; + +// `loadBlitWasm()` initializes the @blit-sh/browser WASM off-browser: it reads +// the colocated blit_browser_bg.wasm from disk and feeds it to init(), so you +// never touch raw wasm bytes. (If you depend on a self-initializing +// `@blit-sh/browser/node` build it is returned as-is.) +const wasm = await loadBlitWasm(); + +const transport = new NodeUnixSocketTransport(process.env.BLIT_SOCK ?? "/tmp/blit.sock"); +const workspace = new BlitWorkspace({ + wasm, + logger: nullLogger, // no-op logger; omit to log lifecycle events to console + connections: [{ id: "default", transport }], +}); + +const session = await workspace.createSession({ + connectionId: "default", + rows: 24, + cols: 80, + command: "my-command", +}); +``` + +The unix transport speaks blit's framing protocol (4-byte little-endian +length-prefixed frames) for you — there is no need to re-implement the wire +format. `BunUnixSocketTransport` and `DenoUnixSocketTransport` are the +runtime-native equivalents. + +### Exit status + +When a session's process exits, its `BlitSession.state` becomes `"exited"` and +`BlitSession.exitStatus` carries the raw status from the server: + +- `>= 0` — normal exit; the value is the exit code. +- `< 0` — terminated by a signal; the value is the negated signal number. +- `EXIT_STATUS_UNKNOWN` — not yet collected. + +`exitCodeFromStatus(status)` maps that to a conventional shell exit code +(unknown → `1`, signalled → `128 + signal`), and `formatExitStatus(status)` +renders `"exited()"` / `"signal()"`. Both mirror the `blit` CLI. + +```ts +import { exitCodeFromStatus } from "@blit-sh/core"; + +workspace.subscribe(() => { + for (const s of workspace.getSnapshot().sessions) { + if (s.state === "exited" && s.exitStatus !== null) { + console.log(`${s.id} exited with code`, exitCodeFromStatus(s.exitStatus)); + } + } +}); +``` + ## Your service, our server: `fd-channel` mode `fd-channel` lets an external process own `blit server`'s lifecycle and control which clients connect via `SCM_RIGHTS` fd passing. See [ARCHITECTURE.md](ARCHITECTURE.md) for the protocol details and the working examples: diff --git a/js/core/package.json b/js/core/package.json index 040c05a7..19ebeda1 100644 --- a/js/core/package.json +++ b/js/core/package.json @@ -17,6 +17,10 @@ "types": "./src/transports/index.ts", "default": "./src/transports/index.ts" }, + "./node": { + "types": "./src/node.ts", + "default": "./src/node.ts" + }, "./types": { "types": "./src/types.ts", "default": "./src/types.ts" @@ -46,6 +50,10 @@ "types": "./dist/transports/index.d.ts", "default": "./dist/transports/index.js" }, + "./node": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" + }, "./types": { "types": "./dist/types.d.ts", "default": "./dist/types.js" diff --git a/js/core/src/BlitConnection.ts b/js/core/src/BlitConnection.ts index ace69a11..bb4f531e 100644 --- a/js/core/src/BlitConnection.ts +++ b/js/core/src/BlitConnection.ts @@ -8,6 +8,7 @@ import type { SessionId, TerminalPalette, } from "./types"; +import { EXIT_STATUS_UNKNOWN } from "./exit-status"; import { FEATURE_AUDIO, FEATURE_COMPOSITOR, @@ -1257,7 +1258,17 @@ export class BlitConnection { const ptyId = bytes[1] | (bytes[2] << 8); const sessionId = this.currentSessionIdByPtyId.get(ptyId); if (sessionId) { - this.updateSession(sessionId, { state: "exited" }); + // Wire: [0x08][pty_id:2][exit_status:4] (i32 LE). Older servers + // may omit the status; default to EXIT_STATUS_UNKNOWN. + const exitStatus = + bytes.length >= 7 + ? new DataView( + bytes.buffer, + bytes.byteOffset + 3, + 4, + ).getInt32(0, true) + : EXIT_STATUS_UNKNOWN; + this.updateSession(sessionId, { state: "exited", exitStatus }); } return; } @@ -1954,6 +1965,7 @@ export class BlitConnection { usedRows: current?.usedRows ?? 0, command, state, + exitStatus: current?.exitStatus ?? null, }; this.currentSessionIdByPtyId.set(ptyId, session.id); this.sessionsById.set(session.id, session); diff --git a/js/core/src/__tests__/exit-status.test.ts b/js/core/src/__tests__/exit-status.test.ts new file mode 100644 index 00000000..7b80bcfc --- /dev/null +++ b/js/core/src/__tests__/exit-status.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; + +import { BlitConnection } from "../BlitConnection"; +import { + EXIT_STATUS_UNKNOWN, + exitCodeFromStatus, + formatExitStatus, +} from "../exit-status"; +import type { BlitWasmModule } from "../TerminalStore"; +import { MockTransport } from "./mock-transport"; + +class FakeTerminal { + constructor(_r: number, _c: number, _pw: number, _ph: number) {} + set_font_family(_f: string) {} + set_font_size(_s: number) {} + set_default_colors(..._a: number[]) {} + set_ansi_color(..._a: number[]) {} + feed_compressed(_d: Uint8Array) {} + invalidate_render_cache() {} + title() { + return ""; + } + free() {} +} + +const wasm = { Terminal: FakeTerminal } as unknown as BlitWasmModule; + +function createConnection() { + const transport = new MockTransport(); + const conn = new BlitConnection({ + id: "test", + transport, + wasm, + autoConnect: false, + }); + return { conn, transport }; +} + +describe("exitCodeFromStatus", () => { + it("maps unknown status to 1", () => { + expect(exitCodeFromStatus(EXIT_STATUS_UNKNOWN)).toBe(1); + }); + + it("passes through normal exit codes", () => { + expect(exitCodeFromStatus(0)).toBe(0); + expect(exitCodeFromStatus(2)).toBe(2); + expect(exitCodeFromStatus(127)).toBe(127); + }); + + it("maps signals to 128 + signal", () => { + expect(exitCodeFromStatus(-9)).toBe(137); // SIGKILL + expect(exitCodeFromStatus(-15)).toBe(143); // SIGTERM + }); +}); + +describe("formatExitStatus", () => { + it("renders the canonical strings", () => { + expect(formatExitStatus(EXIT_STATUS_UNKNOWN)).toBe("exited"); + expect(formatExitStatus(0)).toBe("exited(0)"); + expect(formatExitStatus(3)).toBe("exited(3)"); + expect(formatExitStatus(-2)).toBe("signal(2)"); + }); +}); + +describe("BlitConnection S2C_EXITED", () => { + it("records exitStatus from a normal exit", () => { + const { conn, transport } = createConnection(); + transport.pushCreated(7, "job"); + transport.pushExited(7, 0); + const session = conn.getSnapshot().sessions[0]!; + expect(session.state).toBe("exited"); + expect(session.exitStatus).toBe(0); + expect(exitCodeFromStatus(session.exitStatus!)).toBe(0); + }); + + it("records a non-zero exit code", () => { + const { conn, transport } = createConnection(); + transport.pushCreated(7); + transport.pushExited(7, 3); + const session = conn.getSnapshot().sessions[0]!; + expect(session.exitStatus).toBe(3); + expect(exitCodeFromStatus(session.exitStatus!)).toBe(3); + }); + + it("records a negative (signalled) status", () => { + const { conn, transport } = createConnection(); + transport.pushCreated(7); + transport.pushExited(7, -9); + const session = conn.getSnapshot().sessions[0]!; + expect(session.exitStatus).toBe(-9); + expect(exitCodeFromStatus(session.exitStatus!)).toBe(137); + }); + + it("is null while the session is running", () => { + const { conn, transport } = createConnection(); + transport.pushCreated(7); + expect(conn.getSnapshot().sessions[0]!.exitStatus).toBeNull(); + }); + + it("falls back to EXIT_STATUS_UNKNOWN for a short frame", () => { + const { conn, transport } = createConnection(); + transport.pushCreated(7); + transport.pushExitedRaw(7); // no status bytes + const session = conn.getSnapshot().sessions[0]!; + expect(session.state).toBe("exited"); + expect(session.exitStatus).toBe(EXIT_STATUS_UNKNOWN); + }); +}); diff --git a/js/core/src/__tests__/mock-transport.ts b/js/core/src/__tests__/mock-transport.ts index 6098bbe4..5341969d 100644 --- a/js/core/src/__tests__/mock-transport.ts +++ b/js/core/src/__tests__/mock-transport.ts @@ -7,6 +7,7 @@ import { S2C_CREATED, S2C_CREATED_N, S2C_CLOSED, + S2C_EXITED, S2C_HELLO, S2C_LIST, S2C_QUIT, @@ -113,6 +114,22 @@ export class MockTransport implements BlitTransport { this.push(new Uint8Array([S2C_CLOSED, ptyId & 0xff, (ptyId >> 8) & 0xff])); } + /** Wire: [0x08][pty_id:2][exit_status:4] (i32 LE). */ + pushExited(ptyId: number, exitStatus: number) { + const msg = new Uint8Array(7); + const view = new DataView(msg.buffer); + msg[0] = S2C_EXITED; + msg[1] = ptyId & 0xff; + msg[2] = (ptyId >> 8) & 0xff; + view.setInt32(3, exitStatus, true); + this.push(msg); + } + + /** A legacy-style EXITED frame that omits the exit_status bytes. */ + pushExitedRaw(ptyId: number) { + this.push(new Uint8Array([S2C_EXITED, ptyId & 0xff, (ptyId >> 8) & 0xff])); + } + pushList(entries: { ptyId: number; tag?: string; command?: string }[]) { const parts: number[] = [ S2C_LIST, diff --git a/js/core/src/exit-status.ts b/js/core/src/exit-status.ts new file mode 100644 index 00000000..262708b3 --- /dev/null +++ b/js/core/src/exit-status.ts @@ -0,0 +1,38 @@ +/** + * Exit-status decoding for `S2C_EXITED` frames. + * + * The server encodes a process exit status as a signed 32-bit integer in the + * `S2C_EXITED` frame (`crates/remote/src/lib.rs`, wire layout + * `[0x08][pty_id:2][exit_status:4]`, little-endian): + * + * - `>= 0` — normal exit; the value is the `WEXITSTATUS` exit code. + * - `< 0` — terminated by a signal; the value is the negated signal number. + * - {@link EXIT_STATUS_UNKNOWN} — the status has not been collected yet. + * + * These helpers mirror the canonical Rust implementation in + * `crates/cli/src/agent.rs` (`exit_code_from_status` / `format_exit_status`) + * so JS consumers don't have to re-derive the `128 + signal` convention. + */ + +/** Sentinel exit status meaning "not yet collected" (`i32::MIN`). */ +export const EXIT_STATUS_UNKNOWN = -2147483648; + +/** + * Convert a raw `exit_status` into a conventional shell exit code: + * unknown → `1`, normal exit → the code itself, signalled → `128 + signal`. + */ +export function exitCodeFromStatus(status: number): number { + if (status === EXIT_STATUS_UNKNOWN) return 1; + if (status >= 0) return status; + return 128 + -status; +} + +/** + * Human-readable rendering of an `exit_status`, matching `blit`'s CLI output: + * `"exited"`, `"exited()"` or `"signal()"`. + */ +export function formatExitStatus(status: number): string { + if (status === EXIT_STATUS_UNKNOWN) return "exited"; + if (status >= 0) return `exited(${status})`; + return `signal(${-status})`; +} diff --git a/js/core/src/index.ts b/js/core/src/index.ts index 5811a750..98dedf15 100644 --- a/js/core/src/index.ts +++ b/js/core/src/index.ts @@ -25,6 +25,13 @@ export { createShareTransport } from "./transports/webrtc-share"; export { MuxTransport, MuxChannel } from "./transports/mux"; export { DEFAULT_FONT, DEFAULT_FONT_SIZE } from "./types"; + +export { + EXIT_STATUS_UNKNOWN, + exitCodeFromStatus, + formatExitStatus, +} from "./exit-status"; + export type { BlitConnectionSnapshot, BlitDebug, diff --git a/js/core/src/node-wasm.ts b/js/core/src/node-wasm.ts new file mode 100644 index 00000000..6b0f1c36 --- /dev/null +++ b/js/core/src/node-wasm.ts @@ -0,0 +1,48 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import init from "@blit-sh/browser"; + +import type { BlitWasmModule } from "./TerminalStore"; + +/** + * Initialise the `@blit-sh/browser` WASM module in a non-browser runtime + * (Node / Bun / Deno) and return the module namespace, ready to hand to + * `new BlitWorkspace({ wasm })`. + * + * Why this exists: the `@blit-sh/browser` package published today is a + * wasm-bindgen `--target web` build, so its default `init()` assumes a + * browser that can `fetch(new URL("blit_browser_bg.wasm", import.meta.url))`. + * Under Node/Bun there is no such fetch and `init()` rejects with an opaque, + * stackless error. `loadBlitWasm()` instead resolves the `.wasm` that ships + * alongside `@blit-sh/browser`, reads its bytes from disk and feeds them to + * `init({ module_or_path })` — so consumers never touch raw wasm bytes and a + * missing/incorrect asset fails with a real filesystem error. + * + * It is also forward-compatible with a self-initializing build (e.g. a + * `--target nodejs` artifact resolved via the `node` export condition): such a + * build has no `init` default export and instantiates itself on import, so we + * detect that and return it as-is without any filesystem access. + * + * @param wasmPath Optional override for the `.wasm` location. Accepts a + * filesystem path or a `file:` URL string; defaults to the asset colocated + * with `@blit-sh/browser`. + */ +export async function loadBlitWasm(wasmPath?: string): Promise { + const mod = (await import("@blit-sh/browser")) as unknown as BlitWasmModule & { + default?: unknown; + }; + + // A self-initializing build (`--target nodejs`/`bundler`) has already + // instantiated the module on import and exposes no `init` default export. + if (typeof mod.default !== "function") { + return mod; + } + + const location = + wasmPath ?? import.meta.resolve("@blit-sh/browser/blit_browser_bg.wasm"); + const path = location.startsWith("file:") ? fileURLToPath(location) : location; + const bytes = await readFile(path); + await init({ module_or_path: bytes as unknown as Parameters[0] }); + return mod; +} diff --git a/js/core/src/node.ts b/js/core/src/node.ts new file mode 100644 index 00000000..0c27fd17 --- /dev/null +++ b/js/core/src/node.ts @@ -0,0 +1,18 @@ +/** + * Node / Bun / Deno entry point for `@blit-sh/core`. + * + * This subpath (`@blit-sh/core/node`) exposes the local-IPC building blocks + * needed to drive a `blit server` over a unix-domain socket from a + * non-browser runtime. It is intentionally **not** re-exported from the + * package root: {@link NodeUnixSocketTransport} pulls in `node:net`, and the + * Bun/Deno variants rely on runtime globals, none of which belong in a + * browser bundle. Browser code should import transports from + * `@blit-sh/core` / `@blit-sh/core/transports` instead. + */ + +export { NodeUnixSocketTransport } from "./transports/unix"; +export { BunUnixSocketTransport } from "./transports/unix-bun"; +export { DenoUnixSocketTransport } from "./transports/unix-deno"; +export type { UnixSocketTransportOptions } from "./transports/unix-base"; + +export { loadBlitWasm } from "./node-wasm"; diff --git a/js/core/src/types.ts b/js/core/src/types.ts index bcf33d1f..3fcf85ba 100644 --- a/js/core/src/types.ts +++ b/js/core/src/types.ts @@ -102,6 +102,15 @@ export type BlitSession = { usedRows: number; command: string | null; state: "creating" | "active" | "exited" | "closed"; + /** + * Raw exit status from the server once the process has exited (the + * `exit_status` field of `S2C_EXITED`), or `null` while running. + * + * `>= 0` is the normal exit code, `< 0` is the negated terminating + * signal, and {@link EXIT_STATUS_UNKNOWN} means "not yet collected". + * Use `exitCodeFromStatus` to map it to a conventional shell exit code. + */ + exitStatus: number | null; }; export interface BlitConnectionSnapshot { diff --git a/nix/packages.nix b/nix/packages.nix index 69385fea..f510e430 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -145,6 +145,50 @@ doCheck = false; }; + # Self-initializing Node/Bun build of the same crate (wasm-bindgen + # \`--target nodejs\`). Unlike the \`--target web\` build, this one reads its + # \`.wasm\` from disk and instantiates synchronously on import, so it works + # off-browser with no \`fetch\`/\`init()\` dance. Published under the + # \`@blit-sh/browser/node\` subpath; see nix/tasks.nix \`browser-publish\`. + browserWasmNode = rustPlatform.buildRustPackage { + pname = "blit-browser-node"; + inherit version; + src = ../.; + cargoBuildFlags = [ + "-p" + "blit-browser" + ]; + cargoDeps = browserCargoDeps; + nativeBuildInputs = [ + pkgs.wasm-pack + wasmBindgenCli + pkgs.binaryen + ]; + buildPhase = '' + cd crates/browser + HOME=$TMPDIR wasm-pack build --target nodejs --release --out-dir $out + # wasm-bindgen's nodejs target emits CommonJS glue (require/__dirname) + # but copies the inline JS snippets verbatim as ES modules, so the + # generated require() of each snippet throws under Node. Rewrite the + # snippets to CommonJS. (They are canvas-only helpers, never invoked + # in a headless terminal, but must still load.) + for f in $out/snippets/blit-browser-*/*.js; do + names=$(grep -oE 'export function [A-Za-z0-9_]+' "$f" \ + | sed 's/export function //' | paste -sd, -) + sed -i 's/^export function /function /' "$f" + if [ -n "$names" ]; then + printf '\nmodule.exports = { %s };\n' "$names" >> "$f" + fi + done + # Mark this subtree as CommonJS so it loads correctly when nested + # under the package root's "type":"module". + printf '{"type":"commonjs","main":"blit_browser.js","types":"blit_browser.d.ts"}\n' \ + > $out/package.json + ''; + dontInstall = true; + doCheck = false; + }; + # ------------------------------------------------------------------ # Release binaries # @@ -399,6 +443,7 @@ pkgs version browserWasm + browserWasmNode blit blit-release webAppDist diff --git a/nix/tasks.nix b/nix/tasks.nix index 7558c6b4..c0bc74ce 100644 --- a/nix/tasks.nix +++ b/nix/tasks.nix @@ -2,6 +2,7 @@ pkgs, version, browserWasm, + browserWasmNode, blit, blit-release, blit-release-musl ? null, @@ -42,6 +43,21 @@ let fi chmod -R u+w "$tmp" + # Self-initializing Node/Bun build under ./node (see + # nix/packages.nix `browserWasmNode`). Exposed via the + # `@blit-sh/browser/node` subpath; the root export stays the + # `--target web` build so existing browser consumers are unaffected. + mkdir -p "$tmp/node" + cp ${browserWasmNode}/blit_browser.js "$tmp/node"/ + cp ${browserWasmNode}/blit_browser.d.ts "$tmp/node"/ + cp ${browserWasmNode}/blit_browser_bg.wasm "$tmp/node"/ + cp ${browserWasmNode}/blit_browser_bg.wasm.d.ts "$tmp/node"/ 2>/dev/null || true + if [ -d "${browserWasmNode}/snippets" ]; then + cp -r ${browserWasmNode}/snippets "$tmp/node"/snippets + fi + cp ${browserWasmNode}/package.json "$tmp/node"/package.json + chmod -R u+w "$tmp/node" + cat > "$tmp/package.json" <<'PKGJSON' { "name": "@blit-sh/browser", @@ -50,7 +66,16 @@ let "description": "Low-latency terminal streaming — browser WASM renderer", "main": "blit_browser.js", "types": "blit_browser.d.ts", - "files": ["blit_browser_bg.wasm","blit_browser.js","blit_browser.d.ts","blit_browser_bg.wasm.d.ts","snippets"], + "exports": { + ".": { "types": "./blit_browser.d.ts", "default": "./blit_browser.js" }, + "./node": { "types": "./node/blit_browser.d.ts", "default": "./node/blit_browser.js" }, + "./blit_browser.js": "./blit_browser.js", + "./blit_browser_bg.wasm": "./blit_browser_bg.wasm", + "./blit_browser_bg.wasm.d.ts": "./blit_browser_bg.wasm.d.ts", + "./snippets/*": "./snippets/*", + "./package.json": "./package.json" + }, + "files": ["blit_browser_bg.wasm","blit_browser.js","blit_browser.d.ts","blit_browser_bg.wasm.d.ts","snippets","node"], "sideEffects": ["./snippets/*"], "keywords": ["terminal","tty","wasm","streaming","webgl"], "homepage": "https://blit.sh",