@nodefony/http 10.0.0-beta.6 → 10.0.0-beta.7

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 (96) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +3 -2
  4. package/dist/nodefony/config/config.js +25 -2
  5. package/dist/nodefony/grpc/GrpcCall.js +298 -0
  6. package/dist/nodefony/grpc/GrpcEndpoint.js +528 -0
  7. package/dist/nodefony/grpc/index.js +4 -0
  8. package/dist/nodefony/interfaces/IRpcCall.js +1 -0
  9. package/dist/nodefony/service/audit-logger.js +52 -6
  10. package/dist/nodefony/service/error-renderer.js +41 -9
  11. package/dist/nodefony/service/http-kernel.js +198 -19
  12. package/dist/nodefony/service/pretty-request-logger.js +5 -3
  13. package/dist/nodefony/service/request-logger.js +21 -6
  14. package/dist/nodefony/service/reverse-proxy.js +1 -1
  15. package/dist/nodefony/service/servers/clientError.js +26 -1
  16. package/dist/nodefony/service/servers/server-h2c.js +3 -3
  17. package/dist/nodefony/service/servers/server-http.js +3 -3
  18. package/dist/nodefony/service/servers/server-https.js +4 -4
  19. package/dist/nodefony/service/servers/server-websocket-secure.js +3 -3
  20. package/dist/nodefony/service/servers/server-websocket.js +3 -3
  21. package/dist/nodefony/service/servers/serverShutdown.js +14 -36
  22. package/dist/nodefony/service/sessions/sessions-service.js +6 -6
  23. package/dist/nodefony/service/upload/upload-service.js +2 -2
  24. package/dist/nodefony/src/context/Context.js +12 -1
  25. package/dist/nodefony/src/context/http/ByteStream.js +9 -4
  26. package/dist/nodefony/src/context/http/HttpContext.js +86 -8
  27. package/dist/nodefony/src/context/http/Request.js +96 -16
  28. package/dist/nodefony/src/context/http/Response.js +120 -17
  29. package/dist/nodefony/src/context/http/SseStream.js +4 -4
  30. package/dist/nodefony/src/context/http/StreamLifecycle.js +328 -19
  31. package/dist/nodefony/src/context/http/parser.js +29 -11
  32. package/dist/nodefony/src/context/http/webResponse.js +56 -0
  33. package/dist/nodefony/src/context/http2/Response.js +47 -8
  34. package/dist/nodefony/src/context/requestTrace.js +6 -2
  35. package/dist/nodefony/src/context/rpcOutcome.js +20 -0
  36. package/dist/nodefony/src/context/websocket/Response.js +11 -1
  37. package/dist/nodefony/src/context/websocket/WebsocketContext.js +15 -4
  38. package/dist/nodefony/src/cookies/cookie.js +3 -1
  39. package/dist/nodefony/src/errors/httpError.js +58 -25
  40. package/dist/nodefony/src/grpc/cors.js +59 -0
  41. package/dist/nodefony/src/grpc/limits.js +16 -0
  42. package/dist/nodefony/src/grpc/refusal.js +271 -0
  43. package/dist/nodefony/src/profiler/aggregate.js +2 -2
  44. package/dist/nodefony/src/proxy/forward.js +1 -1
  45. package/dist/nodefony/src/proxy/generateProxyConfig.js +91 -15
  46. package/dist/nodefony/src/servers/http2Goaway.js +76 -0
  47. package/dist/nodefony/src/servers/http2Limits.js +41 -15
  48. package/dist/nodefony/src/servers/http2StreamGrace.js +100 -0
  49. package/dist/nodefony/src/servers/receiveDeadline.js +177 -0
  50. package/dist/types/index.d.ts +5 -2
  51. package/dist/types/nodefony/config/config.d.ts +10 -0
  52. package/dist/types/nodefony/grpc/GrpcCall.d.ts +142 -0
  53. package/dist/types/nodefony/grpc/GrpcEndpoint.d.ts +98 -0
  54. package/dist/types/nodefony/grpc/index.d.ts +15 -0
  55. package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
  56. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +14 -0
  57. package/dist/types/nodefony/interfaces/IRequest.d.ts +2 -1
  58. package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +10 -0
  59. package/dist/types/nodefony/interfaces/IRouting.d.ts +19 -0
  60. package/dist/types/nodefony/interfaces/IRpcCall.d.ts +83 -0
  61. package/dist/types/nodefony/interfaces/IUpload.d.ts +51 -4
  62. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  63. package/dist/types/nodefony/service/audit-logger.d.ts +33 -3
  64. package/dist/types/nodefony/service/error-renderer.d.ts +22 -1
  65. package/dist/types/nodefony/service/http-kernel.d.ts +21 -1
  66. package/dist/types/nodefony/service/request-logger.d.ts +10 -0
  67. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +2 -2
  68. package/dist/types/nodefony/src/context/Context.d.ts +9 -1
  69. package/dist/types/nodefony/src/context/http/ByteStream.d.ts +1 -1
  70. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +56 -2
  71. package/dist/types/nodefony/src/context/http/Request.d.ts +38 -8
  72. package/dist/types/nodefony/src/context/http/Response.d.ts +61 -7
  73. package/dist/types/nodefony/src/context/http/SseStream.d.ts +1 -1
  74. package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +53 -0
  75. package/dist/types/nodefony/src/context/http/parser.d.ts +17 -3
  76. package/dist/types/nodefony/src/context/http/webResponse.d.ts +31 -0
  77. package/dist/types/nodefony/src/context/http2/Response.d.ts +35 -2
  78. package/dist/types/nodefony/src/context/requestTrace.d.ts +3 -1
  79. package/dist/types/nodefony/src/context/rpcOutcome.d.ts +11 -0
  80. package/dist/types/nodefony/src/context/websocket/Response.d.ts +6 -1
  81. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +8 -0
  82. package/dist/types/nodefony/src/errors/httpError.d.ts +29 -4
  83. package/dist/types/nodefony/src/grpc/cors.d.ts +32 -0
  84. package/dist/types/nodefony/src/grpc/limits.d.ts +14 -0
  85. package/dist/types/nodefony/src/grpc/refusal.d.ts +136 -0
  86. package/dist/types/nodefony/src/proxy/forward.d.ts +7 -0
  87. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +15 -2
  88. package/dist/types/nodefony/src/servers/http2Goaway.d.ts +41 -0
  89. package/dist/types/nodefony/src/servers/http2Limits.d.ts +27 -14
  90. package/dist/types/nodefony/src/servers/http2StreamGrace.d.ts +34 -0
  91. package/dist/types/nodefony/src/servers/receiveDeadline.d.ts +77 -0
  92. package/docs/byte-stream.md +52 -17
  93. package/docs/diagnostics-channel.md +45 -1
  94. package/docs/servers.md +57 -44
  95. package/docs/upload.md +43 -17
  96. package/package.json +26 -6
