bun-ws-router
Version:
Lightweight client/server WebSocket router for Bun with type-safe Zod/Valibot validation.
157 lines • 6.12 kB
JavaScript
// 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";
export class RequestTracker {
limit;
pending = new Map();
constructor(limit) {
this.limit = limit;
}
/**
* Registers a pending request.
* Throws StateError if limit exceeded.
*/
register(correlationId, expectedType, schema, timeoutMs, onFlush, signal) {
// 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 = {
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, safeParse) {
// 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
.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
: undefined;
pending.reject(new ServerError(typeof payload?.message === "string"
? payload.message
: "Server error", typeof payload?.code === "string" ? payload.code : "UNKNOWN", payload?.context));
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
: 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() {
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() {
return this.pending.size;
}
}
//# sourceMappingURL=requests.js.map