Aller au contenu

PD-284 — Agent Developer : Module seal-sse-client

Livrable : src/seal/sse-client.ts

Résumé

Ce module implémente le client SSE (Server-Sent Events) fetch-based pour le suivi temps réel des scellements PD-284. Il couvre :

  • Connexion SSE via fetch + ReadableStream (parsing manuel text/event-stream)
  • Reconnexion avec backoff exponentiel : delay = base × factor^attempt (1s, 2s, 4s par défaut)
  • Plafond cumulé max_cumulative_delay=30s avant failover immédiat
  • Failover polling à 5s après épuisement des tentatives SSE
  • Tentative SSE parallèle à chaque cycle de polling (reprise automatique)
  • Arrêt polling immédiat dès reconnexion SSE
  • INV-284-09 : aucun badge Hors ligne déclenché par ce module (responsabilité NetInfo dans C9)

Décisions architecturales

architectural_decisions:
  - decision: "Factory function `createSSEClient()` au lieu d'une classe"
    rationale: "Encapsulation par closures  l'état interne (timers, AbortController) est inaccessible depuis l'extérieur. Plus idiomatique en React/hooks."
    alternatives_considered:
      - "Classe SSEClient  expose l'état interne via properties, plus complexe à tester avec mocks"
      - "Hook React `useSSE()`  couple le transport au lifecycle React, empêche l'utilisation depuis l'orchestrateur C7"
    trade_offs:
      - "Factory ne supporte pas le pattern `instanceof`  non nécessaire ici"
      - "Les closures capturent les références : attention au GC si le client n'est pas disconnect()"

Code source

Le fichier src/seal/sse-client.ts a été créé avec l'implémentation complète. Voici les éléments structurants :

Interfaces exportées

// Configuration du client SSE
export interface SSEClientConfig {
  readonly baseUrl: string;
  readonly getAuthToken: () => Promise<string>;
  readonly sealConfig?: Partial<SealConfig>;
  readonly buildPollingUrl: (sealId: SealId) => string;
  readonly buildSseUrl: (sealId: SealId) => string;
}

// Interface publique du client
export interface SSEClient {
  connect(sealId: SealId): void;
  disconnect(): void;
  readonly transportMode: TransportMode;
}

export type TransportMode = "SSE" | "POLLING" | "DISCONNECTED";
export type SSEEventCallback = (data: unknown) => void;
export type SSETransportChangeCallback = (mode: TransportMode) => void;

Factory

export function createSSEClient(
  config: SSEClientConfig,
  onEvent: SSEEventCallback,
  onTransportChange?: SSETransportChangeCallback,
): SSEClient;

Mécanismes clés

  1. Parsing SSE manuel : readSSEStream() décode le flux text/event-stream ligne par ligne, accumule les lignes data:, et émet un événement JSON à chaque ligne vide (fin de bloc SSE). Les lignes de commentaire (:) sont ignorées (heartbeats).

  2. Backoff exponentiel (reconnectWithBackoff) :

  3. Boucle de 0 à max_attempts - 1
  4. delay = base × factor^attempt → 1s, 2s, 4s avec les defaults
  5. Accumulation de cumulativeDelay — si > max_cumulative_delay → failover immédiat sans attendre les tentatives restantes
  6. Chaque tentative crée un nouvel AbortController pour annulation propre

  7. Failover polling (startPolling) :

  8. setInterval à polling_interval_after_sse_failover × 1000 ms
  9. Chaque tick : pollOnce() (GET status) + attemptSSEReconnectFromPolling() en parallèle
  10. Si SSE reconnecte → stopPolling() immédiat + setTransport("SSE")

  11. Lifecycle :

  12. connect() est idempotent — disconnect l'instance précédente
  13. disconnect() abort toutes les connexions (AbortController), stop le polling, clear les timers de retry
  14. Le flag disconnected est vérifié à chaque point de reprise async pour éviter les effets de bord post-disconnect

Matrice de couverture invariants

Invariant contract Mécanisme dans ce module Vérifié par
Backoff exponentiel : delay = base × factor^attempt (base=1s, factor=2, max_attempts=3) reconnectWithBackoff() — boucle avec baseDelay * Math.pow(factor, attempt) Test unitaire : mesure des délais entre tentatives (TC-NOM-08)
Failover polling à 5s après max_attempts échecs SSE consécutifs startPolling() appelé après échec de reconnectWithBackoff() Test intégration : journal backoff→polling (TC-NOM-08)
Plafond cumulé max_cumulative_delay=30s avant failover immédiat Guard cumulativeDelay > maxCumulative dans boucle backoff Test unitaire : failover avant max_attempts si cumulé dépasse 30s
Tentative SSE parallèle à chaque cycle polling attemptSSEReconnectFromPolling() appelé à chaque tick setInterval Test intégration : SSE attempt observable à chaque cycle (TC-NOM-09)
INV-284-09 : Échec SSE sans perte réseau NE déclenche PAS le badge Hors ligne Module ne modifie aucun état réseau/offline — responsabilité hook NetInfo dans C9 Review code : aucune référence à navigator.onLine ou NetInfo
Arrêt polling immédiat dès que SSE reconnecte stopPolling() dans attemptSSEReconnectFromPolling() dès response.ok Test intégration : polling arrêté après reconnexion SSE (TC-NOM-09)

Vérification des interdits (forbidden)

Interdit Conformité
Déclencher le badge Hors ligne sur échec SSE seul Le module n'a aucune dépendance sur NetInfo ni aucun mécanisme de badge. Le transport change silencieusement de SSE à POLLING via le callback onTransportChange.
Utiliser un délai fixe entre tentatives de reconnexion delay = baseDelay * Math.pow(factor, attempt) — exponentiel garanti.
Continuer le polling après reconnexion SSE réussie stopPolling() appelé dans attemptSSEReconnectFromPolling() avant le switch transport SSE.
Bloquer l'UI pendant la reconnexion Toutes les opérations sont async. connect() est non-bloquant (fire-and-forget via connectInternal()). Aucun await synchrone visible côté appelant.
Utiliser une lib EventSource tierce (sauf clause CE-SSE-01) Implémentation fetch-based pure. Aucune dépendance externe. Si HT-03 invalidée, un wrapper conforme à SSEClient peut être substitué.

Hypothèses

ID Hypothèse Impact si faux Mitigation dans le code
HT-03 Expo SDK 54 supporte fetch streaming (response.body.getReader()) SSE impossible nativement La factory accepte n'importe quelle implémentation SSEClient — un wrapper react-native-sse peut être substitué (clause CE-SSE-01)
HT-08 SSE heartbeat TTL = 30s Reconnexion trop tardive si heartbeat plus fréquent Les paramètres sont configurables via SealConfig

Fichiers hors périmètre identifiés

Aucun. Le module seal-sse-client ne dépend que de src/types/seal.ts (C1) et src/seal/telemetry.ts (C13), tous deux dans le périmètre autorisé.

Tests contractuels couverts

Test ID Point d'observation dans ce module
TC-NOM-08 reconnectWithBackoff() : backoff 1s/2s/4s → startPolling() → pas de badge offline
TC-NOM-09 attemptSSEReconnectFromPolling() : SSE reconnecte → stopPolling()
TC-NOM-13 (proxy) Le module émet les événements via onEvent() sans traitement bloquant — latence ajoutée ≈ 0
TC-NR-03 Régression failover : backoff → polling → retour SSE — séquence complète testable
TC-NEG-09 Échec SSE sans perte réseau → transport passe à POLLING, pas de badge offline