跳到內容

ch16-websocket-hibernation

取得並執行

這個範例可以獨立 clone 執行,不依賴其他章節。

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch16-websocket-hibernation
cd ch16-websocket-hibernation
npm install

可用指令

npm run dev	# wrangler dev
npm run typecheck	# tsc --noEmit
npm run cf-typegen	# wrangler types --env-interface CloudflareBindings

說明

Companion example for docs/16-websocket-hibernation.md.

Two Durable Object classes side by side:

  • HibernatingRoomctx.acceptWebSocket() + handler methods
  • ClassicRoomws.accept() + addEventListener
Terminal window
npm install && npm run dev
# in another shell (Node >= 22 has a built-in WebSocket)
node ws-client.mjs ws://localhost:8787

2026-08-01 · wrangler@4.114.0 · workerd@1.20260722.1

{"inMemorySet":1,"ctxGetWebSockets":0}

ctx.getWebSockets() only knows about sockets registered via acceptWebSocket(). Mixing the two styles gives you a system where some connections receive broadcasts and some silently do not.

{"threw":"Error: you must call 'acceptWebSocket()' before attempting to access the tags of a WebSocket."}

In-memory state is lost while the connection survives

Section titled “In-memory state is lost while the connection survives”

One connection, one message, 20s idle, another message:

t0 instanceAgeMs: 663 msgsSeen: 1
t+20s instanceAgeMs: 605 msgsSeen: 1
instance was recreated: true

instanceAgeMs went down — a newer instance — and the per-instance counter had reset. The WebSocket never disconnected. Reproducible locally.

alice and bob in room:general, carol in room:private:

broadcast?tag=room:general -> {"sent":2}
carol msgs: 0 []
bob msgs: ['pong','{"broadcast":"hello all"}']
{"total":3,"byTag":{"alice":1,"general":2,"nonexistent":0},
"tagsOfFirst":["user:carol","room:private"],
"attachmentOfFirst":{"user":"carol","room":"private","joinedAt":1785557296990},
"autoResponseTimestampOfFirst":null,"eventTimeout":null}

bob sends "ping", receives "pong" — and messagesSeenByThisInstance stays at 1 (alice’s message only). webSocketMessage was never invoked.

The attachment cap counts serialised bytes

Section titled “The attachment cap counts serialised bytes”
{"bytes=1024":{"ok":"accepted"},
"bytes=16000":{"ok":"accepted"},
"bytes=16384":{"threw":"Error: A WebSocket 'attachment' cannot be larger than 16384 bytes.'attachment' was 16398 bytes."},
"bytes=20000":{"threw":"Error: ... 'attachment' was 20014 bytes."}}

16,384 bytes of payload fails at 16,398 — the JSON wrapper counts, the same pattern as KV metadata in chapter 08. Put ids in the attachment and the real data in ctx.storage.

log: ['alice: hello from alice', 'close code=1000 reason=bye clean=true']

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch16-websocket-hibernation",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-24",
  "observability": { "enabled": true },
  "durable_objects": {
    "bindings": [
      { "name": "HIBERNATING", "class_name": "HibernatingRoom" },
      { "name": "CLASSIC", "class_name": "ClassicRoom" }
    ]
  },
  "exports": {
    "HibernatingRoom": { "type": "durable-object", "storage": "sqlite" },
    "ClassicRoom": { "type": "durable-object", "storage": "sqlite" }
  }
}

package.json

{ "name": "ch16-websocket-hibernation", "private": true, "type": "module",
  "scripts": { "dev": "wrangler dev", "typecheck": "tsc --noEmit",
    "cf-typegen": "wrangler types --env-interface CloudflareBindings" },
  "devDependencies": { "typescript": "^5.9.0", "wrangler": "^4.114.0" } }

src/index.ts

import { DurableObject } from "cloudflare:workers";

const t = async (fn: () => unknown | Promise<unknown>): Promise<unknown> => {
  try { return { ok: await fn() }; }
  catch (e) { return { threw: String(e).slice(0, 200) }; }
};

// ---------------------------------------------------------------------------
// A. The hibernation-capable room: ctx.acceptWebSocket + handler methods.
// ---------------------------------------------------------------------------
export class HibernatingRoom extends DurableObject<CloudflareBindings> {
  // Instance state is LOST across hibernation. Only used here to prove it.
  private constructedAt = Date.now();
  private messagesSeenByThisInstance = 0;

