@nodefony/http 10.0.0-beta.2 → 10.0.0-beta.4

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 (139) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  3. package/dist/index.js +19 -11
  4. package/dist/nodefony/command/assetsPublishCommand.js +5 -3
  5. package/dist/nodefony/command/certificatesCommand.js +5 -3
  6. package/dist/nodefony/command/networkCommand.js +5 -3
  7. package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
  8. package/dist/nodefony/config/config.js +45 -6
  9. package/dist/nodefony/config/defineModuleConfig.js +2 -3
  10. package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
  11. package/dist/nodefony/service/HttpAdminApi.js +2 -3
  12. package/dist/nodefony/service/ProfilerAdminApi.js +1 -2
  13. package/dist/nodefony/service/audit-logger.js +22 -16
  14. package/dist/nodefony/service/certificates.js +228 -19
  15. package/dist/nodefony/service/error-renderer.js +30 -13
  16. package/dist/nodefony/service/http-kernel.js +331 -82
  17. package/dist/nodefony/service/pretty-request-logger.js +5 -4
  18. package/dist/nodefony/service/request-logger.js +24 -16
  19. package/dist/nodefony/service/reverse-proxy.js +336 -0
  20. package/dist/nodefony/service/servers/clientError.js +22 -3
  21. package/dist/nodefony/service/servers/server-h2c.js +126 -0
  22. package/dist/nodefony/service/servers/server-http.js +5 -8
  23. package/dist/nodefony/service/servers/server-https.js +12 -24
  24. package/dist/nodefony/service/servers/server-static.js +7 -10
  25. package/dist/nodefony/service/servers/server-websocket-secure.js +18 -18
  26. package/dist/nodefony/service/servers/server-websocket.js +18 -18
  27. package/dist/nodefony/service/servers/serverShutdown.js +78 -18
  28. package/dist/nodefony/service/servers/wsHeartbeat.js +32 -17
  29. package/dist/nodefony/service/sessions/sessions-service.js +34 -20
  30. package/dist/nodefony/service/trace.js +1 -2
  31. package/dist/nodefony/service/upload/upload-service.js +5 -8
  32. package/dist/nodefony/service/x509.js +1 -2
  33. package/dist/nodefony/src/assets/collectAssets.js +1 -2
  34. package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
  35. package/dist/nodefony/src/context/Context.js +51 -32
  36. package/dist/nodefony/src/context/domainMatcher.js +21 -4
  37. package/dist/nodefony/src/context/forwarded.js +3 -4
  38. package/dist/nodefony/src/context/http/HttpContext.js +98 -46
  39. package/dist/nodefony/src/context/http/Request.js +34 -38
  40. package/dist/nodefony/src/context/http/Response.js +39 -7
  41. package/dist/nodefony/src/context/http/SseStream.js +349 -0
  42. package/dist/nodefony/src/context/http/parser.js +36 -10
  43. package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
  44. package/dist/nodefony/src/context/http2/Request.js +4 -8
  45. package/dist/nodefony/src/context/http2/Response.js +22 -9
  46. package/dist/nodefony/src/context/metaData.js +1 -2
  47. package/dist/nodefony/src/context/requestId.js +1 -2
  48. package/dist/nodefony/src/context/responseEnded.js +26 -0
  49. package/dist/nodefony/src/context/transport.js +45 -0
  50. package/dist/nodefony/src/context/trustProxy.js +1 -2
  51. package/dist/nodefony/src/context/websocket/Response.js +42 -6
  52. package/dist/nodefony/src/context/websocket/WebsocketContext.js +94 -20
  53. package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
  54. package/dist/nodefony/src/context/websocket/websocketUrl.js +20 -0
  55. package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
  56. package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
  57. package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
  58. package/dist/nodefony/src/cookies/cookie.js +3 -7
  59. package/dist/nodefony/src/errors/httpError.js +1 -2
  60. package/dist/nodefony/src/errors/thrownMessage.js +1 -2
  61. package/dist/nodefony/src/profiler/FrameProfile.js +1 -2
  62. package/dist/nodefony/src/profiler/Profiler.js +108 -30
  63. package/dist/nodefony/src/profiler/aggregate.js +422 -0
  64. package/dist/nodefony/src/proxy/forward.js +500 -0
  65. package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
  66. package/dist/nodefony/src/proxy/rules.js +188 -0
  67. package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
  68. package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
  69. package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
  70. package/dist/nodefony/src/servers/http2Limits.js +50 -0
  71. package/dist/nodefony/src/servers/kernelServers.js +12 -3
  72. package/dist/nodefony/src/servers/portBinder.js +41 -10
  73. package/dist/nodefony/src/servers/upgradeDispatch.js +128 -0
  74. package/dist/nodefony/src/session/session.js +30 -15
  75. package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
  76. package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
  77. package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
  78. package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
  79. package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
  80. package/dist/types/index.d.ts +12 -3
  81. package/dist/types/nodefony/config/config.d.ts +16 -0
  82. package/dist/types/nodefony/interfaces/IContext.d.ts +65 -4
  83. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
  84. package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
  85. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
  86. package/dist/types/nodefony/interfaces/IRouting.d.ts +26 -0
  87. package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
  88. package/dist/types/nodefony/interfaces/index.d.ts +3 -2
  89. package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
  90. package/dist/types/nodefony/service/certificates.d.ts +147 -5
  91. package/dist/types/nodefony/service/error-renderer.d.ts +32 -1
  92. package/dist/types/nodefony/service/http-kernel.d.ts +157 -15
  93. package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
  94. package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
  95. package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
  96. package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
  97. package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
  98. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +2 -3
  99. package/dist/types/nodefony/service/servers/server-websocket.d.ts +2 -3
  100. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
  101. package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
  102. package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
  103. package/dist/types/nodefony/src/context/Context.d.ts +35 -6
  104. package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
  105. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +48 -1
  106. package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
  107. package/dist/types/nodefony/src/context/http/Response.d.ts +14 -0
  108. package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
  109. package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
  110. package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
  111. package/dist/types/nodefony/src/context/http2/Response.d.ts +15 -5
  112. package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
  113. package/dist/types/nodefony/src/context/transport.d.ts +53 -0
  114. package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
  115. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +57 -7
  116. package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
  117. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
  118. package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +13 -12
  119. package/dist/types/nodefony/src/profiler/Profiler.d.ts +69 -15
  120. package/dist/types/nodefony/src/profiler/aggregate.d.ts +215 -0
  121. package/dist/types/nodefony/src/proxy/forward.d.ts +168 -0
  122. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
  123. package/dist/types/nodefony/src/proxy/rules.d.ts +88 -0
  124. package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
  125. package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
  126. package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
  127. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +69 -0
  128. package/dist/types/nodefony/src/session/session.d.ts +2 -0
  129. package/docs/cookies.md +85 -78
  130. package/docs/index.md +5 -1
  131. package/docs/observabilite.md +81 -81
  132. package/docs/rate-limit.md +83 -83
  133. package/docs/reverse-proxy.md +522 -0
  134. package/docs/servers.md +378 -230
  135. package/docs/session.md +180 -180
  136. package/docs/sse.md +287 -0
  137. package/docs/upload.md +120 -117
  138. package/package.json +10 -11
  139. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -0,0 +1,188 @@
