@nodefony/http 10.0.0-beta.3 → 10.0.0-beta.5

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 (138) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  3. package/dist/index.js +13 -10
  4. package/dist/nodefony/command/assetsPublishCommand.js +5 -3
  5. package/dist/nodefony/command/certificatesCommand.js +5 -3
  6. package/dist/nodefony/command/networkCommand.js +5 -3
  7. package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
  8. package/dist/nodefony/config/config.js +3 -3
  9. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  10. package/dist/nodefony/interfaces/IRequestTrace.js +1 -0
  11. package/dist/nodefony/service/HttpAdminApi.js +2 -2
  12. package/dist/nodefony/service/ProfilerAdminApi.js +13 -3
  13. package/dist/nodefony/service/audit-logger.js +23 -30
  14. package/dist/nodefony/service/certificates.js +97 -13
  15. package/dist/nodefony/service/error-renderer.js +29 -12
  16. package/dist/nodefony/service/http-kernel.js +198 -50
  17. package/dist/nodefony/service/pretty-request-logger.js +15 -13
  18. package/dist/nodefony/service/request-logger.js +24 -16
  19. package/dist/nodefony/service/reverse-proxy.js +3 -3
  20. package/dist/nodefony/service/servers/clientError.js +22 -3
  21. package/dist/nodefony/service/servers/server-h2c.js +126 -0
  22. package/dist/nodefony/service/servers/server-http.js +5 -8
  23. package/dist/nodefony/service/servers/server-https.js +12 -24
  24. package/dist/nodefony/service/servers/server-static.js +3 -3
  25. package/dist/nodefony/service/servers/server-websocket-secure.js +10 -18
  26. package/dist/nodefony/service/servers/server-websocket.js +10 -18
  27. package/dist/nodefony/service/servers/serverShutdown.js +78 -18
  28. package/dist/nodefony/service/servers/wsHeartbeat.js +1 -2
  29. package/dist/nodefony/service/sessions/sessions-service.js +7 -14
  30. package/dist/nodefony/service/trace.js +1 -2
  31. package/dist/nodefony/service/upload/upload-service.js +5 -8
  32. package/dist/nodefony/service/x509.js +1 -2
  33. package/dist/nodefony/src/assets/collectAssets.js +1 -2
  34. package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
  35. package/dist/nodefony/src/context/Context.js +78 -43
  36. package/dist/nodefony/src/context/domainMatcher.js +1 -2
  37. package/dist/nodefony/src/context/forwarded.js +1 -2
  38. package/dist/nodefony/src/context/http/HttpContext.js +28 -6
  39. package/dist/nodefony/src/context/http/Request.js +33 -29
  40. package/dist/nodefony/src/context/http/Response.js +23 -6
  41. package/dist/nodefony/src/context/http/SseStream.js +1 -2
  42. package/dist/nodefony/src/context/http/parser.js +36 -10
  43. package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
  44. package/dist/nodefony/src/context/http2/Request.js +4 -8
  45. package/dist/nodefony/src/context/http2/Response.js +41 -9
  46. package/dist/nodefony/src/context/metaData.js +1 -2
  47. package/dist/nodefony/src/context/requestId.js +1 -2
  48. package/dist/nodefony/src/context/requestTrace.js +104 -0
  49. package/dist/nodefony/src/context/responseEnded.js +1 -2
  50. package/dist/nodefony/src/context/timingMode.js +21 -0
  51. package/dist/nodefony/src/context/transport.js +45 -0
  52. package/dist/nodefony/src/context/trustProxy.js +1 -2
  53. package/dist/nodefony/src/context/unsentResponse.js +30 -0
  54. package/dist/nodefony/src/context/websocket/Response.js +33 -6
  55. package/dist/nodefony/src/context/websocket/WebsocketContext.js +104 -25
  56. package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
  57. package/dist/nodefony/src/context/websocket/websocketUrl.js +1 -2
  58. package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
  59. package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
  60. package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
  61. package/dist/nodefony/src/cookies/cookie.js +3 -7
  62. package/dist/nodefony/src/errors/httpError.js +1 -2
  63. package/dist/nodefony/src/errors/thrownMessage.js +1 -2
  64. package/dist/nodefony/src/profiler/FrameProfile.js +23 -2
  65. package/dist/nodefony/src/profiler/Profiler.js +104 -32
  66. package/dist/nodefony/src/profiler/aggregate.js +442 -0
  67. package/dist/nodefony/src/proxy/forward.js +29 -5
  68. package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
  69. package/dist/nodefony/src/proxy/rules.js +12 -6
  70. package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
  71. package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
  72. package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
  73. package/dist/nodefony/src/servers/http2Limits.js +50 -0
  74. package/dist/nodefony/src/servers/kernelServers.js +12 -3
  75. package/dist/nodefony/src/servers/portBinder.js +41 -10
  76. package/dist/nodefony/src/servers/upgradeDispatch.js +86 -7
  77. package/dist/nodefony/src/session/session.js +2 -3
  78. package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
  79. package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
  80. package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
  81. package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
  82. package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
  83. package/dist/types/index.d.ts +9 -3
  84. package/dist/types/nodefony/config/config.d.ts +4 -0
  85. package/dist/types/nodefony/interfaces/IContext.d.ts +72 -4
  86. package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
  87. package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +56 -0
  88. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +8 -0
  89. package/dist/types/nodefony/interfaces/IRouting.d.ts +26 -0
  90. package/dist/types/nodefony/interfaces/index.d.ts +3 -2
  91. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +3 -0
  92. package/dist/types/nodefony/service/audit-logger.d.ts +3 -11
  93. package/dist/types/nodefony/service/certificates.d.ts +55 -4
  94. package/dist/types/nodefony/service/error-renderer.d.ts +31 -0
  95. package/dist/types/nodefony/service/http-kernel.d.ts +96 -10
  96. package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
  97. package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
  98. package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
  99. package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
  100. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -3
  101. package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -3
  102. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
  103. package/dist/types/nodefony/src/context/Context.d.ts +38 -5
  104. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +16 -2
  105. package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
  106. package/dist/types/nodefony/src/context/http/Response.d.ts +11 -0
  107. package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
  108. package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
  109. package/dist/types/nodefony/src/context/http2/Response.d.ts +27 -5
  110. package/dist/types/nodefony/src/context/requestTrace.d.ts +64 -0
  111. package/dist/types/nodefony/src/context/timingMode.d.ts +23 -0
  112. package/dist/types/nodefony/src/context/transport.d.ts +53 -0
  113. package/dist/types/nodefony/src/context/unsentResponse.d.ts +16 -0
  114. package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
  115. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +58 -8
  116. package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
  117. package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +28 -12
  118. package/dist/types/nodefony/src/profiler/Profiler.d.ts +79 -111
  119. package/dist/types/nodefony/src/profiler/aggregate.d.ts +132 -0
  120. package/dist/types/nodefony/src/proxy/forward.d.ts +14 -2
  121. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
  122. package/dist/types/nodefony/src/proxy/rules.d.ts +3 -0
  123. package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
  124. package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
  125. package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
  126. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +33 -3
  127. package/docs/cookies.md +85 -78
  128. package/docs/diagnostics-channel.md +303 -0
  129. package/docs/index.md +6 -1
  130. package/docs/observabilite.md +159 -81
  131. package/docs/rate-limit.md +83 -83
  132. package/docs/reverse-proxy.md +72 -70
  133. package/docs/servers.md +338 -224
  134. package/docs/session.md +179 -179
  135. package/docs/sse.md +24 -20
  136. package/docs/upload.md +120 -117
  137. package/package.json +8 -9
  138. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,6 +1,6 @@