  constructor(ctx: DurableObjectState, env: CloudflareBindings) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      ctx.storage.sql.exec(
        `CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY, note TEXT NOT NULL) STRICT`,
      );
    });
    // Answer keepalives without waking the object.
    ctx.setWebSocketAutoResponse(
      new WebSocketRequestResponsePair("ping", "pong"),
    );
  }

  override async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const user = url.searchParams.get("user") ?? "anon";
    const room = url.searchParams.get("room") ?? "general";

    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    // THE line. ws.accept() would block hibernation.
    this.ctx.acceptWebSocket(server, [`user:${user}`, `room:${room}`]);

    // The only per-connection memory that survives hibernation. Max 16 KiB.
    server.serializeAttachment({ user, room, joinedAt: Date.now() });

    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    this.messagesSeenByThisInstance++;
    const att = ws.deserializeAttachment() as { user: string; room: string } | null;
    this.ctx.storage.sql.exec(
      "INSERT INTO log (note) VALUES (?)",
      `${att?.user ?? "?"}: ${typeof message === "string" ? message : "<binary>"}`,
    );
    ws.send(JSON.stringify({
      echo: message,
      from: att?.user ?? null,
      tags: this.ctx.getTags(ws),
      instanceAgeMs: Date.now() - this.constructedAt,
      messagesSeenByThisInstance: this.messagesSeenByThisInstance,
    }));
  }

  async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
    this.ctx.storage.sql.exec(
      "INSERT INTO log (note) VALUES (?)",
      `close code=${code} reason=${reason} clean=${wasClean}`,
    );
  }

  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
    this.ctx.storage.sql.exec("INSERT INTO log (note) VALUES (?)", `error ${String(error)}`);
  }

  /** Broadcast to a tag — the reason tags exist. */
  async broadcast(tag: string, text: string): Promise<number> {
    const sockets = this.ctx.getWebSockets(tag);
    for (const ws of sockets) ws.send(JSON.stringify({ broadcast: text }));
    return sockets.length;
  }

  async inspect(): Promise<unknown> {
    const all = this.ctx.getWebSockets();
    return {
      total: all.length,
      byTag: {
        alice: this.ctx.getWebSockets("user:alice").length,
        general: this.ctx.getWebSockets("room:general").length,
        nonexistent: this.ctx.getWebSockets("user:nobody").length,
      },
      tagsOfFirst: all[0] ? this.ctx.getTags(all[0]) : null,
      attachmentOfFirst: all[0] ? all[0].deserializeAttachment() : null,
      autoResponseTimestampOfFirst: all[0]
        ? this.ctx.getWebSocketAutoResponseTimestamp(all[0])
        : null,
      eventTimeout: this.ctx.getHibernatableWebSocketEventTimeout(),
      instanceAgeMs: Date.now() - this.constructedAt,
      messagesSeenByThisInstance: this.messagesSeenByThisInstance,
      log: this.ctx.storage.sql.exec("SELECT note FROM log ORDER BY id").toArray().map((r) => r.note),
    };
  }

  /** Probe the documented 16 KiB attachment cap. */
  async attachmentLimits(): Promise<unknown> {
    const ws = this.ctx.getWebSockets()[0];
    if (!ws) return { error: "connect a websocket first" };
    const out: Record<string, unknown> = {};
    for (const size of [1024, 16_000, 16_384, 20_000]) {
      out[`bytes=${size}`] = await t(() => {
        ws.serializeAttachment({ pad: "x".repeat(size) });
        return "accepted";
      });
    }
    // Restore something sane.
    ws.serializeAttachment({ restored: true });
    return out;
  }

  /** getTags throws for a socket that was never accepted by this object. */
  async getTagsOnForeignSocket(): Promise<unknown> {
    const pair = new WebSocketPair();
    const [, server] = Object.values(pair);
    return await t(() => this.ctx.getTags(server));
  }

  async reset(): Promise<void> {
    this.ctx.storage.sql.exec("DELETE FROM log");
  }
}

