Diagnosticar o vazamento de estado entre salas

CloudflareBeginner
Pratique Agora

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.

O inspetor corrigido mapeia cada sala para o objeto correspondente e preserva o histórico

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

A vinculação ROOMS aponta para o Durable Object 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.

O namespace RoomJournal usa armazenamento SQL

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.

Uma decisão de roteamento estruturada mostra o mapeamento de identidade corrigido

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.