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.
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.
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 - Use return value to detect failures
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;
}
): 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;
}
): Promise<InferMessage<R>>Note: RPC schemas created with the rpc() helper automatically bind the response type. When using client.request(rpcSchema, payload, options), the response type is auto-detected from the RPC schema, eliminating the need for an explicit response schema parameter.
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";
// 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)
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;
}
});Error Classes
ValidationError
Validation failure (outbound or inbound).
class ValidationError extends Error {
constructor(
message: string,
public readonly issues: Array<{ path: string[]; message: string }>
);
}TimeoutError
Request timeout.
class TimeoutError extends Error {
constructor(public readonly timeoutMs: number);
}ServerError
Server-sent error response (legacy - prefer RpcError for new code).
class ServerError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly context?: Record<string, unknown>
);
}RpcError
Enhanced RPC error from server with retry hints.
class RpcError<TCode extends RpcErrorCode = RpcErrorCode> extends Error {
constructor(
message: string,
public readonly code: TCode,
public readonly details?: unknown,
public readonly retryable?: boolean,
public readonly retryAfterMs?: number,
public readonly correlationId?: string
);
}Standard Error Codes:
Terminal errors (don't auto-retry):
UNAUTHENTICATED- Missing or invalid authenticationPERMISSION_DENIED- Authenticated but insufficient permissionsINVALID_ARGUMENT- Input validation failedFAILED_PRECONDITION- Stateful precondition not metNOT_FOUND- Resource does not existALREADY_EXISTS- Uniqueness or idempotency violationABORTED- Concurrency conflict (race condition)
Transient errors (retry with backoff):
DEADLINE_EXCEEDED- RPC timed outRESOURCE_EXHAUSTED- Rate limit, quota, or buffer overflowUNAVAILABLE- Transient infrastructure error
Server/evolution:
UNIMPLEMENTED- Feature not supported or deployedINTERNAL- Unexpected server error (unhandled exception)CANCELLED- Call cancelled (client disconnect, abort)
See Error Handling Spec for complete list.
ConnectionClosedError
Connection closed before reply.
class ConnectionClosedError extends Error {}StateError
Invalid state for operation.
class StateError extends Error {
constructor(message: string);
}WsDisconnectedError
Connection disconnected during RPC request.
class WsDisconnectedError extends Error {
constructor(message?: string);
}Thrown when socket closes while request is in-flight and no idempotencyKey is provided (or reconnect window expires without reconnecting).
Usage:
import {
ValidationError,
TimeoutError,
ServerError,
RpcError,
ConnectionClosedError,
StateError,
WsDisconnectedError,
} from "@ws-kit/client";
try {
const reply = await client.request(Hello, { name: "test" }, HelloOk);
} 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);
if (err.retryable && err.retryAfterMs) {
// Wait and retry
await new Promise((r) => setTimeout(r, err.retryAfterMs));
}
} else if (err instanceof ServerError) {
console.error(`Server error: ${err.code}`, err.context);
} else if (err instanceof WsDisconnectedError) {
console.warn("Disconnected during request");
}
}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>;
// { timestamp?: number, roomId: string }