Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions EMBEDDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<code>)"` / `"signal(<n>)"`. 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:
Expand Down
8 changes: 8 additions & 0 deletions js/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
14 changes: 13 additions & 1 deletion js/core/src/BlitConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
SessionId,
TerminalPalette,
} from "./types";
import { EXIT_STATUS_UNKNOWN } from "./exit-status";
import {
FEATURE_AUDIO,
FEATURE_COMPOSITOR,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
108 changes: 108 additions & 0 deletions js/core/src/__tests__/exit-status.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 17 additions & 0 deletions js/core/src/__tests__/mock-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
S2C_CREATED,
S2C_CREATED_N,
S2C_CLOSED,
S2C_EXITED,
S2C_HELLO,
S2C_LIST,
S2C_QUIT,
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions js/core/src/exit-status.ts
Original file line number Diff line number Diff line change
@@ -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(<code>)"` or `"signal(<n>)"`.
*/
export function formatExitStatus(status: number): string {
if (status === EXIT_STATUS_UNKNOWN) return "exited";
if (status >= 0) return `exited(${status})`;
return `signal(${-status})`;
}
7 changes: 7 additions & 0 deletions js/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions js/core/src/node-wasm.ts
Original file line number Diff line number Diff line change
@@ -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<BlitWasmModule> {
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<typeof init>[0] });
return mod;
}
18 changes: 18 additions & 0 deletions js/core/src/node.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading