WebSocket#

Bidirectional real-time communication backed by Bun's native WebSocket server. No polyfills or extra abstraction - direct access to Bun.serve WebSocket.

Info
For one-way server-to-client streaming, see SSE. WebSocket is best for chat, games, and collaboration where both sides need to send messages.

Basic Usage#

typescript
const app = new Buntok();

app.ws("/chat", {
  open: (ws) => {
    console.log("Client connected");
  },
  message: (ws, message) => {
    // Echo back
    ws.send(message);
  },
  close: (ws, code, reason) => {
    console.log("Disconnected:", code, reason);
  },
});

app.listen(1212);

WSHandler#

CallbackSignatureWhen
open(ws) => voidClient connects
message(ws, message: string | Buffer) => voidClient sends a message
close(ws, code: number, reason: string) => voidClient disconnects
drain(ws) => voidBuffer is ready for more data
authenticate(ws) => Promise<unknown | null>Client connects (return null to reject)

Authentication#

WebSocket bypasses HTTP middleware. Use the authenticate option to validate connections:

typescript
app.ws("/chat", {
  authenticate: async (ws) => {
    const url = new URL(ws.data.ctx.request.url);
    const token = url.searchParams.get("token");
    
    if (!token) return null; // Reject connection
    
    const user = await verifyToken(token);
    return user ? { user } : null; // Return auth data or reject
  },
  open: (ws) => {
    // ws.data.auth contains the return value from authenticate
    const { user } = ws.data.auth as { user: User };
    console.log("Authenticated:", user.name);
  },
  message: (ws, msg) => {
    const { user } = ws.data.auth as { user: User };
    // user is available here
  },
});
Warning
If authenticate returns null, the connection is immediately closed with code 4001. The open handler is never called. Always return null for unauthenticated clients.

wsAuth Helper#

Use wsAuth for a composable auth pattern - it returns a WSHandler with the open callback wired up:

typescript
import { wsAuth } from "@buntok/core/ws-helpers";

app.ws("/chat", {
  ...wsAuth(async (ws) => {
    const url = new URL(ws.data.ctx.request.url);
    const token = url.searchParams.get("token");
    if (!token) return null;
    const user = await verifyToken(token);
    return user ? { user } : null;
  }),
  message: (ws, msg) => {
    // ws.data.auth is available here
    const { user } = ws.data.auth as { user: User };
  },
});

Message Validation#

Use validateWSMessage with Zod to validate incoming messages:

typescript
import { z } from "@buntok/core/middlewares/validator";
import { validateWSMessage } from "@buntok/core/ws-helpers";

const messageSchema = z.object({
  type: z.enum(["chat", "ping", "join"]),
  payload: z.string().optional(),
});

app.ws("/chat", {
  message: (ws, message) => {
    const result = validateWSMessage(messageSchema, message);
    
    if (!result.success) {
      ws.send(JSON.stringify({
        error: "Invalid message",
        details: result.errors.issues,
      }));
      return;
    }
    
    // result.data is fully typed
    switch (result.data.type) {
      case "chat":
        handleChat(ws, result.data.payload);
        break;
      case "ping":
        ws.send(JSON.stringify({ type: "pong" }));
        break;
      case "join":
        handleJoin(ws, result.data.payload);
        break;
    }
  },
});

Room Management#

Use the Room class for higher-level room management:

typescript
import { Room } from "@buntok/core/ws-helpers";

const rooms = new Map<string, Room>();

app.ws("/chat", {
  open: (ws) => {
    const roomName = ws.data.ctx.query.get("room") || "general";
    let room = rooms.get(roomName);
    
    if (!room) {
      room = new Room(roomName);
      rooms.set(roomName, room);
    }
    
    room.join(ws);
    ws.data.room = room;
    
    // Notify others
    room.broadcast({
      type: "user:joined",
      users: room.size,
    }, ws);
  },
  message: (ws, msg) => {
    const room = ws.data.room as Room;
    room.broadcast(msg, ws); // Broadcast to all except sender
  },
  close: (ws) => {
    const room = ws.data.room as Room;
    room.leave(ws);
    
    room.broadcast({
      type: "user:left",
      users: room.size,
    });
  },
});

Room API#

MethodDescription
room.join(ws)Add a WebSocket to the room
room.leave(ws)Remove a WebSocket from the room
room.broadcast(message, exclude?)Send to all members except sender
room.sendAll(message)Send to all members including sender
room.getMembers()Get all WebSocket members as an array
room.sizeNumber of members in the room
room.isEmptyCheck if the room has no members
room.has(ws)Check if a WebSocket is in the room
room.closeAll()Close all connections and clear the room

Heartbeat (Ping/Pong)#

Use wsHeartbeat to detect stale connections:

typescript
import { wsHeartbeat } from "@buntok/core/ws-helpers";

app.ws("/chat", {
  ...wsHeartbeat(30_000), // 30 second interval
  open: (ws) => {
    console.log("Client connected");
  },
  message: (ws, msg) => {
    // Handle message
  },
});

