Трансляция обновлений комнаты

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

Введение

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

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

В этой лабораторной работе намеренно используется стандартный WebSocket API, а набор активных сокетов хранится в памяти. Так вы увидите работу подключений и трансляции до того, как в O06 будут представлены WebSocket Hibernation и connection attachments. SQLite хранит небольшую историю сообщений, чтобы можно было доказать: некорректный ввод не изменил постоянное состояние. Однако само открытое соединение от этого постоянным не становится.

Вы реализуете протокол, подключите двух предоставленных клиентов к одной комнате, а третьего клиента — к другой, увидите корректную трансляцию, отклоните некорректный ввод, повторите проверку в Cloudflare, изучите клиент в браузере и Dashboard, а затем удалите точно указанные временные ресурсы.

Для каждой новой виртуальной машины требуется отдельная авторизация Wrangler. Предполагается, что вы уже понимаете работу со стабильными именами Durable Objects, привязками, RPC и состоянием на базе SQLite из O01–O04. Настройка устанавливает Node.js 22.22.0, локальный Wrangler 4.132.0 и тестовый клиент ws в /home/labex/project/room-broadcast. Она предоставляет браузерный и тестовый клиенты, но не создаёт Worker, не авторизует Cloudflare и не выполняет развёртывание.

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

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

Перейдите в подготовленный проект, проверьте закреплённую версию Wrangler и авторизуйте виртуальную машину:

cd /home/labex/project/room-broadcast
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-o05-$(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": "ROOMS", "class_name": "RoomBroadcast" }
  ] },
  "exports": {
    "RoomBroadcast": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

Привязка ROOMS — это маршрут Worker к пространству имён класса. Вызов getByName("planning") всегда выбирает одну и ту же логическую комнату, а getByName("support") выбирает независимый объект. Экспорт предоставляет каждой выбранной комнате собственное хранилище SQLite. До развёртывания облачных ресурсов не существует.

Реализуйте протокол WebSocket с проверкой сообщений

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

Исходный запрос должен содержать Upgrade: websocket. После обновления сообщения передаются в кадрах, а не в новых HTTP-запросах. Клиент может отправить в кадре любой текст, поэтому разбор JSON — только первая проверка. До изменения постоянного состояния нужно также проверить ожидаемое значение type, одно непустое поле text ограниченной длины и отсутствие неожиданных полей.

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

cat > src/protocol.js <<'JS'
const ROOM_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;

export function parseRoomPath(pathname) {
  const match = pathname.match(/^\/rooms\/([^/]+)\/(connect|state)$/);
  if (!match || !ROOM_PATTERN.test(match[1])) return null;
  return { room: match[1], action: match[2] };
}

export function parseClientMessage(raw) {
  if (typeof raw !== "string" || raw.length > 512) return { ok: false };
  let value;
  try { value = JSON.parse(raw); } catch { return { ok: false }; }
  if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false };
  const keys = Object.keys(value).sort();
  if (keys.length !== 2 || keys[0] !== "text" || keys[1] !== "type") return { ok: false };
  if (value.type !== "update" || typeof value.text !== "string") return { ok: false };
  const text = value.text.trim();
  if (text.length < 1 || text.length > 80) return { ok: false };
  return { ok: true, text };
}
JS

Создайте Worker на входе и класс Durable Object:

cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { CLIENT_HTML } from "./client-html.js";
import { parseClientMessage, parseRoomPath } from "./protocol.js";

const json = (body, status = 200) => Response.json(body, { status });

