ADR-03 — Realtime via Server-Sent Events
SSE natif Elysia pour l'unlock check-in et les chats — pas de WebSocket, pas de service tiers
Statut : Accepté Date : 2026-04 Sujet : Mécanisme de push serveur → client
Contexte
Deux cas d'usage realtime dans Bell :
- Unlock du check-in — le staff confirme l'arrivée physique du guest dans le cardex → la PWA du guest doit débloquer l'app immédiatement
- Chats — guest ↔ AI concierge et guest ↔ staff : quand le staff répond dans le dashboard, le guest doit voir le message sans refresh
Dans l'ancien projet, on polling toutes les 3 secondes sur getMyCheckInStatus et toutes les 5 secondes sur la conversation active. Ça marchait mais :
- Latence médiane de 1.5 seconde pour l'unlock (ressenti comme "lent" en démo)
- Gaspillage de requêtes (un guest sur la waiting page = 20 requêtes/min pour rien la plupart du temps)
- Accumulation si plusieurs tabs PWA ouvertes
- Impossible de scale à 1000 guests actifs sans DDoS propre serveur
Alternatives considérées
Option 1 — Polling (ancien projet)
Status quo.
Contre : voir ci-dessus. Latence + gaspillage + scalabilité.
Option 2 — WebSocket full-duplex
Protocol WS natif. Elysia supporte via elysia-websocket.
Pour :
- Bidirectionnel (utile si on voulait du typing indicator côté client)
- Maintient une connexion unique
Contre :
- Overkill pour nos besoins : on a uniquement besoin de server → client (notifier le client d'un event)
- Gestion de la reconnexion complexe côté client
- Load balancers / reverse proxies nécessitent une config spéciale (sticky sessions)
- Plus coûteux à héberger que HTTP classique
- Outillage de debug plus lourd (Chrome DevTools est moins bien pour WS que pour fetch/SSE)
Option 3 — Service tiers (Ably, Pusher, Soketi)
Déléguer à un SaaS ou un service self-host.
Pour :
- Presence, channels, auth token gérés out-of-box
- Scalabilité "infinite"
Contre :
- Dépendance externe supplémentaire dans un projet qui veut maîtriser son infra
- Coût ($ou infra Soketi self-host à gérer)
- Latence additionnelle (hop via le service tiers)
- Pas justifié pour nos volumes (max ~500 guests actifs simultanés par hôtel)
Option 4 — Server-Sent Events via Elysia (retenu)
Elysia supporte nativement SSE via le Response avec Content-Type: text/event-stream — ou via le plugin @elysiajs/stream. EventSource côté navigateur.
Pour :
- Unidirectionnel serveur → client = exactement notre besoin
- Construit sur HTTP/HTTPS standard — pas de config reverse proxy spéciale
- Reconnexion automatique native du navigateur (EventSource gère ça)
- Supporte HTTP/2 → multiplexing gratuit
- Debug trivial dans Chrome DevTools (onglet Network)
- Eden Treaty expose directement le stream typé
Contre :
- Unidirectionnel — mais c'est exactement ce qu'on veut
- Limite de 6 connexions par origin sur HTTP/1.1 (non-problème en HTTP/2 dont Elysia supporte)
Décision
SSE via Elysia, exposé en Eden Treaty, avec fallback polling 10s si l'EventSource drop 3 fois de suite.
Côté serveur
// packages/api/src/modules/check-in/check-in.routes.ts
import { Elysia, t } from "elysia";
export const checkInStream = new Elysia()
.get("/check-in/stream", async function* ({ user, request }) {
yield sse({ event: "ready", data: { status: "connected" } });
for await (const update of subscribeToGuestStatus(user.guestId)) {
yield sse({ event: "status", data: update });
}
}, {
beforeHandle: [requireAuth],
});subscribeToGuestStatus utilise une pub/sub Redis (qu'on a déjà pour BullMQ — cf. ADR-04). Quand confirmGuestArrival mute la DB, elle publie aussi sur le channel guest:{id}:status ; le stream SSE écoute et push au client.
Côté client (PWA)
// apps/pwa/src/features/check-in/use-check-in-status.ts
export function useCheckInStatus() {
const [status, setStatus] = useState<CheckInStatus | null>(null);
useEffect(() => {
const es = new EventSource("/api/eden/check-in/stream");
es.addEventListener("status", (e) => setStatus(JSON.parse(e.data)));
es.addEventListener("error", () => {
// Fallback polling if SSE drops repeatedly
});
return () => es.close();
}, []);
return status;
}Fallback polling
Si EventSource.readyState === CLOSED pendant plus de 30s, on bascule sur un polling eden.checkIn.status.get() toutes les 10s. C'est l'assurance pour les réseaux mobile / firewalls qui bloquent SSE.
Cas d'usage couverts
| Event | Channel pub/sub | Destinataire |
|---|---|---|
| Staff confirme arrivée guest | guest:{guestId}:status | PWA guest (unlock) |
| Staff répond dans un chat | conversation:{id}:message | PWA guest |
| Nouvelle demande guest dans le chat | organization:{orgId}:chat:new | Dashboard staff (liste des chats) |
| Status d'une commande change (préparation → livré) | guest:{guestId}:orders | PWA guest (drawer bookings) |
Conséquences
Positives :
- Latence < 200ms pour l'unlock au lieu de ~1500ms en polling
- Charge serveur divisée (un guest idle = 0 requête au lieu de 20/min)
- Pas de dépendance externe, tout reste chez nous
- Debug facile dans DevTools
Négatives :
- Dépendance Redis (on l'a déjà pour BullMQ, zéro coût additionnel)
- Test des flows SSE en E2E nécessite un setup Playwright pour consommer l'EventSource
- Si le navigateur met l'onglet en background, le navigateur peut throttle (acceptable — la mise à jour arrivera au retour en foreground)
Métriques à surveiller
- Nombre de connexions SSE simultanées (par hôtel et total)
- Latence entre publication Redis et réception client (cible : < 500ms p95)
- Taux de reconnexion SSE (cible : < 5 % des sessions, signale un problème réseau)
- Taux de bascule vers le fallback polling (cible : < 1 %)