@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.
Files changed (155) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +77 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
  6. package/dist/index.js +108 -0
  7. package/dist/nodefony/command/assetsPublishCommand.js +102 -0
  8. package/dist/nodefony/command/certificatesCommand.js +47 -0
  9. package/dist/nodefony/command/networkCommand.js +27 -0
  10. package/dist/nodefony/command/proxyGenerateCommand.js +66 -0
  11. package/dist/nodefony/config/config.js +335 -0
  12. package/dist/nodefony/config/defineModuleConfig.js +93 -0
  13. package/dist/nodefony/interfaces/IContext.js +1 -0
  14. package/dist/nodefony/interfaces/ICookie.js +1 -0
  15. package/dist/nodefony/interfaces/IErrorRenderer.js +1 -0
  16. package/dist/nodefony/interfaces/IHttpConfig.js +1 -0
  17. package/dist/nodefony/interfaces/IHttpKernel.js +1 -0
  18. package/dist/nodefony/interfaces/IRequest.js +1 -0
  19. package/dist/nodefony/interfaces/IRequestLogger.js +1 -0
  20. package/dist/nodefony/interfaces/IResponse.js +1 -0
  21. package/dist/nodefony/interfaces/ISession.js +1 -0
  22. package/dist/nodefony/interfaces/IUpload.js +1 -0
  23. package/dist/nodefony/interfaces/index.js +1 -0
  24. package/dist/nodefony/service/HttpAdminApi.js +376 -0
  25. package/dist/nodefony/service/ProfilerAdminApi.js +73 -0
  26. package/dist/nodefony/service/audit-logger.js +159 -0
  27. package/dist/nodefony/service/certificates.js +545 -0
  28. package/dist/nodefony/service/error-renderer.js +320 -0
  29. package/dist/nodefony/service/http-kernel.js +948 -0
  30. package/dist/nodefony/service/pretty-request-logger.js +72 -0
  31. package/dist/nodefony/service/request-logger.js +54 -0
  32. package/dist/nodefony/service/servers/clientError.js +20 -0
  33. package/dist/nodefony/service/servers/server-http.js +135 -0
  34. package/dist/nodefony/service/servers/server-https.js +204 -0
  35. package/dist/nodefony/service/servers/server-static.js +192 -0
  36. package/dist/nodefony/service/servers/server-websocket-secure.js +104 -0
  37. package/dist/nodefony/service/servers/server-websocket.js +104 -0
  38. package/dist/nodefony/service/servers/serverShutdown.js +31 -0
  39. package/dist/nodefony/service/servers/wsHeartbeat.js +64 -0
  40. package/dist/nodefony/service/sessions/sessions-service.js +580 -0
  41. package/dist/nodefony/service/trace.js +72 -0
  42. package/dist/nodefony/service/upload/upload-service.js +171 -0
  43. package/dist/nodefony/src/assets/collectAssets.js +34 -0
  44. package/dist/nodefony/src/assets/prebuiltUi.js +125 -0
  45. package/dist/nodefony/src/context/Context.js +415 -0
  46. package/dist/nodefony/src/context/domainMatcher.js +88 -0
  47. package/dist/nodefony/src/context/forwarded.js +185 -0
  48. package/dist/nodefony/src/context/http/HttpContext.js +309 -0
  49. package/dist/nodefony/src/context/http/Request.js +543 -0
  50. package/dist/nodefony/src/context/http/Response.js +368 -0
  51. package/dist/nodefony/src/context/http/parser.js +188 -0
  52. package/dist/nodefony/src/context/http/urlFastPath.js +103 -0
  53. package/dist/nodefony/src/context/http2/Request.js +29 -0
  54. package/dist/nodefony/src/context/http2/Response.js +97 -0
  55. package/dist/nodefony/src/context/metaData.js +47 -0
  56. package/dist/nodefony/src/context/requestId.js +41 -0
  57. package/dist/nodefony/src/context/trustProxy.js +167 -0
  58. package/dist/nodefony/src/context/websocket/Response.js +181 -0
  59. package/dist/nodefony/src/context/websocket/WebsocketContext.js +389 -0
  60. package/dist/nodefony/src/context/websocket/wsBackpressure.js +56 -0
  61. package/dist/nodefony/src/context/websocket/wsLogContent.js +68 -0
  62. package/dist/nodefony/src/cookies/cookie.js +258 -0
  63. package/dist/nodefony/src/errors/httpError.js +69 -0
  64. package/dist/nodefony/src/profiler/FrameProfile.js +95 -0
  65. package/dist/nodefony/src/profiler/Profiler.js +139 -0
  66. package/dist/nodefony/src/proxy/generateProxyConfig.js +157 -0
  67. package/dist/nodefony/src/rateLimit/IRateLimitStore.js +1 -0
  68. package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +146 -0
  69. package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +64 -0
  70. package/dist/nodefony/src/rateLimit/rateLimitFilters.js +20 -0
  71. package/dist/nodefony/src/servers/portBinder.js +114 -0
  72. package/dist/nodefony/src/session/session.js +390 -0
  73. package/dist/nodefony/src/session/storage/MemorySessionStorage.js +185 -0
  74. package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +137 -0
  75. package/dist/nodefony/src/session/storage/sessionFilters.js +83 -0
  76. package/dist/nodefony/src/session/storage/sessionSort.js +53 -0
  77. package/dist/types/index.d.ts +83 -0
  78. package/dist/types/nodefony/command/assetsPublishCommand.d.ts +23 -0
  79. package/dist/types/nodefony/command/certificatesCommand.d.ts +17 -0
  80. package/dist/types/nodefony/command/networkCommand.d.ts +8 -0
  81. package/dist/types/nodefony/command/proxyGenerateCommand.d.ts +19 -0
  82. package/dist/types/nodefony/config/config.d.ts +197 -0
  83. package/dist/types/nodefony/config/defineModuleConfig.d.ts +39 -0
  84. package/dist/types/nodefony/interfaces/IContext.d.ts +138 -0
  85. package/dist/types/nodefony/interfaces/ICookie.d.ts +47 -0
  86. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +55 -0
  87. package/dist/types/nodefony/interfaces/IHttpConfig.d.ts +12 -0
  88. package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -0
  89. package/dist/types/nodefony/interfaces/IRequest.d.ts +35 -0
  90. package/dist/types/nodefony/interfaces/IRequestLogger.d.ts +31 -0
  91. package/dist/types/nodefony/interfaces/IResponse.d.ts +39 -0
  92. package/dist/types/nodefony/interfaces/ISession.d.ts +283 -0
  93. package/dist/types/nodefony/interfaces/IUpload.d.ts +66 -0
  94. package/dist/types/nodefony/interfaces/index.d.ts +7 -0
  95. package/dist/types/nodefony/service/HttpAdminApi.d.ts +18 -0
  96. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +23 -0
  97. package/dist/types/nodefony/service/audit-logger.d.ts +143 -0
  98. package/dist/types/nodefony/service/certificates.d.ts +246 -0
  99. package/dist/types/nodefony/service/error-renderer.d.ts +74 -0
  100. package/dist/types/nodefony/service/http-kernel.d.ts +377 -0
  101. package/dist/types/nodefony/service/pretty-request-logger.d.ts +25 -0
  102. package/dist/types/nodefony/service/request-logger.d.ts +18 -0
  103. package/dist/types/nodefony/service/servers/clientError.d.ts +14 -0
  104. package/dist/types/nodefony/service/servers/server-http.d.ts +42 -0
  105. package/dist/types/nodefony/service/servers/server-https.d.ts +41 -0
  106. package/dist/types/nodefony/service/servers/server-static.d.ts +62 -0
  107. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +29 -0
  108. package/dist/types/nodefony/service/servers/server-websocket.d.ts +29 -0
  109. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +27 -0
  110. package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +46 -0
  111. package/dist/types/nodefony/service/sessions/sessions-service.d.ts +218 -0
  112. package/dist/types/nodefony/service/trace.d.ts +39 -0
  113. package/dist/types/nodefony/service/upload/upload-service.d.ts +61 -0
  114. package/dist/types/nodefony/src/assets/collectAssets.d.ts +35 -0
  115. package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +99 -0
  116. package/dist/types/nodefony/src/context/Context.d.ts +195 -0
  117. package/dist/types/nodefony/src/context/domainMatcher.d.ts +67 -0
  118. package/dist/types/nodefony/src/context/forwarded.d.ts +95 -0
  119. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +85 -0
  120. package/dist/types/nodefony/src/context/http/Request.d.ts +203 -0
  121. package/dist/types/nodefony/src/context/http/Response.d.ts +68 -0
  122. package/dist/types/nodefony/src/context/http/parser.d.ts +65 -0
  123. package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +52 -0
  124. package/dist/types/nodefony/src/context/http2/Request.d.ts +14 -0
  125. package/dist/types/nodefony/src/context/http2/Response.d.ts +20 -0
  126. package/dist/types/nodefony/src/context/metaData.d.ts +58 -0
  127. package/dist/types/nodefony/src/context/requestId.d.ts +28 -0
  128. package/dist/types/nodefony/src/context/trustProxy.d.ts +77 -0
  129. package/dist/types/nodefony/src/context/websocket/Response.d.ts +53 -0
  130. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +125 -0
  131. package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +73 -0
  132. package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +37 -0
  133. package/dist/types/nodefony/src/cookies/cookie.d.ts +88 -0
  134. package/dist/types/nodefony/src/errors/httpError.d.ts +15 -0
  135. package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +110 -0
  136. package/dist/types/nodefony/src/profiler/Profiler.d.ts +192 -0
  137. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +76 -0
  138. package/dist/types/nodefony/src/rateLimit/IRateLimitStore.d.ts +98 -0
  139. package/dist/types/nodefony/src/rateLimit/MemoryRateLimitStore.d.ts +40 -0
  140. package/dist/types/nodefony/src/rateLimit/WsConnectionCounter.d.ts +37 -0
  141. package/dist/types/nodefony/src/rateLimit/rateLimitFilters.d.ts +18 -0
  142. package/dist/types/nodefony/src/servers/portBinder.d.ts +102 -0
  143. package/dist/types/nodefony/src/session/session.d.ts +171 -0
  144. package/dist/types/nodefony/src/session/storage/MemorySessionStorage.d.ts +77 -0
  145. package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +81 -0
  146. package/dist/types/nodefony/src/session/storage/sessionFilters.d.ts +102 -0
  147. package/dist/types/nodefony/src/session/storage/sessionSort.d.ts +45 -0
  148. package/docs/cookies.md +365 -0
  149. package/docs/index.md +163 -0
  150. package/docs/observabilite.md +460 -0
  151. package/docs/rate-limit.md +372 -0
  152. package/docs/servers.md +935 -0
  153. package/docs/session.md +768 -0
  154. package/docs/upload.md +460 -0
  155. 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 };