1
1
  import { type IncomingMessage } from "node:http";
2
2
  import type { Duplex } from "node:stream";
3
- import type { WebSocketServer } from "ws";
3
+ import type { WebSocket, WebSocketServer } from "ws";
4
4
  /** Serveur qui émet `upgrade` : HTTP/1.1, HTTPS ou HTTP/2 sécurisé (`allowHTTP1`). */
5
5
  interface IUpgradeEmitter {
6
6
  on(event: "upgrade", listener: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): unknown;
@@ -11,6 +11,31 @@ interface IUpgradeRelay {
11
11
  readonly mounts: readonly unknown[] | null;
12
12
  handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): boolean;
13
13
  }
14
+ /**
15
+ * Le verdict d'un upgrade, rendu par le noyau APRÈS sa décision — qui peut
16
+ * attendre (session, pare-feu, garde de l'action). Un seul verdict compte : le
17
+ * premier appel l'emporte, les suivants sont ignorés.
18
+ */
19
+ export interface IUpgradeAdmission {
20
+ /**
21
+ * Termine le handshake (`101`) et rend la socket ouverte.
22
+ *
23
+ * @returns la socket DIRECTEMENT quand `ws` la rend tout de suite (le cas
24
+ * nominal : aucune promesse, aucune micro-tâche), sinon sa promesse ;
25
+ * `null` si le client est parti pendant la décision (ou si `ws` a rejeté
26
+ * le handshake) — il n'y a alors rien à servir
27
+ */
28
+ accept(): WebSocket | null | Promise<WebSocket | null>;
29
+ /**
30
+ * Refuse par un statut HTTP, AVANT le `101` (RFC 6455 §4.2.2).
31
+ *
32
+ * @param status - statut du refus (`401`, `403`, `429`…)
33
+ * @param headers - en-têtes du refus (`WWW-Authenticate`, `Retry-After`)
34
+ */
35
+ refuse(status: number, headers?: Readonly<Record<string, string | number>> | null): void;
36
+ }
37
+ /** Ce qui décide d'un upgrade : reçoit la requête et rend son verdict, plus tard s'il le faut. */
38
+ export type UpgradeAdmit = (req: IncomingMessage, admission: IUpgradeAdmission) => void;
14
39
  /**
15
40
  * Pose l'UNIQUE écouteur `upgrade` d'un serveur : le proxy inverse d'abord,
16
41
  * puis le serveur WebSocket de Nodefony.
@@ -24,7 +49,10 @@ interface IUpgradeRelay {
24
49
  *
25
50
  * Entre les deux, `refusal` tranche ce qui doit être refusé AVANT le `101`
26
51
  * (RFC 6455 §4.2.2) : un chemin sans route WebSocket reçoit un statut HTTP,
27
- * jamais une ouverture suivie d'une fermeture.
52
+ * jamais une ouverture suivie d'une fermeture. Puis la FORME du handshake est
53
+ * contrôlée (`websocketHandshakeProblem`, la règle du proxy inverse) : un
54
+ * handshake malformé reçoit `400` sans jamais atteindre le noyau. Enfin
55
+ * `admit` décide — pare-feu, garde de l'action — et seul son verdict ouvre.
28
56
  *
29
57
  * Sans montage, le coût par connexion est une lecture de champ.
30
58
  *
@@ -33,7 +61,9 @@ interface IUpgradeRelay {
33
61
  * @param relay - proxy inverse, lu à chaque upgrade (il peut naître après)
34
62
  * @param refusal - statut HTTP qui refuse l'upgrade, `null` pour l'admettre
35
63
  * (cf `HttpKernel.websocketUpgradeRefusal`)
64
+ * @param admit - décide d'un upgrade admis (cf `HttpKernel.onWebsocketUpgrade`) ;
65
+ * par défaut, tout upgrade conforme est accepté et émis en `connection`
36
66
  * @returns la fonction qui retire l'écouteur — à appeler à l'arrêt
37
67
  */
38
- export declare function attachUpgradeDispatch(server: IUpgradeEmitter, wss: WebSocketServer, relay: () => IUpgradeRelay | null, refusal: (req: IncomingMessage) => number | null): () => void;
68
+ export declare function attachUpgradeDispatch(server: IUpgradeEmitter, wss: WebSocketServer, relay: () => IUpgradeRelay | null, refusal: (req: IncomingMessage) => number | null, admit?: UpgradeAdmit): () => void;
39
69
  export {};
package/docs/cookies.md CHANGED
@@ -112,20 +112,20 @@ Nodefony ne se contente pas de proposer ces attributs : il **choisit des défaut
112
112
  les invariants** que le navigateur exigerait de toute façon — pour que l'erreur ne parte pas sur le fil.
113
113
 
114
114
  **Le défaut est fermé.** Un cookie créé sans options est `Secure` + `HttpOnly` + `SameSite=Lax`
115
- (`cookieDefaultSettings`, `cookie.ts:39`). Il faut **choisir** d'ouvrir (ex. `httpOnly: false` pour un
115
+ (`cookieDefaultSettings`, `cookie.ts#cookieDefaultSettings`). Il faut **choisir** d'ouvrir (ex. `httpOnly: false` pour un
116
116
  cookie lu en JS), jamais choisir de fermer.
117
117
 
118
118
  **Les préfixes sont forcés, pas espérés.** Nommer un cookie `__Host-…` ne suffit pas : `serialize()`
119
- (`cookie.ts:393`) **réécrit** la sortie — `Secure` ajouté, `Path=/` imposé, `Domain` retiré — pour que le
119
+ (`cookie.ts#Cookie.serialize`) **réécrit** la sortie — `Secure` ajouté, `Path=/` imposé, `Domain` retiré — pour que le
120
120
  navigateur ne rejette jamais le cookie en silence. Idem `__Secure-` (Secure imposé) et `SameSite=None` (qui
121
- impose `Secure`, `cookie.ts:393`).
121
+ impose `Secure`, `cookie.ts#Cookie.secure`).
122
122
 
123
123
  **La signature refuse le secret prévisible.** Un cookie `signed: true` sans secret configuré **jette**
124
- (`setValue()`, `cookie.ts:205`) : signer avec le secret public par défaut ne protégerait rien
125
- (fail-closed). La vérification est **timing-safe** (`unsign()` → `crypto.timingSafeEqual`, `cookie.ts:380`).
124
+ (`setValue()`, `cookie.ts#Cookie.setValue`) : signer avec le secret public par défaut ne protégerait rien
125
+ (fail-closed). La vérification est **timing-safe** (`unsign()` → `crypto.timingSafeEqual`, `cookie.ts#Cookie.unsign`).
126
126
 
127
127
  **`SameSite` retombe toujours sur `Lax`, jamais sur `None`.** Toute valeur inconnue est normalisée vers
128
- `Lax` (`setSameSite()`, `cookie.ts:250`) : `None` désactive la protection CSRF, ce n'est jamais un défaut.
128
+ `Lax` (`setSameSite()`, `cookie.ts#Cookie.setSameSite`) : `None` désactive la protection CSRF, ce n'est jamais un défaut.
129
129
 
