Introdução
Uma conexão WebSocket ativa pode durar muito mais que um único objeto JavaScript mantido na memória. O Cloudflare pode hibernar um Durable Object ocioso: os clientes continuam conectados na borda da rede, mas os campos mantidos na memória do objeto desaparecem. Uma mensagem posterior desperta uma nova instância da classe. Isso reduz as cobranças pelo tempo ocioso, mas significa que um mapa comum mantido na memória não é um local confiável para armazenar o nome ou a função de um cliente.
A API Hibernation WebSocket resolve esse problema de ciclo de vida em duas partes. ctx.acceptWebSocket(server) registra uma conexão sem manter o objeto preso na memória. serializeAttachment() armazena um valor estruturado compatível com structured clone junto à conexão; após a reconstrução, deserializeAttachment() o restaura. ctx.getWebSockets() permite que um novo construtor enumere os sockets que ainda estão conectados.
Você criará um serviço de presença em salas que associa a cada socket um ID de cliente validado, um nome de exibição e um nome de sala. Um teste controlado de reconstrução criará uma nova instância da classe usando sockets falsos existentes e comprovará que os anexos reconstroem o mapa de sessões. Você também usará WebSockets locais e implantados, desconectará e reconectará um cliente do navegador e confirmará que o comportamento da sala continua correto. O Cloudflare decide quando a hibernação ocorre em produção; por isso, nem a lição nem a avaliação fingem forçar uma expulsão sob demanda.
Antes de entrar diretamente neste curso, conclua Conectar o LabEx à sua conta do Cloudflare. Cada VM nova precisa de sua própria autorização do Wrangler. Você já deve conhecer Durable Objects nomeados, estado armazenado em SQLite e broadcasts de WebSocket com escopo de sala, vistos em O01–O05.
A configuração instala Node.js 22.22.0, o Wrangler 4.132.0 local do projeto e um cliente WebSocket fixado em /home/labex/project/connection-context. Ela fornece fixtures para o navegador e para os testes, mas não autoriza o Cloudflare, não implementa o Durable Object, não aceita um socket nem implanta um Worker.
Autorizar a VM e declarar o namespace de presença
Nesta etapa, você autorizará esta VM nova, selecionará sua conta de aprendizagem dedicada e declarará uma classe de Durable Object com armazenamento SQLite para as salas de presença.
cd /home/labex/project/connection-context
npx wrangler --version
npx wrangler login --device --browser=false
O Wrangler deve exibir 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. O navegador concede acesso ao Wrangler; sua senha nunca é enviada para a VM.
Leia apenas campos seguros de identidade e crie um nome exclusivo para o Worker descartável:
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 é a rota do Worker para os objetos de sala. Nomes de sala estáveis mantêm as conexões e o histórico de uma sala separados dos de outra. A exportação da classe fornece armazenamento SQLite privado para cada sala; nenhum recurso na nuvem existe até a implantação.
Implementar um contexto de conexão seguro para hibernação
Nesta etapa, você separará os metadados seguros da conexão do objeto socket ativo e usará a API Hibernation WebSocket para restaurar esses metadados sempre que o Cloudflare construir uma nova instância do objeto.
Um anexo é um valor pequeno, compatível com structured clone, armazenado junto a um WebSocket. Ele sobrevive à hibernação somente enquanto a conexão permanece saudável; o histórico durável da sala continua pertencendo ao SQLite. Crie os helpers de validação e reconstrução:
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
Crie o Durable Object e o 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() substitui server.accept() e os listeners de eventos. Agora, as mensagens chegam ao handler webSocketMessage() no nível da classe. O construtor reconstrói sessions a partir dos sockets pertencentes ao runtime e de seus anexos; ele não presume que o Map JavaScript anterior tenha sobrevivido.
Comprovar a reconstrução do contexto sem fingir que a expulsão pode ser forçada
Nesta etapa, você testará diretamente o limite da reconstrução. O Cloudflare decide quando um objeto de produção ocioso entra em hibernação; portanto, um laboratório determinístico não deve esperar por uma expulsão forçada nem afirmar que ela ocorreu. Em vez disso, uma nova instância de PresenceRoom receberá sockets falsos pertencentes ao runtime, cujos anexos foram gravados por uma instância 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 quatro testes aprovados. Esses testes comprovam que o código consegue reconstruir o contexto a partir dos anexos. As verificações ao vivo posteriores confirmarão o comportamento real dos sockets, mas nenhuma delas será apresentada incorretamente como prova de que um objeto específico em produção foi expulso sob demanda.
Reconectar um cliente e manter o comportamento da sala
Nesta etapa, você usará sockets locais reais. A reconexão cria um novo socket e, portanto, um novo anexo, enquanto os anúncios duráveis permanecem no 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 reconecta usando um socket novo, mas sua segunda mensagem ainda contém displayName: Alice; Bob a recebe e Carol continua isolada. Os dois anúncios duráveis da sala mostram que a duração do socket e a duração do histórico da sala são diferentes.
Implantar e repetir o contrato de reconexão
Nesta etapa, você interromperá o processo local exato, fará a implantação, aguardará a rota real com estado e repetirá o contrato do cliente ao vivo usando salas exclusivas na nuvem:
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
O mesmo resultado no Cloudflare comprova que o serviço implantado usa o anexo serializado depois que cada conexão é aceita e depois que Alice se reconecta. Isso não afirma que a plataforma tenha colocado o objeto em hibernação durante esta execução limitada.
Inspecionar a implantação compatível com hibernação
Nesta etapa, você relacionará as evidências do runtime ao Cloudflare Dashboard e fará uma nova implantação sem alterações. Abra Workers & Pages, selecione o nome exato armazenado em .labex/run-name e abra Bindings. PRESENCE deve apontar para PresenceRoom.

