Тема
ТЗ — Broadcasting/realtime (cms/realtime)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Транспорт реального времени на Laravel Reverb: WebSocket-соединения для админки (уведомления, прогресс фоновых задач) и публичного фронта (например, чат cms/chat). Предоставляет единый провайдер realtime-транспорта для остальных модулей.
- WebSocket-сервер на Laravel Reverb
- Приватные и presence-каналы с авторизацией через политики Laravel
- Каналы для админки: живой прогресс импорта/экспорта, счётчик уведомлений
- Каналы для публичного фронта: используются модулями типа
cms/chat - Переподключение клиента с восстановлением пропущенных событий (по возможности транспорта)
- Ограничение числа одновременных соединений на пользователя
Зависимости и выключение
requires: ядро · provides: realtime-transport
Поведение при выключении: события broadcasting не рассылаются по WebSocket, модули-потребители (cms/chat, прогресс импорта) откатываются на периодический опрос (polling) или показывают статичное состояние без live-обновлений — деградация UX, не поломка.
Стоимость внешних API. Не применимо: Reverb — self-hosted компонент, внешних платных провайдеров модуль не использует, тарифных лимитов и биллинга нет. При горизонтальном масштабировании (несколько нод Reverb, см. «Производительность и кеш») расход — собственная инфраструктура (дополнительные процессы, Redis), не внешний счёт с квотой.
Модель данных
Своих таблиц нет — использует стандартные механизмы авторизации каналов Laravel (routes/channels.php) и хранилище сессий ядра. Миграций и кастомных индексов модуль не вносит.
ПДн-паспорт (матрица v2.2). Персистентного хранилища ПДн нет. Presence-каналы временно держат в памяти/Redis отображаемые данные участников (имя, статус — см. «Входные и выходные данные», presence join) с TTL realtime.presence_ttl_seconds: состав пропадает сам по истечении TTL или при disconnect, на диск не пишется и после рестарта Reverb не восстанавливается из архива (архива нет). Модуль не участвует в хуках ядра «выгрузить всё по субъекту» и «забыть по запросу» — постоянного хранилища для выгрузки/удаления нет. Декларация: ПДн не храню.
Входные и выходные данные
Whitelist-принцип: модуль принимает только перечисленные ниже входы — любой канал, не подпадающий под известный префикс (private-, presence-, публичный без префикса из realtime.channel_whitelist), и любой broadcast сверх max_payload_size_kb отклоняется до попадания в Reverb, не «на всякий случай» долетает до подписчиков.
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| WebSocket handshake (браузер → Reverb) | channel_name, токен сессии/Sanctum | routes/channels.php (Broadcasting Auth): резолв текущего пользователя из RequestContext, сверка с сегментом канала |
| Presence join | channel_name, user_id, отображаемые данные presence (имя, статус) | realtime.presence_enabled, тот же guard авторизации канала, что и приватный |
| Событие ядра/модуля (канал 1, внутренний факт) | класс события, payload | издатель валидирует свои данные сам; cms/realtime не ревалидирует бизнес-поля, но ограничивает размер payload |
Сервис-вызов realtime-transport (канал 3, от cms/chat, потенциально cms/import/cms/notifications-inbox) | имя канала, payload для broadcast | whitelist имени канала (префикс + realtime.channel_whitelist), лимит размера, вызывающий модуль отвечает за содержимое |
| Filament: «Разорвать соединение/все соединения» | без полей (триггер) | permission realtime.manage |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
| Клиент браузера (публичный сайт/админка) | событие канала | WS-фрейм JSON, Pusher-совместимый протокол Reverb |
cms/chat (requires) | сообщения диалога, статусы | сервис-вызов broadcast() через realtime-transport |
| Filament-виджет «Активные WebSocket-соединения» | список активных соединений, presence-состав | Blade/Livewire через сервис модуля, не запрос из шаблона |
cms:realtime:status --json | статистика соединений/каналов | JSON в stdout |
cms/health | health-статус доступности Reverb-процесса | health-чек модуля |
Настройки (группа realtime)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
realtime.enabled | bool | true | нет | Включение WebSocket-транспорта — одновременно kill-switch модуля: аварийное отключение всего realtime-транспорта без деинсталляции/выключения пакета (§6 стандарта) |
realtime.max_connections_per_user | int | 5 | нет | Лимит одновременных соединений на авторизованного пользователя (обе вкладки одного пользователя считаются в лимит) |
realtime.max_connections_per_ip | int | 20 | нет | Лимит одновременных соединений с одного IP — действует независимо и дополнительно к max_connections_per_user: для анонимного посетителя публичного канала (например чат-виджет без логина) это единственная защита, для авторизованного — вторая линия сверх лимита на пользователя |
realtime.presence_enabled | bool | true | нет | Разрешить presence-каналы |
realtime.presence_ttl_seconds | int | 60 | нет | Через сколько секунд без heartbeat участник считается отвалившимся и убирается из presence-списка |
realtime.heartbeat_interval_seconds | int | 25 | нет | Интервал ping/pong для держания соединения и детекции обрыва |
realtime.channel_whitelist | array | [] | нет | Разрешённые префиксы/имена каналов сверх стандартных private-/presence-; неизвестный канал в подписке отклоняется |
realtime.max_payload_size_kb | int | 64 | нет | Лимит размера broadcast-payload; превышение — отказ на этапе broadcast(), не отправка в Reverb |
realtime.max_broadcast_rate_per_channel | int | 50 | нет | Троттлинг: максимум сообщений/сек на один канал — защита от шторма рассылки одному каналу |
realtime.max_presence_members | int | 500 | нет | Лимит участников одного presence-канала; join сверх лимита отклоняется понятной ошибкой, канал не разваливается (см. «Производительность и кеш») |
Таблица выше — она же матрица лимитов и квот v2.2: у каждого лимита есть дефолт и понятное поведение при достижении (отказ на этапе broadcast()/join с конвертом ошибок и метрикой, не 500 и не тихое обрезание).
API
Отдельного REST API нет — админ-CRUD через Filament для просмотра активных соединений (диагностика через cms:realtime:status).
Компоненты
Виджеты: Filament-виджет «Активные WebSocket-соединения». Команды: cms:realtime:status --json.
Демо-контент. Не применимо: у модуля нет собственных блоков/виджетов контента для галереи /_gallery и playground — единственный UI-компонент, служебный Filament-виджет диагностики соединений, демо-данными не наполняется (пустое состояние описано в «UX-требования»).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
RealtimeConnectionEstablished | клиент подключился к каналу | user_id, channel |
RealtimeConnectionLimitExceeded | превышен лимит соединений на пользователя | user_id, attempted_channel |
FilterBus не используется. Provides: realtime-transport — контракт, которым пользуются модули-потребители (cms/chat, прогресс импорта/экспорта) для отправки broadcast-событий, не поднимая собственный WebSocket-сервер.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро: пользователи/аутентификация (RequestContext) | сервис-вызов ядра | in | резолв текущего пользователя для авторизации приватного/presence-канала |
| Ядро: settings-store | чтение группы realtime из кеша (§«Настройки» ядра) | in | лимиты соединений, presence TTL, heartbeat, whitelist каналов — 0 запросов на горячем пути |
cms/chat | requires → сервис-вызов (канал 4, realtime-transport) | out | доставка сообщений/статусов диалога подписчикам канала чата |
cms/import (живой прогресс импорта/экспорта) | заявлено как потребитель realtime-transport (канал 3) | out | live-обновление прогресса job; ⚠️ см. «Крайние случаи» — import.md не декларирует suggests: cms/realtime |
cms/notifications-inbox (счётчик уведомлений) | заявлено как потребитель realtime-transport (канал 3) | out | live-обновление счётчика непрочитанных; ⚠️ см. «Крайние случаи» — та же нехватка декларации |
cms/health | health-чек (служебный, вне пяти каналов) | out | доступность Reverb-процесса в агрегате /api/v1/system/health |
Событие ModuleEnabled/ModuleDisabled (ядро, канал 1) | событие | in | выключение модуля-потребителя не должно оставлять зависшие подписки на его каналы |
Фоновая работа
Собственной именованной очереди нет: Reverb-сервер работает как отдельный долгоживущий процесс (не Laravel-очередь), broadcast-события публикуются синхронно через штатный механизм Laravel Broadcasting. Расписания в ядре не требует.
Производительность и кеш
Ожидаемые объёмы. Типичный сайт (несколько операторов чата, десяток одновременных посетителей): десятки-сотни одновременных WS-соединений, единицы сообщений/сек. Крупный сайт (много диалогов чата + live-прогресс импорта на нескольких админах одновременно): тысячи соединений, десятки-сотни сообщений/сек. Reverb — однопроцессный сервер соединений; рост сверх типового профиля требует горизонтального масштабирования через Redis pub/sub (штатный режим Reverb) — заложить в конфиг заранее, не как аварийную меру.
Presence-каналы. Состав участников синхронизируется через join/leave-события Reverb: клиент, подключившийся к presence-каналу, получает текущий список и далее diff-события (участник добавился/ушёл). Дедупликация — по user_id, не по connection_id (см. «Крайние случаи»: две вкладки одного пользователя — один участник в списке). Отображаемые данные участника — whitelist полей (имя, аватар, статус), не произвольный объект: издатель presence не может протащить в payload что-то за пределами whitelist. Лимит участников одного presence-канала — realtime.max_presence_members (дефолт 500, см. «Настройки») — защита от деградации канала, раздутого сверх разумного (например «все на сайте» без сегментации по странице/диалогу). При превышении новый join отклоняется конвертом ошибок ядра (code: "presence_channel_full"), существующие участники канала не страдают и не отключаются.
Масштабирование на несколько нод Reverb. При росте сверх типового профиля — несколько процессов Reverb за балансировщиком, синхронизация broadcast-событий между ними через Redis pub/sub (штатный reverb.scaling-режим, не самописный). Presence-состав в этом режиме обязан быть общим через Redis, а не в памяти одного процесса — иначе presence-список неполон в зависимости от того, к какой ноде подключён конкретный клиент (два подписчика одного канала, разведённые балансировщиком по разным нодам, обязаны видеть один и тот же список участников). Sticky-сессии на балансировщике не обязательны при Redis-режиме, но снижают накладные расходы reconnect при пересборке соединения на другой ноде.
Ранбук (§15 стандарта): симптом → команда.
| Симптом | Проверить / команда |
|---|---|
| Reverb-процесс не отвечает, клиенты не подключаются | cms:realtime:status --json + проверить, что php artisan reverb:start запущен (процесс не поднят или упал) |
| Клиенты массово не подключаются с одной локации (офис за NAT) | Лимит realtime.max_connections_per_ip — не блокирует ли легитимный трафик множества сотрудников за общим IP |
| Шторм сообщений на одном канале, задержки на остальных | realtime.max_broadcast_rate_per_channel и логи троттлинга — издатель не агрегирует частые обновления |
| Presence-список разъехался (дубли/зависшие участники) | realtime.presence_ttl_seconds/heartbeat — форс-очистка через cms:realtime:status --json + рестарт Reverb (presence эфемерна, рестарт безопасен) |
Горячие пути и бюджет запросов. Авторизация канала на handshake — 1 запрос к БД на резолв пользователя (сессия/Sanctum), далее до presence_ttl_seconds — без обращений к БД (heartbeat не бьёт по базе). Чтение лимитов/whitelist — 0 запросов, из кеша группы настроек realtime (как у любого модуля, §10 стандарта). Presence-состав каналов — в памяти Reverb/Redis, не в реляционной БД.
Индексы. Своих таблиц нет — критичных индексов модуль не вносит; при высокой нагрузке критичен индекс на стороне ядра для резолва пользователя при handshake (уже покрыт users/sessions ядра).
Кешируется. Presence-состав канала — в Redis с TTL presence_ttl_seconds, не в CacheTags ядра (это не контентные данные, а живое состояние соединений). Настройки группы realtime — стандартный кеш группы settings-store.
Теги кеша и инвалидация. Собственных тегов CacheTags модуль не объявляет — участвует только в page-cache ядра постольку, поскольку живые обновления могут дублировать данные, отданные из page-cache (потребитель сам решает приоритет: live-виджет обновляет то, что уже отрисовано из кеша страницы). Presence-состав инвалидируется естественным TTL и событиями disconnect/leave, не по кешу-тегам ядра.
Безопасность
Границы входа: приватные и presence-каналы — авторизация через политики Laravel (routes/channels.php), неавторизованный пользователь не получает события канала. Лимит realtime.max_connections_per_user/max_connections_per_ip защищает от исчерпания соединений одним пользователем/ботом. Отдельного REST API с пользовательским вводом нет — поверхность атаки ограничена каналами.
Векторы атак:
| Вектор | Защита |
|---|---|
Подмена канала — клиент запрашивает подписку на чужой приватный канал (например private-user.42, будучи user.7) | Авторизация в routes/channels.php сверяет сегмент имени канала с user_id из RequestContext; несовпадение — 403, подписка не открывается |
| Инъекция в payload — модуль-издатель кладёт неэкранированные данные в событие, клиент рендерит как есть | Realtime не рендерит payload сам — модули-потребители обязаны экранировать на выводе (двойной барьер, как в cms/chat); realtime ограничивает max_payload_size_kb и формат (JSON, не произвольная строка) |
| DoS открытием тысяч соединений одним клиентом/ботом | realtime.max_connections_per_user, realtime.max_connections_per_ip, rate-limit на сам handshake |
| Утечка приватных данных через публичный канал (модуль по ошибке публикует приватное в публичный канал) | Ответственность модуля-издателя; realtime фиксирует канон именования (private-/presence-/публичный без префикса) и проверяет его на whitelist, но не знает семантику payload |
| CSRF при подключении | Авторизация канала (/broadcasting/auth) идёт через тот же Sanctum/session guard и CSRF-токен, что и остальной сайт — не отдельная поверхность |
Публикация в канал напрямую с клиента (client events), минуя серверный broadcast() | Client events выключены в конфиге Reverb — публиковать может только сервер через realtime-transport, иначе любой подписчик канала мог бы разослать поддельное событие остальным |
Конверт ошибок при отказе. Отказ Broadcasting Auth (/broadcasting/auth, неавторизованная подписка на приватный/presence-канал) и whitelist-отказ незнакомого имени канала отвечают конвертом ошибок ядра (ревизия ядра 14.07.2026, п.1): {"message": …, "code": "channel_forbidden"|"channel_unknown", "errors": {…}} — не голый 403 без машиночитаемого кода, клиентский код различает причину отказа программно.
Матрица ролей.
| Роль | realtime.view | realtime.manage | Что доступно |
|---|---|---|---|
| Посетитель/обычный пользователь | — | — | Подключение к своим приватным/presence-каналам — авторизация по RequestContext (сегмент канала сверяется с его user_id), отдельных permissions не требует |
| Менеджер | ✓ | — | Просмотр Filament-виджета «Активные WebSocket-соединения» (диагностика, без управления) |
| Редактор | — | — | Без доступа к диагностике и управлению realtime |
| Studio/админ | ✓ | ✓ | Просмотр + «Разорвать все соединения» (realtime.manage, необратимо, с подтверждением) |
UX-требования
Админ. Пустое состояние виджета «Активные WebSocket-соединения» при нуле подключений — подсказка «нет активных подключений — realtime-модули (чат, live-прогресс) работают без обновлений в реальном времени», не голая пустая таблица. Массовых действий над списком соединений нет (соединение — не сущность для bulk-редактирования), но доступна операция «Разорвать все соединения» с подтверждением (необратимо разрывает активные сессии пользователей — модальное подтверждение обязательно). Ошибки — на человеческом языке: «Reverb-сервер недоступен — проверьте, что процесс php artisan reverb:start запущен» вместо стектрейса или голого «Connection refused».
Посетитель/пользователь. При потере соединения — ненавязчивый индикатор (не блокирующий модал), автоматический reconnect с экспоненциальным backoff; до восстановления UI отдаёт последнее известное состояние, не «замораживается» в ожидании. Воспринимаемая скорость: события в норме доставляются < 1 с; при недоступности транспорта UI не висит в ожидании handshake — таймаут подключения и переход в fallback-режим модуля-потребителя (polling или статичное состояние). Доступность: индикатор состояния соединения («онлайн»/«переподключение») имеет текстовую альтернативу для скринридеров, не только цветовую точку. Presence-канал переполнен (realtime.max_presence_members, см. «Производительность и кеш») — join отклоняется человеческим сообщением («список участников переполнен, обновите позже»), не тихим обрывом соединения.
Крайние случаи и типовые баги
- Reconnect после разрыва и пропущенные за это время сообщения → realtime не хранит журнал сообщений и не гарантирует replay (broadcast — fire-and-forget, как событие канала 1); восстановление состояния при reconnect — обязанность модуля-потребителя через обычный REST-запрос своего API (пример:
cms/chatподгружает последние сообщения диалога поGET /api/v1/chat/conversations/{id}при открытии соединения, не ждёт «дослать» пропущенное через WS). ⚠️ Противоречие: в «Назначение и возможности» заявлено «переподключение клиента с восстановлением пропущенных событий (по возможности транспорта)» — формулировка вводит в заблуждение, Reverb/Pusher-протокол не хранит историю из коробки. Решение: переформулировать пункт как «модули-потребители обязаны реализовать досинхронизацию через свой REST API при (пере)подключении — realtime-транспорт истории не хранит». - Авторизация приватного/presence-канала при подключении →
routes/channels.phpсверяет сегмент имени канала с пользователем изRequestContext, несовпадение — отказ в подписке. - Смена прав пользователя «на лету» на уже открытом соединении → отзыв роли/права не разрывает существующее WS-соединение автоматически — окно между отзывом права и фактическим прекращением доступа к каналу. Ожидаемое поведение: модуль обязан форсированно разрывать соединения пользователя на затронутых приватных каналах при событии смены прав (ядро), а не полагаться на то, что клиент сам переподключится.
- Деградация при недоступном транспорте (Reverb-процесс лежит) → вызовы
broadcast()черезrealtime-transportне блокируют основной поток (таймаут + try/catch на уровне реализации контракта), модули-потребители переключаются на объявленный fallback (polling или статичное состояние — см. «Зависимости и выключение»), health-чек фиксирует простой и алертит черезcms/health. - Двойная подписка на канал из двух вкладок одного пользователя → штатный случай, обе вкладки учитываются в
realtime.max_connections_per_user; presence-список «кто онлайн» дедуплицируется поuser_id, не поconnection_id— иначе пользователь дублируется в списке присутствия. - Шторм сообщений (массовая рассылка одному каналу — например прогресс-бар при импорте шлёт событие на каждую обработанную строку) →
realtime.max_broadcast_rate_per_channelтроттлит частоту; издатель обязан агрегировать частые обновления (прогресс — не чаще раза в секунду), иначе Reverb-воркер деградирует для всех каналов сразу (noisy neighbor). - Выключение модуля посреди активных соединений → активные WS-соединения принудительно разрываются graceful close с понятным кодом (клиент видит «модуль отключён», не голый timeout); модули-потребители реагируют на
ModuleDisabledсинхронно и переключаются на fallback, не оставляя пользователей ждать событий, которые больше не придут. - Отсутствие suggests-модуля (
cms/healthвыключен) → внешний агрегированный health-чек недоступен, но сам транспорт продолжает работать штатно — деградация диагностики, не функциональности. - Пустой/огромный payload сообщения → пустой payload — валидный кейс (события-триггеры без данных, например «обновись»); payload сверх
realtime.max_payload_size_kbотклоняется на этапеbroadcast()доменным исключением модулю-издателю, не долетает до Reverb и подписчиков — соединение не рвётся. - Отсутствие измерения
site_idу имени канала (мультисайт на одном Reverb-инстансе) → без явного сегментаsite_idв имени канала клиент сайта A рискует получить событие сайта B на общем WebSocket-сервере. ⚠️ Противоречие: текущее ТЗ не фиксирует канон именования каналов вообще (нет упоминанияsite_id/locale/city_id), хотя правило измерений §4 стандарта требует корректной работы модуля и при их наличии, и при отсутствии. Решение: до старта разработки зафиксировать канон имени канала с обязательным nullable-safe сегментомsite_id(напримерprivate-site.{site_id}.chat.{id}, дефолт при отсутствии мультисайта — фиксированный сегментdefault). cms/importиcms/notifications-inboxзаявлены потребителямиrealtime-transport(«живой прогресс импорта/экспорта», «счётчик уведомлений» в «Назначение и возможности») → ⚠️ Противоречие:docs/module.mdэтих модулей (import.md—suggests: cms/integrations-bus;notifications-inbox.md—requires: cms/notifications-bus) не декларируютsuggests: cms/realtime, хотя обмен данными §«Модуль ↔ модуль» требует явной декларации связи даже для необязательной (suggests). Решение: добавитьsuggests: cms/realtimeв оба модуля тем же PR, что и их доработку до v2.1, либо исключить эти примеры из текущего файла до появления реальной интеграции.- Публикация в канал напрямую с клиента, минуя серверный
broadcast()→ client events Reverb выключены в конфиге по умолчанию; включение — только осознанное решение с отдельной оценкой риска (см. «Безопасность»), не дефолт. - Presence-канал переполнен (
realtime.max_presence_membersдостигнут) → новый join отклоняется конвертом ошибок сcode: "presence_channel_full", существующие участники не отключаются; модуль-потребитель обязан показать понятное сообщение (см. «UX-требования»), не зависать в ожидании подключения. - Несколько нод Reverb без общего presence-состояния → при масштабировании, где Redis включён только для broadcast-событий, но не для presence, клиент, подключённый к ноде B, не видит участников, подключённых к ноде A — presence-список неполон не по причине бага, а по причине конфигурации. Решение:
reverb.scalingвключается для broadcast и presence одновременно (см. «Производительность и кеш»), не только для обычных событий.
Донорский код
Донор: — (новая разработка на Laravel Reverb)
Миграция legacy-данных (§16 стандарта). Не применимо: cms/realtime не хранит персистентных данных (см. «Модель данных») — переносить с донорских сайтов нечего, команда cms:realtime:import-legacy не заводится.
Тесты и приёмка
- [ ] Контрактный тест: приватный канал не отдаёт события неавторизованному пользователю
- [ ] Health-чек модуля проверяет доступность Reverb-сервера
- [ ] При выключении модуля модули-потребители откатываются на polling без ошибок
- [ ] Лимит
realtime.max_connections_per_user/max_connections_per_ipсоблюдается (тест на превышение) - [ ] Reconnect после разрыва: модуль-потребитель досинхронизирует состояние через REST, а не ждёт replay от realtime
- [ ] Отзыв права у пользователя разрывает его активные приватные соединения на затронутых каналах
- [ ] Presence-список дедуплицирует пользователя с двумя одновременными вкладками
- [ ]
realtime.max_broadcast_rate_per_channelтроттлит шторм сообщений одному каналу без деградации остальных каналов - [ ] Payload сверх
realtime.max_payload_size_kbотклоняется до Reverb, соединение не рвётся - [ ] Права
realtime.view/realtime.manageразграничивают диагностику и управление - [ ] Контрактный набор
cms-testingпройден, пакет протестирован в testbench-изоляции - [ ] Feature-тест на каждый роут модуля; тестовая БД только
realtime_test,migrate:freshзапрещён - [ ] Presence-канал не превышает
realtime.max_presence_members; join сверх лимита отклоняется конвертом ошибок сcode: "presence_channel_full", канал не разваливается - [ ] Несколько нод Reverb за Redis-масштабированием (
reverb.scaling): событие, опубликованное на канале через ноду A, долетает подписчику, подключённому к ноде B - [ ] Отказ Broadcasting Auth (
/broadcasting/auth) возвращает конверт ошибок сcode: "channel_forbidden"/"channel_unknown", не голый 403 без машиночитаемого кода - [ ] Лимит
realtime.max_connections_per_ipсоблюдается для анонимных соединений независимо отrealtime.max_connections_per_userавторизованных