130
130
  **HTTP et WebSocket lisent les mêmes cookies.** Le parseur tourne pour les deux transports ; en revanche la
131
131
  poignée de main WebSocket **ne peut pas** écrire de cookie (limite de la bibliothèque `ws`) — voir plus bas.
@@ -201,10 +201,13 @@ Pour qu'une valeur ne puisse pas être **falsifiée** côté client (sans la chi
201
201
  secret **configuré** (le secret par défaut est refusé) :
202
202
 
203
203
  ```typescript ignore
204
- // fragment — un secret prévisible est refusé (fail-closed)
204
+ // fragment — un secret prévisible est refusé (fail-closed).
205
+ // `NF_COOKIE_SECRET` se déclare dans `env.ts` (`envString`), seul lecteur de l'environnement.
206
+ import { env } from "../../env";
207
+
205
208
  const c = new Cookie("pref", "v1", {
206
209
  signed: true,
207
- secret: process.env.COOKIE_SECRET!,
210
+ secret: env.NF_COOKIE_SECRET,
208
211
  });
209
212
  // c.value === "s:v1.<hmac base64url>" ; c.unsign() renvoie "v1" ou false si altéré
210
213
  ```
@@ -218,120 +221,124 @@ suit montre l'**usage réel**. La classe s'importe `import { Cookie } from "@nod
218
221
  ### Construire un cookie et ses attributs
219
222
 
220
223
  Le constructeur accepte `(nom, valeur, options?)` ou **un cookie à copier** (surcharge,
221
- `cookie.ts:149`). Les options fusionnent avec les défauts sûrs.
222
-
223
- | Option | Type | Défaut | Effet |
224
- | ---------- | -------------------------- | ------------ | ---------------------------------------------------------------------- |
225
- | `maxAge` | `number` (secondes) | `0` | Durée de vie. `0` = cookie de session (ni `Max-Age` ni `Expires`). |
226
- | `expires` | `Date \| string \| number` | — | Date d'expiration absolue (`setExpires()`, `cookie.ts:272`). |
227
- | `path` | `string` | `/` | Préfixe d'URL de portée. |
228
- | `domain` | `string` | `undefined` | Domaine de portée (retiré si nom `__Host-`). |
229
- | `secure` | `boolean` | `true` | HTTPS only. Forcé si `SameSite=None` ou préfixe `__Host-`/`__Secure-`. |
230
- | `httpOnly` | `boolean` | `true` | Invisible à `document.cookie` (anti-XSS). |
231
- | `sameSite` | `SameSiteType` | `Lax` | `Strict`/`Lax`/`None`. Toute autre valeur retombe sur `Lax`. |
232
- | `signed` | `boolean` | `false` | Signe la valeur (HMAC). Exige `secret` configuré, sinon **jette**. |
233
- | `secret` | `string` | (par défaut) | Clé HMAC. Le secret par défaut est **refusé** pour signer. |
234
- | `priority` | `High \| Medium \| Low` | `undefined` | Attribut `Priority` (`setPriority()`, `cookie.ts:305`). |
235
-
236
- Les défauts sont matérialisés dans `cookieDefaultSettings` (`cookie.ts:39`) ; la fusion se fait dans le
237
- constructeur de `Cookie` (`cookie.ts:130`).
224
+ `cookie.ts#Cookie.constructor`). Les options fusionnent avec les défauts sûrs.
225
+
226
+ | Option | Type | Défaut | Effet |
227
+ | ---------- | -------------------------- | ------------ | -------------------------------------------------------------------------- |
228
+ | `maxAge` | `number` (secondes) | `0` | Durée de vie. `0` = cookie de session (ni `Max-Age` ni `Expires`). |
229
+ | `expires` | `Date \| string \| number` | — | Date d'expiration absolue (`setExpires()`, `cookie.ts#Cookie.setExpires`). |
230
+ | `path` | `string` | `/` | Préfixe d'URL de portée. |
231
+ | `domain` | `string` | `undefined` | Domaine de portée (retiré si nom `__Host-`). |
232
+ | `secure` | `boolean` | `true` | HTTPS only. Forcé si `SameSite=None` ou préfixe `__Host-`/`__Secure-`. |
233
+ | `httpOnly` | `boolean` | `true` | Invisible à `document.cookie` (anti-XSS). |
234
+ | `sameSite` | `SameSiteType` | `Lax` | `Strict`/`Lax`/`None`. Toute autre valeur retombe sur `Lax`. |
235
+ | `signed` | `boolean` | `false` | Signe la valeur (HMAC). Exige `secret` configuré, sinon **jette**. |
236
+ | `secret` | `string` | (par défaut) | Clé HMAC. Le secret par défaut est **refusé** pour signer. |
237
+ | `priority` | `High \| Medium \| Low` | `undefined` | Attribut `Priority` (`setPriority()`, `cookie.ts#Cookie.setPriority`). |
238
+
239
+ Les défauts sont matérialisés dans `cookieDefaultSettings` (`cookie.ts#cookieDefaultSettings`) ; la fusion se fait dans le
240
+ constructeur de `Cookie` (`cookie.ts#Cookie.options`).
238
241
 
239
242
  ### Sérialiser : `serialize()` et `serializeWebSocket()`
240
243
 
