Introducción
Una conexión WebSocket activa puede durar mucho más que un objeto de JavaScript en memoria. Cloudflare puede hibernar un Durable Object inactivo: los clientes permanecen conectados en el borde de la red, pero desaparecen los campos almacenados en la memoria del objeto. Un mensaje posterior despierta una nueva instancia de la clase. Esto reduce los cargos por tiempo de inactividad, pero significa que un mapa normal en memoria no es un lugar fiable para guardar el nombre o el rol de un cliente.
La API de WebSocket para hibernación resuelve el problema del ciclo de vida en dos partes. ctx.acceptWebSocket(server) registra una conexión sin mantener el objeto en memoria. serializeAttachment() almacena con esa conexión un valor estructurado pequeño; después de la reconstrucción, deserializeAttachment() lo restaura. ctx.getWebSockets() permite que un nuevo constructor enumere los sockets que todavía están conectados.
Usted creará un servicio de presencia para salas que adjunte a cada socket un ID de cliente validado, un nombre visible y un nombre de sala. Una prueba controlada de reconstrucción creará una nueva instancia de la clase alrededor de sockets simulados existentes y demostrará que sus adjuntos reconstruyen el mapa de sesiones. También probará WebSockets locales y desplegados, desconectará y volverá a conectar un cliente del navegador y confirmará que el comportamiento de la sala sigue siendo correcto. Cloudflare decide cuándo ocurre la hibernación en producción, por lo que ni esta lección ni la evaluación pretenden forzar una expulsión bajo demanda.
Antes de entrar directamente en este curso, complete Conectar LabEx a su cuenta de Cloudflare. Cada VM nueva necesita su propia autorización de Wrangler. Ya debe comprender los Durable Objects con nombre, el estado respaldado por SQLite y las difusiones de WebSocket con alcance de sala de O01–O05.
La configuración instala Node.js 22.22.0, Wrangler 4.132.0 local del proyecto y un cliente WebSocket fijado en /home/labex/project/connection-context. Proporciona fixtures para el navegador y las pruebas, pero no autoriza Cloudflare, no implementa el Durable Object, no acepta un socket ni despliega un Worker.
Autorizar la VM y declarar el espacio de nombres de presencia
En este paso, autorizará esta VM nueva, seleccionará su cuenta de aprendizaje dedicada y declarará una clase de Durable Object respaldada por SQLite para las salas de presencia.
cd /home/labex/project/connection-context
npx wrangler --version
npx wrangler login --device --browser=false
Espere obtener 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 y cree 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-o06-$(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": "PRESENCE", "class_name": "PresenceRoom" }
] },
"exports": {
"PresenceRoom": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
PRESENCE es la ruta del Worker hacia los objetos de las salas. Los nombres de sala estables mantienen separadas las conexiones y el historial de una sala de los de otra. La exportación de la clase proporciona a cada sala su propio almacenamiento SQLite; no existe ningún recurso en la nube hasta el despliegue.
Implementar un contexto de conexión seguro frente a la hibernación
En este paso, separará los metadatos seguros de la conexión del objeto de socket activo y usará la API de WebSocket para hibernación con el fin de restaurar esos metadatos cada vez que Cloudflare cree una nueva instancia del objeto.
Un adjunto es un valor pequeño, clonable de forma estructurada, que se almacena con un WebSocket. Sobrevive a la hibernación únicamente mientras la conexión siga funcionando correctamente; el historial duradero de la sala debe permanecer en SQLite. Cree los asistentes de validación y reconstrucción:
cat > src/context.js <<'JS'
const TOKEN = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
export function connectionContext(url) {
const room = url.pathname.match(/^\/rooms\/([^/]+)\/connect$/)?.[1] ?? "";
const clientId = url.searchParams.get("clientId") ?? "";
const displayName = (url.searchParams.get("name") ?? "").trim();
if (!TOKEN.test(room) || !TOKEN.test(clientId)) return null;
if (displayName.length < 1 || displayName.length > 32) return null;
return { room, clientId, displayName };
}
export function validAttachment(value) {
return Boolean(value && typeof value === "object" && TOKEN.test(value.room) &&
TOKEN.test(value.clientId) && typeof value.displayName === "string" &&
value.displayName.length >= 1 && value.displayName.length <= 32);
}
export function restoreSessions(sockets) {
const sessions = new Map();
for (const socket of sockets) {
const attachment = socket.deserializeAttachment();
if (validAttachment(attachment)) sessions.set(socket, attachment);
}
return sessions;
}
JS
Cree el Durable Object y el Worker de entrada:
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { connectionContext, restoreSessions } from "./context.js";
const json = (body, status = 200) => Response.json(body, { status });
export class PresenceRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.sessions = restoreSessions(ctx.getWebSockets());
this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS announcements (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
display_name TEXT NOT NULL,
text TEXT NOT NULL
)
`);
});
}
async fetch(request) {
const context = connectionContext(new URL(request.url));
if (!context) return json({ error: "invalid_connection_context" }, 400);
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return json({ error: "websocket_upgrade_required" }, 426);
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server, [`room:${context.room}`]);
server.serializeAttachment(context);
this.sessions.set(server, context);
server.send(JSON.stringify({ type: "ready", context, connected: this.sessions.size }));
return new Response(null, { status: 101, webSocket: client });
}
webSocketMessage(socket, raw) {
const context = socket.deserializeAttachment();
if (!context || !this.sessions.has(socket)) {
socket.send(JSON.stringify({ type: "error", code: "missing_context" }));
return;
}
let message;
try { message = JSON.parse(raw); } catch { message = null; }
const text = typeof message?.text === "string" ? message.text.trim() : "";
if (message?.type !== "announce" || text.length < 1 || text.length > 80 || Object.keys(message).length !== 2) {
socket.send(JSON.stringify({ type: "error", code: "invalid_message" }));
return;
}
const row = this.ctx.storage.sql.exec(`
INSERT INTO announcements (client_id, display_name, text)
VALUES (?, ?, ?) RETURNING sequence
`, context.clientId, context.displayName, text).one();
const update = JSON.stringify({ type: "announcement", sequence: row.sequence,
clientId: context.clientId, displayName: context.displayName, text });
for (const peer of this.ctx.getWebSockets(`room:${context.room}`)) peer.send(update);
console.log(JSON.stringify({ event: "presence_announcement", sequence: row.sequence,
clientId: context.clientId, connected: this.ctx.getWebSockets().length }));
}
webSocketClose(socket) {
this.sessions.delete(socket);
}
async getState() {
const announcements = this.ctx.storage.sql.exec(`
SELECT sequence, client_id AS clientId, display_name AS displayName, text
FROM announcements ORDER BY sequence
`).toArray();
return { messageCount: announcements.length, announcements };
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const match = url.pathname.match(/^\/rooms\/([^/]+)\/(connect|state)$/);
if (!match) return json({ error: "not_found" }, 404);
const room = match[1];
if (match[2] === "connect") return env.PRESENCE.getByName(room).fetch(request);
if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
return json({ room, ...await env.PRESENCE.getByName(room).getState() });
}
};
JS
ctx.acceptWebSocket() sustituye a server.accept() y a los detectores de eventos. Los mensajes llegan ahora mediante el controlador de clase webSocketMessage(). El constructor reconstruye sessions a partir de los sockets administrados por el entorno de ejecución y sus adjuntos; no supone que el Map de JavaScript anterior haya sobrevivido.
Demostrar la reconstrucción del contexto sin fingir que se fuerza una expulsión
En este paso, probará directamente el límite de reconstrucción. Cloudflare decide cuándo hiberna un objeto inactivo en producción, por lo que un laboratorio determinista no debe esperar una expulsión forzada ni afirmar que la ha provocado. En su lugar, una instancia nueva de PresenceRoom recibe sockets simulados administrados por el entorno de ejecución cuyos adjuntos fueron escritos por una instancia anterior.
cat > test/context.test.mjs <<'JS'
import test from "node:test";
import assert from "node:assert/strict";
import { connectionContext, restoreSessions, validAttachment } from "../src/context.js";
import { PresenceRoom } from "../src/index.js";
const attachment = (room, clientId, displayName) => ({ room, clientId, displayName });
const socket = value => ({ deserializeAttachment: () => value });
test("connection input becomes a bounded attachment", () => {
const url = new URL("https://example.test/rooms/planning/connect?clientId=alice-1&name=Alice");
assert.deepEqual(connectionContext(url), attachment("planning", "alice-1", "Alice"));
assert.equal(connectionContext(new URL("https://example.test/rooms/Bad!/connect?clientId=a&name=A")), null);
});
test("attachment validation rejects incomplete context", () => {
assert.equal(validAttachment(attachment("planning", "alice-1", "Alice")), true);
assert.equal(validAttachment({ room: "planning", clientId: "alice-1" }), false);
});
test("controlled reconstruction restores only valid socket context", () => {
const alice = socket(attachment("planning", "alice-1", "Alice"));
const bob = socket(attachment("planning", "bob-1", "Bob"));
const broken = socket(null);
const restored = restoreSessions([alice, bob, broken]);
assert.equal(restored.size, 2);
assert.equal(restored.get(alice).displayName, "Alice");
assert.equal(restored.get(bob).clientId, "bob-1");
});
test("a new Durable Object constructor rebuilds its session map", () => {
const sockets = [socket(attachment("planning", "alice-1", "Alice")), socket(attachment("planning", "bob-1", "Bob"))];
const ctx = {
getWebSockets: () => sockets,
blockConcurrencyWhile: fn => fn(),
storage: { sql: { exec: () => ({}) } }
};
const room = new PresenceRoom(ctx, {});
assert.equal(room.sessions.size, 2);
assert.deepEqual([...room.sessions.values()].map(value => value.displayName), ["Alice", "Bob"]);
});
JS
npm test
Espere obtener cuatro pruebas aprobadas. Estas pruebas establecen que el código puede reconstruir el contexto a partir de los adjuntos. Las comprobaciones en vivo posteriores establecerán el comportamiento de los sockets reales, pero ninguna se etiquetará erróneamente como prueba de que un objeto concreto de producción fue expulsado bajo demanda.
Volver a conectar un cliente y mantener el comportamiento de la sala
En este paso, probará sockets locales reales. La reconexión crea un socket nuevo y, por tanto, un adjunto nuevo, mientras que los anuncios duraderos permanecen en SQLite.
cat > tools/reconnect.mjs <<'JS'
import WebSocket from "ws";
const [base, prefix] = process.argv.slice(2);
const wsBase = base.replace(/^http/, "ws");
const room = `${prefix}-planning`, other = `${prefix}-support`;
const open = (roomName, id, name) => new Promise((resolve, reject) => {
const ws = new WebSocket(`${wsBase}/rooms/${roomName}/connect?clientId=${id}&name=${encodeURIComponent(name)}`);
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, predicate) => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("message timeout")), 5000);
const check = value => { if (predicate(value)) { clearTimeout(timer); client.ws.off("message", listener); resolve(value); } };
const listener = raw => check(JSON.parse(raw)); client.ws.on("message", listener); client.inbox.forEach(check);
});
const close = client => new Promise(resolve => { client.ws.once("close", resolve); client.ws.close(1000, "reconnect"); });
const alice = await open(room, `${prefix}-alice`, "Alice");
const bob = await open(room, `${prefix}-bob`, "Bob");
const carol = await open(other, `${prefix}-carol`, "Carol");
alice.ws.send(JSON.stringify({ type: "announce", text: "First update" }));
await Promise.all([waitFor(alice, x => x.sequence === 1), waitFor(bob, x => x.sequence === 1)]);
await close(alice);
const reconnected = await open(room, `${prefix}-alice`, "Alice");
reconnected.ws.send(JSON.stringify({ type: "announce", text: "Back online" }));
const [again, peer] = await Promise.all([waitFor(reconnected, x => x.sequence === 2), waitFor(bob, x => x.sequence === 2)]);
await new Promise(resolve => setTimeout(resolve, 300));
const state = await fetch(`${base}/rooms/${room}/state`).then(r => r.json());
const otherState = await fetch(`${base}/rooms/${other}/state`).then(r => r.json());
console.log(JSON.stringify({ restoredName: again.displayName, peerName: peer.displayName,
otherAnnouncements: carol.inbox.filter(x => x.type === "announcement").length, state, otherState }, null, 2));
await Promise.all([reconnected, bob, carol].map(close));
JS
rm -f .labex/local.json .labex/dev.log .labex/dev.pid
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
LOCAL_READY="$(curl --silent http://127.0.0.1:8787/rooms/probe/state || true)"
test "$(jq -r '.messageCount // -1' <<<"$LOCAL_READY" 2>/dev/null)" = 0 && break
sleep 1
done
test "$(jq -r .messageCount <<<"$LOCAL_READY")" = 0
sleep 2
node tools/reconnect.mjs http://127.0.0.1:8787 local | tee .labex/local.json
Alice se vuelve a conectar con un socket nuevo, pero su segundo mensaje todavía contiene displayName: Alice; Bob lo recibe y Carol permanece aislada. Los dos anuncios duraderos de la sala muestran que la duración del socket y la duración del historial de la sala son diferentes.
Desplegar y repetir el contrato de reconexión
En este paso, detendrá el proceso local exacto, desplegará el servicio, esperará a que la ruta con estado real esté disponible y repetirá el contrato del cliente en vivo con salas únicas en la nube:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
rm -f .labex/cloud.json .labex/deploy.log .labex/app-url
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/rooms/cloud-probe/state" || true)"; test "$(jq -r '.messageCount // -1' <<<"$READY" 2>/dev/null)" = 0 && break; sleep 2; done
test "$(jq -r .messageCount <<<"$READY")" = 0
sleep 5
node tools/reconnect.mjs "$APP_URL" cloud | tee .labex/cloud.json
El mismo resultado en Cloudflare demuestra que el servicio desplegado utiliza su adjunto serializado después de aceptar cada conexión y después de que Alice se vuelve a conectar. No afirma que la plataforma haya hibernado durante esta ejecución limitada.
Inspeccionar el despliegue compatible con la hibernación
En este paso, relacionará las pruebas del entorno de ejecución con el Cloudflare Dashboard y con un redespliegue sin cambios. Abra Workers & Pages, seleccione el nombre exacto que aparece en .labex/run-name y abra Bindings. PRESENCE debe apuntar a PresenceRoom.

Abra Durable Objects, seleccione <your-worker>_PresenceRoom y confirme Storage: SQL. Esta página identifica el espacio de nombres de la clase; no muestra los valores de los adjuntos.

Abra Logs e inspeccione una fila correcta de presence_announcement. Contiene un ID de cliente sintético y una secuencia, pero no el texto del anuncio. El tráfico del Dashboard puede llegar después de la respuesta, por lo que el cliente en vivo y las comprobaciones del backend siguen siendo la autoridad principal.

Vuelva a desplegar el código sin cambios y lea las mismas salas de la nube:
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 de planificación todavía contiene ambos anuncios y la sala de soporte sigue vacía. El redespliegue demuestra que el historial duradero sobrevive a una nueva versión del Worker; la prueba controlada del constructor demuestra por separado la reconstrucción de los adjuntos.
Eliminar el espacio de nombres de presencia
En este paso, eliminará únicamente el Worker y el espacio de nombres generados por este laboratorio mientras la VM todavía está autorizada:
RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o06-*) ;; *) 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": { "PresenceRoom": { "type": "durable-object", "state": "deleted" } }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc
Confirme que el mensaje muestra exactamente $RUN, escriba y y espere obtener Successfully deleted. Mantenga la VM autorizada para la comprobación siguiente:
npx wrangler whoami --json | jq '{loggedIn, authType}'
El JSON debe contener "loggedIn": true; un fallo de autenticación o de red no demuestra que se haya eliminado el recurso.
Revocar la autorización de Wrangler de esta VM
En este paso, eliminará únicamente la autorización OAuth 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
Sustituyó los sockets aceptados de la forma habitual por la API de WebSocket para hibernación, almacenó el contexto limitado del cliente en adjuntos serializados y reconstruyó un mapa de sesiones en memoria a partir de sockets administrados por el entorno de ejecución. Una prueba controlada con una instancia nueva demostró la reconstrucción sin fingir que se había forzado una expulsión en producción. Después, clientes locales y de la nube se desconectaron, se volvieron a conectar y conservaron el comportamiento de la sala, mientras SQLite mantenía los anuncios duraderos. Por último, inspeccionó el despliegue, eliminó los recursos temporales exactos y cerró la sesión.



