Client Setup
The ws-kit client provides a type-safe browser WebSocket client that reuses the same message schemas as your server.
Installation
Choose your validator and install the client:
# With Zod (recommended for familiar APIs)
bun add @ws-kit/client @ws-kit/zod zod
# With Valibot (lighter bundles)
bun add @ws-kit/client @ws-kit/valibot valibotQuick Start
1. Share Schemas
Define message schemas once, import in both client and server:
// shared/schemas.ts
import { z, message } from "@ws-kit/zod";
export const Hello = message("HELLO", { name: z.string() });
export const HelloReply = message("HELLO_REPLY", { greeting: z.string() });
export const Broadcast = message("BROADCAST", { message: z.string() });2. Create the Client
// client.ts
import { wsClient } from "@ws-kit/client/zod";
import { Hello, HelloReply, Broadcast } from "./shared/schemas";
const client = wsClient({
url: "wss://api.example.com/ws",
});
// Connect
await client.connect();
// Send a message (fire-and-forget)
client.send(Hello, { name: "Alice" });
// Returns true if sent/queued, false if dropped
// Listen for replies
client.on(HelloReply, (msg) => {
// ✅ msg.payload.greeting is typed as string
console.log(msg.payload.greeting);
});
// Listen for broadcasts
client.on(Broadcast, (msg) => {
console.log(`Broadcast: ${msg.payload.message}`);
});
// Gracefully disconnect
await client.close();3. Use Schemas on Server
// server.ts
import { createRouter } from "@ws-kit/zod";
import { serve } from "@ws-kit/bun";
import { Hello, HelloReply, Broadcast } from "./shared/schemas";
const router = createRouter();
router.on(Hello, (ctx) => {
// ✅ ctx.payload.name is typed as string
ctx.send(HelloReply, {
greeting: `Hello, ${ctx.payload.name}!`,
});
});
// Broadcast to all subscribed clients
router.publish("all", Broadcast, {
message: "Server broadcast",
});
serve(router, { port: 3000 });Client API
Connection Management
const client = wsClient({ url: "wss://api.example.com/ws" });
// Connect
await client.connect();
// Check connection state
if (client.isConnected) {
console.log("Connected!");
}
// Listen to state changes
client.onState((state) => {
console.log(`State: ${state}`);
// States: "closed", "connecting", "open", "closing", "reconnecting"
});
// Wait for connection to open
await client.onceOpen();
// Close
await client.close();Sending Messages
Fire-and-forget (no response expected):
client.send(HelloMessage, { name: "Bob" });
// Messages without payload
client.send(PingMessage);Request/Response (RPC with auto-detected response):
import { z, rpc, message } from "@ws-kit/zod";
// Define RPC schema - binds request to response
const Hello = rpc("HELLO", { name: z.string() }, "HELLO_REPLY", {
greeting: z.string(),
});
// Response schema auto-detected from RPC
const reply = await client.request(
Hello,
{ name: "Alice" },
{
timeoutMs: 5000,
},
);
console.log(reply.payload.greeting);Request/Response (with explicit response schema):
const reply = await client.request(
HelloMessage,
{ name: "Alice" },
HelloReplyMessage,
{
timeoutMs: 5000,
},
);
console.log(reply.payload.greeting);Receiving Messages
Register handlers:
client.on(BroadcastMessage, (msg) => {
// ✅ msg fully typed
console.log(`Message: ${msg.payload.text}`);
});
// Remove handler (calling returned function)
const unsubscribe = client.on(SomeMessage, handler);
unsubscribe();Handle unknown messages:
client.onUnhandled((msg) => {
console.log(`Unknown message type: ${msg.type}`);
});Handle errors:
client.onError((error, context) => {
console.error(`Error (${context.type}): ${error.message}`);
// Types: "parse", "validation", "overflow", "unknown"
});Authentication
Query Parameter
const client = wsClient({
url: "wss://api.example.com/ws",
auth: {
getToken: async () => localStorage.getItem("token"),
attach: "query", // default
queryParam: "access_token", // default parameter name
},
});
// Token is sent as ?access_token=<value>WebSocket Protocol (Sec-WebSocket-Protocol Header)
const client = wsClient({
url: "wss://api.example.com/ws",
auth: {
getToken: async () => localStorage.getItem("token"),
attach: "protocol", // Use WebSocket subprotocol for auth
protocolPrefix: "bearer.", // default prefix
protocolPosition: "append", // default: append after user protocols
},
});
// Token is sent via Sec-WebSocket-Protocol header as "bearer.<token>"Server-Side Validation
import { serve } from "@ws-kit/bun";
serve(router, {
authenticate(req) {
// Option 1: Get token from query parameter
const url = new URL(req.url);
const tokenFromQuery = url.searchParams.get("access_token");
// Option 2: Get token from Sec-WebSocket-Protocol header
const protocols =
req.headers.get("sec-websocket-protocol")?.split(",") || [];
const tokenFromProtocol = protocols
.find((p) => p.trim().startsWith("bearer."))
?.replace("bearer.", "");
const token = tokenFromQuery || tokenFromProtocol;
if (!token) {
return undefined; // Connection rejected
}
try {
const user = verifyToken(token);
return { userId: user.id, username: user.username };
} catch (err) {
return undefined; // Connection rejected
}
},
});Message Queueing
Messages are automatically queued while connecting or offline:
const client = wsClient({
url: "wss://api.example.com/ws",
queue: "drop-newest", // Queue mode: "drop-newest" (default), "drop-oldest", or "off"
queueSize: 50, // Max queued messages (default: 50)
});
// This will be queued if not connected yet
client.send(SomeMessage, { text: "hello" });
await client.connect();
// Queued messages are sent automaticallyAuto-Reconnection
The client automatically reconnects with exponential backoff:
const client = wsClient({
url: "wss://api.example.com/ws",
reconnect: {
enabled: true, // default
initialDelayMs: 300, // default: Start with 300ms
maxDelayMs: 10_000, // default: Cap at 10 seconds
maxAttempts: Infinity, // default: Retry forever
jitter: "full", // default: Full jitter to prevent thundering herd
},
});
await client.connect();
// Reconnects automatically on failureDisable reconnection if needed:
const client = wsClient({
url: "wss://api.example.com/ws",
reconnect: {
enabled: false,
},
});Error Handling
RPC Errors (ServerError)
When using request(), server errors are thrown as ServerError:
import { ServerError, TimeoutError, ValidationError } from "@ws-kit/client/zod";
try {
const reply = await client.request(Hello, { name: "Alice" }, HelloReply, {
timeoutMs: 5000,
});
console.log(reply.payload.greeting);
} catch (err) {
if (err instanceof ServerError) {
// Server sent ERROR message with standard error code
if (err.code === "UNAUTHENTICATED") {
console.log("Authentication failed");
} else if (err.code === "RESOURCE_EXHAUSTED") {
console.log("Rate limited");
}
} else if (err instanceof TimeoutError) {
console.log("Request timed out");
} else if (err instanceof ValidationError) {
console.log("Invalid message");
}
}Handling Validation Errors
client.onError((error, context) => {
if (context.type === "validation") {
console.log("Invalid message received from server");
}
});Import Patterns
Always use the typed client package for your validator:
// ✅ CORRECT: Import everything from typed client package
import { z, message, rpc, wsClient } from "@ws-kit/client/zod";
// Also correct: Import schemas from validator package
import { z, message, rpc } from "@ws-kit/zod";
import { wsClient } from "@ws-kit/client/zod";
// ❌ AVOID: Mixing validator instances
import { z } from "zod"; // Different Zod instance!
import { message } from "@ws-kit/zod";
// May cause type mismatchesAdvanced: Type-Safe Schemas
Share schemas between client and server for full type safety:
// shared/schemas.ts
import { z, message } from "@ws-kit/zod";
export const Hello = message("HELLO", { name: z.string() });
export const HelloReply = message("HELLO_REPLY", { greeting: z.string() });
// server.ts
import { createRouter } from "@ws-kit/zod";
import { Hello, HelloReply } from "./shared/schemas";
const router = createRouter();
router.on(Hello, (ctx) => {
ctx.send(HelloReply, { greeting: `Hi ${ctx.payload.name}!` });
});
// client.ts
import { wsClient } from "@ws-kit/client/zod";
import { Hello, HelloReply } from "./shared/schemas";
const client = wsClient({ url: "wss://api.example.com/ws" });
// Full type inference from shared schemas
client.on(HelloReply, (msg) => {
console.log(msg.payload.greeting); // ✅ Fully typed as string
});
client.send(Hello, { name: "Alice" }); // ✅ Payload typed from schemaValibot Alternative
Use @ws-kit/client/valibot for Valibot schemas:
bun add @ws-kit/client @ws-kit/valibot valibotThe API is identical:
import { v, message } from "@ws-kit/valibot";
import { wsClient } from "@ws-kit/client/valibot";
const client = wsClient({ url: "wss://api.example.com/ws" });
// Everything else is the same!See Also
@ws-kit/zod— Server-side Zod adapter@ws-kit/valibot— Server-side Valibot adapterdocs/examples.md— Real-world example code