UNPKG

@firtoz/socka

Version:

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

317 lines (315 loc) • 9.77 kB
import { parseStandardSchema, parseWirePayload, decodeSockaWire, SockaWireError, encodeServerError, SockaError, encodeServerResponse, encodeSockaWire, encodeServerEvent, reportSockaError } from './chunk-IFIGKR3W.js'; import { exhaustiveGuard } from '@firtoz/maybe-error'; function isLooseUpgradeConfig(config) { return "strictUpgradeRequest" in config && config.strictUpgradeRequest === false; } function broadcastSockaEventToPeers(sessions, self, event, body, excludeSelf = false) { for (const [ws, session] of sessions) { if (excludeSelf && ws === self.websocket) continue; session.emitWireEvent(event, body); } } function broadcastSockaEventToAll(sessions, event, body) { for (const session of sessions.values()) { session.emitWireEvent(event, body); } } async function broadcastContractPushToAll(sessions, contract, name, body) { const schema = contract.pushes[name]; if (!schema) { throw new Error(`socka: unknown push ${String(name)}`); } const validated = await parseStandardSchema( schema, body ); broadcastSockaEventToAll(sessions, name, validated); } var SockaWebSocketSession = class { constructor(websocket, sessions, config, init) { this.websocket = websocket; this.sessions = sessions; this.config = config; this.wireFormat = config.wireFormat ?? "json"; if (isLooseUpgradeConfig(config)) { const createData = config.createData; const create = createData ?? ((_i) => ({})); this._data = create(init ?? {}); } else { if (!init?.request) { throw new Error( "socka: strict upgrade (default) requires a Request on the upgrade init (e.g. Bun upgrade with `data: { \u2026, request: req }`, or Hono default sockaInit), or use SockaWebSocketSessionConfigLoose with strictUpgradeRequest: false" ); } const strictInit = { request: init.request }; if (config.createData) { this._data = config.createData(strictInit); } else { this._data = {}; } } } get data() { return this._data; } /** * Session data for every connection in the same {@link sessions} map (same room), * optionally excluding this socket. */ listPeers(options) { const out = []; for (const [ws, s] of this.sessions) { if (options?.excludeSelf && ws === this.websocket) continue; out.push(s.data); } return out; } /** * Like {@link listPeers} but maps each peer {@link SockaWebSocketSession} * (e.g. when you need more than {@link #data}). */ listPeersWith(map, options) { const out = []; for (const [ws, s] of this.sessions) { if (options?.excludeSelf && ws === this.websocket) continue; out.push(map(s)); } return out; } /** Count of sessions in this room (same {@link sessions} map), optionally excluding self. */ peerCount(options) { let n = 0; for (const [ws] of this.sessions) { if (options?.excludeSelf && ws === this.websocket) continue; n += 1; } return n; } /** Whether any peer sessions exist (optionally excluding self). */ hasPeers(options) { return this.peerCount(options) > 0; } /** * Invokes the user {@link typeof SockaWebSocketSessionConfig.handleClose} callback. * Server adapters should call this when the WebSocket closes, **before** deleting * this session from the shared `sessions` map. */ async invokeHandleClose() { await this.config.handleClose(this); } async handleRawMessage(rawMessage) { if (this.wireFormat !== "json") { await this.reportValidationError( new Error("socka: unexpected JSON frame in msgpack mode"), rawMessage ); return; } const deserialize = this.config.deserializeJson ?? JSON.parse; let parsed; try { parsed = deserialize(rawMessage); } catch { await this.reportValidationError( new Error("socka: invalid JSON"), rawMessage ); return; } await this.dispatchAfterParsed(parsed, rawMessage); } async handleBinaryMessage(buffer) { if (this.wireFormat !== "msgpack") { await this.reportValidationError( new Error("socka: unexpected binary frame in JSON mode"), buffer ); return; } let parsed; try { parsed = parseWirePayload(buffer, "msgpack"); } catch (err) { await this.reportValidationError( err instanceof Error ? err : new Error("socka: msgpack decode failed"), buffer ); return; } await this.dispatchAfterParsed(parsed, buffer); } async dispatchAfterParsed(parsed, originalWire) { let decoded; try { decoded = decodeSockaWire(parsed); } catch (err) { if (err instanceof SockaWireError) { await this.reportValidationError(err, originalWire); return; } throw err; } switch (decoded.kind) { case "clientRequest": await this.dispatchClientRequest(decoded.frame, originalWire); return; case "serverResponse": case "serverError": case "serverEvent": await this.reportValidationError( new Error("socka: unexpected server-originated frame from client"), parsed ); return; default: exhaustiveGuard(decoded); } } async dispatchClientRequest(frame, _originalWire) { const rpcName = frame.rpc; const procedure = this.config.contract.calls[rpcName]; if (!procedure) { const errorFrame = encodeServerError( frame.id, `Unknown call: ${rpcName}`, { rpc: rpcName } ); this.sendWireFrame(errorFrame); return; } let validatedInput; if (procedure.input) { try { validatedInput = await parseStandardSchema(procedure.input, frame.body); } catch (err) { const msg = err instanceof Error ? err.message : "Input validation failed"; const errorFrame = encodeServerError(frame.id, msg, { rpc: rpcName }); this.sendWireFrame(errorFrame); return; } } let result; try { if (procedure.input) { const handler = this.config.handlers[rpcName]; result = await handler(validatedInput, this); } else { const handler = this.config.handlers[rpcName]; result = await handler(this); } } catch (err) { this.config.onHandlerError?.(err, rpcName, validatedInput, this); const sockaErr = err instanceof SockaError ? err : new SockaError( err instanceof Error ? err.message : "Handler failed" ); const errorFrame = encodeServerError(frame.id, sockaErr.message, { rpc: rpcName, code: sockaErr.code, data: sockaErr.data }); this.sendWireFrame(errorFrame); return; } if (procedure.output === void 0) { return; } let validatedOutput; try { validatedOutput = await parseStandardSchema(procedure.output, result); } catch (err) { const msg = err instanceof Error ? err.message : "Output validation failed"; const errorFrame = encodeServerError(frame.id, msg, { rpc: rpcName }); this.sendWireFrame(errorFrame); return; } const responseFrame = encodeServerResponse( frame.id, rpcName, validatedOutput ); this.sendWireFrame(responseFrame); } encodeOutgoing(frame) { return encodeSockaWire( frame, this.wireFormat, this.config.serializeJson ?? JSON.stringify ); } sendWireFrame(frame) { if (this.websocket.readyState !== WebSocket.OPEN) { return; } const encoded = this.encodeOutgoing(frame); if (typeof encoded === "string") { this.websocket.send(encoded); return; } const copy = new Uint8Array(encoded.byteLength); copy.set(encoded); this.websocket.send(copy.buffer); } /** * Send a server event frame (wire). Prefer {@link emitPush} so * payloads are validated against the contract. */ emitWireEvent(event, body) { const frame = encodeServerEvent(event, body); this.sendWireFrame(frame); } async emitPush(name, body) { const schema = this.config.contract.pushes[name]; if (!schema) { throw new Error(`socka: unknown push ${String(name)}`); } const validated = await parseStandardSchema( schema, body ); this.emitWireEvent(name, validated); } async broadcastPush(name, body, excludeSelf = false) { const schema = this.config.contract.pushes[name]; if (!schema) { throw new Error(`socka: unknown push ${String(name)}`); } const validated = await parseStandardSchema( schema, body ); broadcastSockaEventToPeers( this.sessions, this, name, validated, excludeSelf ); } async reportValidationError(error, originalMessage) { if (this.config.onValidationError) { await this.config.onValidationError(error, originalMessage); } else { console.error("socka: validation error:", error, originalMessage); } } }; function runSockaSessionOnAttached(config, session) { const cb = config.onAttached; if (!cb) return; try { const result = cb(session); void Promise.resolve(result).catch((error) => { reportSockaError(config.reportError, { kind: "serverOnAttached", error }); }); } catch (error) { reportSockaError(config.reportError, { kind: "serverOnAttached", error }); } } export { SockaWebSocketSession, broadcastContractPushToAll, broadcastSockaEventToAll, broadcastSockaEventToPeers, runSockaSessionOnAttached }; //# sourceMappingURL=chunk-TTXY7O5P.js.map //# sourceMappingURL=chunk-TTXY7O5P.js.map