New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details →
Socket
Book a DemoSign in
Socket

@ws-kit/bun

Package Overview
Dependencies
Maintainers
2
Versions
16
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ws-kit/bun

Bun platform adapter for WS-Kit leveraging native WebSocket API with built-in pub/sub and low-latency message routing

latest
Source
npmnpm
Version
0.10.1
Version published
Maintainers
2
Created
Source

@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 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

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/bun

Install with a validator adapter (optional but recommended):

bun add zod @ws-kit/zod
# OR
bun add valibot @ws-kit/valibot

Dependencies

  • @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. 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 attempt
  • onOpen?: (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 connection
  • connectedAt: 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 instead

TypeScript 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 undefined from authenticate rejects the connection with configured status (default 401)
  • Returning an object merges it into ctx.data and accepts the connection
  • Not providing authenticate accepts 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:

  • authenticate is returning an object (not undefined) to accept
  • Use authRejection option 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/redis instead 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}`);

License

MIT

Keywords

bun

FAQs

Package last updated on 20 Jan 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts