Skip to content

ТЗ — 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, токен сессии/Sanctumroutes/channels.php (Broadcasting Auth): резолв текущего пользователя из RequestContext, сверка с сегментом канала
Presence joinchannel_name, user_id, отображаемые данные presence (имя, статус)realtime.presence_enabled, тот же guard авторизации канала, что и приватный
Событие ядра/модуля (канал 1, внутренний факт)класс события, payloadиздатель валидирует свои данные сам; cms/realtime не ревалидирует бизнес-поля, но ограничивает размер payload
Сервис-вызов realtime-transport (канал 3, от cms/chat, потенциально cms/import/cms/notifications-inbox)имя канала, payload для broadcastwhitelist имени канала (префикс + 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/healthhealth-статус доступности Reverb-процессаhealth-чек модуля

Настройки (группа realtime)

КлючТипДефолтaffectsPageCacheОписание
realtime.enabledbooltrueнетВключение WebSocket-транспорта — одновременно kill-switch модуля: аварийное отключение всего realtime-транспорта без деинсталляции/выключения пакета (§6 стандарта)
realtime.max_connections_per_userint5нетЛимит одновременных соединений на авторизованного пользователя (обе вкладки одного пользователя считаются в лимит)
realtime.max_connections_per_ipint20нетЛимит одновременных соединений с одного IP — действует независимо и дополнительно к max_connections_per_user: для анонимного посетителя публичного канала (например чат-виджет без логина) это единственная защита, для авторизованного — вторая линия сверх лимита на пользователя
realtime.presence_enabledbooltrueнетРазрешить presence-каналы
realtime.presence_ttl_secondsint60нетЧерез сколько секунд без heartbeat участник считается отвалившимся и убирается из presence-списка
realtime.heartbeat_interval_secondsint25нетИнтервал ping/pong для держания соединения и детекции обрыва
realtime.channel_whitelistarray[]нетРазрешённые префиксы/имена каналов сверх стандартных private-/presence-; неизвестный канал в подписке отклоняется
realtime.max_payload_size_kbint64нетЛимит размера broadcast-payload; превышение — отказ на этапе broadcast(), не отправка в Reverb
realtime.max_broadcast_rate_per_channelint50нетТроттлинг: максимум сообщений/сек на один канал — защита от шторма рассылки одному каналу
realtime.max_presence_membersint500нетЛимит участников одного 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/chatrequires → сервис-вызов (канал 4, realtime-transport)outдоставка сообщений/статусов диалога подписчикам канала чата
cms/import (живой прогресс импорта/экспорта)заявлено как потребитель realtime-transport (канал 3)outlive-обновление прогресса job; ⚠️ см. «Крайние случаи» — import.md не декларирует suggests: cms/realtime
cms/notifications-inbox (счётчик уведомлений)заявлено как потребитель realtime-transport (канал 3)outlive-обновление счётчика непрочитанных; ⚠️ см. «Крайние случаи» — та же нехватка декларации
cms/healthhealth-чек (служебный, вне пяти каналов)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.viewrealtime.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.mdsuggests: cms/integrations-bus; notifications-inbox.mdrequires: 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 авторизованных

Внутренняя база знаний студии