@nodefony/http 10.0.0-beta.2 → 10.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
- package/dist/index.js +19 -11
- package/dist/nodefony/command/assetsPublishCommand.js +5 -3
- package/dist/nodefony/command/certificatesCommand.js +5 -3
- package/dist/nodefony/command/networkCommand.js +5 -3
- package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
- package/dist/nodefony/config/config.js +45 -6
- package/dist/nodefony/config/defineModuleConfig.js +2 -3
- package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +2 -3
- package/dist/nodefony/service/ProfilerAdminApi.js +1 -2
- package/dist/nodefony/service/audit-logger.js +22 -16
- package/dist/nodefony/service/certificates.js +228 -19
- package/dist/nodefony/service/error-renderer.js +30 -13
- package/dist/nodefony/service/http-kernel.js +331 -82
- package/dist/nodefony/service/pretty-request-logger.js +5 -4
- package/dist/nodefony/service/request-logger.js +24 -16
- package/dist/nodefony/service/reverse-proxy.js +336 -0
- package/dist/nodefony/service/servers/clientError.js +22 -3
- package/dist/nodefony/service/servers/server-h2c.js +126 -0
- package/dist/nodefony/service/servers/server-http.js +5 -8
- package/dist/nodefony/service/servers/server-https.js +12 -24
- package/dist/nodefony/service/servers/server-static.js +7 -10
- package/dist/nodefony/service/servers/server-websocket-secure.js +18 -18
- package/dist/nodefony/service/servers/server-websocket.js +18 -18
- package/dist/nodefony/service/servers/serverShutdown.js +78 -18
- package/dist/nodefony/service/servers/wsHeartbeat.js +32 -17
- package/dist/nodefony/service/sessions/sessions-service.js +34 -20
- package/dist/nodefony/service/trace.js +1 -2
- package/dist/nodefony/service/upload/upload-service.js +5 -8
- package/dist/nodefony/service/x509.js +1 -2
- package/dist/nodefony/src/assets/collectAssets.js +1 -2
- package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
- package/dist/nodefony/src/context/Context.js +51 -32
- package/dist/nodefony/src/context/domainMatcher.js +21 -4
- package/dist/nodefony/src/context/forwarded.js +3 -4
- package/dist/nodefony/src/context/http/HttpContext.js +98 -46
- package/dist/nodefony/src/context/http/Request.js +34 -38
- package/dist/nodefony/src/context/http/Response.js +39 -7
- package/dist/nodefony/src/context/http/SseStream.js +349 -0
- package/dist/nodefony/src/context/http/parser.js +36 -10
- package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
- package/dist/nodefony/src/context/http2/Request.js +4 -8
- package/dist/nodefony/src/context/http2/Response.js +22 -9
- package/dist/nodefony/src/context/metaData.js +1 -2
- package/dist/nodefony/src/context/requestId.js +1 -2
- package/dist/nodefony/src/context/responseEnded.js +26 -0
- package/dist/nodefony/src/context/transport.js +45 -0
- package/dist/nodefony/src/context/trustProxy.js +1 -2
- package/dist/nodefony/src/context/websocket/Response.js +42 -6
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +94 -20
- package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
- package/dist/nodefony/src/context/websocket/websocketUrl.js +20 -0
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
- package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
- package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
- package/dist/nodefony/src/cookies/cookie.js +3 -7
- package/dist/nodefony/src/errors/httpError.js +1 -2
- package/dist/nodefony/src/errors/thrownMessage.js +1 -2
- package/dist/nodefony/src/profiler/FrameProfile.js +1 -2
- package/dist/nodefony/src/profiler/Profiler.js +108 -30
- package/dist/nodefony/src/profiler/aggregate.js +422 -0
- package/dist/nodefony/src/proxy/forward.js +500 -0
- package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
- package/dist/nodefony/src/proxy/rules.js +188 -0
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
- package/dist/nodefony/src/servers/http2Limits.js +50 -0
- package/dist/nodefony/src/servers/kernelServers.js +12 -3
- package/dist/nodefony/src/servers/portBinder.js +41 -10
- package/dist/nodefony/src/servers/upgradeDispatch.js +128 -0
- package/dist/nodefony/src/session/session.js +30 -15
- package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
- package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
- package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
- package/dist/types/index.d.ts +12 -3
- package/dist/types/nodefony/config/config.d.ts +16 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +65 -4
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
- package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
- package/dist/types/nodefony/interfaces/IRouting.d.ts +26 -0
- package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
- package/dist/types/nodefony/interfaces/index.d.ts +3 -2
- package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
- package/dist/types/nodefony/service/certificates.d.ts +147 -5
- package/dist/types/nodefony/service/error-renderer.d.ts +32 -1
- package/dist/types/nodefony/service/http-kernel.d.ts +157 -15
- package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
- package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
- package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
- package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +2 -3
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +2 -3
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
- package/dist/types/nodefony/src/context/Context.d.ts +35 -6
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +48 -1
- package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
- package/dist/types/nodefony/src/context/http/Response.d.ts +14 -0
- package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
- package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
- package/dist/types/nodefony/src/context/http2/Response.d.ts +15 -5
- package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
- package/dist/types/nodefony/src/context/transport.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +57 -7
- package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
- package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +13 -12
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +69 -15
- package/dist/types/nodefony/src/profiler/aggregate.d.ts +215 -0
- package/dist/types/nodefony/src/proxy/forward.d.ts +168 -0
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
- package/dist/types/nodefony/src/proxy/rules.d.ts +88 -0
- package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
- package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
- package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +69 -0
- package/dist/types/nodefony/src/session/session.d.ts +2 -0
- package/docs/cookies.md +85 -78
- package/docs/index.md +5 -1
- package/docs/observabilite.md +81 -81
- package/docs/rate-limit.md +83 -83
- package/docs/reverse-proxy.md +522 -0
- package/docs/servers.md +378 -230
- package/docs/session.md +180 -180
- package/docs/sse.md +287 -0
- package/docs/upload.md +120 -117
- package/package.json +10 -11
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/sse.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Flux d'événements serveur (SSE) — pousser au navigateur sur une réponse qui ne finit pas"
|
|
3
|
+
navTitle: Flux SSE
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/http"
|
|
6
|
+
topic: sse
|
|
7
|
+
section: "Cœur runtime"
|
|
8
|
+
audience: [developer]
|
|
9
|
+
tags:
|
|
10
|
+
[
|
|
11
|
+
sse,
|
|
12
|
+
server-sent-events,
|
|
13
|
+
event-stream,
|
|
14
|
+
eventsource,
|
|
15
|
+
streaming,
|
|
16
|
+
http2,
|
|
17
|
+
temps-réel,
|
|
18
|
+
mcp,
|
|
19
|
+
last-event-id,
|
|
20
|
+
]
|
|
21
|
+
version: "doc"
|
|
22
|
+
status: stable
|
|
23
|
+
updated: 2026-10-08
|
|
24
|
+
source: "src/packages/@nodefony/http/docs/sse.md"
|
|
25
|
+
coverageModule: http
|
|
26
|
+
coverageFiles: context/http/SseStream.ts
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
📍 [Documentation](../../../../../docs/index.md) › [@nodefony/http](index.md) › **Flux SSE**
|
|
30
|
+
|
|
31
|
+
# Flux d'événements serveur (SSE) — pousser au navigateur sur une réponse qui ne finit pas
|
|
32
|
+
|
|
33
|
+
> Une **radio** plutôt qu'un **téléphone** : le serveur parle, le navigateur écoute, et la
|
|
34
|
+
> conversation ne va que dans un sens. Une action ouvre une réponse HTTP qu'elle ne referme pas, et y
|
|
35
|
+
> écrit des événements au fil de l'eau — la progression d'une tâche, la réponse d'un modèle de
|
|
36
|
+
> langage qui s'écrit mot à mot, une notification. Le même code sert HTTP/1.1 et HTTP/2. Chaque fait
|
|
37
|
+
> ci-dessous est ancré sur le code.
|
|
38
|
+
|
|
39
|
+
## 🧠 Le modèle mental — un FORMAT de réponse, pas une méthode
|
|
40
|
+
|
|
41
|
+
SSE n'est pas une route d'un genre particulier : c'est ce que l'action **décide de répondre**. La
|
|
42
|
+
route reste un `GET` ou un `POST` ordinaire ; à l'exécution, l'action appelle `this.renderSse()` au
|
|
43
|
+
lieu de rendre un objet, et la réponse devient un flux `text/event-stream`. Un même `POST` peut donc
|
|
44
|
+
répondre en JSON à un client classique et en flux à un client qui le demande par `Accept` — c'est
|
|
45
|
+
ce que fait une porte MCP.
|
|
46
|
+
|
|
47
|
+
## 📖 Lexique
|
|
48
|
+
|
|
49
|
+
- **SSE** — _Server-Sent Events_ : le format `text/event-stream` normalisé par le standard HTML
|
|
50
|
+
(WHATWG §9.2). Il n'a pas de RFC.
|
|
51
|
+
- **Événement** — un bloc de lignes `event:`, `id:`, `data:` clos par une ligne vide.
|
|
52
|
+
- **`Last-Event-ID`** — l'en-tête qu'un client renvoie en se reconnectant, pour reprendre après le
|
|
53
|
+
dernier identifiant reçu.
|
|
54
|
+
- **Battement de cœur** — un commentaire (`:`) envoyé à intervalle régulier : ignoré par le client,
|
|
55
|
+
il empêche un proxy de couper une connexion qu'il croirait muette.
|
|
56
|
+
|
|
57
|
+
## Qu'est-ce qu'un flux SSE, et quand le préférer au WebSocket ?
|
|
58
|
+
|
|
59
|
+
| Besoin | Choisir |
|
|
60
|
+
| ------------------------------------------------------------- | ------------------------------------------ |
|
|
61
|
+
| Le serveur pousse, le client écoute (progression, journal) | **SSE** |
|
|
62
|
+
| La réponse d'une requête arrive par morceaux (modèle, export) | **SSE** sur le `POST` de la requête |
|
|
63
|
+
| Un réseau d'entreprise bloque WebSocket | **SSE** — c'est une réponse HTTP ordinaire |
|
|
64
|
+
| Échange dans les deux sens, canaux, temps réel partagé | **WebSocket** (`NodefonySocket`) |
|
|
65
|
+
|
|
66
|
+
> [!NOTE]
|
|
67
|
+
> En HTTP/1.1, un navigateur n'ouvre qu'environ six connexions par origine : six onglets chacun
|
|
68
|
+
> abonné à un flux, et les requêtes suivantes attendent leur tour (WHATWG §9.2.7). En HTTP/2, un
|
|
69
|
+
> flux SSE n'est qu'un flux du multiplexage de la connexion.
|
|
70
|
+
>
|
|
71
|
+
> Ce plafond vaut pour l'origine que **voit le navigateur**. Derrière nginx, HAProxy ou un
|
|
72
|
+
> ingress qui termine le TLS, cette origine est le proxy : c'est **lui** qui doit servir HTTP/2,
|
|
73
|
+
> et `proxy:generate` le pose déjà (`http2 on`, `generateProxyConfig.ts#nginxServerBlock`). Nodefony reçoit
|
|
74
|
+
> alors du HTTP/1.1 (`proxy_http_version 1.1`, `generateProxyConfig.ts#nginxServerBlock`) — y activer HTTP/2
|
|
75
|
+
> n'y change rien. Sans proxy, c'est Nodefony qui sert HTTP/2.
|
|
76
|
+
>
|
|
77
|
+
> De chaque côté du proxy, un flux est une connexion qui se tait entre deux événements. Le
|
|
78
|
+
> battement (`SSE_HEARTBEAT_MS`, 15 s, `SseStream.ts#SSE_HEARTBEAT_MS`) la garde vivante ; le délai
|
|
79
|
+
> d'inactivité généré (300 s au moins) en tient vingt. Le seul piège réel : une action qui éteint
|
|
80
|
+
> le battement (`heartbeat: false`) et se tait plus longtemps que ce délai se fait couper par le
|
|
81
|
+
> proxy. Le guide [reverse-proxy](../../../../../docs/guides/reverse-proxy.md) détaille le reste.
|
|
82
|
+
|
|
83
|
+
## 🚀 Démarrage rapide
|
|
84
|
+
|
|
85
|
+
Dans une application générée par `nodefony create app`, une action pousse trois étapes puis ferme.
|
|
86
|
+
|
|
87
|
+
### 1. L'action
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// modules/app/nodefony/controller/ProgressController.ts
|
|
91
|
+
import { Controller, controller, route } from "@nodefony/framework";
|
|
92
|
+
import type { Context } from "@nodefony/http";
|
|
93
|
+
|
|
94
|
+
@controller("/progress")
|
|
95
|
+
class ProgressController extends Controller {
|
|
96
|
+
constructor(context: Context) {
|
|
97
|
+
super("ProgressController", context);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@route("progress-run", { path: "/run" })
|
|
101
|
+
async run() {
|
|
102
|
+
const sse = await this.renderSse();
|
|
103
|
+
for (const step of ["lecture", "calcul", "écriture"]) {
|
|
104
|
+
await sse.send({ step }, { event: "step" });
|
|
105
|
+
}
|
|
106
|
+
return sse.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default ProgressController;
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 2. Le client
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// frontend — la classe se lit comme EventSource
|
|
117
|
+
import { NodefonySse } from "nodefony/client";
|
|
118
|
+
|
|
119
|
+
const sse = new NodefonySse("/progress/run");
|
|
120
|
+
sse.addEventListener("step", (e) => {
|
|
121
|
+
console.log(JSON.parse((e as MessageEvent<string>).data));
|
|
122
|
+
});
|
|
123
|
+
sse.addEventListener("error", () => sse.close()); // fin du flux : ne pas reconnecter
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 3. Observer
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
curl -sN -H 'Accept: text/event-stream' http://127.0.0.1:5151/progress/run
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
event: step
|
|
134
|
+
data: {"step":"lecture"}
|
|
135
|
+
|
|
136
|
+
event: step
|
|
137
|
+
data: {"step":"calcul"}
|
|
138
|
+
|
|
139
|
+
event: step
|
|
140
|
+
data: {"step":"écriture"}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- Les en-têtes partent dès `renderSse()` : `Content-Type: text/event-stream; charset=utf-8`,
|
|
144
|
+
`Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no` (`openSseStream()`,
|
|
145
|
+
`SseStream.ts#openSseStream`). Ce dernier demande à un proxy de type nginx de ne pas garder le
|
|
146
|
+
flux en tampon. À travers le frontal que génère `nodefony proxy:generate nginx`, chaque
|
|
147
|
+
événement arrive à son émission — mesuré par le scénario `edge` du banc de publication. La même
|
|
148
|
+
mesure, en-tête retiré, reste étalée sur nginx 1.30 : l'en-tête n'y est pas ce qui tient le
|
|
149
|
+
flux, il protège les proxys qui retiennent.
|
|
150
|
+
- `await sse.send()` freine l'action si le client lit lentement — c'est la contre-pression.
|
|
151
|
+
|
|
152
|
+
## ⚙️ Les deux bouts de l'API
|
|
153
|
+
|
|
154
|
+
### Côté serveur — `this.renderSse()` et l'objet `SseStream`
|
|
155
|
+
|
|
156
|
+
| Membre | Rôle |
|
|
157
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
158
|
+
| `this.renderSse(options?)` | ouvre le flux (`Controller.renderSse()`, `Controller.ts#Controller.renderSse`) ; lève sur WebSocket |
|
|
159
|
+
| `this.acceptsSse()` | le client a-t-il demandé `text/event-stream` ? (`Controller.acceptsSse()`, `Controller.ts#Controller.acceptsSse`) |
|
|
160
|
+
| `sse.send(data, { event, id })` | un événement ; texte tel quel, objet en JSON (`SseStream.send()`, `SseStream.ts#SseStream.send`) |
|
|
161
|
+
| `sse.comment(text)` | un commentaire, ignoré par le client |
|
|
162
|
+
| `sse.retry(ms)` | impose le délai de reconnexion du client |
|
|
163
|
+
| `sse.onClose(fn)` | appelé à la fermeture, quel que soit le bout qui ferme (`SseStream.onClose()`, `SseStream.ts#SseStream.onClose`) |
|
|
164
|
+
| `sse.close()` | termine la requête, idempotent (`SseStream.close()`, `SseStream.ts#SseStream.close`) |
|
|
165
|
+
| `sse.closed` | le flux est-il fermé — à tester dans une boucle longue |
|
|
166
|
+
|
|
167
|
+
Options de `renderSse()` :
|
|
168
|
+
|
|
169
|
+
| Option | Rôle | Défaut |
|
|
170
|
+
| --------------- | ------------------------------------------------------------------------------------- | --------------------------------- |
|
|
171
|
+
| `heartbeat` | période du battement (commentaire `:`), ms ; `false` l'éteint | 15 000 (`SSE_HEARTBEAT_MS`) |
|
|
172
|
+
| `retry` | délai `retry:` envoyé à l'ouverture | aucun |
|
|
173
|
+
| `stallTimeout` | délai maximal d'une écriture bloquée (le client ne lit plus) avant coupure ; `false` | 30 000 (`SSE_STALL_TIMEOUT_MS`) |
|
|
174
|
+
| `maxEventBytes` | taille maximale d'un événement, octets UTF-8 ; au-delà `send()` lève une `RangeError` | 1 048 576 (`SSE_MAX_EVENT_BYTES`) |
|
|
175
|
+
| `maxDuration` | durée de vie du flux, ms ; il est fermé ensuite et `EventSource` se reconnecte | `false` — pas de borne |
|
|
176
|
+
|
|
177
|
+
Un délai hors de 1 à 2^31-1 ms est refusé à l'ouverture (`RangeError`) : au-delà, Node ramène un
|
|
178
|
+
minuteur à 1 ms.
|
|
179
|
+
|
|
180
|
+
### Même route, deux réponses — la négociation
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
@route("tasks-run", { path: "/tasks", requirements: { methods: ["POST"] } })
|
|
184
|
+
async runTask() {
|
|
185
|
+
if (!this.acceptsSse()) return { status: "accepted" }; // JSON
|
|
186
|
+
const sse = await this.renderSse();
|
|
187
|
+
await sse.send({ status: "running" });
|
|
188
|
+
return sse.close();
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`acceptsEventStream()` (`SseStream.ts#acceptsEventStream`) ne retient `text/event-stream` que **nommé** avec un
|
|
193
|
+
poids non nul : `*/*` ne suffit pas — un client qui ne nomme pas le flux ne sait pas le lire —, et
|
|
194
|
+
`q=0` est un refus (RFC 9110 §12.4.2).
|
|
195
|
+
|
|
196
|
+
### Côté client — `NodefonySse`
|
|
197
|
+
|
|
198
|
+
`NodefonySse` (`NodefonySse.ts#NodefonySse`) reprend le vocabulaire d'`EventSource` — `onopen`,
|
|
199
|
+
`onmessage`, `onerror`, `addEventListener(type)`, `readyState`, `lastEventId`, `close()`,
|
|
200
|
+
reconnexion avec `Last-Event-ID` — et ajoute ce que le natif refuse : `method`, `headers` (un
|
|
201
|
+
`Authorization: Bearer`), `body`. Il est bâti sur `fetch`, donc tourne aussi sous Node, où
|
|
202
|
+
`EventSource` reste expérimental.
|
|
203
|
+
|
|
204
|
+
| Réponse du serveur | Ce que fait le client |
|
|
205
|
+
| ------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
206
|
+
| 200 `text/event-stream` | `OPEN`, événements |
|
|
207
|
+
| fin du flux, coupure réseau | `error`, puis reconnexion après le délai `retry:`, plus une gigue jusqu'à sa moitié |
|
|
208
|
+
| aucune réponse dans `connectTimeout` (10 s) | `error`, puis reconnexion — jamais figé en `CONNECTING` |
|
|
209
|
+
| autre statut (204 compris), autre type | `error`, `CLOSED`, **sans** reconnexion (WHATWG §9.2.3) |
|
|
210
|
+
| événement au-delà de `maxEventSize` (4 Mi) | `CLOSED` sans reconnexion |
|
|
211
|
+
|
|
212
|
+
L'analyseur du format est `SseParser` (`SseParser.ts#SseParser`), exporté par `nodefony/client` : c'est
|
|
213
|
+
le même qui relit le flux dans les tests du serveur.
|
|
214
|
+
|
|
215
|
+
## 🏗️ Architecture interne — ce que fait l'ouverture
|
|
216
|
+
|
|
217
|
+
1. **La réponse est prise** (`context.sended`) : plus rien ne peut répondre à la place du flux.
|
|
218
|
+
2. **La session est sauvée** s'il y en a une — ses cookies partent avec les en-têtes.
|
|
219
|
+
3. **Le transport se constate** : flux HTTP/2, ou réponse HTTP/1.1 ; ni l'un ni l'autre → 500,
|
|
220
|
+
avant d'avoir rien écrit (`startSseStream()`, `SseStream.ts#startSseStream`).
|
|
221
|
+
4. **Les en-têtes partent** — `respond()` en HTTP/2, `flushHeaders()` en HTTP/1.1 — et le délai
|
|
222
|
+
d'inactivité de la requête est **désarmé** : sans cela, le flux vivant prendrait un 408 au bout
|
|
223
|
+
de 30 s.
|
|
224
|
+
5. **Le battement démarre** sur un minuteur `unref()`.
|
|
225
|
+
|
|
226
|
+
La fin, quel que soit le bout qui ferme, passe par **un seul** nettoyage (`SseStream.ts#SseStream.send`) :
|
|
227
|
+
minuteur arrêté, écouteurs `close` et `drain` retirés, attente de `drain` libérée, puis les
|
|
228
|
+
écouteurs `onClose`.
|
|
229
|
+
|
|
230
|
+
## 🔐 Sécurité
|
|
231
|
+
|
|
232
|
+
- **Injection de flux** : `event` et `id` sont refusés s'ils contiennent CR, LF ou NUL
|
|
233
|
+
(`singleLine()`, `SseStream.ts#singleLine`) — une valeur venue d'un utilisateur ne peut pas clore
|
|
234
|
+
l'événement pour en forger un autre. Les `data` sur plusieurs lignes sont découpés en autant de
|
|
235
|
+
champs, jamais recopiés tels quels.
|
|
236
|
+
- **Client** : un serveur qui n'envoie jamais de fin de ligne ferait grossir la mémoire du client
|
|
237
|
+
sans limite — `maxEventSize` (4 Mi par défaut) ferme la connexion.
|
|
238
|
+
- Le flux passe par le pipeline complet : pare-feu, CSRF et `@IsGranted` s'appliquent avant
|
|
239
|
+
l'action, comme pour toute route.
|
|
240
|
+
- **Les mêmes gardes qu'une socket WebSocket** — un attaquant prend le transport le plus faible :
|
|
241
|
+
- **plafond de connexions par IP** : `wsMaxConnectionsPerIp` compte les sockets WebSocket ET les
|
|
242
|
+
flux SSE, **un seul budget** (sinon N sockets plus N flux doubleraient le plafond). Au-delà, le
|
|
243
|
+
flux répond **429** avant d'écrire un octet de flux. Désactivé par défaut, comme pour le
|
|
244
|
+
WebSocket : en cloud, le plafond vit à l'ingress.
|
|
245
|
+
- **révocation** : un flux ouvert par une identité authentifiée est re-validé toutes les 30 s
|
|
246
|
+
(`REVOCATION_REVALIDATE_MS`) par la même règle que les sockets — session détruite ou passée à un
|
|
247
|
+
autre compte, jeton expiré ou annulé — et fermé si elle est morte. Une identité vivante ne
|
|
248
|
+
perd jamais son flux ; un anonyme n'est jamais re-validé.
|
|
249
|
+
- **lecteur bloqué** : le délai d'inactivité étant désarmé, `stallTimeout` le remplace — une
|
|
250
|
+
écriture qui attend un `drain` plus de 30 s coupe la connexion. Un client inactif mais qui lit
|
|
251
|
+
n'est jamais concerné : le minuteur ne s'arme que sous contre-pression.
|
|
252
|
+
- **taille d'un événement** : `maxEventBytes` refuse un événement démesuré avant de le mettre en
|
|
253
|
+
mémoire d'écriture ; le flux reste ouvert.
|
|
254
|
+
|
|
255
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
256
|
+
|
|
257
|
+
| Symptôme | Cause | Correction |
|
|
258
|
+
| ------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
259
|
+
| 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 |
|
|
260
|
+
| 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 |
|
|
261
|
+
| `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 » |
|
|
262
|
+
| 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 |
|
|
263
|
+
| 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 |
|
|
264
|
+
| 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 |
|
|
265
|
+
| `RangeError` à `send()`, le flux continue | événement plus gros que `maxEventBytes` | découper la donnée, ou relever `maxEventBytes` pour ce flux |
|
|
266
|
+
|
|
267
|
+
## 🧪 Tests & couverture
|
|
268
|
+
|
|
269
|
+
| Type | Où |
|
|
270
|
+
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
271
|
+
| 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 |
|
|
272
|
+
| 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 |
|
|
273
|
+
| Unitaires — `Accept` | `unit/acceptsEventStream.test.ts` |
|
|
274
|
+
| 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 |
|
|
275
|
+
| 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 |
|
|
276
|
+
|
|
277
|
+
Ces suites ont été vues échouer en débranchant le câblage : fin de ligne coupée entre deux morceaux,
|
|
278
|
+
`id` contenant NUL, `Last-Event-ID` retiré, 204 reconnecté, borne de taille ignorée, poids `q=0`
|
|
279
|
+
accepté, et fermeture écoutée sur la requête.
|
|
280
|
+
|
|
281
|
+
Ce qui **manque** : aucun banc de débit (événements par seconde) ; la contre-pression n'est pas
|
|
282
|
+
éprouvée sur un client réellement lent ; un flux en HTTP/3 n'existe pas (Node ne sert pas HTTP/3).
|
|
283
|
+
|
|
284
|
+
## 🔗 Pour aller plus loin
|
|
285
|
+
|
|
286
|
+
- ⬆️ **Retour au hub** : [@nodefony/http — vue du module](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
287
|
+
- 🧭 **Pages sœurs** : [Serveurs](servers.md) — HTTP/2, TLS, délais ; [Proxy inverse](reverse-proxy.md).
|