// ---------------------------------------------------------------------------
// B. The classic room: ws.accept() + addEventListener. Cannot hibernate.
// ---------------------------------------------------------------------------
export class ClassicRoom extends DurableObject<CloudflareBindings> {
  private sockets = new Set<WebSocket>();

  override async fetch(request: Request): Promise<Response> {
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    server.accept();                       // <- blocks hibernation
    this.sockets.add(server);
    server.addEventListener("message", (e) => {
      server.send(JSON.stringify({ echo: e.data, style: "classic", live: this.sockets.size }));
    });
    server.addEventListener("close", () => this.sockets.delete(server));

    return new Response(null, { status: 101, webSocket: client });
  }

  /** ctx.getWebSockets() only knows about sockets accepted via acceptWebSocket. */
  async inspect(): Promise<unknown> {
    return {
      inMemorySet: this.sockets.size,
      ctxGetWebSockets: this.ctx.getWebSockets().length,
    };
  }
}

export default {
  async fetch(request: Request, env: CloudflareBindings): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/ws") {
      return env.HIBERNATING.getByName("demo").fetch(request);
    }
    if (url.pathname === "/ws-classic") {
      return env.CLASSIC.getByName("demo").fetch(request);
    }

    const h = env.HIBERNATING.getByName("demo");
    switch (url.pathname) {
      case "/inspect":     return Response.json(await h.inspect());
      case "/broadcast":   return Response.json({
        sent: await h.broadcast(url.searchParams.get("tag") ?? "room:general", "hello all"),
      });
      case "/attachment":  return Response.json(await h.attachmentLimits());
      case "/foreigntags": return Response.json(await h.getTagsOnForeignSocket());
      case "/reset":       await h.reset(); return Response.json({ ok: true });
      case "/classic":     return Response.json(await env.CLASSIC.getByName("demo").inspect());
      default:
        return new Response("ws: /ws /ws-classic | http: /inspect /broadcast /attachment /foreigntags /reset /classic\n", { status: 404 });
    }
  },
} satisfies ExportedHandler<CloudflareBindings>;

ws-client.mjs

const base = process.argv[2] ?? "ws://localhost:8970";
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

function open(url) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(url);
    const msgs = [];
    ws.addEventListener("message", (e) => msgs.push(e.data));
    ws.addEventListener("open", () => resolve({ ws, msgs }));
    ws.addEventListener("error", (e) => reject(new Error("ws error")));
    setTimeout(() => reject(new Error("timeout")), 8000);
  });
}

const alice = await open(`${base}/ws?user=alice&room=general`);
const bob   = await open(`${base}/ws?user=bob&room=general`);
const carol = await open(`${base}/ws?user=carol&room=private`);
console.log("connected: 3");

alice.ws.send("hello from alice");
await sleep(600);
console.log("alice got:", alice.msgs.at(-1));

// auto-response: "ping" should come back "pong" WITHOUT waking the object
bob.ws.send("ping");
await sleep(600);
console.log("bob ping ->", bob.msgs.at(-1));

const r = await fetch(`${base.replace("ws://","http://")}/broadcast?tag=room:general`);
console.log("broadcast:", await r.text());
await sleep(400);
console.log("carol msgs (should NOT include broadcast):", carol.msgs.length, carol.msgs);
console.log("bob msgs:", bob.msgs);

const insp = await fetch(`${base.replace("ws://","http://")}/inspect`);
console.log("inspect:", JSON.stringify(await insp.json(), null, 1));

const att = await fetch(`${base.replace("ws://","http://")}/attachment`);
console.log("attachment limits:", await att.text());

alice.ws.close(1000, "bye");
await sleep(600);
const insp2 = await fetch(`${base.replace("ws://","http://")}/inspect`);
const d = await insp2.json();
console.log("after close -> total:", d.total, "log:", d.log);

// classic style
const c = await open(`${base}/ws-classic`);
c.ws.send("hi classic");
await sleep(500);
console.log("classic echo:", c.msgs.at(-1));
console.log("classic inspect:", await (await fetch(`${base.replace("ws://","http://")}/classic`)).text());
process.exit(0);

tsconfig.json

{ "compilerOptions": { "target": "esnext", "lib": ["esnext"], "module": "esnext",
  "moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
  "strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true },
  "include": ["src/**/*.ts", "worker-configuration.d.ts"] }