1
+ import { isLoopbackHostname } from "nodefony";
2
+ // nodefony/src/proxy/rules.ts
3
+ /**
4
+ * Règles d'un montage du proxy inverse — UNIQUE source, lue par le schéma de
5
+ * configuration (`proxy.mounts`) ET par `ReverseProxy.mount()` : un montage
6
+ * écrit dans la config et un montage posé par un module obéissent aux mêmes
7
+ * refus, avec les mêmes mots.
8
+ *
9
+ * Pur : n'importe que le cœur, importable depuis `config/config.ts` sans
10
+ * cycle.
11
+ */
12
+ /**
13
+ * Préfixes que le framework se réserve : un montage n'y est JAMAIS accepté.
14
+ * `/nodefony/` porte la console d'administration et les API de Studio — les
15
+ * relayer ailleurs détournerait une surface protégée par le pare-feu.
16
+ */
17
+ const RESERVED_PROXY_PREFIXES = ["/nodefony/"];
18
+ /**
19
+ * Préfixes réservés aux montages POSÉS PAR UN MODULE : la configuration ne
20
+ * peut pas les prendre. `/_vite/` appartient à `@nodefony/frontend`, qui y
21
+ * relaie ses serveurs Vite de développement.
22
+ */
23
+ const MODULE_PROXY_PREFIXES = ["/_vite/"];
24
+ /** Jeton de méthode HTTP (RFC 9110 §9.1 → `token`, §5.6.2). */
25
+ const METHOD_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
26
+ /** Nom de champ d'en-tête (RFC 9110 §5.1 → `token`). */
27
+ const HEADER_TOKEN = METHOD_TOKEN;
28
+ /**
29
+ * Normalise un préfixe d'URL : `/` en tête et en fin, sans `/` doublé. Règle
30
+ * UNIQUE des montages statiques et du proxy inverse — deux écritures du même
31
+ * préfixe doivent désigner la même entrée.
32
+ *
33
+ * @param prefix - préfixe tel qu'écrit (`"svc"`, `"/svc"`, `"/svc/"`)
34
+ * @returns le préfixe normalisé (`"/svc/"`)
35
+ */
36
+ function normalizePrefix(prefix) {
37
+ let p = prefix.trim();
38
+ if (!p.startsWith("/")) p = `/${p}`;
39
+ if (!p.endsWith("/")) p = `${p}/`;
40
+ return p.replace(/\/{2,}/g, "/");
41
+ }
42
+ /**
43
+ * Analyse une origine d'amont. Seule une origine NUE est acceptée : un chemin,
44
+ * une requête, un fragment ou des identifiants dans la cible déplaceraient en
45
+ * silence toutes les URL relayées, ou feraient voyager un secret dans la
46
+ * configuration.
47
+ *
48
+ * @param origin - `scheme://hôte[:port]`
49
+ * @returns l'URL analysée, ou `null` si ce n'est pas une origine http(s) nue
50
+ */
51
+ function parseProxyOrigin(origin) {
52
+ let url;
53
+ try {
54
+ url = new URL(origin);
55
+ } catch {
56
+ return null;
57
+ }
58
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
59
+ if (!/^https?:\/\/[^/?#]+\/?$/i.test(origin.trim())) return null;
60
+ if (url.search !== "" || url.hash !== "") return null;
61
+ if (url.username !== "" || url.password !== "") return null;
62
+ return url;
63
+ }
64
+ /**
65
+ * Examine un montage. Rend TOUS les refus, pas le premier : une configuration
66
+ * se corrige en une passe.
67
+ *
68
+ * @param prefix - préfixe tel qu'écrit
69
+ * @param mount - réglages du montage
70
+ * @param source - `"config"` (déclaré dans `proxy.mounts`) ou `"module"`
71
+ * (posé par un module) — seuls les préfixes réservés aux modules diffèrent
72
+ * @returns la liste des refus, vide si le montage est sain
73
+ */
74
+ function proxyMountProblems(prefix, mount, source) {
75
+ const problems = [];
76
+ const raw = prefix.trim();
77
+ const p = normalizePrefix(raw);
78
+ if (p === "/") problems.push({
79
+ field: "prefix",
80
+ message: "le préfixe « / » relaierait TOUTE l'application, routes et pare-feu compris — monter un préfixe dédié (`/svc/`)"
81
+ });
82
+ else if (/[?#\s\\%]/.test(raw) || p.split("/").some((s) => s === "." || s === "..")) problems.push({
83
+ field: "prefix",
84
+ message: `préfixe « ${raw} » invalide — un chemin littéral, sans « ? », « # », « % », « \\ », espace ni segment « . » / « .. »`
85
+ });
86
+ else if (RESERVED_PROXY_PREFIXES.some((r) => p.startsWith(r))) problems.push({
87
+ field: "prefix",
88
+ message: `le préfixe « ${p} » est réservé au framework (console d'administration)`
89
+ });
90
+ else if (source === "config" && MODULE_PROXY_PREFIXES.some((r) => p.startsWith(r))) problems.push({
91
+ field: "prefix",
92
+ message: `le préfixe « ${p} » est réservé à @nodefony/frontend (serveurs Vite de développement), qui le monte lui-même`
93
+ });
94
+ let target = null;
95
+ if (typeof mount.target === "string") {
96
+ target = parseProxyOrigin(mount.target);
97
+ if (target === null) problems.push({
98
+ field: "target",
99
+ message: `cible « ${mount.target} » invalide — attendu une origine http(s) nue (\`scheme://hôte[:port]\`), sans chemin, requête ni identifiants`
100
+ });
101
+ } else if (typeof mount.target !== "function") problems.push({
102
+ field: "target",
103
+ message: "cible absente — une origine `scheme://hôte[:port]`"
104
+ });
105
+ if (mount.secure === false && target !== null) {
106
+ if (target.protocol !== "https:") problems.push({
107
+ field: "secure",
108
+ message: "`secure: false` ne concerne qu'une cible https: — le retirer"
109
+ });
110
+ else if (!isLoopbackHostname(target.hostname)) problems.push({
111
+ field: "secure",
112
+ message: `\`secure: false\` refusé vers « ${target.hostname} » : sans vérification de certificat, n'importe qui sur le chemin réseau se fait passer pour l'amont. Réservé à la boucle locale — sinon, fournir l'autorité de certification de l'amont au processus (\`http.certificates.extraCaFiles\`, ou NODE_EXTRA_CA_CERTS)`
113
+ });
114
+ }
115
+ if (mount.methods !== void 0) {
116
+ if (mount.methods.length === 0) problems.push({
117
+ field: "methods",
118
+ message: "liste de méthodes vide : rien ne serait relayé — retirer `methods` pour toutes"
119
+ });
120
+ for (const m of mount.methods) if (!METHOD_TOKEN.test(m)) problems.push({
121
+ field: "methods",
122
+ message: `méthode « ${m} » invalide (RFC 9110 §9.1)`
123
+ });
124
+ }
125
+ for (const h of mount.stripHeaders ?? []) if (!HEADER_TOKEN.test(h)) problems.push({
126
+ field: "stripHeaders",
127
+ message: `nom d'en-tête « ${h} » invalide`
128
+ });
129
+ else if (h.toLowerCase() === "host") problems.push({
130
+ field: "stripHeaders",
131
+ message: "« host » ne se retire pas : il est toujours posé — voir `preserveHost`"
132
+ });
133
+ if (mount.timeoutMs !== void 0 && (!Number.isInteger(mount.timeoutMs) || mount.timeoutMs <= 0)) problems.push({
134
+ field: "timeoutMs",
135
+ message: `délai « ${mount.timeoutMs} » invalide — un entier de millisecondes > 0`
136
+ });
137
+ return problems;
138
+ }
139
+ /** Encodages qui changent le SENS d'un chemin selon qui le décode. */
140
+ const AMBIGUOUS_ENCODING = /%(?:2e|2f|5c|00|25)/i;
141
+ /**
142
+ * Le chemin d'une requête relayée est-il ambigu ? Un chemin que le proxy et
143
+ * l'amont pourraient lire différemment — segment `.`/`..`, barre inverse,
144
+ * octet nul, ou leur forme encodée (`%2e`, `%2f`, `%5c`, `%00`, `%25`) — est
145
+ * REFUSÉ (400), jamais normalisé en silence : c'est le défaut de Traefik, et
146
+ * la parade aux contournements de préfixe (`/svc/..%2fadmin`).
147
+ *
148
+ * @param url - cible brute de la requête (chemin + requête éventuelle)
149
+ * @returns `true` si le chemin doit être refusé
150
+ */
151
+ function isAmbiguousPath(url) {
152
+ const q = url.indexOf("?");
153
+ const path = q === -1 ? url : url.slice(0, q);
154
+ if (path.includes("\\") || path.includes("\0")) return true;
155
+ if (AMBIGUOUS_ENCODING.test(path)) return true;
156
+ for (const segment of path.split("/")) if (segment === "." || segment === "..") return true;
157
+ return false;
158
+ }
159
+ /** Clé de handshake : 16 octets en base64 (RFC 6455 §4.1, §4.2.1). */
160
+ const WS_KEY = /^\s*[A-Za-z0-9+/]{22}==\s*$/;
161
+ /** `Upgrade: websocket`, casse et blancs indifférents. */
162
+ const UPGRADE_WEBSOCKET = /^\s*websocket\s*$/i;
163
+ /** Le jeton `upgrade` dans une liste `Connection` (RFC 9110 §7.6.1). */
164
+ const CONNECTION_UPGRADE = /(?:^|,)\s*upgrade\s*(?:,|$)/i;
165
+ /**
166
+ * Un upgrade est-il un handshake WebSocket conforme (RFC 6455 §4.2.1) ? Seul
167
+ * un tel handshake est relayé : un `Upgrade` quelconque (`h2c`…) raccordé
168
+ * octet pour octet ouvrirait un tunnel que rien n'inspecte.
169
+ *
170
+ * Appelé à CHAQUE handshake (proxy inverse et routes de Nodefony) : aucune
171
+ * allocation — des expressions testées, jamais de découpe de chaîne.
172
+ *
173
+ * @param method - méthode de la requête
174
+ * @param headers - en-têtes reçus
175
+ * @returns la raison du refus, ou `null` si le handshake est conforme
176
+ */
177
+ function websocketHandshakeProblem(method, headers) {
178
+ if (method !== "GET") return "méthode autre que GET";
179
+ const upgrade = headers.upgrade;
180
+ if (typeof upgrade !== "string" || !UPGRADE_WEBSOCKET.test(upgrade)) return "Upgrade autre que websocket";
181
+ const connection = headers.connection;
182
+ if (!(typeof connection === "string" ? CONNECTION_UPGRADE.test(connection) : Array.isArray(connection) && connection.some((token) => CONNECTION_UPGRADE.test(token)))) return "Connection sans upgrade";
183
+ if (headers["sec-websocket-version"] !== "13") return "Sec-WebSocket-Version ≠ 13";
184
+ const key = headers["sec-websocket-key"];
185
+ if (typeof key !== "string" || !WS_KEY.test(key)) return "Sec-WebSocket-Key invalide";
186
+ return null;
187
+ }
188
+ export { MODULE_PROXY_PREFIXES, RESERVED_PROXY_PREFIXES, isAmbiguousPath, normalizePrefix, parseProxyOrigin, proxyMountProblems, websocketHandshakeProblem };
@@ -1,5 +1,5 @@
1
1
  import { assertPageQuery } from "nodefony";
2
- //#region nodefony/src/rateLimit/MemoryRateLimitStore.ts
2
+ // nodefony/src/rateLimit/MemoryRateLimitStore.ts
3
3
  /**
4
4
  * Store de rate-limit **en mémoire** — algorithme *fixed window* par clé (IP).
5
5
  *
@@ -142,5 +142,4 @@ var MemoryRateLimitStore = class {
142
142
  }
143
143
  }
144
144
  };
145
- //#endregion
146
145
  export { MemoryRateLimitStore };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/rateLimit/WsConnectionCounter.ts
1
+ // nodefony/src/rateLimit/WsConnectionCounter.ts
2
2
  /**
3
3
  * Compteur de connexions WebSocket CONCURRENTES par IP — backstop opt-in
4
4
  * (F6c, revue 0.6). Distinct du {@link MemoryRateLimitStore} : celui-ci compte un
@@ -60,5 +60,4 @@ var WsConnectionCounter = class {
60
60
  return this.#rejectedTotal;
61
61
  }
62
62
  };
63
- //#endregion
64
63
  export { WsConnectionCounter, WsConnectionCounter as default };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/rateLimit/rateLimitFilters.ts
1
+ // nodefony/src/rateLimit/rateLimitFilters.ts
2
2
  /**
3
3
  * **Le vocabulaire de filtre du registre de rate-limit**, en noms PUBLICS —
4
4
  * celui que la console écrit dans l'URL (`?limited=true`).
@@ -16,5 +16,4 @@
16
16
  const RATE_LIMIT_FILTERS = {
17
17
  /** `true` = seulement les clés au plafond, `false` = seulement les autres. */
18
18
  limited: "boolean" };
19
- //#endregion
20
19
  export { RATE_LIMIT_FILTERS };
@@ -0,0 +1,50 @@
1
+ // nodefony/src/servers/http2Limits.ts
2
+ /**
3
+ * Limites anti-DoS d'un serveur HTTP/2, lues dans la section `http2` du module :
4
+ * `maxSessionMemory` (amplification mémoire) et `maxConcurrentStreams` dans les
5
+ * SETTINGS (CVE-2023-44487, Rapid Reset). Règle UNIQUE des serveurs HTTP/2 — TLS
6
+ * (5152) comme h2c — : une limite oubliée sur l'un d'eux en ferait la porte de
7
+ * l'attaque.
8
+ *
9
+ * Une limite absente garde le défaut de Node (aucune régression).
10
+ *
11
+ * @param limits - `module.options.http2`.
12
+ * @returns les options à fusionner dans celles de `createServer`/`createSecureServer`.
13
+ */
14
+ function http2ServerLimits(limits) {
15
+ const options = {};
16
+ if (limits?.maxSessionMemory) options.maxSessionMemory = limits.maxSessionMemory;
17
+ if (limits?.maxConcurrentStreams) options.settings = { maxConcurrentStreams: limits.maxConcurrentStreams };
18
+ return options;
19
+ }
20
+ /**
21
+ * Borne l'inactivité des sessions d'un serveur HTTP/2 — TLS (5152) comme h2c —
22
+ * et contourne un défaut de Node qui garde chaque session fermée en vie jusqu'à
23
+ * l'échéance de ce délai.
24
+ *
25
+ * Le défaut (Node 24, 26) : `closeSession` (`internal/http2/core`) marque la
26
+ * session détruite PUIS appelle `session.setTimeout(0)` ; or `setStreamTimeout`
27
+ * sort aussitôt sur un objet détruit. Le minuteur n'est jamais désarmé et retient
28
+ * la session (~2,4 Ko) pendant `timeoutMs` : sous un flux de connexions neuves,
29
+ * connexions/s × délai × 2,4 Ko. Signalé à Node : https://github.com/nodejs/node/issues/66885
30
+ *
31
+ * Le contournement : désarmer à la réception du GOAWAY, AVANT la destruction —
32
+ * c'est ainsi que ferment un proxy et un client Node, même par `destroy()`. Rien
33
+ * n'est retiré à la protection : Node n'agit déjà plus à l'échéance sur une
34
+ * session fermée (`sessionOnTimeout` sort sur `closed`). Une coupure TCP sans
35
+ * GOAWAY garde la rétention, bornée au délai.
36
+ *
37
+ * @param server - le serveur HTTP/2.
38
+ * @param timeoutMs - délai d'inactivité ; 0 ou absent le laisse désactivé.
39
+ * @param onTimeout - appelé à l'échéance (événement `timeout` du serveur).
40
+ */
41
+ function setHttp2SessionTimeout(server, timeoutMs, onTimeout) {
42
+ if (!timeoutMs) return;
43
+ server.setTimeout(timeoutMs, onTimeout);
44
+ server.on("session", (session) => {
45
+ session.once("goaway", () => {
46
+ session.setTimeout(0);
47
+ });
48
+ });
49
+ }
50
+ export { http2ServerLimits, setHttp2SessionTimeout };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/servers/kernelServers.ts
1
+ // nodefony/src/servers/kernelServers.ts
2
2
  /**
3
3
  * Rétrécit la section `servers` de la configuration du kernel (lue dans le sac
4
4
  * de config non typé du cœur) en {@link ServersConfig}, sans propager `any`.
@@ -13,6 +13,15 @@ function asServersConfig(servers) {
13
13
  return servers !== null && typeof servers === "object" ? servers : void 0;
14
14
  }
15
15
  /**
16
+ * Le serveur h2c démarre-t-il ? Seul un `enabled: true` EXPLICITE l'allume : la
17
+ * section existe toujours (défauts), un port seul ne vaut pas activation.
18
+ *
19
+ * @param entry - `servers.h2c`.
20
+ */
21
+ function isH2cEnabled(entry) {
22
+ return entry?.enabled === true;
23
+ }
24
+ /**
16
25
  * Port DEMANDÉ pour un serveur (`servers.<type>.port`), ou `undefined` si le
17
26
  * serveur est désactivé, absent, ou sans port numérique.
18
27
  *
@@ -22,8 +31,8 @@ function asServersConfig(servers) {
22
31
  function configuredServerPort(servers, type) {
23
32
  const entry = servers?.[type];
24
33
  if (!entry) return void 0;
34
+ if (type === "h2c" && !isH2cEnabled(servers.h2c)) return void 0;
25
35
  const port = entry.port;
26
36
  return typeof port === "number" ? port : void 0;
27
37
  }
28
- //#endregion
29
- export { asServersConfig, configuredServerPort };
38
+ export { asServersConfig, configuredServerPort, isH2cEnabled };
@@ -1,6 +1,7 @@
1
+ import { isH2cEnabled } from "./kernelServers.js";
1
2
  import { isPortListening, resolvePortPolicy } from "nodefony";
2
3
  import cluster from "node:cluster";
3
- //#region nodefony/src/servers/portBinder.ts
4
+ // nodefony/src/servers/portBinder.ts
4
5
  /**
5
6
  * **portBinder** — écoute sur le port désiré, ou sur le prochain port libre.
6
7
  *
@@ -48,27 +49,58 @@ import cluster from "node:cluster";
48
49
  */
49
50
  /** Nombre de ports essayés après le désiré, en `auto`. */
50
51
  const DEFAULT_PORT_RETRY_ATTEMPTS = 20;
52
+ const BOUND_SERVERS = [
53
+ "http",
54
+ "https",
55
+ "h2c"
56
+ ];
51
57
  /** Port configuré d'un serveur, ou `0` (désactivé / non précisé → choix noyau). */
52
- function configuredPort(entry) {
58
+ function configuredPort(servers, which) {
59
+ if (which === "h2c") {
60
+ const entry = servers?.h2c;
61
+ return isH2cEnabled(entry) ? entry?.port ?? 0 : 0;
62
+ }
63
+ const entry = servers?.[which];
53
64
  if (!entry) return 0;
54
65
  return entry.port ?? 0;
55
66
  }
56
67
  /**
68
+ * Serveur actif qui convoite le MÊME port que h2c, ou `null`.
69
+ *
70
+ * Un port partagé avec h2c n'est pas un conflit à contourner : sur ce port, le
71
+ * client HTTP/1.1 et le proxy h2c se marchent dessus, et le repli `auto` ferait
72
+ * glisser l'un des deux là où personne ne l'attend. Le boot le refuse.
73
+ *
74
+ * @param servers - `kernel.options.servers`.
75
+ * @returns `"http"` ou `"https"` en conflit, sinon `null` (h2c inactif, port 0).
76
+ */
77
+ function h2cPortConflict(servers) {
78
+ const port = configuredPort(servers, "h2c");
79
+ if (port === 0) return null;
80
+ if (configuredPort(servers, "http") === port) return "http";
81
+ if (configuredPort(servers, "https") === port) return "https";
82
+ return null;
83
+ }
84
+ /**
57
85
  * Compose le plan de bind d'un serveur depuis la config du kernel — source UNIQUE
58
- * (les deux serveurs l'appellent ; la politique n'est décidée qu'ici).
86
+ * (les serveurs l'appellent ; la politique n'est décidée qu'ici).
59
87
  *
60
88
  * @param which - le serveur qu'on borne.
61
89
  * @param servers - `kernel.options.servers`.
62
90
  * @param environment - `kernel.environment` (arbitre le défaut de la politique).
63
91
  */
64
92
  function buildBindPlan(which, servers, environment) {
65
- const desired = configuredPort(servers?.[which]);
66
- const other = configuredPort(servers?.[which === "http" ? "https" : "http"]);
67
- const policy = resolvePortPolicy(environment, servers?.portPolicy);
93
+ const desired = configuredPort(servers, which);
94
+ const reserved = [];
95
+ for (const other of BOUND_SERVERS) {
96
+ if (other === which) continue;
97
+ const port = configuredPort(servers, other);
98
+ if (port > 0 && port !== desired) reserved.push(port);
99
+ }
68
100
  return {
69
101
  desired,
70
- reserved: other > 0 && other !== desired ? [other] : [],
71
- attempts: policy === "auto" ? servers?.portRetryAttempts ?? 20 : 0
102
+ reserved,
103
+ attempts: resolvePortPolicy(environment, servers?.portPolicy) === "auto" ? servers?.portRetryAttempts ?? 20 : 0
72
104
  };
73
105
  }
74
106
  /** Adresses qui désignent « toutes les interfaces » — rien ne s'y connecte. */
@@ -224,5 +256,4 @@ async function bindWithFallback(server, host, plan, probe = isPortListening, sha
224
256
  candidate = next;
225
257
  }
226
258
  }
227
- //#endregion
228
- export { DEFAULT_PORT_RETRY_ATTEMPTS, bindWithFallback, buildBindPlan, conflictProbeTargets, detectPortConflict, resolvePortPolicy };
259
+ export { DEFAULT_PORT_RETRY_ATTEMPTS, bindWithFallback, buildBindPlan, conflictProbeTargets, detectPortConflict, h2cPortConflict, resolvePortPolicy };
@@ -0,0 +1,128 @@
1
+ import { websocketHandshakeProblem } from "../proxy/rules.js";
2
+ import { refuseUpgrade } from "../proxy/forward.js";
3
+ import { STATUS_CODES } from "node:http";
4
+ // nodefony/src/servers/upgradeDispatch.ts
5
+ /** Défaut sans noyau : tout upgrade conforme est accepté puis émis en `connection`. */
6
+ function acceptAll(wss) {
7
+ return (req, admission) => {
8
+ const accepted = admission.accept();
9
+ if (accepted instanceof Promise) accepted.then((ws) => {
10
+ if (ws !== null) wss.emit("connection", ws, req);
11
+ });
12
+ else if (accepted !== null) wss.emit("connection", accepted, req);
13
+ };
14
+ }
15
+ /**
16
+ * Le verdict d'UN upgrade. Pendant que le noyau décide, le socket n'a plus
17
+ * d'écouteur `error` (`node:http` retire les siens à l'émission d'`upgrade`,
18
+ * `ws` ne pose les siens qu'à `handleUpgrade`) : une coupure du client
19
+ * (`ECONNRESET`) y deviendrait une erreur non gérée qui abat le process. D'où
20
+ * l'écouteur de la fenêtre d'attente, retiré quand le verdict tombe.
21
+ *
22
+ * Une classe, pas un objet de fermetures : une allocation par handshake (plus
23
+ * l'écouteur), méthodes partagées par le prototype.
24
+ */
25
+ var UpgradeAdmission = class {
26
+ wss;
27
+ req;
28
+ socket;
29
+ head;
30
+ #accepted = void 0;
31
+ #settled = false;
32
+ #onPendingError;
33
+ constructor(wss, req, socket, head) {
34
+ this.wss = wss;
35
+ this.req = req;
36
+ this.socket = socket;
37
+ this.head = head;
38
+ this.#onPendingError = () => socket.destroy();
39
+ socket.on("error", this.#onPendingError);
40
+ }
41
+ #settle() {
42
+ if (this.#settled) return false;
43
+ this.#settled = true;
44
+ this.socket.removeListener("error", this.#onPendingError);
45
+ return true;
46
+ }
47
+ accept() {
48
+ if (this.#accepted !== void 0) return this.#accepted;
49
+ if (!this.#settle()) return this.#accepted = null;
50
+ const { socket } = this;
51
+ if (socket.destroyed || !socket.writable) {
52
+ socket.destroy();
53
+ return this.#accepted = null;
54
+ }
55
+ let ws;
56
+ let late = null;
57
+ const onClose = () => late?.(null);
58
+ this.wss.handleUpgrade(this.req, socket, this.head, (accepted) => {
59
+ if (late === null) {
60
+ ws = accepted;
61
+ return;
62
+ }
63
+ socket.removeListener("close", onClose);
64
+ late(accepted);
65
+ });
66
+ if (ws !== void 0) return this.#accepted = ws;
67
+ socket.once("close", onClose);
68
+ return this.#accepted = new Promise((resolve) => {
69
+ late = resolve;
70
+ });
71
+ }
72
+ refuse(status, headers) {
73
+ if (!this.#settle()) return;
74
+ refuseUpgrade(this.socket, status, STATUS_CODES[status] ?? "Refused", headers);
75
+ }
76
+ };
77
+ /**
78
+ * Pose l'UNIQUE écouteur `upgrade` d'un serveur : le proxy inverse d'abord,
79
+ * puis le serveur WebSocket de Nodefony.
80
+ *
81
+ * Pourquoi un seul écouteur : `ws` attaché par son option `server` prend TOUS
82
+ * les upgrades et répond `400` à ceux qu'il ne reconnaît pas. Un second
83
+ * écouteur, même posé avant, ne l'empêcherait pas de répondre sur un socket
84
+ * déjà relayé. Le serveur `ws` est donc créé en `noServer`, et c'est ici que
85
+ * l'upgrade lui est confié — avec ses propres contrôles intacts (`path`,
86
+ * `verifyClient`, `maxPayload`…), que `handleUpgrade` applique.
87
+ *
88
+ * Entre les deux, `refusal` tranche ce qui doit être refusé AVANT le `101`
89
+ * (RFC 6455 §4.2.2) : un chemin sans route WebSocket reçoit un statut HTTP,
90
+ * jamais une ouverture suivie d'une fermeture. Puis la FORME du handshake est
91
+ * contrôlée (`websocketHandshakeProblem`, la règle du proxy inverse) : un
92
+ * handshake malformé reçoit `400` sans jamais atteindre le noyau. Enfin
93
+ * `admit` décide — pare-feu, garde de l'action — et seul son verdict ouvre.
94
+ *
95
+ * Sans montage, le coût par connexion est une lecture de champ.
96
+ *
97
+ * @param server - serveur HTTP(S) qui reçoit les upgrades
98
+ * @param wss - serveur WebSocket de Nodefony (`noServer: true`)
99
+ * @param relay - proxy inverse, lu à chaque upgrade (il peut naître après)
100
+ * @param refusal - statut HTTP qui refuse l'upgrade, `null` pour l'admettre
101
+ * (cf `HttpKernel.websocketUpgradeRefusal`)
102
+ * @param admit - décide d'un upgrade admis (cf `HttpKernel.onWebsocketUpgrade`) ;
103
+ * par défaut, tout upgrade conforme est accepté et émis en `connection`
104
+ * @returns la fonction qui retire l'écouteur — à appeler à l'arrêt
105
+ */
106
+ function attachUpgradeDispatch(server, wss, relay, refusal, admit = acceptAll(wss)) {
107
+ const onUpgrade = (req, socket, head) => {
108
+ const proxy = relay();
109
+ if (proxy !== null && proxy.mounts !== null) {
110
+ if (proxy.handleUpgrade(req, socket, head)) return;
111
+ }
112
+ const status = refusal(req);
113
+ if (status !== null) {
114
+ refuseUpgrade(socket, status, STATUS_CODES[status] ?? "Refused");
115
+ return;
116
+ }
117
+ if (websocketHandshakeProblem(req.method, req.headers) !== null) {
118
+ refuseUpgrade(socket, 400, STATUS_CODES[400] ?? "Bad Request");
119
+ return;
120
+ }
121
+ admit(req, new UpgradeAdmission(wss, req, socket, head));
122
+ };
123
+ server.on("upgrade", onUpgrade);
124
+ return () => {
125
+ server.removeListener("upgrade", onUpgrade);
126
+ };
127
+ }
128
+ export { attachUpgradeDispatch };
@@ -1,7 +1,7 @@
1
1
  import Cookie from "../cookies/cookie.js";
2
2
  import { extend } from "nodefony";
3
3
  import { randomBytes } from "node:crypto";
4
- //#region nodefony/src/session/session.ts
4
+ // nodefony/src/session/session.ts
5
5
  const defaultSessionOptions = {
6
6
  name: "nodefony",
7
7
  strictMode: true,
@@ -9,6 +9,20 @@ const defaultSessionOptions = {
9
9
  };
10
10
  /** Taille de l'identifiant opaque (octets CSPRNG → base64url, 43 chars). */
11
11
  const SESSION_ID_BYTES = 32;
12
+ /** Un sac de session relu du storage : objet, ni `null` ni tableau. */
13
+ function isBag(v) {
14
+ return typeof v === "object" && v !== null && !Array.isArray(v);
15
+ }
16
+ /**
17
+ * Une date relue du storage : `Date` d'un store mémoire, chaîne ou nombre d'un
18
+ * store sérialisé. Toute autre forme → `undefined` (la valeur courante reste).
19
+ * Convertie UNE fois à la lecture : `created`/`updated` sont alors toujours des
20
+ * `Date`, et les contrôles du chemin chaud lisent `getTime()` sans allouer.
21
+ */
22
+ function toDate(v) {
23
+ if (v instanceof Date) return v;
24
+ return typeof v === "string" || typeof v === "number" ? new Date(v) : void 0;
25
+ }
12
26
  /**
13
27
  * Session serveur Nodefony — état persistant lié à une identité, indexé par un
14
28
  * identifiant **opaque** (porté par le cookie) dans un {@link ISessionStorage}
@@ -157,16 +171,19 @@ var Session = class {
157
171
  * `onSaveSession`.
158
172
  *
159
173
  * @param user - principal authentifié (string) lié au blob ; défaut courant.
174
+ * Fourni, il DEVIENT le principal de la session : une sauvegarde suivante,
175
+ * dans la même requête, le garde sans qu'on le lui redonne.
160
176
  */
161
177
  async save(user) {
178
+ if (user !== void 0) this.user = user;
162
179
  if (this.readOnly) {
163
180
  if (this.mutated) this.log(`READONLY SESSION mutated — write skipped : ${this.name}`, "WARNING");
164
181
  return this;
165
182
  }
166
183
  if (!this.mutated) return this;
167
184
  const stored = await this.storage.write(this.id, this.serialize(user));
168
- this.created = stored.createdAt ?? this.created;
169
- this.updated = stored.updatedAt ?? this.updated;
185
+ this.created = toDate(stored.createdAt) ?? this.created;
186
+ this.updated = toDate(stored.updatedAt) ?? this.updated;
170
187
  this.mutated = false;
171
188
  this.saved = true;
172
189
  if (this.context) await this.context.fireAsync("onSaveSession", this);
@@ -221,7 +238,7 @@ var Session = class {
221
238
  }
222
239
  isValidSession(_data, context) {
223
240
  if (this.options.refererCheck) {
224
- let sameHost = false;
241
+ let sameHost;
225
242
  try {
226
243
  sameHost = this.checkSecureReferer(context);
227
244
  } catch {
@@ -234,15 +251,14 @@ var Session = class {
234
251
  }
235
252
  const now = Date.now();
236
253
  if (this.options.absoluteTimeoutS && this.created) {
237
- if (now - new Date(this.created).getTime() > this.options.absoluteTimeoutS * 1e3) {
254
+ if (!(now - this.created.getTime() <= this.options.absoluteTimeoutS * 1e3)) {
238
255
  this.log(`SESSION EXPIRED (absolute) ==> ${this.name} : ${this.id}`, "WARNING");
239
256
  return false;
240
257
  }
241
258
  }
242
259
  const idleS = this.options.idleTimeoutS;
243
260
  if (idleS && idleS > 0 && this.updated) {
244
- const lastUsed = new Date(this.updated).getTime();
245
- if (lastUsed && lastUsed + idleS * 1e3 < now) {
261
+ if (!(this.updated.getTime() + idleS * 1e3 >= now)) {
246
262
  this.log(`SESSION EXPIRED (idle) ==> ${this.name} : ${this.id}`, "WARNING");
247
263
  return false;
248
264
  }
@@ -270,7 +286,7 @@ var Session = class {
270
286
  if (typeof storage.touch !== "function") return;
271
287
  if (!this.updated) return;
272
288
  const now = Date.now();
273
- const lastUsed = new Date(this.updated).getTime();
289
+ const lastUsed = this.updated.getTime();
274
290
  if (lastUsed && now - lastUsed < idleS * 1e3 / 2) return;
275
291
  await storage.touch(this.id, idleS);
276
292
  this.updated = new Date(now);
@@ -356,12 +372,12 @@ var Session = class {
356
372
  }
357
373
  deSerialize(serialized) {
358
374
  const data = serialized;
359
- if (data.Attributes) for (const k in data.Attributes) this.attributesBag[k] = data.Attributes[k];
360
- if (data.metaBag) for (const k in data.metaBag) this.metaBagStore[k] = data.metaBag[k];
361
- if (data.flashBag) for (const k in data.flashBag) this.flashBag[k] = data.flashBag[k];
362
- this.created = data.createdAt ?? this.created;
363
- this.updated = data.updatedAt ?? this.updated;
364
- if (data.user) this.user = data.user;
375
+ if (isBag(data.Attributes)) for (const k in data.Attributes) this.attributesBag[k] = data.Attributes[k];
376
+ if (isBag(data.metaBag)) for (const k in data.metaBag) this.metaBagStore[k] = data.metaBag[k];
377
+ if (isBag(data.flashBag)) for (const k in data.flashBag) this.flashBag[k] = data.flashBag[k];
378
+ this.created = toDate(data.createdAt) ?? this.created;
379
+ this.updated = toDate(data.updatedAt) ?? this.updated;
380
+ if (typeof data.user === "string" && data.user) this.user = data.user;
365
381
  }
366
382
  /** Réinitialise les trois sacs (attributs, métas, flash) — état vide. */
367
383
  clear() {
@@ -394,5 +410,4 @@ var Session = class {
394
410
  }
395
411
  }
396
412
  };
397
- //#endregion
398
413
  export { Session as default };
@@ -1,5 +1,5 @@
1
1
  import { thrownMessage } from "../errors/thrownMessage.js";
2
- //#region nodefony/src/session/sessionStoreFailure.ts
2
+ // nodefony/src/session/sessionStoreFailure.ts
3
3
  /**
4
4
  * Reconnaît une table de session absente, quel que soit le dialecte.
5
5
  *
@@ -26,5 +26,4 @@ function describeSessionStoreFailure(error) {
26
26
  message: `La session n'a pas pu être persistée — la requête est servie en 500 plutôt que laissée sans réponse. Cause : ${cause}.${looksLikeMissingTable(cause) ? " La table de session est probablement absente : appliquer les migrations (`nodefony orm:migrate`) avant de servir, ou choisir un autre stockage (`http.session.store`)." : " Vérifier que le stockage de session déclaré sous `http.session.store` est joignable ; à défaut, appliquer les migrations (`nodefony orm:migrate`)."}`
27
27
  };
28
28
  }
29
- //#endregion
30
29
  export { describeSessionStoreFailure };
@@ -1,6 +1,6 @@
1
1
  import { SESSION_DEFAULT_ORDER, SESSION_SORTABLE_FIELDS } from "./sessionSort.js";
2
2
  import { assertPageQuery, compareByOrder, pickOrder } from "nodefony";
3
- //#region nodefony/src/session/storage/MemorySessionStorage.ts
3
+ // nodefony/src/session/storage/MemorySessionStorage.ts
4
4
  /**
5
5
  * Store de sessions **en mémoire** (Map process) — implémentation de référence
6
6
  * d'{@link ISessionStorage}, pendant `session` des `Memory*Store` de sécurité.
@@ -181,5 +181,4 @@ var MemorySessionStorage = class {
181
181
  return Promise.resolve(users.size);
182
182
  }
183
183
  };
184
- //#endregion
185
184
  export { MemorySessionStorage as default };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/session/storage/RevocationGuardStorage.ts
1
+ // nodefony/src/session/storage/RevocationGuardStorage.ts
2
2
  /**
3
3
  * Durée de vie (ms) d'une pierre tombale de révocation. Couvre la fenêtre
4
4
  * pendant laquelle une requête « en vol » — qui avait chargé la session AVANT
@@ -148,5 +148,4 @@ var RevocationGuardStorage = class {
148
148
  if (t.size === 0) this.#tombstones = null;
149
149
  }
150
150
  };
151
- //#endregion
152
151
  export { RevocationGuardStorage as default };