Pub/Sub (Rooms)#

Bun's WebSocket supports publish/subscribe for broadcasting to groups of clients (rooms):

typescript
app.ws("/chat", {
  open: (ws) => {
    // Join a room
    ws.subscribe("general");
  },
  message: (ws, message) => {
    // Broadcast to everyone in the room
    ws.publish("general", message);
  },
  close: (ws) => {
    // Auto-unsubscribed on close
  },
});

Multiple Rooms#

typescript
app.ws("/chat", {
  open: (ws) => {
    ws.subscribe("room-1");
    ws.subscribe("room-2");
  },
  message: (ws, msg) => {
    // Send to specific room
    ws.publish("room-1", msg);
  },
});

Server-side Broadcast#

Use app.server.publish() to broadcast from HTTP routes:

typescript
// HTTP route broadcasts to WebSocket clients
app.post("/notify", async (ctx) => {
  const { message } = await ctx.body();

  // Broadcast to all clients in "notifications" room
  app.server?.publish("notifications", JSON.stringify({
    type: "notification",
    data: message,
  }));

  return ctx.json({ sent: true });
});

// WebSocket handler subscribes to the room
app.ws("/notifications", {
  open: (ws) => ws.subscribe("notifications"),
  message: (ws, msg) => ws.publish("notifications", msg),
});

Binary Data#

typescript
app.ws("/binary", {
  message: (ws, message) => {
    if (message instanceof Buffer) {
      // Handle binary data
      console.log("Received bytes:", message.length);
      ws.send(message); // Echo binary
    } else {
      // Handle text data
      ws.send(JSON.stringify({ echo: message }));
    }
  },
});

Context Access#

Each WebSocket connection has access to the HTTP request context via ws.data:

typescript
app.ws("/auth-ws", {
  open: (ws) => {
    // Access the HTTP request context
    const ctx = ws.data.ctx;
    const url = ctx.request.url;
    const ip = ctx.ip;
    console.log(`New connection from ${ip} to ${url}`);
  },
  message: (ws, message) => {
    // Access handler reference
    const handler = ws.data.handler;
  },
});

Group WebSocket#

typescript
const api = app.group("/api");

// /api/ws/notifications
api.ws("/ws/notifications", {
  open: (ws) => ws.subscribe("notifications"),
  message: (ws, msg) => ws.publish("notifications", msg),
});

Full Example: Chat Room#

typescript
import { Buntok } from "@buntok/core";

const app = new Buntok();
const rooms = new Map<string, Set<string>>();

app.ws("/chat/:room", {
  open: (ws) => {
    const room = ws.data.ctx.params.room;
    ws.subscribe(room);

    if (!rooms.has(room)) rooms.set(room, new Set());
    rooms.get(room)!.add(ws.data.ctx.ip);

    ws.publish(room, JSON.stringify({
      type: "join",
      users: rooms.get(room)!.size,
    }));
  },
  message: (ws, message) => {
    const room = ws.data.ctx.params.room;
    ws.publish(room, JSON.stringify({
      type: "message",
      data: message,
    }));
  },
  close: (ws, code) => {
    const room = ws.data.ctx.params.room;
    const roomUsers = rooms.get(room);
    if (roomUsers) {
      roomUsers.delete(ws.data.ctx.ip);
      if (roomUsers.size === 0) rooms.delete(room);
    }
  },
});

app.listen(1212);

Rate Limiting#

Limit the number of messages per connection to prevent abuse.

typescript
import { wsRateLimit } from "@buntok/core/ws-helpers";

app.ws("/chat", {
  ...wsRateLimit({
    windowMs: 60_000,   // 1 minute window
    max: 100,           // max 100 messages per window
    closeCode: 4002,    // custom close code (default: 4002)
  }),
  message: (ws, msg) => { /* handle */ },
});

Pluggable Infrastructure#

For multi-instance deployments, replace in-memory PubSub and rate limit stores.

typescript
import {
  MemoryWSPubSub,        // default in-memory PubSub
  MemoryWSRateLimitStore, // default in-memory rate limit store
} from "@buntok/core/ws-helpers";

// Custom PubSub (e.g., Redis-backed for cluster-wide broadcasting)
const pubSub = new RedisWSPubSub({ url: "redis://localhost:6379" });

// Custom rate limit store (e.g., Redis-backed for distributed limits)
const rateLimitStore = new RedisWSRateLimitStore({ url: "redis://localhost:6379" });
InterfaceMethodsDefault
WSPubSubpublish, subscribe, unsubscribeMemoryWSPubSub
WSRateLimitStoreincrement, get, resetMemoryWSRateLimitStore
Info
Room supports cluster-wide broadcasting: new Room("name", { pubSub, channel })

Next Steps#

  • SSE - Simpler server-to-client streaming
  • Event Emitter - Decouple code with events
  • Routing - Route groups and middleware