Введение
Обычный 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: она демонстрирует, что данные не пересекли границу идентичности.

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



