Client API Reference
Complete API documentation for the browser WebSocket client.
TIP
For complete type definitions and implementation details, see the Client Specification.
INFO
Topic subscription methods (subscribe(), unsubscribe()) are not yet implemented. See Client Specification for planned features.
TIP
For error handling patterns and troubleshooting, see Client Error Handling. For complete error code taxonomy, see Error Handling Spec.
Installation & Usage
import { z, message } from "@ws-kit/zod";
import { wsClient } from "@ws-kit/client/zod";
// Define message schemas
const Hello = message("HELLO", { name: z.string() });
const HelloOk = message("HELLO_OK", { text: z.string() });
// Create client
const client = wsClient({
url: "wss://api.example.com",
autoConnect: false, // default: false (explicit connect() required)
reconnect: {
enabled: true, // default: true
maxAttempts: Infinity,
initialDelayMs: 300,
maxDelayMs: 10_000,
jitter: "full",
},
queue: "drop-newest", // default: "drop-newest"
queueSize: 1000, // default: 1000
});
// Connect explicitly (or use autoConnect: true)
await client.connect();
// Send fire-and-forget message
client.send(Hello, { name: "Alice" });
// Listen for messages
client.on(HelloOk, (msg) => {
console.log(msg.payload.text); // Fully typed
});
// Request/response pattern
const response = await client.request(Hello, { name: "Bob" }, HelloOk);
console.log(response.payload.text); // Fully typedAvailable imports:
- Zod:
import { wsClient, z, message, rpc } from "@ws-kit/client/zod" - Valibot:
import { wsClient, v, message, rpc } from "@ws-kit/client/valibot"
Note: The typed client packages re-export schema helpers (message, rpc) and validators (z, v) for convenience. You can also import them from @ws-kit/zod or @ws-kit/valibot directly.
Properties
state
Current connection state (read-only).
readonly state: ClientStateValue: One of "closed", "connecting", "open", "closing", or "reconnecting"
Notes:
- Use
onState()to subscribe to state changes - Check
isConnectedfor a boolean shorthand when you only need to know if connection is open
Example:
if (client.state === "open") {
console.log("Connected and ready");
}
// Subscribe to state changes
client.onState((state) => {
console.log("State:", state);
});isConnected
Convenience flag for checking if connection is open.
readonly isConnected: booleanNotes:
truewhenstate === "open",falseotherwise- Shorter alternative to
client.state === "open"
Example:
if (client.isConnected) {
client.send(Message, {
/* ... */
});
} else {
console.warn("Not connected");
}protocol
Negotiated WebSocket subprotocol from server.
readonly protocol: stringNotes:
- Read after connection opens to verify server selected expected protocol
- Value comes from
Sec-WebSocket-Protocolheader - Useful for validating auth or feature negotiation
- Empty string if no subprotocol negotiated
Example:
await client.connect();
if (client.protocol.startsWith("bearer.")) {
console.log("Authenticated via bearer token");
}Methods
connect()
Establish WebSocket connection.
connect(): Promise<void>Behavior:
- Idempotent: Returns in-flight promise if already connecting
- Resolves immediately if already open
- Auto-called by
send()/request()whenautoConnect: true
Example:
await client.connect();
console.log("Connected!");close()
Gracefully close connection.
close(opts?: { code?: number; reason?: string }): Promise<void>Behavior:
- Fully idempotent: Safe to call in any state
- Never rejects: Always resolves, even if already closed
- Cancels reconnection
- Pending requests reject with
ConnectionClosedError
Example:
await client.close({ code: 1000, reason: "Done" });onState()
Subscribe to connection state changes.
onState(cb: (state: ClientState) => void): () => voidReturns: Unsubscribe function
Example:
const unsubscribe = client.onState((state) => {
console.log("State changed to:", state);
});
// Later: unsubscribe()onceOpen()
Wait until connection opens.
onceOpen(): Promise<void>Behavior:
- Resolves immediately if already open
- Waits for next
"open"state transition otherwise
Example:
await client.onceOpen();
// Now connected, safe to sendon()
Register message handler.
on<S extends AnyMessageSchema>(
schema: S,
handler: (msg: InferMessage<S>) => void
): () => voidReturns: Unsubscribe function (call to remove this specific handler)
Features:
- Multiple handlers per schema (execute in registration order)
- Full type inference for handler
- Handler errors logged to console, don't stop other handlers
Example:
const unsubscribe = client.on(HelloOk, (msg) => {
console.log("Payload:", msg.payload);
console.log("Meta:", msg.meta);
});
// Remove handler later
unsubscribe();INFO
Note: There is no off() method. Use the returned unsubscribe function to remove a specific handler. This pattern follows modern JavaScript conventions (similar to addEventListener with AbortController).
send()
Send fire-and-forget message.
// With payload (schema defines payload field)
send<S extends AnyMessageSchema>(
schema: S,
payload: InferPayload<S>,
opts?: { meta?: InferMeta<S>; correlationId?: string }
): boolean
// Without payload (schema has no payload field)
send<S extends AnyMessageSchema>(
schema: S,
opts?: { meta?: InferMeta<S>; correlationId?: string }
): booleanOverloads: TypeScript uses conditional types to enforce correct usage - schemas with payload require the payload parameter, schemas without payload omit it.
Returns:
true: Message sent or queued successfullyfalse: Message dropped (offline withqueue: "off", queue overflow, or validation error)
Never throws - Fire-and-forget design never blocks or rejects. Check the return value to detect delivery failures. This matches WebSocket semantics where the underlying protocol is asynchronous and best-effort.
Example:
const sent = client.send(ChatMessage, { text: "Hello!" });
if (!sent) {
console.warn("Message dropped");
}
// With extended meta
client.send(
RoomMessage,
{ text: "Hi" },
{
meta: { roomId: "general" },
},
);request()
Send request and wait for reply.
// Traditional: explicit response schema (with payload)
request<S extends AnyMessageSchema, R extends AnyMessageSchema>(
schema: S,
payload: InferPayload<S>,
reply: R,
opts?: {
timeoutMs?: number; // default: 30000
meta?: InferMeta<S>;
correlationId?: string;
signal?: AbortSignal;
onProgress?: (data: unknown) => void; // Streaming progress updates
}
): Promise<InferMessage<R>>
// Traditional: explicit response schema (no payload)
request<S extends AnyMessageSchema, R extends AnyMessageSchema>(
schema: S,
reply: R,
opts?: {
timeoutMs?: number;
meta?: InferMeta<S>;
correlationId?: string;
signal?: AbortSignal;
onProgress?: (data: unknown) => void; // Streaming progress updates
}
): Promise<InferMessage<R>>RPC-Style Shorthand: When using rpc() helper to create request/response pairs, omit the reply parameter and let the response type be auto-detected:
const GetUser = rpc("GET_USER", { userId: z.string() }, (r) =>
r
.ok("USER", { id: z.string(), name: z.string() })
.err("NOT_FOUND", { reason: z.string() }),
);
// Auto-detection: reply schema inferred from RPC definition
const user = await client.request(GetUser, { userId: "123" });Traditional Style: Explicitly provide response schema as a separate parameter (useful when request and response schemas are defined independently).
Returns: Promise resolving to reply message
Rejects with:
ValidationError: Invalid payload or malformed replyTimeoutError: No reply within timeoutServerError: Server sent error response (legacy)RpcError: Server sent RPC error with retry hintsConnectionClosedError: Connection closed before replyWsDisconnectedError: Connection disconnected during requestStateError: Aborted via signal or pending limit exceeded
Never throws synchronously - Always returns a Promise
Example:
import { z, message, rpc } from "@ws-kit/zod";
import { TimeoutError, RpcError } from "@ws-kit/client";
// Traditional: explicit response schema
const Hello = message("HELLO", { name: z.string() });
const HelloOk = message("HELLO_OK", { text: z.string() });
try {
const reply = await client.request(Hello, { name: "Anna" }, HelloOk, {
timeoutMs: 5000,
});
console.log(reply.payload.text);
} catch (err) {
if (err instanceof TimeoutError) {
console.warn("Timeout");
} else if (err instanceof RpcError) {
console.error(`RPC error: ${err.code}`, err.details);
}
}
// With payload-less schema
const Ping = message("PING");
const Pong = message("PONG", { timestamp: z.number() });
const reply = await client.request(Ping, Pong, {
timeoutMs: 5000,
});
// With AbortSignal
const controller = new AbortController();
const promise = client.request(Hello, { name: "test" }, HelloOk, {
signal: controller.signal,
});
// Cancel if needed
controller.abort();onUnhandled()
Hook for unhandled message types.
onUnhandled(cb: (msg: AnyInboundMessage) => void): () => voidReturns: Unsubscribe function
Receives:
- Structurally valid messages with no registered schema
- Messages that pass structure check:
{ type: string, meta?: object, payload?: any }
Never receives:
- Messages that fail schema validation (dropped)
- Invalid messages (dropped)
Important: Only one callback can be registered at a time. Registering a new callback replaces the previous one:
client.onUnhandled((msg) => {
console.warn("Callback A:", msg.type);
});
client.onUnhandled((msg) => {
console.warn("Callback B:", msg.type); // Callback A is replaced
});
// Only Callback B will fire for unhandled messagesUse the returned unsubscribe function to remove the callback and register a new one:
const unsub = client.onUnhandled((msg) => {
console.warn("Callback A:", msg.type);
});
unsub(); // Remove callback A
client.onUnhandled((msg) => {
console.warn("Callback B:", msg.type); // Now callback B is active
});Example:
client.onUnhandled((msg) => {
console.warn("Unhandled message type:", msg.type);
console.log("Payload:", msg.payload);
});onError()
Hook for non-fatal internal errors.
onError(cb: (error: Error, context: ErrorContext) => void): () => voidReturns: Unsubscribe function
Fires for:
"parse": JSON parse failures"validation": Message validation failures"overflow": Queue overflow"unknown": Other internal errors
Does NOT fire for:
- Request rejections (caller handles)
- Handler errors (logged to console)
Example:
client.onError((error, context) => {
switch (context.type) {
case "parse":
console.warn("Invalid JSON:", error.message);
break;
case "validation":
console.warn("Validation failed:", context.details);
break;
case "overflow":
console.warn("Queue full, message dropped");
break;
}
});Connection & Authentication
For comprehensive authentication patterns, setup, and protocol merging edge cases, see Client Authentication Guide.
Connection State Machine
The client progresses through well-defined states:
closed → connecting → open → closing → closed
↑__________________________________| (manual reconnect via connect())
closed → reconnecting → connecting (auto-reconnect)State Descriptions:
- closed — Initial state or after
close(). No connection established. - connecting — Connection attempt in progress.
- open — WebSocket connected and ready for messaging.
- closing — Graceful disconnect initiated (during
close()). - reconnecting — Waiting before retry attempt (exponential backoff when
reconnect.enabled: true).
Transitions:
- Manual connection: Call
connect()fromclosed→connecting→open - Graceful shutdown: Call
close()from any state →closing→closed - Auto-reconnect: After connection loss, cycles
closed→reconnecting→connecting→open(if enabled)
Authentication & Protocol Merging
By default, WebSocket client uses query parameters for auth tokens:
const client = wsClient({
url: "wss://api.example.com",
auth: {
attach: "query", // default
queryParam: "access_token", // param name (default)
getToken: async () => {
const token = await localStorage.getItem("token");
return token;
},
},
});
// Sends: wss://api.example.com?access_token=<token>
await client.connect();Alternative: Protocol-Based Auth
For environments where query params are inconvenient (e.g., proxies, firewalls), attach token to the WebSocket subprotocol header:
const client = wsClient({
url: "wss://api.example.com",
auth: {
attach: "protocol", // Use Sec-WebSocket-Protocol header
protocolPrefix: "bearer.", // default
getToken: async () => token,
},
});
// Sends: Sec-WebSocket-Protocol: bearer.<token>
await client.connect();Combining with User Protocols
If your app defines custom WebSocket subprotocols, merge with auth:
const client = wsClient({
url: "wss://api.example.com",
protocols: ["v1", "v2"], // App-defined protocols
auth: {
attach: "protocol",
protocolPrefix: "bearer.",
getToken: async () => token,
},
});
// Sent protocols: ["bearer.<token>", "v1", "v2"]
// Server selects one; check client.protocol after connection:
await client.connect();
if (client.protocol === "v1") {
// Server chose v1 protocol
}Notes:
- Auth token is refreshed before each connection attempt (including reconnects)
- Reserved query params or protocol prefixes are stripped to prevent spoofing
- Server validates and selects the actual protocol (read
client.protocolto check)
Edge Cases & Advanced Patterns
Multiple Handlers Per Schema
When registering multiple handlers for the same schema, all execute in registration order:
client.on(Message, () => console.log("First"));
client.on(Message, () => console.log("Second"));
// Both fire when Message is received
// Each returns its own unsubscribe function
const unsub1 = client.on(Message, handler1);
const unsub2 = client.on(Message, handler2);
unsub1(); // Remove handler1 only; handler2 still activeError Handling: If a handler throws, the error is logged to console but other handlers still execute. This ensures one broken handler doesn't silently drop messages.
onUnhandled Exclusivity
onUnhandled() fires only for messages with no matching schema. Once a schema matches, onUnhandled() does not fire:
client.on(KnownMessage, (msg) => {
// This fires if schema matches
});
client.onUnhandled((msg) => {
// This fires ONLY if no schema matched the type
// Never fires for KnownMessage
});AbortSignal Semantics
Cancel pending requests via AbortSignal:
const controller = new AbortController();
const promise = client.request(Query, payload, Reply, {
signal: controller.signal,
});
// Later: abort the request
controller.abort();
// promise rejects with StateErrorNotes:
- Abort immediately rejects the promise with
StateError - Aborting after response arrives does nothing (race condition is safe)
- Each request needs its own controller for independent cancellation
Reserved Meta Keys
The following meta fields are reserved and automatically stripped from user-provided meta:
clientId— Auto-injected by serverreceivedAt— Auto-injected by servercorrelationId— Reserved for request/response correlation (overwrite carefully)
Stripping prevents client from spoofing server-provided metadata. If you provide these keys, they are silently removed:
client.send(Message, payload, {
meta: {
custom: "value",
clientId: "ignored", // Removed before sending
},
});Error Classes
For complete error class definitions, detailed examples, and usage patterns, see Client Error Handling Guide.
In Brief: Import and catch errors from @ws-kit/client:
import {
ValidationError,
TimeoutError,
RpcError,
ConnectionClosedError,
StateError,
WsDisconnectedError,
} from "@ws-kit/client";
try {
const reply = await client.request(Query, payload, Response);
} catch (err) {
if (err instanceof TimeoutError) {
console.warn(`Timeout after ${err.timeoutMs}ms`);
} else if (err instanceof RpcError) {
console.error(`RPC error: ${err.code}`, err.details);
}
}Common Patterns & Best Practices
Graceful Shutdown
Always close the client before app exit to cancel pending requests and release resources:
async function shutdown() {
await client.close({ code: 1000, reason: "App shutdown" });
console.log("Client closed");
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);What happens: Pending requests reject with ConnectionClosedError. Messages still in queue are discarded.
Request Delivery Guarantees
send() offers best-effort delivery: message is queued offline but may not survive reconnection without explicit idempotency handling:
// Not guaranteed if connection lost before delivery
client.send(Message, payload);
// For at-least-once semantics, use request/response
const reply = await client.request(Query, payload, Response);Pattern: If you need guaranteed delivery, wrap in application-level retry with idempotency key:
const idempotencyKey = crypto.randomUUID();
for (let i = 0; i < 3; i++) {
try {
await client.request(PaymentRequest, { idempotencyKey, ...payload }, PaymentReply);
break; // Success
} catch (err) {
if (err instanceof TimeoutError && i < 2) {
// Retry with same key (server deduplicates)
continue;
}
throw;
}
}Server-side: See Advanced Usage - Error Recovery & Idempotency for implementation details on server-side deduplication caching.
Reconnection and Auto-Connect
By default, client auto-connects when you send() or request(). For explicit control:
// Explicit connection management
const client = wsClient({ autoConnect: false });
await client.connect();
// After manual close(), auto-connect is permanently disabled
await client.close();
// To reconnect, call connect() again explicitly
await client.connect();Note: Infinite reconnection attempts run forever unless you call close(). For graceful degradation, consider explicit retry limits:
let attempts = 0;
const maxAttempts = 5;
client.onState((state) => {
if (state === "closed") {
attempts++;
if (attempts > maxAttempts) {
console.error("Max reconnection attempts exceeded");
// Handle offline state
}
} else if (state === "open") {
attempts = 0; // Reset on successful connection
}
});Pending Request Limits
By default, max 1000 pending requests can accumulate. Once exceeded, new request() calls reject with StateError:
try {
const reply = await client.request(Query, payload, Response);
} catch (err) {
if (err instanceof StateError) {
console.error("Too many pending requests");
// Server may be slow or unreachable
}
}Protocol Negotiation
Use client.protocol to validate server-selected subprotocol after connection:
await client.connect();
if (client.protocol === "bearer.v1") {
console.log("Server authenticated request");
} else if (!client.protocol) {
console.warn("No subprotocol negotiated");
}This is useful for feature version negotiation or auth validation.
Queue Overflow Handling
With queue: "drop-newest" (default), oldest messages survive overflow. Use onError() to detect drops:
client.onError((error, context) => {
if (context.type === "overflow") {
console.warn("Queue full, newest message dropped");
// Send higher-priority message or reduce send frequency
}
});Decision: Choose queue mode based on priority:
drop-newest(default): Keep older, possibly critical messagesdrop-oldest: Keep fresh data, discard stale updatesoff: Fail fast, handle offline state explicitly
Streaming Responses with Progress Updates
For long-running server operations, receive progress updates before the final response using the onProgress callback:
const response = await client.request(Query, payload, Response, {
timeoutMs: 60_000,
onProgress: (data) => {
// Server sent a progress update
console.log("Progress:", data);
// Update UI, progress bar, etc.
},
});
// Eventually resolves with terminal response
console.log("Final result:", response.payload);How it works:
- Server sends non-terminal progress messages of type
$ws:rpc-progress - Each progress message passes its
datafield toonProgress - Request continues waiting until server sends terminal response (final message type)
- Final response resolves the promise
Use cases:
- File uploads/downloads with progress
- Long-running computations with status updates
- Streaming data with periodic batches
- Async job status polling
Notes:
- Progress messages don't settle the promise; request waits for terminal response
- If server never sends terminal response, request times out (respects
timeoutMs) onProgresserrors are logged to console but don't cancel the request
Server-side: See Advanced Usage - RPC Progress & Streaming to understand how servers send progress updates with ctx.progress().
Monitoring Connection Health
Combine onState() and periodic health checks:
client.onState((state) => {
if (state === "open") {
console.log("Connected, ready for requests");
} else if (state === "closed") {
console.error("Connection lost");
}
});
// Periodic health check via ping
const healthCheck = setInterval(async () => {
if (!client.isConnected) {
clearInterval(healthCheck);
return;
}
try {
await client.request(Ping, Pong, { timeoutMs: 5000 });
} catch (err) {
console.warn("Health check failed:", err.message);
}
}, 30_000);Type Exports
RpcErrorCode
Standard error codes for RPC operations (gRPC-aligned).
type RpcErrorCode =
| "UNAUTHENTICATED"
| "PERMISSION_DENIED"
| "INVALID_ARGUMENT"
| "FAILED_PRECONDITION"
| "NOT_FOUND"
| "ALREADY_EXISTS"
| "ABORTED"
| "DEADLINE_EXCEEDED"
| "RESOURCE_EXHAUSTED"
| "UNAVAILABLE"
| "UNIMPLEMENTED"
| "INTERNAL"
| "CANCELLED"
| string; // Extensible for custom codesType Utilities
InferMessage
Extract full message type from schema.
import { z, message } from "@ws-kit/zod";
import type { InferMessage } from "@ws-kit/zod";
const HelloOk = message("HELLO_OK", { text: z.string() });
type HelloOkMessage = InferMessage<typeof HelloOk>;
// { type: "HELLO_OK", meta: { timestamp?: number }, payload: { text: string } }InferPayload
Extract payload type from schema.
import { z, message } from "@ws-kit/zod";
import type { InferPayload } from "@ws-kit/zod";
const Hello = message("HELLO", { name: z.string() });
type HelloPayload = InferPayload<typeof Hello>;
// { name: string }InferMeta
Extract meta type from schema.
import { z, message } from "@ws-kit/zod";
import type { InferMeta } from "@ws-kit/zod";
const RoomMsg = message(
"CHAT",
{ text: z.string() },
{ roomId: z.string() }, // Extended meta
);
type RoomMeta = InferMeta<typeof RoomMsg>;
// { roomId: string }