Realtime (WebSocket)
Живые обновления приходят по одному WebSocket: клиент подписывается на топики, сервер шлёт события и умеет доиграть то, что было пропущено во время обрыва. Здесь — адрес сокета, способы аутентификации, формат кадров, каталог топиков с правилами доступа и лимиты, которые шлюз действительно проверяет.
Подключение
Канонический адрес — /api/v1/realtime/ws. Исторические написания /api/realtime/ws и /ws зарегистрированы на том же обработчике и работают, но новый код должен брать канонический. Сокет живёт на том же хосте, что и HTTP API: на платформенном домене, на поддомене сообщества и на подтверждённом собственном домене — адрес один и тот же относительный путь.
wscat -c "wss://owt.craazzzyyfoxx.me/api/v1/realtime/ws?token=<JWT>"
Учётка читается из трёх мест, в этом порядке: query-параметр token, заголовок Authorization: Bearer, cookie owt_access_token (устаревшая aqt_access_token — как запасной вариант). Браузер не умеет ставить заголовки на WebSocket-рукопожатие, поэтому там работают cookie или token; серверный клиент может использовать заголовок.
Подходят обе учётки, описанные в статье Аутентификация и ключи API:
- Сессионный JWT. Шлюз проверяет подпись локально и берёт из токена
subиis_superuser. Соединение живёт не дольшеexpтокена: когда срок выходит, сокет закрывается и клиент переподключается с текущей сессией. - API-ключ. Аутентифицирует сокет, только если у ключа есть хотя бы один грант в его воркспейсе; ключ без скоупов подключается как аноним. Флаг суперпользователя владельца на сокет не переносится никогда, а
expу ключа нет, поэтому уже открытое соединение отзывом ключа не рвётся — отказ получит только следующее рукопожатие. - Без учётки. Анонимное подключение разрешено: рукопожатие не требует токена, доступ решается отдельно на каждой подписке.
Для браузерных клиентов проверяется заголовок Origin. По умолчанию разрешены платформенный апекс и его поддомены; собственный домен воркспейса принимается, только если он подтверждён. Origin, совпадающий с Host самого запроса, разрешён всегда, поэтому сайт сообщества на своём домене подключается без дополнительной настройки.
Кадры клиента
Каждый кадр — отдельное JSON-сообщение с полем op.
op | Обязательные поля | Что делает |
|---|---|---|
subscribe | topic, опционально after_event_id | Подписка на топик, при наличии курсора — с доигрыванием |
unsubscribe | topic | Снимает подписку, ответа нет |
ping | — | Проверка живости |
publish | topic, event_type, data | Эфемерная рассылка другим подписчикам топика |
{"op": "subscribe", "topic": "tournament:42:bracket", "after_event_id": 1180}
Кадры сервера
op | Поля | Когда приходит |
|---|---|---|
subscribed | topic, cursor | Подписка принята; cursor — последний сохранённый event_id топика |
event | topic, event | Событие топика (живое или доигранное) |
error | code, message, topic (может быть null) | Кадр отвергнут или подписка не удалась |
pong | — | Ответ на ping |
Полезная нагрузка event — конверт с фиксированными полями:
{
"op": "event",
"topic": "tournament:42:bracket",
"event": {
"event_id": 1181,
"event_type": "cache.invalidated",
"schema_version": 1,
"occurred_at": "2026-09-20T18:04:11.902374Z",
"actor_user_id": 17,
"data": {"resources": ["tournament.encounters", "tournament.standings"]}
}
}
event_id равен нулю у эфемерных событий: они не сохраняются и не двигают курсор доигрывания. actor_user_id — идентификатор аккаунта, чьё действие породило событие, или null для системных.
Каталог топиков
Имя топика всегда <вид>:<id>:<домен>; домен может содержать двоеточие (pick-ban:hero). Виды: tournament, workspace, user, encounter, draft.
| Топик | Содержимое | Кто может подписаться |
|---|---|---|
tournament:{id}:invalidation | Имена устаревших ресурсов турнира | Как на просмотр турнира |
tournament:{id}:bracket | Сетка турнира | Как на просмотр турнира |
tournament:{id}:draft | Живой драфт, плюс присутствие | Как на просмотр турнира |
tournament:{id}:streams | Статусы трансляций | Как на просмотр турнира |
tournament:{id}:balancer | Задачи балансировщика и правки составов, плюс присутствие | Участник воркспейса турнира; суперпользователь |
encounter:{id}:map-veto | Бан-пик карт встречи | Как на просмотр турнира встречи |
encounter:{id}:pick-ban:hero | Бан-пик героев встречи | Как на просмотр турнира встречи |
encounter:{id}:chat | Чат предматчевой комнаты | Участник комнаты или зритель при открытом чтении |
draft:{session_id}:chat | Чат комнаты драфта | Участник комнаты или зритель при открытом чтении |
workspace:{id}:invalidation | Имена устаревших ресурсов воркспейса | Участник воркспейса; суперпользователь |
workspace:{id}:logs | Разбор загруженных логов | Участник воркспейса; суперпользователь |
workspace:{id}:pickup_mix | Миксы (кастомки) | Участник воркспейса; суперпользователь |
workspace:{id}:subscriptions | Проверки подписок | Участник воркспейса; суперпользователь |
workspace:{id}:analytics_jobs | Задачи аналитики | Участник воркспейса; суперпользователь |
user:{id}:invalidation | Устаревшие личные ресурсы | Только сам пользователь |
user:{id}:notifications | Сигнал «пришло уведомление» | Только сам пользователь |
Правила проверяются по порядку, решает первое совпавшее; workspace:{id}:* покрывает любой домен воркспейса разом. Топик, который не совпал ни с одним правилом, запрещён — неизвестное имя нельзя «просто подписать».
«Как на просмотр турнира» означает: обычный турнир виден всем, включая анонимов; скрытый требует авторизованного инсайдера — суперпользователя, участника воркспейса-организатора или аккаунта из списка предпросмотра. Несуществующий турнир (или встреча, или сессия драфта) всегда получает отказ, чтобы подписка не работала как проверка существования.
У личных топиков исключений нет вовсе: user:{id}:notifications и user:{id}:invalidation доступны только аккаунту с этим id, суперпользователь их не обходит.
Инвалидация кэша
Топики *:invalidation не несут данных — только имена ресурсов, которые устарели, в поле data.resources; иногда рядом лежит data.entity_ids с уточнением. Словарь ресурсов фиксирован: tournament.detail, tournament.stages, tournament.encounters, tournament.standings, tournament.teams, tournament.structure, tournament.registrations, tournament.registration_form, tournament.streams, workspace.logs, workspace.pickup_mix, workspace.subscriptions, workspace.analytics_jobs, user.notifications. Тип события всегда cache.invalidated.
Глобального топика инвалидации нет намеренно: сам факт, что турнир существует и меняется, — это информация, поэтому инвалидация гейтится ровно так же, как доменные топики того же вида.
Долговечные и эфемерные топики, доигрывание
Долговечное событие сохраняется в журнале и получает ненулевой event_id; эфемерное публикуется только в шину и живёт до следующего кадра. Долговечны инвалидация, ходы драфта, бан-пик карт и героев, изменения составов и переходы задач балансировщика. Эфемерны присутствие, тики прогресса задач, чат, уведомления, статусы трансляций, миксы, разбор логов и проверки подписок — всё, что переподключившийся клиент всё равно перечитывает обычным запросом.
Доигрывание работает так:
- В
subscribeпередаётсяafter_event_id— последнийevent_id, который клиент уже обработал в этом топике. - Сервер сначала присылает пропущенные
event-кадры в порядке возрастанияevent_id, и только потомsubscribedс текущим курсором. - Без
after_event_idподписка живая: истории не будет вовсе. Это осознанное поведение для первой подписки — иначе свежая страница получила бы весь архив топика. - Если пропущено больше событий, чем разрешено доиграть (по умолчанию 500), приходит ошибка
replay_gap_too_largeи подписки не возникает. Нужно перечитать состояние обычным запросом и подписаться заново безafter_event_id.
Курсор из subscribed и event_id каждого входящего события — это то, что клиент должен хранить и отдавать при следующей подписке.
Журнал не бесконечен: сохранённые события инвалидации удаляются через семь дней, поэтому клиент, пролежавший офлайн дольше, курсором не догонится и должен перечитать состояние запросом.
Чат комнат
Чат предматчевой комнаты и чат драфта ходят по своим топикам (encounter:{id}:chat, draft:{session_id}:chat) событиями chat.message, chat.message_deleted, chat.muted, chat.unmuted, chat.visibility_changed. Все они эфемерные: история хранится в таблице сообщений и читается обычными запросами GET /api/v1/encounters/{encounter_id}/chat и GET /api/v1/balancer/draft/sessions/{session_id}/chat с параметром after_id. Отправка, удаление, муты и настройки — тоже HTTP, не сокет; сокет только доставляет.
Доступ: капитан любой из сторон и штаб воркспейса-организатора проходят всегда, суперпользователь тоже. Остальные — зрители, и их пускают, только пока у комнаты включено чтение для зрителей. По умолчанию у комнаты драфта оно включено, у предматчевой комнаты выключено. Поверх этого всё равно действует правило скрытого турнира: открытый чат не делает скрытый турнир видимым.
Когда организатор выключает чтение для зрителей, шлюз не ждёт переподписки: он перепроверяет права всех живых подписчиков комнаты, снимает подписку у тех, кто больше не проходит, и шлёт им error с кодом forbidden. Клиент должен обрабатывать отзыв подписки в любой момент, а не только в ответ на subscribe.
Публикация с клиента
publish существует ради одного сценария — живого перетаскивания игроков в балансировщике. Кадр принимается, только если выполнено всё сразу: соединение аутентифицировано, клиент уже подписан на этот топик, а event_type равен balancer.drag. Сервер сам проставляет actor_user_id, оставляет event_id нулевым и рассылает кадр остальным подписчикам, исключая отправителя. Иначе приходит error: forbidden, not_subscribed или forbidden_event.
Присутствие
Топики, которые заканчиваются на :balancer и :draft, рассылают присутствие: при каждой подписке и отписке все их подписчики получают эфемерное событие balancer.presence или draft.presence с полями user_ids (отсортированные идентификаторы авторизованных) и anonymous_viewer_count.
Ошибки
code | Причина |
|---|---|
invalid_json | Кадр не разбирается как JSON |
invalid_frame | Неизвестный op или не хватает полей для этого op |
frame_too_large | Кадр больше допустимого размера |
too_many_topics | Исчерпан лимит подписок на соединение |
auth_required | Отказ анонимному клиенту: вход может дать доступ |
forbidden | Отказ авторизованному клиенту, или подписка отозвана |
not_subscribed | publish в топик без подписки |
forbidden_event | Такой event_type клиентам публиковать нельзя |
replay_gap_too_large | Пропущено больше событий, чем можно доиграть |
internal_error | Сбой проверки прав или чтения журнала |
Ошибка — это отказ одного кадра, а не разрыв соединения: сокет остаётся открытым, остальные подписки продолжают работать.
Лимиты
Проверяются шлюзом; помеченные «по умолчанию» настраиваются при развёртывании.
| Лимит | Значение |
|---|---|
| Размер входящего кадра | 8192 байта (кадр сверх этого отвергается с frame_too_large) |
| Длина имени топика | 255 символов |
Длина event_type в publish | 1–64 символа |
Полей верхнего уровня в data при publish | 32 |
Частота publish | 60 кадров в секунду на соединение, лишнее молча отбрасывается |
| Подписок на соединение | 24 анонимно, 128 авторизованно (по умолчанию) |
| Анонимных соединений с одного IP | 64 (по умолчанию); сверх лимита рукопожатие отвечает 429 |
| Простой соединения | 60 секунд без входящего кадра (по умолчанию) — сервер закрывает сокет |
| Глубина доигрывания | 500 событий (по умолчанию) |
Переподключение
Из-за лимита простоя молчащий клиент будет отключён, поэтому сокет нужно поддерживать самому. Референсный клиент сайта шлёт ping каждые 25 секунд, ждёт pong не дольше 10 секунд и иначе закрывает сокет сам, а переподключается с экспоненциальной паузой от 1 до 30 секунд.
Порядок восстановления после обрыва: открыть сокет заново, подписаться на те же топики, передав в каждом after_event_id последний обработанный event_id, и быть готовым к replay_gap_too_large — тогда перечитать состояние запросом и подписаться уже без курсора. Сокет закроется и при истечении срока сессионного токена, так что переподключение — штатный путь, а не только реакция на сетевой сбой.
См. также
- Аутентификация и ключи API — сессии, ключи, скоупы.
- HTTP API — версии, формат ошибок, кэширование ответов.
- Воркспейсы и права — членство, от которого зависят топики воркспейса.
- Балансировщик, драфт и миксы — что происходит за событиями драфта и задач.