WebSocket#
Bidirectional real-time communication backed by Bun's native WebSocket server. No polyfills or extra abstraction - direct access to Bun.serve WebSocket.
Basic Usage#
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#
| Callback | Signature | When |
|---|---|---|
| open | (ws) => void | Client connects |
| message | (ws, message: string | Buffer) => void | Client sends a message |
| close | (ws, code: number, reason: string) => void | Client disconnects |
| drain | (ws) => void | Buffer 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:
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
},
});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:
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:
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:
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#
| Method | Description |
|---|---|
| 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.size | Number of members in the room |
| room.isEmpty | Check 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:
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):
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#
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:
// 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#
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:
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#
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#
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.
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.
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" });| Interface | Methods | Default |
|---|---|---|
| WSPubSub | publish, subscribe, unsubscribe | MemoryWSPubSub |
| WSRateLimitStore | increment, get, reset | MemoryWSRateLimitStore |
new Room("name", { pubSub, channel })Next Steps#
- SSE - Simpler server-to-client streaming
- Event Emitter - Decouple code with events
- Routing - Route groups and middleware