恢复 WebSocket 连接上下文

CloudflareBeginner
立即练习

简介

一个活动中的 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 中的准确名称,然后打开 BindingsPRESENCE 应指向 PresenceRoom

PRESENCE 绑定指向 PresenceRoom Durable Object

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

PresenceRoom 命名空间使用 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 保留持久化公告的同时维持了正确的房间行为。最后,你检查了部署,删除了准确的一次性资源,并退出了登录。