npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

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

  1. authenticate is returning an object (not undefined) to accept
  2. 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:

  1. Router has withPubSub() plugin registered
  2. Sender and receiver are subscribed to the same topic: await ctx.topics.subscribe("channel")
  3. 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}`);

Related Packages

License

MIT