@nodefony/http 10.0.0-beta.1 → 10.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +11 -5
- package/dist/nodefony/command/certificatesCommand.js +0 -1
- package/dist/nodefony/config/config.js +48 -7
- package/dist/nodefony/config/defineModuleConfig.js +1 -1
- package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +0 -1
- package/dist/nodefony/service/audit-logger.js +2 -3
- package/dist/nodefony/service/certificates.js +214 -158
- package/dist/nodefony/service/error-renderer.js +1 -1
- package/dist/nodefony/service/http-kernel.js +221 -47
- package/dist/nodefony/service/reverse-proxy.js +336 -0
- package/dist/nodefony/service/servers/server-http.js +3 -3
- package/dist/nodefony/service/servers/server-https.js +3 -3
- package/dist/nodefony/service/servers/server-static.js +4 -7
- package/dist/nodefony/service/servers/server-websocket-secure.js +12 -4
- package/dist/nodefony/service/servers/server-websocket.js +12 -4
- package/dist/nodefony/service/servers/wsHeartbeat.js +31 -15
- package/dist/nodefony/service/sessions/sessions-service.js +30 -9
- package/dist/nodefony/service/trace.js +2 -1
- package/dist/nodefony/service/upload/upload-service.js +3 -3
- package/dist/nodefony/service/x509.js +324 -0
- package/dist/nodefony/src/context/Context.js +28 -1
- package/dist/nodefony/src/context/domainMatcher.js +20 -2
- package/dist/nodefony/src/context/forwarded.js +2 -2
- package/dist/nodefony/src/context/http/HttpContext.js +91 -40
- package/dist/nodefony/src/context/http/Request.js +9 -4
- package/dist/nodefony/src/context/http/Response.js +46 -7
- package/dist/nodefony/src/context/http/SseStream.js +350 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +35 -10
- package/dist/nodefony/src/context/responseEnded.js +27 -0
- package/dist/nodefony/src/context/websocket/Response.js +9 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +2 -3
- package/dist/nodefony/src/context/websocket/websocketUrl.js +21 -0
- package/dist/nodefony/src/profiler/Profiler.js +6 -10
- package/dist/nodefony/src/proxy/forward.js +476 -0
- package/dist/nodefony/src/proxy/rules.js +182 -0
- package/dist/nodefony/src/servers/portBinder.js +57 -2
- package/dist/nodefony/src/servers/upgradeDispatch.js +49 -0
- package/dist/nodefony/src/session/session.js +28 -12
- package/dist/types/index.d.ts +5 -0
- package/dist/types/nodefony/config/config.d.ts +17 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +2 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
- package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
- package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
- package/dist/types/nodefony/service/certificates.d.ts +126 -50
- package/dist/types/nodefony/service/error-renderer.d.ts +1 -1
- package/dist/types/nodefony/service/http-kernel.d.ts +111 -5
- package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -0
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
- package/dist/types/nodefony/service/x509.d.ts +86 -0
- package/dist/types/nodefony/src/context/Context.d.ts +22 -2
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +47 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +3 -0
- package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
- package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
- package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +1 -0
- package/dist/types/nodefony/src/proxy/forward.d.ts +156 -0
- package/dist/types/nodefony/src/proxy/rules.d.ts +85 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +10 -46
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +39 -0
- package/dist/types/nodefony/src/session/session.d.ts +2 -0
- package/docs/cookies.md +13 -13
- package/docs/index.md +9 -3
- package/docs/observabilite.md +18 -18
- package/docs/rate-limit.md +30 -30
- package/docs/reverse-proxy.md +522 -0
- package/docs/servers.md +115 -78
- package/docs/session.md +61 -61
- package/docs/sse.md +283 -0
- package/docs/upload.md +37 -37
- package/package.json +10 -13
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import type http2 from "node:http2";
|
|
3
|
+
import type { Duplex } from "node:stream";
|
|
4
|
+
import type { IProxyMount } from "../../interfaces/IReverseProxy.js";
|
|
5
|
+
/** Requête entrante relayable : HTTP/1.1 ou HTTP/2 (API de compatibilité). */
|
|
6
|
+
export type ProxiedRequest = http.IncomingMessage | http2.Http2ServerRequest;
|
|
7
|
+
/** Réponse vers le client : HTTP/1.1 ou HTTP/2 (API de compatibilité). */
|
|
8
|
+
export type ProxiedResponse = http.ServerResponse | http2.Http2ServerResponse;
|
|
9
|
+
/**
|
|
10
|
+
* Ce que le proxy sait du client, et qu'il annonce à l'amont
|
|
11
|
+
* (`X-Forwarded-*`, `Via`).
|
|
12
|
+
*/
|
|
13
|
+
export interface IForwardedFrom {
|
|
14
|
+
/** Adresse du pair TCP immédiat — ajoutée à la chaîne `X-Forwarded-For`. */
|
|
15
|
+
remoteAddress: string | undefined;
|
|
16
|
+
/** Scheme par lequel le pair est arrivé (`http` | `https`). */
|
|
17
|
+
scheme: string;
|
|
18
|
+
/** Autorité demandée (`Host` ou `:authority`). */
|
|
19
|
+
host: string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Le pair est-il un relais de confiance (`trustProxy`) ? Seul un tel pair
|
|
22
|
+
* peut faire passer des `Forwarded`/`X-Forwarded-*`/`X-Real-IP` : venus
|
|
23
|
+
* d'ailleurs, ils sont forgeables (RFC 7239 §8.1) et sont écartés.
|
|
24
|
+
*/
|
|
25
|
+
trustedPeer: boolean;
|
|
26
|
+
/** Version du message reçu, pour `Via` (`"1.1"`, `"2.0"`). */
|
|
27
|
+
httpVersion: string;
|
|
28
|
+
}
|
|
29
|
+
/** Réglages du relais, fixés par le service (identiques pour tous les montages). */
|
|
30
|
+
export interface IRelaySettings {
|
|
31
|
+
/** Pseudonyme de CE processus dans `Via` (RFC 9110 §7.6.3) — base de la garde de boucle. */
|
|
32
|
+
pseudonym: string;
|
|
33
|
+
/** Délai d'établissement de la connexion amont (ms). Dépassé : 504. */
|
|
34
|
+
connectTimeoutMs: number;
|
|
35
|
+
/**
|
|
36
|
+
* Pool de connexions du montage (keep-alive borné) ; `false` = connexion à
|
|
37
|
+
* usage unique (arrêt engagé : plus de pool à créer).
|
|
38
|
+
*/
|
|
39
|
+
agent: http.Agent | false | undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Sauts `Via` au-delà desquels un message est tenu pour une boucle. Notre
|
|
43
|
+
* pseudonyme ne repère une boucle qu'au RETOUR dans ce processus : derrière un
|
|
44
|
+
* répartiteur qui tourne sur N instances, cela peut prendre N+1 sauts, chacun
|
|
45
|
+
* tenant une connexion. Une chaîne légitime (CDN, répartiteur, ingress) en
|
|
46
|
+
* compte rarement plus de cinq.
|
|
47
|
+
*/
|
|
48
|
+
export declare const MAX_VIA_HOPS = 10;
|
|
49
|
+
/**
|
|
50
|
+
* Le message est-il déjà passé par CE processus ? Un `Via` qui porte notre
|
|
51
|
+
* pseudonyme signe une boucle (RFC 9110 §7.6 : un proxy ne se renvoie pas un
|
|
52
|
+
* message sans s'en protéger).
|
|
53
|
+
*
|
|
54
|
+
* @param via - en-tête `Via` reçu
|
|
55
|
+
* @param pseudonym - pseudonyme de ce processus
|
|
56
|
+
*/
|
|
57
|
+
export declare function viaLoops(via: string | string[] | undefined, pseudonym: string): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Cadrage du corps transmis à l'amont (RFC 9112 §6). `unsupported` : un
|
|
60
|
+
* codage de transfert autre que `chunked` seul — le proxy ne sait pas le
|
|
61
|
+
* transmettre fidèlement.
|
|
62
|
+
*/
|
|
63
|
+
export type BodyFraming = "none" | "length" | "chunked" | "unsupported";
|
|
64
|
+
/**
|
|
65
|
+
* Cadrage du corps reçu — décidé UNE fois, pour que l'amont lise exactement
|
|
66
|
+
* les octets que le proxy lui envoie (RFC 9112 §6.3). Un `Transfer-Encoding`
|
|
67
|
+
* l'emporte sur un `Content-Length` (que l'on retire alors) ; en HTTP/2, un
|
|
68
|
+
* flux sans fin à la tête porte un corps même sans longueur annoncée.
|
|
69
|
+
*
|
|
70
|
+
* @param req - requête reçue
|
|
71
|
+
* @returns le cadrage à appliquer vers l'amont
|
|
72
|
+
*/
|
|
73
|
+
export declare function bodyFramingOf(req: ProxiedRequest): BodyFraming;
|
|
74
|
+
/**
|
|
75
|
+
* En-têtes de la requête à transmettre à l'amont.
|
|
76
|
+
*
|
|
77
|
+
* - écartés : pseudo-en-têtes HTTP/2, en-têtes de connexion et ceux que nomme
|
|
78
|
+
* `Connection`, `Expect` (le serveur a déjà répondu `100`), en-têtes retirés
|
|
79
|
+
* par le montage, chaîne de relais venue d'un pair NON fiable ;
|
|
80
|
+
* - `Host` réécrit sur la cible (sauf `preserveHost`) ;
|
|
81
|
+
* - `X-Forwarded-For` prolongé (pair fiable) ou recommencé ;
|
|
82
|
+
* `X-Forwarded-Proto`/`-Host` hérités d'un pair fiable, posés sinon ;
|
|
83
|
+
* `X-Forwarded-Prefix` quand le préfixe est retiré — à la suite de celui
|
|
84
|
+
* d'un pair fiable ; `Forwarded` d'un pair fiable prolongé de ce saut ;
|
|
85
|
+
* - `Via` prolongé de ce saut ;
|
|
86
|
+
* - cadrage du corps rendu cohérent ({@link bodyFramingOf}).
|
|
87
|
+
*
|
|
88
|
+
* @returns un objet neuf, prêt pour `http.request`
|
|
89
|
+
*/
|
|
90
|
+
export declare function requestHeadersFor(headers: http.IncomingHttpHeaders, mount: IProxyMount, target: URL, from: IForwardedFrom, pseudonym: string, framing: BodyFraming): http.OutgoingHttpHeaders;
|
|
91
|
+
/**
|
|
92
|
+
* Ramène sous le préfixe un `Location` émis par un amont qui ignore son
|
|
93
|
+
* montage (`stripPrefix`) — l'équivalent de `proxy_redirect default` de
|
|
94
|
+
* nginx. Un chemin absolu (`/login`) et une URL sur l'origine de l'amont sont
|
|
95
|
+
* réécrits ; toute autre URL (un tiers) part intacte.
|
|
96
|
+
*
|
|
97
|
+
* @returns la valeur réécrite, ou celle reçue
|
|
98
|
+
*/
|
|
99
|
+
export declare function rewriteLocation(location: string, mount: IProxyMount, target: URL): string;
|
|
100
|
+
/**
|
|
101
|
+
* En-têtes de la réponse de l'amont à renvoyer au client : en-têtes de
|
|
102
|
+
* connexion écartés — y compris ceux que nomme `Connection` —, `Via`
|
|
103
|
+
* prolongé, `Location` ramené sous le préfixe (`stripPrefix`) ; le reste
|
|
104
|
+
* intact (un `set-cookie` multiple reste un tableau).
|
|
105
|
+
*
|
|
106
|
+
* @returns un objet neuf, valable en HTTP/1.1 comme en HTTP/2
|
|
107
|
+
*/
|
|
108
|
+
export declare function responseHeadersFor(headers: http.IncomingHttpHeaders, mount: IProxyMount, target: URL, hop: string): http.OutgoingHttpHeaders;
|
|
109
|
+
/**
|
|
110
|
+
* Chemin transmis à l'amont : tel quel, ou sans le préfixe (`stripPrefix`).
|
|
111
|
+
*
|
|
112
|
+
* @param url - cible de la requête (chemin + requête), qui commence par le préfixe
|
|
113
|
+
* @param mount - montage qui relaie
|
|
114
|
+
* @returns le chemin, toujours avec un `/` en tête
|
|
115
|
+
*/
|
|
116
|
+
export declare function upstreamPath(url: string, mount: IProxyMount): string;
|
|
117
|
+
/**
|
|
118
|
+
* Relaie une requête HTTP vers l'amont et renvoie sa réponse au client.
|
|
119
|
+
*
|
|
120
|
+
* Le corps est transmis avec un cadrage cohérent ({@link bodyFramingOf}). Un
|
|
121
|
+
* message qui porte déjà notre `Via` rend 508 sans contacter l'amont. Un
|
|
122
|
+
* client parti coupe l'échange amont ; un amont injoignable rend 502 ; une
|
|
123
|
+
* connexion non établie à temps ou un amont muet rendent 504 — à condition
|
|
124
|
+
* que rien n'ait encore été envoyé, sinon la réponse est interrompue (un
|
|
125
|
+
* statut ne se rattrape pas). Un amont coupé en plein corps interrompt la
|
|
126
|
+
* réponse au client au lieu de la laisser pendre.
|
|
127
|
+
*
|
|
128
|
+
* @returns une promesse résolue à la fermeture de la réponse — jamais rejetée
|
|
129
|
+
*/
|
|
130
|
+
export declare function forwardRequest(req: ProxiedRequest, res: ProxiedResponse, mount: IProxyMount, target: URL, from: IForwardedFrom, relay: IRelaySettings): Promise<void>;
|
|
131
|
+
/**
|
|
132
|
+
* Sérialise une tête de réponse HTTP/1.1 depuis des en-têtes BRUTS (paires
|
|
133
|
+
* nom/valeur), sans réordonner ni fusionner, en écartant les noms donnés —
|
|
134
|
+
* une réponse `101` doit rendre au client exactement ce que l'amont a négocié.
|
|
135
|
+
*
|
|
136
|
+
* @param status - ligne de statut, sans CRLF
|
|
137
|
+
* @param rawHeaders - `IncomingMessage.rawHeaders`
|
|
138
|
+
* @param skip - noms à écarter (minuscules)
|
|
139
|
+
* @param extra - lignes ajoutées à la fin (`Nom: valeur`)
|
|
140
|
+
* @returns la tête complète, CRLF final compris
|
|
141
|
+
*/
|
|
142
|
+
export declare function rawHead(status: string, rawHeaders: readonly string[], skip?: ReadonlySet<string> | null, extra?: readonly string[]): string;
|
|
143
|
+
/** Réponse brute minimale sur un socket d'upgrade, puis fermeture. */
|
|
144
|
+
export declare function refuseUpgrade(socket: Duplex, status: number, reason: string): void;
|
|
145
|
+
/**
|
|
146
|
+
* Relaie un upgrade WebSocket DÉJÀ validé (méthode, `Upgrade`, clé, version,
|
|
147
|
+
* `Origin` — cf `ReverseProxy.handleUpgrade`) : rejoue le handshake vers
|
|
148
|
+
* l'amont, renvoie sa réponse `101` au client puis raccorde les deux sockets
|
|
149
|
+
* octet pour octet. Un refus de l'amont (`400`, `403`…) est rendu avec un
|
|
150
|
+
* cadrage refait (fin de corps = fermeture) ; un amont injoignable rend `502`,
|
|
151
|
+
* une connexion non établie à temps — ou un amont qui accepte puis se tait
|
|
152
|
+
* au-delà de `timeoutMs` — `504`, une boucle `508`. Le délai d'inactivité
|
|
153
|
+
* cesse au `101` : un tunnel établi peut rester muet. Une erreur d'un côté
|
|
154
|
+
* détruit l'autre.
|
|
155
|
+
*/
|
|
156
|
+
export declare function forwardUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer, mount: IProxyMount, target: URL, from: IForwardedFrom, relay: IRelaySettings): void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Règles d'un montage du proxy inverse — UNIQUE source, lue par le schéma de
|
|
3
|
+
* configuration (`proxy.mounts`) ET par `ReverseProxy.mount()` : un montage
|
|
4
|
+
* écrit dans la config et un montage posé par un module obéissent aux mêmes
|
|
5
|
+
* refus, avec les mêmes mots.
|
|
6
|
+
*
|
|
7
|
+
* Pur : n'importe que le cœur, importable depuis `config/config.ts` sans
|
|
8
|
+
* cycle.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Préfixes que le framework se réserve : un montage n'y est JAMAIS accepté.
|
|
12
|
+
* `/nodefony/` porte la console d'administration et les API de Studio — les
|
|
13
|
+
* relayer ailleurs détournerait une surface protégée par le pare-feu.
|
|
14
|
+
*/
|
|
15
|
+
export declare const RESERVED_PROXY_PREFIXES: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* Préfixes réservés aux montages POSÉS PAR UN MODULE : la configuration ne
|
|
18
|
+
* peut pas les prendre. `/_vite/` appartient à `@nodefony/frontend`, qui y
|
|
19
|
+
* relaie ses serveurs Vite de développement.
|
|
20
|
+
*/
|
|
21
|
+
export declare const MODULE_PROXY_PREFIXES: readonly string[];
|
|
22
|
+
/**
|
|
23
|
+
* Normalise un préfixe d'URL : `/` en tête et en fin, sans `/` doublé. Règle
|
|
24
|
+
* UNIQUE des montages statiques et du proxy inverse — deux écritures du même
|
|
25
|
+
* préfixe doivent désigner la même entrée.
|
|
26
|
+
*
|
|
27
|
+
* @param prefix - préfixe tel qu'écrit (`"svc"`, `"/svc"`, `"/svc/"`)
|
|
28
|
+
* @returns le préfixe normalisé (`"/svc/"`)
|
|
29
|
+
*/
|
|
30
|
+
export declare function normalizePrefix(prefix: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Analyse une origine d'amont. Seule une origine NUE est acceptée : un chemin,
|
|
33
|
+
* une requête, un fragment ou des identifiants dans la cible déplaceraient en
|
|
34
|
+
* silence toutes les URL relayées, ou feraient voyager un secret dans la
|
|
35
|
+
* configuration.
|
|
36
|
+
*
|
|
37
|
+
* @param origin - `scheme://hôte[:port]`
|
|
38
|
+
* @returns l'URL analysée, ou `null` si ce n'est pas une origine http(s) nue
|
|
39
|
+
*/
|
|
40
|
+
export declare function parseProxyOrigin(origin: string): URL | null;
|
|
41
|
+
/** Ce qu'une règle de montage examine — la forme commune config / code. */
|
|
42
|
+
export interface IProxyMountShape {
|
|
43
|
+
target: unknown;
|
|
44
|
+
methods?: readonly string[] | undefined;
|
|
45
|
+
stripHeaders?: readonly string[] | undefined;
|
|
46
|
+
timeoutMs?: number | undefined;
|
|
47
|
+
secure?: boolean | undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Un refus, nommé : le champ fautif et pourquoi. */
|
|
50
|
+
export interface IProxyMountProblem {
|
|
51
|
+
field: "prefix" | "target" | "methods" | "stripHeaders" | "timeoutMs" | "secure";
|
|
52
|
+
message: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Examine un montage. Rend TOUS les refus, pas le premier : une configuration
|
|
56
|
+
* se corrige en une passe.
|
|
57
|
+
*
|
|
58
|
+
* @param prefix - préfixe tel qu'écrit
|
|
59
|
+
* @param mount - réglages du montage
|
|
60
|
+
* @param source - `"config"` (déclaré dans `proxy.mounts`) ou `"module"`
|
|
61
|
+
* (posé par un module) — seuls les préfixes réservés aux modules diffèrent
|
|
62
|
+
* @returns la liste des refus, vide si le montage est sain
|
|
63
|
+
*/
|
|
64
|
+
export declare function proxyMountProblems(prefix: string, mount: IProxyMountShape, source: "config" | "module"): IProxyMountProblem[];
|
|
65
|
+
/**
|
|
66
|
+
* Le chemin d'une requête relayée est-il ambigu ? Un chemin que le proxy et
|
|
67
|
+
* l'amont pourraient lire différemment — segment `.`/`..`, barre inverse,
|
|
68
|
+
* octet nul, ou leur forme encodée (`%2e`, `%2f`, `%5c`, `%00`, `%25`) — est
|
|
69
|
+
* REFUSÉ (400), jamais normalisé en silence : c'est le défaut de Traefik, et
|
|
70
|
+
* la parade aux contournements de préfixe (`/svc/..%2fadmin`).
|
|
71
|
+
*
|
|
72
|
+
* @param url - cible brute de la requête (chemin + requête éventuelle)
|
|
73
|
+
* @returns `true` si le chemin doit être refusé
|
|
74
|
+
*/
|
|
75
|
+
export declare function isAmbiguousPath(url: string): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Un upgrade est-il un handshake WebSocket conforme (RFC 6455 §4.2.1) ? Seul
|
|
78
|
+
* un tel handshake est relayé : un `Upgrade` quelconque (`h2c`…) raccordé
|
|
79
|
+
* octet pour octet ouvrirait un tunnel que rien n'inspecte.
|
|
80
|
+
*
|
|
81
|
+
* @param method - méthode de la requête
|
|
82
|
+
* @param headers - en-têtes reçus
|
|
83
|
+
* @returns la raison du refus, ou `null` si le handshake est conforme
|
|
84
|
+
*/
|
|
85
|
+
export declare function websocketHandshakeProblem(method: string | undefined, headers: Record<string, string | string[] | undefined>): string | null;
|
|
@@ -1,48 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* **portBinder** — écoute sur le port désiré, ou sur le prochain port libre.
|
|
3
|
-
*
|
|
4
|
-
* Pourquoi ce fichier existe : lancer deux apps Nodefony côte à côte (le repo et
|
|
5
|
-
* une app générée, deux projets, un banc et une démo…) faisait mourir la seconde
|
|
6
|
-
* en `EADDRINUSE`. Le port désiré est une PRÉFÉRENCE en développement ; il reste
|
|
7
|
-
* un CONTRAT en production.
|
|
8
|
-
*
|
|
9
|
-
* ## Pourquoi retenter au `listen()` plutôt que sonder d'abord
|
|
10
|
-
*
|
|
11
|
-
* La tentation est d'appeler « le port 5151 est-il libre ? » puis de binder. C'est
|
|
12
|
-
* une **course** (TOCTOU) : entre la réponse et le bind, un autre process peut
|
|
13
|
-
* prendre le port — et on échoue quand même, après avoir cru le contraire. Le
|
|
14
|
-
* `listen()` est, lui, **atomique** : soit il réussit, soit le noyau dit
|
|
15
|
-
* `EADDRINUSE`. On retente donc sur l'échec réel, jamais sur une prédiction.
|
|
16
|
-
*
|
|
17
|
-
* ## Pourquoi une SONDE malgré cela
|
|
18
|
-
*
|
|
19
|
-
* Le `listen()` est atomique, mais il ne dit pas tout : un port peut être **déjà
|
|
20
|
-
* servi** et le noyau l'accorder quand même. Lier `0.0.0.0:P` alors qu'un autre
|
|
21
|
-
* processus tient `127.0.0.1:P` **réussit** sur macOS et les BSD — ce sont deux
|
|
22
|
-
* liaisons distinctes, et les connexions locales partent à la PLUS SPÉCIFIQUE.
|
|
23
|
-
* Entre familles d'adresses (`::1` tenu, `0.0.0.0` demandé), même linux
|
|
24
|
-
* l'accorde. L'application annonce alors `READY`, publie ses ports… et ne reçoit
|
|
25
|
-
* rien : une panne muette, et un banc qui interroge le serveur du VOISIN.
|
|
26
|
-
*
|
|
27
|
-
* On constate donc, AVANT de binder, qu'aucun serveur ne répond déjà là où notre
|
|
28
|
-
* liaison va recevoir (`isPortListening` — une connexion en boucle locale, aucun
|
|
29
|
-
* outil système, même verdict sur les trois plateformes). Ce n'est pas la sonde
|
|
30
|
-
* « ce port est-il libre ? » écartée plus haut : elle ne remplace pas le repli
|
|
31
|
-
* atomique, qui reste le seul à trancher la course — elle ajoute le seul conflit
|
|
32
|
-
* que le noyau n'exprime JAMAIS. La course résiduelle (un tiers prend le port
|
|
33
|
-
* entre la sonde et le bind) retombe donc sur `EADDRINUSE`, inchangé.
|
|
34
|
-
*
|
|
35
|
-
* ## Pourquoi sauter les ports réservés
|
|
36
|
-
*
|
|
37
|
-
* HTTP veut 5151, HTTPS veut 5152. Si 5151 est pris, incrémenter naïvement ferait
|
|
38
|
-
* voler 5152 à HTTPS — qui se décalerait à son tour, en cascade. Les ports que les
|
|
39
|
-
* AUTRES serveurs convoitent sont donc sautés d'emblée.
|
|
40
|
-
*
|
|
41
|
-
* ## Fail-loud
|
|
42
|
-
*
|
|
43
|
-
* Un décalage est TOUJOURS annoncé (`onShift`) : une app qui écoute ailleurs que
|
|
44
|
-
* là où on l'attend sans le dire est une dégradation silencieuse.
|
|
45
|
-
*/
|
|
46
1
|
import type { AddressInfo } from "node:net";
|
|
47
2
|
import { resolvePortPolicy } from "nodefony";
|
|
48
3
|
import type { PortPolicy } from "nodefony";
|
|
@@ -138,9 +93,18 @@ export declare function detectPortConflict(port: number, host: string | undefine
|
|
|
138
93
|
* handler d'erreur durable s'installe APRÈS, sur le serveur qui écoute — sinon il
|
|
139
94
|
* verrait passer les `EADDRINUSE` de repli et croirait à une panne).
|
|
140
95
|
*
|
|
96
|
+
* @param server - serveur à mettre en écoute.
|
|
97
|
+
* @param host - hôte passé à `listen`.
|
|
98
|
+
* @param plan - port désiré et nombre de replis permis.
|
|
99
|
+
* @param probe - sonde de présence (injectée par le banc).
|
|
100
|
+
* @param sharedListener - vrai dans un worker `node:cluster` : le port y est
|
|
101
|
+
* PARTAGÉ par le maître, et un voisin qui y répond est un frère, pas un
|
|
102
|
+
* conflit. Sans cette exception, `nodefony cluster -w N` ne gardait qu'un
|
|
103
|
+
* worker en vie — les autres mouraient sur « port déjà servi » et
|
|
104
|
+
* bouclaient en relance.
|
|
141
105
|
* @returns l'adresse obtenue + le port désiré si un décalage a eu lieu.
|
|
142
106
|
* @throws l'erreur de `listen` : soit un code qui ne dit pas « ce port est pris »
|
|
143
107
|
* (`ENOTFOUND`, `EACCES` sous 1024 — cf `isPortUnavailable`), soit un port
|
|
144
108
|
* indisponible après épuisement des essais (le fallback n'est PAS infini).
|
|
145
109
|
*/
|
|
146
|
-
export declare function bindWithFallback(server: Listenable, host: string, plan: BindPlan, probe?: PortListeningProbe): Promise<BindResult>;
|
|
110
|
+
export declare function bindWithFallback(server: Listenable, host: string, plan: BindPlan, probe?: PortListeningProbe, sharedListener?: boolean): Promise<BindResult>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type IncomingMessage } from "node:http";
|
|
2
|
+
import type { Duplex } from "node:stream";
|
|
3
|
+
import type { WebSocketServer } from "ws";
|
|
4
|
+
/** Serveur qui émet `upgrade` : HTTP/1.1, HTTPS ou HTTP/2 sécurisé (`allowHTTP1`). */
|
|
5
|
+
interface IUpgradeEmitter {
|
|
6
|
+
on(event: "upgrade", listener: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): unknown;
|
|
7
|
+
removeListener(event: "upgrade", listener: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): unknown;
|
|
8
|
+
}
|
|
9
|
+
/** Ce que le répartiteur lit du proxy inverse (cf `ReverseProxy`). */
|
|
10
|
+
interface IUpgradeRelay {
|
|
11
|
+
readonly mounts: readonly unknown[] | null;
|
|
12
|
+
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Pose l'UNIQUE écouteur `upgrade` d'un serveur : le proxy inverse d'abord,
|
|
16
|
+
* puis le serveur WebSocket de Nodefony.
|
|
17
|
+
*
|
|
18
|
+
* Pourquoi un seul écouteur : `ws` attaché par son option `server` prend TOUS
|
|
19
|
+
* les upgrades et répond `400` à ceux qu'il ne reconnaît pas. Un second
|
|
20
|
+
* écouteur, même posé avant, ne l'empêcherait pas de répondre sur un socket
|
|
21
|
+
* déjà relayé. Le serveur `ws` est donc créé en `noServer`, et c'est ici que
|
|
22
|
+
* l'upgrade lui est confié — avec ses propres contrôles intacts (`path`,
|
|
23
|
+
* `verifyClient`, `maxPayload`…), que `handleUpgrade` applique.
|
|
24
|
+
*
|
|
25
|
+
* Entre les deux, `refusal` tranche ce qui doit être refusé AVANT le `101`
|
|
26
|
+
* (RFC 6455 §4.2.2) : un chemin sans route WebSocket reçoit un statut HTTP,
|
|
27
|
+
* jamais une ouverture suivie d'une fermeture.
|
|
28
|
+
*
|
|
29
|
+
* Sans montage, le coût par connexion est une lecture de champ.
|
|
30
|
+
*
|
|
31
|
+
* @param server - serveur HTTP(S) qui reçoit les upgrades
|
|
32
|
+
* @param wss - serveur WebSocket de Nodefony (`noServer: true`)
|
|
33
|
+
* @param relay - proxy inverse, lu à chaque upgrade (il peut naître après)
|
|
34
|
+
* @param refusal - statut HTTP qui refuse l'upgrade, `null` pour l'admettre
|
|
35
|
+
* (cf `HttpKernel.websocketUpgradeRefusal`)
|
|
36
|
+
* @returns la fonction qui retire l'écouteur — à appeler à l'arrêt
|
|
37
|
+
*/
|
|
38
|
+
export declare function attachUpgradeDispatch(server: IUpgradeEmitter, wss: WebSocketServer, relay: () => IUpgradeRelay | null, refusal: (req: IncomingMessage) => number | null): () => void;
|
|
39
|
+
export {};
|
|
@@ -118,6 +118,8 @@ declare class Session implements ISession {
|
|
|
118
118
|
* `onSaveSession`.
|
|
119
119
|
*
|
|
120
120
|
* @param user - principal authentifié (string) lié au blob ; défaut courant.
|
|
121
|
+
* Fourni, il DEVIENT le principal de la session : une sauvegarde suivante,
|
|
122
|
+
* dans la même requête, le garde sans qu'on le lui redonne.
|
|
121
123
|
*/
|
|
122
124
|
save(user?: string): Promise<this>;
|
|
123
125
|
/**
|
package/docs/cookies.md
CHANGED
|
@@ -255,13 +255,13 @@ constructeur de `Cookie` (`cookie.ts:130`).
|
|
|
255
255
|
|
|
256
256
|
| Besoin | Appel | Où |
|
|
257
257
|
| ------------------------------ | ----------------------------------------------------- | ---------------------------------------- |
|
|
258
|
-
| Lire un cookie entrant | `context.getRequestCookies("nom")` → `Cookie \| null` | `getRequestCookies()` (`Context.ts:
|
|
258
|
+
| Lire un cookie entrant | `context.getRequestCookies("nom")` → `Cookie \| null` | `getRequestCookies()` (`Context.ts:706`) |
|
|
259
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:
|
|
261
|
-
| Supprimer un cookie sortant | `response.deleteCookieByName("nom")` | `http/Response.ts:
|
|
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` |
|
|
262
262
|
|
|
263
|
-
Côté réponse HTTP, `addCookie()` (`http/Response.ts:
|
|
264
|
-
(`http/Response.ts:
|
|
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
|
|
265
265
|
boucle de `setHeader` (qui écraserait tout sauf le dernier). Pour expirer un cookie chez le client :
|
|
266
266
|
`clearCookie()` (`cookie.ts:200`) recule `Expires` à l'époque.
|
|
267
267
|
|
|
@@ -269,14 +269,14 @@ boucle de `setHeader` (qui écraserait tout sauf le dernier). Pour expirer un co
|
|
|
269
269
|
|
|
270
270
|
`cookiesParser(context)` (`cookie.ts:87`) lit l'en-tête `Cookie:` (via la bibliothèque `cookie`,
|
|
271
271
|
`parser()` `cookie.ts:50`), crée un `Cookie` par entrée et l'ajoute au contexte avec `addRequestCookie()`
|
|
272
|
-
(`Context.ts:
|
|
273
|
-
l'initialisation du contexte HTTP (`HttpContext.ts:
|
|
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`).
|
|
274
274
|
|
|
275
275
|
### Côté WebSocket — lecture oui, écriture non
|
|
276
276
|
|
|
277
277
|
Les cookies **entrants** sont lus au handshake, exactement comme en HTTP (même parseur). Mais la **poignée
|
|
278
278
|
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:
|
|
279
|
+
**no-op** (`websocket/Response.ts:315`), une limite de la bibliothèque `ws`. Le cookie de session, lui, est
|
|
280
280
|
posé pendant la **phase HTTP** qui précède l'upgrade. La forme d'un cookie décrit pour le WS est
|
|
281
281
|
`IWsCookie` (`ICookie.ts:19`), produite par `serializeWebSocket()`.
|
|
282
282
|
|
|
@@ -284,12 +284,12 @@ posé pendant la **phase HTTP** qui précède l'upgrade. La forme d'un cookie d
|
|
|
284
284
|
|
|
285
285
|
Les cookies **applicatifs** ne se configurent pas par schéma : on les construit dans le code, avec les
|
|
286
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:
|
|
288
|
-
(`config.ts:
|
|
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
289
|
cette page ne le duplique pas.
|
|
290
290
|
|
|
291
291
|
Le nom effectif du cookie de session (avec ou sans `__Host-` selon le transport) est calculé par
|
|
292
|
-
`getSessionCookieName()` (`Context.ts:
|
|
292
|
+
`getSessionCookieName()` (`Context.ts:757`) — encore un détail qui appartient à la page Sessions.
|
|
293
293
|
|
|
294
294
|
## 🛡️ Défenses par attribut
|
|
295
295
|
|
|
@@ -322,7 +322,7 @@ Le nom effectif du cookie de session (avec ou sans `__Host-` selon le transport)
|
|
|
322
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
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
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:
|
|
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
326
|
| `SameSite` mal orthographié devient `Lax` silencieusement | Fallback fail-safe sur `Lax` | Attendu — vérifier la casse ; `Strict`/`Lax`/`None` seulement |
|
|
327
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 |
|
|
328
328
|
|
|
@@ -330,7 +330,7 @@ Le nom effectif du cookie de session (avec ou sans `__Host-` selon le transport)
|
|
|
330
330
|
|
|
331
331
|
Il n'y a pas d'écran Studio dédié aux cookies applicatifs (le cookie **de session** est surfacé dans
|
|
332
332
|
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:
|
|
333
|
+
réponse HTTP (`ADD COOKIE ==> …`, `setCookie()` `http/Response.ts:261`) — visible via le skill
|
|
334
334
|
`nodefony-tail-error-logs` ou le Suivi de requête. Sur le fil, un `curl -i` montre les lignes `Set-Cookie`.
|
|
335
335
|
|
|
336
336
|
## 🧪 Tests & couverture
|
package/docs/index.md
CHANGED
|
@@ -66,6 +66,7 @@ Le tableau pour choisir vite ; les cards en dessous pour savoir ce qu'on y trouv
|
|
|
66
66
|
| [Upload & corps](upload.md) | parser le corps et recevoir des fichiers | formulaires, imports multipart, API JSON |
|
|
67
67
|
| [Rate-limit](rate-limit.md) | plafonner le débit par client (429) | protéger une API d'un flood ou d'un abus |
|
|
68
68
|
| [Observabilité](observabilite.md) | tracer et journaliser chaque requête | débugger en prod, corréler des logs |
|
|
69
|
+
| [Proxy inverse](reverse-proxy.md) | relayer un préfixe d'URL vers un autre serveur, sur la même origine | servir Vite en dev, adosser un service interne sans CORS |
|
|
69
70
|
| [Pipeline de requête](../../../../../docs/architecture/pipeline-requete.md) | l'ordre exact des étapes, HTTP comme WS | tu débugges « pourquoi ça passe / ça bloque ici » |
|
|
70
71
|
|
|
71
72
|
```nodefony-cards
|
|
@@ -88,6 +89,9 @@ Le tableau pour choisir vite ; les cards en dessous pour savoir ce qu'on y trouv
|
|
|
88
89
|
{ "icon": "📈", "title": "observabilite", "href": "observabilite.md",
|
|
89
90
|
"desc": "Observer les requêtes : lignes de log (pretty ou JSON), requestId de corrélation, W3C Trace Context, trace des frames WebSocket, redaction et sampling d'audit. Où partent les logs est traité par la page Syslog du cœur.",
|
|
90
91
|
"meta": "débugger en prod, corréler les logs par requête" },
|
|
92
|
+
{ "icon": "↪️", "title": "reverse-proxy", "href": "reverse-proxy.md",
|
|
93
|
+
"desc": "Relayer un préfixe d'URL vers un autre serveur sans quitter l'origine de la page : HTTP et WebSocket, en-têtes de connexion jamais transmis, X-Forwarded-* et Via posés, barrière d'hôte appliquée avant le relais. C'est par lui que Vite est servi en développement.",
|
|
94
|
+
"meta": "une origine, un certificat — pas de CORS ni de contenu mixte" },
|
|
91
95
|
{ "icon": "🔀", "title": "pipeline-requete", "href": "../../../../../docs/architecture/pipeline-requete.md",
|
|
92
96
|
"desc": "Où ce module s'arrête et où le framework prend le relais, et dans quel ordre s'enchaînent contexte, rate-limit, routage, session, CSRF et firewall.",
|
|
93
97
|
"meta": "page transverse — celle qui relie tout" }
|
|
@@ -125,14 +129,16 @@ se périmeraient en silence.
|
|
|
125
129
|
## ⚙️ Configuration
|
|
126
130
|
|
|
127
131
|
Tout se déclare dans `nodefony.config.ts` via `use("@nodefony/http", { … })`. Les blocs Zod
|
|
128
|
-
(`nodefony/config/config.ts`) couvrent : `
|
|
129
|
-
`cookie
|
|
132
|
+
(`nodefony/config/config.ts`) couvrent : `http`, `https`, `http2` (transport, TLS), `websocket` / `websocketSecure`, `session`
|
|
133
|
+
(dont `session.cookie`), `trustProxy` et `trustedHosts`, `certificates`, `upload` et `maxBodySize`,
|
|
134
|
+
`rateLimit`, `health` et `statics`. La topologie (`servers` : ports, protocole, politique de port) est
|
|
135
|
+
un bloc de la configuration d'application, pas de ce module. Chaque page
|
|
130
136
|
de brique détaille son bloc et ses défauts réels.
|
|
131
137
|
|
|
132
138
|
## 📜 Normes appliquées
|
|
133
139
|
|
|
134
140
|
RFC 9110/9111/9112 (sémantique HTTP, cache, HTTP/1.1), RFC 9113 (HTTP/2), RFC 6455 (WebSocket et ses
|
|
135
|
-
codes de fermeture), RFC 6265bis (cookies), RFC 6585 (429), RFC
|
|
141
|
+
codes de fermeture), RFC 6265bis (cookies), RFC 6585 (429), RFC 9525 (identité des certificats),
|
|
136
142
|
WHATWG Fetch (CORS), W3C Trace Context (`traceparent`).
|
|
137
143
|
|
|
138
144
|
## 📡 Observabilité — Studio
|
package/docs/observabilite.md
CHANGED
|
@@ -75,7 +75,7 @@ Trois idées portent tout le reste :
|
|
|
75
75
|
| `X-Request-Id` | En-tête HTTP de **convention** (de-facto, non normalisé) portant le `requestId` : le client peut l'imposer, le serveur le réfléchit. |
|
|
76
76
|
| Corrélation | Relier des lignes de log éparses à la **même** requête via une clé partagée (`requestId`, `pid`). |
|
|
77
77
|
| ALS | _AsyncLocalStorage_ : mémoire Node attachée au contexte d'exécution async — propage `requestId` sans le passer en argument. |
|
|
78
|
-
| `RequestContext` | Façade Nodefony au-dessus de l'ALS : `getRequestId()`, `
|
|
78
|
+
| `RequestContext` | Façade Nodefony au-dessus de l'ALS : `getRequestId()`, `getAuthenticatedUser()`, `getUserId()`, `traceparent`. |
|
|
79
79
|
| `Pdu` | _Process Data Unit_ : une entrée de log structurée (RFC 5424). Porte `requestId` + `pid`. |
|
|
80
80
|
| Syslog | Le hub de logs du cœur (ring buffer + drivers). Reçoit chaque `Pdu` — **page distincte**. |
|
|
81
81
|
| `traceparent` | En-tête **W3C Trace Context** : `version-traceId-spanId-flags`. Corrèle une requête à travers plusieurs services. |
|
|
@@ -112,12 +112,12 @@ un `requestId`, un `traceparent` et un contrat de logger **uniques** couvrent le
|
|
|
112
112
|
|
|
113
113
|
**Le `requestId` est un citoyen du contexte, pas un décor.** Il naît dans le constructeur de base
|
|
114
114
|
`Context.requestId = randomUUID()` (`Context.ts:244`), voyage dans l'ALS via `RequestContext.run(...)`
|
|
115
|
-
(`http-kernel.ts:
|
|
115
|
+
(`http-kernel.ts:519` pour HTTP, `http-kernel.ts:1960` pour WS), et se lit de n'importe où avec
|
|
116
116
|
`RequestContext.getRequestId()` — un controller, un service, un adapter ORM, sans jamais le threader.
|
|
117
117
|
|
|
118
118
|
**La ligne de bilan est branchable.** Le kernel ne code pas un format en dur : il consulte un
|
|
119
119
|
`IRequestLogger` (`IRequestLogger.ts:25`) résolu au boot depuis la config (`applyRequestLoggerFromConfig`,
|
|
120
|
-
`http-kernel.ts:
|
|
120
|
+
`http-kernel.ts:756`), remplaçable à chaud par `httpKernel.setRequestLogger(...)` (`http-kernel.ts:986`).
|
|
121
121
|
Trois formateurs sont livrés ; un quatrième maison s'écrit en implémentant l'interface.
|
|
122
122
|
|
|
123
123
|
**Zero Trust sur l'entrée cliente.** Un `X-Request-Id` fourni par le client finit réfléchi en réponse,
|
|
@@ -226,16 +226,16 @@ GET 200 /trace/whoami 3.1ms 127.0.0.1 [demo-abc]
|
|
|
226
226
|
| ------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
227
227
|
| Génération | `Context.requestId = randomUUID()` (`Context.ts:244`) | UUID v4 posé dans le constructeur de base — HTTP **et** WS. |
|
|
228
228
|
| Adoption HTTP | `sanitizeRequestId(headers["x-request-id"])` (`HttpContext.ts:158`) | Remplace l'UUID **si** la valeur cliente est sûre, sinon on garde l'UUID. |
|
|
229
|
-
| Adoption WS | `sanitizeRequestId(...)` au handshake (`WebsocketContext.ts:
|
|
230
|
-
| Réflexion HTTP/1.1 | `Response.setHeader("x-request-id", …)` (`Response.ts:
|
|
229
|
+
| Adoption WS | `sanitizeRequestId(...)` au handshake (`WebsocketContext.ts:162`) | Même validation, stable sur toute la durée de la socket (handshake → close). |
|
|
230
|
+
| Réflexion HTTP/1.1 | `Response.setHeader("x-request-id", …)` (`Response.ts:248`) | Écrit dans `writeHead()`, sur **chaque** réponse. |
|
|
231
231
|
| Réflexion HTTP/2 | `this.headers["x-request-id"] = requestId` (`http2/Response.ts:71`) | Sinon les réponses du port 5152 sortiraient sans corrélation. |
|
|
232
|
-
| ALS (HTTP) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:
|
|
233
|
-
| ALS (WS) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:
|
|
232
|
+
| ALS (HTTP) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:519`) | Ouvre la bulle → tout `Pdu` créé dedans est tagué. |
|
|
233
|
+
| ALS (WS) | `RequestContext.run({ requestId, … })` (`http-kernel.ts:519`) | Handshake **et** messages : la bulle ouverte à la connexion est reliée à chaque message par `AsyncResource.bind`, sinon l'identité résolue au handshake se perdrait au premier message. |
|
|
234
234
|
| Capture dans le log | `Pdu.requestId = Pdu.requestIdProvider?.()` (`Pdu.ts:262`) | Provider injectable branché sur l'ALS côté Node — 0 lecture côté navigateur. |
|
|
235
235
|
|
|
236
236
|
> [!IMPORTANT]
|
|
237
237
|
> Les logs de **fin** de requête (bilan `req`, `onClose`) sont émis **hors** de la bulle ALS (déjà
|
|
238
|
-
> refermée). L'override `Context.log()` (`Context.ts:
|
|
238
|
+
> refermée). L'override `Context.log()` (`Context.ts:545`) rouvre alors une micro-bulle depuis
|
|
239
239
|
> `this.requestId` pour que le `Pdu` capture quand même la corrélation — sinon la ligne d'entrée d'une
|
|
240
240
|
> trace serait la seule à ne PAS porter son `requestId`.
|
|
241
241
|
|
|
@@ -249,15 +249,15 @@ Nodefony implémente **W3C Trace Context** (le code s'y réfère explicitement,
|
|
|
249
249
|
- La validation refuse `version=ff` et un `traceId`/`spanId` tout-à-zéro — `parseTraceparent()`
|
|
250
250
|
(`trace.ts:38`), conforme à la spec (le récepteur NE DOIT PAS propager ces valeurs).
|
|
251
251
|
|
|
252
|
-
Le `traceparent` résolu est propagé en ALS **et** réfléchi sur la réponse HTTP (`context/http/Response.ts:
|
|
252
|
+
Le `traceparent` résolu est propagé en ALS **et** réfléchi sur la réponse HTTP (`context/http/Response.ts:572`). Côté
|
|
253
253
|
**WebSocket**, il est propagé en ALS mais **pas** réfléchi dans la réponse de handshake — la bibliothèque
|
|
254
|
-
`ws` n'expose pas proprement ce chemin (`http-kernel.ts:
|
|
254
|
+
`ws` n'expose pas proprement ce chemin (`http-kernel.ts:1450`) ; la corrélation reste visible côté serveur.
|
|
255
255
|
|
|
256
256
|
### Le contrat de logger — `IRequestLogger`
|
|
257
257
|
|
|
258
|
-
Le kernel tient un `IRequestLogger` singleton (`http-kernel.ts:
|
|
259
|
-
de bilan, au teardown, via `Context.logRequest()` (`Context.ts:
|
|
260
|
-
`WebsocketContext.logRequest()` (`WebsocketContext.ts:
|
|
258
|
+
Le kernel tient un `IRequestLogger` singleton (`http-kernel.ts:334`) et lui délègue le rendu de la ligne
|
|
259
|
+
de bilan, au teardown, via `Context.logRequest()` (`Context.ts:640`) côté HTTP et
|
|
260
|
+
`WebsocketContext.logRequest()` (`WebsocketContext.ts:233`) côté WS. Le contrat a trois méthodes
|
|
261
261
|
(`IRequestLogger.ts:25`) :
|
|
262
262
|
|
|
263
263
|
- `renderHttp(context, error?)` → `{ text, severity, msgid }` remis à `context.log()`.
|
|
@@ -287,7 +287,7 @@ Un seul réglage d'app, côté cœur (bloc `log`), pilote le format des lignes.
|
|
|
287
287
|
| ------------------- | ----------------------------------------- | ------ | ------------------------------------------------------------------------------------ |
|
|
288
288
|
| `log.requestFormat` | `auto` \| `default` \| `pretty` \| `json` | `auto` | Format de la ligne de bilan. `auto` = pretty en dev / json en prod (`schema.ts:39`). |
|
|
289
289
|
|
|
290
|
-
`auto` est résolu au boot selon l'environnement (`applyRequestLoggerFromConfig`, `http-kernel.ts:
|
|
290
|
+
`auto` est résolu au boot selon l'environnement (`applyRequestLoggerFromConfig`, `http-kernel.ts:756`) :
|
|
291
291
|
|
|
292
292
|
| Valeur | Formateur | Rendu |
|
|
293
293
|
| --------- | ---------------------- | ----------------------------------------------------------------------------------------- |
|
|
@@ -320,7 +320,7 @@ timing (`pretty-request-logger.ts:116`).
|
|
|
320
320
|
|
|
321
321
|
### `JsonAuditLogger` — un PDU par requête, pour la machine
|
|
322
322
|
|
|
323
|
-
Émet un `AuditLogEntry` canonique (`audit-logger.ts:28`) : `ts`, `requestId`, `
|
|
323
|
+
Émet un `AuditLogEntry` canonique (`audit-logger.ts:28`) : `ts`, `requestId`, `userIdentifier` (appelant authentifié, posé par le pare-feu sur `context.userIdentifier`), `type`, `method`,
|
|
324
324
|
`url`, `status`, `durationMs`, `remoteAddress`, `host`, `userAgent`, phases, erreur enrichie (nom, code,
|
|
325
325
|
`errorType`, `cause` bornée à 5, stack **dev seulement**). Deux propriétés majeures :
|
|
326
326
|
|
|
@@ -337,7 +337,7 @@ Singleton sans état, 0 allocation par requête (`request-logger.ts:21`). Conser
|
|
|
337
337
|
### Écrire son propre formateur
|
|
338
338
|
|
|
339
339
|
Implémenter `IRequestLogger` (`IRequestLogger.ts:25`) et l'injecter — NCSA Common Log Format, syslog RFC
|
|
340
|
-
5424 texte, OpenTelemetry logs… `httpKernel.setRequestLogger(monLogger)` (`http-kernel.ts:
|
|
340
|
+
5424 texte, OpenTelemetry logs… `httpKernel.setRequestLogger(monLogger)` (`http-kernel.ts:986`). Les trois
|
|
341
341
|
formateurs et le type sont exportés depuis `@nodefony/http` (`index.ts:221`).
|
|
342
342
|
|
|
343
343
|
## 🔐 Sécurité
|
|
@@ -418,9 +418,9 @@ instancié **qu'en dev** (fuite d'info + coût en prod).
|
|
|
418
418
|
| Symptôme | Cause | Correction |
|
|
419
419
|
| --------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
420
420
|
| Le `X-Request-Id` que j'envoie n'est pas réfléchi | Valeur non conforme (espace, CR/LF, non-ASCII, > 128) → **rejetée** | Utiliser `[A-Za-z0-9._-]{1,128}` (UUID/nanoid/traceparent OK) — sinon UUID serveur |
|
|
421
|
-
| Les logs de fin de requête n'ont pas de `requestId` | Ils sont émis hors bulle ALS | Déjà géré : l'override `log()` rouvre une micro-bulle (`Context.ts:
|
|
421
|
+
| Les logs de fin de requête n'ont pas de `requestId` | Ils sont émis hors bulle ALS | Déjà géré : l'override `log()` rouvre une micro-bulle (`Context.ts:545`) |
|
|
422
422
|
| Réponse HTTP/2 sans `x-request-id` | Chemin de réponse h2 distinct du 1.1 | Déjà géré (`http2/Response.ts:106`) — le port 5152 réfléchit aussi |
|
|
423
|
-
| Pas de `traceparent` renvoyé sur un WebSocket | `ws` n'expose pas l'écriture d'en-tête au handshake | Attendu — la trace WS reste propagée en ALS (`http-kernel.ts:
|
|
423
|
+
| Pas de `traceparent` renvoyé sur un WebSocket | `ws` n'expose pas l'écriture d'en-tête au handshake | Attendu — la trace WS reste propagée en ALS (`http-kernel.ts:1990`) |
|
|
424
424
|
| Frame WS binaire loggée en `{"0":..,"1":..}` | Sérialisation naïve d'un Buffer | Déjà géré : résumé `[binary N B]` (`wsLogContent.ts:63`) |
|
|
425
425
|
| Le format de log ne change pas malgré la config | Un `setRequestLogger(...)` programmatique gagne sur la config | L'override est volontaire (last setter wins) — retirer l'appel, ou le régler |
|
|
426
426
|
| Logs d'audit trop volumineux en prod | `stack` sérialisée, ou 100 % des 2xx audités | `includeStack:false` (défaut prod) + `sampleRate` via `setRequestLogger` |
|