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#
- SSE — Server-Sent Events : le format
text/event-streamnormalisé par le standard HTML (WHATWG §9.2). Il n’a pas de RFC. - Événement — un bloc de lignes
event:,id:,data:clos par une ligne vide. Last-Event-ID— l’en-tête qu’un client renvoie en se reconnectant, pour reprendre après le dernier identifiant reçu.- Battement de cœur — un commentaire (
:) envoyé à intervalle régulier : ignoré par le client, il empêche un proxy de couper une connexion qu’il croirait muette.
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:generatele 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 en-têtes partent dès
renderSse():Content-Type: text/event-stream; charset=utf-8,Cache-Control: no-cache, no-transform,X-Accel-Buffering: no(openSseStream(),SseStream.ts#openSseStream). Ce dernier demande à un proxy de type nginx de ne pas garder le flux en tampon. À travers le frontal que génèrenodefony proxy:generate nginx, chaque événement arrive à son émission — mesuré par le scénarioedgedu banc de publication. La même mesure, en-tête retiré, reste étalée sur nginx 1.30 : l’en-tête n’y est pas ce qui tient le flux, il protège les proxys qui retiennent. await sse.send()freine l’action si le client lit lentement — c’est la contre-pression.
⚙️ 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#
- La réponse est prise (
context.sended) : plus rien ne peut répondre à la place du flux. - La session est sauvée s’il y en a une — ses cookies partent avec les en-têtes.
- 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). - 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. - 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é#
- Injection de flux :
eventetidsont refusés s’ils contiennent CR, LF ou NUL (singleLine(),SseStream.ts#singleLine) — une valeur venue d’un utilisateur ne peut pas clore l’événement pour en forger un autre. Lesdatasur plusieurs lignes sont découpés en autant de champs, jamais recopiés tels quels. - Client : un serveur qui n’envoie jamais de fin de ligne ferait grossir la mémoire du client
sans limite —
maxEventSize(4 Mi par défaut) ferme la connexion. - Le flux passe par le pipeline complet : pare-feu, CSRF et
@IsGranteds’appliquent avant l’action, comme pour toute route. - Les mêmes gardes qu’une socket WebSocket — un attaquant prend le transport le plus faible :
- plafond de connexions par IP :
wsMaxConnectionsPerIpcompte les sockets WebSocket ET les flux SSE, un seul budget (sinon N sockets plus N flux doubleraient le plafond). Au-delà, le flux répond 429 avant d’écrire un octet de flux. Désactivé par défaut, comme pour le WebSocket : en cloud, le plafond vit à l’ingress. - révocation : un flux ouvert par une identité authentifiée est re-validé toutes les 30 s
(
REVOCATION_REVALIDATE_MS) par la même règle que les sockets — session détruite ou passée à un autre compte, jeton expiré ou annulé — et fermé si elle est morte. Une identité vivante ne perd jamais son flux ; un anonyme n’est jamais re-validé. - lecteur bloqué : le délai d’inactivité étant désarmé,
stallTimeoutle remplace — une écriture qui attend undrainplus de 30 s coupe la connexion. Un client inactif mais qui lit n’est jamais concerné : le minuteur ne s’arme que sous contre-pression. - taille d’un événement :
maxEventBytesrefuse un événement démesuré avant de le mettre en mémoire d’écriture ; le flux reste ouvert.
- plafond de connexions par IP :
⚠️ 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#
- ⬆️ Retour au hub : @nodefony/http — vue du module · Toute la documentation
- 🧭 Pages sœurs : Serveurs — HTTP/2, TLS, délais ; Proxy inverse.