广播房间更新

CloudflareBeginner
立即练习

简介

普通的 HTTP 请求会打开连接、接收一次响应,然后结束。WebSocket 会将最初的 HTTP 请求升级为保持打开状态的双向连接,因此服务器可以在发生变化后立即发送更新。聊天消息、协作光标和实时订单面板都受益于这种实时通道。

Durable Object 为每个房间提供一个协调点。入口 Worker 会将经过验证的房间名称(例如 planning)转换为稳定的对象标识。选中的对象负责接受该房间的 WebSocket 连接、验证每条传入消息,并仅向该对象中已连接的客户端广播一条通过验证的更新。不同的名称会选择不同的对象,因此 support 无法收到 planning 的流量。

本实验有意使用标准 WebSocket API,并将活动套接字集合保存在内存中。这样,在 O06 介绍 WebSocket Hibernation 和连接附件之前,你可以先直观看到连接和广播行为。SQLite 会存储少量消息历史,以便你证明格式错误的输入没有改变持久化状态;它不会使打开的套接字本身持久化。

你将实现消息协议,将两个提供的客户端连接到同一个房间,并将第三个客户端连接到另一个房间;观察有效广播,拒绝格式错误的输入,在 Cloudflare 上重复测试,检查浏览器客户端和 Dashboard,最后删除指定的临时资源。

每台新 VM 都需要单独完成 Wrangler 授权。你应该已经掌握 O01–O04 中介绍的稳定 Durable Object 名称、绑定、RPC 以及基于 SQLite 的状态。初始化过程会在 /home/labex/project/room-broadcast 中安装 Node.js 22.22.0、项目本地的 Wrangler 4.132.0 和 ws 测试客户端。它会提供浏览器客户端和测试客户端,但不会编写你的 Worker、授权 Cloudflare 或执行部署。

授权 VM 并声明房间命名空间

在本步骤中,你将授权新 VM,并声明一个由 SQLite 支持、用于实时房间的 Durable Object 类。

进入准备好的项目,确认固定的 Wrangler 版本,并为此 VM 完成授权:

cd /home/labex/project/room-broadcast
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-o05-$(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": "ROOMS", "class_name": "RoomBroadcast" }
  ] },
  "exports": {
    "RoomBroadcast": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

ROOMS 绑定是 Worker 访问该类命名空间的入口。调用 getByName("planning") 始终会选择同一个逻辑房间,而调用 getByName("support") 会选择一个独立对象。该导出配置会为每个选中的房间提供独立的 SQLite 存储。在部署之前,云端不会创建任何资源。

实现经过验证的 WebSocket 协议

在本步骤中,你将定义一个小型消息契约,并实现接受和广播 WebSocket 消息的房间对象。

初始请求必须包含 Upgrade: websocket。升级完成后,消息会作为帧传输,而不是新的 HTTP 请求。客户端可以在帧中发送任意文本,因此解析 JSON 只是第一项检查。在修改持久化状态之前,还必须验证预期的 type、一个非空且长度受限的 text 字段,并拒绝多余字段。

创建共享协议辅助函数:

cat > src/protocol.js <<'JS'
const ROOM_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;

export function parseRoomPath(pathname) {
  const match = pathname.match(/^\/rooms\/([^/]+)\/(connect|state)$/);
  if (!match || !ROOM_PATTERN.test(match[1])) return null;
  return { room: match[1], action: match[2] };
}

export function parseClientMessage(raw) {
  if (typeof raw !== "string" || raw.length > 512) return { ok: false };
  let value;
  try { value = JSON.parse(raw); } catch { return { ok: false }; }
  if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false };
  const keys = Object.keys(value).sort();
  if (keys.length !== 2 || keys[0] !== "text" || keys[1] !== "type") return { ok: false };
  if (value.type !== "update" || typeof value.text !== "string") return { ok: false };
  const text = value.text.trim();
  if (text.length < 1 || text.length > 80) return { ok: false };
  return { ok: true, text };
}
JS

创建入口 Worker 和 Durable Object 类:

cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { CLIENT_HTML } from "./client-html.js";
import { parseClientMessage, parseRoomPath } from "./protocol.js";

const json = (body, status = 200) => Response.json(body, { status });

