https://github.com/kriasoft/ws-kit
Type-safe WebSocket message routing & RPC for Bun, Cloudflare, and browser runtimes.
https://github.com/kriasoft/ws-kit
bun hono honojs http pubsub real-time schema server socket socket-io web websocket websockets ws zod zod-validation
Last synced: 26 days ago
JSON representation
Type-safe WebSocket message routing & RPC for Bun, Cloudflare, and browser runtimes.
- Host: GitHub
- URL: https://github.com/kriasoft/ws-kit
- Owner: kriasoft
- License: mit
- Created: 2025-04-28T01:52:47.000Z (7 months ago)
- Default Branch: main
- Last Pushed: 2025-10-31T01:24:04.000Z (28 days ago)
- Last Synced: 2025-10-31T01:24:05.827Z (28 days ago)
- Topics: bun, hono, honojs, http, pubsub, real-time, schema, server, socket, socket-io, web, websocket, websockets, ws, zod, zod-validation
- Language: TypeScript
- Homepage: https://kriasoft.com/bun-ws-router/
- Size: 2.22 MB
- Stars: 25
- Watchers: 3
- Forks: 1
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Contributing: .github/CONTRIBUTING.md
- Funding: .github/FUNDING.yml
- License: LICENSE
- Code of conduct: .github/CODE_OF_CONDUCT.md
- Security: .github/SECURITY.md
Awesome Lists containing this project
README
# WS-Kit — Type-Safe WebSocket Router
[](https://www.npmjs.com/package/@ws-kit/zod)
[](https://www.npmjs.com/package/@ws-kit/zod)
[](https://github.com/kriasoft/ws-kit/actions)
[](https://discord.gg/aW29wXyb7w)
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:
```bash
# 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:
```ts
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();
// 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:
```ts
// 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:
```ts
// ✅ 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:
```ts
type CustomData = { feature: string; version: number };
const featureRouter = createRouter();
```
### 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:
```ts
// 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):**
```ts
import { serve } from "@ws-kit/bun";
import { createRouter } from "@ws-kit/zod";
const router = createRouter();
serve(router, { port: 3000 });
```
**Low-level (advanced control):**
```ts
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:**
```ts
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:
```ts
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();
// 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:
```ts
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:
```ts
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()`:
```ts
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: "alice@example.com" });
});
```
For streaming responses, use `ctx.progress()` for non-terminal updates before the final `ctx.reply()`:
```ts
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:
```ts
import { z, message, createRouter } from "@ws-kit/zod";
import { JoinRoom, UserJoined, SendMessage, UserLeft } from "./schema";
type ConnectionData = {
userId?: string;
roomId?: string;
};
const router = createRouter();
// 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 ``)
- `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()` — Send type-safe error messages
## Broadcasting and Subscriptions
Broadcasting messages to multiple clients is type-safe with schema validation:
```ts
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)
```ts
import { z, message, createRouter } from "@ws-kit/zod";
type AppData = { userId?: string; roomId?: string };
const router = createRouter();
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.
### Error handling with ctx.error()
Use `ctx.error()` to send type-safe error responses:
```ts
import { z, message, createRouter } from "@ws-kit/zod";
type AppData = { userId?: string };
const router = createRouter();
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 error with standardized code
ctx.error("NOT_FOUND", `Room ${roomId} does not exist`, { roomId });
return;
}
// Continue with normal flow
ctx.assignData({ roomId });
ctx.ws.subscribe(roomId);
});
```
### Standard error codes
The standard error codes (aligned with gRPC) are:
**Terminal errors (don't retry):**
- `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
- `ABORTED` — Operation aborted
**Transient errors (retry with backoff):**
- `DEADLINE_EXCEEDED` — Request deadline exceeded
- `RESOURCE_EXHAUSTED` — Rate limit, backpressure, or quota exceeded
- `UNAVAILABLE` — Service temporarily unavailable
**Server & evolution:**
- `UNIMPLEMENTED` — Feature not implemented
- `INTERNAL` — Server error
- `CANCELLED` — Request cancelled by client
See [ADR-015](docs/adr/015-error-handling.md) for the complete error code taxonomy.
### Custom error handling
You can add error handling middleware or lifecycle hooks:
```ts
// 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");
}
});
```
## How to compose routes
Organize code by splitting handlers into separate routers, then merge them into a main router using the `merge()` method:
```ts
import { createRouter } from "@ws-kit/zod";
import { chatRoutes } from "./chat";
import { notificationRoutes } from "./notification";
type AppData = { userId?: string };
// Create main router
const mainRouter = createRouter();
// Compose with sub-routers
mainRouter.merge(chatRoutes).merge(notificationRoutes);
```
Where `chatRoutes` and `notificationRoutes` are separate routers created with `createRouter()` 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:
```ts
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):
```ts
// 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](./docs/specs/client.md) for complete API reference and advanced usage.
## Breaking Changes & Migration
### 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:
```ts
// ✅ 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:
```ts
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](./docs/adr/) 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](https://discord.gg/aW29wXyb7w).
## Backers
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.







