bun-ws-router
Version:
Lightweight client/server WebSocket router for Bun with type-safe Zod/Valibot validation.
96 lines (85 loc) • 2.86 kB
text/typescript
// SPDX-FileCopyrightText: 2025-present Kriasoft
// SPDX-License-Identifier: MIT
import type { ServerWebSocket } from "bun";
import type {
CloseHandler,
OpenHandler,
SendFunction,
WebSocketData,
} from "./types";
/**
* Handles WebSocket connection lifecycle events (open/close).
*
* DESIGN: Multiple handlers can be registered for the same event,
* executing in registration order. Errors in one handler don't affect others.
*/
export class ConnectionHandler<
T extends WebSocketData<Record<string, unknown>>,
> {
private readonly openHandlers: OpenHandler<T>[] = [];
private readonly closeHandlers: CloseHandler<T>[] = [];
addOpenHandler(handler: OpenHandler<T>): void {
this.openHandlers.push(handler);
}
addCloseHandler(handler: CloseHandler<T>): void {
this.closeHandlers.push(handler);
}
handleOpen(ws: ServerWebSocket<T>, send: SendFunction): void {
const clientId = ws.data.clientId;
console.log(`[ws] Connection opened: ${clientId}`);
const context = { ws, send };
// Execute all registered open handlers
// BEHAVIOR: Handlers run sequentially but don't await promises.
// Errors are logged but don't prevent other handlers from running.
this.openHandlers.forEach((handler) => {
try {
const result = handler(context);
// Fire-and-forget async handlers - errors logged but not propagated
if (result instanceof Promise) {
result.catch((error) => {
console.error(
`Unhandled promise rejection in open handler for ${clientId}:`,
error,
);
});
}
} catch (error) {
console.error(`Error in open handler for ${clientId}:`, error);
}
});
}
handleClose(
ws: ServerWebSocket<T>,
code: number,
reason: string | undefined,
send: SendFunction,
): void {
const clientId = ws.data.clientId;
console.log(
`[ws] Connection closed: ${clientId} (Code: ${code}, Reason: ${
reason || "N/A"
})`,
);
const context = { ws, code, reason, send };
// Execute all registered close handlers
// NOTE: Close handlers still execute even if connection is already closed.
// This ensures cleanup code always runs.
this.closeHandlers.forEach((handler) => {
try {
const result = handler(context);
// Fire-and-forget async handlers - errors logged but not propagated
if (result instanceof Promise) {
result.catch((error) => {
console.error(
`[ws] Unhandled promise rejection in close handler for ${clientId}:`,
error,
);
});
}
} catch (error) {
// Catch synchronous errors in handlers
console.error(`[ws] Error in close handler for ${clientId}:`, error);
}
});
}
}