Восстановление контекста WebSocket-соединения

CloudflareBeginner
Практиковаться сейчас

Введение

Активное WebSocket-соединение может существовать гораздо дольше, чем один объект JavaScript в памяти. Cloudflare может перевести в режим гибернации неактивный Durable Object: клиенты остаются подключёнными на сетевом уровне, но поля объекта в памяти исчезают. Позднее новое сообщение создаёт новый экземпляр класса. Это сокращает плату за простой, но означает, что обычная карта в памяти — ненадёжное место для хранения имени или роли клиента.

Hibernation WebSocket API решает проблему жизненного цикла в два этапа. ctx.acceptWebSocket(server) регистрирует соединение, не удерживая объект в памяти. serializeAttachment() сохраняет небольшое значение в формате structured clone вместе с этим соединением; после восстановления объекта deserializeAttachment() возвращает это значение. ctx.getWebSockets() позволяет новому конструктору перечислить сокеты, которые всё ещё подключены.

Вы создадите сервис присутствия в комнате, который прикрепляет к каждому сокету проверенные идентификатор клиента, отображаемое имя и имя комнаты. Контролируемый тест восстановления создаст новый экземпляр класса для уже существующих фиктивных сокетов и подтвердит, что их вложения восстанавливают карту сессий. Вы также проверите реальные локальные и развёрнутые WebSocket-соединения, отключите и повторно подключите одного клиента браузера и убедитесь, что комнаты продолжают работать корректно. В рабочей среде Cloudflare самостоятельно определяет момент гибернации, поэтому этот урок и проверка не пытаются принудительно вызвать удаление объекта.

Прежде чем напрямую приступить к этому курсу, выполните Подключение LabEx к вашей учётной записи Cloudflare. Для каждой новой виртуальной машины требуется отдельная авторизация Wrangler. Вы уже должны понимать именованные Durable Objects, состояние на основе SQLite и трансляцию WebSocket-сообщений в пределах комнаты из O01–O05.

Настройка устанавливает Node.js 22.22.0, локальный для проекта Wrangler 4.132.0 и зафиксированный клиент WebSocket в /home/labex/project/connection-context. Она предоставляет фикстуры для браузера и тестов, но не авторизует Cloudflare, не реализует Durable Object, не принимает сокет и не развёртывает Worker.

Авторизуйте виртуальную машину и объявите пространство имён Presence

На этом шаге вы авторизуете новую виртуальную машину, выберете выделенную учебную учётную запись и объявите один класс Durable Object с хранилищем SQLite для комнат присутствия.

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

Ожидается версия Wrangler 4.132.0. Откройте показанный URL Cloudflare в браузере, введите короткий код, подтвердите нужную учебную учётную запись и авторизуйте её. Браузер предоставляет Wrangler доступ; пароль на виртуальную машину не передаётся.

Прочитайте только безопасные поля идентификации и создайте уникальное имя временного Worker:

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 — это маршрут Worker к объектам комнат. Стабильные имена комнат отделяют соединения и историю одной комнаты от другой. Экспорт класса предоставляет каждой комнате отдельное хранилище SQLite; до развёртывания облачный ресурс не создаётся.

Реализуйте безопасный для гибернации контекст соединения

На этом шаге вы отделите безопасные метаданные соединения от живого объекта сокета, а затем воспользуетесь Hibernation WebSocket API, чтобы восстанавливать эти метаданные при каждом создании Cloudflare нового экземпляра объекта.

Вложение — это небольшое значение в формате structured clone, сохранённое вместе с одним WebSocket. Оно сохраняется во время гибернации только пока соединение остаётся исправным; долговременная история комнаты по-прежнему хранится в SQLite. Создайте вспомогательные функции проверки и восстановления:

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

Создайте Durable Object и входной 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() заменяет server.accept() и обработчики событий. Теперь сообщения поступают в обработчик webSocketMessage() на уровне класса. Конструктор восстанавливает sessions из сокетов, которыми управляет среда выполнения, и их вложений; он не предполагает, что прежняя карта JavaScript Map сохранилась.

Подтвердите восстановление контекста, не имитируя принудительное удаление

