import { createInterface, Interface as ReadLineInterface } from "node:readline"; import WebSocket from "ws"; import { Disposable, isRecord, JsonObject, Logger, RelayFrame, RelayHelloFrame, RelayTransport, } from "./protocol"; export interface RelayClientOptions { url: string; accessToken?: string; sessionId?: string; lastSeq?: number; reconnect?: boolean; reconnectInitialMs?: number; reconnectMaxMs?: number; maxFrameBytes?: number; maxQueuedBytes?: number; logger?: Logger; /** Injectable constructor for tests or a browser-compatible WebSocket. */ webSocket?: new (url: string) => unknown; } type SocketLike = { readyState?: number; send(data: string): void; close(): void; on?(event: string, listener: (...args: any[]) => void): void; addEventListener?(event: string, listener: (...args: any[]) => void): void; }; const OPEN = 1; // A structured Codex history snapshot is routinely larger than 256 KiB even // though its plain-text tail is capped. Keep a bounded limit, but leave enough // room for the message/tool projection of a long attached conversation. export const DEFAULT_MAX_RELAY_FRAME_BYTES = 16 * 1024 * 1024; export const DEFAULT_MAX_RELAY_QUEUE_BYTES = DEFAULT_MAX_RELAY_FRAME_BYTES + 2 * 1024 * 1024; interface QueuedFrame { serialized: string; bytes: number; projectionKey?: string; } const QUEUED_PROJECTION_TYPES = new Set(["session.snapshot", "output.snapshot", "output.chunk"]); function queuedProjectionKey(frame: RelayFrame): string | undefined { if (!isRecord(frame) || frame.kind !== "event" || typeof frame.type !== "string" || !QUEUED_PROJECTION_TYPES.has(frame.type)) return undefined; const sessionId = typeof frame.sessionId === "string" ? frame.sessionId : "default"; return `${sessionId}:transcript`; } /** WebSocket relay transport with bounded reconnect and frame validation. */ export class RelayClient implements RelayTransport { readonly handlesHandshake = true; private readonly options: Required< Pick > & Omit; private socket?: SocketLike; // A WebSocket can report OPEN while its relay authentication handshake is // still in flight. Keep this separate from `socket` so events emitted by // the adapter during reconnect are queued until the relay sends auth.ok. private authenticatedSocket?: SocketLike; private connecting?: Promise; // Incremented whenever a connection attempt is replaced or explicitly // closed. Late events from an older WebSocket must not mutate newer state. private connectionGeneration = 0; private reconnectTimer?: NodeJS.Timeout; private stopped = false; private retryMs: number; private readonly queue: QueuedFrame[] = []; private queueBytes = 0; private readonly listeners = new Set<(frame: RelayFrame) => void>(); private readonly openListeners = new Set<() => void>(); private readonly closeListeners = new Set<(error?: Error) => void>(); constructor(options: RelayClientOptions) { const maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_RELAY_FRAME_BYTES; const defaultMaxQueuedBytes = Math.max( maxFrameBytes, Math.min(DEFAULT_MAX_RELAY_QUEUE_BYTES, maxFrameBytes * 2), ); this.options = { ...options, reconnect: options.reconnect ?? true, reconnectInitialMs: options.reconnectInitialMs ?? 500, reconnectMaxMs: options.reconnectMaxMs ?? 10_000, maxFrameBytes, maxQueuedBytes: Math.max(1, Math.floor(options.maxQueuedBytes ?? defaultMaxQueuedBytes)), }; this.retryMs = this.options.reconnectInitialMs; } /** Let RelayHost assign its stable session id before the first hello. */ setSessionId(sessionId: string): void { this.options.sessionId = sessionId; } async connect(): Promise { this.stopped = false; if (this.socket?.readyState === OPEN && this.authenticatedSocket === this.socket) return; if (this.connecting) return this.connecting; const generation = ++this.connectionGeneration; let connectionPromise: Promise; connectionPromise = new Promise((resolve, reject) => { let settled = false; let authenticated = false; const SocketCtor = this.options.webSocket ?? WebSocket; let socket: SocketLike; try { socket = new SocketCtor(this.options.url) as SocketLike; } catch (error) { reject(error instanceof Error ? error : new Error(String(error))); return; } this.socket = socket; this.authenticatedSocket = undefined; const isCurrent = (): boolean => this.connectionGeneration === generation && this.socket === socket; const onOpen = (): void => { if (!isCurrent() || settled || authenticated) return; try { // The TCP/WebSocket open event is only a transport milestone. Do // not release queued commands until the relay has authenticated us. this.sendHello(socket); } catch (error) { if (!settled) { settled = true; reject(error instanceof Error ? error : new Error(String(error))); } } }; const onMessage = (raw: unknown): void => { if (!isCurrent()) return; const data = extractMessageData(raw); if (Buffer.byteLength(data, "utf8") > this.options.maxFrameBytes) { this.options.logger?.warn?.("Ignoring oversized relay frame"); return; } let frame: unknown; try { frame = JSON.parse(data); } catch { this.options.logger?.warn?.("Ignoring malformed relay JSON"); return; } if (!isRecord(frame)) return; if (frame.type === "auth.ok" && !authenticated && !settled) { authenticated = true; settled = true; this.authenticatedSocket = socket; this.retryMs = this.options.reconnectInitialMs; try { this.flush(socket); } catch (error) { this.options.logger?.warn?.("Unable to flush relay queue after authentication", error); } for (const listener of this.openListeners) listener(); resolve(); } else if (frame.type === "error" && !authenticated && !settled) { settled = true; reject(new Error(typeof frame.message === "string" ? frame.message : "relay authentication failed")); } if (frame.kind === "event" && typeof frame.seq === "number") { this.options.lastSeq = Math.max(this.options.lastSeq ?? 0, frame.seq); } for (const listener of this.listeners) listener(frame as RelayFrame); }; const onError = (raw: unknown): void => { if (!isCurrent()) return; const error = raw instanceof Error ? raw : new Error("relay websocket error"); this.options.logger?.warn?.(error.message); if (!settled) { settled = true; reject(error); } }; const onClose = (): void => { const current = isCurrent(); if (current) { this.socket = undefined; if (this.authenticatedSocket === socket) this.authenticatedSocket = undefined; } const error = new Error("relay websocket closed"); // A stale socket may still need to settle the promise returned to its // caller, but it must never notify the active host or schedule a // second reconnect loop. if (!current) { if (!settled) { settled = true; reject(error); } return; } for (const listener of this.closeListeners) listener(error); if (!settled) { settled = true; reject(error); } if (!this.stopped && this.options.reconnect) this.scheduleReconnect(); }; bindSocket(socket, onOpen, onMessage, onError, onClose); // A small number of test/browser WebSocket implementations can already // be OPEN by the time listeners are attached. if (socket.readyState === OPEN) queueMicrotask(onOpen); }).finally(() => { if (this.connectionGeneration === generation && this.connecting === connectionPromise) { this.connecting = undefined; } }); this.connecting = connectionPromise; return connectionPromise; } send(frame: RelayFrame): void { const serialized = JSON.stringify(frame); const bytes = Buffer.byteLength(serialized, "utf8"); if (bytes > this.options.maxFrameBytes) { throw new Error(`relay frame exceeds ${this.options.maxFrameBytes} bytes`); } if (this.socket?.readyState === OPEN && this.authenticatedSocket === this.socket) { this.socket.send(serialized); return; } this.enqueue({ serialized, bytes, projectionKey: queuedProjectionKey(frame) }); } onMessage(listener: (frame: RelayFrame) => void): Disposable { this.listeners.add(listener); return { dispose: () => this.listeners.delete(listener) }; } onOpen(listener: () => void): Disposable { this.openListeners.add(listener); return { dispose: () => this.openListeners.delete(listener) }; } onClose(listener: (error?: Error) => void): Disposable { this.closeListeners.add(listener); return { dispose: () => this.closeListeners.delete(listener) }; } close(): void { this.stopped = true; this.connectionGeneration += 1; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; this.connecting = undefined; const socket = this.socket; this.socket = undefined; this.authenticatedSocket = undefined; if (socket && socket.readyState !== 3) socket.close(); this.queue.length = 0; this.queueBytes = 0; } private sendHello(socket: SocketLike): void { const hello: RelayHelloFrame = { v: 1, kind: "hello", clientType: "host", protocol: 1, ...(this.options.sessionId ? { sessionId: this.options.sessionId } : {}), ...(this.options.lastSeq !== undefined ? { lastSeq: this.options.lastSeq } : {}), }; socket.send(JSON.stringify(hello)); if (this.options.accessToken) { // Keep authentication separate from hello so a relay can challenge the // host before accepting a bearer token (and so hello remains cacheable). socket.send(JSON.stringify({ v: 1, kind: "auth", accessToken: this.options.accessToken })); } } private flush(socket: SocketLike): void { if (socket.readyState !== OPEN || this.authenticatedSocket !== socket || this.socket !== socket) return; while (this.queue.length > 0) { const entry = this.queue.shift() as QueuedFrame; this.queueBytes = Math.max(0, this.queueBytes - entry.bytes); socket.send(entry.serialized); } } private enqueue(entry: QueuedFrame): void { // Transcript events are reconstructible: RelayHost publishes a fresh full // session snapshot after every authenticated reconnect. Keep only the // newest projection per session while preserving approval/command events. if (entry.projectionKey) { for (let index = this.queue.length - 1; index >= 0; index -= 1) { if (this.queue[index].projectionKey === entry.projectionKey) this.removeQueuedFrame(index); } } if (entry.bytes > this.options.maxQueuedBytes) { this.options.logger?.warn?.("Dropping relay frame that exceeds the reconnect queue byte limit"); return; } while (this.queue.length >= 100 || this.queueBytes + entry.bytes > this.options.maxQueuedBytes) { const projectionIndex = this.queue.findIndex((queued) => Boolean(queued.projectionKey)); if (projectionIndex >= 0) { this.removeQueuedFrame(projectionIndex); continue; } // Never evict an approval/command solely to retain a transcript delta; // the authoritative snapshot emitted after auth restores that state. if (entry.projectionKey) { this.options.logger?.debug?.("Dropping supersedable relay projection while reconnect queue is full"); return; } this.removeQueuedFrame(0); } this.queue.push(entry); this.queueBytes += entry.bytes; } private removeQueuedFrame(index: number): void { const [removed] = this.queue.splice(index, 1); if (removed) this.queueBytes = Math.max(0, this.queueBytes - removed.bytes); } private scheduleReconnect(): void { if (this.reconnectTimer || this.stopped) return; const delay = this.retryMs; this.retryMs = Math.min(this.options.reconnectMaxMs, Math.max(this.retryMs * 2, this.options.reconnectInitialMs)); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = undefined; void this.connect().catch((error) => this.options.logger?.debug?.("relay reconnect failed", error)); }, delay); } } /** * Line-oriented transport for local development and CI. Pipe it to a relay * process with `node dist/cli.js`; each line is one JSON relay frame. */ export class StdioRelayTransport implements RelayTransport { private readonly listeners = new Set<(frame: RelayFrame) => void>(); private readonly lineReader: ReadLineInterface; private closed = false; constructor( private readonly input: NodeJS.ReadableStream = process.stdin, private readonly output: NodeJS.WritableStream = process.stdout, private readonly logger?: Logger, ) { this.lineReader = createInterface({ input, crlfDelay: Infinity }); this.lineReader.on("line", (line) => { if (!line.trim()) return; try { const frame = JSON.parse(line); if (isRecord(frame)) for (const listener of this.listeners) listener(frame as RelayFrame); } catch (error) { this.logger?.warn?.("Ignoring malformed relay stdin frame", error); } }); } async connect(): Promise { this.closed = false; } send(frame: RelayFrame): void { if (this.closed) throw new Error("stdio relay transport is closed"); this.output.write(`${JSON.stringify(frame)}\n`); } onMessage(listener: (frame: RelayFrame) => void): Disposable { this.listeners.add(listener); return { dispose: () => this.listeners.delete(listener) }; } close(): void { this.closed = true; this.lineReader.close(); } } function bindSocket( socket: SocketLike, onOpen: () => void, onMessage: (data: unknown) => void, onError: (error: unknown) => void, onClose: () => void, ): void { if (typeof socket.on === "function") { socket.on("open", onOpen); socket.on("message", onMessage); socket.on("error", onError); socket.on("close", onClose); } else if (typeof socket.addEventListener === "function") { socket.addEventListener("open", onOpen); socket.addEventListener("message", onMessage); socket.addEventListener("error", onError); socket.addEventListener("close", onClose); } else { onError(new Error("WebSocket implementation has no event API")); } } function extractMessageData(raw: unknown): string { if (typeof raw === "string") return raw; if (Buffer.isBuffer(raw)) return raw.toString("utf8"); if (isRecord(raw) && "data" in raw) return extractMessageData(raw.data); return String(raw ?? ""); }