Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
416 lines
16 KiB
TypeScript
416 lines
16 KiB
TypeScript
import type * as Zlib from "node:zlib";
|
|
import type { Frame } from "./wire.ts";
|
|
|
|
/** An ordered (FIFO), bidirectional frame channel between two hosts. */
|
|
export interface Link {
|
|
readonly closed: boolean;
|
|
/**
|
|
* Returns the size of the encoded frame as sent (JSON text length, or bytes of a packed message) when the transport
|
|
* knows it. The connection's flow control counts these sizes; frames without one count as zero, so a link that never
|
|
* reports sizes is not windowed.
|
|
*/
|
|
send(frame: Frame): number | undefined;
|
|
/** Sets the single receiver. Frames arriving earlier are queued. */
|
|
receive(handler: (frame: Frame) => void): void;
|
|
onClose(handler: (reason: string) => void): void;
|
|
/** Closes both ends: the peer gets a `bye`. */
|
|
close(reason?: string): void;
|
|
}
|
|
|
|
/** Shared plumbing: receive queue, close handlers, `bye`. Transports implement `transmit` and `shutdown`. */
|
|
abstract class BaseLink implements Link {
|
|
#handler: ((frame: Frame) => void) | undefined;
|
|
#queue: Frame[] = [];
|
|
readonly #closeHandlers = new Set<(reason: string) => void>();
|
|
#closed = false;
|
|
/** The tail of frames being decoded asynchronously; undefined when none is. */
|
|
#decoding: Promise<void> | undefined;
|
|
|
|
get closed(): boolean {
|
|
return this.#closed;
|
|
}
|
|
|
|
protected abstract transmit(frame: Frame): number | undefined;
|
|
protected abstract shutdown(): void;
|
|
|
|
send(frame: Frame): number | undefined {
|
|
if (this.#closed) return undefined;
|
|
return this.transmit(frame);
|
|
}
|
|
|
|
receive(handler: (frame: Frame) => void): void {
|
|
this.#handler = handler;
|
|
for (const frame of this.#queue.splice(0)) handler(frame);
|
|
}
|
|
|
|
onClose(handler: (reason: string) => void): void {
|
|
if (this.#closed) handler("closed");
|
|
else this.#closeHandlers.add(handler);
|
|
}
|
|
|
|
close(reason = "closed"): void {
|
|
if (this.#closed) return;
|
|
try {
|
|
this.transmit({ t: "bye", reason });
|
|
} catch {
|
|
// The transport may already be gone.
|
|
}
|
|
this.ended(reason);
|
|
}
|
|
|
|
/** Delivers a parsed frame from the transport, after any frame still being decoded (`deliverLater`). */
|
|
protected deliver(frame: Frame): void {
|
|
if (this.#decoding !== undefined) this.deliverLater(Promise.resolve(frame));
|
|
else this.#deliverNow(frame);
|
|
}
|
|
|
|
/** Delivers a frame that is still being decoded (inflated in a browser); later frames wait for it, in order. */
|
|
protected deliverLater(frame: Promise<Frame>): void {
|
|
const next = (this.#decoding ?? Promise.resolve())
|
|
.then(() => frame)
|
|
.then(
|
|
(decoded) => this.#deliverNow(decoded),
|
|
() => this.close("protocol error"),
|
|
)
|
|
.finally(() => {
|
|
if (this.#decoding === next) this.#decoding = undefined;
|
|
});
|
|
this.#decoding = next;
|
|
}
|
|
|
|
#deliverNow(frame: Frame): void {
|
|
if (this.#closed) return;
|
|
if (frame.t === "bye") {
|
|
this.ended(frame.reason ?? "peer closed");
|
|
return;
|
|
}
|
|
if (this.#handler === undefined) this.#queue.push(frame);
|
|
else this.#handler(frame);
|
|
}
|
|
|
|
/** Marks the link closed (transport ended) and notifies once. */
|
|
protected ended(reason: string): void {
|
|
if (this.#closed) return;
|
|
this.#closed = true;
|
|
this.#queue = [];
|
|
try {
|
|
this.shutdown();
|
|
} finally {
|
|
for (const handler of this.#closeHandlers) handler(reason);
|
|
this.#closeHandlers.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── payload packing ─────────────────────────────────────────────────────────
|
|
//
|
|
// A transport optimization, invisible above the links. A frame's payload (call args, a result value, a state snapshot)
|
|
// whose JSON exceeds `PACK_THRESHOLD` characters is sent deflated. A host that only relays the frame forwards the
|
|
// deflated bytes unread (`Packed`); the host that uses the payload inflates it (`unpack`). Only hosts with Node's zlib
|
|
// deflate (synchronously, so frames keep their order); a browser inflates with `DecompressionStream` before delivery.
|
|
|
|
/** Payloads whose JSON is longer than this many characters are deflated. */
|
|
export const PACK_THRESHOLD = 64 * 1024;
|
|
|
|
const zlib: typeof Zlib | undefined = (
|
|
globalThis as { process?: { getBuiltinModule?(id: "node:zlib"): typeof Zlib } }
|
|
).process?.getBuiltinModule?.("node:zlib");
|
|
|
|
/** A deflated JSON payload, kept as received until a host uses it. */
|
|
export class Packed {
|
|
readonly bytes: Uint8Array;
|
|
constructor(bytes: Uint8Array) {
|
|
this.bytes = bytes;
|
|
}
|
|
}
|
|
|
|
/** A frame payload as its user needs it: inflated and parsed if it arrived packed. */
|
|
export function unpack(value: unknown): unknown {
|
|
if (!(value instanceof Packed)) return value;
|
|
if (zlib === undefined) throw new TypeError("packed payload without zlib");
|
|
return JSON.parse(zlib.inflateRawSync(value.bytes).toString("utf8"));
|
|
}
|
|
|
|
type PayloadField = "args" | "value";
|
|
const PAYLOAD_FIELDS: ReadonlySet<string> = new Set<PayloadField>(["args", "value"]);
|
|
|
|
function payloadField(frame: Frame): PayloadField | undefined {
|
|
if (frame.t === "call") return frame.args === undefined ? undefined : "args";
|
|
if (frame.t === "result" || frame.t === "state") return frame.value === undefined ? undefined : "value";
|
|
return undefined;
|
|
}
|
|
|
|
/** The frame without its payload field (the envelope). */
|
|
function envelopeOf(frame: Frame, field: PayloadField): Record<string, unknown> {
|
|
const envelope: Record<string, unknown> = { ...frame };
|
|
delete envelope[field];
|
|
return envelope;
|
|
}
|
|
|
|
/** The payload's deflated bytes: as received, or deflated now if large. Undefined: send it as JSON. */
|
|
function packedBytes(value: unknown, compress: boolean, json?: string): Uint8Array | undefined {
|
|
if (value instanceof Packed) return value.bytes;
|
|
if (!compress || zlib === undefined || json === undefined || json.length <= PACK_THRESHOLD) return undefined;
|
|
return zlib.deflateRawSync(json, { level: 1 });
|
|
}
|
|
|
|
/** For object transports (Node IPC, memory): a packed payload travels as `z: [field, base64]`. */
|
|
function packObject(frame: Frame, compress: boolean): unknown {
|
|
const field = payloadField(frame);
|
|
if (field === undefined) return frame;
|
|
const value = (frame as Record<string, unknown>)[field];
|
|
const bytes = packedBytes(value, compress, compress && zlib !== undefined ? JSON.stringify(value) : undefined);
|
|
if (bytes === undefined) return frame;
|
|
return {
|
|
...envelopeOf(frame, field),
|
|
z: [field, Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64")],
|
|
};
|
|
}
|
|
|
|
function unpackObject(message: unknown): unknown {
|
|
if (message === null || typeof message !== "object" || !("z" in message)) return message;
|
|
const { z, ...rest } = message as { z: unknown } & Record<string, unknown>;
|
|
if (!Array.isArray(z) || !PAYLOAD_FIELDS.has(z[0]) || typeof z[1] !== "string")
|
|
throw new TypeError("bad packed payload");
|
|
return { ...rest, [z[0]]: new Packed(Buffer.from(z[1], "base64")) };
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
const decoder = new TextDecoder();
|
|
|
|
/**
|
|
* For WebSockets: a frame with a packed payload is a binary message, `[envelope length: u32 BE][envelope JSON, with
|
|
* z: field][deflated payload]`; any other frame is text. The payload's JSON is spliced in, not encoded twice.
|
|
*/
|
|
function packMessage(frame: Frame, compress: boolean): string | Uint8Array {
|
|
const field = payloadField(frame);
|
|
if (field === undefined) return JSON.stringify(frame);
|
|
const value = (frame as Record<string, unknown>)[field];
|
|
const json = value instanceof Packed ? undefined : JSON.stringify(value);
|
|
const bytes = packedBytes(value, compress, json);
|
|
const envelope = envelopeOf(frame, field);
|
|
if (bytes === undefined) {
|
|
const head = JSON.stringify(envelope);
|
|
return `${head.slice(0, -1)},${JSON.stringify(field)}:${json}}`;
|
|
}
|
|
const head = encoder.encode(JSON.stringify({ ...envelope, z: field }));
|
|
const message = new Uint8Array(4 + head.byteLength + bytes.byteLength);
|
|
new DataView(message.buffer).setUint32(0, head.byteLength);
|
|
message.set(head, 4);
|
|
message.set(bytes, 4 + head.byteLength);
|
|
return message;
|
|
}
|
|
|
|
/** The envelope of a binary message and its payload bytes. */
|
|
function splitMessage(data: Uint8Array): { envelope: Record<string, unknown>; field: PayloadField; bytes: Uint8Array } {
|
|
const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0);
|
|
const envelope = JSON.parse(decoder.decode(data.subarray(4, 4 + length))) as unknown;
|
|
if (envelope === null || typeof envelope !== "object") throw new TypeError("bad envelope");
|
|
const { z: field, ...rest } = envelope as { z?: unknown } & Record<string, unknown>;
|
|
if (typeof field !== "string" || !PAYLOAD_FIELDS.has(field)) throw new TypeError("bad packed payload");
|
|
return { envelope: rest, field: field as PayloadField, bytes: data.slice(4 + length) };
|
|
}
|
|
|
|
/**
|
|
* The text frame of a binary (packed) WebSocket message, inflated: for a transport that relays text frames only (the
|
|
* TUI launcher feeding its sandbox). Needs zlib.
|
|
*/
|
|
export function inflateMessage(data: Uint8Array): string {
|
|
if (zlib === undefined) throw new TypeError("inflateMessage needs zlib");
|
|
const { envelope, field, bytes } = splitMessage(data);
|
|
const head = JSON.stringify(envelope);
|
|
return `${head.slice(0, -1)},${JSON.stringify(field)}:${zlib.inflateRawSync(bytes).toString("utf8")}}`;
|
|
}
|
|
|
|
/** Inflates without zlib (browsers). */
|
|
async function inflate(bytes: Uint8Array): Promise<unknown> {
|
|
const stream = new Blob([bytes as Uint8Array<ArrayBuffer>])
|
|
.stream()
|
|
.pipeThrough(new DecompressionStream("deflate-raw"));
|
|
return JSON.parse(await new Response(stream).text());
|
|
}
|
|
|
|
/** Parses a JSON frame; anything else is a protocol error. */
|
|
function parseFrame(data: string): Frame {
|
|
const frame = unpackObject(JSON.parse(data)) as Frame;
|
|
if (frame === null || typeof frame !== "object" || typeof frame.t !== "string") throw new TypeError("not a frame");
|
|
return frame;
|
|
}
|
|
|
|
// ─── memory ──────────────────────────────────────────────────────────────────
|
|
|
|
export class MemoryLink extends BaseLink {
|
|
peer: MemoryLink | undefined;
|
|
#severed = false;
|
|
#pending = Promise.resolve();
|
|
|
|
/** Simulates a dead network: frames are dropped silently in both directions and no close is signalled. */
|
|
sever(): void {
|
|
this.#severed = true;
|
|
if (this.peer) this.peer.#severed = true;
|
|
}
|
|
|
|
protected transmit(frame: Frame): number | undefined {
|
|
if (this.#severed) return undefined;
|
|
// A JSON round trip detaches the frame (and rejects what JSON would mangle silently, e.g. bigint).
|
|
const data = JSON.stringify(packObject(frame, true));
|
|
const peer = this.peer;
|
|
// One promise chain per direction keeps delivery FIFO and asynchronous, like a real transport.
|
|
this.#pending = this.#pending.then(() => {
|
|
if (!this.#severed && peer !== undefined) peer.deliver(parseFrame(data));
|
|
});
|
|
return data.length;
|
|
}
|
|
|
|
protected shutdown(): void {
|
|
const peer = this.peer;
|
|
this.#pending = this.#pending.then(() => peer?.ended("peer closed"));
|
|
}
|
|
}
|
|
|
|
/** Two connected in-memory links (tests). */
|
|
export function memoryLinkPair(): [MemoryLink, MemoryLink] {
|
|
const a = new MemoryLink();
|
|
const b = new MemoryLink();
|
|
a.peer = b;
|
|
b.peer = a;
|
|
return [a, b];
|
|
}
|
|
|
|
// ─── Node IPC ────────────────────────────────────────────────────────────────
|
|
|
|
/** `process` in a forked child, or the parent's `ChildProcess`. Structural, so Chord needs no Node types here. */
|
|
export interface IpcChannel {
|
|
readonly connected: boolean;
|
|
/**
|
|
* With a callback, Node reports a failed write (the peer process exited and the pipe is broken: `EPIPE`) to it
|
|
* instead of emitting `error`, which nobody would handle.
|
|
*/
|
|
send(message: unknown, callback?: (error: Error | null) => void): boolean;
|
|
on(event: "message", listener: (message: unknown) => void): unknown;
|
|
on(event: "disconnect", listener: () => void): unknown;
|
|
off(event: "message", listener: (message: unknown) => void): unknown;
|
|
off(event: "disconnect", listener: () => void): unknown;
|
|
}
|
|
|
|
/**
|
|
* Chord frames on an IPC channel are wrapped as `{ chord2: frame }`; other messages (control) pass by. Both ends are
|
|
* Node processes of one application, so large payloads are packed both ways. Node serializes the messages itself, so
|
|
* the link reports no sizes and is not windowed: both ends are local processes.
|
|
*/
|
|
export class IpcLink extends BaseLink {
|
|
readonly #channel: IpcChannel;
|
|
readonly #onMessage = (message: unknown): void => {
|
|
const frame = (message as { chord2?: unknown } | null)?.chord2;
|
|
if (frame === undefined) return;
|
|
try {
|
|
this.deliver(unpackObject(frame) as Frame);
|
|
} catch {
|
|
this.close("protocol error");
|
|
}
|
|
};
|
|
readonly #onDisconnect = (): void => this.ended("ipc disconnected");
|
|
/**
|
|
* A write can fail after the peer exited and before the channel reports `disconnect` (an `ack` or a `bye` sent then).
|
|
* The peer is gone either way: the link ends, as on `disconnect`.
|
|
*/
|
|
readonly #onSent = (error: Error | null): void => {
|
|
if (error !== null) this.ended(`ipc send failed: ${(error as { code?: string }).code ?? error.message}`);
|
|
};
|
|
|
|
constructor(channel: IpcChannel) {
|
|
super();
|
|
this.#channel = channel;
|
|
channel.on("message", this.#onMessage);
|
|
channel.on("disconnect", this.#onDisconnect);
|
|
}
|
|
|
|
protected transmit(frame: Frame): undefined {
|
|
if (this.#channel.connected) this.#channel.send({ chord2: packObject(frame, true) }, this.#onSent);
|
|
return undefined;
|
|
}
|
|
|
|
protected shutdown(): void {
|
|
this.#channel.off("message", this.#onMessage);
|
|
this.#channel.off("disconnect", this.#onDisconnect);
|
|
}
|
|
}
|
|
|
|
export const ipcLink = (channel: IpcChannel): IpcLink => new IpcLink(channel);
|
|
|
|
// ─── WebSocket ───────────────────────────────────────────────────────────────
|
|
|
|
/** The WHATWG WebSocket surface: the browser `WebSocket`, Node's global `WebSocket`, and `ws` sockets. */
|
|
export interface WebSocketLike {
|
|
readonly readyState: number;
|
|
send(data: string | Uint8Array): void;
|
|
close(code?: number, reason?: string): void;
|
|
addEventListener(type: "message", listener: (event: { readonly data: unknown }) => void): void;
|
|
addEventListener(type: "open" | "close" | "error", listener: () => void): void;
|
|
}
|
|
|
|
const OPEN = 1;
|
|
|
|
export interface WebSocketLinkOptions {
|
|
/** Deflate large payloads of outgoing frames (default false; the server's side of client sockets). */
|
|
readonly compress?: boolean;
|
|
/** Accept packed (binary) frames from the peer (default true; false for a server's untrusted clients). */
|
|
readonly acceptPacked?: boolean;
|
|
}
|
|
|
|
export class WebSocketLink extends BaseLink {
|
|
readonly #socket: WebSocketLike;
|
|
readonly #compress: boolean;
|
|
#outbox: (string | Uint8Array)[] = [];
|
|
|
|
constructor(socket: WebSocketLike, options: WebSocketLinkOptions = {}) {
|
|
super();
|
|
this.#socket = socket;
|
|
this.#compress = options.compress ?? false;
|
|
const acceptPacked = options.acceptPacked ?? true;
|
|
// Binary messages as ArrayBuffer (browsers and Node's WebSocket default to Blob).
|
|
if ("binaryType" in socket) (socket as { binaryType: string }).binaryType = "arraybuffer";
|
|
socket.addEventListener("open", () => {
|
|
for (const data of this.#outbox.splice(0)) socket.send(data);
|
|
});
|
|
socket.addEventListener("message", (event) => {
|
|
try {
|
|
const data = event.data;
|
|
if (typeof data === "string") {
|
|
this.deliver(parseFrame(data));
|
|
return;
|
|
}
|
|
if (!acceptPacked) throw new TypeError("packed frames are not accepted");
|
|
const bytes =
|
|
data instanceof ArrayBuffer
|
|
? new Uint8Array(data)
|
|
: ArrayBuffer.isView(data)
|
|
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
: undefined;
|
|
if (bytes === undefined) throw new TypeError("unknown message type");
|
|
const { envelope, field, bytes: payload } = splitMessage(bytes);
|
|
if (zlib !== undefined) this.deliver({ ...envelope, [field]: new Packed(payload) } as Frame);
|
|
else this.deliverLater(inflate(payload).then((value) => ({ ...envelope, [field]: value }) as Frame));
|
|
} catch {
|
|
this.close("protocol error");
|
|
}
|
|
});
|
|
socket.addEventListener("close", () => this.ended("socket closed"));
|
|
}
|
|
|
|
protected transmit(frame: Frame): number {
|
|
const data = packMessage(frame, this.#compress);
|
|
if (this.#socket.readyState === OPEN) this.#socket.send(data);
|
|
else this.#outbox.push(data);
|
|
return typeof data === "string" ? data.length : data.byteLength;
|
|
}
|
|
|
|
protected shutdown(): void {
|
|
this.#outbox = [];
|
|
if (this.#socket.readyState <= OPEN) this.#socket.close(1000);
|
|
}
|
|
}
|
|
|
|
export const webSocketLink = (socket: WebSocketLike, options?: WebSocketLinkOptions): WebSocketLink =>
|
|
new WebSocketLink(socket, options);
|