Introducción
Una solicitud HTTP normal se abre, recibe una respuesta y termina. Un WebSocket transforma esa primera solicitud HTTP en una conexión bidireccional que permanece abierta. Así, el servidor puede enviar una actualización en cuanto cambia algo. Los mensajes de chat, los cursores colaborativos y los paneles de pedidos en directo se benefician de este canal en tiempo real.
Un Durable Object proporciona un punto de coordinación para cada sala. El Worker de entrada convierte un nombre de sala validado, como planning, en una identidad de objeto estable. El objeto seleccionado acepta las conexiones WebSocket de esa sala, valida cada mensaje entrante y difunde una única actualización aprobada a sus propios clientes conectados. Un nombre diferente selecciona otro objeto, por lo que support no puede recibir tráfico de planning.
Este laboratorio utiliza deliberadamente la API WebSocket estándar y mantiene en memoria el conjunto de sockets activos. Así podrá observar el comportamiento de las conexiones y de la difusión antes de que O06 introduzca WebSocket Hibernation y los archivos adjuntos de conexión. SQLite almacena un pequeño historial de mensajes para que pueda demostrar que una entrada malformada no modificó el estado persistente; esto no hace que el socket abierto sea persistente.
Implementará el protocolo, conectará dos clientes proporcionados a una sala y un tercer cliente a otra, observará una difusión válida, rechazará una entrada malformada, repetirá la prueba en Cloudflare, inspeccionará el cliente del navegador y el Dashboard y, finalmente, eliminará los recursos temporales exactos.
Cada VM nueva necesita su propia autorización de Wrangler. Ya debería comprender los nombres estables de Durable Objects, los bindings, RPC y el estado respaldado por SQLite de O01–O04. La configuración instala Node.js 22.22.0, Wrangler 4.132.0 local del proyecto y el cliente de prueba ws en /home/labex/project/room-broadcast. También proporciona los clientes para el navegador y las pruebas, pero no escribe su Worker, no autoriza Cloudflare ni despliega nada.
Autorice la VM y declare el espacio de nombres de la sala
En este paso autorizará la VM nueva y declarará una clase de Durable Object respaldada por SQLite para las salas en tiempo real.
Acceda al proyecto preparado, confirme la versión fijada de Wrangler y autorice esta VM:
cd /home/labex/project/room-broadcast
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. El navegador concede acceso a Wrangler; nunca envía su contraseña a la VM.
Lea únicamente los campos de identidad seguros, seleccione la cuenta que confirmó y genere un nombre único para el Worker temporal:
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-o05-$(openssl rand -hex 6)"
printf '%s\n' "$RUN" | tee .labex/run-name
Si su cuenta de aprendizaje dedicada tiene otro nombre visible, sustituya el nombre por el que confirmó. Ahora escriba la configuración:
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": "RoomBroadcast" }
] },
"exports": {
"RoomBroadcast": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
El binding ROOMS es la ruta del Worker hacia el espacio de nombres de la clase. Una llamada a getByName("planning") siempre seleccionará la misma sala lógica, mientras que getByName("support") seleccionará un objeto independiente. La exportación proporciona almacenamiento SQLite privado a cada sala seleccionada. No existe ningún recurso en la nube hasta el despliegue.
Implemente el protocolo WebSocket validado
En este paso definirá un contrato de mensajes pequeño e implementará el objeto de sala que acepta y difunde mensajes WebSocket.
La solicitud inicial debe contener Upgrade: websocket. Después de la actualización, los mensajes son frames y no nuevas solicitudes HTTP. Un cliente puede enviar cualquier texto dentro de un frame, por lo que analizar el JSON es solo la primera comprobación. La validación también debe exigir el type esperado, un campo text no vacío y con longitud limitada, y ningún campo inesperado antes de modificar el estado persistente.
Cree los helpers del protocolo compartido:
cat > src/protocol.js <<'JS'
const ROOM_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
export function parseRoomPath(pathname) {
const match = pathname.match(/^\/rooms\/([^/]+)\/(connect|state)$/);
if (!match || !ROOM_PATTERN.test(match[1])) return null;
return { room: match[1], action: match[2] };
}
export function parseClientMessage(raw) {
if (typeof raw !== "string" || raw.length > 512) return { ok: false };
let value;
try { value = JSON.parse(raw); } catch { return { ok: false }; }
if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false };
const keys = Object.keys(value).sort();
if (keys.length !== 2 || keys[0] !== "text" || keys[1] !== "type") return { ok: false };
if (value.type !== "update" || typeof value.text !== "string") return { ok: false };
const text = value.text.trim();
if (text.length < 1 || text.length > 80) return { ok: false };
return { ok: true, text };
}
JS
Cree el Worker de entrada y la clase Durable Object:
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { CLIENT_HTML } from "./client-html.js";
import { parseClientMessage, parseRoomPath } from "./protocol.js";
const json = (body, status = 200) => Response.json(body, { status });
export class RoomBroadcast extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.sessions = new Set();
this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`);
});
}
async fetch(request) {
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return json({ error: "websocket_upgrade_required" }, 426);
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
this.sessions.add(server);
server.addEventListener("message", event => this.receive(server, event.data));
const forget = () => this.sessions.delete(server);
server.addEventListener("close", forget);
server.addEventListener("error", forget);
server.send(JSON.stringify({ type: "ready" }));
return new Response(null, { status: 101, webSocket: client });
}
receive(sender, raw) {
const message = parseClientMessage(raw);
if (!message.ok) {
sender.send(JSON.stringify({
type: "error",
code: "invalid_message",
detail: "Send only {type: update, text: 1-80 characters}."
}));
return;
}
const createdAt = Date.now();
const row = this.ctx.storage.sql.exec(`
INSERT INTO messages (text, created_at)
VALUES (?, ?)
RETURNING sequence
`, message.text, createdAt).one();
const update = JSON.stringify({
type: "update",
sequence: row.sequence,
text: message.text,
createdAt
});
for (const socket of this.sessions) {
try { socket.send(update); } catch { this.sessions.delete(socket); }
}
console.log(JSON.stringify({ event: "room_update", sequence: row.sequence, connected: this.sessions.size }));
}
async getState() {
const messages = this.ctx.storage.sql.exec(`
SELECT sequence, text, created_at AS createdAt
FROM messages ORDER BY sequence
`).toArray();
return {
messageCount: messages.length,
latestSequence: messages.at(-1)?.sequence ?? 0,
messages
};
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/" && request.method === "GET") {
return new Response(CLIENT_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
}
const route = parseRoomPath(url.pathname);
if (!route) return json({ error: "not_found" }, 404);
if (route.action === "connect") {
if (request.method !== "GET" || (request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return json({ error: "websocket_upgrade_required" }, 426);
}
return env.ROOMS.getByName(route.room).fetch(request);
}
if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
const state = await env.ROOMS.getByName(route.room).getState();
return json({ room: route.room, ...state });
}
};
JS
WebSocketPair crea los extremos de cliente y servidor de una conexión. Devolver el extremo del cliente con HTTP 101 completa la actualización, mientras que server.accept() inicia el socket estándar del lado del servidor. El conjunto sessions en memoria está limitado deliberadamente a una instancia de objeto, y el nombre estable de la sala evita que el conjunto se vuelva global para todas las salas.
Ejecute las pruebas deterministas del protocolo y pida a Wrangler que compile sin desplegar:
npm test
npx wrangler deploy --dry-run
Deberían aprobarse cuatro pruebas. La ejecución en seco comprueba el módulo del Worker y la configuración de los bindings; los pasos en vivo posteriores demostrarán el comportamiento real de los sockets.
Difunda una actualización dentro de una sala
En este paso ejecutará el Worker localmente y demostrará que una actualización llega a dos clientes que comparten una sala, pero no a un cliente de otra sala.
Inicie Wrangler como trabajo en segundo plano. Redirigir su salida mantiene despejada la terminal, y el ID de trabajo guardado permite detener después exactamente ese proceso:
mkdir -p .labex/local-state
npx wrangler dev --local --ip 127.0.0.1 --port 8787 --persist-to .labex/local-state > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
sleep 1
done
curl --silent --fail http://127.0.0.1:8787/ | grep -o '<title>[^<]*</title>'
El programa cliente proporcionado abre tres conexiones WebSocket reales: dos con el nombre planning y una con el nombre support. Envía una actualización desde el primer cliente de planning y espera pruebas acotadas de los tres:
node tools/room-clients.mjs http://127.0.0.1:8787 planning support broadcast | tee .labex/local-broadcast.json
Los objetos sender y peer deberían contener el mismo sequence: 1 y el mismo texto. otherUpdates debe ser 0. La sección de estado muestra de forma independiente un mensaje persistente en planning y cero mensajes en support. Esto demuestra las dos partes del diseño: el mismo nombre estable une a los dos primeros clientes y el nombre diferente mantiene al tercer cliente fuera del límite de difusión.
Rechace un mensaje malformado antes de modificar el estado
En este paso enviará un frame que contiene JSON válido, pero una entrada de aplicación no válida. Después comparará el estado persistente anterior y posterior.
El campo text vacío es la distinción importante: el análisis de JSON se completa correctamente, pero el protocolo de la sala lo rechaza. Ejecute la segunda fase proporcionada contra los mismos objetos locales:
node tools/room-clients.mjs http://127.0.0.1:8787 planning support invalid | tee .labex/local-invalid.json
Solo el cliente emisor recibe un error con el código invalid_message; peerErrors permanece en 0. Los historiales before y after son idénticos y contienen un mensaje. Por tanto, un cliente no válido no puede añadir una fila, avanzar la secuencia ni convertir un error en una difusión para toda la sala.
Lea directamente el estado de las dos salas:
curl --silent --fail http://127.0.0.1:8787/rooms/planning/state | jq
curl --silent --fail http://127.0.0.1:8787/rooms/support/state | jq
La primera respuesta informa de un mensaje y la segunda, de ninguno. Las lecturas de estado HTTP siguen siendo la fuente de verdad aunque un cliente se desconecte después de la prueba.
Despliegue y pruebe clientes WebSocket en la nube
En este paso detendrá el entorno local, desplegará el mismo código y repetirá el contrato de tres clientes a través de Cloudflare.
Detenga únicamente el trabajo local registrado antes y, después, despliegue:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
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
El despliegue crea primero el Worker y reconcilia el espacio de nombres RoomBroadcast. Una página de inicio correcta no demuestra por sí sola que la ruta con estado esté lista, así que consulte mediante polling el estado de una sala vacía sin efectos secundarios para comprobar el contrato JSON exacto y, después, espere un breve intervalo de estabilización:
for attempt in $(seq 1 30); do
READY="$(curl --silent --show-error "$APP_URL/rooms/cloud-observer/state" || true)"
test "$(printf '%s' "$READY" | jq -r '.messageCount // -1' 2>/dev/null)" = 0 && break
sleep 2
done
test "$(printf '%s' "$READY" | jq -r .messageCount)" = 0
sleep 5
Ejecute el mismo cliente WebSocket real contra salas únicas de la nube:
node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support broadcast | tee .labex/cloud-broadcast.json
node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support invalid | tee .labex/cloud-invalid.json
La salida de la nube debe mostrar el mismo comportamiento que el desarrollo local: dos clientes de planning reciben la secuencia 1, el cliente de support no recibe ninguna actualización y la entrada malformada deja el historial sin cambios.
Abra la APP_URL mostrada en un navegador. Seleccione Connect three clients y, después, Send planning update. Los clientes A y B deberían mostrar la misma update nueva, mientras que el cliente C solo muestra su mensaje ready. Seleccione Send malformed update y confirme que el error aparece únicamente en el cliente A. Cuando termine de observar el resultado, seleccione Disconnect clients y espere a que las tres tarjetas indiquen Closed; así completará el cierre ordenado del WebSocket antes de abandonar la página. Esta página es un cliente de observación proporcionado; la prueba de Node y las comprobaciones del backend siguen siendo las pruebas de aceptación autoritativas.
Inspeccione el cliente del navegador y el Durable Object
En este paso relacionará las pruebas de ejecución con el Cloudflare Dashboard y demostrará que el historial persistente de la sala permanece después de volver a desplegar el código sin cambios.
Mantenga la demostración del navegador conectada el tiempo suficiente para inspeccionar sus tres tarjetas. Las dos tarjetas de planning son una prueba visible de la difusión limitada a una sala; la tarjeta silenciosa de support es igual de importante porque muestra qué no cruzó el límite de identidad.

En el Cloudflare Dashboard, abra Workers & Pages, seleccione el nombre exacto almacenado en .labex/run-name e inspeccione sus bindings. ROOMS debería apuntar a RoomBroadcast. Después abra Durable Objects, seleccione el espacio de nombres llamado <your-worker>_RoomBroadcast y confirme Storage: SQL en Overview.


Abra la pestaña Logs del espacio de nombres. Seleccione una fila reciente correcta asociada al navegador o a la prueba de Node. Un mensaje de aplicación estructurado room_update informa de su secuencia y del número actual de conexiones, sin registrar el texto del mensaje. El Dashboard puede entregar los registros después de la solicitud; las respuestas de ejecución y las comprobaciones independientes siguen siendo la fuente de verdad.
Vuelva a desplegar el código sin cambios. Las conexiones WebSocket abiertas son un transporte activo y no se garantiza que sobrevivan a un despliegue, pero el historial SQLite pertenece al objeto identificado por nombre y debería permanecer:
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/rooms/cloud-planning/state" | jq
curl --silent --fail "$APP_URL/rooms/cloud-support/state" | jq
La sala planning todavía informa de un mensaje con la secuencia 1; support permanece vacía. El sufijo generado, las marcas de tiempo y los totales de tráfico del Dashboard serán distintos de los ejemplos probados.
Elimine el espacio de nombres de la sala
En este paso eliminará el espacio de nombres de Durable Object y el Worker temporales exactos. Después mantendrá la VM autorizada el tiempo suficiente para que LabEx compruebe que ambos recursos ya no existen.
Confirme que el nombre guardado comienza por labex-c10-o05-. Cree un punto de entrada de limpieza sin estado:
RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o05-*) ;; *) echo "Unexpected Worker name" >&2; exit 1;; esac
cat > src/cleanup.js <<'JS'
export default {
fetch() {
return Response.json({ status: "cleanup" }, { status: 410 });
}
};
JS
Cree una configuración de limpieza para el mismo Worker y la misma cuenta. El marcador state: "deleted" elimina únicamente el espacio de nombres de clase de este laboratorio, incluidos sus historiales temporales:
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": {
"RoomBroadcast": { "type": "durable-object", "state": "deleted" }
}
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
La salida de reconciliación debería informar Deleted: RoomBroadcast. Elimine el Worker sin estado restante. Wrangler solicitará confirmación porque la eliminación no se puede deshacer; confirme únicamente después de comprobar que el nombre mostrado coincide exactamente con el valor de $RUN:
npx wrangler delete --config wrangler.cleanup.jsonc
Cuando aparezca la solicitud, escriba y y pulse Enter. El comando debería terminar con Successfully deleted, seguido del nombre de Worker generado.
Mantenga esta VM autorizada para la comprobación al final de este paso. Confirme que Wrangler todavía informa de una sesión autenticada:
npx wrangler whoami --json | jq '{loggedIn, authType}'
El JSON debe contener "loggedIn": true. LabEx ya puede consultar la cuenta seleccionada y demostrar que tanto el Worker como su espacio de nombres de Durable Object no existen. Un error de red o de autenticación no demuestra que la limpieza se haya completado.
Revoque la autorización de Wrangler de esta VM
En este paso revocará la autorización OAuth almacenada únicamente en esta VM nueva, después de verificar que se han eliminado los recursos de la nube.
wrangler logout elimina la autorización local. La comprobación estructurada whoami --json es importante porque la salida legible para humanos puede resultar ambigua; el campo loggedIn es el resultado autoritativo:
npx wrangler logout
npx wrangler whoami --json
El JSON final debe contener "loggedIn": false. Esto no elimina su cuenta de aprendizaje de Cloudflare ni cierra su sesión en el navegador; solo impide que esta VM realice más solicitudes autenticadas mediante Wrangler.
Resumen
Transformó solicitudes HTTP en WebSockets, dirigió nombres de sala validados a Durable Objects independientes, difundió una actualización aprobada a dos clientes de la misma sala y mantuvo aislada otra sala. Separó el análisis de JSON de la validación de la aplicación, demostró que una entrada malformada no modificó ni el estado de difusión ni el historial SQLite, repitió el comportamiento en Cloudflare, inspeccionó las vistas del navegador y del Dashboard, verificó el historial después de volver a desplegar y eliminó el espacio de nombres temporal exacto.
La regla de diseño reutilizable es la siguiente: valide antes de seleccionar o modificar el estado, coordine cada grupo en tiempo real mediante su propia identidad de objeto estable y trate las conexiones activas por separado del historial persistente de la aplicación.



