WebSocket-Verbindungskontext wiederherstellen

CloudflareBeginner
Jetzt üben

Einführung

Eine aktive WebSocket-Verbindung kann deutlich länger bestehen als ein einzelnes JavaScript-Objekt im Speicher. Cloudflare kann ein ruhiges Durable Object in den Hibernation-Zustand versetzen: Die Clients bleiben am Netzwerk-Edge verbunden, aber die In-Memory-Felder des Objekts verschwinden. Eine spätere Nachricht aktiviert eine neue Instanz der Klasse. Dadurch sinken die Kosten für Leerlaufzeiten. Eine einfache Map im Arbeitsspeicher ist deshalb jedoch kein zuverlässiger Speicherort für den Namen oder die Rolle eines Clients.

Die Hibernation-WebSocket-API löst dieses Lifecycle-Problem in zwei Teilen. ctx.acceptWebSocket(server) registriert eine Verbindung, ohne das Objekt dauerhaft im Speicher zu halten. serializeAttachment() speichert einen kleinen Structured-Clone-Wert zusammen mit dieser Verbindung. Nach der Rekonstruktion stellt deserializeAttachment() den Wert wieder her. Mit ctx.getWebSockets() kann ein neuer Konstruktor die weiterhin verbundenen Sockets auflisten.

Sie erstellen einen Room-Presence-Service, der an jeden Socket eine validierte Client-ID, einen Anzeigenamen und einen Room-Namen anhängt. Ein kontrollierter Rekonstruktionstest erstellt eine neue Klasseninstanz um vorhandene Fake-Sockets und weist nach, dass deren Attachments die Session-Map wiederherstellen. Außerdem testen Sie echte lokale und bereitgestellte WebSockets, trennen einen Browser-Client und verbinden ihn erneut. Dabei prüfen Sie, dass das Room-Verhalten korrekt bleibt. Cloudflare entscheidet selbst, wann die Hibernation in der Produktion stattfindet. Deshalb versucht weder diese Lektion noch die Bewertung, eine Eviction gezielt zu erzwingen.

Bevor Sie diesen Kurs direkt beginnen, absolvieren Sie Connect LabEx to Your Cloudflare Account. Jede neue VM benötigt eine eigene Wrangler-Autorisierung. Sie sollten benannte Durable Objects, SQLite-basierten Zustand und Room-bezogene WebSocket-Broadcasts aus O01–O05 bereits verstehen.

Das Setup installiert Node.js 22.22.0, Wrangler 4.132.0 als lokale Projektabhängigkeit und einen festgelegten WebSocket-Client in /home/labex/project/connection-context. Es stellt Browser- und Test-Fixtures bereit, autorisiert Cloudflare jedoch nicht und implementiert weder das Durable Object noch die Socket-Annahme oder die Bereitstellung eines Workers.

Autorisieren Sie die VM und deklarieren Sie den Presence-Namespace

In diesem Schritt autorisieren Sie die neue VM, wählen Ihr dediziertes Lernkonto aus und deklarieren eine SQLite-basierte Durable-Object-Klasse für Presence-Rooms.

cd /home/labex/project/connection-context
npx wrangler --version
npx wrangler login --device --browser=false

Erwarten Sie die Wrangler-Version 4.132.0. Öffnen Sie die angezeigte Cloudflare-URL im Browser, geben Sie den kurzen Code ein, bestätigen Sie das gewünschte Lernkonto und autorisieren Sie es. Der Browser gewährt Wrangler Zugriff. Ihr Passwort wird niemals an die VM gesendet.

Lesen Sie nur sichere Identitätsfelder aus und erstellen Sie einen eindeutigen, nur vorübergehend verwendeten Worker-Namen:

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 ist die Route des Workers zu den Room-Objekten. Stabile Room-Namen halten die Verbindungen und den Verlauf eines Rooms getrennt von denen anderer Rooms. Der Klassenexport gibt jedem Room einen eigenen privaten SQLite-Speicher. Eine Cloud-Ressource wird erst bei der Bereitstellung erstellt.

Implementieren Sie einen hibernationsicheren Verbindungskontext

In diesem Schritt trennen Sie sichere Verbindungsmetadaten vom aktiven Socket. Anschließend verwenden Sie die Hibernation-WebSocket-API, um diese Metadaten jedes Mal wiederherzustellen, wenn Cloudflare eine neue Objektinstanz erstellt.

Ein Attachment ist ein kleiner Structured-Clone-Wert, der zusammen mit einem WebSocket gespeichert wird. Er bleibt während der Hibernation nur erhalten, solange die Verbindung intakt bleibt. Der persistente Room-Verlauf gehört weiterhin in SQLite. Erstellen Sie die Hilfsfunktionen für Validierung und Rekonstruktion:

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

Erstellen Sie das Durable Object und den vorgeschalteten Worker:

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() ersetzt server.accept() und Event-Listener. Nachrichten treffen nun in der klassenweiten Methode webSocketMessage() ein. Der Konstruktor baut sessions aus den vom Runtime-System verwalteten Sockets und deren Attachments wieder auf. Er geht nicht davon aus, dass die vorherige JavaScript-Map erhalten geblieben ist.

Weisen Sie die Kontextrekonstruktion nach, ohne eine Eviction zu erzwingen