Abra Durable Objects, selecione <your-worker>_PresenceRoom e confirme Storage: SQL. Essa página identifica o namespace da classe; ela não expõe os valores dos anexos.

Abra Logs e inspecione uma linha presence_announcement bem-sucedida. Ela contém um ID de cliente sintético e uma sequência, mas não o texto do anúncio. O tráfego do Dashboard pode aparecer depois da resposta; por isso, o cliente ao vivo e as verificações do backend continuam sendo as fontes de autoridade.

Implante novamente o código sem alterações e leia as mesmas salas na nuvem:
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
A sala de planejamento ainda deve conter os dois anúncios, enquanto a sala de suporte permanece vazia. A nova implantação comprova que o histórico durável sobrevive a uma nova versão do Worker; o teste controlado do construtor comprova separadamente a reconstrução dos anexos.
Excluir o namespace de presença
Nesta etapa, você excluirá apenas o Worker e o namespace gerados por este laboratório enquanto a VM ainda estiver 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 o prompt exibe exatamente $RUN, digite y e espere Successfully deleted. Mantenha a VM autorizada para a verificação abaixo:
npx wrangler whoami --json | jq '{loggedIn, authType}'
O JSON deve conter "loggedIn": true; uma falha de autenticação ou de rede não comprova a exclusão.
Revogar a autorização do Wrangler nesta VM
Nesta etapa, você removerá somente 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 continuará conectada no navegador.
Resumo
Você substituiu sockets aceitos normalmente pela API Hibernation WebSocket, armazenou o contexto limitado do cliente em anexos serializados e reconstruiu um mapa de sessões mantido na memória a partir dos sockets pertencentes ao runtime. Um teste controlado com uma nova instância comprovou a reconstrução sem fingir que era possível forçar uma expulsão em produção. Em seguida, clientes locais e na nuvem se desconectaram, reconectaram e mantiveram o comportamento da sala, enquanto o SQLite preservou os anúncios duráveis. Por fim, você inspecionou a implantação, removeu exatamente os recursos descartáveis e encerrou a sessão.



