// Companion SDK — WebSocket transport helpers (pure core). // // The SDK streams replies over Server-Sent Events today; the client comment // always anticipated "when the WebSocket plane lands it slots in behind this // same interface". This module is that slot: pure, dependency-free helpers for // deriving the ws(s) URL and framing/parsing messages, so the live WS loop in // client.ts stays a thin wrapper and the framing logic is unit-testable. // // IMPORTANT — server support: a persistent WebSocket endpoint needs a // WS-capable host (adapter-node, or a standalone WS gateway). The default // serverless deploy (adapter-auto → Vercel/Netlify functions) can't hold an // open socket, so the client keeps SSE as the default and FALLS BACK to it if // the socket can't open. Self-hosters who run a WS gateway can opt in with // `stream: 'websocket'`. This file ships the deployment-agnostic client side. /** Build the WebSocket stream URL from the HTTP base. http→ws, https→wss. The * token rides the query string because browsers can't set an Authorization * header on a WebSocket handshake; the server authenticates it the same way it * authenticates the SSE `?cursor=` stream. `cursor` resumes after a reconnect, * mirroring the SSE plane. */ export function deriveWsStreamUrl( baseUrl: string, sessionId: string, token: string, cursor = 0 ): string { const origin = baseUrl.replace(/\/+$/, '').replace(/^http(s?):\/\//i, (_m, s) => `ws${s}://`); const id = encodeURIComponent(sessionId); const q = new URLSearchParams({ cursor: String(cursor), token }); return `${origin}/api/companion/session/${id}/ws?${q.toString()}`; } /** Normalize an incoming WS message into the `{ event, data }` shape the SSE * frame handler already consumes, so both transports converge on one code * path. Accepts two wire forms: * 1. a framed message: { "event": "...", "data": "" } * 2. a bare envelope: { "type": "...", ... } → event defaults to "message" * Returns null for anything unparseable (the caller drops it). */ export function decodeWsMessage(raw: string): { event: string; data: string } | null { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return null; } if (!parsed || typeof parsed !== 'object') return null; const obj = parsed as Record; // Form 1 — explicit frame. if (typeof obj.event === 'string') { const data = typeof obj.data === 'string' ? obj.data : JSON.stringify(obj.data ?? {}); return { event: obj.event, data }; } // Form 2 — bare envelope. Re-serialize so handleFrame can JSON.parse it back // into a CompanionEnvelope exactly as the SSE path does. if (typeof obj.type === 'string') { return { event: 'message', data: raw }; } return null; } /** Frame an outbound user-input message for the WS plane (for hosts that also * accept input over the socket). Mirrors the REST `/input` body. */ export function encodeWsInput(text: string, opts?: { image?: string }): string { const payload: Record = { type: 'input', text }; if (opts?.image) payload.image = opts.image; return JSON.stringify(payload); }