Skip to content

kriasoft/ws-kit

WS-Kit — Type-Safe WebSocket Router

npm version npm downloads GitHub Actions Chat on Discord

Type-safe WebSocket router for Bun and Cloudflare with Zod or Valibot validation. Routes messages to handlers with full TypeScript support on both server and client.

⚠️ Environment Requirements

WS-Kit is ESM-only and optimized for modern runtimes:

  • Bun (recommended) — native ESM and WebSocket support
  • Cloudflare Workers/Durable Objects — native ESM support
  • Node.js (with bundler) — requires Node 18+ and a bundler like Vite, esbuild, or Rollup
  • Browser — works with modern bundlers

Not compatible with CommonJS-only projects or legacy runtimes.

Monorepo Structure

WS-Kit is organized as a modular monorepo with independent packages:

  • @ws-kit/core — Platform-agnostic router and type system (foundation)
  • @ws-kit/zod — Zod validator adapter with createRouter() helper
  • @ws-kit/valibot — Valibot validator adapter with createRouter() helper
  • @ws-kit/bun — Bun platform adapter with serve() high-level and createBunHandler() low-level
  • @ws-kit/cloudflare-do — Cloudflare Durable Objects adapter
  • @ws-kit/client — Universal browser/Node.js client
  • @ws-kit/redis-pubsub — Optional Redis PubSub for multi-server scaling

Combine any validator adapter with platform-specific packages. Each platform package (e.g., @ws-kit/bun) exports both high-level convenience (serve()) and low-level APIs (createBunHandler()).

Key Features

Server (Bun)

  • 🔒 Type-safe message routing with Zod/Valibot validation
  • 🚀 Built on Bun's native WebSocket implementation
  • 📡 PubSub with schema-validated broadcasts
  • 🧩 Composable routers and middleware support

Client (Browser)

  • 🔄 Auto-reconnection with exponential backoff
  • 📦 Configurable offline message queueing
  • ⏱️ Request/response pattern with timeouts
  • 🔐 Built-in auth (query param or protocol header)

Shared

  • ✨ Shared schemas between server and client
  • ⚡ Choose Zod (familiar) or Valibot (60-80% smaller)
  • 🔒 Full TypeScript inference on both sides

Installation

Choose your validation library and platform:

# With Zod on Bun (recommended for most projects)
bun add @ws-kit/zod @ws-kit/bun
bun add zod bun @types/bun -D

# With Valibot on Bun (lighter bundles)
bun add @ws-kit/valibot @ws-kit/bun
bun add valibot bun @types/bun -D

Quick Start

The export-with-helpers pattern is the first-class way to use WS-Kit —no factories, no dual imports:

import { z, message, createRouter } from "@ws-kit/zod";
import { serve } from "@ws-kit/bun";

// Define message schemas with full type inference
const PingMessage = message("PING", { text: z.string() });
const PongMessage = message("PONG", { reply: z.string() });

// Create type-safe router with optional connection data
type AppData = { userId?: string };
const router = createRouter<AppData>();

// Register handlers — fully typed!
router.on(PingMessage, (ctx) => {
  console.log(`Received: ${ctx.payload.text}`); // ✅ Fully typed
  ctx.send(PongMessage, { reply: `Got: ${ctx.payload.text}` });
});

// Serve with type-safe handlers
serve(router, {
  port: 3000,
  authenticate(req) {
    const token = req.headers.get("authorization");
    return token ? { userId: "u_123" } : undefined;
  },
});

That's it! Validator, router, messages, and platform adapter all come from focused packages. Type-safe from server to client.

Eliminating Verbose Generics with Declaration Merging

For applications with multiple routers, reduce repetition by declaring your connection data type once using TypeScript declaration merging. Then omit the generic everywhere — it's automatic:

// types/app-data.d.ts
declare module "@ws-kit/core" {
  interface AppDataDefault {
    userId?: string;
    email?: string;
    roles?: string[];
  }
}

Now all routers automatically use this type — no repetition:

// ✅ No generic needed — automatically uses AppDataDefault
const router = createRouter();