241
- - `serialize()` (`cookie.ts:393`) produit la **ligne `Set-Cookie`** complète (attributs dans l'ordre,
244
+ - `serialize()` (`cookie.ts#Cookie.serialize`) produit la **ligne `Set-Cookie`** complète (attributs dans l'ordre,
242
245
  préfixes forcés, `Max-Age` seulement s'il est positif).
243
- - `serializeWebSocket()` (`cookie.ts:435`) produit un **objet** `IWsCookie` (mêmes invariants de sécurité)
246
+ - `serializeWebSocket()` (`cookie.ts#Cookie.serializeWebSocket`) produit un **objet** `IWsCookie` (mêmes invariants de sécurité)
244
247
  — utilisé quand un cookie doit être décrit hors en-tête HTTP.
245
- - `toString()` (`cookie.ts:325`) ne rend que `nom=valeurEncodée` (sans attributs).
248
+ - `toString()` (`cookie.ts#Cookie.toString`) ne rend que `nom=valeurEncodée` (sans attributs).
246
249
 
247
250
  ### Signer / vérifier : `sign()` et `unsign()`
248
251
 
249
- - `sign(val, secret)` (`cookie.ts:331`) → `val.base64url(HMAC-SHA256)` — la valeur d'origine est
252
+ - `sign(val, secret)` (`cookie.ts#Cookie.sign`) → `val.base64url(HMAC-SHA256)` — la valeur d'origine est
250
253
  **préservée** (récupérable), la signature garantit l'intégrité.
251
- - `unsign(val?, secret?)` (`cookie.ts:355`) → la valeur en clair si la signature est valide, sinon `false`.
252
- Comparaison **timing-safe** (`cookie.ts:380`) ; tolère le préfixe marqueur `s:`.
254
+ - `unsign(val?, secret?)` (`cookie.ts#Cookie.unsign`) → la valeur en clair si la signature est valide, sinon `false`.
255
+ Comparaison **timing-safe** (`cookie.ts#Cookie.unsign`) ; tolère le préfixe marqueur `s:`.
253
256
 
254
257
  ### Lire et écrire depuis le contexte
255
258
 
256
- | Besoin | Appel | Où |
257
- | ------------------------------ | ----------------------------------------------------- | ---------------------------------------- |
258
- | Lire un cookie entrant | `context.getRequestCookies("nom")` → `Cookie \| null` | `getRequestCookies()` (`Context.ts:706`) |
259
- | Lire tous les cookies entrants | `context.cookies` → `Record<string, Cookie>` | `cookies` (`Context.ts:193`) |
260
- | Écrire un cookie sortant | `context.setCookie(new Cookie(…))` | `setCookie()` (`Context.ts:717`) |
261
- | Supprimer un cookie sortant | `response.deleteCookieByName("nom")` | `http/Response.ts:233` |
259
+ | Besoin | Appel | Où |
260
+ | ------------------------------ | ----------------------------------------------------- | -------------------------------------------------------------- |
261
+ | Lire un cookie entrant | `context.getRequestCookies("nom")` → `Cookie \| null` | `getRequestCookies()` (`Context.ts#Context.getRequestCookies`) |
262
+ | Lire tous les cookies entrants | `context.cookies` → `Record<string, Cookie>` | `cookies` (`Context.ts#Context.cookies`) |
263
+ | Écrire un cookie sortant | `context.setCookie(new Cookie(…))` | `setCookie()` (`Context.ts#Context.setCookie`) |
264
+ | Supprimer un cookie sortant | `response.deleteCookieByName("nom")` | `http/Response.ts#HttpResponse.deleteCookieByName` |
262
265
 
263
- Côté réponse HTTP, `addCookie()` (`http/Response.ts:215`) enregistre le cookie, et `setCookies()`
264
- (`http/Response.ts:241`) émet **une ligne `Set-Cookie` par cookie** — un tableau passé à Node, jamais une
266
+ Côté réponse HTTP, `addCookie()` (`http/Response.ts#HttpResponse.addCookie`) enregistre le cookie, et `setCookies()`
267
+ (`http/Response.ts#HttpResponse.setCookies`) émet **une ligne `Set-Cookie` par cookie** — un tableau passé à Node, jamais une
265
268
  boucle de `setHeader` (qui écraserait tout sauf le dernier). Pour expirer un cookie chez le client :
266
- `clearCookie()` (`cookie.ts:200`) recule `Expires` à l'époque.
269
+ `clearCookie()` (`cookie.ts#Cookie.clearCookie`) recule `Expires` à l'époque.
267
270
 
268
271
  ### Parsing des cookies entrants
269
272
 
270
- `cookiesParser(context)` (`cookie.ts:87`) lit l'en-tête `Cookie:` (via la bibliothèque `cookie`,
271
- `parser()` `cookie.ts:50`), crée un `Cookie` par entrée et l'ajoute au contexte avec `addRequestCookie()`
272
- (`Context.ts:696`). Il est déclenché automatiquement par le pipeline : `parseCookies()` est appelé à
273
- l'initialisation du contexte HTTP (`HttpContext.ts:255`) **et** WebSocket (`WebsocketContext.ts:170`).
273
+ `cookiesParser(context)` (`cookie.ts#cookiesParser`) lit l'en-tête `Cookie:` (via la bibliothèque `cookie`,
274
+ `parser()` `cookie.ts#parser`), crée un `Cookie` par entrée et l'ajoute au contexte avec `addRequestCookie()`
275
+ (`Context.ts#Context.addRequestCookie`). Il est déclenché automatiquement par le pipeline : `parseCookies()` est appelé à
276
+ l'initialisation du contexte HTTP (`HttpContext.ts#HttpContext.constructor`) **et** WebSocket (`WebsocketContext.ts#WebsocketContext.constructor`).
274
277
 
275
278
  ### Côté WebSocket — lecture oui, écriture non
276
279
 
277
280
  Les cookies **entrants** sont lus au handshake, exactement comme en HTTP (même parseur). Mais la **poignée
278
281
  de main WebSocket ne peut pas poser de cookie** : `setCookie()` et `setCookies()` de la réponse WS sont des
279
- **no-op** (`websocket/Response.ts:315`), une limite de la bibliothèque `ws`. Le cookie de session, lui, est
282
+ **no-op** (`websocket/Response.ts#WebsocketResponse.setCookies`), une limite de la bibliothèque `ws`. Le cookie de session, lui, est
280
283
  posé pendant la **phase HTTP** qui précède l'upgrade. La forme d'un cookie décrit pour le WS est
281
- `IWsCookie` (`ICookie.ts:19`), produite par `serializeWebSocket()`.
284
+ `IWsCookie` (`ICookie.ts#IWsCookie`), produite par `serializeWebSocket()`.
282
285
 
283
286
  ## ⚙️ Configuration
284
287
 
285
288
  Les cookies **applicatifs** ne se configurent pas par schéma : on les construit dans le code, avec les
286
- défauts sûrs de `cookieDefaultSettings` (`cookie.ts:39`). Le seul cookie **piloté par la config** est celui
287
- de la **session** — bloc Zod `sessionCookieSchema` (`config.ts:769`), avec notamment `hostPrefix`
288
- (`config.ts:791`) qui décide du préfixe `__Host-`. Tout cela est documenté dans [Sessions](session.md) :
289
+ défauts sûrs de `cookieDefaultSettings` (`cookie.ts#cookieDefaultSettings`). Le seul cookie **piloté par la config** est celui
290
+ de la **session** — bloc Zod `sessionCookieSchema` (`config.ts#sessionCookieSchema`), avec notamment `hostPrefix`
291
+ (`config.ts#sessionCookieSchema`) qui décide du préfixe `__Host-`. Tout cela est documenté dans [Sessions](session.md) :
289
292
  cette page ne le duplique pas.
290
293
 
291
294
  Le nom effectif du cookie de session (avec ou sans `__Host-` selon le transport) est calculé par
292
- `getSessionCookieName()` (`Context.ts:757`) — encore un détail qui appartient à la page Sessions.
295
+ `getSessionCookieName()` (`Context.ts#Context.getSessionCookieName`) — encore un détail qui appartient à la page Sessions.
293
296
 
294
297
  ## 🛡️ Défenses par attribut
295
298
 
296
- | Attribut / mécanisme | Faille bloquée | Comment Nodefony l'applique |
297
- | -------------------------- | --------------------------------------- | ------------------------------------------------------------------------ |
298
- | `HttpOnly` (défaut `true`) | Vol de cookie par **XSS** | Défaut fermé (`cookieDefaultSettings`, `cookie.ts:39`) |
299
- | `SameSite=Lax` (défaut) | **CSRF** inter-site | Fallback toujours `Lax`, jamais `None` (`setSameSite()` `cookie.ts:250`) |
300
- | `Secure` (défaut `true`) | Interception en clair | Forcé aussi par `None`/préfixes (`serialize()` `cookie.ts:393`) |
301
- | Préfixe `__Host-` | **Session fixation** cross-sous-domaine | `Domain` retiré + `Path=/` imposés (`serialize()` `cookie.ts:393`) |
302
- | Cookie signé (HMAC) | Altération de la valeur côté client | `sign()`/`unsign()` timing-safe (`cookie.ts:355`, `cookie.ts:355`) |
303
- | Refus du secret par défaut | Signature « fantôme » sans protection | Fail-closed à la signature (`setValue()` `cookie.ts:205`) |
299
+ | Attribut / mécanisme | Faille bloquée | Comment Nodefony l'applique |
300
+ | -------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------- |
301
+ | `HttpOnly` (défaut `true`) | Vol de cookie par **XSS** | Défaut fermé (`cookieDefaultSettings`, `cookie.ts#cookieDefaultSettings`) |
302
+ | `SameSite=Lax` (défaut) | **CSRF** inter-site | Fallback toujours `Lax`, jamais `None` (`setSameSite()` `cookie.ts#Cookie.setSameSite`) |
303
+ | `Secure` (défaut `true`) | Interception en clair | Forcé aussi par `None`/préfixes (`serialize()` `cookie.ts#Cookie.serialize`) |
304
+ | Préfixe `__Host-` | **Session fixation** cross-sous-domaine | `Domain` retiré + `Path=/` imposés (`serialize()` `cookie.ts#Cookie.serialize`) |
305
+ | Cookie signé (HMAC) | Altération de la valeur côté client | `sign()`/`unsign()` timing-safe (`cookie.ts#Cookie.unsign`) |
306
+ | Refus du secret par défaut | Signature « fantôme » sans protection | Fail-closed à la signature (`setValue()` `cookie.ts#Cookie.setValue`) |
304
307
 
