Skip to content

Custom Handlers

If you’re not using Bun, or need full control over the WebSocket lifecycle, you can use createHandlers() to get runtime-agnostic callbacks.

import { createHandlers } from "@zocket/server";
import { app } from "./app";
const handlers = createHandlers(app);

This returns a HandlerCallbacks object:

interface HandlerCallbacks {
onConnection(conn: Connection): void;
onMessage(conn: Connection, raw: string): Promise<void>;
onClose(conn: Connection): void;
manager: ActorManager;
}

Note that onMessage is async — if you need to know when processing is done (e.g., to ack a message queue delivery), await it.

Your adapter must provide objects that implement Connection:

interface Connection {
/** Send a serialized message down the transport. */
send(message: string): void;
/** Stable identifier for lifecycle hooks. Assigned by the adapter. */
id: string;
/** Authenticated user identity, when the transport provides it. */
userId?: string | null;
/** Verified auth claims or other connection-scoped metadata. */
claims?: Record<string, unknown>;
/** Optional routing scope attached by the transport layer. */
scope?: Record<string, string>;
}

The id must be unique per connection and stable for its lifetime. The optional userId, claims, and scope fields flow through from the transport (e.g. the gateway’s JWT verification) into method ctx and actor lifecycle hooks, so actors can use them for authorization.

Here’s a sketch for wiring to a generic WebSocket server:

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", async (data: string) => {
await handlers.onMessage(conn, data);
});
ws.on("close", () => {
handlers.onClose(conn);
});
});

The handler routes messages based on their type field:

Message TypeAction
rpcInvoke method, send rpc:result back
event:subSubscribe connection to actor events
event:unsubUnsubscribe from events
state:subSubscribe to state + send initial snapshot
state:unsubUnsubscribe from state patches

The manager property on HandlerCallbacks gives you direct access to the actor manager. You can also import and construct one yourself:

import { ActorManager } from "@zocket/server";
const { manager } = handlers;
// Number of hot actor instances
console.log(manager.size);
// List all hot actors
const actors = manager.list();
// [{ actorName: "counter", actorId: "main" }, ...]
// Destroy a specific actor (calls onDeactivate if defined)
await manager.destroy("counter", "main");
// Destroy all actors (for shutdown or redeploy)
await manager.destroyAll();

Subscribe to actor creation and destruction:

manager.on("actorCreated", ({ actorName, actorId }) => {
console.log(`Actor created: ${actorName}/${actorId}`);
});
manager.on("actorDestroyed", ({ actorName, actorId }) => {
console.log(`Actor destroyed: ${actorName}/${actorId}`);
});

The on() method returns an unsubscribe function.

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

After initialization, the actor’s onActivate hook is called if defined.