简介
普通的 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
sender 和 peer 对象应包含相同的 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 保持为 0。before 和 after 历史记录完全相同,均包含一条消息。因此,无效客户端无法添加数据行、推进序列,或将错误转化为整个房间的广播。
直接读取两个房间的状态:
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 客户端收到序列 1,support 客户端收不到更新,格式错误的输入不会改变历史记录。
在浏览器中打开输出的 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 卡片同样重要,因为它显示了哪些内容没有跨越对象标识边界。

在 Cloudflare Dashboard 中打开 Workers & Pages,选择 .labex/run-name 中保存的准确名称,然后检查其绑定。ROOMS 应指向 RoomBroadcast。接着打开 Durable Objects,选择名为 <your-worker>_RoomBroadcast 的命名空间,并在 Overview 中确认 Storage: 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 视图,验证重新部署后历史记录仍然存在,并删除了准确的临时命名空间。
可复用的设计规则是:在选择状态或修改状态之前完成验证;通过各自稳定的对象标识协调每个实时群组;并将活动连接与持久化的应用历史记录分开处理。