На этом шаге вы напрямую проверите границу восстановления. Cloudflare самостоятельно определяет, когда простаивающий объект в рабочей среде переходит в гибернацию, поэтому детерминированный лабораторный тест не должен ждать принудительного удаления или заявлять, что оно было выполнено. Вместо этого новый экземпляр PresenceRoom получает фиктивные сокеты, которыми управляет среда выполнения, а их вложения были записаны предыдущим экземпляром.

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

Ожидаются четыре успешно пройденных теста. Они подтверждают, что код может восстановить контекст из вложений. Последующие проверки в реальном времени устанавливают поведение настоящих сокетов, но ни одна из них не выдаёт себя за доказательство того, что конкретный рабочий объект был удалён по требованию.

Переподключите клиента и сохраните поведение комнаты

На этом шаге вы проверите работу реальных локальных сокетов. Переподключение создаёт новый сокет и, следовательно, новое вложение, а долговременные объявления сохраняются в 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 переподключается через новый сокет, но её второе сообщение по-прежнему содержит displayName: Alice; Bob получает его, а Carol остаётся изолированной. Два долговременных объявления комнаты показывают, что срок жизни сокета и срок хранения истории комнаты различаются.

Разверните сервис и повторите сценарий переподключения

На этом шаге вы остановите именно запущенный локальный процесс, выполните развёртывание, дождётесь доступности настоящего маршрута с состоянием и повторите сценарий работы клиента с уникальными облачными комнатами:

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

Тот же результат в Cloudflare подтверждает, что развёрнутый сервис использует сериализованное вложение после принятия каждого соединения и после переподключения Alice. Это не означает, что платформа успела перевести объект в гибернацию во время этого ограниченного запуска.

Проверьте развёртывание, совместимое с гибернацией

На этом шаге вы сопоставите данные среды выполнения с Cloudflare Dashboard и повторным развёртыванием без изменений. Откройте Workers & Pages, выберите точное имя из .labex/run-name и откройте Bindings. PRESENCE должен указывать на PresenceRoom.

Привязка PRESENCE указывает на Durable Object PresenceRoom

Откройте Durable Objects, выберите <your-worker>_PresenceRoom и подтвердите значение Storage: SQL. Эта страница показывает пространство имён класса, но не раскрывает значения вложений.

Пространство имён PresenceRoom использует хранилище SQL

Откройте Logs и найдите успешную строку presence_announcement. Она содержит синтетический идентификатор клиента и последовательный номер, но не текст объявления. Данные о трафике могут появиться на панели позже ответа, поэтому авторитетными остаются проверки живого клиента и серверной части.

Структурированное событие присутствия безопасно идентифицирует восстановленного клиента

Повторно разверните код без изменений и прочитайте те же облачные комнаты:

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

В комнате planning по-прежнему находятся оба объявления, а support остаётся пустой. Повторное развёртывание подтверждает, что долговременная история переживает выпуск новой версии Worker; контролируемый тест конструктора отдельно подтверждает восстановление вложений.

Удалите пространство имён Presence

На этом шаге вы удалите только созданные для этой лабораторной работы Worker и пространство имён, пока виртуальная машина всё ещё авторизована:

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

Убедитесь, что в запросе отображается точное значение $RUN, введите y и дождитесь сообщения Successfully deleted. Не отменяйте авторизацию виртуальной машины до следующей проверки:

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

JSON должен содержать "loggedIn": true; ошибка авторизации или сети не подтверждает удаление.

Отзовите авторизацию Wrangler для этой виртуальной машины

На этом шаге после независимого подтверждения удаления вы удалите OAuth-авторизацию только для этой виртуальной машины:

npx wrangler logout
npx wrangler whoami --json

Итоговый JSON должен содержать "loggedIn": false. Ваша учебная учётная запись останется авторизованной в браузере.

Итоги

Вы заменили обычные принятые сокеты на Hibernation WebSocket API, сохранили ограниченный контекст клиента в сериализованных вложениях и восстановили карту сессий в памяти из сокетов, которыми управляет среда выполнения. Контролируемый тест для нового экземпляра подтвердил восстановление, не создавая видимость принудительного удаления объекта в рабочей среде. Затем реальные локальные и облачные клиенты отключались, подключались снова и сохраняли корректное поведение комнат, пока SQLite хранил долговременные объявления. В конце вы проверили развёртывание, удалили точно определённые временные ресурсы и вышли из системы.