router.on(SecureMessage, (ctx) => {
  // ✅ ctx.ws.data is properly typed with all default fields
  const userId = ctx.ws.data?.userId; // string | undefined
  const roles = ctx.ws.data?.roles; // string[] | undefined
});

If you need custom data for a specific router, use an explicit generic:

type CustomData = { feature: string; version: number };
const featureRouter = createRouter<CustomData>();

Do and Don't

✅ DO:  import { z, message, createRouter } from "@ws-kit/zod"
❌ DON'T: import { z } from "zod"  (direct imports cause dual-package hazards)

Validation Libraries

Choose between Zod and Valibot — same API, different trade-offs:

// Zod - mature ecosystem, familiar method chaining API
import { z, message, createRouter } from "@ws-kit/zod";
import { serve } from "@ws-kit/bun";

// Valibot - 60-80% smaller bundles, functional composition
import { v, message, createRouter } from "@ws-kit/valibot";
import { serve } from "@ws-kit/bun";

Quick Comparison

Feature Zod Valibot
Bundle Size ~5-6 kB (Zod v4) ~1-2 kB
Performance Baseline ~2x faster
API Style Method chaining Functional
Best for Server-side, familiarity Client-side, performance

Serving Your Router

Each platform adapter exports both high-level convenience and low-level APIs. All approaches support authentication, lifecycle hooks, and error handling.

Platform-Specific Adapters (Recommended)

Use platform-specific imports for production deployments — they provide correct options, type safety, and clear errors:

High-level (recommended):

import { serve } from "@ws-kit/bun";
import { createRouter } from "@ws-kit/zod";

const router = createRouter();
serve(router, { port: 3000 });

Low-level (advanced control):

import { createBunHandler } from "@ws-kit/bun";
import { createRouter } from "@ws-kit/zod";

const router = createRouter();
const { fetch, websocket } = createBunHandler(router);

Bun.serve({
  port: 3000,
  fetch(req, server) {
    if (new URL(req.url).pathname === "/ws") {
      return fetch(req, server);
    }
    return new Response("Not Found", { status: 404 });
  },
  websocket,
});

Benefits:

  • Zero runtime detection — No overhead, optimal tree-shaking
  • Type-safe options — Platform-specific settings built-in (e.g., port for Bun)
  • Clear error messages — Misconfigurations fail fast with helpful guidance
  • Deterministic behavior — Same behavior across all environments

For Cloudflare Durable Objects:

import { createDurableObjectHandler } from "@ws-kit/cloudflare-do";
import { createRouter } from "@ws-kit/zod";

const router = createRouter();
const handler = createDurableObjectHandler(router, {
  authenticate(req) {
    /* ... */
  },
});

export default {
  fetch(req: Request) {
    return handler.fetch(req);
  },
};

Authentication

Secure your router by validating clients during the WebSocket upgrade. Pass authenticated user data via the authenticate hook — all handlers then have type-safe access to this data:

import { z, message, createRouter } from "@ws-kit/zod";
import { serve } from "@ws-kit/bun";
import { verifyIdToken } from "./auth"; // Your authentication logic

// Define secured message
const SendMessage = message("SEND_MESSAGE", {
  text: z.string(),
});

// Define router with user data type
type AppData = {
  userId?: string;
  email?: string;
  roles?: string[];
};

const router = createRouter<AppData>();

// Global middleware for auth checks
router.use((ctx, next) => {
  if (!ctx.ws.data?.userId && ctx.type !== "LOGIN") {
    ctx.error("UNAUTHENTICATED", "Not authenticated");
    return; // Skip handler
  }
  return next();
});

// Handlers have full type safety
router.on(SendMessage, (ctx) => {
  const userId = ctx.ws.data?.userId; // ✅ Type narrowed
  const email = ctx.ws.data?.email; // ✅ Type narrowed
  console.log(`${email} sent: ${ctx.payload.text}`);
});

// Authenticate and serve
serve(router, {
  port: 3000,
  authenticate(req) {
    // Verify JWT or session token
    const token = req.headers.get("authorization")?.replace("Bearer ", "");
    if (token) {
      const decoded = verifyIdToken(token);
      return {
        userId: decoded.uid,
        email: decoded.email,
        roles: decoded.roles || [],
      };
    }
  },
  onError(error, ctx) {
    console.error(`ws-kit error in ${ctx?.type}:`, error);
  },
  onOpen(ctx) {
    console.log(`User ${ctx.ws.data?.email} connected`);
  },
  onClose(ctx) {
    console.log(`User ${ctx.ws.data?.email} disconnected`);
  },
});

