@ws-kit/bun
v0.10.0
Published
Bun platform adapter for WS-Kit leveraging native WebSocket API with built-in pub/sub and low-latency message routing
Readme
@ws-kit/bun
Bun platform adapter for WS-Kit, leveraging Bun's native high-performance WebSocket features.
Purpose
@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)
What This Package Provides
bunPubSub(server): Factory returning aPubSubAdapterfor use withwithPubSub()plugincreateBunHandler(router): Factory returning{ fetch, websocket }forBun.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
clientIdandconnectedAttracking viactx.data
Platform Advantages Leveraged
- 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
Installation
bun add @ws-kit/core @ws-kit/bunInstall with a validator adapter (optional but recommended):
bun add zod @ws-kit/zod
# OR
bun add valibot @ws-kit/valibotDependencies
@ws-kit/core(required) — Core router and types@types/bun(peer) — TypeScript types for Bun (only in TypeScript projects)
Quick Start
Basic Example
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
},
});With Pub/Sub Plugin
For broadcasting to multiple subscribers:
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).
Low-Level API (Advanced)
For more control over server configuration:
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,
});API Reference
bunPubSub(server)
Create a Pub/Sub adapter for use with the withPubSub() plugin.
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.
createBunHandler(router, options?)
Returns { fetch, websocket } handlers for Bun.serve().
Options:
authenticate?: (req: Request) => Promise<TData | undefined> | TData | undefined— Custom auth function called during upgrade. Returnundefinedto 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)
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(`[ws ${ctx.type}] ${error.message}`, {
clientId: ctx.clientId,
phase: ctx.type,
});
},
onOpen: ({ data }) => {
console.log(`Connection opened: ${data.clientId}`);
},
onClose: ({ data }) => {
console.log(`Connection closed: ${data.clientId}`);
},
});Connection Data
All connections automatically include:
type BunConnectionData<TContext> = {
clientId: string; // UUID v7 - unique per connection
connectedAt: number; // Timestamp in milliseconds
// + your custom auth data (TContext)
};Access in handlers:
router.on(SomeSchema, (ctx) => {
const { clientId, connectedAt } = ctx.data;
// Use clientId for logging, userId for auth, etc.
});Broadcasting
// 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.
PubSub Scope & Scaling
Single Bun Instance
In Bun, router.publish(topic) broadcasts to all WebSocket connections in the current process subscribed to that topic.
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",
});Multi-Instance Cluster (Load Balanced)
For deployments with multiple Bun processes behind a load balancer, use @ws-kit/redis:
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 });Connection Lifecycle
Connections go through phases: authenticate → upgrade → open → message(s) → close. Sync-only hooks fire at each phase for observability:
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(`Connected: ${data.clientId}`);
},
onClose: ({ data }) => {
console.log(`Disconnected: ${data.clientId}`);
},
onError: (error, evt) => {
console.error(`Error in ${evt.type}:`, error.message);
},
});Handlers receive validated messages with full connection context:
router.on(LoginMessage, (ctx) => {
const { username, password } = ctx.payload; // From schema
const { userId, clientId } = ctx.data; // From auth or defaults
// Handle login...
});Examples
Chat Application with Pub/Sub
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<string, Set<string>>();
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,
});Performance
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.
Key Concepts
Connection Data
All connection state lives in ctx.data (see ADR-033 for details). Automatic fields are always available; custom fields come from the authenticate hook:
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
Opaque Transport
The WebSocket (ctx.ws) is used only for low-level transport operations:
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 insteadTypeScript Support
Full type inference from schema to handler context. Use module augmentation to define connection data once, shared across all routers:
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
});Architecture & Design
Authentication Gating
Per ADR-035, authentication is a critical security boundary:
- Returning
undefinedfromauthenticaterejects the connection with configured status (default 401) - Returning an object merges it into
ctx.dataand accepts the connection - Not providing
authenticateaccepts connections with only automatic fields (clientId,connectedAt)
This ensures auth is a true gatekeeper, not a side effect.
Sync-Only Hooks
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
Troubleshooting
"Upgrade failed"
Ensure your fetch handler returns the result of fetch(req, server) from createBunHandler().
Authentication rejected
If your connection is rejected with 401, verify:
authenticateis returning an object (notundefined) to accept- Use
authRejectionoption to customize the rejection status/message if needed
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" },
});Messages not broadcasting
Check that:
- Router has
withPubSub()plugin registered - Sender and receiver are subscribed to the same topic:
await ctx.topics.subscribe("channel") - For multi-instance: use
@ws-kit/redisinstead of Bun's built-in pub/sub
Memory leaks
Ensure handlers clean up subscriptions:
router.on(JoinRoomMessage, async (ctx) => {
await ctx.topics.subscribe(`room:${room}`);
});
// Clean up on disconnect (via plugin or external tracking)
await ctx.topics.unsubscribe(`room:${room}`);Related Packages
@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
License
MIT
