14 KiB
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 используется как сигнал ускорения, а не как единственный источник истины.
Изменение протокола
- Добавьте handler/action в consumer.
- Проверьте permission и принадлежность объекта пользователю.
- Определите channel group и поведение для active/inactive screen.
- Обновите
websocket-asyncapi.yaml. - Обновите frontend type guard/handler.
- Добавьте consumer test и, при необходимости, multi-session test.
- Выполните 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-а.