Introduction
Une connexion WebSocket active peut durer bien plus longtemps qu’un objet JavaScript conservé en mémoire. Cloudflare peut faire hiberner un Durable Object inactif : les clients restent connectés au niveau du réseau, mais les champs conservés en mémoire par l’objet disparaissent. Un message ultérieur réveille alors une nouvelle instance de la classe. Cela réduit les frais liés aux périodes d’inactivité, mais signifie qu’une simple map en mémoire n’est pas un emplacement fiable pour conserver le nom ou le rôle d’un client.
L’API WebSocket d’hibernation résout ce problème de cycle de vie en deux parties. ctx.acceptWebSocket(server) enregistre une connexion sans maintenir l’objet en mémoire. serializeAttachment() stocke avec cette connexion une petite valeur structurée compatible avec le structured clone ; après la reconstruction, deserializeAttachment() la restaure. ctx.getWebSockets() permet à un nouveau constructeur d’énumérer les sockets toujours connectées.
Vous allez créer un service de présence pour un salon, qui associe à chaque socket un identifiant client validé, un nom d’affichage et un nom de salon. Un test contrôlé de reconstruction créera une nouvelle instance de classe autour de fausses sockets existantes et vérifiera que leurs pièces jointes reconstruisent la map des sessions. Vous utiliserez également de vraies connexions WebSocket locales et déployées, déconnecterez puis reconnecterez un client dans un navigateur, et vérifierez que le comportement du salon reste correct. Cloudflare décide du moment où l’hibernation a lieu en production : ni le cours ni l’évaluation ne prétendent donc déclencher une éviction à la demande.
Avant d’accéder directement à ce cours, terminez Connect LabEx to Your Cloudflare Account. Chaque nouvelle VM nécessite sa propre autorisation Wrangler. Vous devez déjà comprendre les Durable Objects nommés, l’état géré par SQLite et les diffusions WebSocket limitées à un salon vues dans O01–O05.
La configuration installe Node.js 22.22.0, Wrangler 4.132.0 dans le projet et un client WebSocket verrouillé dans /home/labex/project/connection-context. Elle fournit les fixtures du navigateur et des tests, mais n’autorise pas Cloudflare, n’implémente pas le Durable Object, n’accepte pas de socket et ne déploie pas de Worker.
Autoriser la VM et déclarer l’espace de noms de présence
Dans cette étape, vous allez autoriser cette nouvelle VM, sélectionner votre compte d’apprentissage dédié et déclarer une classe de Durable Object basée sur SQLite pour les salons de présence.
cd /home/labex/project/connection-context
npx wrangler --version
npx wrangler login --device --browser=false
Wrangler doit afficher 4.132.0. Ouvrez dans le navigateur l’URL Cloudflare affichée, saisissez le code court, confirmez le compte d’apprentissage souhaité et autorisez-le. Le navigateur accorde l’accès à Wrangler ; votre mot de passe n’est jamais envoyé à la VM.
Lisez uniquement les champs d’identité sans risque et créez un nom de Worker temporaire unique :
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 est le point d’accès du Worker vers les objets de salon. Des noms de salon stables permettent de garder séparées les connexions et l’historique de chaque salon. L’export de la classe donne à chaque salon son propre stockage SQLite ; aucune ressource cloud n’existe avant le déploiement.
Implémenter un contexte de connexion sûr pour l’hibernation
Dans cette étape, vous allez séparer les métadonnées sûres de connexion de l’objet socket actif, puis utiliser l’API WebSocket d’hibernation pour restaurer ces métadonnées chaque fois que Cloudflare construit une nouvelle instance de l’objet.
Une pièce jointe est une petite valeur structurée compatible avec le structured clone, stockée avec une WebSocket. Elle survit à l’hibernation uniquement tant que la connexion reste saine ; l’historique durable du salon doit toujours être conservé dans SQLite. Créez les fonctions d’aide pour la validation et la reconstruction :
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
Créez le Durable Object et le Worker d’entrée :
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() remplace server.accept() et les écouteurs d’événements. Les messages arrivent désormais via le gestionnaire webSocketMessage() de la classe. Le constructeur reconstruit sessions à partir des sockets gérées par le runtime et de leurs pièces jointes ; il ne suppose pas que la Map JavaScript précédente a été conservée.
Prouver la reconstruction du contexte sans simuler une éviction forcée
Dans cette étape, vous allez tester directement la limite de reconstruction. Cloudflare choisit le moment où un objet de production inactif hiberne ; un laboratoire déterministe ne doit donc ni attendre une éviction ni prétendre en avoir forcé une. À la place, une nouvelle instance de PresenceRoom reçoit des sockets gérées par le runtime dont les pièces jointes ont été écrites par une instance précédente.
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
Vous devez obtenir quatre tests réussis. Ces tests établissent que le code peut reconstruire le contexte à partir des pièces jointes. Les vérifications en direct qui suivent établissent le comportement réel des sockets, mais aucune ne prétend prouver qu’un objet de production précis a été évincé à la demande.
Reconnecter un client et préserver le comportement du salon
Dans cette étape, vous allez utiliser de vraies sockets locales. Une reconnexion crée une nouvelle socket et donc une nouvelle pièce jointe, tandis que les annonces durables restent dans 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 reconnecte avec une nouvelle socket, mais son deuxième message contient toujours displayName: Alice ; Bob le reçoit et Carol reste isolée. Les deux annonces durables du salon montrent que la durée de vie d’une socket et celle de l’historique du salon sont différentes.
Déployer et répéter le contrat de reconnexion
Dans cette étape, vous allez arrêter le processus local exact, déployer, attendre que la route avec état soit réellement disponible et répéter le contrat client en direct avec des salons cloud uniques :
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
Le même résultat sur Cloudflare prouve que le service déployé utilise sa pièce jointe sérialisée après l’acceptation de chaque connexion et après la reconnexion d’Alice. Il ne prétend pas que la plateforme a effectivement fait hiberner l’objet pendant cette exécution limitée.
Inspecter le déploiement compatible avec l’hibernation
Dans cette étape, vous allez relier les éléments observés lors de l’exécution au Cloudflare Dashboard et à un redéploiement inchangé. Ouvrez Workers & Pages, sélectionnez le nom exact indiqué dans .labex/run-name, puis ouvrez Bindings. PRESENCE doit pointer vers PresenceRoom.

