This is the full developer documentation for Zocket
# Actor Handles
> Proxy methods, events, state subscriptions, and lifecycle management.
An **Actor Handle** is the client-side proxy for a single actor instance. It provides typed methods, event subscriptions, state subscriptions, and lifecycle management.
## Getting a Handle
[Section titled “Getting a Handle”](#getting-a-handle)
```ts
const room = client.chat("room-1");
```
This returns a typed `ActorHandle` — all methods, events, and state types are inferred.
## Calling Methods (RPC)
[Section titled “Calling Methods (RPC)”](#calling-methods-rpc)
Methods are accessed directly on the handle. Every method call is an RPC — a request sent over WebSocket with a unique ID, resolved when the server responds:
```ts
// Method with input
await room.sendMessage({ text: "hello" });
// Method without input
const count = await counter.increment();
// Method with return value
const { playerId, color } = await game.join({ name: "Alice" });
```
### How RPC works under the hood
[Section titled “How RPC works under the hood”](#how-rpc-works-under-the-hood)
1. The client generates a unique request ID (`rpc_{counter}_{timestamp}`)
2. Sends `{ type: "rpc", id, actor, actorId, method, input }` over WebSocket
3. The server processes the method (queued, single-writer) and sends back `{ type: "rpc:result", id, result }` or `{ type: "rpc:result", id, error }`
4. The client matches the response by ID and resolves or rejects the promise
### Waiting for connection
[Section titled “Waiting for connection”](#waiting-for-connection)
RPCs automatically wait for the WebSocket to be ready. If the socket is reconnecting, the RPC waits (up to `rpcTimeout`) for it to reopen before sending:
```ts
// This works even during a brief reconnect — the call waits for the socket
const messages = await room.getMessages();
```
The `rpcTimeout` covers the full lifecycle: wait-for-open + server response time.
### Error cases
[Section titled “Error cases”](#error-cases)
```ts
try {
await room.sendMessage({ text: "hello" });
} catch (err) {
// Possible errors:
// - 'RPC "sendMessage" timed out after 10000ms' (timeout)
// - 'WebSocket closed' (socket dropped while RPC was pending)
// - 'Validation failed for sendMessage: [...]' (input validation)
// - 'Forbidden' (middleware rejection)
// - 'Unknown method: foo' (method doesn't exist)
}
```
### Return values
[Section titled “Return values”](#return-values)
If a handler returns a value, the client receives it. If a handler returns void, the client receives `undefined`. Both cases go through the same RPC flow — every method call gets an acknowledgement from the server:
```ts
// Server: returns { count: number }
const result = await counter.increment(); // { count: 1 }
// Server: returns void
await room.clear(); // undefined (but still waits for server confirmation)
```
This means you always know whether the server successfully processed your call, even for void methods. Errors (auth failures, validation, handler throws) are always surfaced.
## Event Subscriptions
[Section titled “Event Subscriptions”](#event-subscriptions)
Use `.on()` to listen for typed events:
```ts
const unsubscribe = room.on("newMessage", (payload) => {
// payload is typed: { text: string; from: string }
console.log(`${payload.from}: ${payload.text}`);
});
// Later:
unsubscribe();
```
Event subscriptions are **lazy** — the client only sends an `event:sub` message to the server when the first listener is added. When all listeners are removed, it sends `event:unsub`.
After a reconnect, active event subscriptions are automatically re-sent to the server.
## State Subscriptions
[Section titled “State Subscriptions”](#state-subscriptions)
The `.state` object provides `subscribe()` and `getSnapshot()`:
```ts
// Subscribe to state changes
const unsub = room.state.subscribe((state) => {
console.log("Current messages:", state.messages);
});
// Read current state synchronously
const current = room.state.getSnapshot();
```
Like events, state subscriptions are lazy. The first `subscribe()` sends `state:sub` to the server, which triggers an immediate `state:snapshot` response. Subsequent mutations on the server are received as `state:patch` messages.
After a reconnect, active state subscriptions are automatically re-sent, and the server sends a fresh snapshot.
### How State Updates Work
[Section titled “How State Updates Work”](#how-state-updates-work)
1. Server mutates state via Immer → generates JSON patches
2. Server sends `state:patch` to all state subscribers
3. Client’s `StateStore` applies patches to local state copy
4. All registered listeners are notified
## Handle Metadata
[Section titled “Handle Metadata”](#handle-metadata)
The `meta` object provides the handle’s identity and lifecycle control:
```ts
room.meta.name // "chat"
room.meta.id // "room-1"
room.meta.dispose() // decrement ref count
```
| Property | Type | Description |
| :--------------- | :----------- | :-------------------------------- |
| `meta.name` | `string` | The actor name (e.g. `"chat"`) |
| `meta.id` | `string` | The instance ID (e.g. `"room-1"`) |
| `meta.dispose()` | `() => void` | Decrement ref count |
## Disposing Handles
[Section titled “Disposing Handles”](#disposing-handles)
Call `meta.dispose()` when you’re done with a handle:
```ts
room.meta.dispose();
```
This decrements the reference count. When the ref count hits zero (after a one-tick delay), the handle:
1. Unsubscribes from events on the server
2. Unsubscribes from state on the server
3. Clears all listeners and state
### Reference Counting
[Section titled “Reference Counting”](#reference-counting)
Multiple consumers can share the same handle. Each call to `client.chat("room-1")` increments the ref count. Each `meta.dispose()` decrements it. The handle is only truly disposed when the count reaches zero.
The one-tick delay on final disposal ensures React StrictMode’s temporary unmount/remount cycle doesn’t kill shared handles.
# Creating a Client
> Connect to your Zocket server with full type safety.
The `@zocket/client` package provides `createClient` — a fully typed WebSocket client that infers its API from your app definition.
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
```ts
import { createClient } from "@zocket/client";
import type { app } from "./server";
const client = createClient({
url: "ws://localhost:3000",
});
```
The generic `` is the only type annotation you need. Everything else — actor names, method signatures, event payloads, state shapes — is inferred.
## Options
[Section titled “Options”](#options)
```ts
interface ClientOptions {
/** WebSocket URL for the Zocket server. */
url: string;
/**
* Total timeout in ms for an RPC, including time spent waiting for a live
* socket before the request can be sent. 0 = no timeout. Default: 10000.
*/
rpcTimeout?: number;
/** Reject `$ready` if the initial connection is not established in time. Default: 10000. */
connectTimeout?: number;
/** Automatically reconnect after unexpected socket closes. Default: true. */
reconnect?: boolean;
/** Override reconnect delay in ms. Useful for deterministic tests. */
reconnectDelayMs?: number;
}
```
### RPC Timeout
[Section titled “RPC Timeout”](#rpc-timeout)
The `rpcTimeout` covers the **entire lifecycle** of an RPC call — from the moment you call the method to when the server responds. This includes time spent waiting for the WebSocket to be ready (e.g., during reconnection):
```ts
const client = createClient({
url: "ws://localhost:3000",
rpcTimeout: 5000, // 5 seconds total
});
```
If the socket is reconnecting and takes 2 seconds to open, the RPC has 3 seconds remaining for the server to respond. If the full timeout elapses, the promise rejects:
```ts
try {
await room.sendMessage({ text: "hello" });
} catch (err) {
// 'RPC "sendMessage" timed out after 5000ms'
}
```
Set to `0` to disable timeouts entirely.
### Connect Timeout
[Section titled “Connect Timeout”](#connect-timeout)
Rejects `connection.ready` if the initial connection isn’t established in time:
```ts
const client = createClient({
url: "ws://localhost:3000",
connectTimeout: 3000,
});
try {
await client.connection.ready;
} catch (err) {
// 'WebSocket did not connect within 3000ms'
}
```
## Getting Actor Handles
[Section titled “Getting Actor Handles”](#getting-actor-handles)
Access actors by name, then pass an instance ID:
```ts
const room = client.chat("general");
const game = client.game("match-42");
```
Each call returns a typed `ActorHandle` — see [Actor Handles](/client/actor-handles/) for the full API.
## Connection Lifecycle
[Section titled “Connection Lifecycle”](#connection-lifecycle)
### `connection.ready`
[Section titled “connection.ready”](#connectionready)
A promise that resolves when the WebSocket connection is open:
```ts
await client.connection.ready;
console.log("Connected!");
```
Also available as `client.$ready` for convenience.
### `client.connection`
[Section titled “client.connection”](#clientconnection)
The `connection` object provides everything you need for connection lifecycle management:
```ts
// Wait for the connection to be ready
await client.connection.ready;
// Read current status synchronously
client.connection.status // "connecting" | "connected" | "reconnecting" | "disconnected"
// Subscribe to status changes
const unsub = client.connection.subscribe((status) => {
console.log("Connection:", status);
});
// Close the connection
client.connection.close();
```
`connection.close()` (also available as `client.$close()`) gracefully shuts down the client:
1. Stops any pending reconnect attempts
2. Disposes all active actor handles (unsubscribes from events/state)
3. Rejects all pending RPCs with `"WebSocket client closed"`
4. Closes the WebSocket
After closing, the client cannot be reused. Create a new client to reconnect.
**Status transitions:**
| From | To | When |
| :------------- | :------------- | :------------------------------------------------------- |
| `connecting` | `connected` | Initial WebSocket connection opens |
| `connected` | `reconnecting` | Socket drops unexpectedly |
| `reconnecting` | `connected` | Reconnect succeeds |
| any | `disconnected` | `close()` called, or reconnect disabled and socket drops |
**In React**, use the `useConnectionStatus()` hook instead (see [React Hooks](/react/hooks/)):
```tsx
function StatusBanner() {
const status = useConnectionStatus();
if (status === "reconnecting") return
Reconnecting...
;
return null;
}
```
## Reconnection
[Section titled “Reconnection”](#reconnection)
By default, the client automatically reconnects when the WebSocket closes unexpectedly. Reconnection uses exponential backoff with jitter (250ms → 500ms → 1s → 2s cap).
```ts
const client = createClient({
url: "ws://localhost:3000",
reconnect: true, // default
});
```
When a reconnection succeeds:
* All active event and state subscriptions are **automatically re-sent** to the server
* State subscribers receive a fresh snapshot
* New RPC calls work immediately
When the socket drops during reconnection:
* All **pending RPCs are rejected** — the caller gets an error and can decide whether to retry
* The client schedules another reconnect attempt
To disable reconnection:
```ts
const client = createClient({
url: "ws://localhost:3000",
reconnect: false,
});
```
### Why pending RPCs are rejected (not resent)
[Section titled “Why pending RPCs are rejected (not resent)”](#why-pending-rpcs-are-rejected-not-resent)
Unlike some frameworks that buffer and resend pending requests on reconnect, Zocket rejects them. This is intentional — actor methods are often non-idempotent mutations (e.g., `sendMessage`). Auto-resending could cause duplicates if the server partially processed the request before the disconnect. The caller is in the best position to decide whether to retry.
## RPC Behavior
[Section titled “RPC Behavior”](#rpc-behavior)
All method calls on actor handles go through WebSocket as request/response messages:
1. Client generates a unique request ID and sends an `rpc` message
2. Server processes the method and sends back an `rpc:result` with the same ID
3. Client resolves the promise with the result (or rejects with the error)
RPCs **wait for the socket to be ready** before sending. If the socket is currently reconnecting, the RPC waits (up to `rpcTimeout`) for it to reopen:
```ts
// Works even if the socket is momentarily down — waits for reconnect
const result = await room.getSnapshot();
```
An RPC will reject if:
* The `rpcTimeout` elapses (including wait time)
* The WebSocket closes while the RPC is pending
* The server returns an error (validation, middleware, handler throw)
* `$close()` is called
## Error Handling
[Section titled “Error Handling”](#error-handling)
```ts
try {
await room.sendMessage({ text: "hello" });
} catch (err) {
console.error(err.message);
// Possible messages:
// - 'RPC "sendMessage" timed out after 10000ms'
// - 'WebSocket closed'
// - 'Validation failed for sendMessage: [...]'
// - 'Forbidden' (from middleware)
}
```
## How It Works
[Section titled “How It Works”](#how-it-works)
`createClient` returns a `Proxy` object. When you access `client.chat`, it returns a function. Calling that function (`client.chat("room-1")`) creates or retrieves a shared `ActorHandleImpl` for that `(actorName, actorId)` pair.
Handles are **reference-counted** — multiple consumers can share the same handle. When the ref count drops to zero, disposal is deferred by one tick to support React StrictMode’s unmount/remount cycle.
# State Store
> Client-side state management with snapshots and JSON patches.
Each actor handle has an internal `StateStore` that manages the client-side copy of the actor’s state.
## How It Works
[Section titled “How It Works”](#how-it-works)
1. When you call `handle.state.subscribe()`, the client sends a `state:sub` message
2. The server responds with a `state:snapshot` containing the full state
3. On each state change, the server sends `state:patch` with JSON Patch operations
4. The `StateStore` applies patches to its local copy and notifies subscribers
## StateStore API
[Section titled “StateStore API”](#statestore-api)
```ts
class StateStore {
getState(): TState | undefined;
setSnapshot(state: TState): void;
applyPatches(patches: JsonPatchOp[]): void;
subscribe(listener: (state: TState) => void): Unsubscribe;
get subscriberCount(): number;
}
```
The `StateStore` is internal to actor handles — you interact with it through the handle’s `.state` property, not directly.
## JSON Patch Support
[Section titled “JSON Patch Support”](#json-patch-support)
The store implements a subset of RFC 6902:
| Operation | Description |
| :-------- | :------------------------------------------------------ |
| `add` | Add a value at a path (including array append with `-`) |
| `replace` | Replace the value at a path |
| `remove` | Remove the value at a path |
### Path Syntax
[Section titled “Path Syntax”](#path-syntax)
Paths follow JSON Pointer syntax (RFC 6901):
```plaintext
/messages/0/text → state.messages[0].text
/players/- → append to state.players array
/count → state.count
```
Special characters are escaped: `~0` for `~`, `~1` for `/`.
## Immutability
[Section titled “Immutability”](#immutability)
Patches are applied using `structuredClone()` — each update produces a new state object. This ensures React’s `useSyncExternalStore` detects changes correctly.
## Subscriber Notification
[Section titled “Subscriber Notification”](#subscriber-notification)
Subscribers are called synchronously after each state update (snapshot or patch). If state is `undefined` (no snapshot received yet), subscribers are not called.
When a new subscriber is added and state is already available, the subscriber is called immediately with the current state.
## Integration with React
[Section titled “Integration with React”](#integration-with-react)
The `useActorState` hook from `@zocket/react` is built on top of `StateStore`:
```tsx
// Subscribes to the store, re-renders on changes
const messages = useActorState(room, (s) => s.messages);
```
See [React Hooks](/react/hooks/) for details on selectors and memoization.
# Comparison
> How Zocket compares to Socket.io, PartyKit, Convex, Liveblocks, and others.
Every tool in this space makes different tradeoffs. Here’s an honest look at when you’d pick each one — and when you wouldn’t.
## Socket.io
[Section titled “Socket.io”](#socketio)
The battle-tested standard for realtime in Node.js.
**Strengths:**
* Massive ecosystem and community — tons of tutorials, plugins, and production battle scars
* Automatic fallback to HTTP long-polling when WebSockets aren’t available
* Room and namespace abstractions built-in
* Works with any language (has clients for Python, Java, Go, etc.)
**Tradeoffs:**
* Type safety is opt-in and manual — you define shared interfaces that can drift from the implementation
* No runtime validation — payloads are untyped `any` by default
* No built-in state management — you manage server state and sync it yourself
* Event-string API (`socket.emit("event", ...)`) is error-prone at scale
**Best for:** Teams that need broad runtime/language support, HTTP fallbacks, or are already invested in the Socket.io ecosystem.
## PartyKit (Cloudflare)
[Section titled “PartyKit (Cloudflare)”](#partykit-cloudflare)
Edge-first realtime platform built on Cloudflare Workers and Durable Objects.
**Strengths:**
* Runs at the edge — low latency globally without managing infrastructure
* Each “party” is an isolated stateful server (similar concept to actors)
* Hibernation support — parties sleep when idle, saving costs
* Managed platform — no servers to operate, scales automatically
* Good integration with Cloudflare’s ecosystem (KV, D1, R2)
**Tradeoffs:**
* Platform lock-in to Cloudflare — you can’t self-host or run on other clouds
* Limited TypeScript inference across the wire — client types are manual
* No automatic state sync — you send messages and manage client state yourself
* Durable Object constraints (single-region per party, storage limits)
* Cloudflare Workers runtime limitations (no Node.js APIs, execution time limits)
**Best for:** Teams that want managed infrastructure at the edge and are comfortable with Cloudflare lock-in. Great for apps where latency matters more than type safety DX.
## Convex
[Section titled “Convex”](#convex)
Reactive backend-as-a-service with a built-in database.
**Strengths:**
* Full reactive backend — queries automatically re-run and push updates when underlying data changes
* Built-in database with ACID transactions — no external DB to manage
* Excellent TypeScript support — functions are typed end-to-end
* Scheduled functions, cron jobs, file storage, and auth built-in
* Handles caching, pagination, and optimistic updates out of the box
**Tradeoffs:**
* Fully managed platform — you can’t self-host or bring your own database
* The reactive model is query-based, not actor-based — great for CRUD-like updates, less natural for game loops or stateful simulations
* Vendor lock-in — your data and logic live on Convex’s infrastructure
* Pricing scales with function calls and database usage
* Not designed for raw WebSocket use cases like streaming binary data
**Best for:** Apps that are primarily data-driven (dashboards, collaborative docs, social feeds) where you want a complete reactive backend without managing infrastructure. Excellent choice if you don’t need fine-grained control over the realtime protocol.
## Liveblocks
[Section titled “Liveblocks”](#liveblocks)
Collaborative infrastructure for building multiplayer experiences.
**Strengths:**
* Purpose-built for collaboration — presence, cursors, selection, comments, notifications out of the box
* Conflict-free storage via Yjs/CRDT integration — true multi-writer without conflicts
* Excellent React bindings with suspense support
* Managed infrastructure — no backend to build for collaboration features
* Rich pre-built components (comments, threads, notifications)
**Tradeoffs:**
* Collaboration-focused — not a general-purpose realtime framework
* Managed service with per-MAU pricing — costs scale with users, not usage
* Limited server-side logic — the server is Liveblocks’ infrastructure, not yours
* You can’t define custom server-side methods or business logic in Liveblocks itself
* Storage model is CRDT-based — powerful for collaboration, but overkill for simple state sync
**Best for:** Teams building collaborative features (multiplayer cursors, shared editing, comments) who want polished, ready-made components. If your app is primarily about collaboration UX, Liveblocks gets you there faster than building from scratch.
## Supabase Realtime
[Section titled “Supabase Realtime”](#supabase-realtime)
Realtime layer on top of Postgres.
**Strengths:**
* Database-driven — changes to Postgres rows automatically broadcast to clients
* Presence and broadcast channels built-in
* Integrates with the full Supabase stack (auth, storage, edge functions)
* Row-level security applies to realtime subscriptions
* Open source — self-hostable
**Tradeoffs:**
* Tied to Postgres — realtime events are driven by database changes, not arbitrary server logic
* No actor model or stateful server-side units
* Limited control over message routing — it’s pub/sub over database changes
* Not ideal for high-frequency updates (game state, cursor positions) due to database overhead
* TypeScript support exists but isn’t as deeply inferred as purpose-built frameworks
**Best for:** Apps already on Supabase that need realtime updates when database records change. Great for live feeds, notifications, and dashboards backed by Postgres.
## Ably / Pusher
[Section titled “Ably / Pusher”](#ably--pusher)
Managed pub/sub messaging platforms.
**Strengths:**
* Global edge infrastructure — low latency, high availability, no servers to manage
* Protocol-level features: message ordering, delivery guarantees, history, presence
* Broad SDK support — works with any language, any platform
* Handles reconnection, deduplication, and exactly-once delivery
**Tradeoffs:**
* Pure message transport — no server-side logic, state management, or actors
* You build the application layer yourself on top of pub/sub primitives
* Per-message pricing can get expensive at scale
* No type safety across the wire — messages are untyped payloads
* Not open source (Ably has an open protocol, but the platform is managed)
**Best for:** Apps that need reliable, global message delivery without building infrastructure — notifications, live scores, IoT telemetry. Less suited for stateful multiplayer where you need server-side logic.
## Quick Comparison
[Section titled “Quick Comparison”](#quick-comparison)
| | Zocket | Socket.io | PartyKit | Convex | Liveblocks | Supabase Realtime |
| :----------------- | :--------------------- | :---------------- | :-------------------------- | :------------------------ | :------------- | :---------------- |
| **Type safety** | ✅ Fully inferred | ⚠️ Manual | ⚠️ Manual | ✅ End-to-end | ✅ SDK-typed | ⚠️ Generated |
| **State sync** | ✅ Auto (JSON patches) | ❌ DIY | ❌ DIY | ✅ Auto (reactive queries) | ✅ Auto (CRDTs) | ✅ Auto (DB rows) |
| **Server logic** | ✅ Actor methods | ⚠️ Event handlers | ✅ Party class | ✅ Server functions | ❌ Managed only | ⚠️ Edge functions |
| **Concurrency** | ✅ Sequential per actor | ❌ DIY | ✅ Single-threaded per party | ✅ Serialized transactions | ✅ CRDT merge | ✅ DB transactions |
| **Self-hostable** | ✅ Yes | ✅ Yes | ❌ Cloudflare only | ❌ No | ❌ No | ✅ Yes |
| **React bindings** | ✅ Built-in | ⚠️ Community | ⚠️ Community | ✅ Built-in | ✅ Built-in | ✅ Built-in |
## When to use something else
[Section titled “When to use something else”](#when-to-use-something-else)
* **You need HTTP fallbacks or broad language support** — use Socket.io
* **You want zero infrastructure and edge deployment** — use PartyKit
* **Your app is data/CRUD-driven and you want a full reactive backend** — use Convex
* **You’re building collaborative editing with cursors, comments, and presence** — use Liveblocks
* **You need realtime updates from Postgres changes** — use Supabase Realtime
* **You need global pub/sub with delivery guarantees** — use Ably or Pusher
* **Your app is purely request/response** — use tRPC
# Actors
> Define stateful actors with typed methods, events, and lifecycle hooks.
This page is the API reference for actors.
If you want the conceptual background first, read [Why Actors](/motivation/actors/).
Actors are the core building block of Zocket. Each actor definition describes a **stateful unit** with a schema-validated state, typed methods, events, and lifecycle hooks.
## Defining an Actor
[Section titled “Defining an Actor”](#defining-an-actor)
Use the `actor()` function from `@zocket/core`:
```ts
import { z } from "zod";
import { actor } from "@zocket/core";
const Counter = actor({
state: z.object({
count: z.number().default(0),
}),
methods: {
increment: {
handler: ({ state }) => {
state.count += 1;
return state.count;
},
},
add: {
input: z.object({ amount: z.number() }),
handler: ({ state, input }) => {
state.count += input.amount;
return state.count;
},
},
},
});
```
The returned `ActorDef` carries full type information — callers never need to specify generics manually.
## State
[Section titled “State”](#state)
State is defined using a Standard Schema (Zod, Valibot, etc.). The server initializes state by validating an empty object `{}` against your schema, so use `.default()` for fields:
```ts
state: z.object({
players: z.array(PlayerSchema).default([]),
phase: z.enum(["lobby", "playing"]).default("lobby"),
round: z.number().default(0),
}),
```
State is managed with **Immer** on the server. Inside method handlers, you mutate a draft directly — Zocket tracks changes and broadcasts JSON patches to subscribers.
## Methods
[Section titled “Methods”](#methods)
Each method has an optional `input` schema and a required `handler`:
```ts
methods: {
// No input
reset: {
handler: ({ state }) => {
state.count = 0;
},
},
// With validated input
setName: {
input: z.object({ name: z.string().min(1) }),
handler: ({ state, input }) => {
state.name = input.name;
},
},
},
```
### MethodContext
[Section titled “MethodContext”](#methodcontext)
Every handler receives a context object:
| Property | Type | Description |
| :------------- | :--------------------- | :------------------------------------------------------- |
| `state` | `TState` (Immer draft) | Mutable state — changes are tracked as patches |
| `input` | `InferSchema` | Validated input (or `undefined` if no schema) |
| `emit` | `TypedEmitFn` | Emit typed events to subscribers |
| `connectionId` | `string` | Opaque ID for the calling connection |
| `ctx` | `TCtx` | Middleware context (see [Middleware](/core/middleware/)) |
### Return Values
[Section titled “Return Values”](#return-values)
Methods can return values. The client receives the return value as a resolved promise:
```ts
// Server
handler: ({ state }) => {
return { count: state.count };
},
// Client
const result = await counter.increment(); // { count: 1 }
```
### Sequential Execution
[Section titled “Sequential Execution”](#sequential-execution)
All method calls on a single actor instance are **queued and executed sequentially** — one at a time. This gives you single-writer semantics without locks.
## Events
[Section titled “Events”](#events)
Events are typed messages broadcast to all connections subscribed to an actor instance:
```ts
const ChatRoom = actor({
state: z.object({
messages: z.array(MessageSchema).default([]),
}),
methods: {
send: {
input: z.object({ text: z.string() }),
handler: ({ state, input, emit, connectionId }) => {
const msg = { text: input.text, from: connectionId };
state.messages.push(msg);
emit("newMessage", msg);
},
},
},
events: {
newMessage: z.object({ text: z.string(), from: z.string() }),
},
});
```
Event payloads are validated at runtime against their schemas before being broadcast.
## Lifecycle Hooks
[Section titled “Lifecycle Hooks”](#lifecycle-hooks)
Actors support `onConnect` and `onDisconnect` hooks. These fire when a connection first interacts with an actor instance and when it disconnects:
```ts
const Room = actor({
state: z.object({
online: z.array(z.string()).default([]),
}),
methods: { /* ... */ },
onConnect({ state, connectionId }) {
state.online.push(connectionId);
},
onDisconnect({ state, connectionId }) {
const idx = state.online.indexOf(connectionId);
if (idx !== -1) state.online.splice(idx, 1);
},
});
```
Lifecycle hooks receive a `LifecycleContext` with `state` (Immer draft), `connectionId`, and `emit`. They are queued alongside method calls to preserve ordering.
Note: middleware `ctx` is **not** available in lifecycle hooks — only in method handlers.
## Full Example: Drawing Room
[Section titled “Full Example: Drawing Room”](#full-example-drawing-room)
From the `example-draw` package:
```ts
import { z } from "zod";
import { actor, createApp } from "@zocket/core";
const Stroke = z.object({
points: z.array(z.tuple([z.number(), z.number()])),
color: z.string(),
width: z.number(),
});
const DrawingRoom = actor({
state: z.object({
players: z.array(z.object({
id: z.string(),
name: z.string(),
score: z.number(),
color: z.string(),
connectionId: z.string().default(""),
})).default([]),
phase: z.enum(["lobby", "drawing", "roundEnd"]).default("lobby"),
drawerId: z.string().default(""),
word: z.string().default(""),
hint: z.string().default(""),
strokes: z.array(Stroke).default([]),
round: z.number().default(0),
}),
methods: {
join: {
input: z.object({ name: z.string() }),
handler: ({ state, input, connectionId }) => {
const existing = state.players.find((p) => p.name === input.name);
if (existing) {
existing.connectionId = connectionId;
return { playerId: existing.id, color: existing.color };
}
const id = Math.random().toString(36).slice(2, 10);
const color = ["#ef4444", "#3b82f6", "#22c55e"][state.players.length % 3];
state.players.push({ id, name: input.name, score: 0, color, connectionId });
return { playerId: id, color };
},
},
draw: {
input: z.object({ stroke: Stroke }),
handler: ({ state, input }) => {
state.strokes.push(input.stroke);
},
},
guess: {
input: z.object({ playerId: z.string(), text: z.string() }),
handler: ({ state, input, emit }) => {
const correct = input.text.toLowerCase() === state.word.toLowerCase();
if (correct) {
emit("correctGuess", { name: input.playerId, word: state.word });
state.phase = "roundEnd";
}
return { correct };
},
},
},
events: {
correctGuess: z.object({ name: z.string(), word: z.string() }),
},
onDisconnect({ state, connectionId }) {
const idx = state.players.findIndex((p) => p.connectionId === connectionId);
if (idx !== -1) state.players.splice(idx, 1);
},
});
export const app = createApp({ actors: { draw: DrawingRoom } });
```
# Apps
> Bundle actor definitions into a typed application.
An **App** bundles one or more actor definitions into a single object that the server, client, and React packages can consume.
## Creating an App
[Section titled “Creating an App”](#creating-an-app)
```ts
import { createApp } from "@zocket/core";
import { ChatRoom } from "./chat";
import { GameMatch } from "./game";
export const app = createApp({
actors: {
chat: ChatRoom,
game: GameMatch,
},
});
```
The keys you choose (`chat`, `game`) become the **actor names** used everywhere:
* **Server** — the handler routes RPC calls to the correct actor by name
* **Client** — `client.chat("room-1")` returns a typed handle for the `chat` actor
* **React** — `useActor("chat", "room-1")` uses the same name
## Type Inference
[Section titled “Type Inference”](#type-inference)
The `AppDef` type carries the full type information of all registered actors. Pass `typeof app` as a generic to the client and React factories:
```ts
import { createClient } from "@zocket/client";
import type { app } from "./server";
// Full type inference — no manual types needed
const client = createClient({ url: "ws://localhost:3000" });
const room = client.chat("general");
// ^? ActorHandle with sendMessage, on("newMessage"), state, etc.
```
## AppDef Shape
[Section titled “AppDef Shape”](#appdef-shape)
The returned object has this shape:
```ts
interface AppDef {
readonly _tag: "AppDef";
readonly actors: TActors; // Record
}
```
The `_tag` discriminant is used internally for type narrowing. You generally don’t need to interact with it directly.
## Naming Conventions
[Section titled “Naming Conventions”](#naming-conventions)
* Use **camelCase** for actor names: `chat`, `gameMatch`, `drawingRoom`
* Each name must be unique within an app
* The name is used as-is in the wire protocol (`{ actor: "chat", actorId: "room-1" }`)
# Middleware
> Chain middleware for authentication, logging, and context enrichment.
Middleware lets you run logic **before** every method handler on an actor. Use it for authentication, context enrichment, rate-limiting, or gating.
## Creating Middleware
[Section titled “Creating Middleware”](#creating-middleware)
```ts
import { middleware } from "@zocket/core";
const authed = middleware()
.use(async ({ connectionId }) => {
const user = await getUser(connectionId);
if (!user) throw new Error("Unauthorized");
return { userId: user.id, role: user.role };
});
```
Each `.use()` call receives the accumulated context and returns additional context. The types **intersect** — downstream middleware and handlers see all prior context.
## Chaining
[Section titled “Chaining”](#chaining)
```ts
const admin = middleware()
.use(async ({ connectionId }) => {
const user = await getUser(connectionId);
if (!user) throw new Error("Unauthorized");
return { userId: user.id, role: user.role };
})
.use(({ ctx }) => {
if (ctx.role !== "admin") throw new Error("Forbidden");
return { isAdmin: true as const };
});
```
After this chain, handlers receive `ctx: { userId: string; role: string; isAdmin: true }`.
## Attaching to Actors
[Section titled “Attaching to Actors”](#attaching-to-actors)
Use `.actor()` instead of the top-level `actor()` function:
```ts
const ProtectedRoom = authed.actor({
state: z.object({
messages: z.array(z.string()).default([]),
}),
methods: {
send: {
input: z.object({ text: z.string() }),
handler: ({ state, input, ctx }) => {
// ctx.userId is available and typed
state.messages.push(`${ctx.userId}: ${input.text}`);
},
},
},
});
```
Actors created via `middleware().actor()` behave identically to `actor()` — they return the same `ActorDef` type and work with `createApp()`.
## MiddlewareArgs
[Section titled “MiddlewareArgs”](#middlewareargs)
Each middleware function receives:
| Property | Type | Description |
| :------------- | :------- | :---------------------------------------- |
| `ctx` | `TCtx` | Accumulated context from prior middleware |
| `connectionId` | `string` | Stable connection identifier |
| `actor` | `string` | Actor name being called |
| `actorId` | `string` | Actor instance ID |
| `method` | `string` | Method name being called |
## Error Handling
[Section titled “Error Handling”](#error-handling)
If middleware throws, the RPC call is rejected and the method handler never runs. The error message is sent back to the client as part of the `rpc:result`:
```ts
.use(async ({ connectionId }) => {
const user = await verifyToken(connectionId);
if (!user) throw new Error("Unauthorized");
return { user };
});
```
On the client:
```ts
try {
await room.send({ text: "hello" });
} catch (err) {
console.error(err.message); // "Unauthorized"
}
```
## Example: Auth + Logging
[Section titled “Example: Auth + Logging”](#example-auth--logging)
```ts
import { middleware } from "@zocket/core";
const withAuth = middleware()
.use(async ({ connectionId }) => {
const session = await verifySession(connectionId);
if (!session) throw new Error("Unauthorized");
return { userId: session.userId };
});
const withLogging = withAuth
.use(({ ctx, actor, actorId, method }) => {
console.log(`[${ctx.userId}] ${actor}/${actorId}.${method}()`);
return {};
});
// Use in actor definition
const MyActor = withLogging.actor({
state: z.object({ /* ... */ }),
methods: {
doSomething: {
handler: ({ ctx }) => {
// ctx.userId is available
},
},
},
});
```
# Protocol
> Wire format, message types, and JSON patches.
Zocket uses a JSON-based protocol over standard WebSockets. All messages are plain JSON objects with a `type` discriminant.
## Message Types
[Section titled “Message Types”](#message-types)
```ts
const MSG = {
RPC: "rpc",
RPC_RESULT: "rpc:result",
EVENT: "event",
EVENT_SUB: "event:sub",
EVENT_UNSUB: "event:unsub",
STATE_SUB: "state:sub",
STATE_UNSUB: "state:unsub",
STATE_SNAPSHOT: "state:snapshot",
STATE_PATCH: "state:patch",
};
```
## Client → Server
[Section titled “Client → Server”](#client--server)
### RPC Call
[Section titled “RPC Call”](#rpc-call)
Invoke a method on an actor instance:
```json
{
"type": "rpc",
"id": "rpc_1_m3abc",
"actor": "chat",
"actorId": "room-1",
"method": "sendMessage",
"input": { "text": "hello" }
}
```
The `id` is generated by the client (`rpc_{counter}_{timestamp}`) and used to correlate the response.
### Event Subscribe / Unsubscribe
[Section titled “Event Subscribe / Unsubscribe”](#event-subscribe--unsubscribe)
```json
{ "type": "event:sub", "actor": "chat", "actorId": "room-1" }
{ "type": "event:unsub", "actor": "chat", "actorId": "room-1" }
```
### State Subscribe / Unsubscribe
[Section titled “State Subscribe / Unsubscribe”](#state-subscribe--unsubscribe)
```json
{ "type": "state:sub", "actor": "chat", "actorId": "room-1" }
{ "type": "state:unsub", "actor": "chat", "actorId": "room-1" }
```
Subscribing to state triggers an immediate `state:snapshot` response.
## Server → Client
[Section titled “Server → Client”](#server--client)
### RPC Result
[Section titled “RPC Result”](#rpc-result)
```json
{
"type": "rpc:result",
"id": "rpc_1_m3abc",
"result": { "count": 42 }
}
```
On error:
```json
{
"type": "rpc:result",
"id": "rpc_1_m3abc",
"error": "Unauthorized"
}
```
### Event
[Section titled “Event”](#event)
Broadcast to all event subscribers for this actor instance:
```json
{
"type": "event",
"actor": "chat",
"actorId": "room-1",
"event": "newMessage",
"payload": { "text": "hello", "from": "conn_123" }
}
```
### State Snapshot
[Section titled “State Snapshot”](#state-snapshot)
Full state sent when a client first subscribes:
```json
{
"type": "state:snapshot",
"actor": "chat",
"actorId": "room-1",
"state": { "messages": [], "online": ["conn_123"] }
}
```
### State Patch
[Section titled “State Patch”](#state-patch)
Incremental update using JSON Patch (RFC 6902):
```json
{
"type": "state:patch",
"actor": "chat",
"actorId": "room-1",
"patches": [
{ "op": "add", "path": "/messages/-", "value": { "text": "hi" } }
]
}
```
## JSON Patch Operations
[Section titled “JSON Patch Operations”](#json-patch-operations)
Zocket generates three patch operations from Immer diffs:
| Operation | Description |
| :-------- | :---------------------- |
| `add` | Add a value at a path |
| `replace` | Replace value at a path |
| `remove` | Remove value at a path |
Paths follow RFC 6902 pointer syntax: `/messages/0/text`, `/players/-` (append).
## Message Builders
[Section titled “Message Builders”](#message-builders)
The `@zocket/core` package exports builder functions for constructing messages:
```ts
import { rpcCall, rpcResult, event, stateSub, stateSnapshot, statePatch } from "@zocket/core/protocol";
// Client builds:
const msg = rpcCall("chat", "room-1", "sendMessage", { text: "hi" });
// Server builds:
const result = rpcResult(msg.id, { ok: true });
const snap = stateSnapshot("chat", "room-1", currentState);
const patch = statePatch("chat", "room-1", [{ op: "add", path: "/count", value: 1 }]);
```
## Parsing
[Section titled “Parsing”](#parsing)
```ts
import { parseMessage } from "@zocket/core/protocol";
const msg = parseMessage(rawString);
// Returns typed ClientMessage | ServerMessage | null
```
Returns `null` for invalid JSON or messages missing a `type` field.
# Types
> Type utilities for inference, handles, and client API shapes.
Zocket’s type system is built around inference — you define actors once and the types flow through to the client and React layers automatically. The `@zocket/core` package exports several utility types for advanced use cases.
## InferSchema
[Section titled “InferSchema”](#inferschema)
Extract the output type from any Standard Schema:
```ts
import type { InferSchema } from "@zocket/core";
import { z } from "zod";
const UserSchema = z.object({ name: z.string(), age: z.number() });
type User = InferSchema;
// { name: string; age: number }
```
## InferState
[Section titled “InferState”](#inferstate)
Extract the state type from an `ActorDef`:
```ts
import type { InferState } from "@zocket/core";
type ChatState = InferState;
// { messages: { text: string; from: string }[] }
```
## InferMethods
[Section titled “InferMethods”](#infermethods)
Map an actor’s method definitions to callable signatures:
```ts
import type { InferMethods } from "@zocket/core";
type ChatMethods = InferMethods;
// {
// sendMessage: (input: { text: string }) => Promise;
// getHistory: () => Promise;
// }
```
Methods with an `input` schema produce `(input: T) => Promise`. Methods without input produce `() => Promise`.
## InferEvents
[Section titled “InferEvents”](#inferevents)
Map event definitions to callback signatures:
```ts
import type { InferEvents } from "@zocket/core";
type ChatEvents = InferEvents;
// {
// newMessage: (payload: { text: string; from: string }) => void;
// }
```
## ActorHandle
[Section titled “ActorHandle”](#actorhandle)
The client-facing typed handle for an actor instance. This is what `client.chat("room-1")` returns:
```ts
type ActorHandle = InferMethods & {
on: >(
event: K,
callback: InferEvents[K],
) => Unsubscribe;
state: {
subscribe: (listener: (state: InferState) => void) => Unsubscribe;
getSnapshot: () => InferState | undefined;
};
meta: ActorHandleMeta;
};
interface ActorHandleMeta {
name: string;
id: string;
dispose: () => void;
}
```
## ClientApi
[Section titled “ClientApi”](#clientapi)
Maps an `AppDef` to the client’s top-level API shape:
```ts
type ClientApi = {
[K in keyof T["actors"]]: (id: string) => ActorHandle;
};
```
So for an app with `{ actors: { chat: ChatRoom, game: GameMatch } }`, the client type is:
```ts
{
chat: (id: string) => ActorHandle;
game: (id: string) => ActorHandle;
}
```
## TypedEmitFn
[Section titled “TypedEmitFn”](#typedemitfn)
Constrains event names and payload types to declared events:
```ts
type TypedEmitFn = (
event: K,
payload: InferSchema,
) => void;
```
Used internally by `MethodContext`. The `emit` function in handlers is automatically typed.
## EventPayload
[Section titled “EventPayload”](#eventpayload)
Extract the payload type from a specific event:
```ts
import type { EventPayload } from "@zocket/core";
type MsgPayload = EventPayload;
// { text: string; from: string }
```
## Wire Protocol Types
[Section titled “Wire Protocol Types”](#wire-protocol-types)
For low-level protocol work, these types are also exported:
```ts
import type {
RpcCallMessage,
RpcResultMessage,
EventMessage,
StateSnapshotMessage,
StatePatchMessage,
JsonPatchOp,
ClientMessage,
ServerMessage,
} from "@zocket/core/types";
```
See the [Protocol](/core/protocol/) page for details on each message format.
# Getting Started
> Install Zocket and build your first realtime actor in minutes.
## Installation
[Section titled “Installation”](#installation)
* bun
```bash
bun add @zocket/core @zocket/server @zocket/client zod
```
* npm
```bash
npm install @zocket/core @zocket/server @zocket/client zod
```
For React integration, also add `@zocket/react`:
```bash
bun add @zocket/react
```
## 1. Define an Actor
[Section titled “1. Define an Actor”](#1-define-an-actor)
Actors are stateful units with typed methods, events, and lifecycle hooks.
chat.ts
```ts
import { z } from "zod";
import { actor, createApp } from "@zocket/core";
const ChatRoom = actor({
state: z.object({
messages: z.array(z.object({
from: z.string(),
text: z.string(),
})).default([]),
}),
methods: {
sendMessage: {
input: z.object({ from: z.string(), text: z.string() }),
handler: ({ state, input }) => {
state.messages.push(input);
},
},
},
});
export const app = createApp({ actors: { chat: ChatRoom } });
```
## 2. Serve It
[Section titled “2. Serve It”](#2-serve-it)
server.ts
```ts
import { serve } from "@zocket/server/bun";
import { app } from "./chat";
const server = serve(app, { port: 3000 });
console.log(`Zocket on ws://localhost:${server.port}`);
```
## 3. Connect a Client
[Section titled “3. Connect a Client”](#3-connect-a-client)
client.ts
```ts
import { createClient } from "@zocket/client";
import type { app } from "./chat";
const client = createClient({ url: "ws://localhost:3000" });
// Get a typed handle for a specific actor instance
const room = client.chat("general");
// Call methods — fully typed
await room.sendMessage({ from: "Alice", text: "Hello!" });
// Subscribe to state changes
room.state.subscribe((state) => {
console.log("Messages:", state.messages);
});
// Clean up when done
room.meta.dispose();
```
## 4. Use with React
[Section titled “4. Use with React”](#4-use-with-react)
zocket.ts
```tsx
import { createClient } from "@zocket/client";
import { createZocketReact } from "@zocket/react";
import type { app } from "./chat";
export const client = createClient({ url: "ws://localhost:3000" });
export const { ZocketProvider, useActor, useActorState } = createZocketReact();
```
App.tsx
```tsx
import { ZocketProvider, useActor, useActorState, client } from "./zocket";
function Chat() {
const room = useActor("chat", "general");
const messages = useActorState(room, (s) => s.messages);
return (
{messages?.map((m, i) => (
{m.from}: {m.text}
))}
);
}
export function App() {
return (
);
}
```
## Next Steps
[Section titled “Next Steps”](#next-steps)
* [Motivation](/motivation/) — why Zocket is structured this way
* [Why Actors](/motivation/actors/) — the conceptual model behind actor-based realtime code
* [Actors](/core/actors/) — full API for state, methods, events, and lifecycle hooks
* [Middleware](/core/middleware/) — auth, context enrichment, gating
* [React Hooks](/react/hooks/) — `useActor`, `useActorState`, `useEvent`
* [Multiplayer Draw](/guides/multiplayer-draw/) — complete example walkthrough
# Authentication
> Middleware-based auth patterns for Zocket actors.
Zocket uses middleware to enforce authentication before method handlers run.
## The Pattern
[Section titled “The Pattern”](#the-pattern)
1. Create a middleware that verifies the connection
2. Throw if unauthorized — the RPC is rejected and the handler never runs
3. Return context (e.g. `userId`) that downstream handlers can use
```ts
import { middleware } from "@zocket/core";
const authed = middleware()
.use(async ({ connectionId }) => {
// Look up the session/user for this connection
const user = await getUserByConnection(connectionId);
if (!user) throw new Error("Unauthorized");
return { userId: user.id, role: user.role };
});
```
## Using in Actors
[Section titled “Using in Actors”](#using-in-actors)
```ts
const PrivateRoom = authed.actor({
state: z.object({
messages: z.array(z.object({
userId: z.string(),
text: z.string(),
})).default([]),
}),
methods: {
send: {
input: z.object({ text: z.string() }),
handler: ({ state, input, ctx }) => {
// ctx.userId is typed and guaranteed to exist
state.messages.push({ userId: ctx.userId, text: input.text });
},
},
},
});
```
## Role-Based Access
[Section titled “Role-Based Access”](#role-based-access)
Chain middleware for role checks:
```ts
const adminOnly = authed
.use(({ ctx }) => {
if (ctx.role !== "admin") throw new Error("Forbidden");
return { isAdmin: true as const };
});
const AdminPanel = adminOnly.actor({
state: z.object({ /* ... */ }),
methods: {
dangerousAction: {
handler: ({ ctx }) => {
// ctx.userId, ctx.role, ctx.isAdmin all available
},
},
},
});
```
## JWT Verification Example
[Section titled “JWT Verification Example”](#jwt-verification-example)
```ts
import { middleware } from "@zocket/core";
import { verify } from "jsonwebtoken";
// Store tokens per connection (e.g., set during an initial "auth" method call)
const connectionTokens = new Map();
const jwtAuth = middleware()
.use(async ({ connectionId }) => {
const token = connectionTokens.get(connectionId);
if (!token) throw new Error("No token");
try {
const payload = verify(token, process.env.JWT_SECRET!) as {
sub: string;
role: string;
};
return { userId: payload.sub, role: payload.role };
} catch {
throw new Error("Invalid token");
}
});
```
## Client-Side Error Handling
[Section titled “Client-Side Error Handling”](#client-side-error-handling)
When middleware throws, the client’s RPC promise rejects with the error message:
```ts
try {
await room.send({ text: "hello" });
} catch (err) {
if (err.message === "Unauthorized") {
// Redirect to login
}
}
```
## Actors Without Auth
[Section titled “Actors Without Auth”](#actors-without-auth)
Actors created with the plain `actor()` function (not through middleware) have no authentication — all connections can call all methods. Use this for public actors like lobbies or status pages.
# Multiplayer Draw
> Full walkthrough of the example-draw package — a multiplayer drawing game built with Zocket.
This guide walks through `packages/example-draw`, a complete multiplayer drawing & guessing game built with Zocket.
## Overview
[Section titled “Overview”](#overview)
Players join a room, take turns drawing a secret word on a shared canvas, and others try to guess it. The game demonstrates:
* Actor state with complex schemas (players, strokes, phases)
* Typed methods with input validation
* Events (`correctGuess`)
* Lifecycle hooks (`onDisconnect` to clean up players)
* React hooks (`useActor`, `useActorState`, `useEvent`)
## 1. Game Actor
[Section titled “1. Game Actor”](#1-game-actor)
The `DrawingRoom` actor manages all game state:
game.ts
```ts
import { z } from "zod";
import { actor, createApp } from "@zocket/core";
const Stroke = z.object({
points: z.array(z.tuple([z.number(), z.number()])),
color: z.string(),
width: z.number(),
});
export const DrawingRoom = actor({
state: z.object({
players: z.array(z.object({
id: z.string(),
name: z.string(),
score: z.number(),
color: z.string(),
connectionId: z.string().default(""),
})).default([]),
phase: z.enum(["lobby", "drawing", "roundEnd"]).default("lobby"),
drawerId: z.string().default(""),
word: z.string().default(""),
hint: z.string().default(""),
strokes: z.array(Stroke).default([]),
guesses: z.array(z.object({
playerId: z.string(),
name: z.string(),
text: z.string(),
correct: z.boolean(),
})).default([]),
round: z.number().default(0),
maxRounds: z.number().default(3),
}),
methods: {
join: {
input: z.object({ name: z.string() }),
handler: ({ state, input, connectionId }) => {
// Reconnect if player exists
const existing = state.players.find((p) => p.name === input.name);
if (existing) {
existing.connectionId = connectionId;
return { playerId: existing.id, color: existing.color };
}
// New player
const playerId = Math.random().toString(36).slice(2, 10);
const color = PLAYER_COLORS[state.players.length % PLAYER_COLORS.length];
state.players.push({
id: playerId, name: input.name,
score: 0, color, connectionId,
});
return { playerId, color };
},
},
startRound: {
handler: ({ state }) => {
if (state.players.length < 2) throw new Error("Need at least 2 players");
state.round += 1;
state.strokes = [];
state.guesses = [];
state.drawerId = state.players[(state.round - 1) % state.players.length].id;
state.word = pickRandom(WORDS);
state.hint = generateHint(state.word);
state.phase = "drawing";
},
},
draw: {
input: z.object({ stroke: Stroke }),
handler: ({ state, input }) => {
if (state.phase === "drawing") state.strokes.push(input.stroke);
},
},
guess: {
input: z.object({ playerId: z.string(), text: z.string() }),
handler: ({ state, input, emit }) => {
if (state.phase !== "drawing") return { correct: false };
const player = state.players.find((p) => p.id === input.playerId);
if (!player) return { correct: false };
const correct = input.text.trim().toLowerCase() === state.word.toLowerCase();
state.guesses.push({
playerId: input.playerId, name: player.name,
text: correct ? "Guessed correctly!" : input.text,
correct,
});
if (correct) {
player.score += 10;
emit("correctGuess", { name: player.name, word: state.word });
state.phase = "roundEnd";
}
return { correct };
},
},
},
events: {
correctGuess: z.object({ name: z.string(), word: z.string() }),
},
onDisconnect({ state, connectionId }) {
const idx = state.players.findIndex((p) => p.connectionId === connectionId);
if (idx === -1) return;
const wasDrawer = state.players[idx].id === state.drawerId;
state.players.splice(idx, 1);
if (wasDrawer && state.phase === "drawing") {
state.phase = "lobby";
state.word = "";
state.strokes = [];
}
},
});
export const app = createApp({ actors: { draw: DrawingRoom } });
```
Tip
The `onDisconnect` hook handles player cleanup and resets the game if the drawer leaves mid-round.
## 2. Server
[Section titled “2. Server”](#2-server)
The entire server is two lines:
server.ts
```ts
import { serve } from "@zocket/server/bun";
import { app } from "./game";
const server = serve(app, { port: 3001 });
console.log(`Zocket server on ws://localhost:${server.port}`);
```
## 3. Client Setup
[Section titled “3. Client Setup”](#3-client-setup)
src/zocket.ts
```ts
import { createClient } from "@zocket/client";
import { createZocketReact } from "@zocket/react";
import type { app } from "../game";
export const client = createClient({
url: "ws://localhost:3001",
});
export const {
ZocketProvider,
useClient,
useActor,
useEvent,
useActorState,
} = createZocketReact();
```
## 4. React Components
[Section titled “4. React Components”](#4-react-components)
### App Shell
[Section titled “App Shell”](#app-shell)
src/App.tsx
```tsx
function RoomView() {
const roomId = window.location.hash.slice(1) || "room-1";
const room = useActor("draw", roomId);
const phase = useActorState(room, (s) => s.phase);
const [playerId, setPlayerId] = useState(null);
if (!phase || phase === "lobby") {
return ;
}
return ;
}
export function App() {
return (
);
}
```
### State Selectors
[Section titled “State Selectors”](#state-selectors)
Components subscribe to exactly the state they need:
```tsx
// Only re-renders when phase changes
const phase = useActorState(room, (s) => s.phase);
// Only re-renders when players array changes
const players = useActorState(room, (s) => s.players);
// Only re-renders when strokes change (for the canvas)
const strokes = useActorState(room, (s) => s.strokes);
```
### Event Handling
[Section titled “Event Handling”](#event-handling)
```tsx
// Show a toast when someone guesses correctly
useEvent(room, "correctGuess", ({ name, word }) => {
toast(`${name} guessed "${word}"!`);
});
```
## Key Takeaways
[Section titled “Key Takeaways”](#key-takeaways)
1. **One actor = one game room** — state, methods, events, and lifecycle in a single definition
2. **Sequential execution** — no race conditions on guesses or draws
3. **Selective subscriptions** — components only re-render for the state they use
4. **Lifecycle management** — `onDisconnect` handles player cleanup automatically
5. **Two-line server** — `serve(app, { port })` is all you need
# State Management
> Immer mutations, JSON patches, and client-side subscriptions.
Zocket’s state management flows from server-side Immer mutations through JSON patches to client-side subscriptions.
## Server: Immer Mutations
[Section titled “Server: Immer Mutations”](#server-immer-mutations)
Inside method handlers and lifecycle hooks, `state` is an Immer draft. Mutate it directly:
```ts
handler: ({ state, input }) => {
// Direct mutations — Immer tracks all changes
state.messages.push(input.message);
state.count += 1;
state.players[0].score = 100;
}
```
After the handler returns, Immer generates a list of JSON Patch operations representing the diff.
## Patch Generation
[Section titled “Patch Generation”](#patch-generation)
Zocket converts Immer patches to RFC 6902 JSON Patches:
```ts
// Immer patch: { op: "add", path: ["messages", 2], value: { text: "hi" } }
// JSON Patch: { op: "add", path: "/messages/2", value: { text: "hi" } }
```
Only `add`, `replace`, and `remove` operations are generated.
## Broadcast
[Section titled “Broadcast”](#broadcast)
If any state subscribers are connected, patches are broadcast as `state:patch` messages:
```json
{
"type": "state:patch",
"actor": "chat",
"actorId": "room-1",
"patches": [
{ "op": "add", "path": "/messages/-", "value": { "text": "hi" } }
]
}
```
## Client: State Store
[Section titled “Client: State Store”](#client-state-store)
Each actor handle has a `StateStore` that maintains a local copy of the state:
1. **Snapshot** — received on first subscription, replaces local state entirely
2. **Patches** — applied incrementally using `structuredClone` + patch logic
3. **Notification** — all subscribers are called after each update
```ts
// Subscribe to state changes
const unsub = room.state.subscribe((state) => {
console.log("Messages:", state.messages);
});
// Read current state
const current = room.state.getSnapshot();
```
## React: Selectors
[Section titled “React: Selectors”](#react-selectors)
Use `useActorState` with a selector to subscribe to specific parts of state:
```tsx
// Only re-renders when `phase` changes
const phase = useActorState(room, (s) => s.phase);
// Only re-renders when the number of players changes
const playerCount = useActorState(room, (s) => s.players.length);
// Full state (re-renders on every change)
const state = useActorState(room);
```
The selector runs locally on every state update. The hook uses `useSyncExternalStore` for correct concurrent mode behavior.
### Caching
[Section titled “Caching”](#caching)
The selector result is cached — if the raw state reference hasn’t changed, the previous selected value is reused. This prevents unnecessary re-renders.
## Best Practices
[Section titled “Best Practices”](#best-practices)
### Keep State Flat
[Section titled “Keep State Flat”](#keep-state-flat)
```ts
// Good — flat, easy to patch
state: z.object({
players: z.array(PlayerSchema).default([]),
phase: z.enum(["lobby", "playing"]).default("lobby"),
round: z.number().default(0),
})
// Avoid — deeply nested, harder to select efficiently
state: z.object({
game: z.object({
round: z.object({
phase: z.string(),
players: z.array(/* ... */),
})
})
})
```
### Use Selectors
[Section titled “Use Selectors”](#use-selectors)
Don’t subscribe to the full state when you only need one field:
```tsx
// Good — minimal re-renders
const phase = useActorState(room, (s) => s.phase);
// Avoid — re-renders on every state change
const state = useActorState(room);
const phase = state?.phase;
```
### Avoid New References in Selectors
[Section titled “Avoid New References in Selectors”](#avoid-new-references-in-selectors)
```tsx
// Avoid — creates a new array every time
const sorted = useActorState(room, (s) =>
[...s.players].sort((a, b) => b.score - a.score)
);
// Better — select the data, sort in useMemo
const players = useActorState(room, (s) => s.players);
const sorted = useMemo(
() => players ? [...players].sort((a, b) => b.score - a.score) : [],
[players]
);
```
# Legacy Documentation (v1)
> Documentation for the original Zocket v1 router/procedure-based API.
Legacy API
This section documents the **old v1 API** based on routers and procedures. The current version of Zocket uses an actor-based architecture. See [Getting Started](/getting-started/) for the new API.
These pages are preserved for reference. The v1 API used:
* **Routers** with `.outgoing()` and `.incoming()` chains
* **Procedures** via `zo.message.input(...).handle(...)`
* **Room-based broadcasting** via `send.*.broadcast()` / `.toRoom()`
* **Connection context** via `zocket.create({ headers, onConnect })`
If you’re starting a new project, use the v2 actor-based API instead.
# Client
> [Legacy v1] Vanilla TypeScript/JavaScript client usage
> **This documents the old v1 API.** See [Creating a Client](/client/creating-a-client/) for the current version.
The Zocket client (`@zocket/client`) is a typed WebSocket client that generates methods from your router type.
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
```typescript
import { createZocketClient } from "@zocket/client";
import type { AppRouter } from "./server";
const client = createZocketClient("ws://localhost:3000");
// Listen
client.on.chat.message((msg) => console.log(msg));
// Send
client.chat.post({ text: "Hi" });
```
# Client Configuration
> [Legacy v1] Reconnection, headers, and lifecycle hooks
> **This documents the old v1 API.** See [Creating a Client](/client/creating-a-client/) for the current version.
## Initialization Options
[Section titled “Initialization Options”](#initialization-options)
```typescript
const client = createZocketClient("ws://localhost:3000", {
headers: {
authorization: "Bearer token123",
},
onOpen: () => console.log("Connected"),
onClose: () => console.log("Disconnected"),
debug: process.env.NODE_ENV === "development",
});
```
## Reconnection Logic
[Section titled “Reconnection Logic”](#reconnection-logic)
```typescript
client.onClose(() => {
setTimeout(() => client.reconnect(), 5000);
});
```
## Error Handling
[Section titled “Error Handling”](#error-handling)
```typescript
client.onError((error) => {
console.error("WebSocket Error:", error);
});
```
Properties: `client.readyState`, `client.lastError`.
# Usage Patterns
> [Legacy v1] RPC, subscriptions, and type safety
> **This documents the old v1 API.** See [Actor Handles](/client/actor-handles/) for the current version.
## Subscriptions (Listening)
[Section titled “Subscriptions (Listening)”](#subscriptions-listening)
```typescript
const unsubscribe = client.on.system.notification((data) => {
toast(data.message);
});
unsubscribe();
```
## RPC (Request / Response)
[Section titled “RPC (Request / Response)”](#rpc-request--response)
```typescript
const user = await client.users.get({ id: "123" });
```
### Timeouts
[Section titled “Timeouts”](#timeouts)
Fixed 10-second RPC timeout. If the server never responds, the promise rejects.
## Fire-and-forget
[Section titled “Fire-and-forget”](#fire-and-forget)
If the handler returns `void`, the client method is typed as `void`:
```ts
client.analytics.track({ event: "click" });
```
# Core Concepts
> [Legacy v1] Routers, messages, context, and middleware
> **This documents the old v1 API.** See [Getting Started](/getting-started/) for the current version.
Zocket is built around a few key concepts that enable its type safety and developer experience.
## Routers
[Section titled “Routers”](#routers)
A **Router** is the contract between your server and client. It defines the structure of your WebSocket application. A router consists of two main parts:
### Outgoing (`.outgoing()`)
[Section titled “Outgoing (.outgoing())”](#outgoing-outgoing)
These are definitions of messages that the **Server sends to the Client**. You define the shape of the data using a schema (like Zod).
```typescript
.outgoing({
chat: {
message: z.object({ text: z.string(), from: z.string() }),
typing: z.object({ userId: z.string(), isTyping: z.boolean() })
}
})
```
### Incoming (`.incoming()`)
[Section titled “Incoming (.incoming())”](#incoming-incoming)
These are handlers for messages that the **Client sends to the Server**. You define the input schema and the handler function.
```typescript
.incoming(({ send }) => ({
chat: {
sendMessage: zo.message
.input(z.object({ text: z.string() }))
.handle(({ ctx, input }) => {
// Handle the message
})
}
}))
```
## Messages
[Section titled “Messages”](#messages)
A **Message** (procedure) definition in the incoming router specifies:
1. **Input schema** (optional): validates data sent by the client
2. **Middleware** (optional): augments `ctx` and/or blocks execution
3. **Handler**: runs when the message is received
```typescript
zo.message
.input(z.object({ roomId: z.string() }))
.handle(({ ctx, input }) => {
ctx.rooms.join(input.roomId);
});
```
## Context
[Section titled “Context”](#context)
The **Context** (`ctx`) is an object that is available in every message handler. It is created when a client connects and persists for the duration of the connection.
```typescript
const zo = zocket.create({
headers: z.object({ token: z.string() }),
onConnect: (headers, clientId) => {
const user = verifyToken(headers.token);
return { user, db: getDbConnection() };
}
});
```
### Built-in Context
[Section titled “Built-in Context”](#built-in-context)
* `ctx.clientId`: The unique ID of the connected client.
* `ctx.rooms`: Room helpers (`join`, `leave`, `has`, `current`).
## Middleware
[Section titled “Middleware”](#middleware)
Middleware allows you to wrap message handlers with common logic.
```typescript
const requireUser = zo.message.use(({ ctx }) => {
if (!ctx.user) throw new Error("Unauthorized");
return { user: ctx.user };
});
```
## JSON payloads
[Section titled “JSON payloads”](#json-payloads)
Zocket’s transport is JSON. Prefer JSON-friendly types (strings, numbers, booleans, objects, arrays).
# Getting Started
> [Legacy v1] Get up and running with Zocket v1 in minutes
> **This documents the old v1 API.** See [Getting Started](/getting-started/) for the current version.
This guide will walk you through creating a simple real-time application with Zocket.
## Installation
[Section titled “Installation”](#installation)
Install the core package, the client package, and Zod (or your preferred schema validation library).
```bash
bun add @zocket/core @zocket/client zod
```
If you are using React, you can also add the React hooks package:
```bash
bun add @zocket/react
```
## Quick Start
[Section titled “Quick Start”](#quick-start)
### 1. Define your Router
[Section titled “1. Define your Router”](#1-define-your-router)
Create a router on your server. This defines the messages your server can send (`outgoing`) and the messages it can receive (`incoming`).
server.ts
```typescript
import { z } from "zod";
import { zocket, createBunServer } from "@zocket/core";
const zo = zocket.create({
headers: z.object({
authorization: z.string().default("guest"),
}),
onConnect: (headers) => {
return { userId: headers.authorization };
},
});
export const appRouter = zo
.router()
.outgoing({
chat: {
message: z.object({ text: z.string(), from: z.string() }),
},
})
.incoming(({ send }) => ({
chat: {
post: zo.message
.input(z.object({ text: z.string() }))
.handle(({ ctx, input }) => {
send.chat
.message({
text: input.text,
from: ctx.userId,
})
.broadcast();
}),
},
}));
export type AppRouter = typeof appRouter;
const handlers = createBunServer(appRouter, zo);
Bun.serve({
fetch: handlers.fetch,
websocket: handlers.websocket,
port: 3000,
});
```
### 2. Create a Client
[Section titled “2. Create a Client”](#2-create-a-client)
On the client side, you can now connect to your server with full type safety.
client.ts
```typescript
import { createZocketClient } from "@zocket/client";
import type { AppRouter } from "./server";
const client = createZocketClient("ws://localhost:3000", {
headers: { authorization: "Alice" },
});
client.on.chat.message((data) => {
console.log(`${data.from}: ${data.text}`);
});
client.chat.post({ text: "Hello world!" });
```
# Authentication (v1)
> [Legacy v1] Secure your WebSocket connections
> **This documents the old v1 API.** See [Authentication](/guides/authentication/) for the current version.
Authentication happens during the initial connection handshake. Headers are sent as URL query parameters.
### Server Side
[Section titled “Server Side”](#server-side)
```typescript
const zo = zocket.create({
headers: z.object({ token: z.string() }),
onConnect: async (headers) => {
const user = await verifyToken(headers.token);
if (!user) return { user: null };
return { user };
},
});
```
### Client Side
[Section titled “Client Side”](#client-side)
```typescript
const client = createZocketClient("ws://localhost:3000", {
headers: { token: "user-session-token" },
});
```
# Error Handling (v1)
> [Legacy v1] Managing errors on both server and client
> **This documents the old v1 API.** See [Getting Started](/getting-started/) for the current version.
## Procedure (RPC) errors
[Section titled “Procedure (RPC) errors”](#procedure-rpc-errors)
Thrown errors are not serialized back to the client. The client call may reject due to RPC timeout.
### Recommended pattern
[Section titled “Recommended pattern”](#recommended-pattern)
```typescript
// Server — return a typed result
.handle(({ input }) => {
if (!isValid(input)) {
return { ok: false as const, error: "VALIDATION_FAILED" as const };
}
return { ok: true as const };
})
// Client
const res = await client.doSomething(/* ... */);
if (!res.ok) console.error(res.error);
```
## Connection errors
[Section titled “Connection errors”](#connection-errors)
```typescript
client.onError((err) => {
console.error("Socket error:", err);
});
```
# Type Inference (v1)
> [Legacy v1] Getting the most out of TypeScript
> **This documents the old v1 API.** See [Types](/core/types/) for the current version.
Always export your router type from the server and import it (as a type) in the client:
server.ts
```typescript
export type AppRouter = typeof appRouter;
// client.ts
import type { AppRouter } from "./server";
const client = createZocketClient(...);
```
This keeps your bundle size small — the runtime router code is not imported.
# Introduction
> [Legacy v1] Type-safe WebSocket library with end-to-end type safety
> **This documents the old v1 API.** See [Getting Started](/getting-started/) for the current version.
**Zocket** is a type-safe WebSocket library that provides end-to-end type safety between client and server, inspired by tRPC. Build real-time applications with confidence using TypeScript and your favorite schema validation library (Zod, Valibot, or any Standard Schema compatible library).
## Key Features
[Section titled “Key Features”](#key-features)
* **End-to-end Type Safety**: Full TypeScript inference from server to client. Change your server code, and your client code updates automatically.
* **Schema Validation**: Works with Zod, Valibot, and any Standard Schema compatible library to validate inputs and outputs.
* **Real-time Rooms**: Built-in support for WebSocket rooms/channels for targeted broadcasting (e.g., chat rooms, notifications).
* **Middleware Support**: Composable middleware for authentication, logging, error handling, and context injection.
* **Runtime Agnostic**: Server is adapter-based (Bun adapter included today) and the client works anywhere with `WebSocket`.
* **Framework Agnostic**: Use with any framework (Next.js, Nuxt, React, Vue) or vanilla JS/TS.
## Why Zocket?
[Section titled “Why Zocket?”](#why-zocket)
Building real-time applications often involves manually syncing types between the backend and frontend. If you change a message structure on the server, you have to remember to update the client code.
**Zocket solves this by inferring types directly from your server router.**
### vs. Socket.io
[Section titled “vs. Socket.io”](#vs-socketio)
Socket.io is great but lacks built-in end-to-end type safety. You often have to maintain shared interface definitions manually. Zocket gives you the same real-time capabilities (rooms, broadcasting) but with the developer experience of tRPC.
### vs. tRPC
[Section titled “vs. tRPC”](#vs-trpc)
tRPC is amazing for request/response (HTTP/RPC) flows. Zocket brings that same DX to **WebSockets** and **Event-driven** architectures, where the server can push data to the client at any time, not just in response to a request.
# Limitations (v1)
> [Legacy v1] Known limitations of the v1 API
> **This documents the old v1 API.** See [Getting Started](/getting-started/) for the current version.
## Resilience & Connection Management
[Section titled “Resilience & Connection Management”](#resilience--connection-management)
* No auto-reconnection
* No backoff strategies
* No heartbeats/ping-pong
## React State Management
[Section titled “React State Management”](#react-state-management)
* No caching/deduplication
* No window focus refetching
* No optimistic updates
## Configuration & Timeouts
[Section titled “Configuration & Timeouts”](#configuration--timeouts)
* Hardcoded 10-second RPC timeout
* JSON serialization only
## Scalability
[Section titled “Scalability”](#scalability)
* Single-node pub/sub only
* No distributed pub/sub adapter
## Testing
[Section titled “Testing”](#testing)
* Requires full WebSocket server for testing
* No mock utilities
# Protocol (v1)
> [Legacy v1] Message protocol specification
> **This documents the old v1 API.** See [Protocol](/core/protocol/) for the current version.
Zocket uses a simple, JSON-based protocol over standard WebSockets.
## Transport & Serialization
[Section titled “Transport & Serialization”](#transport--serialization)
* **Transport**: Standard WebSockets (`ws://` or `wss://`).
* **Serialization**: Every message is a JSON-encoded object.
## Connection Lifecycle
[Section titled “Connection Lifecycle”](#connection-lifecycle)
### Handshake & Authentication
[Section titled “Handshake & Authentication”](#handshake--authentication)
Because the browser’s native `WebSocket` API does not support custom HTTP headers, Zocket uses **URL Query Parameters** for initial authentication.
1. The client connects to the server URL
2. Headers defined in `zocket.create({ headers: ... })` are passed as query parameters
3. The server validates the headers against the schema before completing the WebSocket upgrade
### Client ID
[Section titled “Client ID”](#client-id)
Upon successful connection, the server assigns a unique `clientId`.
### Protocol Versioning
[Section titled “Protocol Versioning”](#protocol-versioning)
`@zocket/client` automatically appends its version via the `x-zocket-version` query parameter.
## Message Formats
[Section titled “Message Formats”](#message-formats)
### Standard Message
[Section titled “Standard Message”](#standard-message)
```json
{
"type": "chat.message",
"payload": { "text": "Hello!", "from": "user_1" }
}
```
### RPC (Request/Response)
[Section titled “RPC (Request/Response)”](#rpc-requestresponse)
**Request (Client → Server):**
```json
{
"type": "users.getProfile",
"payload": { "id": "123" },
"rpcId": "unique-msg-id-456"
}
```
**Response (Server → Client):**
```json
{
"type": "__rpc_res",
"payload": { "name": "John" },
"rpcId": "unique-msg-id-456"
}
```
## Internal Mechanisms
[Section titled “Internal Mechanisms”](#internal-mechanisms)
### Efficient Broadcasting
[Section titled “Efficient Broadcasting”](#efficient-broadcasting)
Zocket uses a reserved internal topic `__zocket_all__` for global broadcast using the native adapter’s `publish` method.
### Rooms
[Section titled “Rooms”](#rooms)
Rooms are implemented using the underlying WebSocket engine’s pub/sub system (`ws.subscribe(roomId)`, `ws.unsubscribe(roomId)`).
# React
> [Legacy v1] Type-safe hooks for React applications
> **This documents the old v1 API.** See [React Setup](/react/setup/) for the current version.
## Setup
[Section titled “Setup”](#setup)
src/utils/zocket.ts
```tsx
import { createZocketReact } from "@zocket/react";
import type { AppRouter } from "path-to-your-server-router-type";
export const zocket = createZocketReact();
```
## Provider
[Section titled “Provider”](#provider)
```tsx
import { createZocketClient } from "@zocket/client";
import { zocket } from "./utils/zocket";
const client = createZocketClient("ws://localhost:3000");
export default function App() {
return (
);
}
```
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
```tsx
function MyComponent() {
const client = zocket.useClient();
const { status } = zocket.useConnectionState();
return
;
}
```
# Server
> [Legacy v1] Server-side setup and message handling
> **This documents the old v1 API.** See [Bun Adapter](/server/bun-adapter/) for the current version.
To create a Zocket server, you create:
1. A Zocket instance (`zocket.create(...)`)
2. A router (`zo.router().outgoing(...).incoming(...)`)
3. A server adapter (today: Bun via `createBunServer`)
## Server Initialization
[Section titled “Server Initialization”](#server-initialization)
```typescript
import { zocket, createBunServer } from "@zocket/core";
import { z } from "zod";
const zo = zocket.create({
headers: z.object({ authorization: z.string().optional() }),
onConnect: (headers, clientId) => ({ userId: headers.authorization ?? null }),
onDisconnect: (ctx, clientId) => {
console.log(`Client disconnected: ${clientId}`);
}
});
const appRouter = zo
.router()
.outgoing({
system: {
announcement: z.object({ text: z.string() }),
},
})
.incoming(({ send }) => ({
system: {
announce: zo.message
.input(z.object({ text: z.string() }))
.handle(({ input }) => {
send.system.announcement({ text: input.text }).broadcast();
}),
},
}));
const handlers = createBunServer(appRouter, zo);
Bun.serve({
fetch: handlers.fetch,
websocket: handlers.websocket,
port: 3000,
});
```
## Sending Messages
[Section titled “Sending Messages”](#sending-messages)
The `send` object mirrors your `outgoing` router definition. You can also use `handlers.send` for server push outside handlers.
# Adapters
> [Legacy v1] Run Zocket on different runtimes
> **This documents the old v1 API.** See [Bun Adapter](/server/bun-adapter/) for the current version.
Zocket’s server is adapter-based.
## Bun (recommended)
[Section titled “Bun (recommended)”](#bun-recommended)
```ts
import { zocket, createBunServer } from "@zocket/core";
const zo = zocket.create({ /* ... */ });
const appRouter = zo.router().outgoing({ /* ... */ }).incoming(() => ({}));
const handlers = createBunServer(appRouter, zo);
Bun.serve({
fetch: handlers.fetch,
websocket: handlers.websocket,
port: 3000,
});
```
## Custom adapter
[Section titled “Custom adapter”](#custom-adapter)
If your runtime provides a WebSocket connection with `send`, `close`, and optional pub/sub, you can wire it via `createServer`:
```ts
import { createServer, type ServerAdapter } from "@zocket/core";
const adapter: ServerAdapter = {
start({ port, hostname, onUpgrade, onOpen, onMessage, onClose }) {
return {
port: port ?? 3000,
stop() { /* ... */ },
publish(topic, message) { /* optional */ },
};
},
};
const server = createServer(appRouter, zo, adapter, { port: 3000 });
```
# Broadcasting
> [Legacy v1] Targeting messages to specific clients
> **This documents the old v1 API.** See [Actors](/core/actors/) for the current version.
When you construct a message, you must specify who receives it.
### Broadcast
[Section titled “Broadcast”](#broadcast)
Sends the message to all connected clients.
```typescript
send.chat.message({ ... }).broadcast();
```
### Direct Message
[Section titled “Direct Message”](#direct-message)
Sends the message to specific client IDs.
```typescript
send.chat.message({ ... }).to([targetClientId]);
```
### Rooms
[Section titled “Rooms”](#rooms)
Sends the message to all clients in a specific room.
```typescript
send.chat.message({ ... }).toRoom(["room-123"]);
```
# Context
> [Legacy v1] State management per connection
> **This documents the old v1 API.** See [Middleware](/core/middleware/) for the current version.
Context (`ctx`) persists for the lifetime of a WebSocket connection.
## Creating Context
[Section titled “Creating Context”](#creating-context)
```typescript
const zo = zocket.create({
headers: z.object({
authorization: z.string().optional(),
}),
onConnect: async (headers, clientId) => {
const user = headers.authorization
? await verifyToken(headers.authorization)
: null;
return { user, connectedAt: new Date() };
},
});
```
## Built-in Context
[Section titled “Built-in Context”](#built-in-context)
| Property | Type | Description |
| :--------- | :--------------- | :----------------------------------- |
| `clientId` | `string` | Unique identifier for the connection |
| `rooms` | `RoomOperations` | Helper to join/leave rooms |
### Rooms API
[Section titled “Rooms API”](#rooms-api)
* `join(roomId)` — Add the connection to a room
* `leave(roomId)` — Remove from a room
* `has(roomId)` — Check membership
* `current` — `ReadonlySet` of joined rooms
# Middleware (v1)
> [Legacy v1] Per-message context augmentation and gating
> **This documents the old v1 API.** See [Middleware](/core/middleware/) for the current version.
Zocket middleware runs before a handler and can add derived values to `ctx` or block execution by throwing.
## Signature
[Section titled “Signature”](#signature)
```ts
const withExtras = zo.message.use(({ ctx, payload }) => {
return { /* merged into ctx */ };
});
```
## Authentication + context narrowing
[Section titled “Authentication + context narrowing”](#authentication--context-narrowing)
```ts
const requireUser = zo.message.use(({ ctx }) => {
if (!ctx.user) throw new Error("UNAUTHORIZED");
return { user: ctx.user };
});
```
## Composing middleware
[Section titled “Composing middleware”](#composing-middleware)
```ts
const requireAdmin = ({ ctx }) => {
if (ctx.userRole !== "admin") throw new Error("FORBIDDEN");
return { isAdmin: true as const };
};
const adminMessage = requireUser.use(requireAdmin);
```
## Notes
[Section titled “Notes”](#notes)
As of `@zocket/core@0.1.0`, thrown errors are not serialized back to the client. If middleware throws, the client call may reject due to RPC timeout.
# Procedures
> [Legacy v1] Defining inputs, handlers, and validation
> **This documents the old v1 API.** See [Actors](/core/actors/) for the current version.
Procedures (incoming messages) are the client → server entry points in your Zocket router.
## Anatomy of a Procedure
[Section titled “Anatomy of a Procedure”](#anatomy-of-a-procedure)
1. **Input Validation** (Optional)
2. **Middleware** (Optional)
3. **Handler** (Required)
```typescript
const sendMessage = zo.message
.use(authMiddleware)
.input(z.object({ text: z.string() }))
.handle(({ ctx, input }) => {
console.log(ctx.clientId, input.text);
});
```
## Input Validation
[Section titled “Input Validation”](#input-validation)
```typescript
.input(
z.object({
title: z.string().min(1),
tags: z.array(z.string()).max(5).optional()
})
)
```
If the input is invalid, the handler will not run.
## Async Handlers
[Section titled “Async Handlers”](#async-handlers)
Handlers can be asynchronous and return values (RPC style):
```typescript
.handle(async ({ ctx, input }) => {
const post = await db.post.create({ data: { title: input.title } });
return post;
})
```
# Rooms
> [Legacy v1] Managing WebSocket rooms and memberships
> **This documents the old v1 API.** See [Actors](/core/actors/) for the current version.
Rooms group clients for targeted broadcasting.
### Joining a Room
[Section titled “Joining a Room”](#joining-a-room)
```typescript
.incoming(({ send }) => ({
joinRoom: zo.message
.input(z.object({ roomId: z.string() }))
.handle(({ ctx, input }) => {
ctx.rooms.join(input.roomId);
})
}))
```
### Leaving a Room
[Section titled “Leaving a Room”](#leaving-a-room)
```typescript
ctx.rooms.leave("room-123");
```
### Checking Membership
[Section titled “Checking Membership”](#checking-membership)
```typescript
if (ctx.rooms.has("admin-room")) { /* ... */ }
```
### Sending to a room
[Section titled “Sending to a room”](#sending-to-a-room)
```ts
send.chat.message({ text: "hello" }).toRoom(["room-123"]);
```
# Routers
> [Legacy v1] Organizing your application logic
> **This documents the old v1 API.** See [Apps](/core/apps/) for the current version.
Routers define the structure of your API and hold your procedures.
## Creating a Router
[Section titled “Creating a Router”](#creating-a-router)
```typescript
const appRouter = zo.router()
.outgoing({ /* Server -> Client messages */ })
.incoming(({ send }) => ({ /* Client -> Server messages */ }));
```
## Modularizing Routers
[Section titled “Modularizing Routers”](#modularizing-routers)
Extract sections into helpers and compose with object spread:
```typescript
export const chatOutgoing = {
chat: {
message: z.object({ text: z.string(), from: z.string() }),
},
} as const;
export function chatIncoming({ send }) {
return {
chat: {
post: zo.message
.input(z.object({ text: z.string() }))
.handle(({ ctx, input }) => {
send.chat.message({ text: input.text, from: ctx.clientId }).broadcast();
}),
},
} as const;
}
export const appRouter = zo
.router()
.outgoing({ ...chatOutgoing })
.incoming(({ send }) => ({ ...chatIncoming({ send }) }));
```
# Why Zocket?
> [Legacy v1] Comparison with Socket.io and raw WebSockets
> **This documents the old v1 API.** See [Getting Started](/getting-started/) for the current version.
## vs. Socket.io
[Section titled “vs. Socket.io”](#vs-socketio)
| Feature | Socket.io | Zocket |
| :----------------------- | :------------------------------ | :--------------------------------- |
| **Type Safety** | Partial (via manual interfaces) | **End-to-End Inferred** |
| **Validation** | Manual | **Built-in (Zod/Valibot)** |
| **Developer Experience** | Event strings (`"chat:msg"`) | **Fluent API** (`client.chat.msg`) |
In Socket.io, you define interfaces on both client and server manually. Zocket infers types directly from your router.
## vs. Raw WebSockets
[Section titled “vs. Raw WebSockets”](#vs-raw-websockets)
With Raw WebSockets you have to build protocol parsing, routing, room management, and type safety yourself. Zocket provides structured routing, rooms, and validation out of the box.
## Summary
[Section titled “Summary”](#summary)
Use **Zocket** if you use TypeScript and want tRPC-like DX for WebSockets. Use **Socket.io** if you need HTTP long-polling fallbacks or use JavaScript without types.
# Motivation
> Why Zocket is shaped the way it is.
Zocket is opinionated on purpose.
It does not try to be a thin WebSocket wrapper or a generic event bus. It assumes that most realtime applications are built from long-lived, stateful things like rooms, matches, documents, boards, and presence channels, and it gives those things a first-class shape.
This section explains the thinking behind that design.
## Read This Section If You Want To Understand…
[Section titled “Read This Section If You Want To Understand…”](#read-this-section-if-you-want-to-understand)
* why Zocket is not just `socket.on("event")` with better TypeScript
* why state sync is built in instead of left to app code
* why the core abstraction is an actor instead of a flat message router
* what kinds of realtime systems Zocket is actually optimized for
## Start Here
[Section titled “Start Here”](#start-here)
* [Philosophy](/motivation/philosophy/) explains the broader design goals behind Zocket.
* [Why Actors](/motivation/actors/) explains why actors are the core unit in the library.
* [Why The Type Safety Is Better](/motivation/type-safety/) explains why inferred, validated types are stronger than shared `types.ts` files.
* [Comparison](/comparison/) shows where Zocket fits relative to Socket.io, PartyKit, Convex, Liveblocks, and others.
## The Short Version
[Section titled “The Short Version”](#the-short-version)
Zocket is built around a simple belief:
Realtime applications are usually collections of isolated stateful things, not a flat stream of unrelated socket messages.
Once you accept that, a lot of the API shape follows naturally:
* define server logic around those stateful things
* infer the client API from the server definition
* make state sync automatic instead of ad hoc
* process changes sequentially per instance so race conditions stay local
That is the core idea behind the rest of the docs.
# Why Actors
> Why Zocket models realtime systems around actors.
Actors are the central abstraction in Zocket because they match the natural shape of most realtime systems.
## Realtime Apps Are Usually Collections of Stateful Things
[Section titled “Realtime Apps Are Usually Collections of Stateful Things”](#realtime-apps-are-usually-collections-of-stateful-things)
Most applications are not one giant socket.
They are made from many separate, addressable things:
* a chat room
* a game match
* a drawing canvas
* a collaborative document
* a presence channel
Each of those things usually needs the same capabilities:
* it owns state
* it exposes methods that mutate that state
* it emits events to interested clients
* it cares about connections coming and going
* it must handle concurrent actions safely
That bundle of concerns is exactly what an actor is good at.
## What “Actor” Means in Zocket
[Section titled “What “Actor” Means in Zocket”](#what-actor-means-in-zocket)
In Zocket, an actor is:
* a named definition
* instantiated by ID
* with its own state
* its own methods
* its own events
* and its own lifecycle
So instead of asking:
* “what message type just arrived?”
you ask:
* “which room, match, or document is this for?”
That shift matters. It turns your server from a message router into a collection of stateful units with clear ownership.
## Why Not a Flat Bag of Handlers?
[Section titled “Why Not a Flat Bag of Handlers?”](#why-not-a-flat-bag-of-handlers)
Without an explicit unit like an actor, realtime code often collapses into routing logic:
```ts
socket.onmessage = async (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "join-room":
// load room state
// check membership
// mutate room
// broadcast update
break;
case "send-message":
// load room state again
// validate input
// append message
// broadcast event
break;
}
};
```
This works for a prototype, but it scales poorly in both senses:
* the code for one logical thing gets scattered across many handlers
* the runtime has no obvious unit to route, isolate, or place
The room exists conceptually, but your code does not reflect that.
## What It Looks Like With Actors
[Section titled “What It Looks Like With Actors”](#what-it-looks-like-with-actors)
With actors, the structure matches the problem:
```ts
const ChatRoom = actor({
state: z.object({
messages: z.array(MessageSchema).default([]),
members: z.array(z.string()).default([]),
}),
methods: {
join: {
input: z.object({ name: z.string() }),
handler: ({ state, input }) => {
state.members.push(input.name);
},
},
sendMessage: {
input: z.object({ text: z.string() }),
handler: ({ state, input, emit, connectionId }) => {
const message = { from: connectionId, text: input.text };
state.messages.push(message);
emit("message", message);
},
},
},
events: {
message: MessageSchema,
},
});
```
Now the room is not an implicit convention. It is the actual unit of code.
## Why This Shape Works Well
[Section titled “Why This Shape Works Well”](#why-this-shape-works-well)
### Local reasoning
[Section titled “Local reasoning”](#local-reasoning)
The state, methods, events, and lifecycle for one realtime thing live together. That makes the code easier to understand and change.
### Single-writer semantics
[Section titled “Single-writer semantics”](#single-writer-semantics)
One actor instance processes one method at a time. That keeps concurrency problems local and dramatically reduces the amount of locking and coordination app code needs.
### Natural scaling boundaries
[Section titled “Natural scaling boundaries”](#natural-scaling-boundaries)
Rooms, matches, and documents are already the things you want to route and place in a distributed system. Actors make those boundaries explicit early.
### Better mental model
[Section titled “Better mental model”](#better-mental-model)
The API stops being “send some event name over the wire” and becomes “talk to this room” or “subscribe to this document.” That is much closer to how developers already think about their app.
## Why Zocket Uses Actors
[Section titled “Why Zocket Uses Actors”](#why-zocket-uses-actors)
Zocket does not use actors because they sound clever. It uses them because they are the right shape for most realtime problems.
If your app has many rooms, many games, or many documents, you already have many actors. Zocket just makes that explicit, typed, and easier to work with.
If you want the API details next, continue to the [Actors reference](/core/actors/).
# Philosophy
> The design principles behind Zocket.
Zocket exists because building realtime apps in TypeScript still usually means choosing between two bad options:
* use a raw WebSocket and hand-roll your own protocol, state sync, and concurrency model
* use an event-driven library that improves ergonomics a bit, but still leaves your actual application structure up to you
That gets a basic demo working, but it does not age well.
## The Problems Zocket Cares About
[Section titled “The Problems Zocket Cares About”](#the-problems-zocket-cares-about)
### 1. The wire is usually stringly typed
[Section titled “1. The wire is usually stringly typed”](#1-the-wire-is-usually-stringly-typed)
With raw sockets or Socket.io-style APIs, server and client are connected by event names and payload conventions.
```ts
socket.emit("chat:message", { text: "hello" });
socket.on("chat:mesage", (data) => {
console.log(data.txt);
});
```
That looks fine until you rename an event, change a payload, or misspell one side. Then the type system stops helping exactly where you need it most: at the boundary between server and client.
Zocket’s answer is to define the shape once on the server and infer the client API from that source of truth.
### 2. State sync is not a side concern
[Section titled “2. State sync is not a side concern”](#2-state-sync-is-not-a-side-concern)
Most realtime apps are not just “fire an event and forget.” They have shared state:
* a room roster
* a game board
* a document tree
* a presence map
* a running score
With lower-level tools, keeping that state in sync becomes app code:
* invent a patch protocol
* decide what to rebroadcast
* track who is subscribed
* recover when the client reconnects
Zocket treats state sync as part of the core runtime instead of something every app reimplements.
### 3. Server logic needs a shape
[Section titled “3. Server logic needs a shape”](#3-server-logic-needs-a-shape)
The hardest part of realtime code is usually not transport. It is organization.
Once an app grows beyond a few messages, you need a clear answer to questions like:
* where does the state for this room live?
* which code owns mutations to that state?
* how do concurrent actions get serialized?
* where do connection lifecycle hooks belong?
* what is the unit we route, scale, and reason about?
Zocket’s answer is: make that unit explicit.
## The Design Principles
[Section titled “The Design Principles”](#the-design-principles)
### Define once, infer everywhere
[Section titled “Define once, infer everywhere”](#define-once-infer-everywhere)
The server should be the source of truth for:
* method names
* input types
* return types
* event payloads
* state shape
The client and React layers should inherit that automatically.
### Treat state as a product feature
[Section titled “Treat state as a product feature”](#treat-state-as-a-product-feature)
State synchronization is not a helper utility in a realtime system. It is most of the product experience. So Zocket bakes it in instead of treating it like optional glue code.
### Make the unit of coordination explicit
[Section titled “Make the unit of coordination explicit”](#make-the-unit-of-coordination-explicit)
A realtime app is usually made from many isolated stateful things. The runtime should expose that directly instead of forcing everything into a global message handler.
### Prefer local reasoning
[Section titled “Prefer local reasoning”](#prefer-local-reasoning)
The more logic you can keep attached to one room, one document, or one match, the easier it is to understand, test, and operate.
## What Zocket Is Trying To Feel Like
[Section titled “What Zocket Is Trying To Feel Like”](#what-zocket-is-trying-to-feel-like)
Not:
* a thin transport wrapper
* a generic pub/sub utility
* a bag of event handlers with better autocomplete
But:
* an application runtime for stateful realtime units
* with typed methods
* built-in state sync
* and clear ownership over where logic lives
That is why the rest of the library looks the way it does.
If you want the deeper reasoning behind the core abstraction, continue to [Why Actors](/motivation/actors/).
# Why The Type Safety Is Better
> Why inferred, validated types are stronger than manually maintained shared type files.
Many realtime tools can be made “type-safe” if you are disciplined enough.
You can create a shared `types.ts` file, export interfaces, and make sure both server and client import them.
That is better than plain `any`.
But it is still not the same thing as Zocket’s type safety.
The difference is this:
* explicit type files describe what you hope the system does
* Zocket’s types are derived from what the system actually does
That sounds subtle, but it changes a lot in practice.
## The Easy Example
[Section titled “The Easy Example”](#the-easy-example)
Imagine a chat room with:
* state: a list of messages
* one method: `sendMessage`
* one event: `newMessage`
### The explicit type file approach
[Section titled “The explicit type file approach”](#the-explicit-type-file-approach)
You might start with a shared file like this:
types.ts
```ts
export type Message = {
from: string;
text: string;
};
export interface ChatEvents {
newMessage: Message;
}
export interface ChatMethods {
sendMessage: { text: string };
}
export interface ChatState {
messages: Message[];
}
```
Then the server tries to follow it:
server.ts
```ts
import type { ChatEvents, ChatMethods, ChatState } from "./types";
const state: ChatState = { messages: [] };
socket.on("sendMessage", (input: ChatMethods["sendMessage"]) => {
const msg = { from: socket.id, text: input.text };
state.messages.push(msg);
io.emit("newMessage", msg satisfies ChatEvents["newMessage"]);
});
```
And the client tries to follow it too:
client.ts
```ts
import type { ChatEvents, ChatMethods } from "./types";
socket.emit("sendMessage", { text: "hello" } satisfies ChatMethods["sendMessage"]);
socket.on("newMessage", (payload: ChatEvents["newMessage"]) => {
console.log(payload.text);
});
```
This looks solid.
It is still weaker than Zocket in several important ways.
## Where explicit type files break down
[Section titled “Where explicit type files break down”](#where-explicit-type-files-break-down)
### 1. The event names are still just strings
[Section titled “1. The event names are still just strings”](#1-the-event-names-are-still-just-strings)
The payload may be typed, but `"sendMessage"` and `"newMessage"` are still string conventions.
If you rename the server event and forget one client:
```ts
socket.on("new-message", (payload: ChatEvents["newMessage"]) => {
console.log(payload.text);
});
```
your shared type file does not save you. The payload type is correct. The wire contract is still broken.
In Zocket, method names and event names come directly from the actor definition, so the client API changes with the server definition.
## 2. The types can drift from the implementation
[Section titled “2. The types can drift from the implementation”](#2-the-types-can-drift-from-the-implementation)
A shared type file is a second source of truth.
That means the real system now lives in two places:
* the implementation
* the manually maintained types
Those two things drift all the time.
For example, someone updates the server logic:
```ts
const msg = {
from: socket.id,
text: input.text,
sentAt: Date.now(),
};
```
but forgets to update `Message` in `types.ts`.
Now:
* the runtime sends `{ from, text, sentAt }`
* the shared types still say `{ from, text }`
The codebase compiles. The contract is already lying.
In Zocket, the shape comes from the schema and handler definitions themselves, so there is no second file to forget.
## 3. Explicit types usually do not validate anything at runtime
[Section titled “3. Explicit types usually do not validate anything at runtime”](#3-explicit-types-usually-do-not-validate-anything-at-runtime)
TypeScript disappears at runtime.
So this client call:
```ts
socket.emit("sendMessage", { txt: 123 });
```
can still happen if the caller is untyped, casted, or coming from another environment.
Shared interfaces do not reject bad payloads on the server.
Zocket method inputs and event payloads are backed by schemas, so the same definition gives you:
* TypeScript inference at compile time
* runtime validation at the boundary
That is a much stronger guarantee.
## 4. Return types are usually not part of the manual contract
[Section titled “4. Return types are usually not part of the manual contract”](#4-return-types-are-usually-not-part-of-the-manual-contract)
With raw socket systems, request and response shapes often end up being informal:
```ts
socket.emit("sendMessage", { text: "hi" }, (result) => {
console.log(result.messageId);
});
```
Now you need another manually maintained type for the callback result.
And then another one for errors.
And another convention for timeouts.
Zocket gets the return type from the method handler itself:
```ts
handler: ({ state, input, connectionId }) => {
const msg = { id: crypto.randomUUID(), from: connectionId, text: input.text };
state.messages.push(msg);
return { messageId: msg.id };
}
```
The client immediately sees:
```ts
const result = await room.sendMessage({ text: "hi" });
// { messageId: string }
```
No separate response type file is needed.
## 5. State types are especially easy to fake
[Section titled “5. State types are especially easy to fake”](#5-state-types-are-especially-easy-to-fake)
In manual systems, people often write:
```ts
type ChatState = {
messages: Message[];
};
```
But that does not tell you:
* how the state is initialized
* whether the server actually conforms to that shape
* whether the client snapshot really matches it
* whether patches preserve that shape over time
It is only a compile-time promise.
In Zocket, state comes from the actor’s schema:
```ts
state: z.object({
messages: z.array(MessageSchema).default([]),
}),
```
That one definition drives:
* the server’s state shape
* the client’s subscribed state type
* the React selector input type
* runtime validation of the initial state shape
## 6. Tooling gets worse as your app grows
[Section titled “6. Tooling gets worse as your app grows”](#6-tooling-gets-worse-as-your-app-grows)
With explicit type files, you have to manually thread types through every layer:
* server handlers
* emitted events
* client wrappers
* React hooks
* local state selectors
That usually leads to helper types like:
```ts
type EventMap = { ... };
type MethodInputs = { ... };
type MethodResults = { ... };
type RoomState = { ... };
```
It works, but the amount of bookkeeping grows with the app.
Zocket goes the other direction: you define the actor once, and the rest is inferred.
## The same example in Zocket
[Section titled “The same example in Zocket”](#the-same-example-in-zocket)
```ts
import { z } from "zod";
import { actor, createApp } from "@zocket/core";
const MessageSchema = z.object({
id: z.string(),
from: z.string(),
text: z.string(),
});
const ChatRoom = actor({
state: z.object({
messages: z.array(MessageSchema).default([]),
}),
methods: {
sendMessage: {
input: z.object({ text: z.string() }),
handler: ({ state, input, connectionId, emit }) => {
const msg = {
id: crypto.randomUUID(),
from: connectionId,
text: input.text,
};
state.messages.push(msg);
emit("newMessage", msg);
return { messageId: msg.id };
},
},
},
events: {
newMessage: MessageSchema,
},
});
export const app = createApp({
actors: { chat: ChatRoom },
});
```
From that one definition, Zocket gives you all of this automatically.
## What Zocket infers, one by one
[Section titled “What Zocket infers, one by one”](#what-zocket-infers-one-by-one)
### Actor names
[Section titled “Actor names”](#actor-names)
From:
```ts
createApp({
actors: { chat: ChatRoom },
});
```
You get:
```ts
client.chat("general");
client.game("general"); // type error
```
A shared type file usually does not protect this unless you build another explicit API wrapper.
### Method names
[Section titled “Method names”](#method-names)
From:
```ts
methods: {
sendMessage: { ... }
}
```
You get:
```ts
await room.sendMessage({ text: "hi" });
await room.postMessage({ text: "hi" }); // type error
```
### Method input types
[Section titled “Method input types”](#method-input-types)
From:
```ts
input: z.object({ text: z.string() })
```
You get:
```ts
await room.sendMessage({ text: "hi" });
await room.sendMessage({ txt: "hi" }); // type error
await room.sendMessage({ text: 123 }); // type error
```
And at runtime, invalid payloads are rejected too.
### Method return types
[Section titled “Method return types”](#method-return-types)
From:
```ts
return { messageId: msg.id };
```
You get:
```ts
const result = await room.sendMessage({ text: "hi" });
result.messageId; // string
result.id; // type error
```
No separate “response type” has to be authored and kept in sync.
### Event names and payloads
[Section titled “Event names and payloads”](#event-names-and-payloads)
From:
```ts
events: {
newMessage: MessageSchema,
}
```
You get:
```ts
room.on("newMessage", (payload) => {
payload.text; // string
payload.sentAt; // type error
});
room.on("message", () => {}); // type error
```
And on the server:
```ts
emit("newMessage", msg); // typed and validated
emit("message", msg); // type error
```
### State shape
[Section titled “State shape”](#state-shape)
From:
```ts
state: z.object({
messages: z.array(MessageSchema).default([]),
})
```
You get:
```ts
room.state.subscribe((state) => {
state.messages[0]?.text;
});
```
And in React:
```tsx
const messages = useActorState(room, (s) => s.messages);
```
The selector input is typed from the same actor definition. No extra `ChatState` wiring is required.
### React actor names
[Section titled “React actor names”](#react-actor-names)
From:
```ts
createZocketReact()
```
You get:
```tsx
const room = useActor("chat", "general");
const game = useActor("game", "general"); // type error if "game" does not exist
```
Again, this comes from the app definition, not a separately maintained string union.
## Why this is stronger than manual types
[Section titled “Why this is stronger than manual types”](#why-this-is-stronger-than-manual-types)
Zocket’s type safety is stronger because it combines four things at once:
### 1. Single source of truth
[Section titled “1. Single source of truth”](#1-single-source-of-truth)
You define the contract in one place: the actor.
There is no separate “documentation type layer” to keep in sync with the runtime.
### 2. Inference across layers
[Section titled “2. Inference across layers”](#2-inference-across-layers)
The same definition flows into:
* server handler context
* emitted event payloads
* client methods
* state subscriptions
* React hooks
Manual type files can imitate parts of this, but only with more boilerplate and more places to drift.
### 3. Runtime validation
[Section titled “3. Runtime validation”](#3-runtime-validation)
Schemas are not just for inference. They are also checked at runtime.
That means the system is safer even when:
* someone uses `as any`
* a third-party client connects
* an old client version sends the wrong shape
Explicit interfaces cannot do that on their own.
### 4. The API surface is generated from real code
[Section titled “4. The API surface is generated from real code”](#4-the-api-surface-is-generated-from-real-code)
In Zocket, the client shape is literally derived from the server definition.
That means renaming a method or event changes the client API immediately.
With manual types, renaming often means updating:
* runtime strings
* shared type maps
* wrappers
* React helpers
That is exactly the kind of duplication that creates stale contracts.
## The honest takeaway
[Section titled “The honest takeaway”](#the-honest-takeaway)
Yes, you can get decent type safety in a raw socket setup with enough discipline.
But it is still mostly manual.
Zocket is different because its type safety is:
* inferred instead of re-declared
* validated instead of assumed
* connected to the actual runtime instead of a parallel type layer
That is why it holds up better as the app grows.
If you want the API details behind this system, see [Types](/core/types/) and [Creating a Client](/client/creating-a-client/).
# React Hooks
> useActor, useEvent, useActorState — typed hooks for realtime React apps.
All hooks are generated by `createZocketReact()` and are fully typed from your actor definitions.
## useClient
[Section titled “useClient”](#useclient)
Returns the typed client from context:
```tsx
const client = useClient();
```
Throws if used outside ``.
## useActor
[Section titled “useActor”](#useactor)
Get a stable, ref-counted `ActorHandle` for a specific actor instance:
```tsx
const room = useActor("chat", roomId);
```
* `actorName` — must match a key in your app’s `actors` (typed and autocompleted)
* `actorId` — the instance identifier (e.g. `"room-1"`)
The handle is **ref-counted** — multiple components can share the same actor without one unmount killing the other’s connection. On unmount, `meta.dispose()` is called automatically.
```tsx
function ChatRoom({ roomId }: { roomId: string }) {
const room = useActor("chat", roomId);
// Call methods
const handleSend = async (text: string) => {
await room.sendMessage({ from: "Alice", text });
};
// Subscribe to events
room.on("newMessage", (msg) => {
console.log(msg);
});
return ;
}
```
### Key Identity
[Section titled “Key Identity”](#key-identity)
The handle is recreated when `actorName` or `actorId` changes. The previous handle is disposed.
### StrictMode Compatibility
[Section titled “StrictMode Compatibility”](#strictmode-compatibility)
React StrictMode unmounts and remounts components. Zocket handles this by deferring disposal by one tick — the remount re-retains the handle before the deferred dispose fires.
## useEvent
[Section titled “useEvent”](#useevent)
Subscribe to an actor event with automatic cleanup:
```tsx
useEvent(room, "correctGuess", (payload) => {
// payload is typed from your event schema
toast(`${payload.name} guessed "${payload.word}"!`);
});
```
* Subscribes on mount, unsubscribes on unmount
* The callback ref is kept up-to-date (no stale closures)
* Equivalent to `useEffect(() => room.on("event", cb), [room])`
### Type Inference Note
[Section titled “Type Inference Note”](#type-inference-note)
For full type inference on event names and payloads, use `room.on()` directly. The `useEvent` hook accepts `string` for the event name to keep the implementation simple — but the callback payload is still inferred from the handle type.
## useActorState
[Section titled “useActorState”](#useactorstate)
Subscribe to actor state with an optional selector:
```tsx
// Full state
const state = useActorState(room);
// With selector — only re-renders when messages change
const messages = useActorState(room, (s) => s.messages);
// Derived value
const messageCount = useActorState(room, (s) => s.messages.length);
```
### How It Works
[Section titled “How It Works”](#how-it-works)
1. Uses `useSyncExternalStore` under the hood
2. Subscribes to the handle’s `StateStore`
3. If a selector is provided, it runs locally on each state update
4. Uses **shallow compare** caching — the selector result is only updated when the raw state reference changes
5. Returns `undefined` until the first state snapshot is received
### Selector Best Practices
[Section titled “Selector Best Practices”](#selector-best-practices)
```tsx
// Good — simple property access
const phase = useActorState(room, (s) => s.phase);
// Good — derived computation
const isMyTurn = useActorState(room, (s) => s.drawerId === myId);
// Avoid — creating new arrays on every state change
// (will cause re-renders because the reference changes)
const sorted = useActorState(room, (s) =>
[...s.players].sort((a, b) => b.score - a.score)
);
// Instead, sort in the component with useMemo
```
## useConnectionStatus
[Section titled “useConnectionStatus”](#useconnectionstatus)
Returns the current WebSocket connection status. Re-renders when it changes:
```tsx
const status = useConnectionStatus();
// "connecting" | "connected" | "reconnecting" | "disconnected"
```
```tsx
function ConnectionBanner() {
const status = useConnectionStatus();
if (status === "reconnecting") {
return
Reconnecting...
;
}
if (status === "disconnected") {
return
Disconnected
;
}
return null;
}
```
## Full Example
[Section titled “Full Example”](#full-example)
```tsx
import { ZocketProvider, useActor, useEvent, useActorState, client } from "./zocket";
function GameRoom({ roomId }: { roomId: string }) {
const room = useActor("draw", roomId);
const phase = useActorState(room, (s) => s.phase);
const players = useActorState(room, (s) => s.players);
useEvent(room, "correctGuess", ({ name, word }) => {
alert(`${name} guessed "${word}"!`);
});
return (
Phase: {phase}
{players?.map((p) => (
{p.name} — {p.score} pts
))}
);
}
export function App() {
return (
);
}
```
# React Setup
> Create typed hooks and providers for your Zocket app.
The `@zocket/react` package provides a factory that generates fully typed hooks and a context provider.
## Installation
[Section titled “Installation”](#installation)
```bash
bun add @zocket/react @zocket/client @zocket/core
```
## Create Hooks
[Section titled “Create Hooks”](#create-hooks)
Use `createZocketReact` to generate typed hooks from your app definition:
src/zocket.ts
```ts
import { createClient } from "@zocket/client";
import { createZocketReact } from "@zocket/react";
import type { app } from "../server/app";
export const client = createClient({
url: "ws://localhost:3000",
});
export const {
ZocketProvider,
useClient,
useActor,
useEvent,
useActorState,
useConnectionStatus,
} = createZocketReact();
```
## Add the Provider
[Section titled “Add the Provider”](#add-the-provider)
Wrap your app with `ZocketProvider`:
src/App.tsx
```tsx
import { ZocketProvider, client } from "./zocket";
export function App() {
return (
);
}
```
The provider makes the client available to all hooks via React Context.
## What You Get
[Section titled “What You Get”](#what-you-get)
| Export | Description |
| :-------------------- | :---------------------------------------------- |
| `ZocketProvider` | Context provider — wrap your app with it |
| `useClient` | Get the typed client from context |
| `useActor` | Get a ref-counted actor handle |
| `useEvent` | Subscribe to actor events with auto-cleanup |
| `useActorState` | Subscribe to actor state with optional selector |
| `useConnectionStatus` | Returns the WebSocket connection status |
All hooks are fully typed from your actor definitions — no manual type annotations needed.
## Multiple Apps
[Section titled “Multiple Apps”](#multiple-apps)
If you have multiple Zocket apps (e.g. different servers), create separate factories:
game-zocket.ts
```ts
export const { ZocketProvider: GameProvider, useActor: useGameActor } =
createZocketReact();
// chat-zocket.ts
export const { ZocketProvider: ChatProvider, useActor: useChatActor } =
createZocketReact();
```
Nest the providers in your app:
```tsx
```
# Bun Adapter
> Serve your Zocket app with Bun in one line.
The `@zocket/server` package ships with a first-class Bun adapter.
## Quick Start
[Section titled “Quick Start”](#quick-start)
```ts
import { serve } from "@zocket/server/bun";
import { app } from "./app";
const server = serve(app, { port: 3000 });
console.log(`Zocket on ws://localhost:${server.port}`);
```
That’s it. `serve()` creates a Bun HTTP server that upgrades WebSocket connections and wires up all the actor message handling.
## Options
[Section titled “Options”](#options)
```ts
serve(app, {
port: 3000, // default: 0 (random available port)
hostname: "0.0.0.0",
});
```
The return value is a standard `Bun.Server` instance.
## Custom Setup with `createBunHandlers`
[Section titled “Custom Setup with createBunHandlers”](#custom-setup-with-createbunhandlers)
For more control (e.g., adding HTTP routes alongside WebSocket), use `createBunHandlers`:
```ts
import { createBunHandlers } from "@zocket/server/bun";
import { app } from "./app";
const zocket = createBunHandlers(app);
Bun.serve({
port: 3000,
fetch(req, server) {
// Try WebSocket upgrade first
const wsResponse = zocket.fetch(req, server);
if (wsResponse === undefined) return wsResponse; // upgrade succeeded
// Custom HTTP routes
const url = new URL(req.url);
if (url.pathname === "/health") {
return new Response("ok");
}
return new Response("Not Found", { status: 404 });
},
websocket: zocket.websocket,
});
```
### BunHandlers Shape
[Section titled “BunHandlers Shape”](#bunhandlers-shape)
```ts
interface BunHandlers {
fetch(req: Request, server: BunServer): Response | undefined;
websocket: WebSocketHandler;
}
```
* `fetch` — attempts to upgrade the request to WebSocket. Returns `undefined` on success, or a `Response` on failure.
* `websocket` — Bun’s `WebSocketHandler` with `open`, `message`, and `close` callbacks wired to Zocket’s handler.
## How It Works
[Section titled “How It Works”](#how-it-works)
Under the hood:
1. `createBunHandlers(app)` calls `createHandlers(app)` to get runtime-agnostic callbacks
2. Each WebSocket connection gets a `BunConnectionAdapter` with a unique `id`
3. The adapter implements the `Connection` interface (`send()` + `id`)
4. Messages are routed through the handler to the `ActorManager`
## Connection IDs
[Section titled “Connection IDs”](#connection-ids)
Each connection gets a unique ID like `bun_1_m3abc`. This ID is:
* Passed to method handlers as `connectionId`
* Used by lifecycle hooks (`onConnect`, `onDisconnect`)
* Stable for the lifetime of a WebSocket connection
# Custom Handlers
> Build custom server adapters with createHandlers().
If you’re not using Bun, or need full control over the WebSocket lifecycle, you can use `createHandlers()` to get runtime-agnostic callbacks.
## createHandlers
[Section titled “createHandlers”](#createhandlers)
```ts
import { createHandlers } from "@zocket/server";
import { app } from "./app";
const handlers = createHandlers(app);
```
This returns a `HandlerCallbacks` object:
```ts
interface HandlerCallbacks {
onConnection(conn: Connection): void;
onMessage(conn: Connection, raw: string): void;
onClose(conn: Connection): void;
}
```
## Connection Interface
[Section titled “Connection Interface”](#connection-interface)
Your adapter must provide objects that implement `Connection`:
```ts
interface Connection {
send(message: string): void;
id: string; // Stable identifier for lifecycle hooks
}
```
The `id` must be unique per connection and stable for its lifetime.
## Wiring to a Custom Runtime
[Section titled “Wiring to a Custom Runtime”](#wiring-to-a-custom-runtime)
Here’s a sketch for wiring to a generic WebSocket server:
```ts
import { createHandlers } from "@zocket/server";
import { app } from "./app";
const handlers = createHandlers(app);
let connId = 0;
myWebSocketServer.on("connection", (ws) => {
const conn = {
id: `custom_${++connId}`,
send: (msg: string) => ws.send(msg),
};
handlers.onConnection(conn);
ws.on("message", (data: string) => {
handlers.onMessage(conn, data);
});
ws.on("close", () => {
handlers.onClose(conn);
});
});
```
## Message Routing
[Section titled “Message Routing”](#message-routing)
The handler routes messages based on their `type` field:
| Message Type | Action |
| :------------ | :----------------------------------------- |
| `rpc` | Invoke method, send `rpc:result` back |
| `event:sub` | Subscribe connection to actor events |
| `event:unsub` | Unsubscribe from events |
| `state:sub` | Subscribe to state + send initial snapshot |
| `state:unsub` | Unsubscribe from state patches |
## ActorManager
[Section titled “ActorManager”](#actormanager)
Behind `createHandlers`, an `ActorManager` owns all actor instances:
* `getOrCreate(actorName, actorId)` — lazily creates actor instances with schema-initialized state
* `removeConnection(conn)` — broadcasts disconnection to all actor instances the connection interacted with
Actor instances are stored in a `Map<"actorName:actorId", ActorInstance>`.
## State Initialization
[Section titled “State Initialization”](#state-initialization)
When an actor instance is first created, the manager initializes state by:
1. Validating `{}` against the state schema (works with schemas that have defaults)
2. If that fails, validating `undefined` (works with top-level `.default()`)
3. If both fail, using `{}` as a fallback
# Deno Adapter
> Serve your Zocket app with Deno.
The Deno adapter is currently in development. It will provide first-class support for serving Zocket apps on Deno using the built-in `Deno.serve` API.
Check back soon or follow progress on [GitHub](https://github.com/ChiChuRita/zocket).
# Node Adapter
> Serve your Zocket app with Node.js.
The Node.js adapter is currently in development. It will provide first-class support for serving Zocket apps on Node.js using the `ws` library.
Check back soon or follow progress on [GitHub](https://github.com/ChiChuRita/zocket).