Skip to content

Type-safe WebSocket message routing for Bun. Choose Zod for familiarity or Valibot for 90% smaller bundles. Full TypeScript support included.

License

Notifications You must be signed in to change notification settings

kriasoft/bun-ws-router

Bun WebSocket Router

npm version npm downloads GitHub Actions Chat on Discord

Type-safe WebSocket communication for Bun servers and browsers with Zod or Valibot validation. Routes messages to handlers with full TypeScript support on both server and client.

Key Features

Server (Bun)

  • 🔒 Type-safe message routing with Zod/Valibot validation
  • 🚀 Built on Bun's native WebSocket implementation
  • 📡 PubSub with schema-validated broadcasts
  • 🧩 Composable routers and middleware support

Client (Browser)

  • 🔄 Auto-reconnection with exponential backoff
  • 📦 Configurable offline message queueing
  • ⏱️ Request/response pattern with timeouts
  • 🔐 Built-in auth (query param or protocol header)

Shared

  • ✨ Shared schemas between server and client
  • ⚡ Choose Zod (familiar) or Valibot (60-80% smaller)
  • 🔒 Full TypeScript inference on both sides

Installation

Choose your validation library:

# With Zod
bun add bun-ws-router zod
bun add @types/bun -D

# With Valibot (60-80% smaller bundles)
bun add bun-ws-router valibot
bun add @types/bun -D

Getting Started

The following example demonstrates how to set up a Bun server with both (RESTful) HTTP and WebSocket routers.

import { Hono } from "hono";
import { WebSocketRouter } from "bun-ws-router/zod"; // Explicit Zod import
import { exampleRouter } from "./example";

// HTTP router
const app = new Hono();
app.get("/", (c) => c.text("Welcome to Hono!"));

// WebSocket router
const ws = new WebSocketRouter();
ws.addRoutes(exampleRouter); // Add routes from another file

Bun.serve({
  port: 3000,

  fetch(req, server) {
    const url = new URL(req.url);

    // Handle WebSocket upgrade requests
    if (url.pathname === "/ws") {
      return ws.upgrade(req, {
        server,
      });
    }

    // Handle regular HTTP requests
    return app.fetch(req, { server });
  },

  // Handle WebSocket connections
  websocket: ws.websocket,
});

console.log(`WebSocket server listening on ws://localhost:3000/ws`);

Validation Libraries

You can choose between Zod and Valibot validators using different import paths:

// Zod - mature ecosystem, method chaining
import { WebSocketRouter, createMessageSchema } from "bun-ws-router/zod";
import { z } from "zod";
const { messageSchema } = createMessageSchema(z);

// Valibot - 90% smaller bundles, functional pipelines
import { WebSocketRouter, createMessageSchema } from "bun-ws-router/valibot";
import * as v from "valibot";
const { messageSchema } = createMessageSchema(v);

Quick Comparison

Feature Zod Valibot
Bundle Size ~5-6 kB (Zod v4) ~1-2 kB
Performance Baseline ~2x faster
API Style Method chaining Functional
Best for Server-side, familiarity Client-side, performance

How to handle authentication

You can handle authentication by checking the Authorization header for a JWT token or any other authentication method you prefer. The following example demonstrates how to verify a JWT token and pass the user information to the WebSocket router.

import { z } from "zod";
import { WebSocketRouter, createMessageSchema } from "bun-ws-router/zod";
import { DecodedIdToken } from "firebase-admin/auth";

const { messageSchema } = createMessageSchema(z);
import { verifyIdToken } from "./auth"; // Your authentication logic

type Meta = {
  user?: DecodedIdToken | null;
};

// WebSocket router
const ws = new WebSocketRouter<Meta>();

Bun.serve({
  port: 3000,

  async fetch(req, server) {
    const url = new URL(req.url);

    // Check if the user is authenticated
    const user = await verifyIdToken(req);

    // Handle WebSocket upgrade requests
    if (url.pathname === "/ws") {
      return ws.upgrade(req, {
        server,
        data: { user },
      });
    }

    // Handle regular HTTP requests
    return await app.fetch(req, { server, user });
  },

  // Handle WebSocket connections
  websocket: ws.websocket,
});

The verifyIdToken function is a placeholder for your authentication logic which could use user ID token verification from firebase-admin or any other authentication library.

By verifying the user before the WebSocket connection is established and passing the user data, you ensure that only authenticated users can connect, and you have their info ready to use in your onOpen, onMessage, and onClose handlers.