The authenticate function receives the HTTP upgrade request and returns user data that becomes ctx.ws.data in all handlers. If it returns null or undefined, the connection is rejected.

Message Schemas

Use the message() helper directly — no factory pattern needed:

import { z, message } from "@ws-kit/zod";

// Define your message types
export const JoinRoom = message("JOIN_ROOM", {
  roomId: z.string(),
});

export const UserJoined = message("USER_JOINED", {
  roomId: z.string(),
  userId: z.string(),
});

export const UserLeft = message("USER_LEFT", {
  userId: z.string(),
});

export const SendMessage = message("SEND_MESSAGE", {
  roomId: z.string(),
  text: z.string(),
});

// With Valibot
import { v, message } from "@ws-kit/valibot";

export const JoinRoom = message("JOIN_ROOM", {
  roomId: v.string(),
});

Simple, no factories, one canonical import source.

Request-Response Pairs with rpc()

For request-response patterns, use rpc() to bind request and response schemas together — no schema repetition at call sites:

import { z, rpc, createRouter } from "@ws-kit/zod";

// Define RPC schema - binds request to response type
const Ping = rpc("PING", { text: z.string() }, "PONG", { reply: z.string() });
const Query = rpc("QUERY", { id: z.string() }, "RESULT", { data: z.string() });

// With Valibot
import { v, rpc } from "@ws-kit/valibot";
const Ping = rpc("PING", { text: v.string() }, "PONG", { reply: v.string() });

The client auto-detects the response type from the RPC schema, eliminating the need to specify it separately on every request.

RPC Handlers

Register RPC handlers with router.rpc() to use request/response pattern with ctx.reply() and ctx.progress():

import { z, rpc, createRouter } from "@ws-kit/zod";

const GetUser = rpc("GET_USER", { userId: z.string() }, "USER_DATA", {
  name: z.string(),
  email: z.string(),
});

const router = createRouter();

router.rpc(GetUser, (ctx) => {
  const { userId } = ctx.payload;

  // Send terminal response (one-shot)
  ctx.reply({ name: "Alice", email: "[email protected]" });
});

For streaming responses, use ctx.progress() for non-terminal updates before the final ctx.reply():

const DownloadFile = rpc(
  "DOWNLOAD_FILE",
  { fileId: z.string() },
  "FILE_CHUNK",
  { chunk: z.string(), finished: z.boolean() },
);

router.rpc(DownloadFile, (ctx) => {
  const { fileId } = ctx.payload;

  // Send progress updates (non-terminal)
  ctx.progress({ chunk: "data...", finished: false });
  ctx.progress({ chunk: "more...", finished: false });

  // Send terminal response (final)
  ctx.reply({ chunk: "end", finished: true });
});

Fire-and-forget vs RPC:

  • router.on(Message, handler) — Use ctx.send() for fire-and-forget messages
  • router.rpc(RpcSchema, handler) — Use ctx.reply() (terminal) and ctx.progress() (streaming) for request/response

Handlers and Routing

Register handlers with full type safety. The context includes schema-typed payloads, connection data, and lifecycle hooks:

import { z, message, createRouter } from "@ws-kit/zod";
import { JoinRoom, UserJoined, SendMessage, UserLeft } from "./schema";

type ConnectionData = {
  userId?: string;
  roomId?: string;
};

const router = createRouter<ConnectionData>();

// Handle new connections
router.onOpen((ctx) => {
  console.log(`Client connected: ${ctx.ws.data.userId}`);
});

// Handle specific message types (fully typed!)
router.on(JoinRoom, (ctx) => {
  const { roomId } = ctx.payload; // ✅ Fully typed from schema
  const userId = ctx.ws.data?.userId;

  // Update connection data
  ctx.assignData({ roomId });

  // Subscribe to room broadcasts
  ctx.subscribe(roomId);

  console.log(`User ${userId} joined room: ${roomId}`);
  console.log(`Message received at: ${ctx.receivedAt}`);

  // Send confirmation (type-safe!)
  ctx.send(UserJoined, { roomId, userId: userId || "anonymous" });
});