305
308
  ## 📜 Normes appliquées
306
309
 
307
- | Domaine | Norme | Ancrage |
308
- | ---------------------------------- | -------------------- | ------------------------------------------------------------------ |
309
- | Cookies — syntaxe `Set-Cookie` | RFC 6265bis | `serialize()` (`cookie.ts:393`) |
310
- | `SameSite` — 3 valeurs canoniques | RFC 6265bis §5.4.7 | `SameSiteType` (`ICookie.ts:3`), `setSameSite()` (`cookie.ts:250`) |
311
- | Préfixes `__Host-` / `__Secure-` | RFC 6265bis §4.1.3 | `serialize()` force les contraintes (`cookie.ts:393`) |
312
- | `SameSite=None` impose `Secure` | RFC 6265bis | `serialize()` (`cookie.ts:393`) |
313
- | Intégrité — HMAC-SHA256, base64url | RFC 2104 / RFC 4648 | `sign()` (`cookie.ts:331`) |
314
- | Vérification à temps constant | bonne pratique OWASP | `unsign()` → `timingSafeEqual` (`cookie.ts:380`) |
310
+ | Domaine | Norme | Ancrage |
311
+ | ---------------------------------- | -------------------- | -------------------------------------------------------------------------------------------- |
312
+ | Cookies — syntaxe `Set-Cookie` | RFC 6265bis | `serialize()` (`cookie.ts#Cookie.serialize`) |
313
+ | `SameSite` — 3 valeurs canoniques | RFC 6265bis §5.4.7 | `SameSiteType` (`ICookie.ts#SameSiteType`), `setSameSite()` (`cookie.ts#Cookie.setSameSite`) |
314
+ | Préfixes `__Host-` / `__Secure-` | RFC 6265bis §4.1.3 | `serialize()` force les contraintes (`cookie.ts#Cookie.serialize`) |
315
+ | `SameSite=None` impose `Secure` | RFC 6265bis | `serialize()` (`cookie.ts#Cookie.serialize`) |
316
+ | Intégrité — HMAC-SHA256, base64url | RFC 2104 / RFC 4648 | `sign()` (`cookie.ts#Cookie.sign`) |
317
+ | Vérification à temps constant | bonne pratique OWASP | `unsign()` → `timingSafeEqual` (`cookie.ts#Cookie.unsign`) |
315
318
 
316
319
  ## ⚠️ Pièges (symptôme → cause → correction)
317
320
 
318
- | Symptôme | Cause | Correction |
319
- | --------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
320
- | Le cookie posé en HTTP clair n'est pas stocké | Défaut `Secure: true` → le navigateur exige HTTPS (sauf `localhost`) | Servir en HTTPS, ou `secure: false` en dev hors localhost |
321
- | Un cookie `__Host-` ignore mon `Domain`/`Path` | `serialize()` **force** les contraintes du préfixe | Attendu (RFC 6265bis) — renommer sans préfixe si tu veux un `Domain` |
322
- | `new Cookie(..., { signed: true })` **jette** | Aucun `secret` configuré → refus du secret prévisible (fail-closed) | Passer un `secret` réel (`{ signed: true, secret: … }`) |
323
- | Modifier un cookie entrant ne change rien côté client | Lecture (`context.cookies`) et écriture (réponse) ne sont pas symétriques | (Re)poser le cookie sur la réponse : `context.setCookie(new Cookie(…))` |
324
- | Le cookie WS posé au handshake n'arrive jamais | `setCookie`/`setCookies` de la réponse WS sont des no-op (`ws`) | Poser le cookie pendant la phase HTTP avant l'upgrade (cf session) |
325
- | Deux `Set-Cookie` s'écrasent, un seul survit | Un `setHeader('Set-Cookie', str)` remplace le précédent | Déjà géré : `setCookies()` passe un **tableau** (`http/Response.ts:241`) |
326
- | `SameSite` mal orthographié devient `Lax` silencieusement | Fallback fail-safe sur `Lax` | Attendu — vérifier la casse ; `Strict`/`Lax`/`None` seulement |
327
- | `maxAge` interprété en millisecondes | `maxAge` est en **secondes** (comme `Set-Cookie` Max-Age) | Passer des secondes (`30*24*60*60`), pas des ms |
321
+ | Symptôme | Cause | Correction |
322
+ | --------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
323
+ | Le cookie posé en HTTP clair n'est pas stocké | Défaut `Secure: true` → le navigateur exige HTTPS (sauf `localhost`) | Servir en HTTPS, ou `secure: false` en dev hors localhost |
324
+ | Un cookie `__Host-` ignore mon `Domain`/`Path` | `serialize()` **force** les contraintes du préfixe | Attendu (RFC 6265bis) — renommer sans préfixe si tu veux un `Domain` |
325
+ | `new Cookie(..., { signed: true })` **jette** | Aucun `secret` configuré → refus du secret prévisible (fail-closed) | Passer un `secret` réel (`{ signed: true, secret: … }`) |
326
+ | Modifier un cookie entrant ne change rien côté client | Lecture (`context.cookies`) et écriture (réponse) ne sont pas symétriques | (Re)poser le cookie sur la réponse : `context.setCookie(new Cookie(…))` |
327
+ | Le cookie WS posé au handshake n'arrive jamais | `setCookie`/`setCookies` de la réponse WS sont des no-op (`ws`) | Poser le cookie pendant la phase HTTP avant l'upgrade (cf session) |
328
+ | Deux `Set-Cookie` s'écrasent, un seul survit | Un `setHeader('Set-Cookie', str)` remplace le précédent | Déjà géré : `setCookies()` passe un **tableau** (`http/Response.ts#HttpResponse.setCookies`) |
329
+ | `SameSite` mal orthographié devient `Lax` silencieusement | Fallback fail-safe sur `Lax` | Attendu — vérifier la casse ; `Strict`/`Lax`/`None` seulement |
330
+ | `maxAge` interprété en millisecondes | `maxAge` est en **secondes** (comme `Set-Cookie` Max-Age) | Passer des secondes (`30*24*60*60`), pas des ms |
328
331
 
329
332
  ## 📡 Observabilité
330
333
 