export class RoomBroadcast extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.sessions = new Set();
    this.ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS messages (
          sequence INTEGER PRIMARY KEY AUTOINCREMENT,
          text TEXT NOT NULL,
          created_at INTEGER NOT NULL
        )
      `);
    });
  }

  async fetch(request) {
    if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
      return json({ error: "websocket_upgrade_required" }, 426);
    }
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    server.accept();
    this.sessions.add(server);
    server.addEventListener("message", event => this.receive(server, event.data));
    const forget = () => this.sessions.delete(server);
    server.addEventListener("close", forget);
    server.addEventListener("error", forget);
    server.send(JSON.stringify({ type: "ready" }));
    return new Response(null, { status: 101, webSocket: client });
  }

  receive(sender, raw) {
    const message = parseClientMessage(raw);
    if (!message.ok) {
      sender.send(JSON.stringify({
        type: "error",
        code: "invalid_message",
        detail: "Send only {type: update, text: 1-80 characters}."
      }));
      return;
    }
    const createdAt = Date.now();
    const row = this.ctx.storage.sql.exec(`
      INSERT INTO messages (text, created_at)
      VALUES (?, ?)
      RETURNING sequence
    `, message.text, createdAt).one();
    const update = JSON.stringify({
      type: "update",
      sequence: row.sequence,
      text: message.text,
      createdAt
    });
    for (const socket of this.sessions) {
      try { socket.send(update); } catch { this.sessions.delete(socket); }
    }
    console.log(JSON.stringify({ event: "room_update", sequence: row.sequence, connected: this.sessions.size }));
  }

  async getState() {
    const messages = this.ctx.storage.sql.exec(`
      SELECT sequence, text, created_at AS createdAt
      FROM messages ORDER BY sequence
    `).toArray();
    return {
      messageCount: messages.length,
      latestSequence: messages.at(-1)?.sequence ?? 0,
      messages
    };
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === "/" && request.method === "GET") {
      return new Response(CLIENT_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
    }
    const route = parseRoomPath(url.pathname);
    if (!route) return json({ error: "not_found" }, 404);
    if (route.action === "connect") {
      if (request.method !== "GET" || (request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
        return json({ error: "websocket_upgrade_required" }, 426);
      }
      return env.ROOMS.getByName(route.room).fetch(request);
    }
    if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
    const state = await env.ROOMS.getByName(route.room).getState();
    return json({ room: route.room, ...state });
  }
};
JS

WebSocketPair создаёт клиентский и серверный концы одного соединения. Возврат клиентского конца с HTTP-статусом 101 завершает обновление, а server.accept() запускает стандартный серверный сокет. Набор sessions в памяти намеренно относится к одному экземпляру объекта, а стабильное имя комнаты не позволяет сделать этот набор общим для разных комнат.

Запустите детерминированные тесты протокола и попросите Wrangler выполнить сборку без развёртывания:

npm test
npx wrangler deploy --dry-run

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

Рассылайте обновление внутри одной комнаты

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

Запустите Wrangler как фоновую задачу. Перенаправление вывода оставляет терминал читаемым, а сохранённый идентификатор задания позволит позже остановить именно этот процесс:

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
  curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
  sleep 1
done
curl --silent --fail http://127.0.0.1:8787/ | grep -o '<title>[^<]*</title>'

Предоставленная программа-клиент открывает три настоящих WebSocket-соединения: два с именем planning и одно с именем support. Она отправляет одно обновление от первого клиента комнаты planning и ждёт ограниченное по времени подтверждение от всех трёх:

node tools/room-clients.mjs http://127.0.0.1:8787 planning support broadcast | tee .labex/local-broadcast.json

Объекты sender и peer должны содержать одинаковые sequence: 1 и текст. Значение otherUpdates должно быть равно 0. В разделе состояния отдельно отображаются одно постоянное сообщение комнаты planning и ноль сообщений комнаты support. Это подтверждает обе части архитектуры: общее стабильное имя объединяет первых двух клиентов, а другое имя не позволяет третьему клиенту попасть в область трансляции.

Отклоните некорректное сообщение до изменения состояния

На этом шаге вы отправите кадр с корректным JSON, но некорректными данными приложения, а затем сравните постоянное состояние до и после отправки.

Важна пустая строка в поле text: JSON успешно разбирается, но протокол комнаты отклоняет сообщение. Запустите вторую предоставленную фазу для тех же локальных объектов:

node tools/room-clients.mjs http://127.0.0.1:8787 planning support invalid | tee .labex/local-invalid.json

Только отправивший сообщение клиент получает ошибку с кодом invalid_message; значение peerErrors остаётся равным 0. Истории before и after совпадают и содержат одно сообщение. Поэтому некорректный клиент не может добавить строку, увеличить последовательность или превратить ошибку в трансляцию для всей комнаты.

Прочитайте состояния обеих комнат напрямую:

curl --silent --fail http://127.0.0.1:8787/rooms/planning/state | jq
curl --silent --fail http://127.0.0.1:8787/rooms/support/state | jq

Первый ответ сообщает об одном сообщении, а второй — ни об одном. HTTP-запросы состояния остаются достоверным источником данных, даже если клиент отключится после теста.

Разверните Worker и протестируйте облачных WebSocket-клиентов

На этом шаге вы остановите локальный runtime, развернёте тот же код и повторите контракт с тремя клиентами через Cloudflare.

Остановите только локальное задание, идентификатор которого был сохранён ранее, затем выполните развёртывание:

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
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

Сначала развёртывание создаёт Worker и синхронизирует пространство имён RoomBroadcast. Успешная загрузка главной страницы сама по себе не доказывает, что маршрут с состоянием готов. Поэтому опросите безвредное состояние пустой комнаты, проверяя точный контракт JSON, а затем подождите немного:

for attempt in $(seq 1 30); do
  READY="$(curl --silent --show-error "$APP_URL/rooms/cloud-observer/state" || true)"
  test "$(printf '%s' "$READY" | jq -r '.messageCount // -1' 2>/dev/null)" = 0 && break
  sleep 2
done
test "$(printf '%s' "$READY" | jq -r .messageCount)" = 0
sleep 5

Запустите тот же настоящий WebSocket-клиент для уникальных облачных комнат:

node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support broadcast | tee .labex/cloud-broadcast.json
node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support invalid | tee .labex/cloud-invalid.json

Вывод в облаке должен показать то же поведение, что и локальная разработка: два клиента planning получают последовательность 1, клиент support не получает обновлений, а некорректный ввод не изменяет историю.

Откройте напечатанный APP_URL в браузере. Выберите Connect three clients, затем Send planning update. Клиенты A и B должны показать одно и то же новое update, а клиент C — только своё сообщение ready. Выберите Send malformed update и убедитесь, что ошибку получает только клиент A. Закончив просмотр результата, выберите Disconnect clients и дождитесь, пока все три карточки покажут состояние Closed; это завершит процедуру закрытия WebSocket перед тем, как вы покинете страницу. Эта страница является предоставленным клиентом для наблюдения; окончательными подтверждениями успешного выполнения остаются Node-проверка и проверки серверной части.

Изучите браузерный клиент и Durable Object

На этом шаге вы сопоставите данные runtime с Cloudflare Dashboard и докажете, что история комнаты сохраняется после повторного развёртывания неизменённого кода.

Оставьте демонстрацию в браузере подключённой на время, достаточное для просмотра трёх карточек. Две карточки planning наглядно показывают трансляцию в пределах комнаты. Не менее важна молчащая карточка support: она демонстрирует, что данные не пересекли границу идентичности.

Два клиента planning получают одно и то же обновление, а support остаётся без изменений

В Cloudflare Dashboard откройте Workers & Pages, выберите точное имя, сохранённое в .labex/run-name, и изучите его привязки. ROOMS должна указывать на RoomBroadcast. Затем откройте Durable Objects, выберите пространство имён с именем <your-worker>_RoomBroadcast и на странице Overview подтвердите наличие Storage: SQL.

Привязка ROOMS указывает на Durable Object RoomBroadcast

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

Откройте вкладку Logs пространства имён. Выберите недавнюю успешно выполненную строку, связанную с браузером или Node-проверкой. Структурированное прикладное сообщение room_update сообщает его последовательность и текущее число подключённых клиентов, но не записывает текст сообщения. Dashboard может доставить журналы с задержкой после запроса; достоверными остаются ответы runtime и независимые проверки.

Повторно разверните неизменённый код. Открытые WebSocket-соединения — это активный транспорт, и их сохранение после развёртывания не гарантируется. Однако история SQLite принадлежит именованному объекту и должна сохраниться:

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 по-прежнему должна сообщать об одном сообщении с последовательностью 1, а комната support должна оставаться пустой. Сгенерированный суффикс, временные метки и итоговые показатели трафика в Dashboard будут отличаться от проверенных примеров.

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

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

Убедитесь, что сохранённое имя начинается с labex-c10-o05-. Создайте не сохраняющую состояние точку входа для очистки:

RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o05-*) ;; *) echo "Unexpected Worker name" >&2; exit 1;; esac
cat > src/cleanup.js <<'JS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
JS

Создайте конфигурацию очистки для того же Worker и той же учётной записи. Метка state: "deleted" удаляет только пространство имён класса этой лабораторной работы, включая его временные истории:

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": {
    "RoomBroadcast": { "type": "durable-object", "state": "deleted" }
  }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc

В выводе синхронизации должно появиться Deleted: RoomBroadcast. Удалите оставшийся Worker без состояния. Wrangler запросит подтверждение, поскольку удаление необратимо. Подтверждайте только после того, как отображаемое имя в точности совпадёт со значением $RUN:

npx wrangler delete --config wrangler.cleanup.jsonc

В ответ на запрос введите y и нажмите Enter. Команда должна завершиться сообщением Successfully deleted, за которым следует сгенерированное имя Worker.

Оставьте эту виртуальную машину авторизованной для проверки в конце шага. Убедитесь, что Wrangler по-прежнему сообщает об авторизованной сессии:

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

JSON должен содержать "loggedIn": true. Теперь LabEx может обратиться к выбранной учётной записи и подтвердить отсутствие Worker и его пространства имён Durable Object. Ошибка сети или авторизации не доказывает успешную очистку.

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

На этом шаге вы отзовёте OAuth-авторизацию, сохранённую только на этой новой виртуальной машине, после проверки удаления облачных ресурсов.

Команда wrangler logout удаляет локальные данные авторизации. Проверка whoami --json в структурированном формате важна, поскольку обычный человекочитаемый вывод может быть неоднозначным; поле loggedIn является достоверным результатом:

npx wrangler logout
npx wrangler whoami --json

Итоговый JSON должен содержать "loggedIn": false. Это не удаляет вашу учебную учётную запись Cloudflare и не выполняет выход из неё в браузере. Команда лишь запрещает этой виртуальной машине выполнять дальнейшие авторизованные запросы Wrangler.

Итоги

Вы преобразовали HTTP-запросы в WebSocket-соединения, направили проверенные имена комнат к независимым Durable Objects, разослали одно одобренное обновление двум клиентам одной комнаты и сохранили изоляцию другой комнаты. Вы отделили разбор JSON от проверки данных приложения, доказали, что некорректный ввод не изменяет ни состояние трансляции, ни историю SQLite, повторили поведение в Cloudflare, изучили представления браузера и Dashboard, проверили сохранность истории после повторного развёртывания и удалили точно указанное временное пространство имён.

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