router.on(SendMessage, (ctx) => {
  const { text } = ctx.payload;
  const userId = ctx.ws.data?.userId;
  const roomId = ctx.ws.data?.roomId;

  console.log(`[${roomId}] ${userId}: ${text}`);

  // Broadcast to room subscribers (type-safe!)
  router.publish(roomId, SendMessage, { text, userId: userId || "anonymous" });
});

// Handle disconnections
router.onClose((ctx) => {
  const userId = ctx.ws.data?.userId;
  const roomId = ctx.ws.data?.roomId;

  if (roomId) {
    ctx.unsubscribe(roomId);
    // Notify others
    router.publish(roomId, UserLeft, { userId: userId || "anonymous" });
  }
  console.log(`Disconnected: ${userId}`);
});

Context Fields:

  • ctx.payload — Typed payload from schema (✅ fully typed!)
  • ctx.ws.data — Connection data (type-narrowed from <TData>)
  • ctx.type — Message type literal (e.g., "JOIN_ROOM")
  • ctx.meta — Client metadata (correlationId, timestamp)
  • ctx.receivedAt — Server receive timestamp
  • ctx.send() — Type-safe send to this client only
  • ctx.assignData() — Type-safe partial data updates
  • ctx.subscribe() / ctx.unsubscribe() — Topic management
  • ctx.error(code, message?, details?, options?) — Send type-safe error with optional retry hints

Broadcasting and Subscriptions

Broadcasting messages to multiple clients is type-safe with schema validation:

import { z, message, createRouter } from "@ws-kit/zod";

const RoomUpdate = message("ROOM_UPDATE", {
  roomId: z.string(),
  users: z.number(),
  message: z.string(),
});

const router = createRouter<{ roomId?: string }>();

router.on(JoinRoom, (ctx) => {
  const { roomId } = ctx.payload;

  // Subscribe to room updates
  ctx.subscribe(roomId);
  ctx.assignData({ roomId });

  console.log(`User joined: ${roomId}`);

  // Broadcast to all room subscribers (type-safe!)
  router.publish(roomId, RoomUpdate, {
    roomId,
    users: 5,
    message: "A user has joined",
  });
});

router.on(SendMessage, (ctx) => {
  const roomId = ctx.ws.data?.roomId;

  // Broadcast message to room (fully typed, no JSON.stringify needed!)
  router.publish(roomId, RoomUpdate, {
    roomId,
    users: 5,
    message: ctx.payload.text,
  });
});

router.onClose((ctx) => {
  const roomId = ctx.ws.data?.roomId;
  if (roomId) {
    ctx.unsubscribe(roomId);
    router.publish(roomId, RoomUpdate, {
      roomId,
      users: 4,
      message: "A user has left",
    });
  }
});

Broadcasting API:

  • router.publish(scope, schema, payload) — Type-safe broadcast to all subscribers on a scope
  • ctx.subscribe(topic) — Subscribe connection to a topic (adapter-dependent)
import { z, message, createRouter } from "@ws-kit/zod";

type AppData = { userId?: string; roomId?: string };
const router = createRouter<AppData>();

const JoinRoom = message("JOIN_ROOM", { roomId: z.string() });
const UserJoined = message("USER_JOINED", {
  roomId: z.string(),
  userId: z.string(),
});
const SendMessage = message("SEND_MESSAGE", {
  roomId: z.string(),
  message: z.string(),
});
const NewMessage = message("NEW_MESSAGE", {
  roomId: z.string(),
  userId: z.string(),
  message: z.string(),
});
const UserLeft = message("USER_LEFT", { userId: z.string() });

router.on(JoinRoom, (ctx) => {
  const { roomId } = ctx.payload;
  const userId = ctx.ws.data?.userId || "anonymous";

  // Store room ID and subscribe to topic
  ctx.assignData({ roomId });
  ctx.subscribe(roomId);

  // Send confirmation back
  ctx.send(UserJoined, { roomId, userId });

  // Broadcast to room subscribers with schema validation
  ctx.publish(roomId, UserJoined, { roomId, userId });
});

