Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Flux d'événements serveur (SSE) — pousser au navigateur sur une réponse qui ne finit pas

stable@nodefony/httpmis à jour 2026-10-08

📍 Documentation › @nodefony/http › Flux SSE

Flux d’événements serveur (SSE) — pousser au navigateur sur une réponse qui ne finit pas

Une radio plutôt qu’un téléphone : le serveur parle, le navigateur écoute, et la conversation ne va que dans un sens. Une action ouvre une réponse HTTP qu’elle ne referme pas, et y écrit des événements au fil de l’eau — la progression d’une tâche, la réponse d’un modèle de langage qui s’écrit mot à mot, une notification. Le même code sert HTTP/1.1 et HTTP/2. Chaque fait ci-dessous est ancré sur le code.

🧠 Le modèle mental — un FORMAT de réponse, pas une méthode#

SSE n’est pas une route d’un genre particulier : c’est ce que l’action décide de répondre. La route reste un GET ou un POST ordinaire ; à l’exécution, l’action appelle this.renderSse() au lieu de rendre un objet, et la réponse devient un flux text/event-stream. Un même POST peut donc répondre en JSON à un client classique et en flux à un client qui le demande par Accept — c’est ce que fait une porte MCP.

📖 Lexique#

Qu’est-ce qu’un flux SSE, et quand le préférer au WebSocket ?#

Besoin Choisir
Le serveur pousse, le client écoute (progression, journal) SSE
La réponse d’une requête arrive par morceaux (modèle, export) SSE sur le POST de la requête
Un réseau d’entreprise bloque WebSocket SSE — c’est une réponse HTTP ordinaire
Échange dans les deux sens, canaux, temps réel partagé WebSocket (NodefonySocket)

ℹ️ Note

En HTTP/1.1, un navigateur n’ouvre qu’environ six connexions par origine : six onglets chacun abonné à un flux, et les requêtes suivantes attendent leur tour (WHATWG §9.2.7). En HTTP/2, un flux SSE n’est qu’un flux du multiplexage de la connexion.

