@nodefony/http 10.0.0-beta.2 → 10.0.0-beta.3
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.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +11 -5
- package/dist/nodefony/config/config.js +44 -4
- package/dist/nodefony/config/defineModuleConfig.js +1 -1
- package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +0 -1
- package/dist/nodefony/service/audit-logger.js +2 -3
- package/dist/nodefony/service/certificates.js +140 -15
- package/dist/nodefony/service/error-renderer.js +1 -1
- package/dist/nodefony/service/http-kernel.js +221 -47
- package/dist/nodefony/service/reverse-proxy.js +336 -0
- package/dist/nodefony/service/servers/server-http.js +3 -3
- package/dist/nodefony/service/servers/server-https.js +3 -3
- package/dist/nodefony/service/servers/server-static.js +4 -7
- package/dist/nodefony/service/servers/server-websocket-secure.js +12 -4
- package/dist/nodefony/service/servers/server-websocket.js +12 -4
- package/dist/nodefony/service/servers/wsHeartbeat.js +31 -15
- package/dist/nodefony/service/sessions/sessions-service.js +30 -9
- package/dist/nodefony/service/upload/upload-service.js +3 -3
- package/dist/nodefony/src/context/Context.js +28 -1
- package/dist/nodefony/src/context/domainMatcher.js +20 -2
- package/dist/nodefony/src/context/forwarded.js +2 -2
- package/dist/nodefony/src/context/http/HttpContext.js +91 -40
- package/dist/nodefony/src/context/http/Request.js +1 -9
- package/dist/nodefony/src/context/http/Response.js +16 -1
- package/dist/nodefony/src/context/http/SseStream.js +350 -0
- package/dist/nodefony/src/context/responseEnded.js +27 -0
- package/dist/nodefony/src/context/websocket/Response.js +9 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +2 -3
- package/dist/nodefony/src/context/websocket/websocketUrl.js +21 -0
- package/dist/nodefony/src/profiler/Profiler.js +6 -10
- package/dist/nodefony/src/proxy/forward.js +476 -0
- package/dist/nodefony/src/proxy/rules.js +182 -0
- package/dist/nodefony/src/servers/upgradeDispatch.js +49 -0
- package/dist/nodefony/src/session/session.js +28 -12
- package/dist/types/index.d.ts +5 -0
- package/dist/types/nodefony/config/config.d.ts +16 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +2 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
- package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
- package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
- package/dist/types/nodefony/service/certificates.d.ts +96 -5
- package/dist/types/nodefony/service/error-renderer.d.ts +1 -1
- package/dist/types/nodefony/service/http-kernel.d.ts +111 -5
- package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -0
- 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 +22 -2
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +47 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +3 -0
- package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
- package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
- package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +1 -0
- package/dist/types/nodefony/src/proxy/forward.d.ts +156 -0
- package/dist/types/nodefony/src/proxy/rules.d.ts +85 -0
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +39 -0
- package/dist/types/nodefony/src/session/session.d.ts +2 -0
- package/docs/cookies.md +13 -13
- package/docs/index.md +5 -1
- package/docs/observabilite.md +15 -15
- package/docs/rate-limit.md +30 -30
- package/docs/reverse-proxy.md +522 -0
- package/docs/servers.md +94 -60
- package/docs/session.md +61 -61
- package/docs/sse.md +283 -0
- package/docs/upload.md +23 -23
- package/package.json +8 -8
package/docs/session.md
CHANGED
|
@@ -69,8 +69,8 @@ flowchart TD
|
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
Le point d'activation est **unique et commun aux deux transports** : `HttpKernel.startSession()`
|
|
72
|
-
(`http-kernel.ts:
|
|
73
|
-
(`http-kernel.ts:
|
|
72
|
+
(`http-kernel.ts:1330`). Il commence par la garde paresseuse `if (!intent && !context.hasSession())`
|
|
73
|
+
(`http-kernel.ts:1314`) — sans intent de route ni cookie entrant, **aucune session n'est ouverte**.
|
|
74
74
|
|
|
75
75
|
## 📖 Lexique
|
|
76
76
|
|
|
@@ -114,15 +114,15 @@ défaut** dans Nodefony :
|
|
|
114
114
|
|
|
115
115
|
- **Vol du cookie (hijacking).** Un script injecté (XSS) ou un réseau en clair capte le cookie et
|
|
116
116
|
rejoue la session. → `HttpOnly` et `Secure` sont à `true` par défaut (`sessionCookieSchema`,
|
|
117
|
-
`config.ts:
|
|
118
|
-
(`Context.getSessionCookieName()`, `Context.ts:
|
|
117
|
+
`config.ts:769`), et le nom du cookie prend le préfixe `__Host-` dès que le transport est TLS
|
|
118
|
+
(`Context.getSessionCookieName()`, `Context.ts:757`).
|
|
119
119
|
- **Fixation.** L'attaquant pose lui-même un identifiant dans le navigateur de la victime, attend
|
|
120
120
|
qu'elle se connecte, puis réutilise **le même** identifiant. → double défense : `strictMode` rejette
|
|
121
|
-
tout identifiant inconnu du store (`Session.resume()`, `session.ts:
|
|
121
|
+
tout identifiant inconnu du store (`Session.resume()`, `session.ts:197`), et le login **régénère**
|
|
122
122
|
l'identifiant (`AuthFlow` — voir plus bas).
|
|
123
123
|
- **Exploitation prolongée d'un identifiant volé.** Une session maintenue artificiellement vivante
|
|
124
124
|
resterait exploitable indéfiniment. → l'**absolute timeout** borne l'âge depuis la création et n'est
|
|
125
|
-
**jamais** prolongé (`Session.isValidSession()`, `session.ts:
|
|
125
|
+
**jamais** prolongé (`Session.isValidSession()`, `session.ts:389`), en plus de l'idle timeout.
|
|
126
126
|
|
|
127
127
|
> [!IMPORTANT]
|
|
128
128
|
> Le cookie **ne chiffre rien** et n'a pas à le faire : il ne porte qu'un numéro. La sécurité repose
|
|
@@ -134,18 +134,18 @@ défaut** dans Nodefony :
|
|
|
134
134
|
Quatre partis pris, chacun vérifiable dans le code.
|
|
135
135
|
|
|
136
136
|
**1. Le cookie ne transporte que l'identifiant.** `Session.getSession()` lit la **valeur brute** du
|
|
137
|
-
cookie, sans déchiffrement (`session.ts:
|
|
137
|
+
cookie, sans déchiffrement (`session.ts:180`) ; l'identifiant vient de `Session.generateId()`
|
|
138
138
|
(`session.ts:231`). Modèle BFF : le web reste sur un cookie opaque, le JWT est réservé aux API et aux
|
|
139
139
|
agents (voir [Firewall](../../security/docs/firewall.md)).
|
|
140
140
|
|
|
141
141
|
**2. La session est paresseuse.** Elle n'existe que si une route la demande — `@UseSession`, ou la
|
|
142
142
|
seule présence d'un paramètre `@Session` — ou si un cookie arrive déjà : c'est la garde de
|
|
143
|
-
`HttpKernel.startSession()` (`http-kernel.ts:
|
|
143
|
+
`HttpKernel.startSession()` (`http-kernel.ts:1330`). Une route publique ne paie **ni lecture de store,
|
|
144
144
|
ni `Set-Cookie`**.
|
|
145
145
|
|
|
146
146
|
**3. Un seul modèle d'état pour le web et le temps réel.** Le même `startSession()` sert
|
|
147
|
-
`HttpKernel.onRequestEnd()` (`http-kernel.ts:
|
|
148
|
-
l'activité HTTP **ou** WS prolonge la même session (`Session.touchIfNeeded()`, `session.ts:
|
|
147
|
+
`HttpKernel.onRequestEnd()` (`http-kernel.ts:1800`) et `HttpKernel.onConnect()` (`http-kernel.ts:2098`) ;
|
|
148
|
+
l'activité HTTP **ou** WS prolonge la même session (`Session.touchIfNeeded()`, `session.ts:454`).
|
|
149
149
|
|
|
150
150
|
**4. L'administration ne voit jamais un identifiant.** Un opérateur manipule une `ref`, HMAC tronqué
|
|
151
151
|
non réversible produit par `computeSessionRef()` (`sessions-service.ts:100`) — comme la liste
|
|
@@ -274,19 +274,19 @@ curl -sk -b /tmp/jar https://localhost:5152/panier
|
|
|
274
274
|
Le cookie obtenu porte `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` et **aucun** `Domain` :
|
|
275
275
|
c'est exactement ce qu'assert le banc d'intégration `session-runtime` (« Set-Cookie de session sur TLS »).
|
|
276
276
|
Sur un transport en clair (port 5151), le préfixe `__Host-` est omis — le navigateur le rejetterait
|
|
277
|
-
faute de `Secure` (`Context.getSessionCookieName()`, `Context.ts:
|
|
277
|
+
faute de `Secure` (`Context.getSessionCookieName()`, `Context.ts:757`).
|
|
278
278
|
|
|
279
279
|
## ⚙️ Configuration
|
|
280
280
|
|
|
281
|
-
Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:
|
|
282
|
-
`sessionCookieSchema` (`config.ts:
|
|
281
|
+
Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:803`) et son sous-schéma
|
|
282
|
+
`sessionCookieSchema` (`config.ts:769`).
|
|
283
283
|
|
|
284
284
|
| Option | Type | Défaut | Effet |
|
|
285
285
|
| ------------------- | ------- | ------------ | ---------------------------------------------------------------------------------------- |
|
|
286
286
|
| `store` | string | `"auto"` | Backend de persistance — voir la résolution ci-dessous (`config.ts:795`). |
|
|
287
287
|
| `name` | string | `"nodefony"` | Nom du cookie, préfixé `__Host-` selon `cookie.hostPrefix` (`config.ts:770`). |
|
|
288
288
|
| `strictMode` | bool | `true` | Un identifiant inconnu du store est rejeté → session neuve (anti-fixation). |
|
|
289
|
-
| `idleTimeoutS` | int ≥ 0 | `1800` | Inactivité max (30 min). `0` = pas d'expiration par inactivité (`config.ts:
|
|
289
|
+
| `idleTimeoutS` | int ≥ 0 | `1800` | Inactivité max (30 min). `0` = pas d'expiration par inactivité (`config.ts:844`). |
|
|
290
290
|
| `absoluteTimeoutS` | int ≥ 0 | `43200` | Âge max depuis la création (12 h), **jamais** prolongé. `0` = désactivé. |
|
|
291
291
|
| `gcIntervalS` | int ≥ 0 | `600` | Période de purge des sessions expirées, hors requête. `0` = timer désarmé. |
|
|
292
292
|
| `gcJitter` | bool | `true` | Décale le départ du GC par process (anti _thundering herd_ sur un store partagé). |
|
|
@@ -351,7 +351,7 @@ Ce qui change, c'est la topologie et la façon d'expirer.
|
|
|
351
351
|
|
|
352
352
|
### `memory` — l'implémentation de référence
|
|
353
353
|
|
|
354
|
-
Store built-in de `@nodefony/http`, enregistré d'office (`sessions-service.ts:
|
|
354
|
+
Store built-in de `@nodefony/http`, enregistré d'office (`sessions-service.ts:950`). Les sessions vivent
|
|
355
355
|
dans une `Map` du process : elles **disparaissent au redémarrage** et ne sont **pas partagées** entre
|
|
356
356
|
pods — c'est un choix (mesurer le framework sans le goulot disque/SQL), pas une limite.
|
|
357
357
|
|
|
@@ -380,24 +380,24 @@ traduit (`$null`) au lieu de chercher `""` (`@nodefony/drizzle/nodefony/src/Sess
|
|
|
380
380
|
### `redis` — TTL natif, zéro balayage
|
|
381
381
|
|
|
382
382
|
Ici l'expiration **idle** est portée par Redis lui-même : `SET … EX` pose le TTL à chaque écriture
|
|
383
|
-
(`@nodefony/redis/nodefony/src/SessionStorage.ts:
|
|
383
|
+
(`@nodefony/redis/nodefony/src/SessionStorage.ts:247`) et `touch` le repositionne par un `EXPIRE` O(1)
|
|
384
384
|
(`@nodefony/redis/nodefony/src/SessionStorage.ts:166`). Conséquence : `gc()` est un **no-op assumé**
|
|
385
385
|
(`@nodefony/redis/nodefony/src/SessionStorage.ts:168`) — aucun balayage périodique.
|
|
386
386
|
|
|
387
387
|
L'absolute timeout, lui, n'est pas exprimable par un TTL glissant : il reste honoré **à la lecture**
|
|
388
|
-
par `Session.isValidSession()` (`session.ts:
|
|
388
|
+
par `Session.isValidSession()` (`session.ts:389`). Une entrée trop vieille peut donc survivre côté
|
|
389
389
|
Redis jusqu'à son TTL idle, mais elle est **refusée à la reprise**.
|
|
390
390
|
|
|
391
391
|
Capacités réduites, annoncées et non simulées : la pagination est **par curseur** (pas de `total`, pas
|
|
392
392
|
d'ordre global) et `countSessions()` renvoie **`-1`** = « je ne sais pas »
|
|
393
|
-
(`@nodefony/redis/nodefony/src/SessionStorage.ts:
|
|
393
|
+
(`@nodefony/redis/nodefony/src/SessionStorage.ts:474`). L'appelant affiche l'inconnu, il ne l'invente pas.
|
|
394
394
|
|
|
395
395
|
### `mongoose` — MongoDB, parité de comportement
|
|
396
396
|
|
|
397
397
|
Même sémantique que le store SQL : `findOneAndUpdate({ upsert: true })` en une passe
|
|
398
|
-
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:
|
|
399
|
-
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:
|
|
400
|
-
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:
|
|
398
|
+
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:149`), `touch` en `updateOne`
|
|
399
|
+
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:219`), GC en deux suppressions `$lt`
|
|
400
|
+
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:189-211`). Les horodatages sont des **nombres**
|
|
401
401
|
(epoch ms) et non des `Date` Mongo, précisément pour que le store reste interchangeable avec Drizzle.
|
|
402
402
|
|
|
403
403
|
> [!TIP]
|
|
@@ -435,12 +435,12 @@ sequenceDiagram
|
|
|
435
435
|
end
|
|
436
436
|
```
|
|
437
437
|
|
|
438
|
-
**Reprise ou création.** `Session.start()` (`session.ts:
|
|
439
|
-
→ `resume()` (`session.ts:
|
|
438
|
+
**Reprise ou création.** `Session.start()` (`session.ts:164`) délègue à `getSession()` : cookie présent
|
|
439
|
+
→ `resume()` (`session.ts:197`), sinon `create()` (`session.ts:227`) qui tire un identifiant CSPRNG,
|
|
440
440
|
pose le cookie et marque la session à persister.
|
|
441
441
|
|
|
442
|
-
**Validation à la reprise.** `Session.isValidSession()` (`session.ts:
|
|
443
|
-
(âge depuis `created`) puis l'**idle** (depuis `updated`). Échec → `invalidate()` (`session.ts:
|
|
442
|
+
**Validation à la reprise.** `Session.isValidSession()` (`session.ts:389`) applique l'**absolute**
|
|
443
|
+
(âge depuis `created`) puis l'**idle** (depuis `updated`). Échec → `invalidate()` (`session.ts:308`)
|
|
444
444
|
détruit l'entrée et recrée une session vierge.
|
|
445
445
|
|
|
446
446
|
> [!WARNING]
|
|
@@ -450,7 +450,7 @@ détruit l'entrée et recrée une session vierge.
|
|
|
450
450
|
> activer `refererCheck` quand l'expiration des sessions compte — c'est-à-dire presque toujours.
|
|
451
451
|
|
|
452
452
|
**Écriture minimale.** `SessionsService.saveSession()` (`sessions-service.ts:463`) n'écrit **que** si la
|
|
453
|
-
session est `dirty` et non `readOnly` ; sinon il appelle `Session.touchIfNeeded()` (`session.ts:
|
|
453
|
+
session est `dirty` et non `readOnly` ; sinon il appelle `Session.touchIfNeeded()` (`session.ts:454`),
|
|
454
454
|
qui prolonge l'idle **sans réécrire le blob**, et seulement au-delà d'une demi-vie d'idle
|
|
455
455
|
(`session.ts:445`). Une requête de lecture coûte donc au pire un `UPDATE` d'horodatage toutes les
|
|
456
456
|
15 minutes (défaut).
|
|
@@ -503,17 +503,17 @@ C'est le différenciateur du framework appliqué à l'état de session : un seul
|
|
|
503
503
|
<!-- prettier-ignore -->
|
|
504
504
|
| Aspect | HTTP | WebSocket |
|
|
505
505
|
| --- | --- | --- |
|
|
506
|
-
| Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:
|
|
506
|
+
| Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:1800`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts:2098`) |
|
|
507
507
|
| Lecture du cookie | constructeur du contexte | constructeur, même nom effectif (`WebsocketContext.ts:172`) |
|
|
508
508
|
| Sauvegarde | fin de requête | après **chaque frame** traitée (`WebsocketContext.ts:302`) |
|
|
509
|
-
| Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts:
|
|
510
|
-
| Portée ALS | une requête | **handshake + toutes les frames** (`http-kernel.ts:
|
|
509
|
+
| Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts:1904`) |
|
|
510
|
+
| Portée ALS | une requête | **handshake + toutes les frames** (`http-kernel.ts:1526`) |
|
|
511
511
|
|
|
512
512
|
La conséquence pratique la plus utile : côté WebSocket, la bulle `AsyncLocalStorage` ouverte au
|
|
513
|
-
handshake par `RequestContext.run()` **enveloppe aussi les messages** (`http-kernel.ts:
|
|
513
|
+
handshake par `RequestContext.run()` **enveloppe aussi les messages** (`http-kernel.ts:519`). L'identité résolue une fois est donc
|
|
514
514
|
disponible à chaque frame sans relire la base — c'est ce dont profite
|
|
515
515
|
`FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts:80`), câblé automatiquement
|
|
516
|
-
par le firewall sur les zones temps réel protégées (`firewall.ts:
|
|
516
|
+
par le firewall sur les zones temps réel protégées (`firewall.ts:298`).
|
|
517
517
|
|
|
518
518
|
> [!WARNING]
|
|
519
519
|
> Rien à écrire dans `initialize()` : il n'existe **pas** de `Controller.startSession()`. La session WS
|
|
@@ -526,13 +526,13 @@ par le firewall sur les zones temps réel protégées (`firewall.ts:297`).
|
|
|
526
526
|
### Régénération d'identifiant à la connexion (anti-fixation)
|
|
527
527
|
|
|
528
528
|
C'est la défense la plus importante et elle est **active**. `AuthFlow.#openSession()`
|
|
529
|
-
(`authFlow.ts:
|
|
529
|
+
(`authFlow.ts:524`) : reprise ou ouverture de la session, mémorisation de l'ancien identifiant, puis
|
|
530
530
|
appel **inconditionnel** de `Session.regenerateId()` (`authFlow.ts:388`), et enfin destruction de
|
|
531
531
|
l'ancienne entrée du store (`authFlow.ts:390`). Un cookie pré-posé par un attaquant **ne survit donc pas
|
|
532
532
|
au login**. Le nouvel identifiant est un CSPRNG frais, l'état applicatif est conservé
|
|
533
|
-
(`Session.regenerateId()`, `session.ts:
|
|
533
|
+
(`Session.regenerateId()`, `session.ts:260`).
|
|
534
534
|
|
|
535
|
-
Au passage, la provenance est capturée dans le `metaBag` : `ip` (`authFlow.ts:
|
|
535
|
+
Au passage, la provenance est capturée dans le `metaBag` : `ip` (`authFlow.ts:556`) et `ua`
|
|
536
536
|
(`authFlow.ts:407`), en mode « au mieux » — ce sont ces deux champs que la console d'administration
|
|
537
537
|
affiche.
|
|
538
538
|
|
|
@@ -540,20 +540,20 @@ affiche.
|
|
|
540
540
|
|
|
541
541
|
| Surface | Méthode | Portée |
|
|
542
542
|
| ------------------------ | ----------------------------------------------- | ------------------------------------------- |
|
|
543
|
-
| Déconnexion locale | `Session.destroy()` (`session.ts:
|
|
543
|
+
| Déconnexion locale | `Session.destroy()` (`session.ts:324`) | la session courante + pierre tombale |
|
|
544
544
|
| Révocation par un admin | `destroyByRef()` (`sessions-service.ts:745`) | une session désignée par sa `ref` publique |
|
|
545
|
-
| « Déconnecter partout » | `destroyByUser()` (`sessions-service.ts:
|
|
546
|
-
| « Mes appareils » (self) | `destroyOwnByRef()` (`sessions-service.ts:
|
|
545
|
+
| « Déconnecter partout » | `destroyByUser()` (`sessions-service.ts:831`) | toutes les sessions d'un utilisateur |
|
|
546
|
+
| « Mes appareils » (self) | `destroyOwnByRef()` (`sessions-service.ts:898`) | une session, **restreinte au propriétaire** |
|
|
547
547
|
|
|
548
548
|
Deux finesses valent d'être connues.
|
|
549
549
|
|
|
550
550
|
`destroyByUser()` ne fait pas un seul passage : il **repasse jusqu'à ce qu'un passage complet ne
|
|
551
|
-
détruise plus rien** (`sessions-service.ts:
|
|
551
|
+
détruise plus rien** (`sessions-service.ts:831`), car supprimer en parcourant décale les rangs sous un
|
|
552
552
|
curseur offset. Une révocation « partout » qui en laisserait une n'est pas une imprécision, c'est une
|
|
553
553
|
faille — on rend donc la main avec la preuve, pas l'espoir.
|
|
554
554
|
|
|
555
555
|
`destroyOwnByRef()` ferme l'IDOR **par construction** : parcours restreint aux sessions du demandeur,
|
|
556
|
-
et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-service.ts:
|
|
556
|
+
et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-service.ts:900`). Une
|
|
557
557
|
`ref` d'autrui est structurellement introuvable.
|
|
558
558
|
|
|
559
559
|
### Redaction — l'identifiant ne sort jamais du process
|
|
@@ -572,22 +572,22 @@ Trois barrières superposées :
|
|
|
572
572
|
|
|
573
573
|
| Menace | Défense | Ancrage |
|
|
574
574
|
| --------------------------------- | ------------------------------------------------- | -------------------------------------------------- |
|
|
575
|
-
| Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts:
|
|
576
|
-
| Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts:
|
|
575
|
+
| Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts:769`) |
|
|
576
|
+
| Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts:757`) |
|
|
577
577
|
| Requête inter-sites | `SameSite=Lax` par défaut | `cookieDefaultSettings` (`cookie.ts:39`) |
|
|
578
|
-
| Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts:
|
|
578
|
+
| Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts:197`) |
|
|
579
579
|
| Identifiant deviné | 32 octets CSPRNG (43 caractères base64url) | `Session.generateId()` (`session.ts:231`) |
|
|
580
|
-
| Session volée exploitée longtemps | absolute timeout, jamais prolongé | `absoluteTimeoutS` à la reprise (`session.ts:
|
|
581
|
-
| Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts:
|
|
580
|
+
| Session volée exploitée longtemps | absolute timeout, jamais prolongé | `absoluteTimeoutS` à la reprise (`session.ts:411`) |
|
|
581
|
+
| Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts:426`) |
|
|
582
582
|
| Résurrection après révocation | pierre tombale 5 min sur `write` **et** `touch` | `RevocationGuardStorage.ts:127-171` |
|
|
583
583
|
| Fuite d'identifiant en admin | `ref` HMAC + projection en liste blanche | `toSessionSummary()` (`sessions-service.ts:112`) |
|
|
584
|
-
| IDOR sur « mes sessions » | périmètre depuis l'identité ALS, jamais du client | `destroyOwnByRef()` (`sessions-service.ts:
|
|
584
|
+
| IDOR sur « mes sessions » | périmètre depuis l'identité ALS, jamais du client | `destroyOwnByRef()` (`sessions-service.ts:898`) |
|
|
585
585
|
|
|
586
586
|
## 🧰 API publique
|
|
587
587
|
|
|
588
588
|
Les signatures vivent dans `.ai/symbols.json` (jamais recopiées ici). Voici les usages réels.
|
|
589
589
|
|
|
590
|
-
**Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts:
|
|
590
|
+
**Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts:397`) ; un
|
|
591
591
|
paramètre `@Session()` suffit à déclarer l'intent.
|
|
592
592
|
|
|
593
593
|
| Besoin | Appel | Effet |
|
|
@@ -595,20 +595,20 @@ paramètre `@Session()` suffit à déclarer l'intent.
|
|
|
595
595
|
| Lire une valeur | `session.get("panier")` | `null` si absente — jamais `undefined`. |
|
|
596
596
|
| Écrire une valeur | `session.set("panier", items)` | Marque la session `dirty` → écriture en fin de requête. |
|
|
597
597
|
| Message « une seule lecture » | `session.setFlashBag("notice", "…")` | Consommé (et effacé) au premier `getFlashBag`. |
|
|
598
|
-
| Lire ce message | `session.getFlashBag("notice")` | Rend la valeur puis la supprime (`session.ts:
|
|
598
|
+
| Lire ce message | `session.getFlashBag("notice")` | Rend la valeur puis la supprime (`session.ts:551`). |
|
|
599
599
|
| Métadonnée technique | `session.getMetaBag("ip")` | ip / ua / host / remoteAddress posés à la création. |
|
|
600
600
|
| Se déconnecter | `await session.destroy(true)` | Détruit l'entrée store **et** efface le cookie. |
|
|
601
|
-
| Renouveler l'identifiant | `session.regenerateId()` | Nouvel identifiant, état conservé (`session.ts:
|
|
602
|
-
| Savoir si une écriture aura lieu | `session.dirty` | Le drapeau de dirty-tracking (`session.ts:
|
|
601
|
+
| Renouveler l'identifiant | `session.regenerateId()` | Nouvel identifiant, état conservé (`session.ts:260`). |
|
|
602
|
+
| Savoir si une écriture aura lieu | `session.dirty` | Le drapeau de dirty-tracking (`session.ts:148`). |
|
|
603
603
|
|
|
604
|
-
**Intent de route** — `UseSession(options)` (`routerDecorators.ts:
|
|
604
|
+
**Intent de route** — `UseSession(options)` (`routerDecorators.ts:830`) s'applique à une classe **ou** à
|
|
605
605
|
une méthode ; la méthode l'emporte, par fusion et non par remplacement
|
|
606
606
|
(`resolveSessionIntent()`, `routerDecorators.ts:858`). **Une seule** option (`SessionIntent`,
|
|
607
607
|
`ISession.ts:17`) :
|
|
608
608
|
|
|
609
609
|
- `readOnly: true` — la session est reprise et lue mais **jamais** persistée ; une mutation tentée est
|
|
610
|
-
journalisée en WARNING sans écriture (`Session.save()`, `session.ts:
|
|
611
|
-
propagé par le kernel (`http-kernel.ts:
|
|
610
|
+
journalisée en WARNING sans écriture (`Session.save()`, `session.ts:273-282`). C'est le seul champ
|
|
611
|
+
propagé par le kernel (`http-kernel.ts:1241`).
|
|
612
612
|
|
|
613
613
|
En décorateur de **classe**, `@UseSession` se place **sous** `@controller` (`routerDecorators.ts:189`).
|
|
614
614
|
|
|
@@ -643,12 +643,12 @@ Trois règles de conception se dégagent du contrat, et méritent d'être respec
|
|
|
643
643
|
| Domaine | Norme | Comment le code s'y conforme |
|
|
644
644
|
| ----------------------------- | ------------------------- | ----------------------------------------------------------------------------- |
|
|
645
645
|
| Attributs et préfixes cookie | RFC 6265bis §4.1.3 | `__Host-` impose `Secure` + `Path=/`, interdit `Domain` (`cookie.ts:386-403`) |
|
|
646
|
-
| Nom du cookie selon transport | RFC 6265bis / OWASP | `getSessionCookieName()` (`Context.ts:
|
|
647
|
-
| Idle timeout | NIST SP 800-63B-4 / OWASP | défaut 1800 s, glissant par `touch` (`config.ts:
|
|
646
|
+
| Nom du cookie selon transport | RFC 6265bis / OWASP | `getSessionCookieName()` (`Context.ts:757`) |
|
|
647
|
+
| Idle timeout | NIST SP 800-63B-4 / OWASP | défaut 1800 s, glissant par `touch` (`config.ts:844`) |
|
|
648
648
|
| Absolute timeout | NIST SP 800-63B-4 / OWASP | défaut 43200 s, jamais prolongé (`config.ts:808`) |
|
|
649
649
|
| Identifiant de session | OWASP Session Management | 32 octets CSPRNG, opaque (`session.ts:226`) |
|
|
650
650
|
| Identifiant hors URL | OWASP Session Management | cookie uniquement — jamais de réécriture d'URL (`session.ts:20-26`) |
|
|
651
|
-
| Renouvellement après auth | OWASP (anti-fixation) | `regenerateId()` inconditionnel au login (`authFlow.ts:
|
|
651
|
+
| Renouvellement après auth | OWASP (anti-fixation) | `regenerateId()` inconditionnel au login (`authFlow.ts:528`) |
|
|
652
652
|
| Révocation côté serveur | OWASP | pierre tombale générique (`RevocationGuardStorage.ts:121`) |
|
|
653
653
|
|
|
654
654
|
## ⚡ Performance & mémoire
|
|
@@ -656,9 +656,9 @@ Trois règles de conception se dégagent du contrat, et méritent d'être respec
|
|
|
656
656
|
Le coût d'une session est **payé seulement quand elle sert** :
|
|
657
657
|
|
|
658
658
|
- **Zéro par défaut** — sans intent ni cookie, `startSession()` sort immédiatement
|
|
659
|
-
(`http-kernel.ts:
|
|
659
|
+
(`http-kernel.ts:1330`) : ni objet `Session`, ni lecture de store.
|
|
660
660
|
- **Objet léger** — trois sacs `{}` à plat, pas de container DI par session (`session.ts:100-104`).
|
|
661
|
-
- **Zéro écriture en lecture** — le dirty-tracking court-circuite `save()` (`session.ts:
|
|
661
|
+
- **Zéro écriture en lecture** — le dirty-tracking court-circuite `save()` (`session.ts:278`) ; le
|
|
662
662
|
`touch` est throttlé à une écriture par demi-vie d'idle (`session.ts:445`).
|
|
663
663
|
- **GC hors requête** — timer déterministe avec jitter par process, à la place du tirage
|
|
664
664
|
probabiliste hérité de PHP (`sessions-service.ts:306-312`).
|
|
@@ -676,7 +676,7 @@ plus un plafond de croissance du tas.
|
|
|
676
676
|
> [!NOTE]
|
|
677
677
|
> Les trois sacs sont des objets **littéraux** et non `Object.create(null)`. C'est délibéré :
|
|
678
678
|
> `drizzle-orm` déréférence le prototype via `is()`, et un objet sans prototype ferait échouer
|
|
679
|
-
> l'écriture (`session.ts:
|
|
679
|
+
> l'écriture (`session.ts:113-116`).
|
|
680
680
|
|
|
681
681
|
## 📡 Observabilité — Studio
|
|
682
682
|
|
|
@@ -719,9 +719,9 @@ SQLite quand c'est pertinent (`SessionStorage.location`,
|
|
|
719
719
|
|
|
720
720
|
| Symptôme | Cause | Correction |
|
|
721
721
|
| ------------------------------------------------------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
722
|
-
| Aucun `Set-Cookie`, `session` toujours vide | La route n'a **aucun intent** : ni `@UseSession`, ni paramètre `@Session` | Ajouter l'un des deux — l'activation est paresseuse (`http-kernel.ts:
|
|
722
|
+
| Aucun `Set-Cookie`, `session` toujours vide | La route n'a **aucun intent** : ni `@UseSession`, ni paramètre `@Session` | Ajouter l'un des deux — l'activation est paresseuse (`http-kernel.ts:1314`) |
|
|
723
723
|
| `context.session` est `null` dans un contrôleur WS | Intent posé sur la classe au lieu de la route, ou absent | `@UseSession()` **sur la route** WebSocket ; `Controller.startSession()` n'existe plus |
|
|
724
|
-
| Mutation ignorée, WARNING « READONLY SESSION mutated » | La route est en `@UseSession({ readOnly: true })` | Retirer `readOnly` sur les routes qui écrivent (`session.ts:
|
|
724
|
+
| Mutation ignorée, WARNING « READONLY SESSION mutated » | La route est en `@UseSession({ readOnly: true })` | Retirer `readOnly` sur les routes qui écrivent (`session.ts:273-282`) |
|
|
725
725
|
| Session détruite qui « revient » après logout | Une requête en vol réécrit le blob supprimé | Déjà couvert : pierre tombale 5 min (`RevocationGuardStorage.ts:121`) |
|
|
726
726
|
| Sessions perdues à chaque redémarrage ou entre pods | Store `memory` (volatil, per-pod) | Déclarer une infra (`NF_DATABASE_URL`/`NF_REDIS_URL`) ou nommer le store |
|
|
727
727
|
| Le boot s'arrête sur « session store … inconnu » | Nom de store explicite non enregistré, en production | Charger le module fournisseur, ou corriger le nom (`sessions-service.ts:258-262`) |
|
|
@@ -753,7 +753,7 @@ depuis vitest, jamais figés ici.
|
|
|
753
753
|
> [!CAUTION]
|
|
754
754
|
> Les suites E2E se **skippent** sans leurs variables d'infra, et un skip compte comme vert. Avant de
|
|
755
755
|
> conclure « tout passe » sur les dialectes PostgreSQL/MySQL, vérifier que `NF_PG_URL`/`NF_MYSQL_URL`
|
|
756
|
-
> étaient bien posées (source unique : `vitest
|
|
756
|
+
> étaient bien posées (source unique : `scripts/test/vitest/gates.ts` à la racine).
|
|
757
757
|
|
|
758
758
|
Skills utiles : `nodefony-load-test` (rejouer ou étendre la charge), `nodefony-check-memory-health`
|
|
759
759
|
(gate mémoire), `nodefony-security-review` (fixation, timeouts, révocation).
|
package/docs/sse.md
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
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:270`). Nodefony reçoit
|
|
74
|
+
> alors du HTTP/1.1 (`proxy_http_version 1.1`, `generateProxyConfig.ts:351`) — 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:77`) 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:427`).
|
|
146
|
+
- `await sse.send()` freine l'action si le client lit lentement — c'est la contre-pression.
|
|
147
|
+
|
|
148
|
+
## ⚙️ Les deux bouts de l'API
|
|
149
|
+
|
|
150
|
+
### Côté serveur — `this.renderSse()` et l'objet `SseStream`
|
|
151
|
+
|
|
152
|
+
| Membre | Rôle |
|
|
153
|
+
| ------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
154
|
+
| `this.renderSse(options?)` | ouvre le flux (`Controller.renderSse()`, `Controller.ts:665`) ; lève sur WebSocket |
|
|
155
|
+
| `this.acceptsSse()` | le client a-t-il demandé `text/event-stream` ? (`Controller.acceptsSse()`, `Controller.ts:649`) |
|
|
156
|
+
| `sse.send(data, { event, id })` | un événement ; texte tel quel, objet en JSON (`SseStream.send()`, `SseStream.ts:260`) |
|
|
157
|
+
| `sse.comment(text)` | un commentaire, ignoré par le client |
|
|
158
|
+
| `sse.retry(ms)` | impose le délai de reconnexion du client |
|
|
159
|
+
| `sse.onClose(fn)` | appelé à la fermeture, quel que soit le bout qui ferme (`SseStream.onClose()`, `SseStream.ts:306`) |
|
|
160
|
+
| `sse.close()` | termine la requête, idempotent (`SseStream.close()`, `SseStream.ts:321`) |
|
|
161
|
+
| `sse.closed` | le flux est-il fermé — à tester dans une boucle longue |
|
|
162
|
+
|
|
163
|
+
Options de `renderSse()` :
|
|
164
|
+
|
|
165
|
+
| Option | Rôle | Défaut |
|
|
166
|
+
| --------------- | ------------------------------------------------------------------------------------- | --------------------------------- |
|
|
167
|
+
| `heartbeat` | période du battement (commentaire `:`), ms ; `false` l'éteint | 15 000 (`SSE_HEARTBEAT_MS`) |
|
|
168
|
+
| `retry` | délai `retry:` envoyé à l'ouverture | aucun |
|
|
169
|
+
| `stallTimeout` | délai maximal d'une écriture bloquée (le client ne lit plus) avant coupure ; `false` | 30 000 (`SSE_STALL_TIMEOUT_MS`) |
|
|
170
|
+
| `maxEventBytes` | taille maximale d'un événement, octets UTF-8 ; au-delà `send()` lève une `RangeError` | 1 048 576 (`SSE_MAX_EVENT_BYTES`) |
|
|
171
|
+
| `maxDuration` | durée de vie du flux, ms ; il est fermé ensuite et `EventSource` se reconnecte | `false` — pas de borne |
|
|
172
|
+
|
|
173
|
+
Un délai hors de 1 à 2^31-1 ms est refusé à l'ouverture (`RangeError`) : au-delà, Node ramène un
|
|
174
|
+
minuteur à 1 ms.
|
|
175
|
+
|
|
176
|
+
### Même route, deux réponses — la négociation
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
@route("tasks-run", { path: "/tasks", requirements: { methods: ["POST"] } })
|
|
180
|
+
async runTask() {
|
|
181
|
+
if (!this.acceptsSse()) return { status: "accepted" }; // JSON
|
|
182
|
+
const sse = await this.renderSse();
|
|
183
|
+
await sse.send({ status: "running" });
|
|
184
|
+
return sse.close();
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`acceptsEventStream()` (`SseStream.ts:395`) ne retient `text/event-stream` que **nommé** avec un
|
|
189
|
+
poids non nul : `*/*` ne suffit pas — un client qui ne nomme pas le flux ne sait pas le lire —, et
|
|
190
|
+
`q=0` est un refus (RFC 9110 §12.4.2).
|
|
191
|
+
|
|
192
|
+
### Côté client — `NodefonySse`
|
|
193
|
+
|
|
194
|
+
`NodefonySse` (`NodefonySse.ts:110`) reprend le vocabulaire d'`EventSource` — `onopen`,
|
|
195
|
+
`onmessage`, `onerror`, `addEventListener(type)`, `readyState`, `lastEventId`, `close()`,
|
|
196
|
+
reconnexion avec `Last-Event-ID` — et ajoute ce que le natif refuse : `method`, `headers` (un
|
|
197
|
+
`Authorization: Bearer`), `body`. Il est bâti sur `fetch`, donc tourne aussi sous Node, où
|
|
198
|
+
`EventSource` reste expérimental.
|
|
199
|
+
|
|
200
|
+
| Réponse du serveur | Ce que fait le client |
|
|
201
|
+
| ------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
202
|
+
| 200 `text/event-stream` | `OPEN`, événements |
|
|
203
|
+
| fin du flux, coupure réseau | `error`, puis reconnexion après le délai `retry:`, plus une gigue jusqu'à sa moitié |
|
|
204
|
+
| aucune réponse dans `connectTimeout` (10 s) | `error`, puis reconnexion — jamais figé en `CONNECTING` |
|
|
205
|
+
| autre statut (204 compris), autre type | `error`, `CLOSED`, **sans** reconnexion (WHATWG §9.2.3) |
|
|
206
|
+
| événement au-delà de `maxEventSize` (4 Mi) | `CLOSED` sans reconnexion |
|
|
207
|
+
|
|
208
|
+
L'analyseur du format est `SseParser` (`SseParser.ts:61`), exporté par `nodefony/client` : c'est
|
|
209
|
+
le même qui relit le flux dans les tests du serveur.
|
|
210
|
+
|
|
211
|
+
## 🏗️ Architecture interne — ce que fait l'ouverture
|
|
212
|
+
|
|
213
|
+
1. **La réponse est prise** (`context.sended`) : plus rien ne peut répondre à la place du flux.
|
|
214
|
+
2. **La session est sauvée** s'il y en a une — ses cookies partent avec les en-têtes.
|
|
215
|
+
3. **Le transport se constate** : flux HTTP/2, ou réponse HTTP/1.1 ; ni l'un ni l'autre → 500,
|
|
216
|
+
avant d'avoir rien écrit (`startSseStream()`, `SseStream.ts:485`).
|
|
217
|
+
4. **Les en-têtes partent** — `respond()` en HTTP/2, `flushHeaders()` en HTTP/1.1 — et le délai
|
|
218
|
+
d'inactivité de la requête est **désarmé** : sans cela, le flux vivant prendrait un 408 au bout
|
|
219
|
+
de 30 s.
|
|
220
|
+
5. **Le battement démarre** sur un minuteur `unref()`.
|
|
221
|
+
|
|
222
|
+
La fin, quel que soit le bout qui ferme, passe par **un seul** nettoyage (`SseStream.ts:254`) :
|
|
223
|
+
minuteur arrêté, écouteurs `close` et `drain` retirés, attente de `drain` libérée, puis les
|
|
224
|
+
écouteurs `onClose`.
|
|
225
|
+
|
|
226
|
+
## 🔐 Sécurité
|
|
227
|
+
|
|
228
|
+
- **Injection de flux** : `event` et `id` sont refusés s'ils contiennent CR, LF ou NUL
|
|
229
|
+
(`singleLine()`, `SseStream.ts:105`) — une valeur venue d'un utilisateur ne peut pas clore
|
|
230
|
+
l'événement pour en forger un autre. Les `data` sur plusieurs lignes sont découpés en autant de
|
|
231
|
+
champs, jamais recopiés tels quels.
|
|
232
|
+
- **Client** : un serveur qui n'envoie jamais de fin de ligne ferait grossir la mémoire du client
|
|
233
|
+
sans limite — `maxEventSize` (4 Mi par défaut) ferme la connexion.
|
|
234
|
+
- Le flux passe par le pipeline complet : pare-feu, CSRF et `@IsGranted` s'appliquent avant
|
|
235
|
+
l'action, comme pour toute route.
|
|
236
|
+
- **Les mêmes gardes qu'une socket WebSocket** — un attaquant prend le transport le plus faible :
|
|
237
|
+
- **plafond de connexions par IP** : `wsMaxConnectionsPerIp` compte les sockets WebSocket ET les
|
|
238
|
+
flux SSE, **un seul budget** (sinon N sockets plus N flux doubleraient le plafond). Au-delà, le
|
|
239
|
+
flux répond **429** avant d'écrire un octet de flux. Désactivé par défaut, comme pour le
|
|
240
|
+
WebSocket : en cloud, le plafond vit à l'ingress.
|
|
241
|
+
- **révocation** : un flux ouvert par une identité authentifiée est re-validé toutes les 30 s
|
|
242
|
+
(`REVOCATION_REVALIDATE_MS`) par la même règle que les sockets — session détruite ou passée à un
|
|
243
|
+
autre compte, jeton expiré ou annulé — et fermé si elle est morte. Une identité vivante ne
|
|
244
|
+
perd jamais son flux ; un anonyme n'est jamais re-validé.
|
|
245
|
+
- **lecteur bloqué** : le délai d'inactivité étant désarmé, `stallTimeout` le remplace — une
|
|
246
|
+
écriture qui attend un `drain` plus de 30 s coupe la connexion. Un client inactif mais qui lit
|
|
247
|
+
n'est jamais concerné : le minuteur ne s'arme que sous contre-pression.
|
|
248
|
+
- **taille d'un événement** : `maxEventBytes` refuse un événement démesuré avant de le mettre en
|
|
249
|
+
mémoire d'écriture ; le flux reste ouvert.
|
|
250
|
+
|
|
251
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
252
|
+
|
|
253
|
+
| Symptôme | Cause | Correction |
|
|
254
|
+
| ------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
255
|
+
| 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 |
|
|
256
|
+
| 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 |
|
|
257
|
+
| `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 » |
|
|
258
|
+
| 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 |
|
|
259
|
+
| 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 |
|
|
260
|
+
| 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 |
|
|
261
|
+
| `RangeError` à `send()`, le flux continue | événement plus gros que `maxEventBytes` | découper la donnée, ou relever `maxEventBytes` pour ce flux |
|
|
262
|
+
|
|
263
|
+
## 🧪 Tests & couverture
|
|
264
|
+
|
|
265
|
+
| Type | Où |
|
|
266
|
+
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
267
|
+
| 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 |
|
|
268
|
+
| 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 |
|
|
269
|
+
| Unitaires — `Accept` | `unit/acceptsEventStream.test.ts` |
|
|
270
|
+
| 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 |
|
|
271
|
+
| 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 |
|
|
272
|
+
|
|
273
|
+
Ces suites ont été vues échouer en débranchant le câblage : fin de ligne coupée entre deux morceaux,
|
|
274
|
+
`id` contenant NUL, `Last-Event-ID` retiré, 204 reconnecté, borne de taille ignorée, poids `q=0`
|
|
275
|
+
accepté, et fermeture écoutée sur la requête.
|
|
276
|
+
|
|
277
|
+
Ce qui **manque** : aucun banc de débit (événements par seconde) ; la contre-pression n'est pas
|
|
278
|
+
éprouvée sur un client réellement lent ; un flux en HTTP/3 n'existe pas (Node ne sert pas HTTP/3).
|
|
279
|
+
|
|
280
|
+
## 🔗 Pour aller plus loin
|
|
281
|
+
|
|
282
|
+
- ⬆️ **Retour au hub** : [@nodefony/http — vue du module](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
283
|
+
- 🧭 **Pages sœurs** : [Serveurs](servers.md) — HTTP/2, TLS, délais ; [Proxy inverse](reverse-proxy.md).
|