/** * Lightweight read-RPC client over the `/ws` WebSocket. * * Each `rpcCall` opens a short-lived WebSocket, sends an `rpc_request` frame, * waits for the matching `rpc_response`, then closes the connection. * * On a transient connection failure the call is retried once before rejecting, * which lets a freshly-started backend race finish before the user sees an * error. Failures surface as `Error` instances whose `.message` is intended * to be visible (toast / banner) — callers must not swallow them silently. */ let correlationCounter = 0; function nextCorrelationId(): string { return `rpc-${Date.now()}-${++correlationCounter}`; } /** * Build the WebSocket URL for the `/ws` endpoint, deriving the protocol * (ws/wss) and host from the current page location. */ function buildWsUrl(): string { const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; return `${proto}//${window.location.host}/ws`; } export interface RpcResponse { ok: boolean; result?: T; error?: string; code?: string; } /** Error subclass for RPC failures so callers can recognise them. */ export class RpcError extends Error { constructor( message: string, public readonly code?: string, public readonly method?: string, ) { super(message); this.name = "RpcError"; } } /** Maximum number of automatic retries on transient WebSocket failure. */ const MAX_RETRIES = 1; /** Delay between retry attempts (ms). */ const RETRY_DELAY_MS = 250; /** * Internal: a single one-shot RPC attempt. Resolves with the result or * rejects with an `RpcError`. */ function rpcAttempt( method: string, params: object, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { const correlationId = nextCorrelationId(); let ws: WebSocket; try { ws = new WebSocket(buildWsUrl()); } catch (err) { reject( new RpcError( `Failed to open WebSocket for ${method}: ${(err as Error).message}`, "CONNECT_FAILED", method, ), ); return; } let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; try { ws.close(); } catch { /* ignore */ } reject(new RpcError(`RPC timeout for ${method}`, "TIMEOUT", method)); } }, timeoutMs); ws.onopen = () => { ws.send( JSON.stringify({ kind: "rpc_request", version: 1, correlation_id: correlationId, ttl_ms: timeoutMs, method, params, }), ); }; ws.onmessage = (event) => { let data: unknown; try { data = JSON.parse(event.data); } catch { // Non-JSON frame is not ours — keep waiting. return; } if (!data || typeof data !== "object") { return; } const frame = data as { kind?: unknown; correlation_id?: unknown; ok?: unknown; result?: unknown; error?: unknown; code?: unknown; }; if (frame.kind !== "rpc_response" || frame.correlation_id !== correlationId) { // Not addressed to this call — ignore (pipeline_state, etc.). return; } settled = true; clearTimeout(timer); try { ws.close(); } catch { /* ignore */ } if (typeof frame.ok !== "boolean") { reject( new RpcError( `Malformed RPC response for ${method}: missing or non-boolean 'ok' field`, "MALFORMED", method, ), ); return; } if (frame.ok) { if (!("result" in frame)) { reject( new RpcError( `Malformed RPC response for ${method}: 'ok:true' frame missing 'result' field`, "MALFORMED", method, ), ); return; } resolve(frame.result as T); } else { const errMsg = typeof frame.error === "string" ? frame.error : undefined; const errCode = typeof frame.code === "string" ? frame.code : undefined; reject( new RpcError( errMsg || `RPC error: ${errCode || "UNKNOWN"}`, errCode, method, ), ); } }; ws.onerror = () => { if (!settled) { settled = true; clearTimeout(timer); reject( new RpcError( `WebSocket error during RPC call to ${method}`, "CONNECT_FAILED", method, ), ); } }; ws.onclose = () => { if (!settled) { settled = true; clearTimeout(timer); reject( new RpcError( `WebSocket closed before RPC response for ${method}`, "CONNECT_FAILED", method, ), ); } }; }); } /** Return true if the error is one we should retry (connection-level). */ function isRetryable(err: unknown): boolean { return ( err instanceof RpcError && (err.code === "CONNECT_FAILED" || err.code === "TIMEOUT") ); } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } /** * Send a read-RPC request over a temporary WebSocket connection and return * the result. On transient connection failure the call is retried once * before rejecting. Rejects with [`RpcError`] on server-side errors, * timeouts, or persistent connection failures. */ export async function rpcCall( method: string, params: object = {}, timeoutMs = 5000, ): Promise { let lastErr: unknown; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { return await rpcAttempt(method, params, timeoutMs); } catch (err) { lastErr = err; if (attempt < MAX_RETRIES && isRetryable(err)) { await sleep(RETRY_DELAY_MS); continue; } throw err; } } // Unreachable but TypeScript can't prove it. throw lastErr; }