How to define message types

To define message types, first create a message schema factory using your validation library, then use it to define your message schemas. This approach ensures proper TypeScript support and avoids dual package hazard issues.

With Zod

import { z } from "zod";
import { createMessageSchema } from "bun-ws-router/zod";

// Create the message schema factory with your Zod instance
const { messageSchema } = createMessageSchema(z);

// Now define your message types
export const JoinRoom = messageSchema("JOIN_ROOM", {
  roomId: z.string(),
});

export const UserJoined = messageSchema("USER_JOINED", {
  roomId: z.string(),
  userId: z.string(),
});

export const UserLeft = messageSchema("USER_LEFT", {
  userId: z.string(),
});

export const SendMessage = messageSchema("SEND_MESSAGE", {
  roomId: z.string(),
  message: z.string(),
});

With Valibot

import * as v from "valibot";
import { createMessageSchema } from "bun-ws-router/valibot";

// Create the message schema factory with your Valibot instance
const { messageSchema } = createMessageSchema(v);

// Now define your message types
export const JoinRoom = messageSchema("JOIN_ROOM", {
  roomId: v.string(),
});

export const UserJoined = messageSchema("USER_JOINED", {
  roomId: v.string(),
  userId: v.string(),
});

Note: The factory pattern (createMessageSchema) ensures that your schemas use the same validation library instance as your application, enabling features like discriminated unions and preventing type conflicts.

How to define routes

Define routes using the WebSocketRouter instance methods: onOpen, onMessage, and onClose.

import { z } from "zod";
import { WebSocketRouter, createMessageSchema } from "bun-ws-router/zod";
import { JoinRoom, UserJoined, SendMessage, UserLeft } from "./schema";

const { messageSchema } = createMessageSchema(z);

// Define custom connection data type
type ConnectionData = {
  userId?: string;
  roomId?: string;
};

const ws = new WebSocketRouter<ConnectionData>();

// Handle new connections
ws.onOpen((ctx) => {
  console.log(`Client connected: ${ctx.ws.data.clientId}`);
  // ctx.ws.data.clientId is always present (UUID v7)
  // Send welcome message if needed
});

// Handle specific message types
ws.onMessage(JoinRoom, (ctx) => {
  const { roomId } = ctx.payload;
  const userId = ctx.ws.data.userId || ctx.ws.data.clientId;

  // Store room in connection data
  ctx.ws.data.roomId = roomId;

  // Subscribe to room topic for broadcasts
  ctx.ws.subscribe(roomId);

  console.log(`User ${userId} joined room: ${roomId}`);
  console.log(`Message received at: ${ctx.receivedAt}`); // Server timestamp

  // Send confirmation back to this client
  ctx.send(UserJoined, { roomId, userId });
});

ws.onMessage(SendMessage, (ctx) => {
  const { message } = ctx.payload;
  const userId = ctx.ws.data.userId || ctx.ws.data.clientId;
  const roomId = ctx.ws.data.roomId;

  console.log(`Message in room ${roomId} from ${userId}: ${message}`);
  // See "How to broadcast messages" section for broadcasting logic
});

// Handle disconnections
ws.onClose((ctx) => {
  const userId = ctx.ws.data.userId || ctx.ws.data.clientId;
  console.log(`Client disconnected: ${userId}, code: ${ctx.code}`);

  if (ctx.ws.data.roomId) {
    // Unsubscribe and notify others (see broadcasting section)
    ctx.ws.unsubscribe(ctx.ws.data.roomId);
  }
});

Handler Context Fields:

  • ctx.ws — ServerWebSocket instance with connection data
  • ctx.ws.data.clientId — Auto-generated UUID v7 (always present)
  • ctx.type — Message type literal (e.g., "JOIN_ROOM")
  • ctx.payload — Typed payload (only exists when schema defines it)
  • ctx.meta — Client-provided metadata (correlationId, timestamp, custom fields)
  • ctx.receivedAt — Server receive timestamp (use for rate limiting, ordering, TTL)
  • ctx.send() — Type-safe send function (sends to this client only)

How to broadcast messages

Broadcasting messages to multiple clients is a common requirement for real-time applications. bun-ws-router complements Bun's built-in PubSub functionality with schema validation support.

Option 1: Using Bun's native WebSocket PubSub

Bun's WebSocket implementation includes built-in support for the PubSub pattern through subscribe, publish, and unsubscribe methods:

ws.onMessage(JoinRoom, (ctx) => {
  const { roomId } = ctx.payload;
  const userId = ctx.ws.data.userId || ctx.ws.data.clientId;

  // Store room ID in connection data
  ctx.ws.data.roomId = roomId;

  // Subscribe the client to the room's topic
  ctx.ws.subscribe(roomId);

  console.log(`User ${userId} joined room: ${roomId}`);

  // Send confirmation back to the user who joined
  ctx.send(UserJoined, { roomId, userId });

  // Broadcast to all other subscribers that a new user joined
  const message = JSON.stringify({
    type: "USER_JOINED",
    meta: { timestamp: Date.now() },
    payload: { roomId, userId },
  });
  ctx.ws.publish(roomId, message);
});

ws.onClose((ctx) => {
  const userId = ctx.ws.data.userId || ctx.ws.data.clientId;
  const roomId = ctx.ws.data.roomId;

  if (roomId) {
    // Unsubscribe from room
    ctx.ws.unsubscribe(roomId);

    // Notify others the user has left
    const message = JSON.stringify({
      type: "USER_LEFT",
      meta: { timestamp: Date.now() },
      payload: { userId },
    });
    ctx.ws.publish(roomId, message);
  }
});

Option 2: Using the publish helper function

The library provides a helper function that combines schema validation with publishing:

import { z } from "zod";
import { WebSocketRouter, createMessageSchema } from "bun-ws-router/zod";
import { publish } from "bun-ws-router/zod/publish";
import { JoinRoom, UserJoined, SendMessage, UserLeft } from "./schema";

const { messageSchema } = createMessageSchema(z);

ws.onMessage(JoinRoom, (ctx) => {
  const { roomId } = ctx.payload;
  const userId = ctx.ws.data.userId || ctx.ws.data.clientId;

  // Store room ID and subscribe to topic
  ctx.ws.data.roomId = roomId;
  ctx.ws.subscribe(roomId);

  // Send confirmation back to the user who joined
  ctx.send(UserJoined, { roomId, userId });

  // Broadcast to other subscribers with schema validation
  publish(ctx.ws, roomId, UserJoined, { roomId, userId });
});

ws.onMessage(SendMessage, (ctx) => {
  const { roomId, message } = ctx.payload;
  const userId = ctx.ws.data.userId || ctx.ws.data.clientId;

  console.log(`Message in room ${roomId} from ${userId}: ${message}`);

  // Broadcast the message to all subscribers in the room
  const NewMessage = messageSchema("NEW_MESSAGE", {
    roomId: z.string(),
    userId: z.string(),
    message: z.string(),
  });

  publish(ctx.ws, roomId, NewMessage, {
    roomId,
    userId,
    message,
  });
});

ws.onClose((ctx) => {
  const userId = ctx.ws.data.userId || ctx.ws.data.clientId;
  const roomId = ctx.ws.data.roomId;

  if (roomId) {
    ctx.ws.unsubscribe(roomId);

    // Notify others using the publish helper
    publish(ctx.ws, roomId, UserLeft, { userId });
  }
});

The publish() function ensures that all broadcast messages are validated against their schemas before being sent, providing the same type safety for broadcasts that you get with direct messaging.

Error handling and sending error messages

Effective error handling is crucial for maintaining robust WebSocket connections. bun-ws-router provides built-in tools for standardized error messages that align with the library's schema validation pattern.

Using ErrorCode and ErrorMessage schema

The library includes a standardized error schema and predefined error codes:

import { z } from "zod";
import { createMessageSchema } from "bun-ws-router/zod";

const { ErrorMessage, ErrorCode } = createMessageSchema(z);

ws.onMessage(JoinRoom, async (ctx) => {
  const { roomId } = ctx.payload;

  // Check if room exists
  const roomExists = await checkRoomExists(roomId);
  if (!roomExists) {
    // Send error with standardized code
    ctx.send(ErrorMessage, {
      code: ErrorCode.RESOURCE_NOT_FOUND,
      message: `Room ${roomId} does not exist`,
      context: { roomId }, // Optional context for debugging
    });
    return;
  }

  // Continue with normal flow...
  ctx.ws.data.roomId = roomId;
  ctx.ws.subscribe(roomId);
  // ...
});

Custom error handling

You can add your own error handling middleware by using the onMessage handler:

