Overwatch Tournaments нужны функциональные cookie, чтобы сохранять вход и язык интерфейса. Аналитические cookie необязательны и только показывают, как используются страницы, — подробнее в политике конфиденциальности.

Перейти к содержимому

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Обязательные поляЧто делает
subscribetopic, опционально after_event_idПодписка на топик, при наличии курсора — с доигрыванием
unsubscribetopicСнимает подписку, ответа нет
ping—Проверка живости
publishtopic, event_type, dataЭфемерная рассылка другим подписчикам топика
{"op": "subscribe", "topic": "tournament:42:bracket", "after_event_id": 1180}

Кадры сервера

opПоляКогда приходит
subscribedtopic, cursorПодписка принята; cursor — последний сохранённый event_id топика
eventtopic, eventСобытие топика (живое или доигранное)
errorcode, 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; эфемерное публикуется только в шину и живёт до следующего кадра. Долговечны инвалидация, ходы драфта, бан-пик карт и героев, изменения составов и переходы задач балансировщика. Эфемерны присутствие, тики прогресса задач, чат, уведомления, статусы трансляций, миксы, разбор логов и проверки подписок — всё, что переподключившийся клиент всё равно перечитывает обычным запросом.

Доигрывание работает так:

  1. В subscribe передаётся after_event_id — последний event_id, который клиент уже обработал в этом топике.
  2. Сервер сначала присылает пропущенные event-кадры в порядке возрастания event_id, и только потом subscribed с текущим курсором.
  3. Без after_event_id подписка живая: истории не будет вовсе. Это осознанное поведение для первой подписки — иначе свежая страница получила бы весь архив топика.
  4. Если пропущено больше событий, чем разрешено доиграть (по умолчанию 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_subscribedpublish в топик без подписки
forbidden_eventТакой event_type клиентам публиковать нельзя
replay_gap_too_largeПропущено больше событий, чем можно доиграть
internal_errorСбой проверки прав или чтения журнала

Ошибка — это отказ одного кадра, а не разрыв соединения: сокет остаётся открытым, остальные подписки продолжают работать.

Лимиты

Проверяются шлюзом; помеченные «по умолчанию» настраиваются при развёртывании.

ЛимитЗначение
Размер входящего кадра8192 байта (кадр сверх этого отвергается с frame_too_large)
Длина имени топика255 символов
Длина event_type в publish1–64 символа
Полей верхнего уровня в data при publish32
Частота publish60 кадров в секунду на соединение, лишнее молча отбрасывается
Подписок на соединение24 анонимно, 128 авторизованно (по умолчанию)
Анонимных соединений с одного IP64 (по умолчанию); сверх лимита рукопожатие отвечает 429
Простой соединения60 секунд без входящего кадра (по умолчанию) — сервер закрывает сокет
Глубина доигрывания500 событий (по умолчанию)

Переподключение

Из-за лимита простоя молчащий клиент будет отключён, поэтому сокет нужно поддерживать самому. Референсный клиент сайта шлёт ping каждые 25 секунд, ждёт pong не дольше 10 секунд и иначе закрывает сокет сам, а переподключается с экспоненциальной паузой от 1 до 30 секунд.

Порядок восстановления после обрыва: открыть сокет заново, подписаться на те же топики, передав в каждом after_event_id последний обработанный event_id, и быть готовым к replay_gap_too_large — тогда перечитать состояние запросом и подписаться уже без курсора. Сокет закроется и при истечении срока сессионного токена, так что переподключение — штатный путь, а не только реакция на сетевой сбой.

См. также