import { createGeneration, type Generation } from "./v3-generation.js"; import type { RpcRequest, RpcResponse } from "./v3-rpc-messages.js"; type RpcResult = { ok: true; value: unknown } | { ok: false; error: unknown }; /** * Metadata provided to registered RPC handlers. */ export interface RpcHandlerMeta { rpcId: string; viewId?: string; idemKey: string; gen: Generation; method: string; } type RpcCall = { rpcId: string; method: string; idemKey: string; timeoutId?: ReturnType; retriable: boolean; resolve: (value: unknown) => void; reject: (reason: unknown) => void; }; type RpcHandler = ( params: unknown, meta: RpcHandlerMeta, ) => unknown | Promise; /** * Transport used by the RPC engine to send messages. */ export type RpcSend = (message: RpcRequest | RpcResponse) => void; /** * Configuration options for the RPC engine. */ export interface RpcConfig { defaultTimeoutMs?: number; idemCacheSize?: number; now?: () => number; setTimer?: ( handler: () => void, timeout: number, ) => ReturnType; clearTimer?: (id: ReturnType) => void; } /** * RPC engine interface for sending calls and handling responses. */ export interface RpcEngine { call( method: string, params: unknown, options?: { idemKey?: string; timeoutMs?: number; retriable?: boolean; viewId?: string; gen?: Generation | number; }, ): Promise; handleMessage(message: RpcRequest | RpcResponse): Promise; registerHandler( method: string, handler: RpcHandler, options?: { cacheResponse?: boolean }, ): void; reset(reason?: unknown): void; } const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_IDEM_CACHE_SIZE = 1000; const UUID_BYTE_LENGTH = 16; const fillRandomBytes = (bytes: Uint8Array): void => { if ( typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.getRandomValues === "function" ) { globalThis.crypto.getRandomValues(bytes); return; } for (let index = 0; index < bytes.length; index += 1) { bytes[index] = Math.floor(Math.random() * 256); } }; const toHexByte = (value: number): string => value.toString(16).padStart(2, "0"); const createUuidV4Fallback = (): string => { const bytes = new Uint8Array(UUID_BYTE_LENGTH); fillRandomBytes(bytes); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const segments = [ Array.from(bytes.slice(0, 4), toHexByte).join(""), Array.from(bytes.slice(4, 6), toHexByte).join(""), Array.from(bytes.slice(6, 8), toHexByte).join(""), Array.from(bytes.slice(8, 10), toHexByte).join(""), Array.from(bytes.slice(10, 16), toHexByte).join(""), ]; return segments.join("-"); }; /** * Creates an RPC engine bound to the provided transport. */ export function createRpcEngine( send: RpcSend, config: RpcConfig = {}, ): RpcEngine { const inFlight = new Map(); const handlers = new Map< string, { handle: RpcHandler; cacheResponse: boolean } >(); const idemCache = new Map(); const setTimer = config.setTimer ?? ((handler, timeout) => setTimeout(handler, timeout)); const clearTimer = config.clearTimer ?? ((id) => { clearTimeout(id); }); function enqueueCall(call: RpcCall, timeoutMs: number): void { const timeoutId = setTimer(() => { inFlight.delete(call.rpcId); call.reject( createError("timeout", `RPC ${call.method} timed out`, call.rpcId), ); }, timeoutMs); call.timeoutId = timeoutId; inFlight.set(call.rpcId, call); } function completeCall( rpcId: string, result: RpcResult, retriable: boolean, ): void { const entry = inFlight.get(rpcId); if (!entry) { return; } if (entry.timeoutId) { clearTimer(entry.timeoutId); } inFlight.delete(rpcId); if (result.ok) { entry.resolve(result.value); } else { const error = createError( "error", String(result.error ?? "RPC failed"), rpcId, retriable, ); entry.reject(error); } } function respond(request: RpcRequest, result: RpcResult): void { const message: RpcResponse = result.ok ? { t: "getuserfeedback:rpc:response", gen: request.gen, rpcId: request.rpcId, ok: true, result: result.value, viewId: request.viewId, } : { t: "getuserfeedback:rpc:response", gen: request.gen, rpcId: request.rpcId, ok: false, error: String(result.error ?? "RPC failed"), retriable: true, viewId: request.viewId, }; send(message); } function cacheResult(idemKey: string, result: RpcResult): void { if (idemCache.has(idemKey)) { return; } if (idemCache.size >= (config.idemCacheSize ?? DEFAULT_IDEM_CACHE_SIZE)) { const firstKey = idemCache.keys().next().value; if (firstKey) { idemCache.delete(firstKey); } } idemCache.set(idemKey, result); } async function handleRequest(request: RpcRequest): Promise { const handler = handlers.get(request.method); if (handler?.cacheResponse !== false && idemCache.has(request.idemKey)) { const cached = idemCache.get(request.idemKey); if (cached) { respond(request, cached); } return; } if (!handler) { respond(request, { ok: false, error: "no handler" }); return; } try { const value = await handler.handle(request.params, { rpcId: request.rpcId, viewId: request.viewId, idemKey: request.idemKey, gen: request.gen, method: request.method, }); const success: RpcResult = { ok: true, value }; if (handler.cacheResponse) cacheResult(request.idemKey, success); respond(request, success); } catch (error) { respond(request, { ok: false, error }); } } function buildRpcId(): string { return typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : createUuidV4Fallback(); } function toGeneration(value?: Generation | number): Generation { if (typeof value === "number") { return createGeneration(value); } return value ?? createGeneration(0); } return { call(method, params, options = {}) { const rpcId = buildRpcId(); const gen = toGeneration(options.gen); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const idemKey = options.idemKey ?? rpcId; const retriable = options.retriable ?? true; return new Promise((resolve, reject) => { const request: RpcRequest = { t: "getuserfeedback:rpc:request", gen, rpcId, method, params, idemKey, viewId: options.viewId, }; enqueueCall( { rpcId, method, idemKey, retriable, resolve, reject, }, timeoutMs, ); send(request); }); }, async handleMessage(message) { if (message.t === "getuserfeedback:rpc:response") { const response = message; if (response.ok) { completeCall( response.rpcId, { ok: true, value: response.result }, false, ); return; } const retryable = response.retriable ?? false; completeCall( response.rpcId, { ok: false, error: response.error }, retryable, ); return; } if (message.t === "getuserfeedback:rpc:request") { await handleRequest(message); } }, registerHandler(method, handler, options) { handlers.set(method, { handle: handler, cacheResponse: options?.cacheResponse !== false, }); }, reset(reason) { for (const call of inFlight.values()) { if (call.timeoutId) { clearTimer(call.timeoutId); } call.reject( createError( "channel_closed", String(reason ?? "RPC channel closed"), call.rpcId, call.retriable, ), ); } inFlight.clear(); }, }; } function createError( code: string, message: string, rpcId: string, retriable?: boolean, ): Error { const error = new Error(message); Object.defineProperty(error, "code", { value: code }); Object.defineProperty(error, "rpcId", { value: rpcId }); if (retriable !== undefined) { Object.defineProperty(error, "retriable", { value: retriable }); } return error; }