router.on(SendMessage, (ctx) => {
  const { roomId, message: msg } = ctx.payload;
  const userId = ctx.ws.data?.userId || "anonymous";

  console.log(`Message in room ${roomId} from ${userId}: ${msg}`);

  // Broadcast the message to all room subscribers
  ctx.publish(roomId, NewMessage, { roomId, userId, message: msg });
});

router.onClose((ctx) => {
  const userId = ctx.ws.data?.userId || "anonymous";
  const roomId = ctx.ws.data?.roomId;

  if (roomId) {
    ctx.unsubscribe(roomId);
    // Notify others in the room
    router.publish(roomId, UserLeft, { userId });
  }
});

The publish() function ensures that all broadcast messages are validated against their schemas before being sent, providing the same type safety for broadcasts that you get with direct messaging.

Error handling and sending error messages

Effective error handling is crucial for maintaining robust WebSocket connections. WS-Kit provides built-in error response support with standardized error codes and automatic retry inference for clients.

Error handling with ctx.error()

Use ctx.error() to send type-safe error responses with optional retry hints:

import { z, message, createRouter } from "@ws-kit/zod";

type AppData = { userId?: string };
const router = createRouter<AppData>();

const JoinRoom = message("JOIN_ROOM", { roomId: z.string() });

router.on(JoinRoom, async (ctx) => {
  const { roomId } = ctx.payload;

  // Check if room exists
  const roomExists = await checkRoomExists(roomId);
  if (!roomExists) {
    // Send non-retryable error with context
    ctx.error("NOT_FOUND", `Room ${roomId} does not exist`, { roomId });
    return;
  }

  // Continue with normal flow
  ctx.assignData({ roomId });
  ctx.subscribe(roomId);
});

For transient errors, include a backoff hint:

router.on(SomeMessage, async (ctx) => {
  try {
    const result = await getDataWithQuota();
    // ...
  } catch (error) {
    if (isRateLimited(error)) {
      // Send retryable error with backoff hint
      ctx.error("RESOURCE_EXHAUSTED", "Rate limit exceeded", undefined, {
        retryable: true,
        retryAfterMs: 1000, // Client should wait 1s before retry
      });
    } else {
      ctx.error("INTERNAL", "Server error");
    }
  }
});

Standard error codes

The standard error codes (13 codes, aligned with gRPC) are automatically inferred as retryable or non-retryable:

Terminal errors (non-retryable):

  • UNAUTHENTICATED — Authentication failed
  • PERMISSION_DENIED — Authenticated but lacks rights
  • INVALID_ARGUMENT — Invalid payload or schema mismatch
  • FAILED_PRECONDITION — Operation preconditions not met
  • NOT_FOUND — Resource not found
  • ALREADY_EXISTS — Resource already exists
  • UNIMPLEMENTED — Feature not implemented
  • CANCELLED — Request cancelled by client

Transient errors (retryable with backoff):

  • DEADLINE_EXCEEDED — Request deadline exceeded
  • RESOURCE_EXHAUSTED — Rate limit, backpressure, or quota exceeded
  • UNAVAILABLE — Service temporarily unavailable
  • ABORTED — Concurrency conflict or operation aborted

Mixed (app-specific):

  • INTERNAL — Unexpected server error (retryability determined by app)

Clients automatically infer retry behavior from the error code. Use retryAfterMs to provide backoff hints for transient errors, or override retryable for specific cases.

See ADR-015 for the complete error code taxonomy and docs/specs/error-handling.md for retry semantics.

Custom error handling

You can add error handling middleware or lifecycle hooks:

// Error handling in connection setup
router.onOpen((ctx) => {
  try {
    console.log(`Client ${ctx.ws.data?.clientId} connected`);
  } catch (error) {
    console.error("Error in connection setup:", error);
    ctx.error("INTERNAL", "Failed to set up connection");
  }
});

// Error handling with middleware
router.use((ctx, next) => {
  try {
    return next();
  } catch (error) {
    ctx.error("INTERNAL", "Request failed");
  }
});

