@nodefony/http 10.0.0-alpha.1
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 +544 -0
- package/README.md +77 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +108 -0
- package/dist/nodefony/command/assetsPublishCommand.js +102 -0
- package/dist/nodefony/command/certificatesCommand.js +47 -0
- package/dist/nodefony/command/networkCommand.js +27 -0
- package/dist/nodefony/command/proxyGenerateCommand.js +66 -0
- package/dist/nodefony/config/config.js +335 -0
- package/dist/nodefony/config/defineModuleConfig.js +93 -0
- package/dist/nodefony/interfaces/IContext.js +1 -0
- package/dist/nodefony/interfaces/ICookie.js +1 -0
- package/dist/nodefony/interfaces/IErrorRenderer.js +1 -0
- package/dist/nodefony/interfaces/IHttpConfig.js +1 -0
- package/dist/nodefony/interfaces/IHttpKernel.js +1 -0
- package/dist/nodefony/interfaces/IRequest.js +1 -0
- package/dist/nodefony/interfaces/IRequestLogger.js +1 -0
- package/dist/nodefony/interfaces/IResponse.js +1 -0
- package/dist/nodefony/interfaces/ISession.js +1 -0
- package/dist/nodefony/interfaces/IUpload.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +376 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +73 -0
- package/dist/nodefony/service/audit-logger.js +159 -0
- package/dist/nodefony/service/certificates.js +545 -0
- package/dist/nodefony/service/error-renderer.js +320 -0
- package/dist/nodefony/service/http-kernel.js +948 -0
- package/dist/nodefony/service/pretty-request-logger.js +72 -0
- package/dist/nodefony/service/request-logger.js +54 -0
- package/dist/nodefony/service/servers/clientError.js +20 -0
- package/dist/nodefony/service/servers/server-http.js +135 -0
- package/dist/nodefony/service/servers/server-https.js +204 -0
- package/dist/nodefony/service/servers/server-static.js +192 -0
- package/dist/nodefony/service/servers/server-websocket-secure.js +104 -0
- package/dist/nodefony/service/servers/server-websocket.js +104 -0
- package/dist/nodefony/service/servers/serverShutdown.js +31 -0
- package/dist/nodefony/service/servers/wsHeartbeat.js +64 -0
- package/dist/nodefony/service/sessions/sessions-service.js +580 -0
- package/dist/nodefony/service/trace.js +72 -0
- package/dist/nodefony/service/upload/upload-service.js +171 -0
- package/dist/nodefony/src/assets/collectAssets.js +34 -0
- package/dist/nodefony/src/assets/prebuiltUi.js +125 -0
- package/dist/nodefony/src/context/Context.js +415 -0
- package/dist/nodefony/src/context/domainMatcher.js +88 -0
- package/dist/nodefony/src/context/forwarded.js +185 -0
- package/dist/nodefony/src/context/http/HttpContext.js +309 -0
- package/dist/nodefony/src/context/http/Request.js +543 -0
- package/dist/nodefony/src/context/http/Response.js +368 -0
- package/dist/nodefony/src/context/http/parser.js +188 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +103 -0
- package/dist/nodefony/src/context/http2/Request.js +29 -0
- package/dist/nodefony/src/context/http2/Response.js +97 -0
- package/dist/nodefony/src/context/metaData.js +47 -0
- package/dist/nodefony/src/context/requestId.js +41 -0
- package/dist/nodefony/src/context/trustProxy.js +167 -0
- package/dist/nodefony/src/context/websocket/Response.js +181 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +389 -0
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +56 -0
- package/dist/nodefony/src/context/websocket/wsLogContent.js +68 -0
- package/dist/nodefony/src/cookies/cookie.js +258 -0
- package/dist/nodefony/src/errors/httpError.js +69 -0
- package/dist/nodefony/src/profiler/FrameProfile.js +95 -0
- package/dist/nodefony/src/profiler/Profiler.js +139 -0
- package/dist/nodefony/src/proxy/generateProxyConfig.js +157 -0
- package/dist/nodefony/src/rateLimit/IRateLimitStore.js +1 -0
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +146 -0
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +64 -0
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +20 -0
- package/dist/nodefony/src/servers/portBinder.js +114 -0
- package/dist/nodefony/src/session/session.js +390 -0
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +185 -0
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +137 -0
- package/dist/nodefony/src/session/storage/sessionFilters.js +83 -0
- package/dist/nodefony/src/session/storage/sessionSort.js +53 -0
- package/dist/types/index.d.ts +83 -0
- package/dist/types/nodefony/command/assetsPublishCommand.d.ts +23 -0
- package/dist/types/nodefony/command/certificatesCommand.d.ts +17 -0
- package/dist/types/nodefony/command/networkCommand.d.ts +8 -0
- package/dist/types/nodefony/command/proxyGenerateCommand.d.ts +19 -0
- package/dist/types/nodefony/config/config.d.ts +197 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +138 -0
- package/dist/types/nodefony/interfaces/ICookie.d.ts +47 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +55 -0
- package/dist/types/nodefony/interfaces/IHttpConfig.d.ts +12 -0
- package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -0
- package/dist/types/nodefony/interfaces/IRequest.d.ts +35 -0
- package/dist/types/nodefony/interfaces/IRequestLogger.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IResponse.d.ts +39 -0
- package/dist/types/nodefony/interfaces/ISession.d.ts +283 -0
- package/dist/types/nodefony/interfaces/IUpload.d.ts +66 -0
- package/dist/types/nodefony/interfaces/index.d.ts +7 -0
- package/dist/types/nodefony/service/HttpAdminApi.d.ts +18 -0
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +23 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +143 -0
- package/dist/types/nodefony/service/certificates.d.ts +246 -0
- package/dist/types/nodefony/service/error-renderer.d.ts +74 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +377 -0
- package/dist/types/nodefony/service/pretty-request-logger.d.ts +25 -0
- package/dist/types/nodefony/service/request-logger.d.ts +18 -0
- package/dist/types/nodefony/service/servers/clientError.d.ts +14 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +42 -0
- package/dist/types/nodefony/service/servers/server-https.d.ts +41 -0
- package/dist/types/nodefony/service/servers/server-static.d.ts +62 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +29 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +29 -0
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +27 -0
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +46 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +218 -0
- package/dist/types/nodefony/service/trace.d.ts +39 -0
- package/dist/types/nodefony/service/upload/upload-service.d.ts +61 -0
- package/dist/types/nodefony/src/assets/collectAssets.d.ts +35 -0
- package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +99 -0
- package/dist/types/nodefony/src/context/Context.d.ts +195 -0
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +67 -0
- package/dist/types/nodefony/src/context/forwarded.d.ts +95 -0
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +85 -0
- package/dist/types/nodefony/src/context/http/Request.d.ts +203 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +68 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +65 -0
- package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +52 -0
- package/dist/types/nodefony/src/context/http2/Request.d.ts +14 -0
- package/dist/types/nodefony/src/context/http2/Response.d.ts +20 -0
- package/dist/types/nodefony/src/context/metaData.d.ts +58 -0
- package/dist/types/nodefony/src/context/requestId.d.ts +28 -0
- package/dist/types/nodefony/src/context/trustProxy.d.ts +77 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +125 -0
- package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +73 -0
- package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +37 -0
- package/dist/types/nodefony/src/cookies/cookie.d.ts +88 -0
- package/dist/types/nodefony/src/errors/httpError.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +110 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +192 -0
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +76 -0
- package/dist/types/nodefony/src/rateLimit/IRateLimitStore.d.ts +98 -0
- package/dist/types/nodefony/src/rateLimit/MemoryRateLimitStore.d.ts +40 -0
- package/dist/types/nodefony/src/rateLimit/WsConnectionCounter.d.ts +37 -0
- package/dist/types/nodefony/src/rateLimit/rateLimitFilters.d.ts +18 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +102 -0
- package/dist/types/nodefony/src/session/session.d.ts +171 -0
- package/dist/types/nodefony/src/session/storage/MemorySessionStorage.d.ts +77 -0
- package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +81 -0
- package/dist/types/nodefony/src/session/storage/sessionFilters.d.ts +102 -0
- package/dist/types/nodefony/src/session/storage/sessionSort.d.ts +45 -0
- package/docs/cookies.md +365 -0
- package/docs/index.md +163 -0
- package/docs/observabilite.md +460 -0
- package/docs/rate-limit.md +372 -0
- package/docs/servers.md +935 -0
- package/docs/session.md +768 -0
- package/docs/upload.md +460 -0
- package/package.json +101 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
//#region nodefony/src/proxy/generateProxyConfig.ts
|
|
2
|
+
/** Valeurs par défaut d'un modèle d'introspection (complété par la commande). */
|
|
3
|
+
const defaultIntrospection = {
|
|
4
|
+
domains: [],
|
|
5
|
+
backendHost: "127.0.0.1",
|
|
6
|
+
httpPort: 5151,
|
|
7
|
+
httpsPort: 5152,
|
|
8
|
+
staticRoots: [],
|
|
9
|
+
mounts: [],
|
|
10
|
+
listen: 80,
|
|
11
|
+
reencrypt: false,
|
|
12
|
+
maxBodyBytes: 0,
|
|
13
|
+
keepaliveIntervalMs: 0
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Délai d'inactivité, en secondes, qu'un proxy doit accorder à une connexion
|
|
17
|
+
* portée par le heartbeat WebSocket.
|
|
18
|
+
*
|
|
19
|
+
* Quatre intervalles de battement : il faut trois pings perdus d'affilée pour
|
|
20
|
+
* que le proxy coupe, ce qui laisse passer une pause de collecteur mémoire ou
|
|
21
|
+
* une seconde de charge sans sacrifier des sockets vivantes. Plancher à 300 s
|
|
22
|
+
* pour que les requêtes HTTP lentes ne soient pas coupées par le même réglage ;
|
|
23
|
+
* heartbeat éteint → une heure, parce que plus rien ne garantit du trafic et
|
|
24
|
+
* qu'un silence légitime peut alors durer.
|
|
25
|
+
*/
|
|
26
|
+
function idleTimeoutSeconds(intro) {
|
|
27
|
+
if (intro.keepaliveIntervalMs <= 0) return 3600;
|
|
28
|
+
return Math.max(300, Math.ceil(intro.keepaliveIntervalMs * 4 / 1e3));
|
|
29
|
+
}
|
|
30
|
+
/** `server_name` nginx / hôte de comparaison — IP et `0.0.0.0` exclus. */
|
|
31
|
+
function serverNames(domains) {
|
|
32
|
+
const names = domains.filter((d) => d && d !== "0.0.0.0" && !isIpLiteral(d));
|
|
33
|
+
return names.length > 0 ? names.join(" ") : "_";
|
|
34
|
+
}
|
|
35
|
+
/** IP littérale (IPv4 ou IPv6) — ne va pas en `server_name`. */
|
|
36
|
+
function isIpLiteral(host) {
|
|
37
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":");
|
|
38
|
+
}
|
|
39
|
+
/** En-têtes forwarded nginx (pattern EDGE : on ÉCRASE X-Forwarded-For). */
|
|
40
|
+
const NGINX_FORWARD_HEADERS_TPL = ` # EDGE (face client) : on ÉCRASE X-Forwarded-For avec la SEULE IP vue par
|
|
41
|
+
# nginx → toute valeur forgée par le client est jetée (RFC 7239 §8.1).
|
|
42
|
+
proxy_set_header Host $host;
|
|
43
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
44
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
45
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
46
|
+
proxy_set_header X-Forwarded-Host $host;
|
|
47
|
+
proxy_set_header X-Forwarded-Port $server_port;
|
|
48
|
+
# WebSocket (Nodefony co-héberge HTTP + WS sur le même port).
|
|
49
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
50
|
+
proxy_set_header Connection $connection_upgrade;
|
|
51
|
+
# Inactivité tolérée — dérivée du heartbeat WebSocket du serveur, pas du
|
|
52
|
+
# défaut nginx (60 s) : entre deux messages, une socket vivante ne montre
|
|
53
|
+
# au proxy que les pings du serveur.
|
|
54
|
+
proxy_read_timeout __IDLE__s;
|
|
55
|
+
proxy_send_timeout __IDLE__s;`;
|
|
56
|
+
/** Les en-têtes forwarded nginx, avec le délai d'inactivité effectif. */
|
|
57
|
+
function nginxForwardHeaders(idleSeconds) {
|
|
58
|
+
return NGINX_FORWARD_HEADERS_TPL.replaceAll("__IDLE__", String(idleSeconds));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Génère une configuration nginx complète (reverse-proxy + offload statiques).
|
|
62
|
+
*
|
|
63
|
+
* @param intro - modèle d'introspection Nodefony.
|
|
64
|
+
* @returns le contenu d'un `nginx.conf`.
|
|
65
|
+
*/
|
|
66
|
+
function generateNginxConfig(intro) {
|
|
67
|
+
const scheme = intro.reencrypt ? "https" : "http";
|
|
68
|
+
const backendPort = intro.reencrypt ? intro.httpsPort : intro.httpPort;
|
|
69
|
+
const idleSeconds = idleTimeoutSeconds(intro);
|
|
70
|
+
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
|
+
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(" server {", ` listen ${intro.listen};`, ` server_name ${serverNames(intro.domains)};`);
|
|
74
|
+
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) lines.push("", ` location ${m.prefix} {`, ` alias ${ensureTrailingSlash(m.dir)};`, " access_log off;", " expires 1h;", " }");
|
|
76
|
+
lines.push("", ` location @nodefony {`, ` proxy_pass ${scheme}://nodefony;`, " proxy_http_version 1.1;", nginxForwardHeaders(idleSeconds), " }");
|
|
77
|
+
if (intro.staticRoots.length === 0) lines.push("", " location / {", ` proxy_pass ${scheme}://nodefony;`, " proxy_http_version 1.1;", nginxForwardHeaders(idleSeconds), " }");
|
|
78
|
+
else {
|
|
79
|
+
const roots = intro.staticRoots;
|
|
80
|
+
lines.push("", " # Statiques multi-dossiers (racine app + modules) : chaîne try_files,", " # fallback vers le backend Nodefony si aucun fichier ne matche.", " location / {", ` root ${roots[0]};`, ` try_files $uri ${roots.length > 1 ? "@r1" : "@nodefony"};`, " }");
|
|
81
|
+
for (let i = 1; i < roots.length; i++) {
|
|
82
|
+
const next = i + 1 < roots.length ? `@r${i + 1}` : "@nodefony";
|
|
83
|
+
lines.push(` location @r${i} {`, ` root ${roots[i]};`, ` try_files $uri ${next};`, " }");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
lines.push(" }", "}", "");
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Génère une configuration haproxy (reverse-proxy + Forwarded RFC 7239).
|
|
91
|
+
* haproxy ne sert PAS de fichiers : les statiques sont proxifiés au backend
|
|
92
|
+
* (ou désactivés côté serveur via `statics.enabled: false` + un nginx/CDN).
|
|
93
|
+
*
|
|
94
|
+
* @param intro - modèle d'introspection Nodefony.
|
|
95
|
+
* @returns le contenu d'un `haproxy.cfg`.
|
|
96
|
+
*/
|
|
97
|
+
function generateHaproxyConfig(intro) {
|
|
98
|
+
const backendPort = intro.reencrypt ? intro.httpsPort : intro.httpPort;
|
|
99
|
+
const idleSeconds = idleTimeoutSeconds(intro);
|
|
100
|
+
const serverSsl = intro.reencrypt ? ` ssl ca-file /etc/haproxy/certs/ca.pem verify required verifyhost ${firstDomain(intro)} sni str(${firstDomain(intro)})` : "";
|
|
101
|
+
const staticNote = intro.staticRoots.length > 0 || intro.mounts.length > 0 ? " # NB : haproxy ne sert pas de fichiers — les statiques sont proxifiés au\n # backend. Pour les offloader, utiliser nginx (proxy:generate nginx) +\n # `statics.enabled: false` côté Nodefony.\n" : "";
|
|
102
|
+
return `# Généré par \`nodefony proxy:generate haproxy\` — NE PAS éditer à la main.
|
|
103
|
+
# Reverse-proxy dérivé de l'introspection Nodefony.
|
|
104
|
+
global
|
|
105
|
+
log stdout format raw local0 info
|
|
106
|
+
|
|
107
|
+
defaults
|
|
108
|
+
mode http
|
|
109
|
+
log global
|
|
110
|
+
option httplog
|
|
111
|
+
timeout connect 5s
|
|
112
|
+
timeout client ${idleSeconds}s
|
|
113
|
+
timeout server ${idleSeconds}s
|
|
114
|
+
# Une fois l'échange passé en WebSocket, ce sont ces secondes-là qui comptent :
|
|
115
|
+
# \`timeout server\` ne s'applique plus au tunnel. Sans cette ligne, la valeur
|
|
116
|
+
# implicite coupe des sockets que le heartbeat gardait pourtant vivantes.
|
|
117
|
+
timeout tunnel ${idleSeconds}s
|
|
118
|
+
|
|
119
|
+
frontend fe_nodefony
|
|
120
|
+
bind *:${intro.listen}
|
|
121
|
+
|
|
122
|
+
# SÉCU : effacer le Forwarded entrant (forgé) avant de poser le nôtre (§8.1).
|
|
123
|
+
http-request del-header Forwarded
|
|
124
|
+
option forwardfor
|
|
125
|
+
|
|
126
|
+
# Le \`proto\` annoncé au serveur est le scheme vu par le CLIENT — il se
|
|
127
|
+
# CONSTATE sur la connexion entrante (\`ssl_fc\`), il ne se déduit pas.
|
|
128
|
+
#
|
|
129
|
+
# Il était déduit du re-chiffrement vers le backend, qui est une tout autre
|
|
130
|
+
# question : un frontend en clair re-chiffrant vers le backend annonçait
|
|
131
|
+
# \`proto=https\`, et le serveur traitait alors une requête EN CLAIR comme
|
|
132
|
+
# sécurisée — cookies \`Secure\` posés sur du clair, garde « exiger HTTPS »
|
|
133
|
+
# jamais déclenchée. Le cas inverse (frontend TLS, backend en clair) faisait
|
|
134
|
+
# boucler les redirections vers HTTPS.
|
|
135
|
+
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
|
136
|
+
http-request set-header X-Forwarded-Proto http unless { ssl_fc }
|
|
137
|
+
http-request set-header X-Forwarded-Host %[req.hdr(host)]
|
|
138
|
+
http-request set-header X-Real-IP %[src]
|
|
139
|
+
http-request set-header Forwarded "for=%[src];proto=https;host=%[req.hdr(host)]" if { ssl_fc }
|
|
140
|
+
http-request set-header Forwarded "for=%[src];proto=http;host=%[req.hdr(host)]" unless { ssl_fc }
|
|
141
|
+
|
|
142
|
+
default_backend be_nodefony
|
|
143
|
+
|
|
144
|
+
backend be_nodefony
|
|
145
|
+
${staticNote} server nodefony ${intro.backendHost}:${backendPort} check${serverSsl}
|
|
146
|
+
`;
|
|
147
|
+
}
|
|
148
|
+
/** Premier domaine non-IP (pour verifyhost/sni), défaut `localhost`. */
|
|
149
|
+
function firstDomain(intro) {
|
|
150
|
+
return intro.domains.find((d) => d && d !== "0.0.0.0" && !isIpLiteral(d)) ?? "localhost";
|
|
151
|
+
}
|
|
152
|
+
/** Garantit un `/` final (requis par `alias` nginx). */
|
|
153
|
+
function ensureTrailingSlash(dir) {
|
|
154
|
+
return dir.endsWith("/") ? dir : `${dir}/`;
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
export { defaultIntrospection, generateHaproxyConfig, generateNginxConfig };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { assertPageQuery } from "nodefony";
|
|
2
|
+
//#region nodefony/src/rateLimit/MemoryRateLimitStore.ts
|
|
3
|
+
/**
|
|
4
|
+
* Store de rate-limit **en mémoire** — algorithme *fixed window* par clé (IP).
|
|
5
|
+
*
|
|
6
|
+
* Une entrée par IP `{ count, resetAt }` ; à l'expiration de la fenêtre le
|
|
7
|
+
* compteur repart à zéro (mutation in-place, 0 alloc pour une IP récurrente).
|
|
8
|
+
* `hit()` est O(1) (1 `Map.get` + arithmétique), et la `Map` est allouée en
|
|
9
|
+
* **lazy** au 1ᵉʳ hit → 0 coût mémoire si le rate-limit n'est jamais sollicité.
|
|
10
|
+
*
|
|
11
|
+
* Mémoire **bornée** par `maxTracked` : au cap, on purge d'abord les fenêtres
|
|
12
|
+
* expirées puis on évince en FIFO (ordre d'insertion `Map`). Un {@link gc}
|
|
13
|
+
* planifiable (GcScheduler du core) fait le ménage hors hot-path.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ Limite assumée (fenêtre fixe) : un pic à cheval sur deux fenêtres peut
|
|
16
|
+
* laisser passer jusqu'à `2 × max` sur un court intervalle. Acceptable pour une
|
|
17
|
+
* défense de capacité ; un *sliding window* viendrait en option si nécessaire.
|
|
18
|
+
*/
|
|
19
|
+
var MemoryRateLimitStore = class {
|
|
20
|
+
#entries = null;
|
|
21
|
+
#rejectedTotal = 0;
|
|
22
|
+
#windowMs;
|
|
23
|
+
#max;
|
|
24
|
+
#maxTracked;
|
|
25
|
+
#now;
|
|
26
|
+
/**
|
|
27
|
+
* @param options - fenêtre, plafond, borne mémoire.
|
|
28
|
+
* @param now - horloge injectable (ms) — `Date.now` par défaut, surchargée en test.
|
|
29
|
+
*/
|
|
30
|
+
constructor(options, now = Date.now) {
|
|
31
|
+
this.#windowMs = options.windowMs;
|
|
32
|
+
this.#max = options.max;
|
|
33
|
+
this.#maxTracked = options.maxTracked;
|
|
34
|
+
this.#now = now;
|
|
35
|
+
}
|
|
36
|
+
hit(key) {
|
|
37
|
+
const now = this.#now();
|
|
38
|
+
const entries = this.#entries ??= /* @__PURE__ */ new Map();
|
|
39
|
+
const entry = entries.get(key);
|
|
40
|
+
if (entry === void 0) {
|
|
41
|
+
if (entries.size >= this.#maxTracked) this.#evict(now);
|
|
42
|
+
const resetAt = now + this.#windowMs;
|
|
43
|
+
entries.set(key, {
|
|
44
|
+
count: 1,
|
|
45
|
+
resetAt
|
|
46
|
+
});
|
|
47
|
+
return this.#allow(resetAt, this.#max - 1);
|
|
48
|
+
}
|
|
49
|
+
if (now >= entry.resetAt) {
|
|
50
|
+
entry.count = 1;
|
|
51
|
+
entry.resetAt = now + this.#windowMs;
|
|
52
|
+
return this.#allow(entry.resetAt, this.#max - 1);
|
|
53
|
+
}
|
|
54
|
+
entry.count += 1;
|
|
55
|
+
if (entry.count > this.#max) {
|
|
56
|
+
this.#rejectedTotal += 1;
|
|
57
|
+
return {
|
|
58
|
+
limited: true,
|
|
59
|
+
limit: this.#max,
|
|
60
|
+
remaining: 0,
|
|
61
|
+
resetAtMs: entry.resetAt,
|
|
62
|
+
retryAfterS: Math.max(1, Math.ceil((entry.resetAt - now) / 1e3))
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return this.#allow(entry.resetAt, this.#max - entry.count);
|
|
66
|
+
}
|
|
67
|
+
gc(nowMs = this.#now()) {
|
|
68
|
+
if (this.#entries === null) return 0;
|
|
69
|
+
let purged = 0;
|
|
70
|
+
for (const [key, entry] of this.#entries) if (nowMs >= entry.resetAt) {
|
|
71
|
+
this.#entries.delete(key);
|
|
72
|
+
purged += 1;
|
|
73
|
+
}
|
|
74
|
+
return purged;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* {@inheritDoc IRateLimitStore.listPage}
|
|
78
|
+
*
|
|
79
|
+
* La collection est déjà en RAM et **bornée par `maxTracked`** (c'est la
|
|
80
|
+
* nature de ce store) : le tri porte sur des références, seule la page est
|
|
81
|
+
* matérialisée en objets de sortie. Les fenêtres expirées sont exclues à la
|
|
82
|
+
* lecture — les montrer ferait passer un compteur mort pour du trafic vivant
|
|
83
|
+
* (le `gc` les retire plus tard, hors hot-path).
|
|
84
|
+
*/
|
|
85
|
+
listPage(query) {
|
|
86
|
+
assertPageQuery(query, "offset");
|
|
87
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
88
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
89
|
+
const now = this.#now();
|
|
90
|
+
const prefix = query.q !== void 0 && query.q.length > 0 ? query.q : null;
|
|
91
|
+
const matched = [];
|
|
92
|
+
for (const pair of this.#entries ?? []) {
|
|
93
|
+
const [key, entry] = pair;
|
|
94
|
+
if (now >= entry.resetAt) continue;
|
|
95
|
+
if (prefix !== null && !key.startsWith(prefix)) continue;
|
|
96
|
+
if (query.limited !== void 0 && entry.count > this.#max !== query.limited) continue;
|
|
97
|
+
matched.push(pair);
|
|
98
|
+
}
|
|
99
|
+
matched.sort((a, b) => b[1].count - a[1].count || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
100
|
+
const items = matched.slice(offset, offset + limit).map(([key, entry]) => ({
|
|
101
|
+
key,
|
|
102
|
+
count: entry.count,
|
|
103
|
+
resetAtMs: entry.resetAt,
|
|
104
|
+
limited: entry.count > this.#max
|
|
105
|
+
}));
|
|
106
|
+
return Promise.resolve({
|
|
107
|
+
items,
|
|
108
|
+
total: query.withTotal === false ? void 0 : matched.length,
|
|
109
|
+
limit,
|
|
110
|
+
offset,
|
|
111
|
+
hasNext: offset + items.length < matched.length
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
get trackedCount() {
|
|
115
|
+
return this.#entries?.size ?? 0;
|
|
116
|
+
}
|
|
117
|
+
get rejectedTotal() {
|
|
118
|
+
return this.#rejectedTotal;
|
|
119
|
+
}
|
|
120
|
+
/** Verdict « autorisé » — factorise la forme commune (0 rejet). */
|
|
121
|
+
#allow(resetAtMs, remaining) {
|
|
122
|
+
return {
|
|
123
|
+
limited: false,
|
|
124
|
+
limit: this.#max,
|
|
125
|
+
remaining,
|
|
126
|
+
resetAtMs,
|
|
127
|
+
retryAfterS: 0
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Borne mémoire : supprime d'abord les fenêtres expirées ; si le cap tient
|
|
132
|
+
* toujours, évince en FIFO (ordre d'insertion `Map`) jusqu'à faire de la place.
|
|
133
|
+
*/
|
|
134
|
+
#evict(now) {
|
|
135
|
+
const entries = this.#entries;
|
|
136
|
+
if (entries === null) return;
|
|
137
|
+
for (const [key, entry] of entries) if (now >= entry.resetAt) entries.delete(key);
|
|
138
|
+
while (entries.size >= this.#maxTracked) {
|
|
139
|
+
const oldest = entries.keys().next().value;
|
|
140
|
+
if (oldest === void 0) break;
|
|
141
|
+
entries.delete(oldest);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
//#endregion
|
|
146
|
+
export { MemoryRateLimitStore };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//#region nodefony/src/rateLimit/WsConnectionCounter.ts
|
|
2
|
+
/**
|
|
3
|
+
* Compteur de connexions WebSocket CONCURRENTES par IP — backstop opt-in
|
|
4
|
+
* (F6c, revue 0.6). Distinct du {@link MemoryRateLimitStore} : celui-ci compte un
|
|
5
|
+
* DÉBIT d'ouverture par fenêtre (handshakes/s) ; celui-là un NOMBRE de sockets
|
|
6
|
+
* simultanément ouvertes par IP.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ Portée : PAR PROCESS (1 pod). Un vrai plafond global/IP se fait à l'ingress
|
|
9
|
+
* (nginx `limit_conn`, HAProxy `sc_conn_cur`, annotation k8s) — l'edge voit tout le
|
|
10
|
+
* trafic, rejette avant que l'app paie le fd + le handshake TLS, et couvre TOUS les
|
|
11
|
+
* pods. Ce compteur est une défense en profondeur pour le bare-metal/VPS sans
|
|
12
|
+
* ingress. Cf `wsMaxConnectionsPerIp` (config, opt-in, `null` par défaut).
|
|
13
|
+
*
|
|
14
|
+
* Auto-bornée : la Map ne suit que les IP AYANT des sockets ouvertes (bornée par le
|
|
15
|
+
* nombre de connexions réelles), et se vide au fur et à mesure des fermetures — pas
|
|
16
|
+
* besoin d'un GC ni d'un `maxTracked` (contrairement au store de débit qui retient
|
|
17
|
+
* les IP sur toute la fenêtre). Lazy : Map allouée au 1ᵉʳ acquire.
|
|
18
|
+
*/
|
|
19
|
+
var WsConnectionCounter = class {
|
|
20
|
+
#max;
|
|
21
|
+
#counts = null;
|
|
22
|
+
#rejectedTotal = 0;
|
|
23
|
+
/** @param max - plafond de connexions concurrentes par IP (entier > 0). */
|
|
24
|
+
constructor(max) {
|
|
25
|
+
this.#max = max;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Tente de réserver un créneau pour `ip`. `true` = sous le plafond (compteur
|
|
29
|
+
* incrémenté, appeler {@link release} à la fermeture) ; `false` = plafond atteint
|
|
30
|
+
* (rien n'est incrémenté, la connexion doit être refusée).
|
|
31
|
+
*/
|
|
32
|
+
tryAcquire(ip) {
|
|
33
|
+
const counts = this.#counts ??= /* @__PURE__ */ new Map();
|
|
34
|
+
const cur = counts.get(ip) ?? 0;
|
|
35
|
+
if (cur >= this.#max) {
|
|
36
|
+
this.#rejectedTotal += 1;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
counts.set(ip, cur + 1);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
/** Libère un créneau de `ip` (à la fermeture de la socket). Idempotent-safe. */
|
|
43
|
+
release(ip) {
|
|
44
|
+
if (this.#counts === null) return;
|
|
45
|
+
const cur = this.#counts.get(ip);
|
|
46
|
+
if (cur === void 0) return;
|
|
47
|
+
if (cur <= 1) this.#counts.delete(ip);
|
|
48
|
+
else this.#counts.set(ip, cur - 1);
|
|
49
|
+
}
|
|
50
|
+
/** Plafond configuré (par IP). */
|
|
51
|
+
get max() {
|
|
52
|
+
return this.#max;
|
|
53
|
+
}
|
|
54
|
+
/** Nombre d'IP actuellement suivies (avec ≥ 1 socket ouverte). */
|
|
55
|
+
get trackedIps() {
|
|
56
|
+
return this.#counts?.size ?? 0;
|
|
57
|
+
}
|
|
58
|
+
/** Total de connexions refusées depuis la construction (observabilité). */
|
|
59
|
+
get rejectedTotal() {
|
|
60
|
+
return this.#rejectedTotal;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
export { WsConnectionCounter, WsConnectionCounter as default };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region nodefony/src/rateLimit/rateLimitFilters.ts
|
|
2
|
+
/**
|
|
3
|
+
* **Le vocabulaire de filtre du registre de rate-limit**, en noms PUBLICS —
|
|
4
|
+
* celui que la console écrit dans l'URL (`?limited=true`).
|
|
5
|
+
*
|
|
6
|
+
* `limited` répond à la seule question qu'on pose à ce registre en exploitation :
|
|
7
|
+
* « qui est au plafond en ce moment ? ». Il est booléen STRICT — `?limited=1`
|
|
8
|
+
* est refusé plutôt que lu comme `false`, ce que faisait la comparaison
|
|
9
|
+
* `limitedRaw === "true"` : sur un tableau de bord d'incident, une liste vide
|
|
10
|
+
* obtenue par erreur de syntaxe se lit « aucun client bloqué ».
|
|
11
|
+
*
|
|
12
|
+
* `q` n'y figure pas : c'est une clé du contrat de page, lue par
|
|
13
|
+
* `parsePageQuery`. Le data plane la recopiait à la main — deuxième lecteur du
|
|
14
|
+
* même paramètre, exactement le motif que ce chantier supprime.
|
|
15
|
+
*/
|
|
16
|
+
const RATE_LIMIT_FILTERS = {
|
|
17
|
+
/** `true` = seulement les clés au plafond, `false` = seulement les autres. */
|
|
18
|
+
limited: "boolean" };
|
|
19
|
+
//#endregion
|
|
20
|
+
export { RATE_LIMIT_FILTERS };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
//#region nodefony/src/servers/portBinder.ts
|
|
2
|
+
/** Nombre de ports essayés après le désiré, en `auto`. */
|
|
3
|
+
const DEFAULT_PORT_RETRY_ATTEMPTS = 20;
|
|
4
|
+
/**
|
|
5
|
+
* Politique de port effective.
|
|
6
|
+
*
|
|
7
|
+
* Le défaut dépend de l'environnement, et ce n'est pas de la coquetterie :
|
|
8
|
+
* - **production** → `strict`. Le port y est un contrat (service k8s, ingress,
|
|
9
|
+
* sonde de santé). Un bind silencieux ailleurs donnerait un pod déclaré sain
|
|
10
|
+
* que personne n'atteint : une panne invisible, le pire des deux mondes.
|
|
11
|
+
* - **test** → `strict`. Un port occupé veut dire « un serveur est resté debout » ;
|
|
12
|
+
* le banc doit s'arrêter, pas viser à côté (il taperait le serveur du voisin).
|
|
13
|
+
* - **développement** → `auto`. Ici un port pris n'est qu'une nuisance.
|
|
14
|
+
*
|
|
15
|
+
* @param environment - `kernel.environment` (normalisé `development`/`production`/`test`).
|
|
16
|
+
* @param explicit - `servers.portPolicy` s'il est configuré (il gagne toujours).
|
|
17
|
+
*/
|
|
18
|
+
function resolvePortPolicy(environment, explicit) {
|
|
19
|
+
if (explicit === "auto" || explicit === "strict") return explicit;
|
|
20
|
+
return environment === "development" ? "auto" : "strict";
|
|
21
|
+
}
|
|
22
|
+
/** Port configuré d'un serveur, ou `0` (désactivé / non précisé → choix noyau). */
|
|
23
|
+
function configuredPort(entry) {
|
|
24
|
+
if (!entry) return 0;
|
|
25
|
+
return entry.port ?? 0;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Compose le plan de bind d'un serveur depuis la config du kernel — source UNIQUE
|
|
29
|
+
* (les deux serveurs l'appellent ; la politique n'est décidée qu'ici).
|
|
30
|
+
*
|
|
31
|
+
* @param which - le serveur qu'on borne.
|
|
32
|
+
* @param servers - `kernel.options.servers`.
|
|
33
|
+
* @param environment - `kernel.environment` (arbitre le défaut de la politique).
|
|
34
|
+
*/
|
|
35
|
+
function buildBindPlan(which, servers, environment) {
|
|
36
|
+
const desired = configuredPort(servers?.[which]);
|
|
37
|
+
const other = configuredPort(servers?.[which === "http" ? "https" : "http"]);
|
|
38
|
+
const policy = resolvePortPolicy(environment, servers?.portPolicy);
|
|
39
|
+
return {
|
|
40
|
+
desired,
|
|
41
|
+
reserved: other > 0 && other !== desired ? [other] : [],
|
|
42
|
+
attempts: policy === "auto" ? servers?.portRetryAttempts ?? 20 : 0
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Prochain candidat : incrémente, en sautant ce que les autres serveurs veulent. */
|
|
46
|
+
function nextCandidate(from, reserved) {
|
|
47
|
+
let port = from + 1;
|
|
48
|
+
while (reserved.includes(port)) port += 1;
|
|
49
|
+
return port;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Vrai si ce code d'erreur signifie « ce port-ci n'est pas prenable ».
|
|
53
|
+
*
|
|
54
|
+
* `EADDRINUSE` est le cas de tous les systèmes. Windows en ajoute un second, et
|
|
55
|
+
* il n'est pas anecdotique : Hyper-V, WSL et WinNAT **réservent des plages
|
|
56
|
+
* entières** de ports éphémères (`netsh interface ipv4 show excludedportrange`),
|
|
57
|
+
* qu'un `listen` refuse en **`EACCES`** — pas en `EADDRINUSE`. Une application
|
|
58
|
+
* qui glisse de port en port finit statistiquement dans l'une de ces plages et
|
|
59
|
+
* meurt là où linux et macOS auraient continué. C'est le PRODUIT que l'utilisateur
|
|
60
|
+
* Windows subit, pas une singularité de banc.
|
|
61
|
+
*
|
|
62
|
+
* La borne est le port privilégié : sous 1024, `EACCES` veut dire « pas les
|
|
63
|
+
* droits », et se replier en silence sur 81 quand l'exploitant demande 80 serait
|
|
64
|
+
* une dégradation muette — exactement ce que ce fichier refuse.
|
|
65
|
+
*
|
|
66
|
+
* @param code - code d'erreur rendu par `listen`.
|
|
67
|
+
* @param port - le port qui vient d'être refusé.
|
|
68
|
+
* @returns `true` s'il faut essayer le port suivant plutôt qu'échouer.
|
|
69
|
+
*/
|
|
70
|
+
function isPortUnavailable(code, port) {
|
|
71
|
+
if (code === "EADDRINUSE") return true;
|
|
72
|
+
return code === "EACCES" && port >= 1024;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Écoute sur `plan.desired`, ou sur le prochain port libre si `attempts > 0`.
|
|
76
|
+
*
|
|
77
|
+
* Aucun `error` permanent ne doit être attaché au serveur pendant l'appel : cette
|
|
78
|
+
* fonction pose ses propres écouteurs le temps du bind et les retire toujours (le
|
|
79
|
+
* handler d'erreur durable s'installe APRÈS, sur le serveur qui écoute — sinon il
|
|
80
|
+
* verrait passer les `EADDRINUSE` de repli et croirait à une panne).
|
|
81
|
+
*
|
|
82
|
+
* @returns l'adresse obtenue + le port désiré si un décalage a eu lieu.
|
|
83
|
+
* @throws l'erreur de `listen` : soit un code qui ne dit pas « ce port est pris »
|
|
84
|
+
* (`ENOTFOUND`, `EACCES` sous 1024 — cf `isPortUnavailable`), soit un port
|
|
85
|
+
* indisponible après épuisement des essais (le fallback n'est PAS infini).
|
|
86
|
+
*/
|
|
87
|
+
function bindWithFallback(server, host, plan) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
let candidate = plan.desired;
|
|
90
|
+
let used = 0;
|
|
91
|
+
const attempt = () => {
|
|
92
|
+
const onError = (error) => {
|
|
93
|
+
server.removeListener("listening", onListening);
|
|
94
|
+
if (!isPortUnavailable(error.code, candidate) || plan.desired === 0 || used >= plan.attempts) return reject(error);
|
|
95
|
+
used += 1;
|
|
96
|
+
candidate = nextCandidate(candidate, plan.reserved);
|
|
97
|
+
attempt();
|
|
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);
|
|
109
|
+
};
|
|
110
|
+
attempt();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
export { DEFAULT_PORT_RETRY_ATTEMPTS, bindWithFallback, buildBindPlan, resolvePortPolicy };
|