Introducción
El nombre de un Durable Object forma parte del modelo de datos de una aplicación. Las llamadas que usan el mismo nombre llegan al mismo objeto lógico y a su base de datos SQLite; los nombres diferentes seleccionan unidades de coordinación distintas. Por tanto, una regresión de enrutamiento puede exponer el estado de una sala a través de la URL de otra sala, aunque la clase de Durable Object y su código de almacenamiento sean correctos.
En este laboratorio, implementará un pequeño registro de salas con historiales intactos para planning y support, reproducirá una versión defectuosa que envía todas las salas al objeto planning y usará un diagnóstico de rutas para encontrar la discrepancia. Corregirá únicamente la asignación de nombres, volverá a desplegar y demostrará que ambos historiales originales se conservaron. Después, una herramienta WebSocket proporcionada realizará actualizaciones simultáneas, desconectará y reconectará las salas, y confirmará que las salas nuevas siguen aisladas.
Si accedió directamente a este curso, complete primero Conectar LabEx con su cuenta de Cloudflare. Allí aprenderá a usar el terminal de la VM de LabEx, la autorización de dispositivos de Wrangler, la confirmación de la cuenta y la configuración explícita del ID de cuenta que se utilizan aquí. Esta VM nueva todavía necesita su propia autorización.
Autorizar la VM y declarar el espacio de nombres de las salas
En este paso, autorizará esta VM nueva, confirmará la cuenta de aprendizaje dedicada y declarará un espacio de nombres de Durable Object respaldado por SQLite.
cd /home/labex/project/room-routing
npx wrangler --version
npx wrangler login --device --browser=false
Debería aparecer Wrangler 4.132.0. Abra en el navegador la URL de Cloudflare que se muestra, introduzca el código corto, confirme la cuenta de aprendizaje correcta y autorícela. La autorización mediante dispositivo permite que esta VM tenga acceso sin enviar su contraseña al terminal.
Lea únicamente los campos de identidad seguros, seleccione por nombre la cuenta confirmada y cree un nombre de Worker desechable:
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 es un binding de espacio de nombres: puede direccionar muchos objetos RoomJournal. El nombre elegido por la aplicación y pasado a getByName() determina la base de datos SQLite y las conexiones activas del objeto que reciben la llamada.
Crear un registro con nombres de objeto explícitos
En este paso, implementará la clase con estado y mantendrá la selección de identidad en una única función de enrutamiento pequeña. Esta separación es importante durante el diagnóstico: el comportamiento del almacenamiento puede seguir siendo correcto mientras el llamador selecciona el objeto equivocado.
Cree el asignador inicialmente correcto. Un nombre de sala validado ya es un nombre de objeto estable y determinista:
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
Cree el Worker y el Durable Object. ctx.id.name informa del nombre estable utilizado para llegar a este objeto. La página registra únicamente los nombres sintéticos de sala solicitados y seleccionados; el texto del registro se omite deliberadamente de los 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
La prueba comprueba directamente el límite de identidad. El Durable Object utiliza el nombre que administra el runtime para los diagnósticos y almacena las filas del registro en SQLite antes de devolverlas.
Desplegar dos historiales de sala intactos
En este paso, desplegará primero la versión correcta y creará un evento reconocible en cada sala. Estas filas son la evidencia de conservación: la reparación posterior solo será correcta si ambas filas regresan de sus objetos originales.
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
Los dos campos objectName deben ser diferentes. planning debe contener únicamente plan-start, mientras que support debe contener únicamente support-start. El nombre del Worker es desechable, pero estos historiales de objetos deben sobrevivir a la regresión y a la reparación de la versión.
Reproducir y rastrear la versión defectuosa
En este paso, simulará una regresión de versión incluida en el laboratorio. La función defectuosa ignora su argumento y siempre devuelve planning. Se espera que la prueba de identidad falle; registrar ese fallo controlado permite observar el defecto antes del despliegue.
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
El diagnóstico separa la sala solicitada del nombre del objeto seleccionado. Una solicitud para support ahora informa objectName: planning, y su lectura muestra plan-start. No eliminó ni sobrescribió el objeto original support; la versión defectuosa simplemente dejó de direccionarlo.
Reparar el asignador y demostrar el aislamiento tras la reconexión
En este paso, reparará únicamente la asignación de identidad. No es necesario restablecer el almacenamiento ni reproducir los datos, porque los objetos originales con nombre todavía existen.
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
La lectura reparada encuentra ambos identificadores de evento originales en sus objetos originales. Después, la herramienta WebSocket actualiza dos objetos nuevos simultáneamente, cierra ambas conexiones y vuelve a conectarlas. Cada frame ready contiene únicamente su propio evento, lo que demuestra que el enrutamiento —no un restablecimiento de la base de datos— corrigió la fuga.
Inspeccionar y volver a desplegar el servicio reparado
En este paso, relacionará la evidencia del runtime con las vistas del navegador y del Dashboard, diseñadas para principiantes. Abra la URL almacenada en .labex/app-url añadiendo /inspect. La frase verde y la tabla deben mostrar planning → planning, support → support y los dos identificadores de evento conservados.