export class RoomBroadcast extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.sessions = new Set();
    this.ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS messages (
          sequence INTEGER PRIMARY KEY AUTOINCREMENT,
          text TEXT NOT NULL,
          created_at INTEGER NOT NULL
        )
      `);
    });
  }

  async fetch(request) {
    if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
      return json({ error: "websocket_upgrade_required" }, 426);
    }
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    server.accept();
    this.sessions.add(server);
    server.addEventListener("message", event => this.receive(server, event.data));
    const forget = () => this.sessions.delete(server);
    server.addEventListener("close", forget);
    server.addEventListener("error", forget);
    server.send(JSON.stringify({ type: "ready" }));
    return new Response(null, { status: 101, webSocket: client });
  }

  receive(sender, raw) {
    const message = parseClientMessage(raw);
    if (!message.ok) {
      sender.send(JSON.stringify({
        type: "error",
        code: "invalid_message",
        detail: "Send only {type: update, text: 1-80 characters}."
      }));
      return;
    }
    const createdAt = Date.now();
    const row = this.ctx.storage.sql.exec(`
      INSERT INTO messages (text, created_at)
      VALUES (?, ?)
      RETURNING sequence
    `, message.text, createdAt).one();
    const update = JSON.stringify({
      type: "update",
      sequence: row.sequence,
      text: message.text,
      createdAt
    });
    for (const socket of this.sessions) {
      try { socket.send(update); } catch { this.sessions.delete(socket); }
    }
    console.log(JSON.stringify({ event: "room_update", sequence: row.sequence, connected: this.sessions.size }));
  }

  async getState() {
    const messages = this.ctx.storage.sql.exec(`
      SELECT sequence, text, created_at AS createdAt
      FROM messages ORDER BY sequence
    `).toArray();
    return {
      messageCount: messages.length,
      latestSequence: messages.at(-1)?.sequence ?? 0,
      messages
    };
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === "/" && request.method === "GET") {
      return new Response(CLIENT_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
    }
    const route = parseRoomPath(url.pathname);
    if (!route) return json({ error: "not_found" }, 404);
    if (route.action === "connect") {
      if (request.method !== "GET" || (request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
        return json({ error: "websocket_upgrade_required" }, 426);
      }
      return env.ROOMS.getByName(route.room).fetch(request);
    }
    if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
    const state = await env.ROOMS.getByName(route.room).getState();
    return json({ room: route.room, ...state });
  }
};
JS

WebSocketPair 会创建一个连接的客户端端和服务器端。以 HTTP 101 状态返回客户端端即可完成升级,而 server.accept() 会启动标准的服务器端套接字。内存中的 sessions 集合明确限定在单个对象实例内;稳定的房间名称则确保该集合不会跨房间变成全局集合。

运行确定性的协议测试,并让 Wrangler 构建但不执行部署:

npm test
npx wrangler deploy --dry-run

预期四项测试全部通过。试运行会检查 Worker 模块和绑定配置;后续在线步骤会验证实际的套接字行为。

在一个房间内广播更新

在本步骤中,你将运行本地 Worker,并证明一条更新会发送给共享同一房间的两个客户端,但不会发送给另一个房间中的客户端。

将 Wrangler 作为后台任务启动。重定向输出可以保持终端清晰,保存的任务 ID 方便稍后停止准确的进程:

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
  curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
  sleep 1
done
curl --silent --fail http://127.0.0.1:8787/ | grep -o '<title>[^<]*</title>'

提供的客户端程序会建立三个真实的 WebSocket 连接:其中两个命名为 planning,一个命名为 support。它会从第一个 planning 客户端发送一条更新,并等待来自三个客户端的有限证据:

node tools/room-clients.mjs http://127.0.0.1:8787 planning support broadcast | tee .labex/local-broadcast.json

senderpeer 对象应包含相同的 sequence: 1 和文本。otherUpdates 必须为 0。状态部分会独立显示一个持久化的 planning 消息和零条 support 消息。这证明了设计的两个方面:共享的稳定名称会让前两个客户端加入同一房间,而不同的名称会将第三个客户端排除在广播边界之外。

在状态变更前拒绝格式错误的消息

在本步骤中,你将发送一帧语法上有效但应用层输入无效的 JSON,然后比较发送前后的持久化状态。

空的 text 字段是关键区别:JSON 解析成功,但房间协议会拒绝该消息。针对相同的本地对象运行提供的第二个阶段:

node tools/room-clients.mjs http://127.0.0.1:8787 planning support invalid | tee .labex/local-invalid.json

只有发送客户端会收到代码为 invalid_message 的错误;peerErrors 保持为 0beforeafter 历史记录完全相同,均包含一条消息。因此,无效客户端无法添加数据行、推进序列,或将错误转化为整个房间的广播。

直接读取两个房间的状态:

curl --silent --fail http://127.0.0.1:8787/rooms/planning/state | jq
curl --silent --fail http://127.0.0.1:8787/rooms/support/state | jq

第一个响应会报告一条消息,第二个响应会报告零条消息。即使客户端在测试后断开连接,HTTP 状态读取仍然是权威结果。

部署并运行云端 WebSocket 客户端

在本步骤中,你将停止本地运行时,部署相同的代码,并通过 Cloudflare 重复三个客户端的契约测试。

只停止之前记录的本地任务,然后执行部署:

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
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

部署首先会创建 Worker,并协调 RoomBroadcast 命名空间。主页成功打开本身并不能证明有状态路由已经就绪,因此先轮询一个无害的空房间状态,确认精确的 JSON 契约,然后等待一小段稳定时间:

for attempt in $(seq 1 30); do
  READY="$(curl --silent --show-error "$APP_URL/rooms/cloud-observer/state" || true)"
  test "$(printf '%s' "$READY" | jq -r '.messageCount // -1' 2>/dev/null)" = 0 && break
  sleep 2
done
test "$(printf '%s' "$READY" | jq -r .messageCount)" = 0
sleep 5

使用唯一的云端房间运行相同的真实 WebSocket 客户端:

node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support broadcast | tee .labex/cloud-broadcast.json
node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support invalid | tee .labex/cloud-invalid.json

云端输出必须显示与本地开发相同的行为:两个 planning 客户端收到序列 1support 客户端收不到更新,格式错误的输入不会改变历史记录。

在浏览器中打开输出的 APP_URL。选择 Connect three clients,然后选择 Send planning update。客户端 A 和 B 应显示相同的新 update,而客户端 C 只显示其 ready 消息。选择 Send malformed update,确认只有客户端 A 显示错误。观察完结果后,选择 Disconnect clients,等待三个卡片都报告 Closed;这样可以在离开页面前完成 WebSocket 关闭握手。此页面是提供的观察客户端;Node 探针和后端检查仍是权威的验收证据。

检查浏览器客户端和 Durable Object

在本步骤中,你将把运行时证据与 Cloudflare Dashboard 关联起来,并通过一次未修改代码的重新部署,证明持久化的房间历史仍然存在。

保持浏览器演示连接足够长的时间,以检查三个卡片。两个 planning 卡片是按房间广播的可见证据;保持安静的 support 卡片同样重要,因为它显示了哪些内容没有跨越对象标识边界。

两个 planning 客户端收到相同的更新,而 support 保持安静

在 Cloudflare Dashboard 中打开 Workers & Pages,选择 .labex/run-name 中保存的准确名称,然后检查其绑定。ROOMS 应指向 RoomBroadcast。接着打开 Durable Objects,选择名为 <your-worker>_RoomBroadcast 的命名空间,并在 Overview 中确认 Storage: SQL

ROOMS 绑定指向 RoomBroadcast Durable Object

所属的 RoomBroadcast 命名空间使用 SQL 存储

打开该命名空间的 Logs 选项卡。选择一条与浏览器或 Node 探针关联的近期成功记录。结构化的 room_update 应用消息会报告其序列和当前连接数,但不会记录消息文本。Dashboard 可能会在请求完成后才交付日志;运行时响应和独立检查仍然是权威结果。

重新部署未修改的代码。打开的 WebSocket 连接属于实时传输,不保证能够在部署后继续存在,但 SQLite 历史记录属于已命名的对象,因此应当保留:

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 房间仍应报告一条序列为 1 的消息;support 仍为空。你生成的后缀、时间戳和 Dashboard 流量总数会与测试示例不同。

删除房间命名空间

在本步骤中,你将删除准确的临时 Durable Object 命名空间和 Worker,然后在 VM 上保持授权状态,以便 LabEx 验证这两个资源都已不存在。

确认保存的名称以 labex-c10-o05- 开头。创建无状态的清理入口:

RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o05-*) ;; *) echo "Unexpected Worker name" >&2; exit 1;; esac
cat > src/cleanup.js <<'JS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
JS

为同一个 Worker 和账号创建清理配置。state: "deleted" 墓碑只会删除本实验的类命名空间,包括其中的临时历史记录:

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": {
    "RoomBroadcast": { "type": "durable-object", "state": "deleted" }
  }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc

协调输出应报告 Deleted: RoomBroadcast。删除剩余的无状态 Worker。由于删除操作无法撤销,Wrangler 会要求确认;只有在显示的名称与 $RUN 值完全一致后才确认:

npx wrangler delete --config wrangler.cleanup.jsonc

在提示符处输入 y,然后按 Enter。命令应以 Successfully deleted 和你生成的 Worker 名称结尾。

在本步骤结束时的检查完成前,保持此 VM 处于授权状态。确认 Wrangler 仍报告已认证会话:

npx wrangler whoami --json | jq '{loggedIn, authType}'

JSON 必须包含 "loggedIn": true。现在,LabEx 可以查询所选账号,并证明 Worker 和其 Durable Object 命名空间都不存在。网络错误或身份验证错误不能证明清理成功。

撤销此 VM 的 Wrangler 授权

在本步骤中,你将在确认云端资源已删除后,撤销仅存储在这台新 VM 上的 OAuth 授权。

wrangler logout 会删除本地授权。结构化的 whoami --json 检查很重要,因为普通的人类可读输出可能存在歧义;loggedIn 字段才是权威结果:

npx wrangler logout
npx wrangler whoami --json

最终 JSON 必须包含 "loggedIn": false。这不会删除或退出你在浏览器中的 Cloudflare 学习账号;它只会阻止此 VM 继续发出经过身份验证的 Wrangler 请求。

总结

你将 HTTP 请求升级为 WebSocket,将经过验证的房间名称路由到彼此独立的 Durable Object,并向同一房间中的两个客户端广播一条通过验证的更新,同时保持另一个房间隔离。你区分了 JSON 解析和应用层验证,证明格式错误的输入既不会改变广播状态,也不会改变 SQLite 历史记录;随后在 Cloudflare 上重复了相同行为,检查了浏览器和 Dashboard 视图,验证重新部署后历史记录仍然存在,并删除了准确的临时命名空间。

可复用的设计规则是:在选择状态或修改状态之前完成验证;通过各自稳定的对象标识协调每个实时群组;并将活动连接与持久化的应用历史记录分开处理。