bun-ws-router
Version:
A simple and efficient WebSocket router for Bun with Zod/Valibot message validation.
227 lines (199 loc) • 7.05 kB
text/typescript
/* SPDX-FileCopyrightText: 2025-present Kriasoft */
/* SPDX-License-Identifier: MIT */
import type { ServerWebSocket, WebSocketHandler } from "bun";
import { v7 as randomUUIDv7 } from "uuid";
import { ConnectionHandler } from "./connection";
import { MessageRouter } from "./message";
import type {
CloseHandler,
MessageHandler,
MessageSchemaType,
OpenHandler,
SendFunction,
UpgradeOptions,
WebSocketData,
} from "./types";
export interface ValidatorAdapter {
getMessageType(schema: MessageSchemaType): string;
safeParse(
schema: MessageSchemaType,
data: unknown,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): { success: boolean; data?: any; error?: any };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
infer<T extends MessageSchemaType>(schema: T): any;
}
/**
* 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<
T extends Record<string, unknown> = Record<string, never>,
> {
private readonly connectionHandler = new ConnectionHandler<
WebSocketData<T>
>();
private readonly messageRouter: MessageRouter<WebSocketData<T>>;
private readonly validator: ValidatorAdapter;
constructor(validator: ValidatorAdapter) {
this.validator = validator;
this.messageRouter = new MessageRouter<WebSocketData<T>>(validator);
}
/**
* Merges open, close, and message handlers from another WebSocketRouter instance.
*/
addRoutes(router: WebSocketRouter<T>): this {
// Access private members through a type assertion
interface AccessibleConnectionHandler {
openHandlers: OpenHandler<WebSocketData<T>>[];
closeHandlers: CloseHandler<WebSocketData<T>>[];
}
interface AccessibleMessageRouter {
messageHandlers: Map<
string,
{
schema: MessageSchemaType;
handler: MessageHandler<MessageSchemaType, WebSocketData<T>>;
}
>;
}
// Merge open handlers
const otherConnectionHandler =
router.connectionHandler as unknown as AccessibleConnectionHandler;
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 as unknown as AccessibleMessageRouter;
const otherMessageRouter =
router.messageRouter as unknown as AccessibleMessageRouter;
otherMessageRouter.messageHandlers.forEach((value, key) => {
thisMessageRouter.messageHandlers.set(key, value);
});
return this;
}
/**
* Upgrades an HTTP request to a WebSocket connection.
*/
public upgrade(req: Request, options: UpgradeOptions<WebSocketData<T>>) {
const { server, data, headers } = options;
const clientId = randomUUIDv7();
const upgraded = server.upgrade(req, {
data: { clientId, ...data },
headers: {
"x-client-id": clientId,
...headers,
},
});
if (!upgraded) {
return new Response(
"Failed to upgrade the request to a WebSocket connection",
{
status: 500,
headers: {
"Content-Type": "text/plain",
},
},
);
}
return new Response(null, { status: 101 });
}
onOpen(handler: OpenHandler<WebSocketData<T>>): this {
this.connectionHandler.addOpenHandler(handler);
return this;
}
onClose(handler: CloseHandler<WebSocketData<T>>): this {
this.connectionHandler.addCloseHandler(handler);
return this;
}
onMessage<Schema extends MessageSchemaType>(
schema: Schema,
handler: MessageHandler<Schema, WebSocketData<T>>,
): this {
this.messageRouter.addMessageHandler(schema, handler);
return this;
}
/**
* Returns a WebSocket handler that can be used with `Bun.serve`.
*/
get websocket(): WebSocketHandler<WebSocketData<T>> {
return {
open: this.handleOpen.bind(this),
message: this.handleMessage.bind(this),
close: this.handleClose.bind(this),
};
}
// ———————————————————————————————————————————————————————————————————————————
// Private methods
// ———————————————————————————————————————————————————————————————————————————
private handleOpen(ws: ServerWebSocket<WebSocketData<T>>) {
const send = this.createSendFunction(ws);
this.connectionHandler.handleOpen(ws, send);
}
private handleClose(
ws: ServerWebSocket<WebSocketData<T>>,
code: number,
reason?: string,
) {
const send = this.createSendFunction(ws);
this.connectionHandler.handleClose(ws, code, reason, send);
}
private handleMessage(
ws: ServerWebSocket<WebSocketData<T>>,
message: string | Buffer,
) {
const send = this.createSendFunction(ws);
this.messageRouter.handleMessage(ws, message, send);
}
/**
* Creates a send function for a specific WebSocket connection.
* This function allows handlers to send typed messages with proper validation.
*/
private createSendFunction(
ws: ServerWebSocket<WebSocketData<T>>,
): SendFunction {
return (
schema: MessageSchemaType,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
meta: any = {},
) => {
try {
// Extract the message type from the schema
const messageType = this.validator.getMessageType(schema);
// Create the message object with the required structure
const message = {
type: messageType,
meta: {
clientId: ws.data.clientId,
timestamp: Date.now(),
...meta,
},
...(payload !== undefined && { payload }),
};
// 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
ws.send(JSON.stringify(validationResult.data));
} catch (error) {
console.error(`[ws] Error sending message:`, error);
}
};
}
}