简介
一个活动中的 WebSocket 连接,持续时间可能远远超过内存中的某个 JavaScript 对象。Cloudflare 可以让空闲的 Durable Object 休眠:客户端仍连接在网络边缘,但对象的内存字段会消失。之后收到消息时,系统会唤醒一个新的类实例。这可以降低空闲时长费用,但也意味着普通的内存映射不是可靠的客户端名称或角色存储位置。
Hibernation WebSocket API 从两个方面解决了生命周期问题。ctx.acceptWebSocket(server) 会注册连接,但不会让对象一直驻留在内存中。serializeAttachment() 会将一个小型结构化克隆值与连接一起存储;对象重建后,deserializeAttachment() 会恢复该值。ctx.getWebSockets() 允许新的构造函数枚举仍保持连接的套接字。
你将构建一个房间在线状态服务,为每个套接字附加经过验证的客户端 ID、显示名称和房间名称。受控的重建测试会围绕已有的模拟套接字创建新的类实例,并验证这些附加数据能够重建会话映射。你还将使用真实的本地和已部署 WebSocket,断开并重新连接一个浏览器客户端,确认房间行为仍然正确。Cloudflare 会决定生产环境何时发生休眠,因此本实验和评分都不会假装能够按需强制对象被驱逐。
在直接进入本课程之前,请完成 将 LabEx 连接到你的 Cloudflare 账户。 每个全新的 VM 都需要单独完成 Wrangler 授权。你应该已经了解 O01–O05 中介绍的命名 Durable Objects、基于 SQLite 的状态以及按房间范围广播 WebSocket 消息。
安装过程会在 /home/labex/project/connection-context 中安装 Node.js 22.22.0、项目本地的 Wrangler 4.132.0 以及固定版本的 WebSocket 客户端。它会提供浏览器和测试所需的 fixture,但不会为 Cloudflare 授权,也不会实现 Durable Object、接受套接字或部署 Worker。
授权 VM 并声明在线状态命名空间
在此步骤中,你将为这个全新的 VM 完成授权,选择专用学习账户,并声明一个由 SQLite 支持的 Durable Object 类,用于管理在线状态房间。
cd /home/labex/project/connection-context
npx wrangler --version
npx wrangler login --device --browser=false
预期 Wrangler 版本为 4.132.0。在浏览器中打开显示的 Cloudflare URL,输入短代码,确认目标学习账户并完成授权。浏览器会授予 Wrangler 访问权限;你的密码不会发送到 VM。
读取安全的身份字段,并创建唯一的一次性 Worker 名称:
WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"
RUN="labex-c10-o06-$(openssl rand -hex 6)"
printf '%s\n' "$RUN" | tee .labex/run-name
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/index.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true, "head_sampling_rate": 1 },
"durable_objects": { "bindings": [
{ "name": "PRESENCE", "class_name": "PresenceRoom" }
] },
"exports": {
"PresenceRoom": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
PRESENCE 是 Worker 访问房间对象的路由。稳定的房间名称可以将一个房间的连接和历史记录与其他房间分开。类导出会为每个房间提供独立的 SQLite 存储;部署之前,云端不会创建任何资源。
实现可安全休眠的连接上下文
在此步骤中,你将把安全的连接元数据与活动的套接字对象分开,然后使用 Hibernation WebSocket API,在 Cloudflare 创建新的对象实例时恢复这些元数据。
附加数据(attachment)是与某个 WebSocket 一起存储的小型结构化克隆值。只要连接保持正常,它就能在休眠期间保留;持久化的房间历史记录仍然应存储在 SQLite 中。创建验证和重建辅助函数:
cat > src/context.js <<'JS'
const TOKEN = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
export function connectionContext(url) {
const room = url.pathname.match(/^\/rooms\/([^/]+)\/connect$/)?.[1] ?? "";
const clientId = url.searchParams.get("clientId") ?? "";
const displayName = (url.searchParams.get("name") ?? "").trim();
if (!TOKEN.test(room) || !TOKEN.test(clientId)) return null;
if (displayName.length < 1 || displayName.length > 32) return null;
return { room, clientId, displayName };
}
export function validAttachment(value) {
return Boolean(value && typeof value === "object" && TOKEN.test(value.room) &&
TOKEN.test(value.clientId) && typeof value.displayName === "string" &&
value.displayName.length >= 1 && value.displayName.length <= 32);
}
export function restoreSessions(sockets) {
const sessions = new Map();
for (const socket of sockets) {
const attachment = socket.deserializeAttachment();
if (validAttachment(attachment)) sessions.set(socket, attachment);
}
return sessions;
}
JS
创建 Durable Object 和前端 Worker:
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { connectionContext, restoreSessions } from "./context.js";
const json = (body, status = 200) => Response.json(body, { status });
export class PresenceRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.sessions = restoreSessions(ctx.getWebSockets());
this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS announcements (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
display_name TEXT NOT NULL,
text TEXT NOT NULL
)
`);
});
}
async fetch(request) {
const context = connectionContext(new URL(request.url));
if (!context) return json({ error: "invalid_connection_context" }, 400);
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return json({ error: "websocket_upgrade_required" }, 426);
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server, [`room:${context.room}`]);
server.serializeAttachment(context);
this.sessions.set(server, context);
server.send(JSON.stringify({ type: "ready", context, connected: this.sessions.size }));
return new Response(null, { status: 101, webSocket: client });
}
webSocketMessage(socket, raw) {
const context = socket.deserializeAttachment();
if (!context || !this.sessions.has(socket)) {
socket.send(JSON.stringify({ type: "error", code: "missing_context" }));
return;
}
let message;
try { message = JSON.parse(raw); } catch { message = null; }
const text = typeof message?.text === "string" ? message.text.trim() : "";
if (message?.type !== "announce" || text.length < 1 || text.length > 80 || Object.keys(message).length !== 2) {
socket.send(JSON.stringify({ type: "error", code: "invalid_message" }));
return;
}
const row = this.ctx.storage.sql.exec(`
INSERT INTO announcements (client_id, display_name, text)
VALUES (?, ?, ?) RETURNING sequence
`, context.clientId, context.displayName, text).one();
const update = JSON.stringify({ type: "announcement", sequence: row.sequence,
clientId: context.clientId, displayName: context.displayName, text });
for (const peer of this.ctx.getWebSockets(`room:${context.room}`)) peer.send(update);
console.log(JSON.stringify({ event: "presence_announcement", sequence: row.sequence,
clientId: context.clientId, connected: this.ctx.getWebSockets().length }));
}
webSocketClose(socket) {
this.sessions.delete(socket);
}
async getState() {
const announcements = this.ctx.storage.sql.exec(`
SELECT sequence, client_id AS clientId, display_name AS displayName, text
FROM announcements ORDER BY sequence
`).toArray();
return { messageCount: announcements.length, announcements };
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const match = url.pathname.match(/^\/rooms\/([^/]+)\/(connect|state)$/);
if (!match) return json({ error: "not_found" }, 404);
const room = match[1];
if (match[2] === "connect") return env.PRESENCE.getByName(room).fetch(request);
if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
return json({ room, ...await env.PRESENCE.getByName(room).getState() });
}
};
JS
ctx.acceptWebSocket() 会替代 server.accept() 和事件监听器。现在,消息会通过类级别的 webSocketMessage() 处理函数传入。构造函数会从运行时管理的套接字及其附加数据中重建 sessions;它不会假设之前的 JavaScript Map 仍然存在。
证明上下文重建,而不是假装能够强制驱逐
在此步骤中,你将直接测试重建边界。Cloudflare 会决定空闲的生产对象何时休眠,因此确定性的实验不应等待或声称发生了强制驱逐。相反,一个全新的 PresenceRoom 实例会接收由早期实例写入附加数据的、由运行时管理的模拟套接字。
cat > test/context.test.mjs <<'JS'
import test from "node:test";
import assert from "node:assert/strict";
import { connectionContext, restoreSessions, validAttachment } from "../src/context.js";
import { PresenceRoom } from "../src/index.js";
const attachment = (room, clientId, displayName) => ({ room, clientId, displayName });
const socket = value => ({ deserializeAttachment: () => value });
test("connection input becomes a bounded attachment", () => {
const url = new URL("https://example.test/rooms/planning/connect?clientId=alice-1&name=Alice");
assert.deepEqual(connectionContext(url), attachment("planning", "alice-1", "Alice"));
assert.equal(connectionContext(new URL("https://example.test/rooms/Bad!/connect?clientId=a&name=A")), null);
});
test("attachment validation rejects incomplete context", () => {
assert.equal(validAttachment(attachment("planning", "alice-1", "Alice")), true);
assert.equal(validAttachment({ room: "planning", clientId: "alice-1" }), false);
});
test("controlled reconstruction restores only valid socket context", () => {
const alice = socket(attachment("planning", "alice-1", "Alice"));
const bob = socket(attachment("planning", "bob-1", "Bob"));
const broken = socket(null);
const restored = restoreSessions([alice, bob, broken]);
assert.equal(restored.size, 2);
assert.equal(restored.get(alice).displayName, "Alice");
assert.equal(restored.get(bob).clientId, "bob-1");
});
test("a new Durable Object constructor rebuilds its session map", () => {
const sockets = [socket(attachment("planning", "alice-1", "Alice")), socket(attachment("planning", "bob-1", "Bob"))];
const ctx = {
getWebSockets: () => sockets,
blockConcurrencyWhile: fn => fn(),
storage: { sql: { exec: () => ({}) } }
};
const room = new PresenceRoom(ctx, {});
assert.equal(room.sessions.size, 2);
assert.deepEqual([...room.sessions.values()].map(value => value.displayName), ["Alice", "Bob"]);
});
JS
npm test
预期四个测试全部通过。这些测试证明代码能够从附加数据重建上下文。后续的实时检查会验证真实套接字行为,但不会把任何一个检查错误地称为证明某个生产对象曾按需被驱逐。
重新连接客户端并保持房间行为
在此步骤中,你将使用真实的本地套接字。重新连接会创建新的套接字,因此也会创建新的附加数据;而持久化的公告仍保存在 SQLite 中。
cat > tools/reconnect.mjs <<'JS'
import WebSocket from "ws";
const [base, prefix] = process.argv.slice(2);
const wsBase = base.replace(/^http/, "ws");
const room = `${prefix}-planning`, other = `${prefix}-support`;
const open = (roomName, id, name) => new Promise((resolve, reject) => {
const ws = new WebSocket(`${wsBase}/rooms/${roomName}/connect?clientId=${id}&name=${encodeURIComponent(name)}`);
const inbox = [];
ws.on("message", raw => { const value = JSON.parse(raw); inbox.push(value); if (value.type === "ready") resolve({ ws, inbox, ready: value }); });
ws.on("error", reject);
});
const waitFor = (client, predicate) => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("message timeout")), 5000);
const check = value => { if (predicate(value)) { clearTimeout(timer); client.ws.off("message", listener); resolve(value); } };
const listener = raw => check(JSON.parse(raw)); client.ws.on("message", listener); client.inbox.forEach(check);
});
const close = client => new Promise(resolve => { client.ws.once("close", resolve); client.ws.close(1000, "reconnect"); });
const alice = await open(room, `${prefix}-alice`, "Alice");
const bob = await open(room, `${prefix}-bob`, "Bob");
const carol = await open(other, `${prefix}-carol`, "Carol");
alice.ws.send(JSON.stringify({ type: "announce", text: "First update" }));
await Promise.all([waitFor(alice, x => x.sequence === 1), waitFor(bob, x => x.sequence === 1)]);
await close(alice);
const reconnected = await open(room, `${prefix}-alice`, "Alice");
reconnected.ws.send(JSON.stringify({ type: "announce", text: "Back online" }));
const [again, peer] = await Promise.all([waitFor(reconnected, x => x.sequence === 2), waitFor(bob, x => x.sequence === 2)]);
await new Promise(resolve => setTimeout(resolve, 300));
const state = await fetch(`${base}/rooms/${room}/state`).then(r => r.json());
const otherState = await fetch(`${base}/rooms/${other}/state`).then(r => r.json());
console.log(JSON.stringify({ restoredName: again.displayName, peerName: peer.displayName,
otherAnnouncements: carol.inbox.filter(x => x.type === "announcement").length, state, otherState }, null, 2));
await Promise.all([reconnected, bob, carol].map(close));
JS
rm -f .labex/local.json .labex/dev.log .labex/dev.pid
mkdir -p .labex/local-state
npx wrangler dev --local --ip 127.0.0.1 --port 8787 --persist-to .labex/local-state > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
LOCAL_READY="$(curl --silent http://127.0.0.1:8787/rooms/probe/state || true)"
test "$(jq -r '.messageCount // -1' <<<"$LOCAL_READY" 2>/dev/null)" = 0 && break
sleep 1
done
test "$(jq -r .messageCount <<<"$LOCAL_READY")" = 0
sleep 2
node tools/reconnect.mjs http://127.0.0.1:8787 local | tee .labex/local.json
Alice 使用新的套接字重新连接,但她发送的第二条消息仍然包含 displayName: Alice;Bob 能收到该消息,而 Carol 仍与该房间隔离。房间中的两条持久化公告表明,套接字生命周期和房间历史记录生命周期是不同的。
部署并重复验证重连接约定
在此步骤中,你将停止刚才的本地任务,部署应用,等待实际的有状态路由就绪,然后使用唯一的云端房间重复实时客户端验证:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
rm -f .labex/cloud.json .labex/deploy.log .labex/app-url
npx wrangler deploy | tee .labex/deploy.log
APP_URL="$(grep -Eo 'https://[^ ]+\.workers\.dev' .labex/deploy.log | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL" | tee .labex/app-url
for attempt in $(seq 1 30); do READY="$(curl --silent "$APP_URL/rooms/cloud-probe/state" || true)"; test "$(jq -r '.messageCount // -1' <<<"$READY" 2>/dev/null)" = 0 && break; sleep 2; done
test "$(jq -r .messageCount <<<"$READY")" = 0
sleep 5
node tools/reconnect.mjs "$APP_URL" cloud | tee .labex/cloud.json
Cloudflare 上出现相同结果,证明部署后的服务在每次接受连接以及 Alice 重新连接后,都使用了序列化的附加数据。但这并不表示在这段有限时长的运行期间,平台恰好发生了休眠。
检查兼容休眠的部署
在此步骤中,你将把运行时证据与 Cloudflare Dashboard 以及一次未修改代码的重新部署联系起来。打开 Workers & Pages,选择 .labex/run-name 中的准确名称,然后打开 Bindings。PRESENCE 应指向 PresenceRoom。

打开 Durable Objects,选择 <your-worker>_PresenceRoom,并确认 Storage: SQL。此页面用于标识类命名空间;它不会显示附加数据的值。

打开 Logs,检查一条成功的 presence_announcement 日志记录。记录中包含合成的客户端 ID 和序列号,但不包含公告文本。Dashboard 中的流量可能晚于响应到达,因此实时客户端检查和后端检查仍然是权威依据。

重新部署未修改的代码,并读取相同的云端房间:
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/rooms/cloud-planning/state" | jq
curl --silent --fail "$APP_URL/rooms/cloud-support/state" | jq
planning 房间仍包含两条公告,而 support 房间仍为空。重新部署证明持久化历史记录能够在新的 Worker 版本中保留;受控构造函数测试则单独证明了附加数据重建。
删除在线状态命名空间
在此步骤中,你将仅删除本实验生成的 Worker 和命名空间,同时保持 VM 的授权状态:
RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o06-*) ;; *) echo "Unexpected Worker name" >&2; exit 1;; esac
cat > src/cleanup.js <<'JS'
export default { fetch() { return Response.json({ status: "cleanup" }, { status: 410 }); } };
JS
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/cleanup.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"exports": { "PresenceRoom": { "type": "durable-object", "state": "deleted" } }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc
确认提示中显示的是准确的 $RUN,输入 y,并预期看到 Successfully deleted。在下面的检查完成之前,保持 VM 处于授权状态:
npx wrangler whoami --json | jq '{loggedIn, authType}'
JSON 中必须包含 "loggedIn": true;身份验证或网络失败不能证明删除成功。
撤销此 VM 的 Wrangler 授权
在独立验证删除完成后,于此步骤中移除仅属于此 VM 的 OAuth 授权:
npx wrangler logout
npx wrangler whoami --json
最终 JSON 必须包含 "loggedIn": false。你的学习账户仍会在浏览器中保持登录状态。
总结
你使用 Hibernation WebSocket API 替代了普通的已接受套接字,将受限的客户端上下文存储在序列化附加数据中,并从运行时管理的套接字重建了内存中的会话映射。受控的新实例测试证明了上下文重建,同时没有假装能够强制生产环境发生驱逐。随后,真实的本地和云端客户端断开并重新连接,在 SQLite 保留持久化公告的同时维持了正确的房间行为。最后,你检查了部署,删除了准确的一次性资源,并退出了登录。



