Bun WS Router: Client SDK
Goal: A tiny, type-safe WebSocket client for browsers and Node.js that reuses the same message schemas as the server. Non-goals: State management, persistence, plugin systems, or anything not essential to sending/receiving typed messages.
Section Map
Quick navigation for AI tools:
- #Design-Principles — Core design goals
- #Public-API — Complete API surface (types and methods)
- #Message-Contract — Wire format and correlation
- #Validation--Normalization — Outbound message processing
- #Reconnect--Queueing — Connection state machine and queue behavior
- #Error-Handling — Error types and contract
- #Usage-Examples — Integration patterns
- Server-side routing: See @router.md for server message handling
Design Principles
- Lean & predictable. Minimal API surface, explicit behavior, no hidden magic.
- Type-safe I/O. Same schemas on both ends; strict mode enforced.
- Hard to misuse. Clear defaults; safe fallbacks; bounded queues.
- Testable. Pure helpers, DI for WebSocket factory, deterministic backoff.
Runtime & Packaging
- Runtime: Modern browsers (standard
WebSocket), works under bundlers. - Validator choice: Valibot recommended for browser clients (smaller bundle); Zod acceptable for larger apps.
- Imports: Use export-with-helpers pattern (ADR-007) for canonical imports.
- Typed Client (Primary): ✅ Strongly recommended
- Zod:
import { wsClient } from "@ws-kit/client/zod" - Valibot:
import { wsClient } from "@ws-kit/client/valibot" - Schemas also available from same entry:
import { z, message } from "@ws-kit/client/zod"
- Zod:
- Generic Client (Advanced):
import { wsClient } from "@ws-kit/client"(custom validators only; handlers infer asunknown) - Shared schemas: Portable between client and server; import from typed client packages with export-with-helpers pattern.
- Typed Client (Primary): ✅ Strongly recommended
Public API (Stable v1)
Primary: Use typed clients (@ws-kit/client/zod or @ws-kit/client/valibot) for full type inference and safe defaults. Generic client (@ws-kit/client) available for advanced use with custom validators. See ADR-002 for type override rationale.
// Primary: @ws-kit/client/zod or @ws-kit/client/valibot
// (Typed clients provide full type inference; see below for generic client)
export type ClientOptions = {
url: string | URL;
protocols?: string | string[]; // WebSocket subprotocols (Sec-WebSocket-Protocol header)
reconnect?: {
enabled?: boolean; // default: true
maxAttempts?: number; // default: Infinity
initialDelayMs?: number; // default: 300
maxDelayMs?: number; // default: 10_000
jitter?: "full" | "none"; // default: "full"
};
// Backoff calculation:
// delay = min(maxDelayMs, initialDelayMs × 2^(attempt-1))
// Backoff multiplier fixed at 2 (exponential doubling)
// - Without jitter ("none"): use delay exactly
// - With full jitter ("full"): use random(0, delay)
// With defaults (300ms initial, 10s max, full jitter):
// attempt 1: random(0, 300ms)
// attempt 2: random(0, 600ms)
// attempt 3: random(0, 1200ms)
// attempt 4: random(0, 2400ms)
// attempt 5: random(0, 4800ms)
// attempt 6: random(0, 9600ms)
// attempt 7+: random(0, 10000ms) [capped at maxDelayMs]
// Rationale: Full jitter prevents thundering herd on mass reconnects
queue?: "drop-oldest" | "drop-newest" | "off"; // default: "drop-newest"
// Controls outbound message queueing behavior when state !== "open" (disconnected):
// - "drop-oldest": Queue up to queueSize; evict oldest on overflow
// - "drop-newest": Queue up to queueSize; reject newest on overflow
// - "off": Drop immediately; send() returns false
queueSize?: number; // default: 50 (maximum pending messages while offline)
autoConnect?: boolean; // default: false
// When true, client auto-connects on first send/request if state === "closed" and never connected.
// Connection errors fail fast (reject pending operations).
// Does NOT auto-reconnect from "closed" after manual disconnect.
pendingRequestsLimit?: number; // default: 1000
// Maximum concurrent pending requests. When exceeded, new request() rejects immediately with StateError; existing requests are unaffected.
// Rationale: Prevents unbounded memory growth if server stops replying or timeout is too high.
// Production note: If you hit this limit, add application-level request queueing/throttling.
auth?: {
getToken?: () => string | null | undefined | Promise<string | null | undefined>; // Called once per (re)connect; supports sync or async
attach?: "query" | "protocol"; // default: "query"
queryParam?: string; // default: "access_token" (for query attach)
protocolPrefix?: string; // default: "bearer." (for protocol attach)
// MUST NOT contain spaces/commas (RFC 6455 constraint)
protocolPosition?: "append" | "prepend"; // default: "append" (for protocol attach)
// "append": token after user protocols
// "prepend": token before user protocols
};
wsFactory?: (url: string | URL, protocols?: string | string[]) => WebSocket; // DI for tests
};
### Protocol Merging (Auth + User Protocols) {#protocol-merging}
When `auth.attach === "protocol"` AND `protocols` is provided:
1. Normalize `protocols` to array (scalar → `[value]`, `undefined` → `[]`)
2. Call `getToken()` to retrieve token
3. If token exists (not `null`/`undefined`): generate `"${protocolPrefix}${token}"`
4. Combine based on `protocolPosition`:
- `"append"` (default): `combinedProtocols = [...normalizedUserProtocols, tokenProtocol]`
- `"prepend"`: `combinedProtocols = [tokenProtocol, ...normalizedUserProtocols]`
5. De-duplicate preserving **first occurrence** (insertion order)
6. Filter out empty strings (prevent malformed `Sec-WebSocket-Protocol` header)
7. Pass combined array to WebSocket constructor
**Edge cases:**
| Scenario | Behavior (append) | Behavior (prepend) |
|------------------------------------|-----------------------------------------------|-----------------------------------------------|
| `protocols: undefined`, token exists | WebSocket receives `["bearer.<token>"]` | WebSocket receives `["bearer.<token>"]` |
| User protocol duplicates token | Keep first occurrence only (user's wins) | Keep first occurrence only (token wins) |
| `getToken()` returns `null` | WebSocket receives user protocols only | WebSocket receives user protocols only |
| Invalid `protocolPrefix` | Throw `TypeError` during `connect()` (before WS) | Throw `TypeError` during `connect()` (before WS) |
| Server accepts connection but selects no subprotocol | Client proceeds with no selected protocol (`ws.protocol === ""`) | Client proceeds with no selected protocol (`ws.protocol === ""`) |
**Examples:**
```typescript
// Default: append token after user protocols
wsClient({
url: "wss://api.example.com",
protocols: "chat-v2", // User protocol
auth: {
getToken: () => "abc123",
attach: "protocol", // Generates "bearer.abc123"
protocolPrefix: "bearer.", // default
protocolPosition: "append", // default
},
});
// WebSocket constructor receives: ["chat-v2", "bearer.abc123"]
// Prepend: token before user protocols (some servers require auth first)
wsClient({
url: "wss://api.example.com",
protocols: "chat-v2",
auth: {
getToken: () => "abc123",
attach: "protocol",
protocolPosition: "prepend", // Auth protocol first
},
});
// WebSocket constructor receives: ["bearer.abc123", "chat-v2"]
```
**Validation:** `protocolPrefix` MUST NOT contain spaces or commas (RFC 6455 subprotocol constraint). Client validates before calling `new WebSocket()`:
```typescript
if (!/^[^\s,]+$/.test(protocolPrefix)) {
throw new TypeError(
`Invalid protocolPrefix: "${protocolPrefix}" (must not contain spaces/commas)`
);
}
```
**Security:** WebSocket subprotocols are visible in HTTP headers (plaintext over non-TLS). Use WSS:// (TLS) when transmitting tokens via protocols. Some proxies log headers; prefer short-lived tokens when using protocol auth. For HTTP-only environments, prefer `attach: "query"` with short-lived tokens.
**Server Protocol Selection:**
The server selects ONE protocol from client's list. Client can check `client.protocol` after connection:
- `client.protocol === "bearer.abc123"` → Server selected token protocol
- `client.protocol === "chat-v2"` → Server selected user protocol
- `client.protocol === ""` → Server accepted connection but selected no protocol
**Failure mode:** If app requires specific protocol (e.g., `"chat-v2"`) but server selects different one, close connection with `1002 Protocol Error`:
```typescript
client.onState((state) => {
if (state === "open" && client.protocol !== "chat-v2" && client.protocol !== "") {
client.close({ code: 1002, reason: "Unsupported protocol" });
}
});
```
**Server considerations:** The server MUST be configured to accept the token-bearing protocol. Servers typically select ONE protocol from the client's list. If the server selects the token protocol (e.g., `"bearer.abc123"`), ensure application logic handles both authentication AND functional protocols.
export type ClientState =
| "closed" // No connection; initial state or post-disconnect
| "connecting" // Connection attempt in progress (maps to native WebSocket CONNECTING)
| "open" // WebSocket connected, messages flow (maps to native WebSocket OPEN)
| "closing" // Graceful disconnect initiated (maps to native WebSocket CLOSING)
| "reconnecting"; // Waiting during backoff delay before retry (not a native WebSocket state)
export interface WebSocketClient {
readonly state: ClientState;
readonly isConnected: boolean; // Sugar for state === "open" (read-only getter)
readonly protocol: string; // Selected subprotocol; "" until connected or if none selected
// Idempotent: CLOSED → connect; CONNECTING → return in-flight promise; OPEN → resolved promise.
// Called implicitly by send/request when autoConnect: true and state === "closed".
connect(): Promise<void>;
// Graceful close: waits for CLOSING → CLOSED transition; cancels reconnect; pending requests reject
// Fully idempotent: safe to call in any state; resolves immediately if already closed
close(opts?: { code?: number; reason?: string }): Promise<void>;
// State change notifications (fires on every state transition)
onState(cb: (state: ClientState) => void): () => void; // returns unsubscribe
// Sugar: resolves when state becomes "open" (resolves immediately if already open)
onceOpen(): Promise<void>;
// Inbound routing with type-safe validation
// Multiple handlers may be registered for the same schema (execute in registration order)
// Returns unsubscribe function (removes only this handler)
// See @client.md#Multiple-Handlers for multi-handler semantics
on<S extends AnyMessageSchema>(
schema: S,
handler: (msg: InferMessage<S>) => void,
): () => void;
// Fire-and-forget to server (unicast)
// Returns true if sent/enqueued; false if dropped (see @client.md#fire-and-forget-return)
// Payload conditional: use overloads to omit payload param for no-payload schemas
send<S extends AnyMessageSchema>(
schema: S,
payload: InferPayload<S>,
opts?: { meta?: InferMeta<S>; correlationId?: string },
): boolean;
// Request/response with auto-detected response schema (RPC helper)
// Bind request and response schemas with rpc() for cleaner client calls
// Usage: const Ping = rpc("PING", {...}, "PONG", {...});
// await client.request(Ping, {...}, opts?);
// Returns Promise resolving to fully typed reply message
// Rejects on timeout, validation error, connection close, or server error
request<S extends AnyMessageSchema & { response: AnyMessageSchema }>(
schema: S,
payload: InferPayload<S>,
opts?: { timeoutMs?: number; meta?: InferMeta<S>; correlationId?: string; signal?: AbortSignal }, // timeoutMs default: 30000
): Promise<InferMessage<S["response"]>>;
// Request/response with explicit reply schema (backward compatible)
// Note: opts.meta applies to the outbound request, not the reply
// Returns Promise resolving to fully typed reply message
// Rejects on timeout, validation error, connection close, or server error
request<S extends AnyMessageSchema, R extends AnyMessageSchema>(
schema: S,
payload: InferPayload<S>,
reply: R,
opts?: { timeoutMs?: number; meta?: InferMeta<S>; correlationId?: string; signal?: AbortSignal }, // timeoutMs default: 30000
): Promise<InferMessage<R>>;
// Hook for unhandled message types
// Contract: Receives only structurally valid messages whose type has no registered schema; messages for registered types that fail validation are dropped and do not reach onUnhandled()
// Input: AnyInboundMessage (treat as readonly; do not mutate)
onUnhandled(cb: (msg: AnyInboundMessage) => void): () => void;
// Hook for non-fatal internal errors (centralized error reporting)
// Fires for: parse failures, validation failures, queue overflow, invalid inbound messages
// Does NOT fire for: request() rejections (caller handles), handler errors (logged to console.error)
// Use for: centralized logging, error tracking (Sentry/DataDog), debugging
onError(cb: (error: Error, context: { type: "parse" | "validation" | "overflow" | "unknown"; details?: unknown }) => void): () => void;
// Dispatch order:
// - Schema handlers registered via on(schema, handler) execute first
// - onUnhandled() fires only for valid messages with no registered schema
// - Invalid messages (parse/validation failures) trigger onError() then dropped (never reach onUnhandled())
}
export function wsClient(opts: ClientOptions): WebSocketClient;
// Error classes for client-side error handling
export {
ValidationError,
TimeoutError,
ServerError,
ConnectionClosedError,
StateError,
} from "@ws-kit/client";Type Inference: Typed clients (/zod/client, /valibot/client) provide full inference via type overrides (see ADR-002):
// Zod typed client
import { wsClient } from "@ws-kit/client/zod";
const client = wsClient({ url: "wss://api.example.com" });
client.on(HelloOk, (msg) => {
// ✅ msg fully typed: { type: "HELLO_OK", meta: MessageMeta, payload: { text: string } }
msg.type; // "HELLO_OK" (literal type)
msg.meta.timestamp; // number | undefined
msg.payload.text; // string (was `unknown` in generic client)
});Payload conditional typing enforced via overloads:
// Define schemas
const Hello = message("HELLO", { name: z.string() });
const Logout = message("LOGOUT"); // No payload
// ✅ Payload required (schema has payload)
client.send(Hello, { name: "Alice" });
// ✅ Payload omitted (schema has no payload)
client.send(Logout);
// ❌ Type error - payload required but missing
client.send(Hello);
// Error: Expected 2-3 arguments, but got 1
// ❌ Type error - payload provided but schema has none
client.send(Logout, {});
// Error: Expected 1-2 arguments, but got 2-3Advanced: Generic Client (Custom Validators Only)
For custom validators not supported by typed clients, the generic client is available but handlers receive unknown:
// Generic client (advanced; custom validators only)
import { wsClient } from "@ws-kit/client";
client.on(HelloOk, (msg) => {
// ⚠️ msg is unknown - requires manual type assertion
const typed = msg as InferMessage<typeof HelloOk>;
});When to use: Only if your validator is not supported by @ws-kit/client/zod or @ws-kit/client/valibot. For standard use, always prefer typed clients.
Multiple Handlers
Multiple handlers MAY be registered for the same schema. Handlers execute in registration order.
const unsubscribe1 = client.on(TestMsg, handler1);
const unsubscribe2 = client.on(TestMsg, handler2);
// Both run when TestMsg arrives: handler1 → handler2
// unsubscribe2() removes only handler2Error handling: If a handler throws, remaining handlers still execute. Errors are logged via console.error.
Removal during dispatch: Unsubscribing a handler during dispatch does NOT affect the current dispatch cycle (stable iteration).
Performance: O(n) iteration where n = handler count per schema (typical n = 1-3; acceptable overhead).
Rationale: Multi-handler pattern is idiomatic for client-side event systems (browser addEventListener model). Enables composability across modules (e.g., logging + UI both listening for same message) without collision footgun.
Request/Response Timeout Semantics
Timeout behavior:
timeoutMsmeasures time from message transmission (flushed on OPEN socket) to response arrival- Default:
30000ms (30 seconds) - If message is buffered (
state !== "open"), timeout does NOT start until buffer is flushed - If connection closes before response, promise rejects with
ConnectionClosedError(timeout cancelled)
Cancellation via AbortSignal:
opts.signalprovides fetch-style cancellation semantics- If
signal.abortedistruebefore send → reject immediately withStateError("Request aborted before dispatch") - If aborted while pending → cancel timeout timer, remove from pending map, reject with
StateError("Request aborted") - AbortSignal cleanup is automatic; no manual unsubscribe needed
Example:
// Basic timeout
client.connect();
client.request(Hello, { name: "Anna" }, HelloOk, { timeoutMs: 5000 });
// ✅ Timeout starts ONLY after connection opens and message is sent
// ❌ Does NOT start counting while buffered during connection attempt
// Cancellation with AbortController
const controller = new AbortController();
const promise = client.request(Hello, { name: "Anna" }, HelloOk, {
signal: controller.signal,
});
// Cancel the request (before or after dispatch)
controller.abort();
// Promise rejects with StateError: "Request aborted"Rationale: Timeout measures network roundtrip time, not buffer wait time. AbortSignal provides composable cancellation (e.g., tying multiple requests to single controller, race conditions, component unmount).
Fire-and-Forget Return Value Semantics
send() return value:
- Returns
truewhen:- Message sent immediately (
state === "open") - Message queued successfully (
state !== "open"ANDqueueis"drop-oldest"or"drop-newest")
- Message sent immediately (
- Returns
falsewhen message is dropped (will not be retried):queue === "off"whilestate !== "open"- Queue overflow with
queue === "drop-newest" - Payload fails schema validation (logs to
console.error)
Example:
// Handle dropped messages
const sent = client.send(ChatMsg, { text: "hello" });
if (!sent) {
console.warn("Message dropped (offline or buffer full)");
// Show UI feedback, don't retry
}
// Validation errors return false (logged to console.error)
const sent2 = client.send(ChatMsg, { text: 123 }); // ❌ Type error caught by TS
if (!sent2) {
// Message invalid, already logged
}Rationale: Boolean return enables fire-and-forget patterns with optional backpressure handling. Validation errors return false to maintain fire-and-forget semantics (never throw).
Extended Meta Usage
When schemas define extended meta fields, provide them via opts.meta:
// Schema with extended meta (required field)
const RoomMsg = message(
"CHAT",
{ text: z.string() },
{ roomId: z.string() }, // Required meta field
);
// ✅ Provide extended meta
client.send(
RoomMsg,
{ text: "hello" },
{
meta: { roomId: "general" },
},
);
// ✅ Works with correlationId
client.request(RoomMsg, { text: "hello" }, RoomMsgOk, {
meta: { roomId: "general" },
correlationId: "req-123",
});
// ❌ Type error - missing required meta field
client.send(RoomMsg, { text: "hello" }); // Compile error: roomId required
// ✅ Optional extended meta
const OptionalMetaMsg = message(
"NOTIFY",
{ text: z.string() },
{ priority: z.enum(["low", "high"]).optional() },
);
client.send(OptionalMetaMsg, { text: "hello" }); // OK - priority is optional
client.send(
OptionalMetaMsg,
{ text: "hello" },
{
meta: { priority: "high" },
},
); // Also OKNormalization: See @client.md#client-normalization for outbound meta merging rules (auto-injection, reserved key stripping).
Type safety: InferMeta<S> enforces schema-defined meta fields at compile time. Required fields cause type errors if omitted; optional fields can be omitted.
Message Processing Order
Inbound message pipeline:
graph TD
A["Raw WebSocket<br/>message"] --> B["JSON.parse()"]
B -->|parse error| C["console.warn()<br/>drop"]
B -->|success| D["Extract type field"]
D -->|missing| C
D -->|found| E["Lookup schema<br/>in registry"]
E -->|found| F["Validate with schema<br/>strict mode"]
E -->|not found| G["Validate<br/>structure only"]
F -->|valid| H["Invoke on handler"]
F -->|invalid| C
G -->|valid| I["Invoke onUnhandled<br/>if registered"]
G -->|invalid| C
H --> J["Done"]
I --> J
C --> J- Receive — WebSocket
onmessageevent fires - Parse — JSON.parse() the raw string
- Extract type — Read
typefield (drop if missing) - Lookup schema — Check internal schema registry
Map<type, schema>built fromon(schema, handler)registrations - Validate & Route:
- Schema found: Validate against schema (strict mode)
- Valid → Invoke all registered
on(schema, handler)callbacks - Invalid → Drop message (console.warn), NEVER reach
onUnhandled()
- Valid → Invoke all registered
- No schema found: Validate structural correctness (
{ type: string, meta?: object, payload?: any })- Structurally valid → Invoke
onUnhandled()(if registered) with raw parsed message - Structurally invalid → Drop message (console.warn)
- Structurally valid → Invoke
- Schema found: Validate against schema (strict mode)
Schema registry:
- Client maintains
Map<type, schema>updated byon(schema, handler)registrations - Multiple handlers may register the same schema (multi-handler support)
- Schema is stored once per type; validation uses stored schema before routing
onUnhandled use cases:
- Graceful degradation — Handle unhandled server messages during version mismatch
- Protocol negotiation — Client knows message types server doesn't yet support
- Debug/logging — Observe unregistered message types in development
Input contract:
- Type:
AnyInboundMessage(structurally valid message with unregistered type) - Treat as readonly — Do not mutate the message object (same guidance as schema handlers)
- Structure:
{ type: string, meta?: object, payload?: any }
Ordering guarantees:
- Schema handlers execute BEFORE
onUnhandled(schema match takes precedence) - Invalid messages NEVER reach
onUnhandled(dropped at validation) onUnhandledreceives only:- Structurally valid messages with unregistered
type(no schema registered) - Messages that pass structural validation:
{ type: string, meta?: object, payload?: any }
- Structurally valid messages with unregistered
- Messages that fail schema validation (registered type, invalid structure) are NEVER passed to
onUnhandled
Message Contract (Client ↔ Server)
- Message structure is identical to server spec (
type,meta, optionalpayload). - Strict schemas: extra/unknown keys MUST fail validation.
- Reserved meta keys (never set by users):
clientId--- server/transport identityreceivedAt--- server ingress timestamp (ms since epoch)
Timestamps
Client auto-injects meta.timestamp = Date.now() on send()/request() if not provided in opts.meta. Server sets receivedAt when message arrives.
When to use which timestamp: See @schema.md#Which-timestamp-to-use for canonical guidance (client UI vs server logic).
Correlation
- If
opts.correlationIdis missing, client MUST generate a unique ID (UUIDv4 viacrypto.randomUUID()). - Servers MUST echo
meta.correlationIdin replies. request()resolves/rejects when an inbound message with matchingmeta.correlationIdarrives:- Type matches
replyschema: Resolve with validated message - Type is
ERROR: Reject withServerError(attach code/payload from error message) - Type mismatches
replyschema (non-error): Reject withValidationError(stop waiting) - Validation fails against
replyschema: Reject withValidationError(malformed reply)
- Type matches
request()also rejects on:- Timeout (
TimeoutError) — no reply with matchingcorrelationIdwithintimeoutMs - Connection closed before response (
ConnectionClosedError)
- Timeout (
Request Dispatch Implementation
Client maintains Map<correlationId, PendingRequest> where:
type PendingRequest = {
expectedType: string;
schema: AnyMessageSchema;
resolve: (msg: any) => void;
reject: (err: Error) => void;
timeoutHandle: number;
};On inbound message with meta.correlationId:
- Lookup pending request by
correlationId - If found:
- Cancel timeout
- If
msg.type === "ERROR": reject withServerError - Else if
msg.type !== expectedType: reject withValidationError("Expected X, got Y") - Else: validate against
schema- Success: resolve with validated message
- Failure: reject with
ValidationError(attach validation issues)
- Remove from pending map
- If NOT found (already settled or unknown): drop silently (no error, no handler invoked)
Duplicate reply behavior:
If multiple inbound messages arrive with the same correlationId, only the first settles the pending promise. Subsequent messages with the same correlationId are ignored (dropped silently) after the entry is removed from the pending map.
Rationale: Protects against server bugs (duplicate replies) and ensures each request() settles exactly once.
Validation & Normalization
Validation
- Outbound: Before buffer or send, client validates against the provided
schema.send()returnsfalseon validation failure (never throws)request()returns a rejected Promise withValidationError(never throws synchronously)- Validation errors logged to
console.errorwith validation issues
- Inbound: Extract
typefield, lookup schema in registration Map, validate.- Unknown/invalid messages are dropped; a diagnostic event is logged via
console.warn(no throws). - Validation uses same strict mode as server (reject unknown keys).
- Unknown/invalid messages are dropped; a diagnostic event is logged via
- Strict mode: The client MUST validate with strict schemas that reject unknown keys in
metaorpayload.
Validation consistency: See @validation.md#normalization-rules for server normalization behavior.
Normalization Rules
Outbound normalization (applied before validation):
Client MUST normalize messages before sending in send() and request():
Strip reserved/managed keys from user meta: Remove
clientId,receivedAt,correlationIdfromopts.meta- Security boundary: prevents spoofing server-only fields (
clientId,receivedAt) - Client-managed field:
correlationIdMUST be provided viaopts.correlationId, notopts.meta(ignored if present)
- Security boundary: prevents spoofing server-only fields (
Merge meta: Combine default fields, sanitized user meta, and correlationId:
typescript// Strip reserved + managed keys from user-provided meta const userMeta = omit(opts?.meta, [ "clientId", "receivedAt", "correlationId", ]); // Build meta deterministically const meta = { timestamp: Date.now(), // Auto-inject (default) ...userMeta, // User-provided extended meta (sanitized) ...(opts?.correlationId && { correlationId: opts.correlationId }), };Auto-generate correlationId: For
request()only, ifopts.correlationIdis absent, generate UUIDv4 viacrypto.randomUUID()
Example normalization:
// User calls send()
client.send(RoomMsg, { text: "hi" }, {
meta: { roomId: "general", timestamp: 123 } // User provides timestamp
});
// After normalization (timestamp NOT overwritten, user value preserved)
{
type: "CHAT",
meta: {
timestamp: 123, // User value preserved
roomId: "general" // User extended meta
},
payload: { text: "hi" }
}
// User tries to spoof reserved keys
client.send(RoomMsg, { text: "hi" }, {
meta: { roomId: "general", clientId: "fake" } // clientId stripped
});
// After normalization (clientId stripped before validation)
{
type: "CHAT",
meta: {
timestamp: Date.now(), // Auto-injected
roomId: "general" // Preserved
// clientId stripped
},
payload: { text: "hi" }
}
// User tries to set correlationId via meta (ignored)
client.send(RoomMsg, { text: "hi" }, {
meta: { roomId: "general", correlationId: "sneaky" }, // correlationId stripped
correlationId: "correct" // Only this is used
});
// After normalization (correlationId from meta ignored)
{
type: "CHAT",
meta: {
timestamp: Date.now(),
roomId: "general",
correlationId: "correct" // Only opts.correlationId used
},
payload: { text: "hi" }
}Inbound Normalization
- Client MUST NOT strip any fields from inbound messages (server already normalized)
- Trust server-provided
clientId/receivedAtif present (though these typically don't appear in client-bound messages)
Timestamp usage
See @schema.md#Which-timestamp-to-use for when to use meta.timestamp (producer time, UI display) vs receivedAt (server logic, authoritative).
Reconnect & Queueing
Connection State Machine
closed → connecting → open → closing → closed
↑__________________________________| (manual reconnect)
closed → reconnecting → connecting (auto-reconnect)- closed: No connection; initial state or post-disconnect
- connecting: Connection attempt in progress (maps to native WebSocket
CONNECTING) - open: WebSocket connected, messages flow (maps to native WebSocket
OPEN) - closing: Graceful disconnect initiated (maps to native WebSocket
CLOSING) - reconnecting: Waiting before retry attempt (only when
reconnect.enabled: true)
Reconnect Behavior
- Reconnect: Exponential backoff (
initialDelayMs...maxDelayMs) with optional full jitter. - Auth refresh: Before each (re)connect, call
getToken()if provided.attach: "query"(default): Append as?${queryParam}=<token>(default param:access_token)attach: "protocol": UseSec-WebSocket-Protocol: ${prefix}<token>(default prefix:bearer.)
Queue Behavior
When to queue: When state !== "open", outbound send()/request() behavior follows queue option.
Modes:
"drop-newest"(default): Queue messages while offline; discard new messages when queue full"drop-oldest": Queue messages while offline; evict oldest message when queue full"off": Drop messages immediately when not connected; no queue
Bounds: queueSize (default: 1000) prevents memory leaks. Only applies to queue modes with buffer.
Overflow logging: Both queue modes log console.warn on overflow (oldest evicted or newest rejected).
Auto-Connect Interaction: When autoConnect: true and first operation triggers connect():
- Auto-connect attempt happens before
queuepolicy check - Connection errors reject with connection error (not
StateError) - After failed auto-connect, subsequent operations follow
queuepolicy (e.g.,queue: "off"→ immediateStateError) - Auto-connect only triggers once per
"closed"state (does NOT retry after failure) send(): Auto-connect failure → returnsfalse(logged toconsole.error); NEVER throwsrequest(): Auto-connect failure → returns rejected Promise; NEVER throws synchronously- If auto-connect succeeds but socket not yet OPEN → apply
queuepolicy
Order of Operations (for request() with autoConnect=true + queue="off"):
request()
├─ state === "closed" && never connected? → YES
├─ Trigger connect() (autoConnect)
├─ connect() fails → Reject with connection error
└─ (queue policy NOT evaluated on first attempt)
request() [second call]
├─ state === "closed" && never connected? → NO (already attempted)
├─ state !== "open" && queue === "off"? → YES
└─ Reject immediately with StateErrorSee @test-requirements.md#L873 for edge case validation.
Return values:
send()returnstrueif sent/queued,falseif discarded/auto-connect failed (see @client.md#fire-and-forget-return)request()behavior whenstate !== "open":queue: "drop-newest"or"drop-oldest": Queues pending request; timeout starts after flush (see @client.md#request-timeout)queue: "off": Rejects immediately withStateError("Cannot send request while disconnected with queue disabled")- Auto-connect failure: Rejects with connection error (never throws synchronously)
Error Handling (Client-Side)
Fire-and-forget errors (send()):
send()never throws- Returns
falseon outbound validation failure (logged toconsole.error) - Returns
falsewhen message is dropped (queue overflow orqueue: "off") - See @client.md#fire-and-forget-return for complete return value semantics
Request/response errors (request()):
request() returns a rejected Promise on:
ValidationError(outbound) --- Invalid payload/meta before sending (client-side validation failure)ValidationError(inbound) --- Reply has wrong type or fails schema validation (server sent malformed/mismatched reply)TimeoutError--- No reply withintimeoutMsServerError--- Server sentERRORmessage with matchingcorrelationIdConnectionClosedError--- Connection closed before reply arrivedStateError--- Request aborted viasignal, attemptedrequest()whilestate !== "open"andqueue: "off", or pending request limit exceeded (new requests rejected; existing requests unaffected)
StateError rejection cases:
request() rejects immediately (returns a rejected Promise) with StateError when:
opts.signal.aborted === truebefore dispatch (message: "Request aborted before dispatch")opts.signalaborted while pending (message: "Request aborted"; timeout cancelled, pending map cleaned)state !== "open"ANDqueue: "off"(cannot send while disconnected with queue disabled)pendingRequestsLimitexceeded (prevents unbounded memory growth; existing pending requests unaffected)
Example:
try {
const controller = new AbortController();
const promise = client.request(Hello, { name: "test" }, HelloOk, {
signal: controller.signal,
});
// Cancel if needed
controller.abort();
await promise;
} catch (err) {
if (err instanceof StateError) {
// Aborted, queue disabled + offline, or pending limit exceeded
}
}Important: StateError is always a Promise rejection, never a synchronous throw.
Error Contract
Synchronous validation (throws TypeError):
Only during setup or preflight validation:
wsClient()--- Invalid options (e.g., illegalprotocolPrefixwith spaces/commas)connect()--- Preflight validation failures (e.g., malformed URL)
Fire-and-forget (send()):
- NEVER throws
- Returns
boolean:trueif sent/queued,falseif dropped/invalid
Promise-based methods:
| Method | Synchronous Throws | Promise Rejection | Notes |
|---|---|---|---|
connect() | ❌ Never | ✅ Connection errors | Idempotent: returns in-flight promise if connecting; resolves immediately if already open |
request() | ❌ Never | ✅ ValidationError, TimeoutError, ServerError, ConnectionClosedError, StateError | StateError only when: (1) aborted, (2) queue: "off" + disconnected + autoConnect didn't trigger, (3) pending limit exceeded |
close() | ❌ Never | ❌ NEVER rejects | Fully idempotent: safe to call in any state (including already "closed"); no StateError possible |
Key guarantees:
connect()is idempotent: returns in-flight promise if already connecting; resolves immediately if already openclose()is fully idempotent: NEVER rejects due to state; safe to call in any state (including already"closed"); mirrors nativeWebSocket#close()ergonomics; noStateErrorwill ever be thrownsend()NEVER throws (returnsfalseon failure)request()NEVER throws synchronously (returns rejected Promise)correlationIdis client-managed: MUST be provided viaopts.correlationId; values inopts.meta.correlationIdare ignored and stripped during normalization
Error Class Structures:
class ValidationError extends Error {
constructor(
message: string,
public readonly issues: Array<{ path: string[]; message: string }>,
) {}
}
class TimeoutError extends Error {
constructor(public readonly timeoutMs: number) {}
}
class ServerError extends Error {
constructor(
message: string,
public readonly code: ErrorCode,
public readonly context?: Record<string, unknown>,
) {}
}
class ConnectionClosedError extends Error {}
class StateError extends Error {}Observability:
- The client may
console.debugconnection transitions andconsole.warnon drops, invalid inbound messages, and queue overflow.
Catching Errors
import {
ValidationError,
TimeoutError,
ServerError,
ConnectionClosedError,
} from "@ws-kit/client";
try {
const reply = await client.request(Hello, { name: "test" }, HelloOk, {
timeoutMs: 5000,
});
console.log("Reply:", reply.payload.text);
} catch (err) {
if (err instanceof TimeoutError) {
console.warn(`Request timed out after ${err.timeoutMs}ms`);
} else if (err instanceof ServerError) {
console.error(`Server error: ${err.code}`, err.context);
} else if (err instanceof ConnectionClosedError) {
console.warn("Connection closed before reply");
} else if (err instanceof ValidationError) {
console.error("Invalid reply:", err.issues);
}
}Centralized Error Reporting with onError()
For non-fatal internal errors (parse failures, validation errors, queue overflow), use the onError() hook:
// Centralized error tracking (Sentry, DataDog, etc.)
client.onError((error, context) => {
switch (context.type) {
case "parse":
console.warn("Invalid JSON from server:", error.message);
Sentry.captureException(error, { tags: { type: "ws-parse" } });
break;
case "validation":
console.warn(
"Message validation failed:",
error.message,
context.details,
);
Sentry.captureException(error, {
tags: { type: "ws-validation" },
extra: context.details,
});
break;
case "overflow":
console.warn("Queue overflow (message dropped):", error.message);
metrics.increment("ws.queue.overflow");
break;
case "unknown":
console.warn("Unknown client error:", error.message, context.details);
Sentry.captureException(error, { tags: { type: "ws-unknown" } });
break;
}
});
// onError does NOT fire for:
// - request() rejections (caller handles with try/catch)
// - Handler errors (logged to console.error automatically)Use cases:
- ✅ Centralized logging across all non-fatal errors
- ✅ Error tracking integration (Sentry, DataDog, LogRocket)
- ✅ Debugging production issues (malformed server messages)
- ✅ Metrics collection (queue overflow frequency)
Not for:
- ❌ Request/response errors → use
try/catchonrequest() - ❌ Handler errors → already logged to
console.error
Security & Safety
- Never attach secrets into
metaorpayloadunless encrypted. - Query-string auth: Tokens in URLs may be logged by browsers, proxies, or servers. Prefer short-lived tokens when using
attach: "query"(default). - Protocol auth: Use
attach: "protocol"to avoid URL logging. Tokens inSec-WebSocket-Protocolheaders are visible in plaintext over non-TLS; always use WSS:// (TLS) for production. - Custom auth: For custom authentication mechanisms (e.g., cookies, header-based auth via server proxy), implement via
wsFactoryor server-side upgrade patterns. - Client ignores any inbound attempt to override reserved meta keys.
Bundle Size
- Client logic (without validator): ~2-3 kB min+gz
- Validator choice significantly impacts total size:
- Valibot: Smaller footprint (recommended for browsers)
- Zod: Larger but acceptable for apps already using Zod
Use tree-shaking and measure with your bundler.
Performance Targets
- Message routing: O(1) type lookup + O(n fields) validation per message
- No deep cloning of messages
- Reconnect backoff: Microtask-scheduled; no timers < 250ms after connection stable
- Memory: Bounded queues (default 1000 messages); drop oldest on overflow
Implementation Details
Inbound Message Routing
Client MUST use Map-based schema lookup (same pattern as server):
- Parse JSON
- Extract
typefield (drop if missing) - Lookup schema in
Map<type, schema>built fromon()registrations - If schema found: validate with schema
- Valid → Invoke all registered handlers for this type
- Invalid → Drop message (console.warn), never reach
onUnhandled()
- If no schema found:
- Validate structural correctness:
{ type: string, meta?: object, payload?: any } - Structurally valid → Invoke
onUnhandled()(if registered) with raw parsed message - Structurally invalid → Drop message (console.warn)
- Validate structural correctness:
Performance: O(1) type lookup + O(n fields) validation per message.
Type Safety: Each on() call provides full type inference for its handler; no union needed.
Testing Hooks
wsFactoryfor dependency injection (FakeWebSocket in tests).- Deterministic backoff when
jitter: "none"for reproducible tests. - Provide helper fakes in
@ws-kit/testing(optional):createFakeWS(),flushBackoff(),tick(ms).
Usage Examples
Sharing Schemas Between Client and Server
Schemas are portable TypeScript values using the export-with-helpers pattern (ADR-007).
Define schemas once in a shared module, import in both client and server:
// shared/schemas.ts (imported by both client and server)
import { z, message } from "@ws-kit/zod"; // Single canonical import source
export const Hello = message("HELLO", { name: z.string() });
export const HelloOk = message("HELLO_OK", { text: z.string() });
export const ChatMessage = message("CHAT", { text: z.string() });
// client.ts
import { wsClient } from "@ws-kit/client/zod"; // Typed client
import { Hello, HelloOk } from "./shared/schemas";
const client = wsClient({ url: "wss://api.example.com/ws" });
await client.connect();
client.send(Hello, { name: "Anna" });
// Full type inference via typed client
client.on(HelloOk, (msg) => {
console.log(msg.payload.text); // Fully typed
});
// server.ts
import { createRouter } from "@ws-kit/zod";
import { Hello, HelloOk } from "./shared/schemas";
const router = createRouter();
router.on(Hello, (ctx) => {
ctx.send(HelloOk, { text: `Hello, ${ctx.payload.name}!` });
});Tree-shaking: Bundlers eliminate server-only code when building client bundles. Schemas are pure data structures with no server dependencies.
1) Basic Client with Typed Request/Response
// client.ts
import { wsClient } from "@ws-kit/client/zod"; // ✅ Typed client
import { Hello, HelloOk } from "./shared/schemas"; // Shared schemas
// Explicit connection (default for production)
const client = wsClient({ url: "wss://example.com/ws" });
await client.connect();
// Fire-and-forget (one-way)
client.send(Hello, { name: "Anna" });
// Listen for inbound messages (fully typed)
client.on(HelloOk, (msg) => {
// ✅ msg.payload fully typed: { text: string }
console.log("Server says:", msg.payload.text);
});
// Request/response (typed reply, auto-timeout, auto-correlationId)
try {
const reply = await client.request(Hello, { name: "Bob" }, HelloOk, {
timeoutMs: 5000, // Default: 30000ms
});
// ✅ reply fully typed: { type: "HELLO_OK", meta: {...}, payload: { text: string } }
console.log("Reply:", reply.payload.text);
} catch (err) {
if (err instanceof TimeoutError) {
console.error("Server did not reply in time");
}
}
// Option: Auto-connection (lazy init on first operation)
const client2 = wsClient({
url: "wss://example.com/ws",
autoConnect: true, // Lazy connect on first send/request
});
client2.send(Hello, { name: "Alice" }); // Auto-connects if closed2) Auth with Token Refresh & Reconnect
// client.ts
import { wsClient, TimeoutError, ServerError } from "@ws-kit/client/valibot"; // ✅ Typed client
import { Hello, HelloOk, Chat } from "./shared/schemas";
const client = wsClient({
url: "wss://api.example.com/ws",
autoConnect: true,
// Reconnect (exponential backoff: 300ms → 600ms → 1.2s → ... → 10s cap)
reconnect: {
enabled: true,
initialDelayMs: 300, // default
maxDelayMs: 10_000, // default (10 seconds)
jitter: "full", // default: randomize within delay
},
// Queue behavior while offline (drop-newest is default)
queue: "drop-newest", // Keep oldest messages, drop newest on overflow
queueSize: 1000, // default
// Auth: refresh token on each (re)connect
auth: {
getToken: () => localStorage.getItem("access_token"), // Called once per (re)connect
attach: "protocol",
protocolPrefix: "bearer.",
},
});
// Connection lifecycle
client.onState((state) => console.debug("Connection:", state));
await client.onceOpen();
// Listen for typed inbound messages
client.on(HelloOk, (msg) => {
// ✅ msg fully typed via Valibot inference
console.log("Server:", msg.payload.text);
});
// Fire-and-forget with extended meta
client.send(Chat, { text: "Hi there!" }, { meta: { roomId: "general" } });
// Request/response with typed reply (auto-correlationId, 30s default timeout)
try {
const reply = await client.request(Hello, { name: "Anna" }, HelloOk, {
timeoutMs: 5000, // Override default 30s
});
// ✅ reply fully typed: { type: "HELLO_OK", meta: {...}, payload: { text: string } }
console.log("Reply:", reply.payload.text);
} catch (err) {
if (err instanceof TimeoutError) {
console.error(`Timed out after ${err.timeoutMs}ms`);
} else if (err instanceof ServerError) {
console.error(`Server error: ${err.code}`, err.context);
}
}
// Request with AbortSignal (cancellable, e.g., component unmount)
const controller = new AbortController();
const replyPromise = client.request(Hello, { name: "Bob" }, HelloOk, {
signal: controller.signal,
timeoutMs: 30000,
});
// Cancel if needed
controller.abort();
try {
const reply = await replyPromise;
console.log("Reply:", reply.payload.text); // ✅ Typed
} catch (err) {
if (err instanceof StateError && err.message.includes("aborted")) {
console.log("Request was cancelled");
}
}
await client.close({ code: 1000, reason: "Done" });3) Testing with a fake WebSocket
// client.test.ts
import { wsClient } from "@ws-kit/client";
import { createFakeWS } from "@ws-kit/testing";
const client = wsClient({
url: "ws://test",
wsFactory: (url) => createFakeWS(url),
reconnect: { enabled: false },
});Auto-Connection Behavior
By default, client requires explicit connect() before sending messages. Opt-in to lazy initialization:
const client = wsClient({
url: "wss://api.example.com",
autoConnect: true, // Auto-connect on first operation
});
// No explicit connect() needed
client.send(Hello, { name: "Anna" }); // Triggers connection if idleSemantics:
- First
send()orrequest()triggersconnect()ifstate === "closed"AND never connected before send(): Connection errors returnfalse(logged); never throwsrequest(): Connection errors reject Promise; never throws synchronously- State observable via
client.stateproperty orclient.isConnectedgetter - Applies
queuepolicy after auto-connection succeeds - Does NOT auto-reconnect from
"closed"after manual close
Sugar: isConnected getter
// Instead of checking state explicitly
if (client.state === "open") {
client.send(Hello, { name: "Anna" });
}
// Use isConnected for cleaner UI code
if (client.isConnected) {
client.send(Hello, { name: "Anna" });
}
// Works well in reactive frameworks
const buttonDisabled = !client.isConnected;When to use:
- ✅ Prototypes/demos where connection is assumed
- ✅ Apps with single connection lifecycle
- ❌ Complex apps needing connection lifecycle control
- ❌ Cases requiring explicit error handling for connection failures
Behavioral Notes (Do/Don't)
- Do: register
on(schema, handler)beforeconnect()to avoid race on early messages. - Do: use
request()for command-reply flows (autocorrelationId+ timeout). - Do: use
autoConnect: truefor prototypes where connection is assumed. - Don't: rely on
meta.timestampfor server logic; the server usesreceivedAt. - Don't: mutate messages passed to handlers or
onUnhandled()(treat as readonly). - Don't: use
autoConnect: truein complex apps needing explicit connection lifecycle control.
Status
- Maturity: Alpha (client).
- Known gaps: No topic helpers; no heartbeats (server ping/pong recommended).
- Planned: Optional
subscribe(topic)once server topic API stabilizes.