介绍
Durable Object 的名称是应用数据模型的一部分。使用相同名称的调用会到达同一个逻辑对象及其 SQLite 数据库;不同名称会选择不同的协调单元。因此,即使 Durable Object 类及其存储代码都正确,路由回归问题仍可能导致一个房间的状态通过另一个房间的 URL 暴露出来。
在本实验中,你将部署一个小型房间日志应用,其中包含正常的 planning 和 support 历史记录;然后复现一个会将所有房间发送到 planning 对象的错误版本,并使用路由诊断功能找出不匹配的位置。你只需修复名称映射并重新部署,然后验证两个原始历史记录都得到保留。实验提供的 WebSocket 探测脚本还会执行并发更新、断开连接和重新连接,并确认新房间仍然彼此隔离。
如果你是直接进入本课程,请先完成 将 LabEx 连接到你的 Cloudflare 账户。该实验会介绍这里使用的 LabEx 虚拟机终端、Wrangler 设备授权、账户确认和显式账户 ID 配置。这台新的虚拟机仍需要单独完成授权。
授权虚拟机并声明房间命名空间
在本步骤中,你将授权这台新的虚拟机,确认专用学习账户,并声明一个由 SQLite 支持的 Durable Object 命名空间。
cd /home/labex/project/room-routing
npx wrangler --version
npx wrangler login --device --browser=false
预期 Wrangler 版本为 4.132.0。在浏览器中打开显示的 Cloudflare URL,输入短代码,确认目标学习账户并完成授权。设备授权允许这台虚拟机访问账户,而不会将密码发送到终端。
仅读取安全的身份字段,按名称选择已确认的账户,并创建一个临时 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-o07-$(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": "RoomJournal" }
] },
"exports": {
"RoomJournal": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
ROOMS 是一个命名空间绑定,可以定位多个 RoomJournal 对象。传递给 getByName() 的应用程序名称决定哪个对象的 SQLite 数据库和实时连接会接收调用。
使用明确的对象名称构建日志
在本步骤中,你将实现有状态的类,并将身份选择集中在一个小型路由函数中。诊断期间,这种分离非常重要:存储行为可以保持正常,但调用方可能选择了错误的对象。
创建最初正确的映射函数。经过验证的房间名称本身就是稳定且确定性的对象名称:
cat > src/router.js <<'JS'
export function objectNameFor(room) {
return room;
}
JS
cat > test/router.test.mjs <<'JS'
import test from "node:test";
import assert from "node:assert/strict";
import { objectNameFor } from "../src/router.js";
test("each validated room keeps its own object identity", () => {
assert.equal(objectNameFor("planning"), "planning");
assert.equal(objectNameFor("support"), "support");
assert.notEqual(objectNameFor("planning"), objectNameFor("support"));
});
JS
创建 Worker 和 Durable Object。ctx.id.name 会报告用于访问此对象的稳定名称。页面日志只记录请求的房间名称和选中的合成房间名称;日志会刻意省略日志文本。
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { objectNameFor } from "./router.js";
const ROOM = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
const EVENT = /^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/;
const json = (value, status = 200) => Response.json(value, { status });
const safeRoom = value => ROOM.test(value || "") ? value : null;
export class RoomJournal extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
text TEXT NOT NULL
)`);
});
}
state() {
return {
objectName: this.ctx.id.name,
events: this.ctx.storage.sql.exec(
"SELECT sequence, event_id AS eventId, text FROM events ORDER BY sequence"
).toArray()
};
}
append(eventId, text) {
if (!EVENT.test(eventId || "") || typeof text !== "string" || text.length < 1 || text.length > 80) {
throw new Error("invalid_event");
}
this.ctx.storage.sql.exec("INSERT OR IGNORE INTO events (event_id, text) VALUES (?, ?)", eventId, text);
return this.state();
}
async fetch(request) {
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return json({ error: "upgrade_required" }, 426);
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
server.send(JSON.stringify({ type: "ready", ...this.state() }));
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(socket, raw) {
try {
const message = JSON.parse(raw);
if (message.type !== "append") throw new Error("invalid_event");
const state = this.append(message.eventId, message.text);
const frame = JSON.stringify({ type: "event", ...state });
for (const peer of this.ctx.getWebSockets()) peer.send(frame);
} catch {
socket.send(JSON.stringify({ type: "error", error: "invalid_event" }));
}
}
}
async function roomState(env, room) {
return env.ROOMS.getByName(objectNameFor(room)).state();
}
function inspectPage(planning, support) {
const rows = [planning, support].map(([requested, state]) => `<tr><td>${requested}</td><td>${state.objectName}</td><td>${state.events.map(x => x.eventId).join(", ")}</td></tr>`).join("");
return `<!doctype html><html lang="en"><meta charset="utf-8"><title>Room routing inspector</title>
<style>body{font:18px system-ui;max-width:900px;margin:48px auto;color:#17212b}h1{color:#5b8c00}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccd5df;padding:14px;text-align:left}th{background:#eef7dc}.ok{padding:12px;background:#eef7dc;border-left:5px solid #78aa00}</style>
<h1>Room routing inspector</h1><p class="ok">Each requested room resolves to the matching Durable Object name.</p>
<table><thead><tr><th>Requested room</th><th>Object name</th><th>Preserved event IDs</th></tr></thead><tbody>${rows}</tbody></table></html>`;
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/inspect") {
const states = await Promise.all(["planning", "support"].map(async room => [room, await roomState(env, room)]));
return new Response(inspectPage(...states), { headers: { "content-type": "text/html; charset=utf-8" } });
}
const debug = url.pathname.match(/^\/debug\/route\/([^/]+)$/);
if (debug) {
const room = safeRoom(debug[1]);
if (!room) return json({ error: "invalid_room" }, 400);
return json({ requestedRoom: room, objectName: objectNameFor(room) });
}
const match = url.pathname.match(/^\/rooms\/([^/]+)\/(events|connect)$/);
if (!match) return json({ error: "not_found" }, 404);
const room = safeRoom(match[1]);
if (!room) return json({ error: "invalid_room" }, 400);
const objectName = objectNameFor(room);
console.log(JSON.stringify({ event: "routing_decision", requestedRoom: room, objectName, operation: match[2] }));
const stub = env.ROOMS.getByName(objectName);
if (match[2] === "connect") return stub.fetch(request);
if (request.method === "GET") return json(await stub.state());
if (request.method === "POST") {
try {
const body = await request.json();
return json(await stub.append(body.eventId, body.text), 201);
} catch (error) {
return json({ error: error.message === "invalid_event" ? "invalid_event" : "invalid_json" }, 400);
}
}
return json({ error: "method_not_allowed" }, 405);
}
};
JS
npm test
该测试会直接检查身份边界。Durable Object 使用运行时管理的名称进行诊断,并在返回日志记录前将其存储在 SQLite 中。
部署两个正常的房间历史记录
在本步骤中,你将先部署正常版本,并在每个房间中创建一条易于识别的事件。这些记录是后续验证保留情况的依据:只有两个记录都从其原始对象中返回,后续修复才算成功。
rm -f .labex/deploy.log .labex/app-url .labex/baseline.json
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/debug/route/planning" || true)"; test "$(jq -r '.objectName // empty' <<<"$READY" 2>/dev/null)" = planning && break; sleep 2; done
test "$(jq -r .objectName <<<"$READY")" = planning
sleep 5
curl --silent --fail -X POST "$APP_URL/rooms/planning/events" -H 'content-type: application/json' --data '{"eventId":"plan-start","text":"Planning kickoff"}' >/dev/null
curl --silent --fail -X POST "$APP_URL/rooms/support/events" -H 'content-type: application/json' --data '{"eventId":"support-start","text":"Support handoff"}' >/dev/null
jq -n --argjson planning "$(curl --silent --fail "$APP_URL/rooms/planning/events")" --argjson support "$(curl --silent --fail "$APP_URL/rooms/support/events")" '{planning:$planning,support:$support}' | tee .labex/baseline.json
两个 objectName 字段必须不同。planning 只能包含 plan-start,support 只能包含 support-start。Worker 名称是临时的,但这些对象历史记录必须在版本回归和修复过程中保留。
复现并追踪错误版本
在本步骤中,你将模拟实验提供的版本回归问题。错误函数会忽略传入的参数,并始终返回 planning。运行身份测试时预期会失败;记录这次受控失败,可以在部署前让缺陷变得可观察。
cp fixtures/router-bug.js src/router.js
rm -f .labex/bug-test.log .labex/bug.json
set -o pipefail
if npm test 2>&1 | tee .labex/bug-test.log; then TEST_STATUS=0; else TEST_STATUS=$?; fi
set +o pipefail
printf '%s\n' "$TEST_STATUS" > .labex/bug-test-status
test "$TEST_STATUS" -ne 0
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
for attempt in $(seq 1 30); do
BUG_ROUTE="$(curl --silent "$APP_URL/debug/route/support" || true)"
BUG_READ="$(curl --silent "$APP_URL/rooms/support/events" || true)"
test "$(jq -r '.objectName // empty' <<<"$BUG_ROUTE" 2>/dev/null)" = planning && test "$(jq -r '.objectName // empty' <<<"$BUG_READ" 2>/dev/null)" = planning && break
sleep 2
done
test "$(jq -r .objectName <<<"$BUG_ROUTE")" = planning
test "$(jq -r .objectName <<<"$BUG_READ")" = planning
jq -n \
--argjson planningRoute "$(curl --silent --fail "$APP_URL/debug/route/planning")" \
--argjson supportRoute "$BUG_ROUTE" \
--argjson supportRead "$BUG_READ" \
'{planningRoute:$planningRoute,supportRoute:$supportRoute,supportRead:$supportRead}' | tee .labex/bug.json
诊断结果将请求的房间与选中的对象名称区分开来。现在,请求 support 时会报告 objectName: planning,并且读取结果会暴露 plan-start。你没有删除或覆盖原来的 support 对象;错误版本只是停止访问它。
修复映射并验证重连隔离
在本步骤中,你只需修复身份映射。无需重置存储或重新写入数据,因为原来的命名对象仍然存在。
cat > src/router.js <<'JS'
export function objectNameFor(room) {
return room;
}
JS
npm test
cat > tools/isolation.mjs <<'JS'
import WebSocket from "ws";
const [base, prefix] = process.argv.slice(2);
const wsBase = base.replace(/^http/, "ws");
const rooms = [`${prefix}-planning`, `${prefix}-support`];
const open = room => new Promise((resolve, reject) => {
const ws = new WebSocket(`${wsBase}/rooms/${room}/connect`);
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, eventId) => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("event timeout")), 5000);
const inspect = value => { if (value.type === "event" && value.events.some(x => x.eventId === eventId)) { clearTimeout(timer); client.ws.off("message", listener); resolve(value); } };
const listener = raw => inspect(JSON.parse(raw));
client.ws.on("message", listener); client.inbox.forEach(inspect);
});
const close = client => new Promise(resolve => { client.ws.once("close", resolve); client.ws.close(1000, "reconnect"); });
const [planning, support] = await Promise.all(rooms.map(open));
planning.ws.send(JSON.stringify({ type:"append", eventId:`${prefix}-plan`, text:"Plan update" }));
support.ws.send(JSON.stringify({ type:"append", eventId:`${prefix}-support`, text:"Support update" }));
await Promise.all([waitFor(planning, `${prefix}-plan`), waitFor(support, `${prefix}-support`)]);
await Promise.all([close(planning), close(support)]);
const [planningAgain, supportAgain] = await Promise.all(rooms.map(open));
const result = { planning:planningAgain.ready, support:supportAgain.ready };
console.log(JSON.stringify(result, null, 2));
await Promise.all([close(planningAgain), close(supportAgain)]);
JS
rm -f .labex/repaired.json .labex/reconnect.json
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
for attempt in $(seq 1 30); do REPAIRED_READY="$(curl --silent "$APP_URL/debug/route/support" || true)"; test "$(jq -r '.objectName // empty' <<<"$REPAIRED_READY" 2>/dev/null)" = support && break; sleep 2; done
test "$(jq -r .objectName <<<"$REPAIRED_READY")" = support
sleep 5
jq -n --argjson planning "$(curl --silent --fail "$APP_URL/rooms/planning/events")" --argjson support "$(curl --silent --fail "$APP_URL/rooms/support/events")" '{planning:$planning,support:$support}' | tee .labex/repaired.json
node tools/isolation.mjs "$APP_URL" cloud | tee .labex/reconnect.json
修复后的读取结果应在原来的对象中找到两个原始事件 ID。随后,WebSocket 探测脚本会并发更新两个新对象,关闭两个连接并重新连接。每个 ready 帧只包含自己的事件,证明修复的是路由,而不是通过重置数据库来解决泄漏问题。
检查并重新部署修复后的服务
在本步骤中,你将把运行时证据与适合初学者查看的浏览器和 Dashboard 界面关联起来。打开 .labex/app-url 中保存的 URL,并在末尾加上 /inspect。绿色提示和表格应显示 planning → planning、support → support,以及两个保留的事件 ID。

打开 Workers & Pages,选择 .labex/run-name 中的准确 Worker 名称,然后打开 Bindings。ROOMS 应连接到 RoomJournal。

打开 Durable Objects,选择 <your-worker>_RoomJournal,并确认 Storage: SQL。一个命名空间可以包含多个命名对象;名称用于选择其中隔离的对象。

返回 Workers & Pages 中的 Worker,打开 Observability,并搜索包含 routing_decision 的存储事件。展开一个房间操作产生的事件。该决策由无状态 Worker 在调用 Durable Object 前记录,因此会出现在 Worker 的日志中,而不是命名空间的日志中。安全字段应显示相同的请求房间名称和选中的合成房间名称,不包含日志文本。

最后,重新部署未修改的代码,并再次读取原始房间:
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/rooms/planning/events" | jq
curl --silent --fail "$APP_URL/rooms/support/events" | jq
两个原始历史记录都应保留。由于相同的已验证名称仍选择命名空间中的相同条目,未修改的部署不会创建新的对象身份。
删除房间日志命名空间
在本步骤中,你将在虚拟机仍处于授权状态时,只删除本实验的 Worker 和生成的命名空间。声明式删除标记会在 Wrangler 删除脚本前移除类命名空间。
RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o07-*) ;; *) 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": { "RoomJournal": { "type": "durable-object", "state": "deleted" } }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc
确认提示中显示的是准确的 $RUN,输入 y,并预期看到 Successfully deleted。保留虚拟机的授权,以便执行独立的不存在性检查:
npx wrangler whoami --json | jq '{loggedIn, authType}'
JSON 必须包含 "loggedIn": true;网络或身份验证失败不能作为已删除的证据。
撤销这台虚拟机的 Wrangler 授权
在独立确认删除完成后,本步骤将移除这台虚拟机的 OAuth 授权:
npx wrangler logout
npx wrangler whoami --json
最终 JSON 必须包含 "loggedIn": false。你的学习账户仍会在浏览器中保持登录状态。
总结
你在身份路由边界诊断出了 Durable Object 缺陷,而不是重置正常的存储。受控的错误版本证明 support 选择了 planning 对象;路由诊断则显示了请求名称和选中名称。恢复直接的名称映射后,两个原始 SQLite 历史记录立即恢复。随后,通过并发 WebSocket 更新、断开连接、重新连接和一次未修改的重新部署,证明新旧房间都保持隔离。最后,你检查了修复后的部署,在注销前删除了准确的临时资源。



