Core Concepts
Understanding these core concepts will help you build robust WebSocket applications with Bun WebSocket Router.
Factory Pattern Required
Required since v0.4.0 to fix discriminated union support. Use createMessageSchema()
to create schemas:
import { z } from "zod";
import { createMessageSchema } from "bun-ws-router/zod";
const { messageSchema } = createMessageSchema(z);
The old direct messageSchema
export is deprecated and will be removed in v1.0. See Message Schemas for migration details.
Message-Based Architecture
Bun WebSocket Router uses a message-based architecture where all communication follows a consistent structure. This provides several benefits:
- Type Safety: Messages are validated against schemas before reaching handlers
- Predictability: All messages have the same structure, making debugging easier
- Routing: Messages are automatically routed based on their type
- Traceability: Built-in metadata helps track message flow
Message Structure
Every message consists of three parts:
interface Message<T = unknown> {
type: string; // Unique identifier for routing
meta: {
// Metadata (optional, auto-populated on send)
timestamp?: number; // Producer time (client clock, UI display only)
correlationId?: string; // Optional request tracking
};
payload?: T; // Optional validated data
}
Server Timestamp Usage
Server logic must use ctx.receivedAt
(authoritative server time), not meta.timestamp
(client clock, untrusted). See Timestamp Handling below for guidance.
Connection Lifecycle
1. Connection Opening
When a client connects, the router:
- Generates a unique
clientId
(UUID v7) - Stores connection metadata in
ws.data
- Calls your
onOpen
handler
router.onOpen((ctx) => {
// ctx.ws.data.clientId is always available (UUID v7)
console.log(`Client ${ctx.ws.data.clientId} connected`);
});
2. Message Handling
When a message arrives, the router processes it through a security-focused pipeline:
- Capture Timestamp -
ctx.receivedAt = Date.now()
(before parsing, authoritative server time) - Parse - JSON.parse() the raw WebSocket message
- Type Check - Ensure
type
field exists - Handler Lookup - Find registered handler for this message type
- Normalize (Security Boundary) - Strip reserved keys (
clientId
,receivedAt
) to prevent client spoofing - Validate - Schema validation on normalized message (strict mode rejects unknown keys)
- Handler Execution - Your handler receives validated message + server context
Security
Normalization is a security boundary that prevents clients from spoofing server-only fields. Handlers receive only validated, normalized messages.
import { publish } from "bun-ws-router/zod/publish";
router.onMessage(ChatMessage, (ctx) => {
// ctx provides everything you need:
// - ctx.ws: The WebSocket instance
// - ctx.ws.data.clientId: Client identifier (UUID v7, auto-generated)
// - ctx.type: Message type literal from schema
// - ctx.meta: Validated metadata (timestamp, correlationId, custom fields)
// - ctx.payload: Validated message data (conditional - only if schema defines it)
// - ctx.receivedAt: Server receive timestamp (Date.now(), authoritative for server logic)
// - ctx.send: Type-safe send function
// For broadcasting, use the standalone publish() helper:
publish(ctx.ws, "chat", ChatMessage, ctx.payload);
// For subscriptions:
ctx.ws.subscribe("room:123");
ctx.ws.unsubscribe("room:456");
});
3. Connection Closing
When a client disconnects:
router.onClose((ctx) => {
console.log(
`Client ${ctx.ws.data.clientId} disconnected: ${ctx.code} ${ctx.reason || "N/A"}`,
);
// Clean up resources, notify other clients, etc.
});
Type Safety
The router provides full type inference from schema definition to handler:
const UpdateProfileMessage = messageSchema("UPDATE_PROFILE", {
name: z.string(),
avatar: z.url().optional(),
});
router.onMessage(UpdateProfileMessage, (ctx) => {
// TypeScript knows:
// - ctx.payload.name is string
// - ctx.payload.avatar is string | undefined
// - ctx.send() only accepts valid message schemas
});
Error Handling
Error Boundaries
All handlers are wrapped in error boundaries to prevent crashes:
router.onMessage(SomeMessage, (ctx) => {
throw new Error("Something went wrong");
// Router catches this and sends an error message to the client
});
Standard Error Codes
Use the built-in ErrorCode
enum for consistent error handling:
const { ErrorCode, ErrorMessage } = createMessageSchema(z);
ctx.send(ErrorMessage, {
code: ErrorCode.VALIDATION_FAILED,
message: "Invalid room ID",
});
Available error codes:
INVALID_MESSAGE_FORMAT
: Message isn't valid JSON or lacks required structureVALIDATION_FAILED
: Message failed schema validationUNSUPPORTED_MESSAGE_TYPE
: No handler registered for message typeAUTHENTICATION_FAILED
: Authentication required or token invalidAUTHORIZATION_FAILED
: Insufficient permissionsRESOURCE_NOT_FOUND
: Resource not foundRATE_LIMIT_EXCEEDED
: Too many requestsINTERNAL_SERVER_ERROR
: Server error
WebSocket Data
The router extends Bun's WebSocket data with typed metadata:
interface WebSocketData<T = unknown> {
clientId: string; // UUID v7, auto-generated by router
} & T
Pass custom data during upgrade:
server.upgrade(req, {
data: {
// Router auto-generates clientId (UUID v7)
id: "123",
name: "Alice",
roles: ["user", "admin"],
},
});
Context Object
Handler contexts provide access to message data and WebSocket operations:
interface MessageContext<TPayload, TData = unknown> {
ws: ServerWebSocket<TData>; // WebSocket instance
type: string; // Message type literal
meta: {
// Validated metadata
timestamp?: number; // Client timestamp (optional, for UI only)
correlationId?: string; // Optional correlation ID
[key: string]: unknown; // Custom metadata fields
};
receivedAt: number; // Server receive timestamp (authoritative)
send: SendFunction; // Type-safe send function
payload?: TPayload; // Validated payload (conditional)
}
Key points:
- Access client ID via
ctx.ws.data.clientId
(notctx.clientId
) - Use
ctx.receivedAt
for server-side logic (rate limiting, ordering, TTL, auditing) - Use
ctx.meta.timestamp
only for UI display (not authoritative) - For subscriptions:
ctx.ws.subscribe(topic)
andctx.ws.unsubscribe(topic)
- For publishing: Use standalone
publish()
helper frombun-ws-router/zod/publish
- For custom data: Access
ctx.ws.data
directly (no getData/setData methods)
Broadcasting and PubSub
Leverage Bun's native PubSub for efficient broadcasting:
import { publish } from "bun-ws-router/zod/publish";
router.onMessage(JoinRoomMessage, (ctx) => {
const roomId = ctx.payload.roomId;
// Subscribe to room topic
ctx.ws.subscribe(`room:${roomId}`);
// Broadcast to all subscribers with type-safe publish helper
publish(ctx.ws, `room:${roomId}`, UserJoinedMessage, {
username: ctx.payload.username,
});
});
router.onMessage(LeaveRoomMessage, (ctx) => {
const roomId = ctx.payload.roomId;
// Unsubscribe when leaving
ctx.ws.unsubscribe(`room:${roomId}`);
// Notify others
publish(ctx.ws, `room:${roomId}`, UserLeftMessage, {
username: ctx.payload.username,
});
});
TIP
The publish()
helper validates messages before broadcasting. See API Reference for complete documentation.
Timestamp Handling
The router provides two timestamps with different trust levels:
ctx.receivedAt
- Server receive timestamp (authoritative,Date.now()
captured before parsing)- Use for: Rate limiting, ordering, TTL, auditing, all server-side logic
ctx.meta.timestamp
- Producer time (client clock, untrusted, may be skewed/missing)- Use for: UI "sent at" display, optimistic ordering, lag calculation
Rule: Server logic MUST use ctx.receivedAt
for all business logic (rate limiting, ordering, TTL, auditing).
router.onMessage(ChatMessage, (ctx) => {
// Rate limiting with server timestamp
const lastMessageTime = messageLog.get(ctx.ws.data.clientId);
if (lastMessageTime && ctx.receivedAt - lastMessageTime < 1000) {
ctx.send(ErrorMessage, {
code: "RATE_LIMIT_EXCEEDED",
message: "Please wait before sending another message",
});
return;
}
messageLog.set(ctx.ws.data.clientId, ctx.receivedAt);
// Store both for different purposes
await saveMessage({
text: ctx.payload.text,
sentAt: ctx.meta.timestamp, // UI display
receivedAt: ctx.receivedAt, // Business logic
});
});
Performance Considerations
- Message Parsing: Messages are parsed once and cached
- Validation: Schema validation happens before handler execution
- Error Boundaries: Handlers are wrapped but with minimal overhead
- PubSub: Uses Bun's native implementation for maximum performance