Une seule connexion WebSocket par appareil : WS /ws, servie par l'API (pas de service séparé). Elle ne fait que
recevoir. Toutes les commandes passent en HTTP. Rien n'est gardé en mémoire à part la liste des sockets : une
coupure ne perd rien, l'app relit l'état en HTTP.
requête HTTP (worker A) Redis worker B
─────────────────────── ───── ────────
commit en base
realtime.notify([uid], payload) ──PUBLISH rt:user:<uid>──► ──pmessage──► relay()
sockets[uid] → ws.send_text
(chaque worker est abonné à rt:user:* et filtre ses sockets)
routers/realtime.py, socket)Authorization: Bearer …, sinon ?token= dans l'URL. L'app utilise ?token=.decode_token (même JWT que l'API, voir Comptes). Puis db.get(User, id).deleted_at non NULL, comme deps.user_from_header) :ws.close(code=4401) avant accept. Uvicorn refuse alors la poignée de main (HTTP 403) : le client ne voitaccept, puis realtime.connect(user_id, ws).receive_text() : on lit seulement pour détecter la fermeture. Ce que le client envoie est ignoré.realtime.disconnect.Le jeton n'est vérifié qu'à l'ouverture. Une connexion ouverte reste valable après expiration du JWT.
services/realtime.py)sockets: dict[user_id, set[WebSocket]], propre à chaque worker.disconnect retire la socket et supprime l'entrée vide.HEARTBEAT_S = 25 : le serveur envoie {"type": "ping"} toutes les 25 s, pour garder la connexion à travers lesping.notify(user_ids, payload) fait un PUBLISH rt:user:<id> par destinataire (JSON). Toujours par Redis, mêmerelay(), une tâche de fond par worker lancée dans main.py (lifespan). PSUBSCRIBE rt:user:*, puispmessage : envoi à toutes les sockets locales de ce joueur. Une erreur d'envoi est ignorée (la boucle deREDIS_URL (défaut redis://localhost:6379/0), client unique app/redis.py (get_redis).type |
Champs | Émis par | Destinataires | Effet dans l'app |
|---|---|---|---|---|
ping |
— | heartbeat | la socket | ignoré |
conversation |
conversation_id |
realtime.conversation_changed : nouveau message (/me/conversations/…), partie créée, statut d'un direct qui change, clôture d'un différé par sweep |
membres actuels de la conversation | Session.refreshInbox() ; la conversation ouverte se recharge (_onNews) |
match |
match_id, state (LiveStateOut, vu par le destinataire) |
live.broadcast : join, start, live/answers, chaque transition de live_loop |
membres actuels de la conversation | l'écran du direct applique l'état ; la conversation ouverte met la carte à jour |
Détail des émissions par les parties : Parties.
app/lib/api/realtime.dart, classe Realtime)AppConfig.baseUri en ws/wss, chemin /ws, ?token=<JWT>.start() : à la connexion (Session.didAuthenticate), au lancement si un jeton existe, et au retour au premier planmain.dart, AppLifecycleState.resumed).stop() : à la déconnexion et au passage en arrière-plan (AppLifecycleState.paused).connected (ValueNotifier<bool>) dit aux écrans s'ils doivent relever en HTTP.min(2^(échecs−1), 15) s → 1, 2, 4, 8, 15, 15… Remis à zéro à la| Écran | Relève |
|---|---|
Boîte (main.dart, _ShellState) |
toutes les 45 s si !connected, et au retour au premier plan |
Conversation ouverte (conversation_screen.dart) |
toutes les 10 s si !connected |
Direct (live_match_screen.dart) |
GET /matches/{id}/live chaque seconde si !connected ; sinon seulement si aucun état n'arrive 2 s après l'échéance |
Le chrono du direct tourne en local (remaining_ms reçu + horloge du téléphone), rafraîchi toutes les 100 ms.
Code : back/app/routers/realtime.py (socket), back/app/services/realtime.py (notify, relay, conversation_changed), back/app/main.py (lifespan), app/lib/api/realtime.dart, app/lib/session.dart
?token= peut finir dans les journaux d'accès (uvicorn, proxy). Le code évite pourtant ce cas/me/devices/forget les prend dans le corps).relay() n'a pas de boucle de reprise. Si la connexion pub/sub à Redis tombe, lalive_loop et sweep_loop, eux, journalisent etPSUBSCRIBE rt:user:* fait recevoir chaque message par chaque worker,/ws. Elle retente toutes les 15 s,conversation.match ; la fin d'une partie par la dernière réponse n'émet rien (voir