331
334
  Il n'y a pas d'écran Studio dédié aux cookies applicatifs (le cookie **de session** est surfacé dans
332
335
  l'écran **Sessions**). En développement, chaque écriture de cookie est journalisée en `DEBUG` par la
333
- réponse HTTP (`ADD COOKIE ==> …`, `setCookie()` `http/Response.ts:261`) — visible via le skill
334
- `nodefony-tail-error-logs` ou le Suivi de requête. Sur le fil, un `curl -i` montre les lignes `Set-Cookie`.
336
+ réponse HTTP — nom et attributs, **valeur masquée** (`ADD COOKIE ==> nodefony=<masqué>; Path=/; …`,
337
+ `http/Response.ts#HttpResponse.setCookie`) — visible via le skill `nodefony-tail-error-logs` ou le Suivi
338
+ de requête. La valeur n'y figure jamais : ce journal se lit par le plan d'administration, et un
339
+ identifiant de session y passerait à quiconque le consulte, agent de code compris. La ligne n'est
340
+ construite que si `DEBUG` passe le seuil du journal (debug ciblé compris) : en production, rien n'est
341
+ alloué. La valeur se lit sur le fil : un `curl -i` montre les lignes `Set-Cookie`.
335
342
 
336
343
  ## 🧪 Tests & couverture
337
344
 
@@ -0,0 +1,303 @@
1
+ ---
2
+ title: "Canal de diagnostic — le cycle de chaque requête publié sur diagnostics_channel"
3
+ navTitle: Canal de diagnostic
4
+ lang: fr
5
+ module: "@nodefony/http"
6
+ topic: diagnostics-channel
7
+ section: "Cœur runtime"
8
+ audience: [developer, devops]
9
+ tags:
10
+ [
11
+ observabilite,
12
+ diagnostics_channel,
13
+ tracingchannel,
14
+ opentelemetry,
15
+ apm,
16
+ trace,
17
+ requestId,
18
+ websocket,
19
+ ]
20
+ version: "doc"
21
+ status: stable
22
+ updated: 2026-10-24
23
+ source: "src/packages/@nodefony/http/docs/diagnostics-channel.md"
24
+ coverageModule: http
25
+ coverageFiles: requestTrace.ts
26
+ ---
27
+
28
+ # Canal de diagnostic — le cycle de chaque requête publié sur `diagnostics_channel`
29
+
30
+ > Nodefony **annonce** le début et la fin de chaque requête HTTP et de chaque handshake WebSocket
31
+ > sur un canal natif de Node, `nodefony.http.request`. Un outil d'observation installé dans le même
32
+ > process — OpenTelemetry, Datadog, Sentry, ou votre propre code — s'y abonne et reçoit, sans
33
+ > monkey-patch, ce que Nodefony est seul à connaître : la **route** qui a répondu, son **gabarit**,
34
+ > le **module** signataire, le `requestId`, le statut réel et la durée. Personne n'écoute ? Le canal
35
+ > coûte une lecture de booléen par requête, et rien n'est alloué.
36
+
37
+ 📍 [Documentation](../../../../../docs/index.md) › [@nodefony/http](index.md) › **Canal de diagnostic**
38
+
39
+ ## Schéma général
40
+
41
+ ```mermaid
42
+ sequenceDiagram
43
+ participant C as Client
44
+ participant N as Nodefony (pipeline)
45
+ participant A as Abonné (agent APM)
46
+ C->>N: GET /users/42
47
+ N->>A: start — requestId, méthode, URL
48
+ N->>A: end — la partie synchrone a rendu la main
49
+ N->>C: 200 (dernier octet remis)
50
+ N->>A: asyncStart puis asyncEnd — route, gabarit, module, statut, durée
51
+ ```
52
+
53
+ Le **même objet** voyage de `start` à `asyncEnd` : l'abonné y lit à la fin ce qui n'était pas
54
+ encore connu au début.
55
+
56
+ ## Lexique
57
+
58
+ | Terme | Sens |
59
+ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
60
+ | `diagnostics_channel` | Module natif de Node (`node:diagnostics_channel`) : un bus de publication NOMMÉ, interne au process, fait pour que des outils observent une bibliothèque. |
61
+ | Canal | Un nom (`tracing:nodefony.http.request:start`) ; publieur et abonné le retrouvent par ce nom, sans s'importer l'un l'autre. |
62
+ | `TracingChannel` | La forme « trace » du bus : cinq sous-canaux (`start`, `end`, `asyncStart`, `asyncEnd`, `error`) qui encadrent UNE opération, avec un objet partagé. |
63
+ | `runStores` / `bindStore` | Un abonné lie un `AsyncLocalStorage` au canal (`bindStore`) ; le publieur entre dans ce store pour la durée de l'opération (`runStores`). |
64
+ | APM | _Application Performance Monitoring_ : agent (Datadog, New Relic, Elastic…) qui mesure et trace une application en production. |
65
+ | OTel | _OpenTelemetry_ : standard ouvert de traces, métriques et journaux, et ses conventions de nommage d'attributs (« semconv »). |
66
+ | Span | Une opération chronométrée d'une trace distribuée — ici, une requête serveur. |
67
+ | Gabarit de route | Le chemin déclaré (`/users/{id}`), pas l'URL reçue (`/users/42`) : faible cardinalité, donc agrégeable. C'est l'attribut `http.route` d'OTel. |
68
+ | `requestId` | Identifiant de corrélation de la requête (`X-Request-Id`), voir [Observabilité HTTP](observabilite.md). |
69
+
70
+ ## Qu'est-ce que c'est ?
71
+
72
+ Un **haut-parleur interne au process**. Nodefony annonce « une requête commence », puis « elle est
73
+ finie, voici son bilan ». Quiconque s'est mis à l'écoute de ce canal, dans le même process, reçoit
74
+ l'annonce — immédiatement, sur le même fil, avec le même objet : pas de copie, pas de réseau, pas
75
+ de fichier. Rien ne sort du process par lui-même : c'est l'abonné qui décide d'exporter, avec son
76
+ propre transport (OTLP vers un collecteur, agent Datadog, Sentry).
77
+
78
+ Le problème qu'il résout : sans lui, un outil de traçage ne voit d'une application Nodefony que ce
79
+ que `node:http` publie — une requête brute, sans route, sans module, sans `requestId`. Pour en savoir
80
+ plus, il devrait patcher les classes du framework à l'exécution, ce qui casse à chaque version.
81
+ Node publie bien ses propres canaux serveur (`http.server.request.start`,
82
+ `http.server.response.finish`), mais ils ne portent que la requête et la réponse brutes : ce que
83
+ Nodefony a décidé — quelle route, quel module, quel statut réel derrière un proxy — n'y figure pas.
84
+
85
+ ## La vision Nodefony
86
+
87
+ - **Un contrat, pas un crochet** : le canal et la forme de son message (`IRequestTrace`) sont une
88
+ API publique de `@nodefony/http`. Un plugin d'APM s'écrit contre eux, et non contre l'intérieur du
89
+ pipeline. C'est un contrat neuf : les agents existants ne le lisent pas d'office, un plugin doit
90
+ le consommer (comme Fastify publie `tracing:fastify.request.handler` pour les siens).
91
+ - **Zéro coût sans abonné** : la garde (`requestTrace.ts#hasRequestTraceSubscribers`) est lue UNE
92
+ fois, au début de la requête. Faux → aucun message n'est construit, la fin ne lit qu'un champ
93
+ `null` (`Context.ts#Context.requestTrace`). Pas d'option de configuration : « éteint » veut dire
94
+ « personne n'écoute ».
95
+ - **Une seule horloge** : la durée publiée est celle de la ligne d'accès (`Context.ts#Context.stopClock`),
96
+ jamais une mesure propre au canal.
97
+ - **La séquence de Node, à la main** : `start` sous `runStores` à l'entrée, `end` quand la partie
98
+ synchrone rend la main (`requestTrace.ts#runTraced`), puis `error`, `asyncStart` et `asyncEnd` au
99
+ démontage, quand le dernier octet est remis (`requestTrace.ts#closeRequestTrace`). C'est l'ordre
100
+ de `TracingChannel.tracePromise`, que les abonnés génériques attendent ; la publication est
101
+ manuelle parce que la fin d'une requête arrive sur l'événement `close` de la réponse, hors de
102
+ toute fonction qu'on pourrait envelopper.
103
+ - **Les stores des abonnés sont honorés** : un agent qui lie son `AsyncLocalStorage` au canal
104
+ (`bindStore`) voit son contexte actif pendant tout le traitement — les requêtes SQL et les appels
105
+ sortants d'une route deviennent des enfants de son span.
106
+
107
+ ## Démarrage rapide
108
+
109
+ Un abonné de dix lignes, dans un fichier chargé AVANT l'application — c'est ainsi que se chargent
110
+ les agents d'APM :
111
+
112
+ ```ts
113
+ // tracing.ts — chargé par `--import`, avant l'application
114
+ import diagnosticsChannel from "node:diagnostics_channel";
115
+ import { REQUEST_TRACE_CHANNEL } from "@nodefony/http";
116
+ import type { IRequestTrace } from "@nodefony/http";
117
+
118
+ const channel = diagnosticsChannel.tracingChannel<IRequestTrace>(
119
+ REQUEST_TRACE_CHANNEL,
120
+ );
121
+
122
+ channel.subscribe({
123
+ start: () => {},
124
+ end: () => {},
125
+ asyncStart: () => {},
126
+ asyncEnd: (trace) => {
127
+ console.log(
128
+ `${trace.requestId} ${trace.method} ${trace.routePath ?? "-"} → ${trace.statusCode} en ${trace.durationMs?.toFixed(3)} ms (${trace.module})`,
129
+ );
130
+ },
131
+ error: (trace) => {
132
+ console.error(trace.requestId, trace.error);
133
+ },
134
+ });
135
+ ```
136
+
137
+ On le charge par `NODE_OPTIONS`, que les process lancés par la commande de développement héritent :
138
+
139
+ ```bash
140
+ NODE_OPTIONS="--import ./tracing.ts" npx nodefony development
141
+ curl -k https://127.0.0.1:5152/
142
+ ```
143
+
144
+ Ce qu'on observe, une ligne par requête servie — ici la page d'accueil d'une application générée :
145
+
146
+ ```text
147
+ 6f1c…-9e2a GET / → 200 en 3.528 ms (app)
148
+ ```
149
+
150
+ Importer `REQUEST_TRACE_CHANNEL` plutôt que recopier la chaîne : le nom vit en un seul endroit.
151
+
152
+ ## Le message — `IRequestTrace`
153
+
154
+ Déclaré dans `IRequestTrace.ts#IRequestTrace`, exporté par `@nodefony/http`. Tout est en lecture
155
+ seule pour l'abonné.
156
+
157
+ | Champ | Posé à | Contenu |
158
+ | ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------ |
159
+ | `requestId` | `start` | identifiant de corrélation (celui de `X-Request-Id` entrant, sinon généré) |
160
+ | `protocol` | `start` | `"http"` pour une requête, `"websocket"` pour un handshake |
161
+ | `method` | `start` | méthode de la requête (`"WEBSOCKET"` pour un handshake) |
162
+ | `scheme` | `start` | `http`, `https`, `ws`, `wss` — tel que vu par le client, proxy de confiance compris |
163
+ | `url` | `start` | URL complète, **chaîne de requête comprise** |
164
+ | `route` | fin | nom de la route qui a répondu, `null` sans route (fichier statique, 404) |
165
+ | `routePath` | fin | gabarit de la route, préfixe compris (`/users/{id}`) ; la racine vaut `/` ; `null` sans route |
166
+ | `module` | fin | module qui a signé la réponse — le même que celui de la ligne d'accès |
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
+ | `durationMs` | fin | construction du contexte → dernier octet remis (fin du handshake en WebSocket) ; `null` si `timing.enabled: false` |
169
+ | `error` | `error` | l'exception attrapée par le pipeline, `null` sinon |
170
+
171
+ Les champs « fin » valent `null` à `start` et `end` : c'est normal, la route et le statut ne sont
172
+ pas encore connus. On les lit sur `asyncEnd`.
173
+
174
+ ### Correspondance avec OpenTelemetry
175
+
176
+ Pour qui écrit un plugin OTel, la table des attributs du span serveur (conventions sémantiques HTTP) :
177
+
178
+ | Attribut OTel | Champ |
179
+ | ------------------------------ | -------------------------------------------------------------- |
180
+ | `http.request.method` | `method` |
181
+ | `url.scheme` | `scheme` |
182
+ | `url.path` / `url.query` | découpés de `url` par l'abonné (`new URL(trace.url)`) |
183
+ | `http.route` | `routePath` — jamais `route`, qui est un nom et pas un gabarit |
184
+ | `http.response.status_code` | `statusCode` |
185
+ | `error.type` | dérivé de `error` (nom de la classe d'erreur) |
186
+ | `http.server.request.duration` | `durationMs / 1000` (la métrique OTel est en secondes) |
187
+
188
+ Un **4xx sans exception** ne publie pas `error` : pour un span serveur, OTel demande de laisser le
189
+ statut d'un 4xx non renseigné. C'est à l'abonné de classer un statut ; `statusCode` est là pour ça.
190
+
191
+ ## La séquence, sortie par sortie
192
+
193
+ | Situation | Événements publiés | `statusCode` |
194
+ | --------------------------------------- | ----------------------------------------------------- | -------------------- |
195
+ | HTTP servi | `start` · `end` · `asyncStart` · `asyncEnd` | statut de la réponse |
196
+ | HTTP dont le pipeline lève | `start` · `end` · `error` · `asyncStart` · `asyncEnd` | statut de l'erreur |
197
+ | Handshake WebSocket accepté | `start` · `end` · `asyncStart` · `asyncEnd` | `101` |
198
+ | Handshake refusé (origine, pare-feu, …) | `start` · `end` · `error` · `asyncStart` · `asyncEnd` | statut du refus |
199
+ | Client parti pendant la décision | `start` · `end` · `asyncStart` · `asyncEnd` | `null` |
200
+
201
+ La trace d'un WebSocket couvre le **handshake**, pas la vie de la connexion : elle se ferme au
202
+ `101`. Les messages qui suivent ne sont pas publiés sur ce canal.
203
+
204
+ ## Architecture interne
205
+
206
+ ```mermaid
207
+ flowchart TD
208
+ E["contexte construit"] --> G{"quelqu'un écoute ?"}
209
+ G -->|non| P["pipeline, rien d'alloué"]
210
+ G -->|oui| O["message créé, pipeline sous runStores"]
211
+ O --> F["démontage : error, asyncStart, asyncEnd"]
212
+ ```
213
+
214
+ - **Entrée HTTP** — `http-kernel.ts#HttpKernel.handleHttp` : juste après la construction du
215
+ contexte, la garde ; avec abonné, le message est créé (`requestTrace.ts#openRequestTrace`, seule
216
+ allocation du canal) et le pipeline tourne sous `start.runStores`
217
+ (`http-kernel.ts#HttpKernel.dispatchHttpContext`).
218
+ - **Fin HTTP** — `http-kernel.ts#HttpKernel.teardownHttp`, sur l'événement `close` de la réponse,
219
+ APRÈS la ligne d'accès : celle-ci a déjà recopié le statut de l'amont d'une requête relayée
220
+ (`HttpContext.ts#HttpContext.logRequest`).
221
+ - **Handshake WebSocket** — `http-kernel.ts#HttpKernel.handleWebsocket` ouvre la trace ; elle se
222
+ ferme à l'acceptation (`WebsocketContext.ts#WebsocketContext.connect`, au même point que la ligne
223
+ d'accès), au refus (`WebsocketContext.ts#WebsocketContext.refuseHandshake`, seul endroit où le
224
+ statut du refus est connu), ou en échec (`http-kernel.ts#HttpKernel.closeFailedWebsocketTrace`).
225
+ - **Idempotence** — `closeRequestTrace` remet `Context.requestTrace` à `null` avant de publier : un
226
+ handshake a plusieurs sorties, la première ferme la trace, les suivantes ne font rien.
227
+
228
+ ## Configuration
229
+
230
+ Aucune, à dessein. Lire un réglage à chaque requête coûterait autant que lire la garde elle-même,
231
+ pour un résultat identique : sans abonné, rien n'est publié. Pour couper le canal, on ne s'y abonne
232
+ pas — ou on se désabonne (`channel.unsubscribe(subscribers)`).
233
+
234
+ ## Normes appliquées
235
+
236
+ - **Documentation de `node:diagnostics_channel`** — nommage des canaux de trace en
237
+ `tracing:module.function:start` (d'où `nodefony.http.request`) ; et la règle « les événements ne
238
+ sont publiés que si l'abonné était présent **avant le début** de la trace » : un abonné qui arrive
239
+ en cours de requête ne reçoit rien de cette requête, pas même sa fin.
240
+ - **Conventions sémantiques HTTP d'OpenTelemetry** — `http.route` exige un gabarit de faible
241
+ cardinalité, d'où `routePath` ; la durée de `http.server.request.duration` doit égaler celle du
242
+ span serveur, d'où une seule horloge.
243
+
244
+ ## Sécurité
245
+
246
+ - **L'URL complète part chez l'abonné, chaîne de requête comprise** — un jeton passé en `?token=`
247
+ y figure. L'abonné est du code que l'exploitant a choisi d'installer, et les canaux natifs de
248
+ `node:http` exposent déjà la requête entière ; mais un agent qui EXPORTE l'URL doit en retirer les
249
+ paramètres sensibles. Préférer `routePath` pour nommer une opération.
250
+ - **Une exception levée par un abonné fait tomber le process** : Node la relance par
251
+ `process.nextTick` en exception non capturée, hors de la pile de Nodefony. Un abonné se protège
252
+ lui-même (`try`/`catch`).
253
+ - **Un abonné lent ralentit chaque requête** : il est appelé de façon synchrone, sur le fil du
254
+ serveur.
255
+
256
+ ## Performance & mémoire
257
+
258
+ Mesuré au micro-banc (Node 26, médiane de 7 passages) :
259
+
260
+ | Chemin | Coût par requête |
261
+ | ----------------------------- | ------------------------------------------------------------------------------------- |
262
+ | Personne n'écoute (le défaut) | une lecture de `hasSubscribers` (~1,6 ns), un champ `null` sur le contexte (8 octets) |
263
+ | Un abonné | un objet de message, l'appel des abonnés, l'entrée dans les stores liés |
264
+
265
+ `diagnostics_channel.hasSubscribers("nom")` coûterait ~41 ns : la garde lit donc le
266
+ `TracingChannel` gardé en mémoire de module, jamais une recherche par nom. Le gate mémoire
267
+ (`npm run test:memory`) est vert **sans** abonné et **avec** un abonné armé : le message ne retient
268
+ ni contexte ni scope.
269
+
270
+ ## Pièges
271
+
272
+ | Symptôme | Cause | Correction |
273
+ | ----------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------- |
274
+ | L'abonné ne reçoit rien des premières requêtes | il s'est abonné après leur début — règle des `TracingChannel` | s'abonner AVANT l'application (`--import`) |
275
+ | `route`, `statusCode` à `null` dans le gestionnaire `start` | ils ne sont connus qu'à la fin | les lire dans `asyncEnd` |
276
+ | Le tableau de bord explose en cardinalité | l'opération est nommée d'après `url` (`/users/42`, `/users/43`…) | nommer par `routePath` (`/users/{id}`) |
277
+ | Le serveur tombe sur une exception venue de l'agent | un abonné a levé : Node la relance en exception non capturée | `try`/`catch` dans chaque gestionnaire de l'abonné |
278
+ | Un 404 apparaît en erreur dans l'APM | l'abonné classe sur l'événement `error` sans regarder le statut | classer par `statusCode` ; `error` signale une exception, pas un statut |
279
+ | `durationMs` toujours `null` | `timing.enabled: false` dans la configuration de l'application | rétablir le défaut — la durée est mesurée partout, production comprise |
280
+
281
+ ## Tests & couverture
282
+
283
+ | Type | Où |
284
+ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
285
+ | Intégration — abonné réel | `integration/request-trace-channel.test.ts` — séquences HTTP servi / en erreur, handshake accepté / refusé, abonné tardif sans fin orpheline, désabonnement, même objet de bout en bout |
286
+ | Mémoire | `http/memory.test.ts` — le gate, rejoué sans abonné (le défaut) et avec la sonde armée |
287
+
288
+ L'abonné vit dans le process du serveur : la sonde `TraceChannelController` du module `test`
289
+ (`/nodefony/test/trace-channel/{arm,disarm,events}`) s'abonne à la demande. Ce qui **manque** : pas
290
+ de banc unitaire isolé de `requestTrace.ts` (exercé de bout en bout par le banc d'intégration), et
291
+ pas de cas « client parti pendant la décision » au banc (sortie couverte par lecture du code).
292
+
293
+ Suites : `npm run test:integration` (serveur requis), `npm run test:memory`. Skills associés :
294
+ `nodefony-check-memory-health`, `nodefony-load-test`.
295
+
296
+ ## Pour aller plus loin
297
+
298
+ - ⬆️ **Retour au hub** : [@nodefony/http — vue du module](index.md) · [Toute la documentation](../../../../../docs/index.md)
299
+ - 🧭 **Pages sœurs** : [Observabilité HTTP](observabilite.md) · [Serveurs](servers.md)
300
+ - La durée d'une requête et sa définition → [Observabilité HTTP](observabilite.md)
301
+ - Le trajet complet d'une requête → [pipeline-requete](../../../../../docs/architecture/pipeline-requete.md)
302
+ - Le proxy inverse (statut de l'amont) → [Proxy inverse](reverse-proxy.md)
303
+ - Les lignes du journal, sur le canal `nodefony.syslog.log` → [Journalisation](../../../../nodefony/docs/syslog.md#observer-le-journal-depuis-un-outil-tiers)