In diesem Schritt testen Sie die Rekonstruktionsgrenze direkt. Cloudflare entscheidet selbst, wann ein Objekt in der Produktion bei Inaktivität in die Hibernation wechselt. Ein deterministisches Lab sollte daher weder auf eine erzwungene Eviction warten noch behaupten, eine solche ausgelöst zu haben. Stattdessen erhält eine neue PresenceRoom-Instanz Fake-Sockets aus der Runtime, deren Attachments von einer früheren Instanz geschrieben wurden.

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

Erwarten Sie vier erfolgreiche Tests. Diese Tests zeigen, dass der Code den Kontext aus Attachments rekonstruieren kann. Die späteren Live-Prüfungen zeigen das Verhalten echter Sockets. Keine dieser Prüfungen wird jedoch fälschlich als Nachweis bezeichnet, dass ein bestimmtes Produktionsobjekt gezielt evicted wurde.

Verbinden Sie einen Client erneut und erhalten Sie das Room-Verhalten

In diesem Schritt testen Sie echte lokale Sockets. Beim erneuten Verbinden wird ein neuer Socket und damit ein neues Attachment erstellt. Persistente Announcements bleiben dagegen in SQLite erhalten.

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 verbindet sich mit einem neuen Socket erneut. Ihre zweite Nachricht enthält trotzdem weiterhin displayName: Alice. Bob empfängt die Nachricht, während Carol getrennt bleibt. Die beiden persistenten Announcements des Rooms zeigen, dass die Lebensdauer eines Sockets und die Lebensdauer des Room-Verlaufs unterschiedlich sind.

Stellen Sie den Reconnect-Vertrag bereit und wiederholen Sie ihn

In diesem Schritt beenden Sie den exakt gestarteten lokalen Prozess, stellen ihn bereit, warten auf die echte zustandsbehaftete Route und wiederholen den Live-Client-Vertrag mit eindeutigen Cloud-Rooms:

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

Dasselbe Ergebnis auf Cloudflare zeigt, dass der bereitgestellte Service sein serialisiertes Attachment verwendet, nachdem jede Verbindung angenommen wurde und nachdem Alice sich erneut verbunden hat. Es behauptet nicht, dass die Plattform während dieses begrenzten Laufs tatsächlich in die Hibernation gewechselt ist.

Überprüfen Sie die hibernationsichere Bereitstellung

In diesem Schritt verknüpfen Sie die Laufzeitnachweise mit dem Cloudflare-Dashboard und einer unveränderten erneuten Bereitstellung. Öffnen Sie Workers & Pages, wählen Sie den exakten Namen aus .labex/run-name und öffnen Sie Bindings. PRESENCE sollte auf PresenceRoom verweisen.

Die PRESENCE-Bindung verweist auf das PresenceRoom Durable Object

Öffnen Sie Durable Objects, wählen Sie <your-worker>_PresenceRoom aus und bestätigen Sie Storage: SQL. Diese Seite identifiziert den Klassen-Namespace. Die Werte der Attachments werden dort nicht angezeigt.

Der PresenceRoom-Namespace verwendet SQL-Speicher

Öffnen Sie Logs und suchen Sie einen erfolgreichen Eintrag mit presence_announcement. Er enthält eine synthetische Client-ID und eine Sequenznummer, aber nicht den Text des Announcements. Dashboard-Daten können später als die Antwort eintreffen. Deshalb bleiben der Live-Client und die Backend-Prüfungen maßgeblich.

Ein strukturiertes Presence-Ereignis identifiziert den wiederhergestellten Client sicher

Stellen Sie den unveränderten Code erneut bereit und lesen Sie dieselben Cloud-Rooms:

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

Der Planning-Room enthält weiterhin beide Announcements, und der Support-Room bleibt leer. Die erneute Bereitstellung zeigt, dass der persistente Verlauf eine neue Worker-Version übersteht. Der kontrollierte Konstruktor-Test weist separat die Rekonstruktion der Attachments nach.

Löschen Sie den Presence-Namespace

In diesem Schritt löschen Sie nur den von diesem Lab erzeugten Worker und Namespace, solange die VM noch autorisiert ist:

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

Bestätigen Sie, dass die Eingabeaufforderung exakt $RUN anzeigt, geben Sie y ein und erwarten Sie Successfully deleted. Lassen Sie die VM für die folgende Prüfung autorisiert:

npx wrangler whoami --json | jq '{loggedIn, authType}'

Das JSON muss "loggedIn": true enthalten. Ein Authentifizierungs- oder Netzwerkfehler ist kein Nachweis für die Löschung.

Widerrufen Sie die Wrangler-Autorisierung dieser VM

In diesem Schritt entfernen Sie nach der unabhängigen Überprüfung der Löschung nur die OAuth-Autorisierung dieser VM:

npx wrangler logout
npx wrangler whoami --json

Das abschließende JSON muss "loggedIn": false enthalten. Ihr Lernkonto bleibt im Browser angemeldet.

Zusammenfassung

Sie haben gewöhnliche angenommene Sockets durch die Hibernation-WebSocket-API ersetzt, begrenzten Client-Kontext in serialisierten Attachments gespeichert und eine In-Memory-Session-Map aus den von der Runtime verwalteten Sockets wiederhergestellt. Ein kontrollierter Test mit einer neuen Instanz wies die Rekonstruktion nach, ohne vorzugeben, eine Eviction in der Produktion zu erzwingen. Anschließend trennten sich echte lokale und Cloud-Clients, verbanden sich erneut und behielten das Room-Verhalten bei, während SQLite die persistenten Announcements speicherte. Zum Schluss überprüften Sie die Bereitstellung, entfernten die exakt erzeugten temporären Ressourcen und meldeten die VM ab.