Corp/docs/guides/WEBSOCKET.md
lizerk 14f1e52425
Some checks failed
Deploy production / deploy (push) Failing after 4s
fix(messages): make HTTP authoritative for chat reads
2026-09-10 11:41:59 +03:00

14 KiB
Raw Blame History

WebSocket и realtime

EUSRR использует одно авторизованное WebSocket-соединение на вкладку приложения:

POST /api/auth/ws-ticket/ → ws://localhost:8000/ws/?ticket=ONE_TIME_TICKET

Отдельных /ws/chat/, /ws/chats/, /ws/notifications/ и /ws/realtime/ в текущем routing нет.

Источники истины

  • ASGI composition: backend/eusrr_backend/asgi.py
  • JWT middleware: backend/eusrr_backend/channels_jwt.py
  • routing: backend/realtime/routing.py
  • user consumer: backend/realtime/consumers.py
  • chat protocol: backend/communications/consumers.py
  • machine-readable contract: backend/docs/architecture/websocket-asyncapi.yaml
  • singleton frontend connection: frontend/src/lib/websocketManager.ts
  • chat adapter поверх singleton: frontend/src/hooks/useWebSocket.ts
  • calendar realtime publisher: backend/scheduling/realtime.py
  • commerce realtime publisher: backend/commerce/realtime.py
  • tests: backend/tests/realtime/

AsyncAPI доступен через /api/ws/schema/ и /api/ws/docs/. При расхождении с этим guide ориентируйтесь на consumer и AsyncAPI schema.

Запуск

cd backend
daphne -b 0.0.0.0 -p 8000 eusrr_backend.asgi:application

Для Redis-backed channel layer должны быть доступны REDIS_HOST и REDIS_PORT. Docker image проекта также запускает Daphne на порту 8000.

Frontend:

NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
NEXT_PUBLIC_WS_URL=ws://localhost:8000/ws/

NEXT_PUBLIC_WS_URL — полный endpoint. Если переменная отсутствует, URL строится из NEXT_PUBLIC_BACKEND_URL с заменой http → ws или https → wss и добавлением /ws/.

Аутентификация

Browser сначала выполняет авторизованный POST /api/auth/ws-ticket/, затем передаёт полученный одноразовый билет как ?ticket=.... Билет действует 30 секунд, подписан сервером и может быть использован только один раз. Поэтому долгоживущий JWT не попадает в URL WebSocket handshake.

Non-browser clients могут передать Authorization: Bearer <access_token>. Legacy ?token=<access_token> по умолчанию отключён; временно включить его можно только явной настройкой WEBSOCKET_ALLOW_QUERY_JWT=true.

ASGI проверяет Origin через AllowedHostsOriginValidator. Если frontend и WebSocket работают на разных host, разрешённые frontend origins задаются явно через WEBSOCKET_ALLOWED_ORIGINS=https://app.example.com. JWT и ticket также проверяют связанную UserAuthSession; отзыв сессии немедленно закрывает принадлежащее ей соединение кодом 4401.

Подписки consumer-а

После connect UserConsumer:

  • подписывается только на доступные активные chat_{id};
  • подписывается на user_{user_id};
  • подписывается на notifications_{user_id};
  • подписывается на общий read-only канал статусов commerce-интеграций;
  • запускает server ping каждые 20 секунд.

Изменение ChatMembership синхронизируется с уже открытыми соединениями: при добавлении consumer входит в группу, при отзыве доступа немедленно выходит из неё и закрывает активный чат.

active_chat_id определяет, получает соединение полное chat event или только обновление списка. open_chat регистрирует только presence и не меняет read state. Курсор сообщения и связанные notifications отмечаются прочитанными одной HTTP-операцией при загрузке видимой истории или через POST /chats/{id}/mark-read/; результат рассылается по WebSocket другим вкладкам. Presence дополнительно публикуется в ActiveChatSession и продлевается heartbeat, чтобы подавлять нативный Web Push для уже открытого чата. Событие состояния notification по WebSocket доставляется во все сессии и само по себе не означает прочтение.

Команды клиента

action Обязательные данные Назначение
open_chat chat_id Активировать чат; optional load_history
close_chat chat_id Закрыть активный чат
send_message content или text; optional mentioned_user_ids Отправить текст в активный чат
edit_message message_id, content; optional mentioned_user_ids Изменить своё сообщение
delete_message message_id Soft-delete своего сообщения
add_reaction message_id, emoji Добавить реакцию
remove_reaction message_id, emoji Удалить реакцию
typing — Начать typing для активного чата
stop_typing — Остановить typing
vote_poll poll_id, option_ids Изменить голос в опросе активного чата
mark_read chat_id Deprecated; используйте HTTP mark-read action
calendar_subscribe subscription_id, start, end Подписаться на snapshot событий диапазона; optional calendar_id, scope
calendar_unsubscribe subscription_id Закрыть подписку диапазона календаря
pong optional timestamp Ответ на server heartbeat

Пример:

{"action":"open_chat","chat_id":42,"load_history":false}

