@nodefony/http 10.0.0-alpha.4 → 10.0.0-alpha.6
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/LICENSE +201 -543
- package/README.md +1 -1
- package/dist/nodefony/command/assetsPublishCommand.js +2 -4
- package/dist/nodefony/command/proxyGenerateCommand.js +28 -9
- package/dist/nodefony/service/certificates.js +25 -0
- package/dist/nodefony/src/context/domainMatcher.js +34 -1
- package/dist/nodefony/src/proxy/generateProxyConfig.js +34 -7
- package/dist/nodefony/src/servers/portBinder.js +96 -24
- package/dist/types/nodefony/command/proxyGenerateCommand.d.ts +4 -0
- package/dist/types/nodefony/service/certificates.d.ts +22 -0
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +23 -0
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +24 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +58 -1
- package/docs/cookies.md +2 -2
- package/docs/observabilite.md +2 -2
- package/docs/rate-limit.md +5 -5
- package/docs/servers.md +16 -16
- package/docs/session.md +8 -8
- package/docs/upload.md +3 -3
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -43,14 +43,12 @@ var AssetsPublish = class extends Command {
|
|
|
43
43
|
async generate(opts) {
|
|
44
44
|
const outDir = opts.out ? isAbsolute(opts.out) ? opts.out : resolve(process.cwd(), opts.out) : join(process.cwd(), "dist-assets");
|
|
45
45
|
const sources = this.collectSources();
|
|
46
|
-
if (sources.length === 0) {
|
|
47
|
-
this.log("Aucune source d'assets (0 mount natif, 0 bundle frontend) — rien à publier.", "WARNING");
|
|
48
|
-
return this;
|
|
49
|
-
}
|
|
46
|
+
if (sources.length === 0) process.stdout.write(`Aucune source d'assets (0 mount natif, 0 bundle frontend) — arbre vide → ${outDir}\n`);
|
|
50
47
|
if (opts.clean && existsSync(outDir)) await fsp.rm(outDir, {
|
|
51
48
|
recursive: true,
|
|
52
49
|
force: true
|
|
53
50
|
});
|
|
51
|
+
await fsp.mkdir(outDir, { recursive: true });
|
|
54
52
|
const plan = planAssetPublish(sources, outDir);
|
|
55
53
|
const manifest = {
|
|
56
54
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { resolveTrustedHostNames } from "../src/context/domainMatcher.js";
|
|
1
2
|
import { defaultIntrospection, generateHaproxyConfig, generateNginxConfig } from "../src/proxy/generateProxyConfig.js";
|
|
3
|
+
import { planAssetPublish } from "../src/assets/collectAssets.js";
|
|
2
4
|
import { Command } from "nodefony";
|
|
3
5
|
import fsp from "node:fs/promises";
|
|
6
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
7
|
//#region nodefony/command/proxyGenerateCommand.ts
|
|
5
8
|
const options = {
|
|
6
9
|
helpGroup: "FRONT ET RÉSEAU",
|
|
@@ -21,17 +24,20 @@ var ProxyGenerate = class extends Command {
|
|
|
21
24
|
this.addOption("-b, --backend <host>", "backend host the proxy connects to (default 127.0.0.1)");
|
|
22
25
|
this.addOption("-l, --listen <port>", "proxy listen port (default 80)");
|
|
23
26
|
this.addOption("--reencrypt", "re-encrypt to the HTTPS backend (TLS proxy↔backend) instead of clear");
|
|
27
|
+
this.addOption("--assets-root <dir>", "serve every static asset from this single tree (see `assets:publish`)");
|
|
28
|
+
this.addOption("--tls-cert <file>", "TLS certificate chain (nginx only)");
|
|
29
|
+
this.addOption("--tls-key <file>", "TLS private key (nginx only)");
|
|
30
|
+
this.addOption("--tls-listen <port>", "TLS listen port (default 443)");
|
|
24
31
|
}
|
|
25
32
|
async generate(target, opts) {
|
|
26
|
-
if (target !== "nginx" && target !== "haproxy") {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
33
|
+
if (target !== "nginx" && target !== "haproxy") throw new Error(`Cible inconnue '${target}' — attendu: nginx | haproxy.`);
|
|
34
|
+
if (Boolean(opts.tlsCert) !== Boolean(opts.tlsKey)) throw new Error("--tls-cert et --tls-key vont ensemble — l'un sans l'autre ne produit aucune écoute TLS.");
|
|
35
|
+
if (opts.tlsCert && target === "haproxy") throw new Error("--tls-cert/--tls-key ne valent que pour nginx : haproxy exige un PEM combiné (cf docker/certs/build-haproxy-pem.sh).");
|
|
30
36
|
const intro = this.buildIntrospection(opts);
|
|
31
37
|
const conf = target === "nginx" ? generateNginxConfig(intro) : generateHaproxyConfig(intro);
|
|
32
38
|
if (opts.out) {
|
|
33
39
|
await fsp.writeFile(opts.out, conf, "utf8");
|
|
34
|
-
|
|
40
|
+
process.stdout.write(`Configuration ${target} écrite → ${opts.out}\n`);
|
|
35
41
|
} else process.stdout.write(conf);
|
|
36
42
|
return this;
|
|
37
43
|
}
|
|
@@ -42,14 +48,22 @@ var ProxyGenerate = class extends Command {
|
|
|
42
48
|
const servers = (this.kernel?.options)?.servers;
|
|
43
49
|
const staticSvc = module?.get("server-static");
|
|
44
50
|
staticSvc?.mountModulePublics?.();
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
let staticRoots = staticSvc?.servers ? Object.keys(staticSvc.servers) : [];
|
|
52
|
+
let mounts = (staticSvc?.mounts ?? []).map((m) => ({
|
|
47
53
|
prefix: m.prefix,
|
|
48
54
|
dir: m.dir
|
|
49
55
|
}));
|
|
56
|
+
if (opts.assetsRoot) {
|
|
57
|
+
const root = isAbsolute(opts.assetsRoot) ? opts.assetsRoot : resolve(process.cwd(), opts.assetsRoot);
|
|
58
|
+
mounts = planAssetPublish(mounts, root).map((p) => ({
|
|
59
|
+
prefix: p.prefix,
|
|
60
|
+
dir: p.target
|
|
61
|
+
}));
|
|
62
|
+
staticRoots = [root];
|
|
63
|
+
}
|
|
50
64
|
return {
|
|
51
65
|
...defaultIntrospection,
|
|
52
|
-
domains:
|
|
66
|
+
domains: resolveTrustedHostNames(this.kernel?.domain ?? "", httpOpts.trustedHosts),
|
|
53
67
|
backendHost: opts.backend ?? "127.0.0.1",
|
|
54
68
|
httpPort: Number(servers?.http?.port) || defaultIntrospection.httpPort,
|
|
55
69
|
httpsPort: Number(servers?.https?.port) || defaultIntrospection.httpsPort,
|
|
@@ -58,7 +72,12 @@ var ProxyGenerate = class extends Command {
|
|
|
58
72
|
listen: opts.listen ? Number(opts.listen) : defaultIntrospection.listen,
|
|
59
73
|
reencrypt: Boolean(opts.reencrypt),
|
|
60
74
|
maxBodyBytes: Number(httpOpts.maxBodySize) || 0,
|
|
61
|
-
keepaliveIntervalMs: Number(httpOpts.websocket?.keepaliveInterval) || 0
|
|
75
|
+
keepaliveIntervalMs: Number(httpOpts.websocket?.keepaliveInterval) || 0,
|
|
76
|
+
tls: opts.tlsCert && opts.tlsKey ? {
|
|
77
|
+
certPath: opts.tlsCert,
|
|
78
|
+
keyPath: opts.tlsKey,
|
|
79
|
+
listen: Number(opts.tlsListen) || 443
|
|
80
|
+
} : null
|
|
62
81
|
};
|
|
63
82
|
}
|
|
64
83
|
};
|
|
@@ -98,7 +98,32 @@ var Certificate = class Certificate extends Service {
|
|
|
98
98
|
if (!this.forge) throw new Error("node-forge non chargé — appeler loadForge() avant toute génération.");
|
|
99
99
|
return this.forge;
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Fabrique-t-on un certificat au démarrage ?
|
|
103
|
+
*
|
|
104
|
+
* 🔴 Seulement si un serveur TLS est ACTIF. Sans cette question, le hook
|
|
105
|
+
* ci-dessous écrit dans `nodefony/config/certificates` à CHAQUE boot — y
|
|
106
|
+
* compris celui d'une application qui a coupé son écoute TLS, et y compris un
|
|
107
|
+
* run de console qui n'ouvre aucun port.
|
|
108
|
+
*
|
|
109
|
+
* Ce qu'il en coûtait, mesuré sur une image générée : le code d'une image
|
|
110
|
+
* appartient à `root` et le processus tourne en `1000` (c'est voulu — une
|
|
111
|
+
* application qui peut réécrire son propre `dist/` offre à une faille un moyen
|
|
112
|
+
* de PERSISTER). Le `mkdir` mourait donc en `EACCES`, le hook de boot était
|
|
113
|
+
* « critique », et l'application ne démarrait PAS — quel que soit son préset.
|
|
114
|
+
* L'erreur nommait un dossier de certificats sur une application qui n'en veut
|
|
115
|
+
* aucun : elle envoyait chercher du côté du TLS un défaut de permission.
|
|
116
|
+
*
|
|
117
|
+
* C'est aussi ce que le gabarit d'application promet en toutes lettres : en
|
|
118
|
+
* production, l'écoute TLS est coupée tant qu'aucun port HTTPS n'est demandé,
|
|
119
|
+
* précisément pour ne PAS fabriquer une clé RSA à chaque démarrage de chaque
|
|
120
|
+
* exemplaire. La promesse était écrite ; rien ne la tenait.
|
|
121
|
+
*/
|
|
122
|
+
get tlsWanted() {
|
|
123
|
+
return !!this.module.kernel?.options?.servers?.https;
|
|
124
|
+
}
|
|
101
125
|
async init() {
|
|
126
|
+
if (!this.tlsWanted) return this;
|
|
102
127
|
this.kernel?.once("onBoot", async () => {
|
|
103
128
|
this.options = extend(true, this.options, this.module.options.certificates || {});
|
|
104
129
|
await this.generateServerCertificates();
|
|
@@ -74,6 +74,39 @@ function compileTrustedHosts(domain, trusted, isDev) {
|
|
|
74
74
|
return compileDomainPatterns(patterns);
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
|
+
* Rend les NOMS d'hôtes que la barrière `trustedHosts` accepte — la même
|
|
78
|
+
* politique que {@link compileTrustedHosts}, mais lisible par un humain ou par
|
|
79
|
+
* un générateur de configuration (`server_name` nginx, `hdr(host)` haproxy).
|
|
80
|
+
*
|
|
81
|
+
* Pourquoi une seconde lecture de la même règle : une `RegExp` ne se réécrit pas
|
|
82
|
+
* en nom d'hôte. La commande `proxy:generate` lisait donc `trustedHosts` à sa
|
|
83
|
+
* façon, en le supposant TOUJOURS `string[]` — alors que sa valeur par DÉFAUT
|
|
84
|
+
* est `false`, celle de toute application générée. Une politique lue à deux
|
|
85
|
+
* endroits finit par diverger : ici les deux fonctions partent de la même
|
|
86
|
+
* valeur, et la règle « le domaine canonique est toujours accepté » n'est
|
|
87
|
+
* écrite qu'une fois.
|
|
88
|
+
*
|
|
89
|
+
* Le loopback de développement n'en fait volontairement pas partie : une
|
|
90
|
+
* configuration de proxy décrit un déploiement, pas la machine de l'auteur.
|
|
91
|
+
*
|
|
92
|
+
* @param domain - domaine canonique du serveur (`kernel.domain`).
|
|
93
|
+
* @param trusted - config `http.trustedHosts` (optionnelle).
|
|
94
|
+
* @returns les noms acceptés, sans doublon. **Vide** si `trusted === true`
|
|
95
|
+
* (bypass : le proxy filtre déjà le `Host`, aucun nom n'est à imposer) ; les
|
|
96
|
+
* motifs `RegExp` sont écartés, faute d'être exprimables en nom d'hôte.
|
|
97
|
+
*/
|
|
98
|
+
function resolveTrustedHostNames(domain, trusted) {
|
|
99
|
+
if (trusted === true) return [];
|
|
100
|
+
const patterns = [domain];
|
|
101
|
+
if (trusted) {
|
|
102
|
+
if (Array.isArray(trusted)) patterns.push(...trusted);
|
|
103
|
+
else patterns.push(trusted);
|
|
104
|
+
}
|
|
105
|
+
const names = [];
|
|
106
|
+
for (const p of patterns) if (typeof p === "string" && p && !names.includes(p)) names.push(p);
|
|
107
|
+
return names;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
77
110
|
* Teste un `Host` entrant contre une liste de `RegExp` pré-compilée.
|
|
78
111
|
*
|
|
79
112
|
* @param regAlias - sortie de {@link compileTrustedHosts} ou {@link compileDomainPatterns}.
|
|
@@ -85,4 +118,4 @@ function isDomainAllowed(regAlias, domain) {
|
|
|
85
118
|
return false;
|
|
86
119
|
}
|
|
87
120
|
//#endregion
|
|
88
|
-
export { compileDomainPattern, compileDomainPatterns, compileTrustedHosts, isDomainAllowed };
|
|
121
|
+
export { compileDomainPattern, compileDomainPatterns, compileTrustedHosts, isDomainAllowed, resolveTrustedHostNames };
|
|
@@ -10,7 +10,8 @@ const defaultIntrospection = {
|
|
|
10
10
|
listen: 80,
|
|
11
11
|
reencrypt: false,
|
|
12
12
|
maxBodyBytes: 0,
|
|
13
|
-
keepaliveIntervalMs: 0
|
|
13
|
+
keepaliveIntervalMs: 0,
|
|
14
|
+
tls: null
|
|
14
15
|
};
|
|
15
16
|
/**
|
|
16
17
|
* Délai d'inactivité, en secondes, qu'un proxy doit accorder à une connexion
|
|
@@ -68,23 +69,49 @@ function generateNginxConfig(intro) {
|
|
|
68
69
|
const backendPort = intro.reencrypt ? intro.httpsPort : intro.httpPort;
|
|
69
70
|
const idleSeconds = idleTimeoutSeconds(intro);
|
|
70
71
|
const lines = [];
|
|
71
|
-
lines.push("# Généré par `nodefony proxy:generate nginx` — NE PAS éditer à la main.", "# Reverse-proxy dérivé de l'introspection Nodefony (domaines, statiques, ports).", "worker_processes auto;", "events { worker_connections 1024; }", "", "http {", " # Upgrade WebSocket — HTTP et WS co-habitent sur le même port Nodefony.", " map $http_upgrade $connection_upgrade { default upgrade; '' close; }", "", ` upstream nodefony { server ${intro.backendHost}:${backendPort}; keepalive 32; }`, "");
|
|
72
|
+
lines.push("# Généré par `nodefony proxy:generate nginx` — NE PAS éditer à la main.", "# Reverse-proxy dérivé de l'introspection Nodefony (domaines, statiques, ports).", "worker_processes auto;", "events { worker_connections 1024; }", "", "http {", " include /etc/nginx/mime.types;", " default_type application/octet-stream;", "", " server_tokens off;", "", " sendfile on;", " tcp_nopush on;", " tcp_nodelay on;", "", " gzip on;", " gzip_vary on;", " gzip_min_length 1024;", " gzip_proxied any;", " gzip_types text/plain text/css text/xml application/javascript application/json application/xml image/svg+xml application/manifest+json;", "", " # Upgrade WebSocket — HTTP et WS co-habitent sur le même port Nodefony.", " map $http_upgrade $connection_upgrade { default upgrade; '' close; }", "", ` upstream nodefony { server ${intro.backendHost}:${backendPort}; keepalive 32; }`, "");
|
|
72
73
|
if (intro.maxBodyBytes > 0) lines.push(` # Aligné sur \`http.maxBodySize\` (${intro.maxBodyBytes} octets) — sans quoi`, " # nginx rendrait 413 à 1 Mo, son défaut, sans que le serveur le sache.", ` client_max_body_size ${intro.maxBodyBytes};`, "");
|
|
73
|
-
lines.push(
|
|
74
|
+
lines.push(...nginxServerBlock(intro, scheme, idleSeconds, null));
|
|
75
|
+
if (intro.tls) lines.push("", ...nginxServerBlock(intro, scheme, idleSeconds, intro.tls));
|
|
76
|
+
lines.push("}", "");
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Un bloc `server {}` nginx — corps IDENTIQUE en clair et en TLS.
|
|
81
|
+
*
|
|
82
|
+
* Le scheme annoncé au backend reste `$scheme`, que nginx CONSTATE sur la
|
|
83
|
+
* connexion entrante : le même corps sert donc les deux écoutes sans qu'aucune
|
|
84
|
+
* n'ait à savoir laquelle elle est. C'est ce qui fait qu'un cookie `Secure`
|
|
85
|
+
* tient derrière le frontal alors que le lien interne est en clair.
|
|
86
|
+
*
|
|
87
|
+
* @param intro - modèle d'introspection Nodefony.
|
|
88
|
+
* @param scheme - `http` ou `https` vers le BACKEND (re-chiffrement).
|
|
89
|
+
* @param idleSeconds - inactivité tolérée, dérivée du heartbeat WebSocket.
|
|
90
|
+
* @param tls - terminaison TLS de CE bloc, ou `null` pour une écoute en clair.
|
|
91
|
+
* @returns les lignes du bloc `server`.
|
|
92
|
+
*/
|
|
93
|
+
function nginxServerBlock(intro, scheme, idleSeconds, tls) {
|
|
94
|
+
const lines = [" server {"];
|
|
95
|
+
if (tls) lines.push(` listen ${tls.listen} ssl;`, " http2 on;", ` server_name ${serverNames(intro.domains)};`, "", " # Terminaison TLS au frontal. Ces chemins sont ceux d'un MONTAGE, à", " # pourvoir au déploiement (volume compose, secret k8s) : une clé privée", " # n'entre pas dans une image, où la couche reste lisible même effacée.", ` ssl_certificate ${tls.certPath};`, ` ssl_certificate_key ${tls.keyPath};`, " ssl_protocols TLSv1.2 TLSv1.3;", " ssl_session_cache shared:SSL:10m;", " ssl_session_timeout 1h;", "", " error_page 497 =301 https://$http_host$request_uri;");
|
|
96
|
+
else lines.push(` listen ${intro.listen};`, ` server_name ${serverNames(intro.domains)};`);
|
|
74
97
|
if (intro.reencrypt) lines.push(" # Re-encrypt : valider le cert backend (cf docker/certs).", " # proxy_ssl_trusted_certificate /etc/nginx/certs/ca.pem;", " # proxy_ssl_verify on; proxy_ssl_name nodefony.com;");
|
|
75
|
-
for (const m of intro.mounts)
|
|
98
|
+
for (const m of intro.mounts) {
|
|
99
|
+
const prefix = ensureTrailingSlash(m.prefix);
|
|
100
|
+
const dir = ensureTrailingSlash(m.dir);
|
|
101
|
+
lines.push("", ` location ${prefix}assets/ {`, ` alias ${dir}assets/;`, " access_log off;", " expires 1y;", " add_header Cache-Control \"public, immutable\";", " }", "", ` location ${prefix} {`, ` alias ${dir};`, " access_log off;", " expires 1h;", " }");
|
|
102
|
+
}
|
|
76
103
|
lines.push("", ` location @nodefony {`, ` proxy_pass ${scheme}://nodefony;`, " proxy_http_version 1.1;", nginxForwardHeaders(idleSeconds), " }");
|
|
77
104
|
if (intro.staticRoots.length === 0) lines.push("", " location / {", ` proxy_pass ${scheme}://nodefony;`, " proxy_http_version 1.1;", nginxForwardHeaders(idleSeconds), " }");
|
|
78
105
|
else {
|
|
79
106
|
const roots = intro.staticRoots;
|
|
80
|
-
lines.push("", " # Statiques multi-dossiers (racine app + modules) : chaîne try_files
|
|
107
|
+
lines.push("", roots.length > 1 ? " # Statiques multi-dossiers (racine app + modules) : chaîne try_files,\n # fallback vers le backend Nodefony si aucun fichier ne matche." : " # Statiques servis par le frontal ; fallback vers le backend\n # Nodefony si aucun fichier ne correspond.", " location / {", ` root ${roots[0]};`, ` try_files $uri ${roots.length > 1 ? "@r1" : "@nodefony"};`, " }");
|
|
81
108
|
for (let i = 1; i < roots.length; i++) {
|
|
82
109
|
const next = i + 1 < roots.length ? `@r${i + 1}` : "@nodefony";
|
|
83
110
|
lines.push(` location @r${i} {`, ` root ${roots[i]};`, ` try_files $uri ${next};`, " }");
|
|
84
111
|
}
|
|
85
112
|
}
|
|
86
|
-
lines.push(" }"
|
|
87
|
-
return lines
|
|
113
|
+
lines.push(" }");
|
|
114
|
+
return lines;
|
|
88
115
|
}
|
|
89
116
|
/**
|
|
90
117
|
* Génère une configuration haproxy (reverse-proxy + Forwarded RFC 7239).
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPortListening } from "nodefony";
|
|
1
2
|
//#region nodefony/src/servers/portBinder.ts
|
|
2
3
|
/** Nombre de ports essayés après le désiré, en `auto`. */
|
|
3
4
|
const DEFAULT_PORT_RETRY_ATTEMPTS = 20;
|
|
@@ -42,6 +43,69 @@ function buildBindPlan(which, servers, environment) {
|
|
|
42
43
|
attempts: policy === "auto" ? servers?.portRetryAttempts ?? 20 : 0
|
|
43
44
|
};
|
|
44
45
|
}
|
|
46
|
+
/** Adresses qui désignent « toutes les interfaces » — rien ne s'y connecte. */
|
|
47
|
+
const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
|
|
48
|
+
"",
|
|
49
|
+
"*",
|
|
50
|
+
"0.0.0.0",
|
|
51
|
+
"::",
|
|
52
|
+
"[::]"
|
|
53
|
+
]);
|
|
54
|
+
/** Adresses de la boucle locale, toutes familles — c'est aussi ce qu'on REND. */
|
|
55
|
+
const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
|
|
56
|
+
/** Les mêmes, plus le nom qui les désigne — DÉRIVÉ, jamais recopié. */
|
|
57
|
+
const LOOPBACK_ALIASES = /* @__PURE__ */ new Set([...LOOPBACK_HOSTS, "localhost"]);
|
|
58
|
+
/**
|
|
59
|
+
* Adresses à interroger pour savoir si notre future liaison chevauche déjà un
|
|
60
|
+
* serveur vivant.
|
|
61
|
+
*
|
|
62
|
+
* La règle est celle du TRAFIC, pas celle du noyau : on sonde ce que notre
|
|
63
|
+
* liaison va RECEVOIR.
|
|
64
|
+
* - Écouter sur **toutes les interfaces** recouvre la boucle locale : on sonde
|
|
65
|
+
* ses deux adresses (`127.0.0.1` et `::1`). Un wildcard n'est pas une
|
|
66
|
+
* destination, on ne peut pas s'y connecter.
|
|
67
|
+
* - Écouter sur la **boucle locale** (`localhost` résout `::1` ici, `127.0.0.1`
|
|
68
|
+
* ailleurs — et les clients ne choisissent pas) : les deux, là encore.
|
|
69
|
+
* - Écouter sur une **adresse d'interface précise** : elle seule. Une connexion
|
|
70
|
+
* vers elle atteint le tiers qu'il ait lié cette adresse ou le wildcard, donc
|
|
71
|
+
* une cible suffit — et sonder la boucle locale ferait refuser un pod à cause
|
|
72
|
+
* d'un voisin de conteneur qui n'a jamais été en conflit.
|
|
73
|
+
*
|
|
74
|
+
* Fonction PURE : le banc l'éprouve sans ouvrir un socket.
|
|
75
|
+
*
|
|
76
|
+
* @param host - hôte passé à `listen` (`kernel.domain`).
|
|
77
|
+
* @returns les adresses à interroger, dans l'ordre.
|
|
78
|
+
*/
|
|
79
|
+
function conflictProbeTargets(host) {
|
|
80
|
+
const value = (host ?? "").trim().toLowerCase();
|
|
81
|
+
if (WILDCARD_HOSTS.has(value) || LOOPBACK_ALIASES.has(value)) return LOOPBACK_HOSTS;
|
|
82
|
+
return [value];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Première adresse où un serveur répond DÉJÀ sur ce port, `null` si la voie est
|
|
86
|
+
* libre.
|
|
87
|
+
*
|
|
88
|
+
* @param port - port candidat.
|
|
89
|
+
* @param host - hôte que `listen` recevra (décide des adresses interrogées).
|
|
90
|
+
* @param probe - sonde de présence (injectée par le banc).
|
|
91
|
+
*/
|
|
92
|
+
async function detectPortConflict(port, host, probe) {
|
|
93
|
+
for (const target of conflictProbeTargets(host)) if (await probe(port, target)) return target;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Erreur d'un conflit que le noyau aurait laissé passer.
|
|
98
|
+
*
|
|
99
|
+
* Porte `EADDRINUSE` : pour l'appelant c'est le même fait — le port n'est pas
|
|
100
|
+
* prenable — et une seule branche le traite. Le message, lui, doit dire ce que
|
|
101
|
+
* le code d'erreur seul ferait chercher au mauvais endroit : ici `listen` aurait
|
|
102
|
+
* RÉUSSI, et c'est la liaison d'une AUTRE adresse qui capte le trafic.
|
|
103
|
+
*/
|
|
104
|
+
function portConflictError(port, host) {
|
|
105
|
+
const error = /* @__PURE__ */ new Error(`Port ${port} déjà servi par un autre processus sur ${host} — le noyau l'aurait pourtant accordé (deux adresses distinctes coexistent sur le même port), et cette application aurait écouté sans rien recevoir. Libérer le port (nodefony status · nodefony stop), en figer un autre (servers.http.port / servers.https.port), ou autoriser le repli (servers.portPolicy = "auto", défaut en développement).`);
|
|
106
|
+
error.code = "EADDRINUSE";
|
|
107
|
+
return error;
|
|
108
|
+
}
|
|
45
109
|
/** Prochain candidat : incrémente, en sautant ce que les autres serveurs veulent. */
|
|
46
110
|
function nextCandidate(from, reserved) {
|
|
47
111
|
let port = from + 1;
|
|
@@ -84,31 +148,39 @@ function isPortUnavailable(code, port) {
|
|
|
84
148
|
* (`ENOTFOUND`, `EACCES` sous 1024 — cf `isPortUnavailable`), soit un port
|
|
85
149
|
* indisponible après épuisement des essais (le fallback n'est PAS infini).
|
|
86
150
|
*/
|
|
87
|
-
function bindWithFallback(server, host, plan) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const onListening = () => {
|
|
100
|
-
server.removeListener("error", onError);
|
|
101
|
-
resolve({
|
|
102
|
-
address: server.address(),
|
|
103
|
-
shiftedFrom: candidate === plan.desired ? null : plan.desired
|
|
104
|
-
});
|
|
105
|
-
};
|
|
106
|
-
server.once("error", onError);
|
|
107
|
-
server.once("listening", onListening);
|
|
108
|
-
server.listen(candidate, host);
|
|
151
|
+
async function bindWithFallback(server, host, plan, probe = isPortListening) {
|
|
152
|
+
let candidate = plan.desired;
|
|
153
|
+
let used = 0;
|
|
154
|
+
/**
|
|
155
|
+
* Une tentative d'écoute. L'erreur est RENDUE, jamais levée depuis le
|
|
156
|
+
* callback : les deux écouteurs se retirent dans les deux cas, et la boucle
|
|
157
|
+
* ci-dessous reste le seul endroit qui décide.
|
|
158
|
+
*/
|
|
159
|
+
const listenOnce = (port) => new Promise((settle) => {
|
|
160
|
+
const onError = (error) => {
|
|
161
|
+
server.removeListener("listening", onListening);
|
|
162
|
+
settle(error);
|
|
109
163
|
};
|
|
110
|
-
|
|
164
|
+
const onListening = () => {
|
|
165
|
+
server.removeListener("error", onError);
|
|
166
|
+
settle(null);
|
|
167
|
+
};
|
|
168
|
+
server.once("error", onError);
|
|
169
|
+
server.once("listening", onListening);
|
|
170
|
+
server.listen(port, host);
|
|
111
171
|
});
|
|
172
|
+
for (;;) {
|
|
173
|
+
const conflict = plan.desired === 0 || candidate !== plan.desired ? null : await detectPortConflict(candidate, host, probe);
|
|
174
|
+
const failure = conflict ? portConflictError(candidate, conflict) : await listenOnce(candidate);
|
|
175
|
+
if (failure === null) return {
|
|
176
|
+
address: server.address(),
|
|
177
|
+
shiftedFrom: candidate === plan.desired ? null : plan.desired
|
|
178
|
+
};
|
|
179
|
+
if (!conflict && !isPortUnavailable(failure.code, candidate)) throw failure;
|
|
180
|
+
if (plan.desired === 0 || used >= plan.attempts) throw failure;
|
|
181
|
+
used += 1;
|
|
182
|
+
candidate = nextCandidate(candidate, plan.reserved);
|
|
183
|
+
}
|
|
112
184
|
}
|
|
113
185
|
//#endregion
|
|
114
|
-
export { DEFAULT_PORT_RETRY_ATTEMPTS, bindWithFallback, buildBindPlan, resolvePortPolicy };
|
|
186
|
+
export { DEFAULT_PORT_RETRY_ATTEMPTS, bindWithFallback, buildBindPlan, conflictProbeTargets, detectPortConflict, resolvePortPolicy };
|
|
@@ -12,6 +12,10 @@ declare class ProxyGenerate extends Command {
|
|
|
12
12
|
backend?: string;
|
|
13
13
|
listen?: string;
|
|
14
14
|
reencrypt?: boolean;
|
|
15
|
+
assetsRoot?: string;
|
|
16
|
+
tlsCert?: string;
|
|
17
|
+
tlsKey?: string;
|
|
18
|
+
tlsListen?: string;
|
|
15
19
|
}): Promise<this>;
|
|
16
20
|
/** Construit le modèle d'introspection depuis le kernel + le service statique. */
|
|
17
21
|
private buildIntrospection;
|
|
@@ -146,6 +146,28 @@ declare class Certificate extends Service {
|
|
|
146
146
|
loadForge(): Promise<ForgeModule>;
|
|
147
147
|
/** Accès au backend forge déjà chargé (lève si `loadForge` n'a pas été appelé). */
|
|
148
148
|
private get forgeLib();
|
|
149
|
+
/**
|
|
150
|
+
* Fabrique-t-on un certificat au démarrage ?
|
|
151
|
+
*
|
|
152
|
+
* 🔴 Seulement si un serveur TLS est ACTIF. Sans cette question, le hook
|
|
153
|
+
* ci-dessous écrit dans `nodefony/config/certificates` à CHAQUE boot — y
|
|
154
|
+
* compris celui d'une application qui a coupé son écoute TLS, et y compris un
|
|
155
|
+
* run de console qui n'ouvre aucun port.
|
|
156
|
+
*
|
|
157
|
+
* Ce qu'il en coûtait, mesuré sur une image générée : le code d'une image
|
|
158
|
+
* appartient à `root` et le processus tourne en `1000` (c'est voulu — une
|
|
159
|
+
* application qui peut réécrire son propre `dist/` offre à une faille un moyen
|
|
160
|
+
* de PERSISTER). Le `mkdir` mourait donc en `EACCES`, le hook de boot était
|
|
161
|
+
* « critique », et l'application ne démarrait PAS — quel que soit son préset.
|
|
162
|
+
* L'erreur nommait un dossier de certificats sur une application qui n'en veut
|
|
163
|
+
* aucun : elle envoyait chercher du côté du TLS un défaut de permission.
|
|
164
|
+
*
|
|
165
|
+
* C'est aussi ce que le gabarit d'application promet en toutes lettres : en
|
|
166
|
+
* production, l'écoute TLS est coupée tant qu'aucun port HTTPS n'est demandé,
|
|
167
|
+
* précisément pour ne PAS fabriquer une clé RSA à chaque démarrage de chaque
|
|
168
|
+
* exemplaire. La promesse était écrite ; rien ne la tenait.
|
|
169
|
+
*/
|
|
170
|
+
private get tlsWanted();
|
|
149
171
|
init(): Promise<this>;
|
|
150
172
|
/**
|
|
151
173
|
* Numéro de série X.509 — RFC 5280 §4.1.2.2 : entier positif unique par CA.
|
|
@@ -57,6 +57,29 @@ export declare function compileDomainPatterns(patterns: DomainPattern | DomainPa
|
|
|
57
57
|
* @returns liste de `RegExp` pour {@link isDomainAllowed}.
|
|
58
58
|
*/
|
|
59
59
|
export declare function compileTrustedHosts(domain: string, trusted: ITrustedHostsConfig | undefined, isDev: boolean): RegExp[];
|
|
60
|
+
/**
|
|
61
|
+
* Rend les NOMS d'hôtes que la barrière `trustedHosts` accepte — la même
|
|
62
|
+
* politique que {@link compileTrustedHosts}, mais lisible par un humain ou par
|
|
63
|
+
* un générateur de configuration (`server_name` nginx, `hdr(host)` haproxy).
|
|
64
|
+
*
|
|
65
|
+
* Pourquoi une seconde lecture de la même règle : une `RegExp` ne se réécrit pas
|
|
66
|
+
* en nom d'hôte. La commande `proxy:generate` lisait donc `trustedHosts` à sa
|
|
67
|
+
* façon, en le supposant TOUJOURS `string[]` — alors que sa valeur par DÉFAUT
|
|
68
|
+
* est `false`, celle de toute application générée. Une politique lue à deux
|
|
69
|
+
* endroits finit par diverger : ici les deux fonctions partent de la même
|
|
70
|
+
* valeur, et la règle « le domaine canonique est toujours accepté » n'est
|
|
71
|
+
* écrite qu'une fois.
|
|
72
|
+
*
|
|
73
|
+
* Le loopback de développement n'en fait volontairement pas partie : une
|
|
74
|
+
* configuration de proxy décrit un déploiement, pas la machine de l'auteur.
|
|
75
|
+
*
|
|
76
|
+
* @param domain - domaine canonique du serveur (`kernel.domain`).
|
|
77
|
+
* @param trusted - config `http.trustedHosts` (optionnelle).
|
|
78
|
+
* @returns les noms acceptés, sans doublon. **Vide** si `trusted === true`
|
|
79
|
+
* (bypass : le proxy filtre déjà le `Host`, aucun nom n'est à imposer) ; les
|
|
80
|
+
* motifs `RegExp` sont écartés, faute d'être exprimables en nom d'hôte.
|
|
81
|
+
*/
|
|
82
|
+
export declare function resolveTrustedHostNames(domain: string, trusted: ITrustedHostsConfig | undefined): string[];
|
|
60
83
|
/**
|
|
61
84
|
* Teste un `Host` entrant contre une liste de `RegExp` pré-compilée.
|
|
62
85
|
*
|
|
@@ -16,6 +16,22 @@ export interface ProxyStaticMount {
|
|
|
16
16
|
/** Dossier absolu servi. */
|
|
17
17
|
dir: string;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Terminaison TLS au frontal — **nginx uniquement**.
|
|
21
|
+
*
|
|
22
|
+
* Les chemins sont ceux vus par le PROXY à l'exécution, jamais ceux de la
|
|
23
|
+
* machine qui a généré la configuration : une clé privée n'entre pas dans une
|
|
24
|
+
* image (une couche reste lisible même effacée plus loin), elle se MONTE au
|
|
25
|
+
* déploiement — volume compose, secret Kubernetes.
|
|
26
|
+
*/
|
|
27
|
+
export interface ProxyTlsTermination {
|
|
28
|
+
/** Chaîne de certificats servie au client (`fullchain.pem`). */
|
|
29
|
+
certPath: string;
|
|
30
|
+
/** Clé privée correspondante (`privkey.pem`). */
|
|
31
|
+
keyPath: string;
|
|
32
|
+
/** Port d'écoute TLS du proxy. */
|
|
33
|
+
listen: number;
|
|
34
|
+
}
|
|
19
35
|
/** Modèle d'introspection consommé par les générateurs. */
|
|
20
36
|
export interface ProxyIntrospection {
|
|
21
37
|
/** `server_name` (hôtes de confiance, IP exclues). Vide → `_` (catch-all). */
|
|
@@ -55,6 +71,14 @@ export interface ProxyIntrospection {
|
|
|
55
71
|
* saines.
|
|
56
72
|
*/
|
|
57
73
|
keepaliveIntervalMs: number;
|
|
74
|
+
/**
|
|
75
|
+
* Terminaison TLS au frontal, ou `null` pour n'écouter qu'en clair.
|
|
76
|
+
*
|
|
77
|
+
* **nginx seulement** : haproxy exige un PEM COMBINÉ (certificat + clé dans
|
|
78
|
+
* un même fichier), que Nodefony ne fabrique pas — la commande REFUSE donc
|
|
79
|
+
* l'option sur cette cible plutôt que de l'accepter et de la jeter.
|
|
80
|
+
*/
|
|
81
|
+
tls: ProxyTlsTermination | null;
|
|
58
82
|
}
|
|
59
83
|
/** Valeurs par défaut d'un modèle d'introspection (complété par la commande). */
|
|
60
84
|
export declare const defaultIntrospection: ProxyIntrospection;
|
|
@@ -14,6 +14,24 @@
|
|
|
14
14
|
* `listen()` est, lui, **atomique** : soit il réussit, soit le noyau dit
|
|
15
15
|
* `EADDRINUSE`. On retente donc sur l'échec réel, jamais sur une prédiction.
|
|
16
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
|
+
*
|
|
17
35
|
* ## Pourquoi sauter les ports réservés
|
|
18
36
|
*
|
|
19
37
|
* HTTP veut 5151, HTTPS veut 5152. Si 5151 est pris, incrémenter naïvement ferait
|
|
@@ -86,6 +104,45 @@ export interface ServersPortConfig {
|
|
|
86
104
|
* @param environment - `kernel.environment` (arbitre le défaut de la politique).
|
|
87
105
|
*/
|
|
88
106
|
export declare function buildBindPlan(which: "http" | "https", servers: ServersPortConfig | undefined, environment: string | undefined): BindPlan;
|
|
107
|
+
/**
|
|
108
|
+
* Sonde « un serveur répond-il déjà ici ? ».
|
|
109
|
+
*
|
|
110
|
+
* Injectable pour que le banc éprouve la DÉCISION sans dépendre d'un décor
|
|
111
|
+
* réseau ; le défaut est `isPortListening`, exposé par le cœur pour ne pas être
|
|
112
|
+
* recopié de paquet en paquet.
|
|
113
|
+
*/
|
|
114
|
+
export type PortListeningProbe = (port: number, host: string) => Promise<boolean>;
|
|
115
|
+
/**
|
|
116
|
+
* Adresses à interroger pour savoir si notre future liaison chevauche déjà un
|
|
117
|
+
* serveur vivant.
|
|
118
|
+
*
|
|
119
|
+
* La règle est celle du TRAFIC, pas celle du noyau : on sonde ce que notre
|
|
120
|
+
* liaison va RECEVOIR.
|
|
121
|
+
* - Écouter sur **toutes les interfaces** recouvre la boucle locale : on sonde
|
|
122
|
+
* ses deux adresses (`127.0.0.1` et `::1`). Un wildcard n'est pas une
|
|
123
|
+
* destination, on ne peut pas s'y connecter.
|
|
124
|
+
* - Écouter sur la **boucle locale** (`localhost` résout `::1` ici, `127.0.0.1`
|
|
125
|
+
* ailleurs — et les clients ne choisissent pas) : les deux, là encore.
|
|
126
|
+
* - Écouter sur une **adresse d'interface précise** : elle seule. Une connexion
|
|
127
|
+
* vers elle atteint le tiers qu'il ait lié cette adresse ou le wildcard, donc
|
|
128
|
+
* une cible suffit — et sonder la boucle locale ferait refuser un pod à cause
|
|
129
|
+
* d'un voisin de conteneur qui n'a jamais été en conflit.
|
|
130
|
+
*
|
|
131
|
+
* Fonction PURE : le banc l'éprouve sans ouvrir un socket.
|
|
132
|
+
*
|
|
133
|
+
* @param host - hôte passé à `listen` (`kernel.domain`).
|
|
134
|
+
* @returns les adresses à interroger, dans l'ordre.
|
|
135
|
+
*/
|
|
136
|
+
export declare function conflictProbeTargets(host: string | undefined): readonly string[];
|
|
137
|
+
/**
|
|
138
|
+
* Première adresse où un serveur répond DÉJÀ sur ce port, `null` si la voie est
|
|
139
|
+
* libre.
|
|
140
|
+
*
|
|
141
|
+
* @param port - port candidat.
|
|
142
|
+
* @param host - hôte que `listen` recevra (décide des adresses interrogées).
|
|
143
|
+
* @param probe - sonde de présence (injectée par le banc).
|
|
144
|
+
*/
|
|
145
|
+
export declare function detectPortConflict(port: number, host: string | undefined, probe: PortListeningProbe): Promise<string | null>;
|
|
89
146
|
/**
|
|
90
147
|
* Écoute sur `plan.desired`, ou sur le prochain port libre si `attempts > 0`.
|
|
91
148
|
*
|
|
@@ -99,4 +156,4 @@ export declare function buildBindPlan(which: "http" | "https", servers: ServersP
|
|
|
99
156
|
* (`ENOTFOUND`, `EACCES` sous 1024 — cf `isPortUnavailable`), soit un port
|
|
100
157
|
* indisponible après épuisement des essais (le fallback n'est PAS infini).
|
|
101
158
|
*/
|
|
102
|
-
export declare function bindWithFallback(server: Listenable, host: string, plan: BindPlan): Promise<BindResult>;
|
|
159
|
+
export declare function bindWithFallback(server: Listenable, host: string, plan: BindPlan, probe?: PortListeningProbe): Promise<BindResult>;
|
package/docs/cookies.md
CHANGED
|
@@ -284,8 +284,8 @@ 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:43`). 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:748`), avec notamment `hostPrefix`
|
|
288
|
+
(`config.ts:770`) 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
|
package/docs/observabilite.md
CHANGED
|
@@ -112,7 +112,7 @@ 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:435` pour HTTP, `http-kernel.ts:435` 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
|
|
@@ -420,7 +420,7 @@ instancié **qu'en dev** (fuite d'info + coût en prod).
|
|
|
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
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:459`) |
|
|
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:71`) — 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:1505`) |
|
|
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` |
|