ch16-websocket-hibernation
對應 16. WebSocket Hibernation:讓一萬條連線幾乎不花錢
在 GitHub 上檢視·5 個檔案·10.1 KB
取得並執行
這個範例可以獨立 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說明
ch16 — WebSocket hibernation
Section titled “ch16 — WebSocket hibernation”Companion example for docs/16-websocket-hibernation.md.
Two Durable Object classes side by side:
HibernatingRoom—ctx.acceptWebSocket()+ handler methodsClassicRoom—ws.accept()+addEventListener
npm install && npm run dev
# in another shell (Node >= 22 has a built-in WebSocket)node ws-client.mjs ws://localhost:8787Verified findings
Section titled “Verified findings”2026-08-01 · wrangler@4.114.0 · workerd@1.20260722.1
The two styles cannot see each other
Section titled “The two styles cannot see each other”{"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: 1t+20s instanceAgeMs: 605 msgsSeen: 1instance was recreated: trueinstanceAgeMs went down — a newer instance — and the per-instance counter
had reset. The WebSocket never disconnected. Reproducible locally.
Tag-based broadcast
Section titled “Tag-based broadcast”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}Auto-response does not wake the object
Section titled “Auto-response does not wake the object”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.
webSocketClose fires with full detail
Section titled “webSocketClose fires with full detail”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"] }