Bun platform adapter for WS-Kit leveraging native WebSocket API with built-in pub/sub and low-latency message routing
npm install @ws-kit/bunBun platform adapter for WS-Kit, leveraging Bun's native high-performance WebSocket features.
@ws-kit/bun provides the platform-specific integration layer for WS-Kit on Bun, enabling:
- Direct use of Bun's native server.publish() for zero-copy broadcasting
- Seamless integration with Bun.serve()
- Type-safe WebSocket message routing with @ws-kit/core
- Pluggable validator adapters (Zod, Valibot, or custom)
- bunPubSub(server): Factory returning a PubSubAdapter for use with withPubSub() plugin
- createBunHandler(router): Factory returning { fetch, websocket } for Bun.serve() integration
- Native UUID client ID generation: Using Bun's built-in crypto.randomUUID() for unique connection identifiers
- Authentication support: Auth gating during WebSocket upgrade (return undefined to reject)
- Connection metadata: Automatic clientId and connectedAt tracking via ctx.data
- Native PubSub: Uses Bun's event-loop integrated broadcasting (no third-party message queue needed)
- Zero-copy: Messages broadcast without serialization overhead
- Auto-cleanup: Subscriptions cleaned up on connection close via Bun's garbage collection
- Automatic backpressure: Respects WebSocket write buffer limits
- Optimal performance: Direct integration with Bun's optimized WebSocket implementation
``bash`
bun add @ws-kit/core @ws-kit/bun
Install with a validator adapter (optional but recommended):
`bash`
bun add zod @ws-kit/zodOR
bun add valibot @ws-kit/valibot
- @ws-kit/core (required) — Core router and types@types/bun
- (peer) — TypeScript types for Bun (only in TypeScript projects)
`typescript
import { serve } from "@ws-kit/bun";
import { z, createRouter, message } from "@ws-kit/zod";
// Define message schemas
const PingMessage = message("PING", { text: z.string() });
const PongMessage = message("PONG", { reply: z.string() });
// Create router
const router = createRouter();
// Register handlers
router.on(PingMessage, (ctx) => {
ctx.send(PongMessage, { reply: ctx.payload.text });
});
// Serve with authentication
serve(router, {
port: 3000,
authenticate(req) {
// Verify auth token and return user data
// Returning undefined rejects the connection with 401
const token = req.headers.get("authorization");
if (!token) return undefined; // Reject
return { userId: "user_123" }; // Accept
},
});
`
For broadcasting to multiple subscribers:
`typescript
import { serve } from "@ws-kit/bun";
import { createRouter, message } from "@ws-kit/zod";
import { withPubSub } from "@ws-kit/pubsub";
import { z } from "zod";
const NotificationMessage = message("NOTIFICATION", {
text: z.string(),
});
const router = createRouter().plugin(withPubSub());
router.on(NotificationMessage, async (ctx) => {
// Broadcast to all subscribers on the topic
await ctx.publish("notifications", NotificationMessage, {
text: "Hello everyone!",
});
});
serve(router, { port: 3000 });
`
Note: serve() automatically initializes the Bun Pub/Sub adapter. For createBunHandler(), you must manually configure the adapter (see low-level API section below).
For more control over server configuration:
`typescript
import { createBunHandler } from "@ws-kit/bun";
import { z, createRouter, message } from "@ws-kit/zod";
// Define and register handlers
const PingMessage = message("PING", { text: z.string() });
const PongMessage = message("PONG", { reply: z.string() });
const router = createRouter();
router.on(PingMessage, (ctx) => {
ctx.send(PongMessage, { reply: ctx.payload.text });
});
// Create handlers
const { fetch, websocket } = createBunHandler(router, {
authenticate: async (req) => {
// Verify tokens, sessions, etc.
return {};
},
});
// Start server
Bun.serve({
port: 3000,
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/ws") {
return fetch(req, server);
}
return new Response("Not Found", { status: 404 });
},
websocket,
});
`
Create a Pub/Sub adapter for use with the withPubSub() plugin.
`typescript
import { createRouter } from "@ws-kit/zod";
import { withPubSub } from "@ws-kit/pubsub";
import { bunPubSub } from "@ws-kit/bun";
const server = Bun.serve({ fetch: ..., websocket: ... });
const adapter = bunPubSub(server);
const router = createRouter()
.plugin(withPubSub({ adapter }));
`
Note: Bun's pub/sub is process-scoped. For multi-instance clusters, use @ws-kit/redis.
Returns { fetch, websocket } handlers for Bun.serve().
Options:
- authenticate?: (req: Request) => Promise — Custom auth function called during upgrade. Return undefined to reject with configured status (default 401), or an object to merge into connection data and accept.authRejection?: { status?: number; message?: string }
- — Customize rejection response when authenticate returns undefined (default: { status: 401, message: "Unauthorized" })clientIdHeader?: string
- — Header name for returning client ID (default: "x-client-id")onError?: (error: Error, evt: BunErrorEvent) => void
- — Called when errors occur (sync-only, for logging/telemetry)onUpgrade?: (req: Request) => void
- — Called before upgrade attemptonOpen?: (ctx: BunConnectionContext) => void
- — Called after connection established (sync-only)onClose?: (ctx: BunConnectionContext) => void
- — Called after connection closed (sync-only)
`typescript[ws ${ctx.type}] ${error.message}
const { fetch, websocket } = createBunHandler(router, {
authenticate: async (req) => {
const token = req.headers.get("authorization");
if (!token) return undefined; // Reject with 401
const user = await validateToken(token);
return { userId: user.id, role: user.role };
},
authRejection: { status: 403, message: "Forbidden" }, // Custom rejection
onError: (error, ctx) => {
console.error(, {Connection opened: ${data.clientId}
clientId: ctx.clientId,
phase: ctx.type,
});
},
onOpen: ({ data }) => {
console.log();Connection closed: ${data.clientId}
},
onClose: ({ data }) => {
console.log();`
},
});
All connections automatically include:
`typescript`
type BunConnectionData
clientId: string; // UUID v7 - unique per connection
connectedAt: number; // Timestamp in milliseconds
// + your custom auth data (TContext)
};
Access in handlers:
`typescript`
router.on(SomeSchema, (ctx) => {
const { clientId, connectedAt } = ctx.data;
// Use clientId for logging, userId for auth, etc.
});
`typescript
// Define schemas
const JoinRoom = message("JOIN_ROOM", { room: z.string() });
const RoomUpdate = message("ROOM_UPDATE", { text: z.string() });
router.on(JoinRoom, async (ctx) => {
const { room } = ctx.payload;
// Subscribe to room channel
await ctx.topics.subscribe(room:${room});
});
// Broadcast to all subscribers on a channel
await router.publish("room:123", RoomUpdate, { text: "Hello everyone!" });
`
Messages published to a channel are received by all connections subscribed to that channel.
In Bun, router.publish(topic) broadcasts to all WebSocket connections in the current process subscribed to that topic.
`typescript
import { createRouter } from "@ws-kit/zod";
import { withPubSub } from "@ws-kit/pubsub";
import { bunPubSub } from "@ws-kit/bun";
const server = Bun.serve({
fetch() {
return new Response("");
},
websocket: {},
});
const router = createRouter().plugin(
withPubSub({ adapter: bunPubSub(server) }),
);
// This broadcasts to connections in THIS process only
const NotificationMessage = message("NOTIFICATION", { message: z.string() });
await router.publish("notifications", NotificationMessage, {
message: "Hello",
});
`
For deployments with multiple Bun processes behind a load balancer, use @ws-kit/redis:
`typescript
import { createClient } from "redis";
import { createRouter } from "@ws-kit/zod";
import { withPubSub } from "@ws-kit/pubsub";
import { redisPubSub } from "@ws-kit/redis";
import { serve } from "@ws-kit/bun";
const redis = createClient();
await redis.connect();
const router = createRouter().plugin(
withPubSub({ adapter: redisPubSub(redis) }),
);
// Now publishes across ALL instances
const NotificationMessage = message("NOTIFICATION", { message: z.string() });
await router.publish("notifications", NotificationMessage, {
message: "Hello",
});
serve(router, { port: 3000 });
`
Connections go through phases: authenticate → upgrade → open → message(s) → close. Sync-only hooks fire at each phase for observability:
`typescriptConnected: ${data.clientId}
const { fetch, websocket } = createBunHandler(router, {
authenticate: async (req) => {
// Verify auth; return undefined to reject, object to accept
const token = req.headers.get("authorization");
return token ? { userId: "user_123" } : undefined;
},
onOpen: ({ data }) => {
console.log();Disconnected: ${data.clientId}
},
onClose: ({ data }) => {
console.log();Error in ${evt.type}:
},
onError: (error, evt) => {
console.error(, error.message);`
},
});
Handlers receive validated messages with full connection context:
`typescript`
router.on(LoginMessage, (ctx) => {
const { username, password } = ctx.payload; // From schema
const { userId, clientId } = ctx.data; // From auth or defaults
// Handle login...
});
`typescript
import { createBunHandler } from "@ws-kit/bun";
import { createRouter } from "@ws-kit/zod";
import { withPubSub } from "@ws-kit/pubsub";
import { bunPubSub } from "@ws-kit/bun";
import { z, message } from "@ws-kit/zod";
declare module "@ws-kit/core" {
interface ConnectionData {
userId?: string;
room?: string;
}
}
// Message schemas
const JoinRoomMessage = message("ROOM:JOIN", { room: z.string() });
const SendMessageMessage = message("ROOM:MESSAGE", { text: z.string() });
const UserListMessage = message("ROOM:LIST", {
users: z.array(z.string()),
});
const BroadcastMessage = message("ROOM:BROADCAST", {
user: z.string(),
text: z.string(),
});
const server = Bun.serve({
fetch() {
return new Response("");
},
websocket: {},
});
// Router with pub/sub
const router = createRouter().plugin(
withPubSub({ adapter: bunPubSub(server) }),
);
// Track rooms
const rooms = new Map
router.on(JoinRoomMessage, async (ctx) => {
const { room } = ctx.payload;
const { clientId } = ctx.data;
// Update connection data
ctx.assignData({ room });
// Subscribe to room
await ctx.topics.subscribe(room:${room});
// Track membership
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room)!.add(clientId);
// Broadcast user list using schema
const users = Array.from(rooms.get(room)!);
await router.publish(room:${room}, UserListMessage, { users });
});
router.on(SendMessageMessage, async (ctx) => {
const { text } = ctx.payload;
const { clientId, room } = ctx.data;
// Broadcast to all in room using schema
await router.publish(room:${room}, BroadcastMessage, {
user: clientId,
text,
});
});
router.onClose((ctx) => {
const { clientId, room } = ctx.data;
if (room && rooms.has(room)) {
rooms.get(room)!.delete(clientId);
}
});
const { fetch, websocket } = createBunHandler(router);
Bun.serve({
fetch(req) {
if (new URL(req.url).pathname === "/ws") {
return fetch(req, server);
}
return new Response("Not Found", { status: 404 });
},
websocket,
});
`
Bun's native WebSocket implementation provides excellent performance characteristics:
- Zero-copy broadcasting — Uses Bun's server.publish() for efficient message distribution
- Automatic backpressure — WebSocket write buffer limits are respected
- In-memory pub/sub — Fast topic subscriptions without external dependencies
- Connection limits — Determined by OS and Bun runtime (typically 10,000+ concurrent connections)
For exact performance benchmarks, see Bun's WebSocket documentation.
All connection state lives in ctx.data (see ADR-033 for details). Automatic fields are always available; custom fields come from the authenticate hook:
`typescript
declare module "@ws-kit/core" {
interface ConnectionData {
userId?: string;
roles?: string[];
}
}
router.on(SomeMessage, (ctx) => {
const { clientId, connectedAt } = ctx.data; // Automatic
const { userId, roles } = ctx.data; // Custom (from auth)
ctx.assignData({ roles: ["admin"] }); // Update
});
`
Automatic fields:
- clientId: string — Unique per connectionconnectedAt: number
- — Timestamp when upgraded
The WebSocket (ctx.ws) is used only for low-level transport operations:
`typescript
ctx.ws.send(data); // Low-level send
ctx.ws.close(1000); // Close with code
const state = ctx.ws.readyState; // Check state
// Don't access platform-specific fields; use ctx.data instead
`
Full type inference from schema to handler context. Use module augmentation to define connection data once, shared across all routers:
`typescript
declare module "@ws-kit/core" {
interface ConnectionData {
userId?: string;
role?: "admin" | "user";
}
}
router.on(SomeSchema, (ctx) => {
const role = ctx.data.role; // Fully typed: "admin" | "user" | undefined
});
`
Per ADR-035, authentication is a critical security boundary:
- Returning undefined from authenticate rejects the connection with configured status (default 401)ctx.data
- Returning an object merges it into and accepts the connectionauthenticate
- Not providing accepts connections with only automatic fields (clientId, connectedAt)
This ensures auth is a true gatekeeper, not a side effect.
Error and lifecycle hooks (onError, onOpen, onClose) are sync-only for predictability:
- Cannot await promises (no async footguns)
- Used for observability and logging, not recovery
- For async cleanup or recovery, use plugins instead
Ensure your fetch handler returns the result of fetch(req, server) from createBunHandler().
If your connection is rejected with 401, verify:
1. authenticate is returning an object (not undefined) to acceptauthRejection
2. Use option to customize the rejection status/message if needed
`typescript`
const { fetch } = createBunHandler(router, {
authenticate: (req) => {
// ✓ Correct: return {} to accept with no custom data
// ✓ Correct: return { userId: "..." } to accept with data
// ✗ Wrong: returning undefined still rejects
return undefined; // This rejects
},
authRejection: { status: 403, message: "Forbidden" },
});
Check that:
1. Router has withPubSub() plugin registeredawait ctx.topics.subscribe("channel")
2. Sender and receiver are subscribed to the same topic: @ws-kit/redis
3. For multi-instance: use instead of Bun's built-in pub/sub
Ensure handlers clean up subscriptions:
`typescriptroom:${room}
router.on(JoinRoomMessage, async (ctx) => {
await ctx.topics.subscribe();
});
// Clean up on disconnect (via plugin or external tracking)
await ctx.topics.unsubscribe(room:${room});`
- @ws-kit/core — Core router and types
- @ws-kit/zod — Zod validator adapter
- @ws-kit/valibot — Valibot validator adapter
- @ws-kit/redis — Redis rate limiter and pub/sub
- @ws-kit/memory — In-memory pub/sub
- @ws-kit/client` — Browser/Node.js client
MIT