@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.
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
- package/dist/index.js +13 -10
- package/dist/nodefony/command/assetsPublishCommand.js +5 -3
- package/dist/nodefony/command/certificatesCommand.js +5 -3
- package/dist/nodefony/command/networkCommand.js +5 -3
- package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
- package/dist/nodefony/config/config.js +3 -3
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/interfaces/IRequestTrace.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +2 -2
- package/dist/nodefony/service/ProfilerAdminApi.js +13 -3
- package/dist/nodefony/service/audit-logger.js +23 -30
- package/dist/nodefony/service/certificates.js +97 -13
- package/dist/nodefony/service/error-renderer.js +29 -12
- package/dist/nodefony/service/http-kernel.js +198 -50
- package/dist/nodefony/service/pretty-request-logger.js +15 -13
- package/dist/nodefony/service/request-logger.js +24 -16
- package/dist/nodefony/service/reverse-proxy.js +3 -3
- package/dist/nodefony/service/servers/clientError.js +22 -3
- package/dist/nodefony/service/servers/server-h2c.js +126 -0
- package/dist/nodefony/service/servers/server-http.js +5 -8
- package/dist/nodefony/service/servers/server-https.js +12 -24
- package/dist/nodefony/service/servers/server-static.js +3 -3
- package/dist/nodefony/service/servers/server-websocket-secure.js +10 -18
- package/dist/nodefony/service/servers/server-websocket.js +10 -18
- package/dist/nodefony/service/servers/serverShutdown.js +78 -18
- package/dist/nodefony/service/servers/wsHeartbeat.js +1 -2
- package/dist/nodefony/service/sessions/sessions-service.js +7 -14
- package/dist/nodefony/service/trace.js +1 -2
- package/dist/nodefony/service/upload/upload-service.js +5 -8
- package/dist/nodefony/service/x509.js +1 -2
- package/dist/nodefony/src/assets/collectAssets.js +1 -2
- package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
- package/dist/nodefony/src/context/Context.js +78 -43
- package/dist/nodefony/src/context/domainMatcher.js +1 -2
- package/dist/nodefony/src/context/forwarded.js +1 -2
- package/dist/nodefony/src/context/http/HttpContext.js +28 -6
- package/dist/nodefony/src/context/http/Request.js +33 -29
- package/dist/nodefony/src/context/http/Response.js +23 -6
- package/dist/nodefony/src/context/http/SseStream.js +1 -2
- package/dist/nodefony/src/context/http/parser.js +36 -10
- package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
- package/dist/nodefony/src/context/http2/Request.js +4 -8
- package/dist/nodefony/src/context/http2/Response.js +41 -9
- package/dist/nodefony/src/context/metaData.js +1 -2
- package/dist/nodefony/src/context/requestId.js +1 -2
- package/dist/nodefony/src/context/requestTrace.js +104 -0
- package/dist/nodefony/src/context/responseEnded.js +1 -2
- package/dist/nodefony/src/context/timingMode.js +21 -0
- package/dist/nodefony/src/context/transport.js +45 -0
- package/dist/nodefony/src/context/trustProxy.js +1 -2
- package/dist/nodefony/src/context/unsentResponse.js +30 -0
- package/dist/nodefony/src/context/websocket/Response.js +33 -6
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +104 -25
- package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
- package/dist/nodefony/src/context/websocket/websocketUrl.js +1 -2
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
- package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
- package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
- package/dist/nodefony/src/cookies/cookie.js +3 -7
- package/dist/nodefony/src/errors/httpError.js +1 -2
- package/dist/nodefony/src/errors/thrownMessage.js +1 -2
- package/dist/nodefony/src/profiler/FrameProfile.js +23 -2
- package/dist/nodefony/src/profiler/Profiler.js +104 -32
- package/dist/nodefony/src/profiler/aggregate.js +442 -0
- package/dist/nodefony/src/proxy/forward.js +29 -5
- package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
- package/dist/nodefony/src/proxy/rules.js +12 -6
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
- package/dist/nodefony/src/servers/http2Limits.js +50 -0
- package/dist/nodefony/src/servers/kernelServers.js +12 -3
- package/dist/nodefony/src/servers/portBinder.js +41 -10
- package/dist/nodefony/src/servers/upgradeDispatch.js +86 -7
- package/dist/nodefony/src/session/session.js +2 -3
- package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
- package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
- package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
- package/dist/types/index.d.ts +9 -3
- package/dist/types/nodefony/config/config.d.ts +4 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +72 -4
- package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
- package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +56 -0
- package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +8 -0
- package/dist/types/nodefony/interfaces/IRouting.d.ts +26 -0
- package/dist/types/nodefony/interfaces/index.d.ts +3 -2
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +3 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +3 -11
- package/dist/types/nodefony/service/certificates.d.ts +55 -4
- package/dist/types/nodefony/service/error-renderer.d.ts +31 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +96 -10
- package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
- package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
- package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -3
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -3
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
- package/dist/types/nodefony/src/context/Context.d.ts +38 -5
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +16 -2
- package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
- package/dist/types/nodefony/src/context/http/Response.d.ts +11 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
- package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
- package/dist/types/nodefony/src/context/http2/Response.d.ts +27 -5
- package/dist/types/nodefony/src/context/requestTrace.d.ts +64 -0
- package/dist/types/nodefony/src/context/timingMode.d.ts +23 -0
- package/dist/types/nodefony/src/context/transport.d.ts +53 -0
- package/dist/types/nodefony/src/context/unsentResponse.d.ts +16 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +58 -8
- package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +28 -12
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +79 -111
- package/dist/types/nodefony/src/profiler/aggregate.d.ts +132 -0
- package/dist/types/nodefony/src/proxy/forward.d.ts +14 -2
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
- package/dist/types/nodefony/src/proxy/rules.d.ts +3 -0
- package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
- package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
- package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +33 -3
- package/docs/cookies.md +85 -78
- package/docs/diagnostics-channel.md +303 -0
- package/docs/index.md +6 -1
- package/docs/observabilite.md +159 -81
- package/docs/rate-limit.md +83 -83
- package/docs/reverse-proxy.md +72 -70
- package/docs/servers.md +338 -224
- package/docs/session.md +179 -179
- package/docs/sse.md +24 -20
- package/docs/upload.md +120 -117
- package/package.json +8 -9
- 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
|
|
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
|
|
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
|
|
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
|
|
125
|
-
(fail-closed). La vérification est **timing-safe** (`unsign()` → `crypto.timingSafeEqual`, `cookie.ts
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
235
|
-
|
|
236
|
-
Les défauts sont matérialisés dans `cookieDefaultSettings` (`cookie.ts
|
|
237
|
-
constructeur de `Cookie` (`cookie.ts
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
252
|
-
Comparaison **timing-safe** (`cookie.ts
|
|
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
|
|
259
|
-
| Lire tous les cookies entrants | `context.cookies` → `Record<string, Cookie>` | `cookies` (`Context.ts
|
|
260
|
-
| Écrire un cookie sortant | `context.setCookie(new Cookie(…))` | `setCookie()` (`Context.ts
|
|
261
|
-
| Supprimer un cookie sortant | `response.deleteCookieByName("nom")` | `http/Response.ts
|
|
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
|
|
264
|
-
(`http/Response.ts
|
|
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
|
|
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
|
|
271
|
-
`parser()` `cookie.ts
|
|
272
|
-
(`Context.ts
|
|
273
|
-
l'initialisation du contexte HTTP (`HttpContext.ts
|
|
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
|
|
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
|
|
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
|
|
287
|
-
de la **session** — bloc Zod `sessionCookieSchema` (`config.ts
|
|
288
|
-
(`config.ts
|
|
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
|
|
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
|
|
299
|
-
| `SameSite=Lax` (défaut) | **CSRF** inter-site | Fallback toujours `Lax`, jamais `None` (`setSameSite()` `cookie.ts
|
|
300
|
-
| `Secure` (défaut `true`) | Interception en clair | Forcé aussi par `None`/préfixes (`serialize()` `cookie.ts
|
|
301
|
-
| Préfixe `__Host-` | **Session fixation** cross-sous-domaine | `Domain` retiré + `Path=/` imposés (`serialize()` `cookie.ts
|
|
302
|
-
| Cookie signé (HMAC) | Altération de la valeur côté client | `sign()`/`unsign()` timing-safe (`cookie.ts
|
|
303
|
-
| Refus du secret par défaut | Signature « fantôme » sans protection | Fail-closed à la signature (`setValue()` `cookie.ts
|
|
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
|
|
310
|
-
| `SameSite` — 3 valeurs canoniques | RFC 6265bis §5.4.7 | `SameSiteType` (`ICookie.ts
|
|
311
|
-
| Préfixes `__Host-` / `__Secure-` | RFC 6265bis §4.1.3 | `serialize()` force les contraintes (`cookie.ts
|
|
312
|
-
| `SameSite=None` impose `Secure` | RFC 6265bis | `serialize()` (`cookie.ts
|
|
313
|
-
| Intégrité — HMAC-SHA256, base64url | RFC 2104 / RFC 4648 | `sign()` (`cookie.ts
|
|
314
|
-
| Vérification à temps constant | bonne pratique OWASP | `unsign()` → `timingSafeEqual` (`cookie.ts
|
|
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
|
|
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
|
|
334
|
-
`nodefony-tail-error-logs` ou le Suivi
|
|
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)
|