mentioned_user_ids — массив максимум из 50 ID, выбранных autocomplete-ом. Consumer проверяет, что каждый пользователь имеет доступ к активному чату, и связывает ID только с реально присутствующим в тексте @Полное имя. События с сериализованным сообщением возвращают поле mentions в виде массива {user_id, name, start, end}. Если поле не передано при редактировании старым клиентом, существующие упоминания сохраняются и их диапазоны пересчитываются по новому тексту.

События сервера

Основные публичные type:

  • connection: ping, error;
  • access lifecycle: chat_access_changed;
  • chat lifecycle: chat_opened, chat_closed, initial_messages;
  • messages: new_message, list_update, message_edited, message_deleted;
  • reactions: reaction_added, reaction_removed;
  • typing: typing_start, typing_stop;
  • polls/read receipts: poll_update, marked_read;
  • notifications: notification, unread_count, notification_read, notifications_read_all;
  • domain events: procurement_update, task_board_update, calendar_update, commerce_connection_updated;
  • calendar stream: calendar_snapshot, calendar_error.

commerce_connection_updated содержит безопасный срез состояния подключения и последнего запуска синхронизации без credentials. Экран управления применяет его к локальному состоянию вместо опроса /api/v1/commerce/connections/ каждые 2,5 секунды. При недоступном WebSocket остаётся REST fallback раз в 30 секунд; при живом соединении периодический запрос не выполняется.

calendar_subscribe открывает независимую подписку на диапазон до 400 дней. Одно соединение поддерживает до восьми подписок, поэтому большой и боковой календари могут иметь разные диапазоны. Backend проверяет доступ пользователя, разворачивает recurring occurrences и отправляет calendar_snapshot сразу после подписки и после связанных изменений. scope=all применяется только для staff/superuser.

calendar_update остаётся компактным доменным событием для синхронизации списка календарей. Сами видимые события поступают в calendar_snapshot; REST-загрузка используется frontend только как fallback, если WebSocket недоступен. Создание, изменение, удаление, импорт и экспорт календаря остаются HTTP-командами.

Channel-layer handler names вроде chat_message, notification_message и notification_count_update — внутренний Python protocol. Frontend должен обрабатывать JSON type, отправленный consumer-ом, а не имена внутренних handlers.

Frontend lifecycle

websocketManager поддерживает один shared connection, пока есть message/status subscribers. Он:

  • подключается только при наличии auth-сессии и получает новый одноразовый ticket на каждую попытку;
  • отключается после очистки auth-сессии;
  • синхронизирует active chat после reconnect;
  • отвечает pong, контролирует heartbeat и закрывает зависшее соединение;
  • использует exponential backoff с jitter, после пяти быстрых попыток продолжает восстановление раз в минуту;
  • немедленно повторяет подключение после browser online и возвращения вкладки;
  • закрывает socket, когда исчезает последний subscriber.

Не создавайте WebSocket напрямую в page/component. Chat-specific hook подписывается на shared manager и не владеет отдельным соединением.

После reconnect frontend повторяет активные календарные подписки. Если snapshot не приходит за три секунды или исчерпаны попытки reconnect, выполняется HTTP fallback для текущего диапазона. Чаты синхронизируют сообщения после локального watermark, список чатов перечитывается, а задачи и список календарей выполняют контрольную REST-синхронизацию. Поэтому WebSocket используется как сигнал ускорения, а не как единственный источник истины.

Изменение протокола

  1. Добавьте handler/action в consumer.
  2. Проверьте permission и принадлежность объекта пользователю.
  3. Определите channel group и поведение для active/inactive screen.
  4. Обновите websocket-asyncapi.yaml.
  5. Обновите frontend type guard/handler.
  6. Добавьте consumer test и, при необходимости, multi-session test.
  7. Выполните OpenAPI/AsyncAPI documentation tests.

Проверка

cd backend
pytest tests/realtime tests/api/test_documentation.py -q

Ручная проверка через wscat:

TICKET=$(curl -s -X POST http://localhost:8000/api/auth/ws-ticket/ \
  -H 'Authorization: Bearer ACCESS_TOKEN' | jq -r .ticket)
wscat -c "ws://localhost:8000/ws/?ticket=${TICKET}" \
  -H 'Origin: http://localhost:3000'

После подключения server отправляет ping; client должен ответить {"action":"pong"}. При отсутствии ответа в течение трёх интервалов соединение закрывается кодом 4408.

Production proxy

Reverse proxy должен:

  • передавать /ws/ в ASGI server;
  • поддерживать HTTP/1.1 Upgrade и Connection: upgrade;
  • не буферизовать WebSocket;
  • иметь idle timeout больше 20 секунд;
  • маршрутизировать все worker-ы к общему Redis channel layer;
  • передавать корректные Host/forwarded headers.
  • не подменять и не отбрасывать допустимый Origin.

1006 — общий abnormal closure, а не самостоятельная причина. Сначала проверяйте close code/log consumer-а, JWT/session, proxy Upgrade, доступность Redis и совпадение endpoint-а.