Introdução
O nome de um Durable Object faz parte do modelo de dados de uma aplicação. As chamadas que usam o mesmo nome chegam ao mesmo objeto lógico e ao mesmo banco de dados SQLite; nomes diferentes selecionam unidades de coordenação diferentes. Por isso, uma regressão de roteamento pode expor o estado de uma sala pela URL de outra sala, mesmo quando a classe do Durable Object e o código de armazenamento estão corretos.
Neste laboratório, você vai implantar um pequeno diário de salas com históricos íntegros de planning e support, reproduzir uma versão defeituosa que envia todas as salas para o objeto planning e usar um diagnóstico de rota para encontrar a divergência. Você vai corrigir apenas o mapeamento de nomes, fazer uma nova implantação e comprovar que os dois históricos originais foram preservados. Em seguida, um teste de WebSocket fornecido fará atualizações simultâneas, desconectará e reconectará as salas e confirmará que as novas salas continuam isoladas.
Se você entrou diretamente neste curso, conclua primeiro Connect LabEx to Your Cloudflare Account. Esse laboratório ensina a usar o terminal da VM do LabEx, a autorização de dispositivo do Wrangler, a confirmação da conta e a configuração explícita do ID da conta usadas aqui. Esta VM nova ainda precisa ser autorizada.
Autorizar a VM e declarar o namespace das salas
Nesta etapa, você vai autorizar esta VM nova, confirmar a conta de aprendizagem dedicada e declarar um namespace de Durable Object com armazenamento SQLite.
cd /home/labex/project/room-routing
npx wrangler --version
npx wrangler login --device --browser=false
A versão esperada do Wrangler é 4.132.0. Abra no navegador a URL do Cloudflare exibida, informe o código curto, confirme a conta de aprendizagem correta e autorize o acesso. A autorização por dispositivo permite que esta VM acesse a conta sem enviar sua senha ao terminal.
Leia apenas campos de identidade seguros, selecione pelo nome a conta confirmada e crie um nome descartável para o 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 é uma vinculação de namespace: ela pode endereçar muitos objetos RoomJournal. O nome escolhido pela aplicação e passado a getByName() determina qual banco de dados SQLite e quais conexões ativas do objeto recebem a chamada.
Criar um diário com nomes de objetos explícitos
Nesta etapa, você vai implementar a classe com estado e manter a seleção de identidade em uma única função pequena de roteamento. Essa separação é importante durante o diagnóstico: o comportamento do armazenamento pode continuar correto enquanto o chamador seleciona o objeto errado.
Crie o mapeador inicialmente correto. Um nome de sala validado já é um nome de objeto estável e determinístico:
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
Crie o Worker e o Durable Object. ctx.id.name informa o nome estável usado para acessar este objeto. A página registra apenas os nomes sintéticos de salas solicitados e selecionados; o texto do diário é deliberadamente omitido dos logs.
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
O teste verifica diretamente o limite de identidade. O Durable Object usa o nome fornecido pelo runtime para diagnóstico e armazena as linhas do diário no SQLite antes de retorná-las.
Implantar os históricos íntegros de duas salas
Nesta etapa, você vai implantar primeiro a versão íntegra e criar um evento identificável em cada sala. Essas linhas são as evidências de preservação: o reparo posterior só será bem-sucedido se as duas linhas retornarem de seus objetos originais.
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
Os dois campos objectName devem ser diferentes. planning deve conter apenas plan-start, enquanto support deve conter apenas support-start. O nome do Worker é descartável, mas os históricos desses objetos devem sobreviver à regressão e ao reparo da versão.
Reproduzir e rastrear a versão defeituosa
Nesta etapa, você vai simular uma regressão de versão fornecida com o laboratório. A função defeituosa ignora o argumento e sempre retorna planning. A execução do teste de identidade deve falhar; registrar essa falha controlada torna o defeito observável antes da implantação.
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
O diagnóstico separa a sala solicitada do nome do objeto selecionado. Uma solicitação para support agora informa objectName: planning, e a leitura expõe plan-start. Você não excluiu nem sobrescreveu o objeto original support; a versão defeituosa apenas deixou de acessá-lo.
Corrigir o mapeador e comprovar o isolamento após a reconexão
Nesta etapa, você vai corrigir apenas o mapeamento de identidade. Não é necessário redefinir o armazenamento nem reproduzir os dados, pois os objetos nomeados originais ainda existem.
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
A leitura após o reparo encontra os dois IDs de evento originais em seus objetos originais. Em seguida, o teste de WebSocket atualiza dois objetos novos simultaneamente, fecha as duas conexões e reconecta. Cada quadro ready contém apenas o evento da própria sala, comprovando que o vazamento foi corrigido pelo roteamento, e não pela redefinição do banco de dados.
Inspecionar e reimplantar o serviço corrigido
Nesta etapa, você vai relacionar as evidências do runtime às visualizações do navegador e do Dashboard, mais fáceis para iniciantes. Abra a URL armazenada em .labex/app-url adicionando /inspect. A mensagem verde e a tabela devem mostrar planning → planning, support → support e os dois IDs de evento preservados.

