UNPKG

bun-ws-router

Version:

Lightweight client/server WebSocket router for Bun with type-safe Zod/Valibot validation.

234 lines (206 loc) 6.38 kB
// SPDX-FileCopyrightText: 2025-present Kriasoft // SPDX-License-Identifier: MIT /** * Request/response correlation tracking. * See @specs/client.md#request-response-timeout-semantics. */ import { ConnectionClosedError, ServerError, StateError, TimeoutError, ValidationError, } from "./errors.js"; import type { AnyMessageSchema } from "./types.js"; interface PendingRequest { expectedType: string; schema: AnyMessageSchema; resolve: (msg: unknown) => void; reject: (err: Error) => void; timeoutHandle: ReturnType<typeof setTimeout> | null; abortHandler: (() => void) | null; } export class RequestTracker { private pending = new Map<string, PendingRequest>(); constructor(private limit: number) {} /** * Registers a pending request. * Throws StateError if limit exceeded. */ register( correlationId: string, expectedType: string, schema: AnyMessageSchema, timeoutMs: number, onFlush: () => void, signal?: AbortSignal, ): Promise<unknown> { // Check if already aborted before dispatch if (signal?.aborted) { return Promise.reject(new StateError("Request aborted before dispatch")); } // Check limit before adding if (this.pending.size >= this.limit) { throw new Error( `Pending request limit exceeded (max: ${this.limit}). Consider adding application-level throttling.`, ); } return new Promise((resolve, reject) => { const pending: PendingRequest = { expectedType, schema, resolve, reject, timeoutHandle: null, // Set after flush abortHandler: null, }; this.pending.set(correlationId, pending); // Set up AbortSignal listener if (signal) { const abortHandler = () => { const req = this.pending.get(correlationId); if (req) { this.pending.delete(correlationId); if (req.timeoutHandle) { clearTimeout(req.timeoutHandle); } reject(new StateError("Request aborted")); } }; signal.addEventListener("abort", abortHandler); pending.abortHandler = abortHandler; } // Start timeout AFTER message is flushed on OPEN socket onFlush(); // Now start timeout pending.timeoutHandle = setTimeout(() => { const req = this.pending.get(correlationId); if (req) { this.pending.delete(correlationId); if (req.abortHandler && signal) { signal.removeEventListener("abort", req.abortHandler); } } reject(new TimeoutError(timeoutMs)); }, timeoutMs); }); } /** * Handles inbound message with correlationId. * Four-way dispatch: * 1. Type matches → resolve with validated message * 2. Type is ERROR → reject with ServerError * 3. Type mismatches (non-error) → reject with ValidationError * 4. Validation fails → reject with ValidationError */ handleReply( msg: unknown, safeParse: ( schema: AnyMessageSchema, data: unknown, ) => { success: boolean; data?: unknown; error?: unknown }, ): void { // Type guard to ensure msg is an object with meta if ( !msg || typeof msg !== "object" || !("meta" in msg) || !msg.meta || typeof msg.meta !== "object" || !("correlationId" in msg.meta) ) { return; // Not a correlated message } const correlationId = (msg.meta as { correlationId: unknown }) .correlationId; if (typeof correlationId !== "string") { return; // Not a valid correlationId } const pending = this.pending.get(correlationId); if (!pending) { // Already settled or unknown correlationId - drop silently return; } // Type guard to ensure msg has type field if (!("type" in msg) || typeof msg.type !== "string") { return; } // Remove from map (first reply settles, subsequent dropped) this.pending.delete(correlationId); // Cancel timeout if (pending.timeoutHandle) { clearTimeout(pending.timeoutHandle); } // Remove abort listener if (pending.abortHandler) { // Note: We don't have direct access to signal here, but the handler will be GC'd // This is safe because we're settling the promise pending.abortHandler = null; } // 2. Type is ERROR → reject with ServerError if (msg.type === "ERROR") { const payload = "payload" in msg && msg.payload && typeof msg.payload === "object" ? (msg.payload as { message?: unknown; code?: unknown; context?: unknown; }) : undefined; pending.reject( new ServerError( typeof payload?.message === "string" ? payload.message : "Server error", typeof payload?.code === "string" ? payload.code : "UNKNOWN", payload?.context as Record<string, unknown> | undefined, ), ); return; } // 3. Type mismatches → reject with ValidationError if (msg.type !== pending.expectedType) { pending.reject( new ValidationError( `Expected type ${pending.expectedType}, got ${msg.type}`, [], ), ); return; } // 4. Validate against schema const result = safeParse(pending.schema, msg); if (!result.success) { const error = result.error && typeof result.error === "object" && "issues" in result.error ? (result.error as { issues?: { path: string[]; message: string }[]; }) : undefined; pending.reject( new ValidationError("Reply validation failed", error?.issues ?? []), ); return; } // 1. Type matches and valid → resolve pending.resolve(result.data); } /** * Rejects all pending requests (on disconnect). */ rejectAll(): void { for (const [, pending] of Array.from(this.pending)) { if (pending.timeoutHandle) { clearTimeout(pending.timeoutHandle); } // Note: AbortSignal listeners will be GC'd, no need to remove pending.reject(new ConnectionClosedError()); } this.pending.clear(); } get size(): number { return this.pending.size; } }