// Error handling in message handlers
const AuthenticateUser = message("AUTH", { token: z.string() });
router.on(AuthenticateUser, (ctx) => {
  try {
    const { token } = ctx.payload;
    const user = validateToken(token);

    if (!user) {
      ctx.error("UNAUTHENTICATED", "Invalid authentication token");
      return;
    }

    // Use assignData for type-safe connection data updates
    ctx.assignData({ userId: user.id, userRole: user.role });
  } catch (error) {
    ctx.error("INTERNAL", "Authentication process failed");
  }
});

Rate Limiting

Protect your WebSocket server from abuse with atomic, distributed rate limiting. WS-Kit provides an adapter-first rate limiting system that works across single-instance and multi-pod deployments.

Quick Start (Single-Instance)

For development or single-instance deployments, use the in-memory adapter:

import { rateLimit, keyPerUserPerType } from "@ws-kit/middleware";
import { memoryRateLimiter } from "@ws-kit/adapters/memory";

const limiter = rateLimit({
  limiter: memoryRateLimiter({
    capacity: 200, // Max 200 tokens per bucket
    tokensPerSecond: 100, // Refill at 100 tokens/second
  }),
  key: keyPerUserPerType, // Per-user per-message-type buckets (recommended)
});

const router = createRouter<AppData>();
router.use(limiter); // Apply to all messages

Multi-Pod Deployments (Redis)

For distributed deployments, coordinate via Redis:

import { rateLimit, keyPerUserPerType } from "@ws-kit/middleware";
import { redisRateLimiter } from "@ws-kit/adapters/redis";
import { createClient } from "redis";

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

const limiter = rateLimit({
  limiter: redisRateLimiter(redisClient, {
    capacity: 200,
    tokensPerSecond: 100,
  }),
  key: keyPerUserPerType,
});

router.use(limiter);

Cloudflare Workers (Durable Objects)

For Cloudflare Workers, use Durable Objects for coordination:

import { rateLimit, keyPerUserPerType } from "@ws-kit/middleware";
import { durableObjectRateLimiter } from "@ws-kit/adapters/cloudflare-do";

const limiter = rateLimit({
  limiter: durableObjectRateLimiter(env.RATE_LIMITER, {
    capacity: 200,
    tokensPerSecond: 100,
  }),
  key: keyPerUserPerType,
});

router.use(limiter);

Key Functions

Three built-in key functions provide different isolation strategies:

  • keyPerUserPerType (recommended) — One bucket per (user, message type). Prevents one operation from starving others.
  • keyPerUserOrIpPerType — Per-user for authenticated traffic, IP fallback for anonymous (requires router integration for IP access).
  • perUserKey — Simpler per-user bucket. Use cost() to weight operations within a shared budget.

Create custom key functions for other strategies:

const limiter = rateLimit({
  limiter: memoryRateLimiter({ capacity: 100, tokensPerSecond: 50 }),
  key: (ctx) => `${ctx.ws.data?.userId}:${ctx.type}`, // Custom keying
  cost: (ctx) => (ctx.type === "ExpensiveOp" ? 10 : 1),
});

Observability

Rate limit violations are reported via the onLimitExceeded hook:

serve(router, {
  port: 3000,
  onLimitExceeded(info) {
    if (info.type === "rate") {
      console.warn("rate_limited", {
        clientId: info.clientId,
        observed: info.observed, // Attempted cost
        limit: info.limit, // Bucket capacity
        retryAfterMs: info.retryAfterMs,
      });
      metrics.increment("rate_limit.exceeded");
    }
  },
});

For complete documentation, see docs/proposals/rate-limiting.md and docs/guides/rate-limiting.md.

How to compose routes

Organize code by splitting handlers into separate routers, then merge them into a main router using the merge() method:

import { createRouter } from "@ws-kit/zod";
import { chatRoutes } from "./chat";
import { notificationRoutes } from "./notification";

type AppData = { userId?: string };

// Create main router
const mainRouter = createRouter<AppData>();

// Compose with sub-routers
mainRouter.merge(chatRoutes).merge(notificationRoutes);

