diff --git a/packages/server/src/client/daemon-client-transport.test.ts b/packages/server/src/client/daemon-client-transport.test.ts new file mode 100644 index 000000000..63ad6cd52 --- /dev/null +++ b/packages/server/src/client/daemon-client-transport.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test, vi } from "vitest"; +import { + createWebSocketTransportFactory, + decodeMessageData, + describeTransportClose, + describeTransportError, + encodeUtf8String, + extractRelayMessageData, +} from "./daemon-client-transport.js"; + +describe("daemon-client transport helpers", () => { + test("createWebSocketTransportFactory forwards sends when socket is open", () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + const send = vi.fn(); + const close = vi.fn(); + + const factory = createWebSocketTransportFactory(() => ({ + readyState: 1, + send, + close, + binaryType: "blob", + addEventListener, + removeEventListener, + })); + + const transport = factory({ url: "ws://example.test" }); + transport.send("hello"); + transport.close(1000, "bye"); + + expect(send).toHaveBeenCalledWith("hello"); + expect(close).toHaveBeenCalledWith(1000, "bye"); + }); + + test("createWebSocketTransportFactory rejects sends when socket is not open", () => { + const factory = createWebSocketTransportFactory(() => ({ + readyState: 3, + send: vi.fn(), + close: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + + const transport = factory({ url: "ws://example.test" }); + expect(() => transport.send("hello")).toThrow("WebSocket not open (readyState=3)"); + }); + + test("createWebSocketTransportFactory binds and unbinds event listeners", () => { + const listeners = new Map void>(); + const ws = { + readyState: 1, + send: vi.fn(), + close: vi.fn(), + addEventListener: vi.fn((event: string, handler: (...args: any[]) => void) => { + listeners.set(event, handler); + }), + removeEventListener: vi.fn((event: string) => { + listeners.delete(event); + }), + }; + + const transport = createWebSocketTransportFactory(() => ws)({ url: "ws://example.test" }); + + const onMessage = vi.fn(); + const unsubscribe = transport.onMessage(onMessage); + + const message = { data: "payload" }; + listeners.get("message")?.(message); + expect(onMessage).toHaveBeenCalledWith(message); + + unsubscribe(); + expect(ws.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function)); + }); + + test("describeTransportClose prefers reason, then message, then code", () => { + expect(describeTransportClose({ reason: "peer closed" })).toBe("peer closed"); + expect(describeTransportClose({ message: "closed" })).toBe("closed"); + expect(describeTransportClose({ code: 1001 })).toBe("Transport closed (code 1001)"); + expect(describeTransportClose()).toBe("Transport closed"); + }); + + test("describeTransportError returns normalized messages", () => { + expect(describeTransportError(new Error("boom"))).toBe("boom"); + expect(describeTransportError({ message: "bad frame" })).toBe("bad frame"); + expect(describeTransportError()).toBe("Transport error"); + }); + + test("extractRelayMessageData returns strings and array buffers", () => { + expect(extractRelayMessageData({ data: "hello" })).toBe("hello"); + + const view = new Uint8Array([1, 2, 3]); + const extracted = extractRelayMessageData({ data: view }); + expect(extracted).toBeInstanceOf(ArrayBuffer); + expect(Array.from(new Uint8Array(extracted as ArrayBuffer))).toEqual([1, 2, 3]); + }); + + test("decodeMessageData decodes strings, array buffers, and typed arrays", () => { + const bytes = encodeUtf8String("hello"); + expect(decodeMessageData("hello")).toBe("hello"); + expect(decodeMessageData(bytes.buffer)).toBe("hello"); + expect(decodeMessageData(bytes)).toBe("hello"); + }); +}); diff --git a/packages/server/src/client/daemon-client-transport.ts b/packages/server/src/client/daemon-client-transport.ts new file mode 100644 index 000000000..85f89e478 --- /dev/null +++ b/packages/server/src/client/daemon-client-transport.ts @@ -0,0 +1,437 @@ +import { + createClientChannel, + type EncryptedChannel, + type Transport as RelayTransport, +} from "@getpaseo/relay/e2ee"; + +export type DaemonTransport = { + send: (data: string | Uint8Array | ArrayBuffer) => void; + close: (code?: number, reason?: string) => void; + onMessage: (handler: (data: unknown) => void) => () => void; + onOpen: (handler: () => void) => () => void; + onClose: (handler: (event?: unknown) => void) => () => void; + onError: (handler: (event?: unknown) => void) => () => void; +}; + +export type DaemonTransportFactory = (options: { + url: string; + headers?: Record; +}) => DaemonTransport; + +export type WebSocketFactory = ( + url: string, + options?: { headers?: Record } +) => WebSocketLike; + +export type WebSocketLike = { + readyState: number; + send: (data: string | Uint8Array | ArrayBuffer) => void; + close: (code?: number, reason?: string) => void; + binaryType?: string; + on?: (event: string, listener: (...args: any[]) => void) => void; + off?: (event: string, listener: (...args: any[]) => void) => void; + removeListener?: (event: string, listener: (...args: any[]) => void) => void; + addEventListener?: (event: string, listener: (event: any) => void) => void; + removeEventListener?: (event: string, listener: (event: any) => void) => void; + onopen?: ((event: any) => void) | null; + onclose?: ((event: any) => void) | null; + onerror?: ((event: any) => void) | null; + onmessage?: ((event: any) => void) | null; +}; + +export interface TransportLogger { + warn(obj: object, msg?: string): void; +} + +export function defaultWebSocketFactory( + url: string, + _options?: { headers?: Record } +): WebSocketLike { + const globalWs = (globalThis as { WebSocket?: any }).WebSocket; + if (!globalWs) { + throw new Error("WebSocket is not available in this runtime"); + } + return new globalWs(url); +} + +export function createWebSocketTransportFactory( + factory: WebSocketFactory +): DaemonTransportFactory { + return ({ url, headers }) => { + const ws = factory(url, { headers }); + if ("binaryType" in ws) { + try { + ws.binaryType = "arraybuffer"; + } catch { + // no-op + } + } + return { + send: (data) => { + if (typeof ws.readyState === "number" && ws.readyState !== 1) { + throw new Error(`WebSocket not open (readyState=${ws.readyState})`); + } + ws.send(data); + }, + close: (code?: number, reason?: string) => ws.close(code, reason), + onOpen: (handler) => bindWsHandler(ws, "open", handler), + onClose: (handler) => bindWsHandler(ws, "close", handler), + onError: (handler) => bindWsHandler(ws, "error", handler), + onMessage: (handler) => bindWsHandler(ws, "message", handler), + }; + }; +} + +export function createRelayE2eeTransportFactory(args: { + baseFactory: DaemonTransportFactory; + daemonPublicKeyB64: string; + logger: TransportLogger; +}): DaemonTransportFactory { + return ({ url, headers }) => { + const base = args.baseFactory({ url, headers }); + return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger); + }; +} + +export function createEncryptedTransport( + base: DaemonTransport, + daemonPublicKeyB64: string, + logger: TransportLogger +): DaemonTransport { + let channel: EncryptedChannel | null = null; + let opened = false; + let closed = false; + + const openHandlers = new Set<() => void>(); + const closeHandlers = new Set<(event?: unknown) => void>(); + const errorHandlers = new Set<(event?: unknown) => void>(); + const messageHandlers = new Set<(data: unknown) => void>(); + + const emitOpen = () => { + if (opened || closed) return; + opened = true; + for (const handler of openHandlers) { + try { + handler(); + } catch { + // no-op + } + } + }; + + const emitClose = (event?: unknown) => { + if (closed) return; + closed = true; + for (const handler of closeHandlers) { + try { + handler(event); + } catch { + // no-op + } + } + }; + + const emitError = (event?: unknown) => { + if (closed) return; + for (const handler of errorHandlers) { + try { + handler(event); + } catch { + // no-op + } + } + }; + + const emitMessage = (data: unknown) => { + if (closed) return; + for (const handler of messageHandlers) { + try { + handler(data); + } catch { + // no-op + } + } + }; + + const relayTransport: RelayTransport = { + send: (data) => { + if (typeof data === "string") { + base.send(data); + return; + } + if (ArrayBuffer.isView(data)) { + const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const out = new Uint8Array(view.byteLength); + out.set(view); + base.send(out.buffer); + return; + } + if (data instanceof ArrayBuffer) { + if (typeof TextDecoder !== "undefined") { + base.send(data); + return; + } + if (typeof Buffer !== "undefined") { + base.send(data); + return; + } + base.send(data); + return; + } + base.send(String(data)); + }, + close: (code?: number, reason?: string) => base.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + + const startHandshake = async () => { + try { + channel = await createClientChannel(relayTransport, daemonPublicKeyB64, { + onopen: emitOpen, + onmessage: (data) => emitMessage(data), + onclose: (code, reason) => emitClose({ code, reason }), + onerror: (error) => emitError(error), + }); + } catch (error) { + logger.warn({ err: error }, "relay_e2ee_handshake_failed"); + emitError(error); + base.close(1011, "E2EE handshake failed"); + } + }; + + base.onOpen(() => { + void startHandshake(); + }); + base.onMessage((event) => { + relayTransport.onmessage?.(extractRelayMessageData(event)); + }); + base.onClose((event) => { + const record = event as { code?: number; reason?: string } | undefined; + relayTransport.onclose?.(record?.code ?? 0, record?.reason ?? ""); + emitClose(event); + }); + base.onError((event) => { + relayTransport.onerror?.( + event instanceof Error ? event : new Error(String(event)) + ); + emitError(event); + }); + + return { + send: (data) => { + if (!channel) { + throw new Error("Encrypted channel not ready"); + } + const outbound = + typeof data === "string" + ? data + : data instanceof ArrayBuffer + ? data + : ArrayBuffer.isView(data) + ? (() => { + const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const out = new Uint8Array(view.byteLength); + out.set(view); + return out.buffer; + })() + : String(data); + void channel.send(outbound).catch((error) => { + emitError(error); + }); + }, + close: (code?: number, reason?: string) => { + if (channel) { + channel.close(code, reason); + } else { + base.close(code, reason); + } + emitClose({ code, reason }); + }, + onMessage: (handler) => { + messageHandlers.add(handler); + return () => messageHandlers.delete(handler); + }, + onOpen: (handler) => { + openHandlers.add(handler); + if (opened) { + try { + handler(); + } catch { + // no-op + } + } + return () => openHandlers.delete(handler); + }, + onClose: (handler) => { + closeHandlers.add(handler); + if (closed) { + try { + handler(); + } catch { + // no-op + } + } + return () => closeHandlers.delete(handler); + }, + onError: (handler) => { + errorHandlers.add(handler); + return () => errorHandlers.delete(handler); + }, + }; +} + +export function extractRelayMessageData(event: unknown): string | ArrayBuffer { + const raw = + event && typeof event === "object" && "data" in event + ? (event as { data: unknown }).data + : event; + if (typeof raw === "string") return raw; + if (raw instanceof ArrayBuffer) return raw; + if (ArrayBuffer.isView(raw)) { + const view = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength); + const out = new Uint8Array(view.byteLength); + out.set(view); + return out.buffer; + } + return String(raw ?? ""); +} + +export function bindWsHandler( + ws: WebSocketLike, + event: "open" | "close" | "error" | "message", + handler: (...args: any[]) => void +): () => void { + if (typeof ws.addEventListener === "function") { + ws.addEventListener(event, handler); + return () => { + if (typeof ws.removeEventListener === "function") { + ws.removeEventListener(event, handler); + } + }; + } + if (typeof ws.on === "function") { + ws.on(event, handler); + return () => { + if (typeof ws.off === "function") { + ws.off(event, handler); + return; + } + if (typeof ws.removeListener === "function") { + ws.removeListener(event, handler); + } + }; + } + const prop = `on${event}` as "onopen" | "onclose" | "onerror" | "onmessage"; + const previous = (ws as any)[prop]; + (ws as any)[prop] = handler; + return () => { + if ((ws as any)[prop] === handler) { + (ws as any)[prop] = previous ?? null; + } + }; +} + +export function describeTransportClose(event?: unknown): string { + if (!event) { + return "Transport closed"; + } + if (event instanceof Error) { + return event.message; + } + if (typeof event === "string") { + return event; + } + if (typeof event === "object") { + const record = event as { reason?: unknown; message?: unknown; code?: unknown }; + if (typeof record.reason === "string" && record.reason.trim().length > 0) { + return record.reason.trim(); + } + if (typeof record.message === "string" && record.message.trim().length > 0) { + return record.message.trim(); + } + if (typeof record.code === "number") { + return `Transport closed (code ${record.code})`; + } + } + return "Transport closed"; +} + +export function describeTransportError(event?: unknown): string { + if (!event) { + return "Transport error"; + } + if (event instanceof Error) { + return event.message; + } + if (typeof event === "string") { + return event; + } + if (typeof event === "object") { + const record = event as { message?: unknown }; + if (typeof record.message === "string" && record.message.trim().length > 0) { + return record.message.trim(); + } + } + return "Transport error"; +} + +export function safeRandomId(): string { + try { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + } catch { + // ignore + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +export function decodeMessageData(data: unknown): string | null { + if (data === null || data === undefined) { + return null; + } + if (typeof data === "string") { + return data; + } + if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) { + if (typeof Buffer !== "undefined") { + return Buffer.from(data).toString("utf8"); + } + if (typeof TextDecoder !== "undefined") { + return new TextDecoder().decode(data); + } + } + if (ArrayBuffer.isView(data)) { + const view = new Uint8Array( + data.buffer, + data.byteOffset, + data.byteLength + ); + if (typeof Buffer !== "undefined") { + return Buffer.from(view).toString("utf8"); + } + if (typeof TextDecoder !== "undefined") { + return new TextDecoder().decode(view); + } + } + if (typeof (data as { toString?: () => string }).toString === "function") { + return (data as { toString: () => string }).toString(); + } + return null; +} + +export function encodeUtf8String(value: string): Uint8Array { + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(value); + } + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(value, "utf8")); + } + const out = new Uint8Array(value.length); + for (let i = 0; i < value.length; i++) { + out[i] = value.charCodeAt(i) & 0xff; + } + return out; +} diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 50a92142e..e5d72d43c 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -54,11 +54,6 @@ import type { AgentSessionConfig, } from "../server/agent/agent-sdk-types.js"; import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js"; -import { - createClientChannel, - type EncryptedChannel, - type Transport as RelayTransport, -} from "@getpaseo/relay/e2ee"; import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js"; import { asUint8Array, @@ -73,6 +68,19 @@ import { encodeTerminalKeyInput, type TerminalKeyInput, } from "../shared/terminal-key-input.js"; +import { + createRelayE2eeTransportFactory, + createWebSocketTransportFactory, + decodeMessageData, + defaultWebSocketFactory, + describeTransportClose, + describeTransportError, + encodeUtf8String, + safeRandomId, + type DaemonTransport, + type DaemonTransportFactory, + type WebSocketFactory, +} from "./daemon-client-transport.js"; export interface Logger { debug(obj: object, msg?: string): void; @@ -88,40 +96,12 @@ const consoleLogger: Logger = { error: (obj, msg) => console.error(msg, obj), }; -export type DaemonTransport = { - send: (data: string | Uint8Array | ArrayBuffer) => void; - close: (code?: number, reason?: string) => void; - onMessage: (handler: (data: unknown) => void) => () => void; - onOpen: (handler: () => void) => () => void; - onClose: (handler: (event?: unknown) => void) => () => void; - onError: (handler: (event?: unknown) => void) => () => void; -}; - -export type DaemonTransportFactory = (options: { - url: string; - headers?: Record; -}) => DaemonTransport; - -export type WebSocketFactory = ( - url: string, - options?: { headers?: Record } -) => WebSocketLike; - -export type WebSocketLike = { - readyState: number; - send: (data: string | Uint8Array | ArrayBuffer) => void; - close: (code?: number, reason?: string) => void; - binaryType?: string; - on?: (event: string, listener: (...args: any[]) => void) => void; - off?: (event: string, listener: (...args: any[]) => void) => void; - removeListener?: (event: string, listener: (...args: any[]) => void) => void; - addEventListener?: (event: string, listener: (event: any) => void) => void; - removeEventListener?: (event: string, listener: (event: any) => void) => void; - onopen?: ((event: any) => void) | null; - onclose?: ((event: any) => void) | null; - onerror?: ((event: any) => void) | null; - onmessage?: ((event: any) => void) | null; -}; +export type { + DaemonTransport, + DaemonTransportFactory, + WebSocketFactory, + WebSocketLike, +} from "./daemon-client-transport.js"; export type ConnectionState = | { status: "idle" } @@ -3045,403 +3025,6 @@ export class DaemonClient { } } -// ============================================================================ -// Helpers -// ============================================================================ - -function defaultWebSocketFactory( - url: string, - _options?: { headers?: Record } -): WebSocketLike { - const globalWs = (globalThis as { WebSocket?: any }).WebSocket; - if (!globalWs) { - throw new Error("WebSocket is not available in this runtime"); - } - return new globalWs(url); -} - -function createWebSocketTransportFactory( - factory: WebSocketFactory -): DaemonTransportFactory { - return ({ url, headers }) => { - const ws = factory(url, { headers }); - if ("binaryType" in ws) { - try { - ws.binaryType = "arraybuffer"; - } catch { - // no-op - } - } - return { - send: (data) => { - if (typeof ws.readyState === "number" && ws.readyState !== 1) { - throw new Error(`WebSocket not open (readyState=${ws.readyState})`); - } - ws.send(data); - }, - close: (code?: number, reason?: string) => ws.close(code, reason), - onOpen: (handler) => bindWsHandler(ws, "open", handler), - onClose: (handler) => bindWsHandler(ws, "close", handler), - onError: (handler) => bindWsHandler(ws, "error", handler), - onMessage: (handler) => bindWsHandler(ws, "message", handler), - }; - }; -} - -function createRelayE2eeTransportFactory(args: { - baseFactory: DaemonTransportFactory; - daemonPublicKeyB64: string; - logger: Logger; -}): DaemonTransportFactory { - return ({ url, headers }) => { - const base = args.baseFactory({ url, headers }); - return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger); - }; -} - -function createEncryptedTransport( - base: DaemonTransport, - daemonPublicKeyB64: string, - logger: Logger -): DaemonTransport { - let channel: EncryptedChannel | null = null; - let opened = false; - let closed = false; - - const openHandlers = new Set<() => void>(); - const closeHandlers = new Set<(event?: unknown) => void>(); - const errorHandlers = new Set<(event?: unknown) => void>(); - const messageHandlers = new Set<(data: unknown) => void>(); - - const emitOpen = () => { - if (opened || closed) return; - opened = true; - for (const handler of openHandlers) { - try { - handler(); - } catch { - // no-op - } - } - }; - - const emitClose = (event?: unknown) => { - if (closed) return; - closed = true; - for (const handler of closeHandlers) { - try { - handler(event); - } catch { - // no-op - } - } - }; - - const emitError = (event?: unknown) => { - if (closed) return; - for (const handler of errorHandlers) { - try { - handler(event); - } catch { - // no-op - } - } - }; - - const emitMessage = (data: unknown) => { - if (closed) return; - for (const handler of messageHandlers) { - try { - handler(data); - } catch { - // no-op - } - } - }; - - const relayTransport: RelayTransport = { - send: (data) => { - if (typeof data === "string") { - base.send(data); - return; - } - if (ArrayBuffer.isView(data)) { - const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - const out = new Uint8Array(view.byteLength); - out.set(view); - base.send(out.buffer); - return; - } - if (data instanceof ArrayBuffer) { - if (typeof TextDecoder !== "undefined") { - base.send(data); - return; - } - if (typeof Buffer !== "undefined") { - base.send(data); - return; - } - base.send(data); - return; - } - base.send(String(data)); - }, - close: (code?: number, reason?: string) => base.close(code, reason), - onmessage: null, - onclose: null, - onerror: null, - }; - - const startHandshake = async () => { - try { - channel = await createClientChannel(relayTransport, daemonPublicKeyB64, { - onopen: emitOpen, - onmessage: (data) => emitMessage(data), - onclose: (code, reason) => emitClose({ code, reason }), - onerror: (error) => emitError(error), - }); - } catch (error) { - logger.warn({ err: error }, "relay_e2ee_handshake_failed"); - emitError(error); - base.close(1011, "E2EE handshake failed"); - } - }; - - base.onOpen(() => { - void startHandshake(); - }); - base.onMessage((event) => { - relayTransport.onmessage?.(extractRelayMessageData(event)); - }); - base.onClose((event) => { - const record = event as { code?: number; reason?: string } | undefined; - relayTransport.onclose?.(record?.code ?? 0, record?.reason ?? ""); - emitClose(event); - }); - base.onError((event) => { - relayTransport.onerror?.( - event instanceof Error ? event : new Error(String(event)) - ); - emitError(event); - }); - - return { - send: (data) => { - if (!channel) { - throw new Error("Encrypted channel not ready"); - } - const outbound = - typeof data === "string" - ? data - : data instanceof ArrayBuffer - ? data - : ArrayBuffer.isView(data) - ? (() => { - const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - const out = new Uint8Array(view.byteLength); - out.set(view); - return out.buffer; - })() - : String(data); - void channel.send(outbound).catch((error) => { - emitError(error); - }); - }, - close: (code?: number, reason?: string) => { - if (channel) { - channel.close(code, reason); - } else { - base.close(code, reason); - } - emitClose({ code, reason }); - }, - onMessage: (handler) => { - messageHandlers.add(handler); - return () => messageHandlers.delete(handler); - }, - onOpen: (handler) => { - openHandlers.add(handler); - if (opened) { - try { - handler(); - } catch { - // no-op - } - } - return () => openHandlers.delete(handler); - }, - onClose: (handler) => { - closeHandlers.add(handler); - if (closed) { - try { - handler(); - } catch { - // no-op - } - } - return () => closeHandlers.delete(handler); - }, - onError: (handler) => { - errorHandlers.add(handler); - return () => errorHandlers.delete(handler); - }, - }; -} - -function extractRelayMessageData(event: unknown): string | ArrayBuffer { - const raw = - event && typeof event === "object" && "data" in event - ? (event as { data: unknown }).data - : event; - if (typeof raw === "string") return raw; - if (raw instanceof ArrayBuffer) return raw; - if (ArrayBuffer.isView(raw)) { - const view = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength); - const out = new Uint8Array(view.byteLength); - out.set(view); - return out.buffer; - } - return String(raw ?? ""); -} - -function bindWsHandler( - ws: WebSocketLike, - event: "open" | "close" | "error" | "message", - handler: (...args: any[]) => void -): () => void { - if (typeof ws.addEventListener === "function") { - ws.addEventListener(event, handler); - return () => { - if (typeof ws.removeEventListener === "function") { - ws.removeEventListener(event, handler); - } - }; - } - if (typeof ws.on === "function") { - ws.on(event, handler); - return () => { - if (typeof ws.off === "function") { - ws.off(event, handler); - return; - } - if (typeof ws.removeListener === "function") { - ws.removeListener(event, handler); - } - }; - } - const prop = `on${event}` as "onopen" | "onclose" | "onerror" | "onmessage"; - const previous = (ws as any)[prop]; - (ws as any)[prop] = handler; - return () => { - if ((ws as any)[prop] === handler) { - (ws as any)[prop] = previous ?? null; - } - }; -} - -function describeTransportClose(event?: unknown): string { - if (!event) { - return "Transport closed"; - } - if (event instanceof Error) { - return event.message; - } - if (typeof event === "string") { - return event; - } - if (typeof event === "object") { - const record = event as { reason?: unknown; message?: unknown; code?: unknown }; - if (typeof record.reason === "string" && record.reason.trim().length > 0) { - return record.reason.trim(); - } - if (typeof record.message === "string" && record.message.trim().length > 0) { - return record.message.trim(); - } - if (typeof record.code === "number") { - return `Transport closed (code ${record.code})`; - } - } - return "Transport closed"; -} - -function describeTransportError(event?: unknown): string { - if (!event) { - return "Transport error"; - } - if (event instanceof Error) { - return event.message; - } - if (typeof event === "string") { - return event; - } - if (typeof event === "object") { - const record = event as { message?: unknown }; - if (typeof record.message === "string" && record.message.trim().length > 0) { - return record.message.trim(); - } - } - return "Transport error"; -} - -function safeRandomId(): string { - try { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - } catch { - // ignore - } - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; -} - -function decodeMessageData(data: unknown): string | null { - if (data === null || data === undefined) { - return null; - } - if (typeof data === "string") { - return data; - } - if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) { - if (typeof Buffer !== "undefined") { - return Buffer.from(data).toString("utf8"); - } - if (typeof TextDecoder !== "undefined") { - return new TextDecoder().decode(data); - } - } - if (ArrayBuffer.isView(data)) { - const view = new Uint8Array( - data.buffer, - data.byteOffset, - data.byteLength - ); - if (typeof Buffer !== "undefined") { - return Buffer.from(view).toString("utf8"); - } - if (typeof TextDecoder !== "undefined") { - return new TextDecoder().decode(view); - } - } - if (typeof (data as { toString?: () => string }).toString === "function") { - return (data as { toString: () => string }).toString(); - } - return null; -} - -function encodeUtf8String(value: string): Uint8Array { - if (typeof TextEncoder !== "undefined") { - return new TextEncoder().encode(value); - } - if (typeof Buffer !== "undefined") { - return new Uint8Array(Buffer.from(value, "utf8")); - } - const out = new Uint8Array(value.length); - for (let i = 0; i < value.length; i++) { - out[i] = value.charCodeAt(i) & 0xff; - } - return out; -} - function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionConfig { const { config,