Abra Workers & Pages, seleccione el nombre exacto del Worker que aparece en .labex/run-name y abra Bindings. ROOMS debe estar conectado a RoomJournal.

Abra Durable Objects, seleccione <your-worker>_RoomJournal y confirme Storage: SQL. Un espacio de nombres puede contener muchos objetos con nombre; el nombre selecciona el objeto aislado dentro de él.

Vuelva al Worker en Workers & Pages, abra Observability y busque los eventos almacenados mediante routing_decision. Expanda un evento correspondiente a una operación de sala. Esta decisión la registra el Worker sin estado antes de llamar al Durable Object, por lo que aparece en los logs del Worker y no en los logs del espacio de nombres. Los campos seguros deben mostrar el mismo nombre sintético de sala solicitado y seleccionado, sin incluir el texto del registro.

Por último, vuelva a desplegar el código sin cambios y lea de nuevo las salas originales:
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
Ambos historiales originales permanecen. Un despliegue sin cambios no crea identidades de objeto nuevas porque los mismos nombres validados siguen seleccionando las mismas entradas del espacio de nombres.
Eliminar el espacio de nombres del registro de salas
En este paso, eliminará únicamente el Worker de este laboratorio y el espacio de nombres generado mientras la VM todavía está autorizada. El marcador declarativo elimina el espacio de nombres de la clase antes de que Wrangler elimine el 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 el aviso muestra exactamente su $RUN, escriba y y espere Successfully deleted. Mantenga la VM autorizada para realizar la comprobación independiente de ausencia:
npx wrangler whoami --json | jq '{loggedIn, authType}'
El JSON debe contener "loggedIn": true; un fallo de red o de autenticación no demuestra que se haya eliminado el recurso.
Revocar la autorización de Wrangler de esta VM
En este paso, eliminará la autorización OAuth únicamente de esta VM después de verificar la eliminación de forma independiente:
npx wrangler logout
npx wrangler whoami --json
El JSON final debe contener "loggedIn": false. Su cuenta de aprendizaje seguirá conectada en el navegador.
Resumen
Diagnosticó un defecto de Durable Object en el límite del enrutamiento de identidades, en lugar de restablecer un almacenamiento que funcionaba correctamente. Una versión defectuosa controlada demostró que support seleccionaba el objeto planning; el diagnóstico de rutas hizo visibles los nombres solicitados y seleccionados. Restaurar la asignación directa de nombres recuperó de inmediato ambos historiales originales de SQLite. Después, las actualizaciones simultáneas mediante WebSocket, las desconexiones, las reconexiones y un nuevo despliegue sin cambios demostraron que las salas nuevas y existentes seguían aisladas. Por último, inspeccionó el despliegue reparado, eliminó los recursos desechables exactos y cerró la sesión.



