@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.
Files changed (76) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
  4. package/dist/index.js +11 -5
  5. package/dist/nodefony/config/config.js +44 -4
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -1
  7. package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
  8. package/dist/nodefony/service/HttpAdminApi.js +0 -1
  9. package/dist/nodefony/service/audit-logger.js +2 -3
  10. package/dist/nodefony/service/certificates.js +140 -15
  11. package/dist/nodefony/service/error-renderer.js +1 -1
  12. package/dist/nodefony/service/http-kernel.js +221 -47
  13. package/dist/nodefony/service/reverse-proxy.js +336 -0
  14. package/dist/nodefony/service/servers/server-http.js +3 -3
  15. package/dist/nodefony/service/servers/server-https.js +3 -3
  16. package/dist/nodefony/service/servers/server-static.js +4 -7
  17. package/dist/nodefony/service/servers/server-websocket-secure.js +12 -4
  18. package/dist/nodefony/service/servers/server-websocket.js +12 -4
  19. package/dist/nodefony/service/servers/wsHeartbeat.js +31 -15
  20. package/dist/nodefony/service/sessions/sessions-service.js +30 -9
  21. package/dist/nodefony/service/upload/upload-service.js +3 -3
  22. package/dist/nodefony/src/context/Context.js +28 -1
  23. package/dist/nodefony/src/context/domainMatcher.js +20 -2
  24. package/dist/nodefony/src/context/forwarded.js +2 -2
  25. package/dist/nodefony/src/context/http/HttpContext.js +91 -40
  26. package/dist/nodefony/src/context/http/Request.js +1 -9
  27. package/dist/nodefony/src/context/http/Response.js +16 -1
  28. package/dist/nodefony/src/context/http/SseStream.js +350 -0
  29. package/dist/nodefony/src/context/responseEnded.js +27 -0
  30. package/dist/nodefony/src/context/websocket/Response.js +9 -0
  31. package/dist/nodefony/src/context/websocket/WebsocketContext.js +2 -3
  32. package/dist/nodefony/src/context/websocket/websocketUrl.js +21 -0
  33. package/dist/nodefony/src/profiler/Profiler.js +6 -10
  34. package/dist/nodefony/src/proxy/forward.js +476 -0
  35. package/dist/nodefony/src/proxy/rules.js +182 -0
  36. package/dist/nodefony/src/servers/upgradeDispatch.js +49 -0
  37. package/dist/nodefony/src/session/session.js +28 -12
  38. package/dist/types/index.d.ts +5 -0
  39. package/dist/types/nodefony/config/config.d.ts +16 -0
  40. package/dist/types/nodefony/interfaces/IContext.d.ts +2 -0
  41. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
  42. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
  43. package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
  44. package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
  45. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  46. package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
  47. package/dist/types/nodefony/service/certificates.d.ts +96 -5
  48. package/dist/types/nodefony/service/error-renderer.d.ts +1 -1
  49. package/dist/types/nodefony/service/http-kernel.d.ts +111 -5
  50. package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
  51. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -0
  52. package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -0
  53. package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
  54. package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
  55. package/dist/types/nodefony/src/context/Context.d.ts +22 -2
  56. package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
  57. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +47 -0
  58. package/dist/types/nodefony/src/context/http/Response.d.ts +3 -0
  59. package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
  60. package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
  61. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
  62. package/dist/types/nodefony/src/profiler/Profiler.d.ts +1 -0
  63. package/dist/types/nodefony/src/proxy/forward.d.ts +156 -0
  64. package/dist/types/nodefony/src/proxy/rules.d.ts +85 -0
  65. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +39 -0
  66. package/dist/types/nodefony/src/session/session.d.ts +2 -0
  67. package/docs/cookies.md +13 -13
  68. package/docs/index.md +5 -1
  69. package/docs/observabilite.md +15 -15
  70. package/docs/rate-limit.md +30 -30
  71. package/docs/reverse-proxy.md +522 -0
  72. package/docs/servers.md +94 -60
  73. package/docs/session.md +61 -61
  74. package/docs/sse.md +283 -0
  75. package/docs/upload.md +23 -23
  76. 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:1246`). Il commence par la garde paresseuse `if (!intent && !context.hasSession())`
73
- (`http-kernel.ts:1283`) — sans intent de route ni cookie entrant, **aucune session n'est ouverte**.
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:752-755`), et le nom du cookie prend le préfixe `__Host-` dès que le transport est TLS
118
- (`Context.getSessionCookieName()`, `Context.ts:733`).
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:177`), et le login **régénère**
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:371`), en plus de l'idle timeout.
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:160`) ; l'identifiant vient de `Session.generateId()`
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:1246`). Une route publique ne paie **ni lecture de store,
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:1697`) et `HttpKernel.onConnect()` (`http-kernel.ts:2008`) ;
148
- l'activité HTTP **ou** WS prolonge la même session (`Session.touchIfNeeded()`, `session.ts:421`).
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:733`).
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:786`) et son sous-schéma
282
- `sessionCookieSchema` (`config.ts:752`).
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:823`). |
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:913`). Les sessions vivent
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:137`) et `touch` le repositionne par un `EXPIRE` O(1)
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:371`). Une entrée trop vieille peut donc survivre côté
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:303`). L'appelant affiche l'inconnu, il ne l'invente pas.
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:195`), `touch` en `updateOne`
399
- (`@nodefony/mongoose/nodefony/src/SessionStorage.ts:174`), GC en deux suppressions `$lt`
400
- (`@nodefony/mongoose/nodefony/src/SessionStorage.ts:144-166`). Les horodatages sont des **nombres**
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:144`) délègue à `getSession()` : cookie présent
439
- → `resume()` (`session.ts:177`), sinon `create()` (`session.ts:204`) qui tire un identifiant CSPRNG,
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:371`) applique l'**absolute**
443
- (âge depuis `created`) puis l'**idle** (depuis `updated`). Échec → `invalidate()` (`session.ts:290`)
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:421`),
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:1697`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts:2008`) |
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:1801`) |
510
- | Portée ALS | une requête | **handshake + toutes les frames** (`http-kernel.ts:1495`) |
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:1929`). L'identité résolue une fois est donc
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:297`).
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:378`) : reprise ou ouverture de la session, mémorisation de l'ancien identifiant, puis
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:236`).
533
+ (`Session.regenerateId()`, `session.ts:260`).
534
534
 
535
- Au passage, la provenance est capturée dans le `metaBag` : `ip` (`authFlow.ts:405`) et `ua`
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:306`) | la session courante + pierre tombale |
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:775`) | toutes les sessions d'un utilisateur |
546
- | « Mes appareils » (self) | `destroyOwnByRef()` (`sessions-service.ts:872`) | une session, **restreinte au propriétaire** |
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:775`), car supprimer en parcourant décale les rangs sous un
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:863`). Une
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:752`) |
576
- | Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts:733`) |
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:177`) |
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:388`) |
581
- | Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts:401`) |
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:872`) |
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:384`) ; un
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:530`). |
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:236`). |
602
- | Savoir si une écriture aura lieu | `session.dirty` | Le drapeau de dirty-tracking (`session.ts:128`). |
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:800`) s'applique à une classe **ou** à
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:255-264`). C'est le seul champ
611
- propagé par le kernel (`http-kernel.ts:1210`).
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:733`) |
647
- | Idle timeout | NIST SP 800-63B-4 / OWASP | défaut 1800 s, glissant par `touch` (`config.ts:831`) |
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:388`) |
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:1246`) : ni objet `Session`, ni lecture de store.
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:255`) ; le
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:95-98`).
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:1283`) |
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:255-264`) |
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.gates.ts` à la racine).
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).