Введение
Имя Durable Object — часть модели данных приложения. Вызовы с одним и тем же именем обращаются к одному логическому объекту и его базе данных SQLite, а разные имена выбирают разные единицы координации. Поэтому регрессия маршрутизации может открыть состояние одной комнаты через URL другой комнаты, даже если класс Durable Object и код его хранилища работают правильно.
В этой лабораторной работе вы развернёте небольшой журнал комнат с корректными историями planning и support, воспроизведёте ошибочный релиз, который направляет каждую комнату к объекту planning, и с помощью диагностики маршрута найдёте несоответствие. Вы исправите только сопоставление имён, повторно развернёте приложение и подтвердите, что обе исходные истории сохранены. Затем предоставленная проверка WebSocket выполнит параллельные обновления, отключит и снова подключит соединения, а также подтвердит изоляцию новых комнат.
Если вы перешли к этому курсу напрямую, сначала выполните Подключение LabEx к вашей учётной записи Cloudflare. В этой лабораторной работе объясняется, как использовать терминал виртуальной машины LabEx, авторизовать устройство Wrangler, подтвердить учётную запись и явно настроить идентификатор учётной записи. Для этой новой виртуальной машины авторизацию нужно пройти отдельно.
Авторизуйте виртуальную машину и объявите пространство имён комнат
На этом шаге вы авторизуете новую виртуальную машину, подтвердите выделенную учебную учётную запись и объявите одно пространство имён Durable Object с хранилищем SQLite.
cd /home/labex/project/room-routing
npx wrangler --version
npx wrangler login --device --browser=false
Ожидается версия Wrangler 4.132.0. Откройте показанный URL Cloudflare в браузере, введите короткий код, подтвердите нужную учебную учётную запись и авторизуйте её. Авторизация устройства предоставляет этой виртуальной машине доступ, не передавая ваш пароль в терминал.
Прочитайте только безопасные поля идентификации, выберите подтверждённую учётную запись по имени и создайте одноразовое имя 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
После исправления чтение находит оба исходных идентификатора событий в соответствующих исходных объектах. Затем проверка WebSocket одновременно обновляет два новых объекта, закрывает оба соединения и снова подключается к ним. Каждый кадр ready содержит только собственное событие, что доказывает: утечку устранила маршрутизация, а не сброс базы данных.
Проверьте и повторно разверните исправленный сервис
На этом шаге вы сопоставите данные среды выполнения с удобными для начинающих представлениями в браузере и Dashboard. Откройте URL, сохранённый в .labex/app-url, добавив /inspect. Зелёное сообщение и таблица должны показывать planning → planning, support → support и два сохранённых идентификатора событий.

Откройте Workers & Pages, выберите точное имя Worker из .labex/run-name и откройте Bindings. Привязка ROOMS должна указывать на RoomJournal.

Откройте Durable Objects, выберите <your-worker>_RoomJournal и подтвердите значение Storage: SQL. Одно пространство имён может содержать множество именованных объектов; имя выбирает изолированный объект внутри него.

Вернитесь к Worker в разделе Workers & Pages, откройте Observability и найдите в сохранённых событиях routing_decision. Разверните одно событие, относящееся к операции комнаты. Это решение записывается статeless 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, отключение, переподключение и повторное развертывание кода без изменений подтвердили, что новые и существующие комнаты остаются изолированными. Наконец, вы проверили исправленное развертывание, удалили созданные одноразовые ресурсы и вышли из системы.