@@ -0,0 +1,41 @@
1
+ import type { ServerHttp2Session } from "node:http2";
2
+ /** Plus long délai d'annonce (ms) : un pair qui ne répond pas au PING ne retient pas la fermeture. */
3
+ export declare const ANNOUNCE_CAP_MS = 1000;
4
+ /**
5
+ * Annonce à UNE session HTTP/2 qu'elle va fermer, SANS écarter un flux déjà en route.
6
+ *
7
+ * Un PING mesure l'aller-retour, puis GOAWAY à `2^31-1`, puis l'attente de cet
8
+ * aller-retour : après elle, tout flux ouvert par le client avant l'annonce est
9
+ * arrivé, et le GOAWAY définitif (`session.close()`, dernier flux traité) peut
10
+ * suivre. RFC 9113 §6.8 : « SHOULD send an initial GOAWAY frame with the last
11
+ * stream identifier set to 2^31-1 […] After allowing time for any in-flight
12
+ * stream creation (at least one round-trip time) ». Sans annonce, un flux croisé
13
+ * avec le GOAWAY est refusé alors qu'il aurait pu être servi.
14
+ *
15
+ * L'aller-retour se MESURE avant l'annonce : après un GOAWAY, un client Node ne
16
+ * répond plus aux PING (constaté sur Node 26.13).
17
+ *
18
+ * @param session - la session à annoncer, ni fermée ni détruite
19
+ * @param settle - appelé UNE fois : après l'attente, à la fermeture de la
20
+ * session, ou aussitôt si le PING ne part pas. Un pair muet ne l'appelle
21
+ * jamais — le délai maximal appartient à l'appelant.
22
+ */
23
+ export declare function announceSessionClose(session: ServerHttp2Session, settle: () => void): void;
24
+ /**
25
+ * Ferme EN DOUCEUR une session HTTP/2 restée inactive au-delà du délai du serveur.
26
+ *
27
+ * RFC 9113 §9.1 : un serveur est « permitted to terminate idle connections »,
28
+ * et « SHOULD first send a GOAWAY ». La fermeture suit l'annonce en deux temps
29
+ * ({@link announceSessionClose}), puis `session.close()` : aucun nouveau flux,
30
+ * mais les flux en cours vont à leur terme — un flux long et muet (abonnement
31
+ * gRPC) n'est pas coupé, seuls les NOUVEAUX flux repartent sur une autre
32
+ * connexion, ce que tout client HTTP/2 fait sur GOAWAY.
33
+ *
34
+ * Le délai de la session est désarmé d'abord : Node (26.13) marque une session
35
+ * détruite AVANT de désarmer son minuteur, qui la retiendrait alors jusqu'à son
36
+ * échéance (nodejs/node#66885).
37
+ *
38
+ * @param session - la session inactive
39
+ * @param capMs - délai maximal d'attente de l'écho du PING (ms)
40
+ */
41
+ export declare function closeIdleSession(session: ServerHttp2Session, capMs?: number): void;
@@ -14,24 +14,37 @@ import type { IHttpConfig } from "../../config/config.js";
14
14
  */
15
15
  export declare function http2ServerLimits(limits: Partial<IHttpConfig["http2"]> | undefined): Pick<ServerOptions, "maxSessionMemory" | "settings">;
16
16
  /**
17
- * Borne l'inactivité des sessions d'un serveur HTTP/2 — TLS (5152) comme h2c —
18
- * et contourne un défaut de Node qui garde chaque session fermée en vie jusqu'à
19
- * l'échéance de ce délai.
17
+ * Ferme les sessions d'un serveur HTTP/2 — TLS (5152) comme h2c — restées
18
+ * inactives au-delà de `timeoutMs`, et contourne un défaut de Node qui garde
19
+ * chaque session fermée en vie jusqu'à l'échéance de ce délai.
20
20
  *
21
- * Le défaut (Node 24, 26) : `closeSession` (`internal/http2/core`) marque la
22
- * session détruite PUIS appelle `session.setTimeout(0)` ; or `setStreamTimeout`
23
- * sort aussitôt sur un objet détruit. Le minuteur n'est jamais désarmé et retient
24
- * la session (~2,4 Ko) pendant `timeoutMs` : sous un flux de connexions neuves,
25
- * connexions/s × délai × 2,4 Ko. Signalé à Node : https://github.com/nodejs/node/issues/66885
21
+ * **La fermeture est À NOUS.** Node arme chaque session par
22
+ * `session.setTimeout(server.timeout, sessionOnTimeout)` — un écouteur `once` —
23
+ * et `sessionOnTimeout` ne détruit la session que si le serveur n'a AUCUN
24
+ * écouteur `timeout` ; s'il en a un, il lui passe la main, UNE fois par session
25
+ * (`internal/http2/core`, Node 26.13). Le serveur 5152 en a toujours un
26
+ * (`routeSocketTimeout`, pour ses sockets HTTP/1.1), et ce délai doit prévenir
27
+ * l'application (`onTimeout`) : sans fermeture ici, une session inactive vivait
28
+ * jusqu'à ce que le CLIENT la ferme (#763). À l'échéance, la session est fermée
29
+ * en douceur — GOAWAY annoncé puis définitif, les flux en cours vont à leur
30
+ * terme ({@link closeIdleSession}, RFC 9113 §9.1 et §6.8).
26
31
  *
27
- * Le contournement : désarmer à la réception du GOAWAY, AVANT la destruction —
28
- * c'est ainsi que ferment un proxy et un client Node, même par `destroy()`. Rien
29
- * n'est retiré à la protection : Node n'agit déjà plus à l'échéance sur une
30
- * session fermée (`sessionOnTimeout` sort sur `closed`). Une coupure TCP sans
31
- * GOAWAY garde la rétention, bornée au délai.
32
+ * **Le contournement** (Node 24, 26) : `closeSession` marque la session détruite
33
+ * PUIS appelle `session.setTimeout(0)`, qui sort aussitôt sur un objet détruit ;
34
+ * le minuteur retient alors la session (~2,4 Ko) pendant `timeoutMs`. Il est
35
+ * désarmé à la réception du GOAWAY, AVANT la destruction — c'est ainsi que
36
+ * ferment un proxy et un client Node, même par `destroy()` ; et
37
+ * `closeIdleSession` le désarme avant de fermer. Signalé à Node :
38
+ * https://github.com/nodejs/node/issues/66885 (corrigé par nodejs/node#66886,
39
+ * absent de la 26.13). Une coupure TCP sans GOAWAY garde la rétention, bornée
40
+ * au délai.
41
+ *
42
+ * Coût par requête : nul — un écouteur par serveur, et la fermeture n'agit qu'à
43
+ * l'échéance.
32
44
  *
33
45
  * @param server - le serveur HTTP/2.
34
46
  * @param timeoutMs - délai d'inactivité ; 0 ou absent le laisse désactivé.
35
- * @param onTimeout - appelé à l'échéance (événement `timeout` du serveur).
47
+ * @param onTimeout - appelé à l'échéance d'une session (événement `timeout` du
48
+ * serveur), après le début de sa fermeture.
36
49
  */