Where chatRoutes and notificationRoutes are separate routers created with createRouter<AppData>() in their own files. The merge() method combines handlers, lifecycle hooks, and middleware from the composed routers.

Browser Client

Type-safe browser WebSocket client with automatic reconnection, authentication, and request/response patterns — using the same validator and message definitions:

import { rpc, message, wsClient } from "@ws-kit/client/zod";

// Define message schemas
const Hello = rpc("HELLO", { name: z.string() }, "HELLO_OK", {
  text: z.string(),
});
const ServerBroadcast = message("BROADCAST", { data: z.string() });

// Create type-safe client with authentication
const client = wsClient({
  url: "wss://api.example.com/ws",
  auth: {
    getToken: () => localStorage.getItem("access_token"),
  },
});

await client.connect();

// Send fire-and-forget message
client.send(Hello, { name: "Anna" });

// Listen for server broadcasts with full type inference
client.on(ServerBroadcast, (msg) => {
  // ✅ msg.payload.data is typed as string
  console.log("Server broadcast:", msg.payload.data);
});

// Request/response with auto-detected response schema (modern RPC-style)
try {
  const reply = await client.request(
    Hello,
    { name: "Bob" },
    {
      timeoutMs: 5000,
    },
  );
  // ✅ reply.payload.text is fully typed from RPC schema
  console.log("Server replied:", reply.payload.text);
} catch (err) {
  console.error("Request failed:", err);
}

// Graceful disconnect
await client.disconnect();

You can also use explicit response schemas for backward compatibility (traditional style):

// Traditional: client.request(schema, payload, responseSchema, options)
const reply = await client.request(Hello, { name: "Bob" }, HelloOk, {
  timeoutMs: 5000,
});

Client Features:

  • Auto-reconnection with exponential backoff
  • Configurable offline message queueing
  • Request/response pattern with timeouts
  • Built-in auth (query param or protocol header)
  • Full TypeScript type inference from schemas

See the Client Documentation for complete API reference and advanced usage.

Breaking Changes & Migration

Error Message Parameter is Optional

The ctx.error() method now has an optional message parameter and supports retry semantics:

// Old signature (still works - backward compatible)
ctx.error("NOT_FOUND", "Resource not found", { resourceId });

// New signature with retry hints
ctx.error("RESOURCE_EXHAUSTED", undefined, undefined, {
  retryable: true,
  retryAfterMs: 1000,
});

The wire format for errors now includes optional retryable and retryAfterMs fields. Clients automatically infer retry behavior from error codes via ERROR_CODE_META.

Validator is Required

The router now requires a validator to be configured. All imports should come from validator packages to ensure the correct validator is set up:

// ✅ Correct: Validator is included
import { createRouter } from "@ws-kit/zod";
const router = createRouter();

// ❌ Incorrect: Will throw if no validator is set
import { WebSocketRouter } from "@ws-kit/core";
const router = new WebSocketRouter(); // ← Error: validator is required

Migration: Always import createRouter() from @ws-kit/zod or @ws-kit/valibot, not from @ws-kit/core.

Heartbeat is Now Opt-In

Heartbeat is no longer enabled by default. Enable it explicitly if you need client liveness detection:

import { createRouter } from "@ws-kit/zod";
import { serve } from "@ws-kit/bun";

const router = createRouter();

serve(router, {
  port: 3000,
  heartbeat: {
    intervalMs: 30_000, // Ping every 30s (default)
    timeoutMs: 5_000, // Wait 5s for pong (default)
    onStaleConnection(clientId, ws) {
      console.log(`Connection ${clientId} is stale, closing...`);
      ws.close();
    },
  },
});

Migration: Add heartbeat config to serve() options if you previously relied on default heartbeat behavior.

PubSub is Lazily Initialized

PubSub (for ctx.publish() and subscriptions) is now created only on first use. Apps without broadcasting incur zero overhead.

Migration: No action needed. Broadcasting works the same way; initialization is just deferred.

Design & Architecture

See Architectural Decision Records for the core design decisions that shaped ws-kit, including type safety patterns, platform adapters, and composability.

Support

Questions or issues? Join us on Discord.

Backers

              

License

This project is licensed under the MIT License. See the LICENSE file for details.