Skip to content
cable
Esc
↑↓navigate↵open⌘Jpreview
On this page

Cloudflare Durable Objects

Run each cable channel host in a Cloudflare Durable Object with hibernatable WebSockets, durable storage, edge authentication, and signed private grants.

@cablejs/cloudflare runs one cable channel host in a Durable Object. Export the generated class from the Worker module, bind its class name in Wrangler, and register that namespace in the edge handler.

import {
  cloudflareHost,
  cloudflareServiceHost,
  createHandler,
  type CloudflareHostInstance,
} from "@cablejs/cloudflare";

interface Env {
  readonly CHAT_HOSTS: DurableObjectNamespace<CloudflareHostInstance<Env>>;
  readonly CABLE_GRANT_SECRET: string;
}

export const ChatHost = cloudflareHost(api.chat, chatImplementation, {
  grantSecret: (env: Env) => env.CABLE_GRANT_SECRET,
  peer: (env: Env, key) => env.CHAT_HOSTS.getByName(key),
});

const handler = createHandler(api, procedures, {
  authenticate: (request, env) => authenticate(request, env),
  context: ({ identity, hosts }) => ({ identity, hosts }),
  credentials: { mode: "bearer" },
  grantSecret: (env) => env.CABLE_GRANT_SECRET,
  grants: (identity) => grantsFor(identity),
  hosts: (env) => [{ channel: api.chat, namespace: env.CHAT_HOSTS }],
  uid: (identity) => identity.userId,
});

export default { fetch: handler.fetch };
{
  "durable_objects": { "bindings": [{ "name": "CHAT_HOSTS", "class_name": "ChatHost" }] },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ChatHost"] }],
}

class_name must equal the exported class name. Add a migration when introducing a class. Do not rename or remove a deployed class without Cloudflare’s migration process.

Request flow

The edge handler authenticates RPC, host-fallback, and WebSocket requests. For an accepted upgrade, it strips caller credentials, signs a private grant, and forwards the request to the Durable Object named for the channel key. The host verifies that grant before accepting the socket. grantSecret must return the same secret in both places.

In cookie mode, set credentials: { mode: "cookie", origins: ["https://app.example.com"] } and authenticate the session cookie. In bearer mode, set credentials: { mode: "bearer" } and validate the Authorization header or browser token. grants derives short-lived channel capabilities. It does not replace a channel’s authorize check.

The adapter uses hibernatable WebSockets, serialized attachments, Durable Object storage and alarms, and RPC for peer calls. Keep application state in context.storage, not object fields, because hibernation creates a new engine instance.

Share one service with handlers and the Durable Object

Use cloudflareServiceHost() when channel handlers and application RPC or lifecycle methods must call the same per-instance service. Its factory runs once for each in-memory Durable Object instance. It runs again after hibernation or eviction, so keep durable state in Durable Object storage rather than service fields.

class ChatRoom {
  constructor(private readonly storage: DurableObjectStorage) {}

  async archive(messageId: string): Promise<void> {
    await this.storage.put(`archived:${messageId}`, true);
  }
}

const CableChatHost = cloudflareServiceHost(
  api.chat,
  ({ state }) => {
    const room = new ChatRoom(state.storage);
    return {
      service: room,
      implementation: createChatImplementation(room),
    };
  },
  options,
);

export class ChatHost extends CableChatHost {
  async archive(messageId: string): Promise<void> {
    await this.cableService.archive(messageId);
    await this.cable.emit("changed", undefined);
  }
}

cableService is protected and is available only on classes returned by cloudflareServiceHost(). The factory’s implementation closes over the same service value exposed to the subclass.

The protected cable control uses the same durable event log and timer registry as channel handlers. Use setTimer(key, kind, at, args) for one replaceable application deadline and clearTimer(key) when its durable work queue is empty. The same methods are available on channel handler contexts. schedule(kind, at, args) remains the one-shot timer API.

await this.cable.setTimer("workspace-work", "flush", nextDueAt, undefined);
await this.cable.clearTimer("workspace-work");

Compute nextDueAt from durable application state before replacing a named timer. Do not write the Durable Object’s native alarm directly because Cable’s timer registry owns that single alarm.

Extend the Durable Object

cloudflareHost() returns a Durable Object class. Extend that class when the object needs an application RPC method or an additional lifecycle hook. The generated class still owns Cable’s upgrade, socket, and alarm handling.

const CableChatHost = cloudflareHost(api.chat, chatImplementation, options);

export class ChatHost extends CableChatHost {
  async objectName(): Promise<string> {
    return this.ctx.id.toString();
  }

  override async alarm(): Promise<void> {
    await super.alarm();
    // Run application alarm work after Cable restores its durable state.
  }
}

Keep durable application data in this.ctx.storage or the channel context’s storage. Do not replace fetch, WebSocket callbacks, or alarm without calling the generated implementation through super; those methods restore Cable state after hibernation. The class’s env property is the same Worker environment passed to the generated host.

Use an existing Hono auth boundary

The handler also exposes a typed host facade for application routes outside cable’s HTTP handler. Authenticate with the middleware already used by the application, then pass that verified principal to handler.hosts. This does not authenticate the request a second time or bypass channel authorization.

import { Hono } from "hono";

const app = new Hono<{ Bindings: Env }>();

app.post("/rooms/:roomId/messages", async (c) => {
  const principal = await requireVerifiedPrincipal(c.req.raw, c.env);
  if (principal === null) return c.json({ error: "unauthorized" }, 401);

  const seq = await handler
    .hosts({ env: c.env, principal })
    .chat({ roomId: c.req.param("roomId") })
    .emit("message", { text: "hello from Hono", user: principal.identity.name });

  return c.json({ seq });
});

requireVerifiedPrincipal is application code: it should return the same { identity, uid } shape used by the handler’s authenticated context. The message payload is checked against the channel contract before delivery, and emit returns its durable sequence. Keep authorization in the channel contract so this route and a WebSocket apply the same policy.

Run the example

The local example needs both the Worker and Vite processes. Create the Worker secret and start Wrangler in one terminal:

(cd examples/chat-cloudflare && cp .dev.vars.example .dev.vars)
(cd examples/chat-cloudflare && bunx wrangler dev --local)

Set CABLE_GRANT_SECRET in examples/chat-cloudflare/.dev.vars to at least 32 random characters. Then start Vite in a second terminal:

bun --filter @cablejs/example-chat-cloudflare dev

The local browser identity is only a development bearer token. Replace it with application authentication before deployment. The contract, Worker, and client code live in examples/chat-cloudflare/src/.

Was this page helpful?