UNPKG

@firtoz/socka

Version:

Standard Schema–first WebSocket RPC for TypeScript — Bun, Hono, Node ws, Cloudflare Workers, Durable Objects

603 lines (600 loc) • 18.3 kB
import { RESERVED_SOCKA_PROCEDURE_NAMES } from './chunk-YMT4HAH7.js'; import { parseWirePayload, decodeSockaWire, SockaWireError, encodeClientRequest, encodeSockaWire, SockaError, reportSockaError, parseStandardSchema } from './chunk-IFIGKR3W.js'; // src/client/SockaWebSocketClient.ts var SockaWebSocketClient = class { constructor(options) { this.manualClose = false; this.reconnectAttempt = 0; this.statusListeners = /* @__PURE__ */ new Set(); this.opts = options; this.contract = options.contract; this.wireFormat = options.wireFormat ?? "json"; this.serializeJson = options.serializeJson ?? JSON.stringify; this.deserializeJson = options.deserializeJson ?? JSON.parse; this.onResponseCb = options.onResponse; this.onServerErrorCb = options.onServerError; this.onEventCb = options.onEvent; this.onValidationError = options.onValidationError; if (options.autoConnect !== false) { this._status = "connecting"; this.attachSocket(this.createSocket()); } else { this._status = "idle"; } } setStatus(next) { if (this._status === next) return; this._status = next; for (const fn of this.statusListeners) { fn(next); } } /** Current connection lifecycle state. */ get status() { return this._status; } /** * Subscribe to {@link status} changes. The listener is called immediately with the * current status, then on every transition. Returns an unsubscribe function. */ onStatusChange(listener) { this.statusListeners.add(listener); listener(this._status); return () => { this.statusListeners.delete(listener); }; } createSocket() { if (this.opts.webSocket) { return this.opts.webSocket; } if (this.opts.url) { return new WebSocket(this.opts.url); } throw new Error("Either 'url' or 'webSocket' must be provided"); } attachSocket(ws) { this.setStatus("connecting"); this.ws = ws; if (this.wireFormat === "msgpack") { ws.binaryType = "arraybuffer"; } ws.addEventListener("open", (event) => { const prev = this.reconnectAttempt; this.reconnectAttempt = 0; if (prev > 0) { this.opts.onReconnected?.({ attempt: prev }); } this.setStatus("open"); this.opts.onOpen?.(event); }); ws.addEventListener("message", (event) => { this.handleMessageEvent(event); }); ws.addEventListener("close", (event) => { this.opts.onClose?.(event); if (this.manualClose) { this.setStatus("closed"); return; } if (!this.getReconnectEnabled()) { this.setStatus("closed"); return; } const cfg = this.resolveReconnectConfig(); const maxAttempts = cfg.maxAttempts; if (maxAttempts !== void 0 && this.reconnectAttempt >= maxAttempts) { this.setStatus("closed"); return; } this.maybeScheduleReconnect(); }); ws.addEventListener("error", (event) => { this.opts.onError?.(event); }); } getReconnectEnabled() { if (this.opts.reconnect === false) return false; if (this.opts.webSocket !== void 0 && this.opts.reconnect === void 0) { return false; } return this.opts.url !== void 0; } resolveReconnectConfig() { const r = this.opts.reconnect; if (r === false) return {}; return r ?? {}; } computeReconnectDelayMs(attempt, cfg) { const initial = cfg.initialDelayMs ?? 1e3; const max = cfg.maxDelayMs ?? 3e4; const jitterRatio = cfg.jitter ?? 0.2; const base = Math.min(max, initial * 2 ** Math.max(0, attempt - 1)); const spread = base * jitterRatio; return Math.max(0, base + (Math.random() * 2 - 1) * spread); } maybeScheduleReconnect() { if (this.manualClose) return; if (!this.getReconnectEnabled()) return; const cfg = this.resolveReconnectConfig(); const maxAttempts = cfg.maxAttempts; if (maxAttempts !== void 0 && this.reconnectAttempt >= maxAttempts) { return; } if (cfg.pauseWhenHidden !== false && typeof document !== "undefined" && document.hidden) { this.setStatus("reconnecting"); const onVis = () => { if (!document.hidden) { document.removeEventListener("visibilitychange", onVis); this.maybeScheduleReconnect(); } }; document.addEventListener("visibilitychange", onVis); return; } this.reconnectAttempt += 1; this.setStatus("reconnecting"); const delayMs = this.computeReconnectDelayMs(this.reconnectAttempt, cfg); this.opts.onReconnecting?.({ attempt: this.reconnectAttempt, delayMs }); if (this.reconnectTimer !== void 0) { clearTimeout(this.reconnectTimer); } this.reconnectTimer = setTimeout(() => { this.reconnectTimer = void 0; if (this.manualClose) return; this.openReplacementSocket(); }, delayMs); } openReplacementSocket() { if (this.manualClose) return; if (!this.opts.url) return; this.ws = void 0; this.attachSocket(this.createSocket()); } handleMessageEvent(event) { try { const fmt = this.wireFormat; let payload; if (fmt === "json") { if (typeof event.data !== "string") { this.onValidationError?.( new Error("socka: expected JSON text frame"), event.data ); return; } payload = event.data; } else { if (!(event.data instanceof ArrayBuffer)) { this.onValidationError?.( new Error("socka: expected ArrayBuffer msgpack frame"), event.data ); return; } payload = event.data; } let parsed; try { parsed = parseWirePayload(payload, fmt, this.deserializeJson); } catch (err) { this.onValidationError?.( err instanceof Error ? err : new Error(String(err)), event.data ); return; } let decoded; try { decoded = decodeSockaWire(parsed); } catch (err) { if (err instanceof SockaWireError) { this.onValidationError?.(err, parsed); return; } throw err; } switch (decoded.kind) { case "serverResponse": this.onResponseCb?.(decoded.frame); break; case "serverError": this.onServerErrorCb?.(decoded.frame); break; case "serverEvent": this.onEventCb?.(decoded.frame); break; case "clientRequest": this.onValidationError?.( new Error("socka: unexpected clientRequest frame from server"), parsed ); break; default: { const _exhaustive = decoded; throw new Error( `socka: unexpected wire decode branch ${JSON.stringify(_exhaustive)}` ); } } } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.onValidationError?.(err, event.data); } } /** * Creates the WebSocket (when {@link SockaWebSocketClientOptions.autoConnect} * was `false`) and waits until the connection is open. */ async connect() { if (!this.ws) { this.attachSocket(this.createSocket()); } await this.waitForOpen(); } sendRequest(id, rpc, body) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error("WebSocket is not open"); } const frame = encodeClientRequest(id, rpc, body); const encoded = encodeSockaWire(frame, this.wireFormat, this.serializeJson); if (typeof encoded === "string") { this.ws.send(encoded); return; } const copy = new Uint8Array(encoded.byteLength); copy.set(encoded); this.ws.send(copy.buffer); } close(code, reason) { this.manualClose = true; if (this.reconnectTimer !== void 0) { clearTimeout(this.reconnectTimer); this.reconnectTimer = void 0; } this.setStatus("closed"); this.ws?.close(code, reason); } get readyState() { return this.ws?.readyState ?? WebSocket.CONNECTING; } get socket() { if (!this.ws) { throw new Error( "socka: WebSocket not created yet; call connect() first or use autoConnect: true" ); } return this.ws; } async waitForOpen() { const ws = this.ws; if (!ws) { throw new Error( "socka: WebSocket not created yet; call connect() first or use autoConnect: true" ); } if (ws.readyState === WebSocket.OPEN) { return; } return new Promise((resolve, reject) => { const abortController = new AbortController(); const { signal } = abortController; const cleanup = () => { abortController.abort(); }; ws.addEventListener( "open", () => { cleanup(); resolve(); }, { signal } ); ws.addEventListener( "error", () => { cleanup(); reject(new Error("WebSocket connection failed")); }, { signal } ); }); } }; // src/client/SockaSession.ts var RESERVED_CALL_NAMES = new Set(RESERVED_SOCKA_PROCEDURE_NAMES); function waitForPushAbortError() { if (typeof DOMException !== "undefined") { return new DOMException("waitForPush aborted", "AbortError"); } return new Error("socka: waitForPush aborted"); } var SockaSessionBase = class { constructor(options) { this.pending = /* @__PURE__ */ new Map(); this.idSeq = 0; this.pushListeners = /* @__PURE__ */ new Map(); const { pushHandlers, reportError, ...clientOpts } = options; this.reportError = reportError; this.client = new SockaWebSocketClient({ ...clientOpts, onResponse: (frame) => this.handleResponse(frame), onServerError: (frame) => this.handleServerError(frame), onEvent: (frame) => this.handleEvent(frame) }); this.subscribe = this.createSubscribeApi(); const sendBag = this.buildSendMethods(); for (const name of Object.keys(sendBag)) { if (RESERVED_CALL_NAMES.has(name)) { throw new Error( `socka: call name "${name}" is reserved on SockaSession.send; rename it in defineSocka` ); } } this.send = sendBag; if (pushHandlers) { for (const key of Object.keys(pushHandlers)) { const fn = pushHandlers[key]; if (fn) { this.subscribe.on(key, fn); } } } } createSubscribeApi() { return { on: (name, handler) => { this.addPushListener(name, handler); }, off: (name, handler) => { this.removePushListener(name, handler); }, once: (name, handler) => { const wrapped = (payload) => { this.removePushListener(name, wrapped); try { const result = handler( payload ); void Promise.resolve(result).catch((error) => { reportSockaError(this.reportError, { kind: "clientEventListener", eventName: String(name), error }); }); } catch (error) { reportSockaError(this.reportError, { kind: "clientEventListener", eventName: String(name), error }); } }; this.addPushListener(name, wrapped); }, waitForPush: (name, options) => this.waitForPushImpl(name, options) }; } addPushListener(name, handler) { let set = this.pushListeners.get(name); if (!set) { set = /* @__PURE__ */ new Set(); this.pushListeners.set(name, set); } set.add(handler); } removePushListener(name, handler) { this.pushListeners.get(name)?.delete(handler); } waitForPushImpl(name, options) { return new Promise((resolve, reject) => { const signal = options?.signal; if (signal?.aborted) { reject(waitForPushAbortError()); return; } const onAbort = () => { cleanup(); reject(waitForPushAbortError()); }; signal?.addEventListener("abort", onAbort); let timeoutId; if (options?.timeoutMs != null) { timeoutId = setTimeout(() => { cleanup(); reject(new Error("socka: waitForPush timed out")); }, options.timeoutMs); } const listener = (payload) => { if (options?.predicate && !options.predicate(payload)) { return; } cleanup(); resolve(payload); }; const cleanup = () => { if (timeoutId !== void 0) clearTimeout(timeoutId); signal?.removeEventListener("abort", onAbort); this.removePushListener(name, listener); }; this.addPushListener(name, listener); }); } buildSendMethods() { const methods = {}; for (const name of Object.keys(this.client.contract.calls)) { const proc = this.client.contract.calls[name]; if (proc.input) { methods[name] = (input) => this.call(name, input); } else { methods[name] = () => this.call(name, void 0); } } return methods; } call(callName, input) { return (async () => { await this.client.connect(); if (this.client.readyState !== WebSocket.OPEN) { throw new Error("WebSocket not connected"); } const id = this.nextId(callName); const body = input !== void 0 && input !== null ? input : {}; const proc = this.client.contract.calls[callName]; if (proc !== void 0 && proc.output === void 0) { try { this.client.sendRequest(id, callName, body); } catch (err) { throw err instanceof Error ? err : new Error(String(err)); } return; } return new Promise((resolve, reject) => { this.pending.set(id, { rpc: callName, resolve, reject }); try { this.client.sendRequest(id, callName, body); } catch (err) { this.pending.delete(id); reject(err instanceof Error ? err : new Error(String(err))); } }); })(); } handleResponse(frame) { const proc = this.client.contract.calls[frame.rpc]; if (!proc) { const entry2 = this.pending.get(frame.id); if (entry2) { this.pending.delete(frame.id); entry2.reject(new SockaError(`Unknown call: ${frame.rpc}`)); } return; } if (proc.output === void 0) { const entry2 = this.pending.get(frame.id); if (entry2) { this.pending.delete(frame.id); reportSockaError(this.reportError, { kind: "clientUnexpectedServerResponse", rpc: frame.rpc, requestId: frame.id }); entry2.reject( new SockaError( "socka: unexpected serverResponse for fire-and-forget call", { requestId: frame.id, rpc: frame.rpc } ) ); } else { reportSockaError(this.reportError, { kind: "clientUnexpectedServerResponse", rpc: frame.rpc, requestId: frame.id }); } return; } const entry = this.pending.get(frame.id); if (!entry) return; this.pending.delete(frame.id); void parseStandardSchema(proc.output, frame.body).then( (validated) => entry.resolve(validated), (err) => entry.reject(err instanceof Error ? err : new Error(String(err))) ); } handleServerError(frame) { const entry = this.pending.get(frame.id); if (entry) { this.pending.delete(frame.id); entry.reject(SockaError.fromWire(frame)); return; } const err = SockaError.fromWire(frame); const rpcName = frame.rpc; if (rpcName === void 0) { reportSockaError(this.reportError, { kind: "clientFireAndForgetRpcError", error: err }); return; } const proc = this.client.contract.calls[rpcName]; if (proc !== void 0 && proc.output === void 0) { reportSockaError(this.reportError, { kind: "clientFireAndForgetRpcError", error: err }); return; } reportSockaError(this.reportError, { kind: "clientOrphanServerError", error: err }); } handleEvent(frame) { const schema = this.client.contract.pushes[frame.event]; if (schema) { void parseStandardSchema(schema, frame.body).then( (validated) => this.dispatchValidatedPush(frame.event, validated), (error) => reportSockaError(this.reportError, { kind: "clientEventValidation", eventName: frame.event, error }) ); } else { this.dispatchValidatedPush(frame.event, frame.body); } } dispatchValidatedPush(pushName, payload) { const set = this.pushListeners.get(pushName); if (!set || set.size === 0) return; for (const fn of [...set]) { try { const result = fn(payload); void Promise.resolve(result).catch((error) => { reportSockaError(this.reportError, { kind: "clientEventListener", eventName: pushName, error }); }); } catch (error) { reportSockaError(this.reportError, { kind: "clientEventListener", eventName: pushName, error }); } } } nextId(prefix) { return `${prefix}-${++this.idSeq}`; } rejectAllPending(reason) { for (const [, entry] of this.pending) { entry.reject(reason); } this.pending.clear(); } close(code, reason) { this.client.close(code, reason); } /** Opens the WebSocket when using {@link SockaWebSocketClientOptions.autoConnect} `false`. */ connect() { return this.client.connect(); } /** Same as {@link SockaWebSocketClient.status}. */ get status() { return this.client.status; } /** Same as {@link SockaWebSocketClient.onStatusChange}. */ onStatusChange(listener) { return this.client.onStatusChange(listener); } }; var SockaSession = SockaSessionBase; export { SockaSession, SockaWebSocketClient }; //# sourceMappingURL=chunk-THFUHQJ3.js.map //# sourceMappingURL=chunk-THFUHQJ3.js.map