Ce plafond vaut pour l’origine que voit le navigateur. Derrière nginx, HAProxy ou un ingress qui termine le TLS, cette origine est le proxy : c’est lui qui doit servir HTTP/2, et proxy:generate le pose déjà (http2 on, generateProxyConfig.ts#nginxServerBlock). Nodefony reçoit alors du HTTP/1.1 (proxy_http_version 1.1, generateProxyConfig.ts#nginxServerBlock) — y activer HTTP/2 n’y change rien. Sans proxy, c’est Nodefony qui sert HTTP/2.

De chaque côté du proxy, un flux est une connexion qui se tait entre deux événements. Le battement (SSE_HEARTBEAT_MS, 15 s, SseStream.ts#SSE_HEARTBEAT_MS) la garde vivante ; le délai d’inactivité généré (300 s au moins) en tient vingt. Le seul piège réel : une action qui éteint le battement (heartbeat: false) et se tait plus longtemps que ce délai se fait couper par le proxy. Le guide reverse-proxy détaille le reste.

🚀 Démarrage rapide#

Dans une application générée par nodefony create app, une action pousse trois étapes puis ferme.

1. L’action#

// modules/app/nodefony/controller/ProgressController.ts
import { Controller, controller, route } from "@nodefony/framework";
import type { Context } from "@nodefony/http";

@controller("/progress")
class ProgressController extends Controller {
  constructor(context: Context) {
    super("ProgressController", context);
  }

  @route("progress-run", { path: "/run" })
  async run() {
    const sse = await this.renderSse();
    for (const step of ["lecture", "calcul", "écriture"]) {
      await sse.send({ step }, { event: "step" });
    }
    return sse.close();
  }
}

export default ProgressController;

2. Le client#

// frontend — la classe se lit comme EventSource
import { NodefonySse } from "nodefony/client";

const sse = new NodefonySse("/progress/run");
sse.addEventListener("step", (e) => {
  console.log(JSON.parse((e as MessageEvent<string>).data));
});
sse.addEventListener("error", () => sse.close()); // fin du flux : ne pas reconnecter

3. Observer#

curl -sN -H 'Accept: text/event-stream' http://127.0.0.1:5151/progress/run
event: step
data: {"step":"lecture"}

event: step
data: {"step":"calcul"}

event: step
data: {"step":"écriture"}

⚙️ Les deux bouts de l’API#

Côté serveur — this.renderSse() et l’objet SseStream#

Membre Rôle
this.renderSse(options?) ouvre le flux (Controller.renderSse(), Controller.ts#Controller.renderSse) ; lève sur WebSocket
this.acceptsSse() le client a-t-il demandé text/event-stream ? (Controller.acceptsSse(), Controller.ts#Controller.acceptsSse)
sse.send(data, { event, id }) un événement ; texte tel quel, objet en JSON (SseStream.send(), SseStream.ts#SseStream.send)
sse.comment(text) un commentaire, ignoré par le client
sse.retry(ms) impose le délai de reconnexion du client
sse.onClose(fn) appelé à la fermeture, quel que soit le bout qui ferme (SseStream.onClose(), SseStream.ts#SseStream.onClose)
sse.close() termine la requête, idempotent (SseStream.close(), SseStream.ts#SseStream.close)
sse.closed le flux est-il fermé — à tester dans une boucle longue

Options de renderSse() :

Option Rôle Défaut
heartbeat période du battement (commentaire :), ms ; false l’éteint 15 000 (SSE_HEARTBEAT_MS)
retry délai retry: envoyé à l’ouverture aucun
stallTimeout délai maximal d’une écriture bloquée (le client ne lit plus) avant coupure ; false 30 000 (SSE_STALL_TIMEOUT_MS)
maxEventBytes taille maximale d’un événement, octets UTF-8 ; au-delà send() lève une RangeError 1 048 576 (SSE_MAX_EVENT_BYTES)
maxDuration durée de vie du flux, ms ; il est fermé ensuite et EventSource se reconnecte false — pas de borne

Un délai hors de 1 à 2^31-1 ms est refusé à l’ouverture (RangeError) : au-delà, Node ramène un minuteur à 1 ms.

Même route, deux réponses — la négociation#

@route("tasks-run", { path: "/tasks", requirements: { methods: ["POST"] } })
async runTask() {
  if (!this.acceptsSse()) return { status: "accepted" }; // JSON
  const sse = await this.renderSse();
  await sse.send({ status: "running" });
  return sse.close();
}

acceptsEventStream() (SseStream.ts#acceptsEventStream) ne retient text/event-stream que nommé avec un poids non nul : */* ne suffit pas — un client qui ne nomme pas le flux ne sait pas le lire —, et q=0 est un refus (RFC 9110 §12.4.2).

Côté client — NodefonySse#

NodefonySse (NodefonySse.ts#NodefonySse) reprend le vocabulaire d’EventSource — onopen, onmessage, onerror, addEventListener(type), readyState, lastEventId, close(), reconnexion avec Last-Event-ID — et ajoute ce que le natif refuse : method, headers (un Authorization: Bearer), body. Il est bâti sur fetch, donc tourne aussi sous Node, où EventSource reste expérimental.

Réponse du serveur Ce que fait le client
200 text/event-stream OPEN, événements
fin du flux, coupure réseau error, puis reconnexion après le délai retry:, plus une gigue jusqu’à sa moitié
aucune réponse dans connectTimeout (10 s) error, puis reconnexion — jamais figé en CONNECTING
autre statut (204 compris), autre type error, CLOSED, sans reconnexion (WHATWG §9.2.3)
événement au-delà de maxEventSize (4 Mi) CLOSED sans reconnexion

L’analyseur du format est SseParser (SseParser.ts#SseParser), exporté par nodefony/client : c’est le même qui relit le flux dans les tests du serveur.

🏗️ Architecture interne — ce que fait l’ouverture#

  1. La réponse est prise (context.sended) : plus rien ne peut répondre à la place du flux.
  2. La session est sauvée s’il y en a une — ses cookies partent avec les en-têtes.
  3. Le transport se constate : flux HTTP/2, ou réponse HTTP/1.1 ; ni l’un ni l’autre → 500, avant d’avoir rien écrit (startSseStream(), SseStream.ts#startSseStream).
  4. Les en-têtes partent — respond() en HTTP/2, flushHeaders() en HTTP/1.1 — et le délai d’inactivité de la requête est désarmé : sans cela, le flux vivant prendrait un 408 au bout de 30 s.
  5. Le battement démarre sur un minuteur unref().

La fin, quel que soit le bout qui ferme, passe par un seul nettoyage (SseStream.ts#SseStream.send) : minuteur arrêté, écouteurs close et drain retirés, attente de drain libérée, puis les écouteurs onClose.

🔐 Sécurité#

⚠️ Pièges (symptôme → cause → correction)#

Symptôme Cause Correction
Le flux n’apparaît qu’en entier, à la fin un proxy (nginx) le met en tampon X-Accel-Buffering: no est posé ; vérifier un proxy_buffering on explicite
Le serveur ne voit jamais le client partir fermeture écoutée sur la REQUÊTE : en HTTP/1.1 elle a déjà émis close (corps lu, Node ≥ 16) sse.onClose() — la fermeture est lue sur la RÉPONSE
error en boucle côté client après la fin voulue une fin de flux est une coupure pour EventSource : il reconnecte close() dans onerror, ou répondre 204 pour dire « fini »
Six onglets, et le site ne répond plus plafond de connexions HTTP/1.1 du navigateur envers l’origine qu’il voit faire servir HTTP/2 par le proxy — ou par Nodefony s’il n’y a pas de proxy
Un flux muet coupé au bout de quelques minutes heartbeat: false, et un silence plus long que le délai d’inactivité du proxy garder le battement, ou allonger ce délai sur le proxy
Le flux répond 429 sans avoir rien fait wsMaxConnectionsPerIp atteint — sockets WebSocket et flux SSE de l’IP comptent ensemble relever le plafond, ou fermer les connexions laissées ouvertes
RangeError à send(), le flux continue événement plus gros que maxEventBytes découper la donnée, ou relever maxEventBytes pour ce flux

🧪 Tests & couverture#

Type Où
Unitaires — format src/nodefony/src/tests/SseParser.test.ts — une règle de WHATWG §9.2.5-9.2.6 par cas, borne de taille
Unitaires — client src/nodefony/src/tests/NodefonySse.test.ts — serveur node:http réel : ordre, POST + Authorization, reconnexion Last-Event-ID, 204, mauvais type, flux sans fin
Unitaires — Accept unit/acceptsEventStream.test.ts
Intégration http/sse.test.ts — serveur de développement, HTTP/1.1 ET HTTP/2 : événements dans l’ordre, en-têtes, départ du client vu (et pas avant) en GET et en POST avec corps, battement, négociation, NodefonySse de bout en bout
Mémoire http/memory.test.ts — flux fermés par le serveur et flux abandonnés par le client : pente du tas, scopes et contextes résiduels

Ces suites ont été vues échouer en débranchant le câblage : fin de ligne coupée entre deux morceaux, id contenant NUL, Last-Event-ID retiré, 204 reconnecté, borne de taille ignorée, poids q=0 accepté, et fermeture écoutée sur la requête.

Ce qui manque : aucun banc de débit (événements par seconde) ; la contre-pression n’est pas éprouvée sur un client réellement lent ; un flux en HTTP/3 n’existe pas (Node ne sert pas HTTP/3).

🔗 Pour aller plus loin#