Abra Workers & Pages, selecione o nome exato do Worker em .labex/run-name e abra Bindings. ROOMS deve estar conectado a RoomJournal.

Abra Durable Objects, selecione <your-worker>_RoomJournal e confirme Storage: SQL. Um namespace pode conter muitos objetos nomeados; o nome seleciona o objeto isolado dentro dele.

Volte ao Worker em Workers & Pages, abra Observability e pesquise nos eventos armazenados por routing_decision. Expanda um evento de uma operação de sala. Essa decisão é registrada pelo Worker sem estado antes de ele chamar o Durable Object, por isso aparece nos logs do Worker, e não nos logs do namespace. Os campos seguros devem mostrar o mesmo nome sintético de sala solicitado e selecionado, sem o texto do diário.

Por fim, reimplante o código sem alterações e leia novamente as salas originais:
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
Os dois históricos originais continuam presentes. Uma implantação sem alterações não cria novas identidades de objeto, pois os mesmos nomes validados continuam selecionando as mesmas entradas do namespace.
Excluir o namespace do diário de salas
Nesta etapa, você vai excluir apenas o Worker deste laboratório e o namespace gerado, enquanto a VM ainda está autorizada. O tombstone declarativo remove o namespace da classe antes que o Wrangler exclua o script.
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
Confirme que o prompt mostra exatamente o seu $RUN, digite y e espere Successfully deleted. Mantenha a VM autorizada para a verificação independente de ausência:
npx wrangler whoami --json | jq '{loggedIn, authType}'
O JSON deve conter "loggedIn": true; uma falha de rede ou de autenticação não comprova a exclusão.
Revogar a autorização do Wrangler nesta VM
Nesta etapa, você vai remover apenas a autorização OAuth desta VM depois de verificar a exclusão de forma independente:
npx wrangler logout
npx wrangler whoami --json
O JSON final deve conter "loggedIn": false. Sua conta de aprendizagem continua conectada no navegador.
Resumo
Você diagnosticou um defeito de Durable Object no limite do roteamento de identidade, em vez de redefinir o armazenamento íntegro. Uma versão defeituosa controlada comprovou que support estava selecionando o objeto planning; o diagnóstico de rota tornou visíveis os nomes solicitado e selecionado. A restauração do mapeamento direto de nomes recuperou imediatamente os dois históricos originais do SQLite. Atualizações simultâneas por WebSocket, desconexões, reconexões e uma nova implantação sem alterações comprovaram que as salas novas e existentes continuaram isoladas. Por fim, você inspecionou a implantação corrigida, removeu os recursos descartáveis exatos e encerrou a sessão.