// Error handling in connection setup
ws.onOpen((ctx) => {
  try {
    // Your connection setup logic
    console.log(`Client ${ctx.ws.data.clientId} connected`);
  } catch (error) {
    console.error("Error in connection setup:", error);
    ctx.send(ErrorMessage, {
      code: ErrorCode.INTERNAL_SERVER_ERROR,
      message: "Failed to set up connection",
    });
  }
});

// Error handling in message handlers
ws.onMessage(AuthenticateUser, (ctx) => {
  try {
    // Validate token
    const { token } = ctx.payload;
    const user = validateToken(token);

    if (!user) {
      ctx.send(ErrorMessage, {
        code: ErrorCode.AUTHENTICATION_FAILED,
        message: "Invalid authentication token",
      });
      return;
    }

    // Store user data for future requests
    ctx.ws.data.userId = user.id;
    ctx.ws.data.userRole = user.role;

    // Send success response
    ctx.send(AuthSuccess, { userId: user.id });
  } catch (error) {
    ctx.send(ErrorMessage, {
      code: ErrorCode.INTERNAL_SERVER_ERROR,
      message: "Authentication process failed",
    });
  }
});

Available Error Codes

The built-in ErrorCode enum provides consistent error types:

Error Code Description
INVALID_MESSAGE_FORMAT Message isn't valid JSON or lacks required structure
VALIDATION_FAILED Message failed schema validation
UNSUPPORTED_MESSAGE_TYPE No handler registered for this message type
AUTHENTICATION_FAILED Client isn't authenticated or has invalid credentials
AUTHORIZATION_FAILED Client lacks permission for the requested action
RESOURCE_NOT_FOUND Requested resource (user, room, etc.) doesn't exist
RATE_LIMIT_EXCEEDED Client is sending messages too frequently
INTERNAL_SERVER_ERROR Unexpected server error occurred

You can also broadcast error messages to multiple clients using the publish function:

// Notify all users in a room that it's being deleted
publish(ctx.ws, roomId, ErrorMessage, {
  code: ErrorCode.RESOURCE_NOT_FOUND,
  message: "This room is being deleted and will no longer be available",
  context: { roomId },
});

How to compose routes

You can compose routes from different files into a single router. This is useful for organizing your code and keeping related routes together.

import { z } from "zod";
import { WebSocketRouter, createMessageSchema } from "bun-ws-router/zod";
import { Meta } from "./schemas";

const { messageSchema } = createMessageSchema(z);
import { chatRoutes } from "./chat";
import { notificationRoutes } from "./notification";

const ws = new WebSocketRouter<Meta>();
ws.addRoutes(chatRoutes);
ws.addRoutes(notificationRoutes);

Where chatRoutes and notificationRoutes are other router instances defined in separate files.

Browser Client

Type-safe browser WebSocket client with automatic reconnection, authentication, and request/response patterns:

import { z } from "zod";
import { createMessageSchema } from "bun-ws-router/zod";
import { createClient } from "bun-ws-router/zod/client"; // ✅ Typed client

// Define schemas (shared with server)
const { messageSchema } = createMessageSchema(z);
const Hello = messageSchema("HELLO", { name: z.string() });
const HelloOk = messageSchema("HELLO_OK", { text: z.string() });

// Create client with auth and reconnection
const client = createClient({
  url: "wss://api.example.com/ws",
  reconnect: { enabled: true },
  auth: {
    getToken: () => localStorage.getItem("access_token"),
    attach: "query", // Appends ?access_token=...
  },
});

await client.connect();

// Send fire-and-forget message
client.send(Hello, { name: "Anna" });

// Receive messages with full type inference
client.on(HelloOk, (msg) => {
  // ✅ msg.payload.text is typed as string
  console.log("Server says:", msg.payload.text);
});

// Request/response pattern with timeout
try {
  const reply = await client.request(Hello, { name: "Bob" }, HelloOk, {
    timeoutMs: 5000,
  });
  // ✅ reply.payload.text is typed as string
  console.log("Reply:", reply.payload.text);
} catch (err) {
  console.error("Request failed:", err);
}

See the Client Documentation for complete API reference and advanced usage.

Support

Questions or issues? Join us on Discord.

Backers

              

License

This project is licensed under the MIT License. See the LICENSE file for details.

About

Type-safe WebSocket message routing for Bun. Choose Zod for familiarity or Valibot for 90% smaller bundles. Full TypeScript support included.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Sponsor this project