UNPKG

bun-ws-router

Version:

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

164 lines 7.04 kB
// SPDX-FileCopyrightText: 2025-present Kriasoft // SPDX-License-Identifier: MIT import { v7 as randomUUIDv7 } from "uuid"; import { ConnectionHandler } from "./connection"; import { MessageRouter } from "./message"; /** * WebSocket router for Bun that provides type-safe message routing with validation. * Routes incoming messages to handlers based on message type. * * @template T - Application-specific data to store with each WebSocket connection. * Always includes a clientId property generated automatically. */ export class WebSocketRouter { connectionHandler = new ConnectionHandler(); messageRouter; validator; constructor(validator) { this.validator = validator; this.messageRouter = new MessageRouter(validator); } /** * Merges open, close, and message handlers from another WebSocketRouter instance. * * USE CASE: Compose routers from different modules/features. * WARNING: Message type conflicts are resolved by last-write-wins. * * NOTE: Accepts `| any` to support Zod/Valibot router instances. The type override * in derived classes creates an LSP variance issue, making them technically incompatible * with the base type. This is an intentional trade-off for better developer experience. * * @param router - Router instance to merge routes from (including Zod/Valibot routers) * @returns This router instance for method chaining * @see specs/adrs.md#ADR-001 - Explains the type override variance issue */ // eslint-disable-next-line @typescript-eslint/no-explicit-any addRoutes(router) { // Merge open handlers const otherConnectionHandler = router.connectionHandler; otherConnectionHandler.openHandlers.forEach((handler) => { this.connectionHandler.addOpenHandler(handler); }); // Merge close handlers otherConnectionHandler.closeHandlers.forEach((handler) => { this.connectionHandler.addCloseHandler(handler); }); // Merge message handlers const thisMessageRouter = this .messageRouter; const otherMessageRouter = router.messageRouter; otherMessageRouter.messageHandlers.forEach((value, key) => { thisMessageRouter.messageHandlers.set(key, value); }); return this; } /** * Upgrades an HTTP request to a WebSocket connection. * * FLOW: Generate clientId → Attempt upgrade → Return appropriate HTTP response * NOTE: clientId (UUID v7) is both stored in data and sent as header. */ upgrade(req, options) { const { server, data, headers } = options; const clientId = randomUUIDv7(); // UUID v7 for time-ordered IDs const upgraded = server.upgrade(req, { data: { clientId, ...data }, headers: { "x-client-id": clientId, ...headers, }, }); // Bun's upgrade() returns false if upgrade fails (e.g., not a WS request) if (!upgraded) { return new Response("Failed to upgrade the request to a WebSocket connection", { status: 500, headers: { "Content-Type": "text/plain", }, }); } // 101 Switching Protocols - standard WebSocket upgrade response return new Response(null, { status: 101 }); } onOpen(handler) { this.connectionHandler.addOpenHandler(handler); return this; } onClose(handler) { this.connectionHandler.addCloseHandler(handler); return this; } onMessage(schema, handler) { this.messageRouter.addMessageHandler(schema, handler); return this; } /** * Returns a WebSocket handler that can be used with `Bun.serve`. * * USAGE: Pass to Bun.serve({ websocket: router.websocket }) * NOTE: Methods are bound to preserve 'this' context. */ get websocket() { return { open: this.handleOpen.bind(this), message: this.handleMessage.bind(this), close: this.handleClose.bind(this), }; } // ——————————————————————————————————————————————————————————————————————————— // Private methods - Internal event handlers // ——————————————————————————————————————————————————————————————————————————— handleOpen(ws) { const send = this.createSendFunction(ws); this.connectionHandler.handleOpen(ws, send); } handleClose(ws, code, reason) { const send = this.createSendFunction(ws); this.connectionHandler.handleClose(ws, code, reason, send); } handleMessage(ws, message) { const send = this.createSendFunction(ws); this.messageRouter.handleMessage(ws, message, send); } /** * Creates a send function for a specific WebSocket connection. * * PURPOSE: Provides handlers with a validated way to send messages. * Each connection gets its own send function with timestamp auto-injection. */ createSendFunction(ws) { return (schema, // eslint-disable-next-line @typescript-eslint/no-explicit-any payload, // eslint-disable-next-line @typescript-eslint/no-explicit-any meta = {}) => { try { // Extract the message type from the schema const messageType = this.validator.getMessageType(schema); // Create the message object with the required structure // NOTE: timestamp auto-generated (producer time); clientId is NEVER injected const message = { type: messageType, meta: { timestamp: Date.now(), ...meta, }, ...(payload !== undefined && { payload }), // Omit if undefined }; // Validate the constructed message against the schema const validationResult = this.validator.safeParse(schema, message); if (!validationResult.success) { console.error(`[ws] Failed to send message of type "${messageType}": Validation error`, validationResult.error); return; } // Send the validated message // NOTE: ws.send() goes to this specific connection only (not broadcast) ws.send(JSON.stringify(validationResult.data)); } catch (error) { console.error(`[ws] Error sending message:`, error); } }; } } //# sourceMappingURL=router.js.map