37
50
  export declare function setHttp2SessionTimeout(server: Http2Server | Http2SecureServer, timeoutMs: number | undefined, onTimeout: () => void): void;
@@ -0,0 +1,34 @@
1
+ import { type Http2Stream } from "node:http2";
2
+ /**
3
+ * Ferme en `NO_ERROR` un flux HTTP/2 dont la réponse est partie mais dont le
4
+ * client émet encore, après la grâce d'un aller-retour (`PING`, RFC 9113 §6.7).
5
+ *
6
+ * RFC 9113 §8.1 : après une réponse complète, le serveur PEUT demander au
7
+ * client d'arrêter d'émettre par un `RST_STREAM` `NO_ERROR`. La coupe ne doit
8
+ * jamais précéder l'envoi de la réponse : un `RST_STREAM` soumis pendant que
9
+ * les en-têtes attendent encore dans la file de nghttp2 les fait JETER, et le
10
+ * client reçoit une fin de flux sans statut (un client gRPC la lit INTERNAL).
11
+ * Le `PING` part derrière la réponse ; son écho prouve qu'elle est arrivée.
12
+ *
13
+ * **Un seul `PING` par session** couvre tous les flux qui attendent : un flux
14
+ * arrivé pendant qu'un `PING` est en vol attend le suivant. Le plafond de
15
+ * `PING` en vol de Node (10 par session) n'est donc jamais atteint par ce
16
+ * chemin, et aucun flux n'est coupé sans grâce — ce que faisait la coupe
17
+ * immédiate quand `ping()` rendait `false`, au-delà de 10 refus simultanés.
18
+ *
19
+ * Session en fermeture GRACIEUSE (délai d'inactivité, arrêt) : Node n'envoie
20
+ * plus de `PING`, et la session attend la fin de ses flux — un client muet la
21
+ * retiendrait. La coupe part alors au tour de boucle suivant, le temps que les
22
+ * réponses déjà soumises quittent la file de nghttp2.
23
+ *
24
+ * Après la grâce : client qui a fini d'émettre → rien (le flux se ferme seul,
25
+ * sans `RST_STREAM`) ; sinon → coupé. L'écho d'un `PING` passe avant les autres
26
+ * trames (§6.7) : une trame DATA encore en route peut arriver après lui, la
27
+ * coupe reste conforme (§8.1).
28
+ *
29
+ * Coût : chemin d'erreur seulement — une entrée de `WeakMap` par session tant
30
+ * que des flux attendent, un `PING` par aller-retour et par session.
31
+ *
32
+ * @param stream - flux dont la réponse est ÉCRITE (`finish` passé).
33
+ */
34
+ export declare function closeStreamAfterGrace(stream: Http2Stream): void;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Une échéance ABSOLUE pour des réceptions en cours, sans un minuteur par
3
+ * réception : un seul balayage périodique pour toutes.
4
+ *
5
+ * ## Pourquoi
6
+ *
7
+ * `node:http2` ne borne pas la réception d'une requête : il n'a que des délais
8
+ * d'INACTIVITÉ (`http2stream.setTimeout`, `http2session.setTimeout`), qu'un
9
+ * client tient en vie avec un octet avant chaque échéance. `requestTimeout`,
10
+ * la borne absolue anti « slow-loris », n'existe que pour HTTP/1.1.
11
+ *
12
+ * ## Le coût, et pourquoi cette forme
13
+ *
14
+ * C'est la forme que Node donne lui-même à `requestTimeout` : aucun minuteur
15
+ * par requête, un contrôle périodique (`connectionsCheckingInterval`). Les
16
+ * réceptions suivies vivent dans un ANNEAU — un tableau des éléments et un
17
+ * `Int32Array` des tours d'entrée, indexés par un numéro de séquence :
18
+ * - suivre = deux écritures ; libérer = une comparaison et une écriture. Ni
19
+ * minuteur, ni fermeture, ni lecture d'horloge (le temps se compte en TOURS),
20
+ * ni champ ajouté à l'élément suivi ;
21
+ * - mesuré contre une `Map` (hachage d'identité de la clé) : ~26–32 ns par
22
+ * réception contre ~90–150 ns, de 1 à 10 000 réceptions simultanées.
23
+ *
24
+ * Toutes les entrées ont le même délai : l'ordre d'insertion EST l'ordre
25
+ * d'échéance. Un tour avance la tête et s'arrête à la première entrée vivante
26
+ * non échue — il ne coûte que ce qu'il expire ou saute.
27
+ *
28
+ * Contrepartie, bornée : une case libérée DERRIÈRE une entrée vivante attend
29
+ * que la tête la dépasse, soit au plus un délai de réception — 12 octets par
30
+ * réception commencée dans cette fenêtre. Sous charge, l'anneau garde sa
31
+ * capacité d'un tour à l'autre (jamais rétréci puis recrû) ; il la rend au
32
+ * premier tour sans réception nouvelle. Le minuteur unique est `unref`, et s'arrête au
33
+ * même moment.
34
+ *
35
+ * ## Précision
36
+ *
37
+ * Une entrée expire entre `timeoutMs` et `timeoutMs + periodMs` après son
38
+ * suivi — jamais AVANT le délai configuré.
39
+ *
40
+ * @typeParam T - l'élément suivi (un contexte de requête)
41
+ */
42
+ export declare class ReceiveDeadline<T extends object> {
43
+ #private;
44
+ /**
45
+ * @param timeoutMs - le délai de réception (> 0)
46
+ * @param onExpire - appelé une fois par entrée échue, déjà retirée
47
+ * @param periodMs - la période du balayage, bornée par le délai (défaut 1 s)
48
+ * @throws RangeError si `timeoutMs` ou `periodMs` n'est pas un entier > 0
49
+ */
50
+ constructor(timeoutMs: number, onExpire: (item: T) => void, periodMs?: number);
51
+ /** Cases occupées, libérées ou non, entre la tête et la queue. */
52
+ get size(): number;
53
+ /** Capacité courante de l'anneau. */
54
+ get capacity(): number;
55
+ /** Vrai tant que le balayage tourne. */
56
+ get running(): boolean;
57
+ /**
58
+ * Suit une réception qui commence ; démarre le balayage s'il dormait.
59
+ *
60
+ * @param item - la réception
61
+ * @returns le numéro à rendre à {@link ReceiveDeadline.release}
62
+ */
63
+ track(item: T): number;
64
+ /**
65
+ * Cesse de suivre une réception terminée (reçue, refusée ou échue). Sans
66
+ * effet si la case a déjà été balayée ou réattribuée : l'identité de
67
+ * l'élément est vérifiée. Le balayage ne s'arrête pas ici mais au tour
68
+ * suivant s'il ne reste rien — jamais un minuteur armé puis désarmé par
69
+ * requête.
70
+ *
71
+ * @param seq - le numéro rendu par {@link ReceiveDeadline.track}
72
+ * @param item - la réception suivie
73
+ */
74
+ release(seq: number, item: T): void;
75
+ /** Arrête le balayage et oublie toutes les réceptions (arrêt du serveur). */
76
+ close(): void;
77
+ }
@@ -181,6 +181,27 @@ L'union **peut s'agrandir** : un `switch` exhaustif sur `reason` garde une branc
181
181
 
182
182
  ### Relayer un autre service
183
183
 
184
+ **Le plus court : renvoyer la réponse de `fetch` telle quelle.** Une action qui renvoie une
185
+ `Response` Web est envoyée en flux par `HttpContext.openWebResponse()`
186
+ (`HttpContext.ts#HttpContext.openWebResponse`) — même tuyauterie que `openStream`, statut et
187
+ en-têtes de l'amont compris :
188
+
189
+ ```ts
190
+ @route("camera-live", { path: "/camera" })
191
+ camera() {
192
+ return fetch("http://camera.local/stream", { signal: this.context.signal });
193
+ }
194
+ ```
195
+
196
+ Ce que ce geste court fait pour toi (`webResponse.ts#webResponseParts`) : il recopie le statut et les
197
+ en-têtes de l'amont **sauf** `content-encoding` et `content-length` (le corps a déjà été décompressé
198
+ par `fetch`), `set-cookie` (les cookies d'un tiers ne se posent pas sous ton domaine) et les en-têtes
199
+ de connexion (RFC 9110 §7.6.1). Une `Response` que ton code CONSTRUIT (`new Response(…)`) garde, elle,
200
+ tous ses en-têtes, cookies compris — `response.type` les distingue (`"default"` contre `"basic"`).
201
+
202
+ **Le réglage fin : `renderStream`**, dès qu'il faut refuser un amont en erreur, choisir les en-têtes
203
+ recopiés ou poser des délais :
204
+
184
205
  ```ts
185
206
  @route("camera-live", { path: "/camera" })
186
207
  async camera() {
@@ -277,6 +298,17 @@ deux types de flux.
277
298
  Aucun minuteur ni appel d'horloge par morceau : la coupure tombe entre `idleTimeout` et
278
299
  1,25 × `idleTimeout`.
279
300
  4. **HEAD** : les en-têtes du GET, aucun corps, la source n'est jamais lue (toute route GET sert HEAD).
301
+ Pour ne pas lancer un producteur coûteux (ffmpeg) pour rien, passer une source VIDE en mode
302
+ octets : `Readable.from([])` seul est en mode objet, donc refusé (500).
303
+
304
+ ```ts
305
+ if (this.method === "HEAD") {
306
+ return this.renderStream(Readable.from([], { objectMode: false }), {
307
+ headers: { "content-type": "video/mp4" },
308
+ });
309
+ }
310
+ ```
311
+
280
312
  5. **Au premier morceau** : les en-têtes, puis le morceau. Sous contre-pression, la source est mise en
281
313
  pause et le minuteur `stallTimeout` armé ; il est désarmé au `drain`.
282
314
  6. **La fin passe par un seul nettoyage** (`ByteStream.ts#openByteStream`, puis
@@ -336,26 +368,29 @@ désoptimisation de V8 (`--trace-deopt`) ne cite pas `ByteStream`.
336
368
 
337
369
  ## ⚠️ Pièges (symptôme → cause → correction)
338
370
 
339
- | Symptôme | Cause | Correction |
340
- | ----------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
341
- | `TypeError : une source en mode objet` | `Readable.from([...])` est en mode objet par défaut | `Readable.from(chunks, { objectMode: false })` |
342
- | Un processus fils survit à un crash du processus Node | la destruction de la source n'a jamais eu lieu : le processus Node est mort avant | supervision de l'application (cgroup, `detached: false`, `kill` au signal) |
343
- | Coupé au bout de 30 s alors que la source est lente | `idleTimeout` (défaut 30 s) : la source n'a rien produit pendant ce délai | relever `idleTimeout`, ou `0` pour une source légitimement silencieuse |
344
- | Coupé derrière nginx au bout de 60 s | `proxy_read_timeout` (60 s) plus court que le silence de la source | garder `idleTimeout` SOUS ce délai, ou l'allonger sur le proxy |
345
- | Le flux répond 429 sans avoir rien fait | `wsMaxConnectionsPerIp` atteint — sockets, flux SSE et flux d'octets comptent ensemble | relever le plafond, ou fermer les connexions laissées ouvertes |
346
- | `content-encoding` faux sur un relais | en-têtes de l'amont recopiés, alors que `fetch` a décompressé | ne recopier que `content-type` |
371
+ | Symptôme | Cause | Correction |
372
+ | -------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
373
+ | `TypeError : une source en mode objet` | `Readable.from([...])` est en mode objet par défaut | `Readable.from(chunks, { objectMode: false })` |
374
+ | Un processus fils survit à un crash du processus Node | la destruction de la source n'a jamais eu lieu : le processus Node est mort avant | supervision de l'application (cgroup, `detached: false`, `kill` au signal) |
375
+ | Coupé au bout de 30 s alors que la source est lente | `idleTimeout` (défaut 30 s) : la source n'a rien produit pendant ce délai | relever `idleTimeout`, ou `0` pour une source légitimement silencieuse |
376
+ | Coupé derrière nginx au bout de 60 s | `proxy_read_timeout` (60 s) plus court que le silence de la source | garder `idleTimeout` SOUS ce délai, ou l'allonger sur le proxy |
377
+ | Le flux répond 429 sans avoir rien fait | `wsMaxConnectionsPerIp` atteint — sockets, flux SSE et flux d'octets comptent ensemble | relever le plafond, ou fermer les connexions laissées ouvertes |
378
+ | `content-encoding` faux sur un relais | en-têtes de l'amont recopiés, alors que `fetch` a décompressé | ne recopier que `content-type` — ou `return fetch(…)`, qui les écarte |
379
+ | Requête pendue jusqu'au 408 sur `return new Response(…)` | framework antérieur à la reconnaissance de la `Response` Web | mettre à jour, ou `return this.renderStream(response.body, …)` |
380
+ | Un cookie de l'amont n'arrive pas au navigateur | `return fetch(…)` écarte le `set-cookie` d'une réponse RELAYÉE | le reposer soi-même, en connaissance de cause (`Response.ts#HttpResponse.setCookie`) |
347
381
 
348
382
  ## 🧪 Tests & couverture
349
383
 
350
- | Type | Où |
351
- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
352
- | Parité du cycle de vie | `http/streamLifecycle.test.ts` — la MÊME table jouée sur le SSE et le flux d'octets : réponse prise, départ du client en HTTP/1.1, HTTP/2 et h2c, fin propre, session, double ouverture, client lent, durée de vie |
353
- | Parité — unitaire | `unit/streamLifecycleParity.test.ts` — client parti PENDANT la sauvegarde de session : flux né fermé, source détruite |
354
- | Intégration | `http/byteStream.test.ts` — HEAD (3 transports), coupure après le premier octet (3 transports), 500 / 502 avant, source muette 504 et coupure, `idleTimeout: 0`, source déjà finie, en-tête refusé, producteur bloqué libéré, relais `fetch` ; anneau du journal relu (une seule `ERROR`, aucun « onError on closed context », statut de la ligne d'accès) ; session sauvée deux fois, écrite une fois |
355
- | Délais réels | même fichier, derrière `NF_RUN_LONG_STREAM=1` (~80 s) — défaut d'`idleTimeout` (coupure entre 30 et 37,5 s, 504 au journal) et `idleTimeout: 0` tenu 40 s, au-delà du délai de socket du serveur |
356
- | Gardes | `http/sse-guards.attack.test.ts` — plafond par IP (429, place rendue, source refusée détruite), anonyme refusé (401) et révocation, joués sur le SSE ET le flux d'octets |
357
- | Refus — unitaire | `unit/byteStreamRefusal.test.ts` — un appel qui lève (500, 429, `RangeError`, `TypeError`) détruit la source, annule un flux web |
358
- | Mémoire | `http/memory.test.ts` — flux finis par leur source et flux abandonnés par le client : pente du tas, scopes et contextes résiduels |
384
+ | Type | Où |
385
+ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
386
+ | Parité du cycle de vie | `http/streamLifecycle.test.ts` — la MÊME table jouée sur le SSE et le flux d'octets : réponse prise, départ du client en HTTP/1.1, HTTP/2 et h2c, fin propre, session, double ouverture, client lent, durée de vie |
387
+ | Parité — unitaire | `unit/streamLifecycleParity.test.ts` — client parti PENDANT la sauvegarde de session : flux né fermé, source détruite |
388
+ | Intégration | `http/byteStream.test.ts` — HEAD (3 transports), coupure après le premier octet (3 transports), 500 / 502 avant, source muette 504 et coupure, `idleTimeout: 0`, source déjà finie, en-tête refusé, producteur bloqué libéré, relais `fetch` ; anneau du journal relu (une seule `ERROR`, aucun « onError on closed context », statut de la ligne d'accès) ; session sauvée deux fois, écrite une fois |
389
+ | `Response` Web et flux nu renvoyés | `http/webResponse.test.ts` — `return new Response(…)` (statut, en-têtes, deux cookies, en-tête de connexion écarté), `Response.json`, réponse sans corps (204), `Response.error()` (502), `return fetch(…)` d'un amont gzip (encodage et cookies de l'amont écartés), `Readable` et `ReadableStream` nus, départ du client (source détruite / annulée) — chaque cas vu ROUGE (408) sans la reconnaissance |
390
+ | Délais réels | même fichier, derrière `NF_RUN_LONG_STREAM=1` (~80 s) — défaut d'`idleTimeout` (coupure entre 30 et 37,5 s, 504 au journal) et `idleTimeout: 0` tenu 40 s, au-delà du délai de socket du serveur |
391
+ | Gardes | `http/sse-guards.attack.test.ts` — plafond par IP (429, place rendue, source refusée détruite), anonyme refusé (401) et révocation, joués sur le SSE ET le flux d'octets |
392
+ | Refus — unitaire | `unit/byteStreamRefusal.test.ts` — un appel qui lève (500, 429, `RangeError`, `TypeError`) détruit la source, annule un flux web |
393
+ | Mémoire | `http/memory.test.ts` — flux finis par leur source et flux abandonnés par le client : pente du tas, scopes et contextes résiduels |
359
394
 
360
395
  Le cas du **producteur bloqué** lance un enfant `node -e` qui écrit sans fin par `fs.writeSync(1, …)`
361
396
  — une écriture bloquante, comme ffmpeg — et vérifie qu'il sort en moins de 5 s après le départ du
@@ -167,6 +167,7 @@ seule pour l'abonné.
167
167
  | `statusCode` | fin | statut rendu : celui de l'amont pour une requête relayée, `101` pour un handshake accepté, `null` si le client est parti |
168
168
  | `durationMs` | fin | construction du contexte → dernier octet remis (fin du handshake en WebSocket) ; `null` si `timing.enabled: false` |
169
169
  | `error` | `error` | l'exception attrapée par le pipeline, `null` sinon |
170
+ | `rpc` | fin | appel RPC (gRPC, gRPC-Web, Connect) servi par la requête — `system`, `method`, `code`, `codeName` — ; `null` hors RPC |
170
171
 
171
172
  Les champs « fin » valent `null` à `start` et `end` : c'est normal, la route et le statut ne sont
172
173
  pas encore connus. On les lit sur `asyncEnd`.
@@ -188,6 +189,43 @@ Pour qui écrit un plugin OTel, la table des attributs du span serveur (conventi
188
189
  Un **4xx sans exception** ne publie pas `error` : pour un span serveur, OTel demande de laisser le
189
190
  statut d'un 4xx non renseigné. C'est à l'abonné de classer un statut ; `statusCode` est là pour ça.
190
191
 
192
+ ### Un appel RPC — gRPC, gRPC-Web, Connect
193
+
194
+ Un appel gRPC répond le plus souvent en HTTP 200 : son résultat voyage dans le trailer
195
+ `grpc-status`. `statusCode` reste le statut HTTP, vrai sur le fil ; le résultat de l'appel est dans
196
+ `rpc`, une COPIE de quatre champs, fixée au `close` de la réponse :
197
+
198
+ | Champ | Contenu |
199
+ | ---------- | ------------------------------------------------------------------------------------------ |
200
+ | `system` | protocole parlé sur le fil : `grpc`, `grpc-web` ou `connect` |
201
+ | `method` | `paquet.Service/Méthode`, tiré de la route |
202
+ | `code` | code de l'appel (0 à 16) ; `null` si l'appel n'a jamais commencé (refusé par le transport) |
203
+ | `codeName` | son nom (`OK`, `UNAVAILABLE`…), `null` avec lui |
204
+
205
+ Le code est celui que lit le client, à une course près : quand l'échéance du client expire, sa
206
+ coupure et le minuteur du serveur se croisent, et le serveur publie 4 ou 1. Une réponse que le
207
+ serveur n'a pas terminée vaut 1 `CANCELLED` — le client est parti, comme le statut 499 —, sauf une
208
+ fin qu'il a lui-même posée (échéance 4, identité révoquée 16). Un refus de l'adaptateur avant le
209
+ gestionnaire (message trop grand, encodage inconnu) porte son code. Une exception devient `error` :
210
+ un appel en erreur publie UN `error`. Rien n'est publié par message d'un flux : un flux est un span,
211
+ de l'ouverture au `close`.
212
+
213
+ Correspondance avec les conventions RPC d'OpenTelemetry :
214
+
215
+ | Attribut OTel | Champ |
216
+ | -------------------------- | ----------------------------------------------------------------------------------------------- |
217
+ | nom du span | `rpc.method` |
218
+ | `rpc.method` | `rpc.method` |
219
+ | `rpc.system.name` | `grpc` (convention stable) ou `connectrpc` (convention en développement) — au choix de l'abonné |
220
+ | `rpc.status_code` | `rpc.codeName` |
221
+ | `error.type` | dérivé de `error`, sinon `rpc.codeName` pour un code d'erreur |
222
+ | `rpc.server.call.duration` | `durationMs / 1000` — même horloge que le span |
223
+
224
+ Un code est une erreur du serveur — statut du span en erreur — pour 2, 4, 12, 13, 14 et 15 ; les
225
+ autres codes non nuls sont des refus ou des échecs du client. La ligne d'accès suit la même règle
226
+ pour sa gravité, et ajoute `RPC : <méthode> <NOM>` au statut HTTP. `traceparent` voyage dans les
227
+ métadonnées gRPC, qui sont des en-têtes HTTP : la propagation W3C existante les lit.
228
+
191
229
  ## La séquence, sortie par sortie
192
230
 
193
231
  | Situation | Événements publiés | `statusCode` |
@@ -240,6 +278,9 @@ pas — ou on se désabonne (`channel.unsubscribe(subscribers)`).
240
278
  - **Conventions sémantiques HTTP d'OpenTelemetry** — `http.route` exige un gabarit de faible
241
279
  cardinalité, d'où `routePath` ; la durée de `http.server.request.duration` doit égaler celle du
242
280
  span serveur, d'où une seule horloge.
281
+ - **Conventions sémantiques RPC d'OpenTelemetry** (`docs/rpc/rpc-spans.md`, `grpc.md`,
282
+ `connect-rpc.md`, `rpc-metrics.md`) — nom du span = `rpc.method`, jamais une valeur inventée ;
283
+ codes d'erreur du serveur gRPC (« SHOULD be considered errors ») ; durée sur l'horloge du span.
243
284
 
244
285
  ## Sécurité
245
286
 
@@ -261,11 +302,14 @@ Mesuré au micro-banc (Node 26, médiane de 7 passages) :
261
302
  | ----------------------------- | ------------------------------------------------------------------------------------- |
262
303
  | Personne n'écoute (le défaut) | une lecture de `hasSubscribers` (~1,6 ns), un champ `null` sur le contexte (8 octets) |
263
304
  | Un abonné | un objet de message, l'appel des abonnés, l'entrée dans les stores liés |
305
+ | Un appel RPC, avec un abonné | une copie de quatre champs (`rpc`), à la fin — rien par message d'un flux |
264
306
 
265
307
  `diagnostics_channel.hasSubscribers("nom")` coûterait ~41 ns : la garde lit donc le
266
308
  `TracingChannel` gardé en mémoire de module, jamais une recherche par nom. Le gate mémoire
267
309
  (`npm run test:memory`) est vert **sans** abonné et **avec** un abonné armé : le message ne retient
268
- ni contexte ni scope.
310
+ ni contexte ni scope. Un abonné peut le garder après la fin de la requête : son `error` ne
311
+ retient pas davantage la requête — une `HttpError` lit le contexte à sa construction et n'en garde
312
+ aucune référence.
269
313
 
270
314
  ## Pièges
271
315
 
package/docs/servers.md CHANGED
@@ -428,7 +428,7 @@ souvent **moins** de requêtes par seconde qu'en HTTP/1.1. Le choix se fait sur
428
428
 
429
429
  | Forme du trafic | h2c ? | Pourquoi |
430
430
  | -------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
431
- | gRPC | **requis** | gRPC n'existe qu'en HTTP/2. |
431
+ | gRPC natif derrière le proxy | **requis** | gRPC n'existe qu'en HTTP/2 ; gRPC-Web et Connect passent aussi en HTTP/1.1 — voir [gRPC](../../framework/docs/grpc.md). |
432
432
  | Beaucoup de flux longs (SSE) par pod | **oui** | En HTTP/1.1, chaque flux occupe une connexion du proxy ; en h2c, ils partagent quelques connexions. |
433
433
  | En-têtes lourds et répétés (cookies, jetons) | probable | HPACK les compresse d'une requête à l'autre. |
434
434
  | REST classique, petites réponses | **mesurer** | Souvent neutre, parfois perdant. |
@@ -473,9 +473,9 @@ automatiquement un éventuel décalage de port.
473
473
  le battement de cœur (`server-websocket.ts#Websocket.createServer`).
474
474
  - **Keep-alive** armé à la création (`startHeartbeat()`, `server-websocket.ts#Websocket.createServer`) et par connexion
475
475
  (`trackPong()`, `http-kernel.ts#HttpKernel.handleWebsocket`, juste après l'acceptation du handshake).
476
- - **Arrêt** : il s'inscrit en tête des écouteurs de terminaison
477
- (`prependOnceListener`, `server-websocket.ts#Websocket.createServer`) — l'ordre
478
- compte, voir la section Arrêt gracieux.
476
+ - **Arrêt** : il s'inscrit à l'étape « congé » de l'arrêt du trafic
477
+ (`addShutdownStep("dismiss")`, `server-websocket.ts#Websocket.createServer`), attendue avant le
478
+ drain HTTP — voir la section Arrêt gracieux.
479
479
 
480
480
  ### `server-websocket-secure` — WebSocket sur TLS
481
481
 
@@ -522,16 +522,16 @@ Défauts matérialisés dans `defaultAppConfig` (`src/nodefony/src/config/defaul
522
522
  Table dérivée de `httpServerSchema` (`config.ts#httpServerSchema`) ; la section `https` reprend les mêmes clés et en
523
523
  ajoute une (`httpsServerSchema`, `config.ts#httpsServerSchema`).
524
524
 
525
- | Option | Type | Défaut | Effet |
526
- | ---------------------------- | ----- | ------- | -------------------------------------------------------------------------------- |
527
- | `maxHeadersCount` | int | `2000` | Nombre maximum d'en-têtes par requête — anti _header flooding_. |
528
- | `keepAliveTimeout` | ms | `5000` | Délai de réutilisation de la socket TCP entre deux requêtes. |
529
- | `timeout` | ms | `30000` | Timeout global de socket, aligné sur `responseTimeout`. `0` = désactivé. |
530
- | `requestTimeout` | ms | `30000` | Délai de réception de la requête complète — **anti slow-loris**. |
531
- | `responseTimeout` | ms | `30000` | Délai d'envoi de la réponse complète (couche pipeline). |
532
- | `shutdownTimeout` | ms | `5000` | Drain au shutdown avant destruction forcée. Garder < grâce orchestrateur. |
533
- | `headers` | objet | `null` | En-têtes ajoutés à toutes les réponses. |
534
- | `rejectUnauthorized` (https) | bool | `false` | Rejette les certificats invalides. `false` en dev (auto-signés), `true` en prod. |
525
+ | Option | Type | Défaut | Effet |
526
+ | ---------------------------- | ----- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
527
+ | `maxHeadersCount` | int | `2000` | Nombre maximum d'en-têtes par requête — anti _header flooding_. |
528
+ | `keepAliveTimeout` | ms | `5000` | Délai de réutilisation de la socket TCP entre deux requêtes. |
529
+ | `timeout` | ms | `30000` | Timeout global de socket, aligné sur `responseTimeout`. En HTTP/2 (5152, h2c), une connexion inactive au-delà de ce délai est fermée par GOAWAY ; les flux en cours vont à leur terme. `0` = désactivé. |
530
+ | `requestTimeout` | ms | `30000` | Délai de réception de la requête complète — **anti slow-loris**, en HTTP/1.1 (Node) comme en HTTP/2 (framework : 408, puis flux fermé). `0` le désactive. |
531
+ | `responseTimeout` | ms | `30000` | Délai d'inactivité d'une action (couche pipeline) : au-delà, 503 et `ctx.signal` annulé. Réglable par route (`responseTimeout` d'une option de route). |
532
+ | `shutdownTimeout` | ms | `5000` | Drain au shutdown avant destruction forcée. Garder < grâce orchestrateur. |
533
+ | `headers` | objet | `null` | En-têtes ajoutés à toutes les réponses. |
534
+ | `rejectUnauthorized` (https) | bool | `false` | Rejette les certificats invalides. `false` en dev (auto-signés), `true` en prod. |
535
535
 
536
536
  Ces deux sections sont **permissives** (`z.looseObject`) : toute option supplémentaire de
537
537
  `http.Server` / `net.Server` / TLS (`insecureHTTPParser`, `ciphers`, `minVersion`…) est transmise telle
@@ -552,22 +552,23 @@ Depuis `http2Schema` (`config.ts#http2Schema`), appliqué seulement si défini
552
552
  Depuis `websocketSchema` (`config.ts#websocketSchema`). Les deux sections partagent la forme et les défauts ; le WSS
553
553
  lit `websocketSecure` (`config.ts#httpConfigSchema`).
554
554
 
555
- | Option | Type | Défaut | Effet |
556
- | ----------------------------- | ------------------- | ------- | ---------------------------------------------------------------------------------------- |
557
- | `keepaliveInterval` | ms | `20000` | Intervalle des pings — détecte les connexions zombies. |
558
- | `keepaliveGracePeriod` | ms | `10000` | Délai de grâce après un ping sans réponse avant fermeture. |
559
- | `closeTimeout` | ms | `5000` | Délai de fermeture propre avant destruction de la socket. |
560
- | `maxPayload` | octets | `1 MiB` | Taille max d'un message entrant → au-delà, **close 1009** (`config.ts#websocketSchema`). |
561
- | `allowedOrigins` | bool \| str \| list | `false` | Allowlist d'`Origin` au handshake — **anti-CSWSH** (`config.ts#websocketSchema`). |
562
- | `perMessageDeflate` | bool \| objet | `false` | Compression RFC 7692. Désactivée par défaut : coût CPU/RAM + risque de _zip bomb_. |
563
- | `skipUTF8Validation` | bool | `false` | Désactive la validation UTF-8 des frames texte (RFC 6455 §8.1). À laisser `false`. |
564
- | `autoPong` | bool | `true` | Répond automatiquement aux pings entrants (RFC 6455 §5.5.2-3). À laisser `true`. |
565
- | `allowSynchronousEvents` | bool | `true` | Émet plusieurs frames d'un même chunk réseau de façon synchrone (débit vs équité). |
566
- | `maxBackpressure` | octets | `4 MiB` | Seuil du tampon d'envoi par connexion (client lent à recevoir) — anti-OOM. |
567
- | `backpressurePolicy` | `drop` \| `close` | `drop` | Au-delà du seuil : sauter la frame, ou fermer le client (close 1013). |
568
- | `backpressureCloseAfterDrops` | frames | `1000` | Solde de frames refusées (+1 refus, −1 envoi) qui ferme en 1013 ; `0` = jamais. |
569
- | `maxFramesPerSecond` | trames / s | `0` | Trames REÇUES admises par connexion ; au-delà, écartées avant décodage. `0` = coupé. |
570
- | `frameRateCloseAfterRejects` | trames | `1000` | Solde de trames écartées (+1 écartée, −1 admise) qui ferme en 1008 ; `0` = jamais. |
555
+ | Option | Type | Défaut | Effet |
556
+ | ----------------------------- | ------------------- | ------- | ----------------------------------------------------------------------------------------------- |
557
+ | `keepaliveInterval` | ms | `20000` | Intervalle des pings — détecte les connexions zombies. |
558
+ | `keepaliveGracePeriod` | ms | `10000` | Délai de grâce après un ping sans réponse avant fermeture. |
559
+ | `closeTimeout` | ms | `5000` | Délai de fermeture propre avant destruction de la socket. |
560
+ | `maxPayload` | octets | `1 MiB` | Taille max d'un message entrant → au-delà, **close 1009** (`config.ts#websocketSchema`). |
561
+ | `allowedOrigins` | bool \| str \| list | `false` | Allowlist d'`Origin` au handshake — **anti-CSWSH** (`config.ts#websocketSchema`). |
562
+ | `perMessageDeflate` | bool \| objet | `false` | Compression RFC 7692. Désactivée par défaut : coût CPU/RAM + risque de _zip bomb_. |
563
+ | `skipUTF8Validation` | bool | `false` | Désactive la validation UTF-8 des frames texte (RFC 6455 §8.1). À laisser `false`. |
564
+ | `autoPong` | bool | `true` | Répond automatiquement aux pings entrants (RFC 6455 §5.5.2-3). À laisser `true`. |
565
+ | `maxPendingAutoPongs` | entier ≥ 0 | `60` | Pongs en attente au-delà desquels la connexion est coupée — anti-DoS mémoire. `0` = sans borne. |
566
+ | `allowSynchronousEvents` | bool | `true` | Émet plusieurs frames d'un même chunk réseau de façon synchrone (débit vs équité). |
567
+ | `maxBackpressure` | octets | `4 MiB` | Seuil du tampon d'envoi par connexion (client lent à recevoir) — anti-OOM. |
568
+ | `backpressurePolicy` | `drop` \| `close` | `drop` | Au-delà du seuil : sauter la frame, ou fermer le client (close 1013). |
569
+ | `backpressureCloseAfterDrops` | frames | `1000` | Solde de frames refusées (+1 refus, −1 envoi) qui ferme en 1013 ; `0` = jamais. |
570
+ | `maxFramesPerSecond` | trames / s | `0` | Trames REÇUES admises par connexion ; au-delà, écartées avant décodage. `0` = coupé. |
571
+ | `frameRateCloseAfterRejects` | trames | `1000` | Solde de trames écartées (+1 écartée, −1 admise) qui ferme en 1008 ; `0` = jamais. |
571
572
 
572
573
  > [!WARNING]
573
574
  > `keepalive*`, `closeTimeout`, `maxBackpressure`, `backpressurePolicy`,
@@ -855,7 +856,9 @@ serveurs (HTTP et HTTPS) servent ces chemins — un kubelet configuré en `schem
855
856
  ## Arrêt gracieux — la séquence du SIGTERM
856
857
 
857
858
  Un arrêt brutal coupe les requêtes en vol : à chaque mise à jour progressive, des utilisateurs voient
858
- une erreur. L'arrêt de Nodefony est une **séquence ordonnée**, et l'ordre est le cœur du sujet.
859
+ une erreur. L'arrêt de Nodefony est une **séquence ordonnée**, et l'ordre est le cœur du sujet. Il
860
+ est écrit dans **une seule fonction**, `http-kernel.ts#HttpKernel.drainTraffic`, l'écouteur `onDrain` du
861
+ module (ADR-0027) : les serveurs s'y inscrivent à leur création, ils n'écoutent pas le noyau.
859
862
 
860
863
  ```mermaid
861
864
  sequenceDiagram
@@ -864,21 +867,22 @@ sequenceDiagram
864
867
  participant W as server-websocket
865
868
  participant H as server-http / https
866
869
  O->>K: SIGTERM
867
- K->>K: readyz → 503 (+ shutdownDelay)
870
+ K->>K: isTerminating → readyz 503 (+ shutdownDelay)
868
871
  Note over K: le load balancer retire le pod
869
- K->>W: onTerminate (prepend)
872
+ K->>W: onDrain — étape « congé »
870
873
  W->>W: close 1001 « Going Away » à chaque client
871
- Note over W: fenêtre ~300 ms
872
- K->>H: onTerminate (once)
874
+ Note over W: fenêtre ~300 ms, fin des flux longs en même temps
875
+ K->>H: onDrain — étape « drain », serveurs ensemble
873
876
  H->>H: drain — in-flight terminées, sockets idle fermées
874
877
  Note over H: destruction forcée après shutdownTimeout
878
+ K->>K: onTerminate — sessions, base, Redis (dernier inscrit d'abord)
875
879
  K->>O: exit 0
876
880
  ```
877
881
 
878
- **Étape 1 — la disponibilité bascule en premier.** L'écouteur est posé à `onPostReady` pour être
879
- inséré **en tête** et donc s'exécuter **en premier** (`http-kernel.ts#HttpKernel.init`). `readyz` répond `503`, le
880
- load balancer cesse d'envoyer du trafic, et `shutdownDelay` laisse à cette information le temps de se
881
- propager.
882
+ **Étape 1 — la disponibilité bascule en premier.** Le noyau pose `kernel.isTerminating` avant toute
883
+ phase d'arrêt (`Kernel.ts#Kernel.isTerminating`), y compris pour un arrêt reçu pendant le démarrage :
884
+ `readyz` répond `503`, le load balancer cesse d'envoyer du trafic, et `shutdownDelay` (attendu si le
885
+ pod a été annoncé disponible) laisse à cette information le temps de se propager.
882
886
 
883
887
  **Étape 2 — les clients WebSocket sont prévenus.** `Websocket.terminate()` (`server-websocket.ts#Websocket.terminate`)
884
888
  envoie un message applicatif puis une **frame Close 1001 « Going Away »** (`server-websocket.ts#Websocket.terminate`).
@@ -895,11 +899,16 @@ indiscernable d'une panne réseau. Avec 1001, le client sait qu'il doit simpleme
895
899
  fermées.
896
900
 
897
901
  Ce qui reste au-delà de `shutdownTimeout` (défaut `5000` ms, `serverShutdown.ts#DEFAULT_SHUTDOWN_TIMEOUT`)
898
- est détruit.
902
+ est détruit. Les serveurs (HTTP, HTTPS, h2c) se drainent **ensemble** : ports distincts, aucune
903
+ dépendance — l'arrêt dure un `shutdownTimeout`, pas un par serveur.
904
+
905
+ **Étape 4 — les ressources sont libérées.** Une fois plus aucune requête en vol, `onTerminate` ferme
906
+ le stockage des sessions, les pools du proxy inverse, les connexions aux bases et à Redis — du
907
+ dernier inscrit au premier.
899
908
 
900
909
  > [!IMPORTANT]
901
- > L'ordre WS-avant-HTTP n'est pas cosmétique. Les serveurs WebSocket s'inscrivent en
902
- > `prependOnceListener` (`server-websocket.ts#Websocket.createServer`), les serveurs HTTP en `once`
910
+ > L'ordre WS-avant-HTTP n'est pas cosmétique. `drainTraffic` attend la fin de l'étape « congé »
911
+ > (`server-websocket.ts#Websocket.createServer`) avant de lancer l'étape « drain »
903
912
  > (`server-http.ts#ServerHttp.createServer`). Inversé, le drain détruirait les sockets déjà upgradées **sans** frame
904
913
  > Close : retour du 1006 pour tous les clients temps réel.
905
914
 
@@ -918,8 +927,12 @@ transport.
918
927
  Dès qu'un écouteur `clientError` est attaché, **Node cesse de fermer la socket automatiquement**. Un
919
928
  écouteur naïf (« je journalise et je passe ») transforme donc une requête malformée en **fuite de
920
929
  socket et de descripteur de fichier** — c'est-à-dire un vecteur de déni de service. Nodefony répond et
921
- ferme explicitement (`handleClientError()`, `clientError.ts#handleClientError`) : `431` si les en-têtes débordent
922
- (RFC 6585 §5), `400` sinon (`clientError.ts#handleClientError`), et rien du tout si la socket est déjà morte.
930
+ ferme explicitement (`clientError.ts#handleClientError`), avec la réponse que Node aurait donnée sans
931
+ écouteur : `431` si les en-têtes débordent (RFC 6585 §5) ou `414` si c'est la cible (RFC 9112 §3),
932
+ `413` si les extensions de chunk débordent, `408` si la réception dépasse `requestTimeout`
933
+ (RFC 9110 §15.5.9), `400` sinon. Rien n'est écrit si la socket est déjà morte — ni si une réponse en
934
+ vol a déjà envoyé ses en-têtes : une ligne de statut écrite après elle corromprait ce que lit le
935
+ client ; la socket est alors détruite, comme le fait Node.
923
936
 
924
937
  ### Connexions WebSocket zombies
925
938