My App

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 :

  1. 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
  2. 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

EventChannel pub/subDestinataire
Staff confirme arrivée guestguest:{guestId}:statusPWA guest (unlock)
Staff répond dans un chatconversation:{id}:messagePWA guest
Nouvelle demande guest dans le chatorganization:{orgId}:chat:newDashboard staff (liste des chats)
Status d'une commande change (préparation → livré)guest:{guestId}:ordersPWA 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 %)

On this page