Ouvrez Durable Objects, sélectionnez <your-worker>_PresenceRoom et vérifiez Storage: SQL. Cette page identifie l’espace de noms de la classe ; elle n’expose pas les valeurs des pièces jointes.

Ouvrez Logs et inspectez une ligne presence_announcement réussie. Elle contient un identifiant client synthétique et une séquence, mais pas le texte de l’annonce. Le trafic du Dashboard peut apparaître après la réponse ; les vérifications du client en direct et du backend restent donc les références.

Redéployez le code sans le modifier et lisez les mêmes salons cloud :
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
Le salon planning contient toujours les deux annonces et le salon support reste vide. Le redéploiement prouve que l’historique durable survit à une nouvelle version du Worker ; le test contrôlé du constructeur prouve séparément la reconstruction des pièces jointes.
Supprimer l’espace de noms de présence
Dans cette étape, vous allez supprimer uniquement le Worker et l’espace de noms générés par ce laboratoire, tant que la VM reste autorisée :
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
Vérifiez que l’invite affiche exactement $RUN, saisissez y et attendez Successfully deleted. Gardez la VM autorisée pour la vérification suivante :
npx wrangler whoami --json | jq '{loggedIn, authType}'
Le JSON doit contenir "loggedIn": true ; une erreur d’authentification ou de réseau ne prouve pas la suppression.
Révoquer l’autorisation Wrangler de cette VM
Dans cette étape, vous allez supprimer uniquement l’autorisation OAuth de cette VM après avoir vérifié la suppression indépendamment :
npx wrangler logout
npx wrangler whoami --json
Le JSON final doit contenir "loggedIn": false. Votre compte d’apprentissage reste connecté dans le navigateur.
Résumé
Vous avez remplacé les sockets acceptées de manière classique par l’API WebSocket d’hibernation, stocké le contexte client limité dans des pièces jointes sérialisées et reconstruit une map de sessions en mémoire à partir des sockets gérées par le runtime. Un test contrôlé avec une nouvelle instance a prouvé la reconstruction sans prétendre forcer une éviction en production. Des clients locaux et cloud réels se sont ensuite déconnectés, reconnectés et ont conservé le comportement du salon, tandis que SQLite gardait les annonces durables. Enfin, vous avez inspecté le déploiement, supprimé les ressources temporaires exactes et vous êtes déconnecté.



