@nodefony/http 10.0.0-beta.5 → 10.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorateParam.js +1 -1
  4. package/dist/index.js +29 -26
  5. package/dist/nodefony/command/proxyGenerateCommand.js +2 -2
  6. package/dist/nodefony/config/config.js +4 -2
  7. package/dist/nodefony/service/ProfilerAdminApi.js +5 -2
  8. package/dist/nodefony/service/error-renderer.js +6 -4
  9. package/dist/nodefony/service/http-kernel.js +112 -34
  10. package/dist/nodefony/service/pretty-request-logger.js +1 -1
  11. package/dist/nodefony/service/request-logger.js +1 -1
  12. package/dist/nodefony/service/reverse-proxy.js +7 -2
  13. package/dist/nodefony/service/servers/clientError.js +28 -4
  14. package/dist/nodefony/service/servers/server-h2c.js +5 -5
  15. package/dist/nodefony/service/servers/server-http.js +5 -3
  16. package/dist/nodefony/service/servers/server-https.js +12 -5
  17. package/dist/nodefony/service/servers/server-static.js +1 -6
  18. package/dist/nodefony/service/servers/server-websocket-secure.js +6 -10
  19. package/dist/nodefony/service/servers/server-websocket.js +6 -10
  20. package/dist/nodefony/service/servers/serverShutdown.js +63 -4
  21. package/dist/nodefony/service/sessions/sessions-service.js +3 -3
  22. package/dist/nodefony/service/upload/upload-service.js +2 -2
  23. package/dist/nodefony/src/assets/prebuiltUi.js +23 -5
  24. package/dist/nodefony/src/context/http/ByteStream.js +359 -0
  25. package/dist/nodefony/src/context/http/HttpContext.js +55 -26
  26. package/dist/nodefony/src/context/http/Request.js +15 -6
  27. package/dist/nodefony/src/context/http/Response.js +354 -35
  28. package/dist/nodefony/src/context/http/SseStream.js +81 -146
  29. package/dist/nodefony/src/context/http/StreamLifecycle.js +273 -0
  30. package/dist/nodefony/src/context/http/urlFastPath.js +80 -1
  31. package/dist/nodefony/src/context/http2/Request.js +1 -1
  32. package/dist/nodefony/src/context/http2/Response.js +8 -4
  33. package/dist/nodefony/src/context/transport.js +20 -1
  34. package/dist/nodefony/src/context/websocket/Response.js +4 -2
  35. package/dist/nodefony/src/context/websocket/WebsocketContext.js +71 -14
  36. package/dist/nodefony/src/context/websocket/websocketUrl.js +7 -0
  37. package/dist/nodefony/src/context/websocket/wsBackpressure.js +15 -15
  38. package/dist/nodefony/src/context/websocket/wsFrameRate.js +71 -0
  39. package/dist/nodefony/src/context/websocket/wsLogContent.js +55 -17
  40. package/dist/nodefony/src/cookies/cookie.js +54 -13
  41. package/dist/nodefony/src/profiler/Profiler.js +14 -1
  42. package/dist/nodefony/src/proxy/forward.js +109 -9
  43. package/dist/nodefony/src/proxy/generateProxyConfig.js +6 -2
  44. package/dist/nodefony/src/proxy/rules.js +29 -14
  45. package/dist/nodefony/src/servers/upgradeDispatch.js +62 -4
  46. package/dist/types/index.d.ts +6 -0
  47. package/dist/types/nodefony/config/config.d.ts +4 -0
  48. package/dist/types/nodefony/interfaces/ISecurity.d.ts +27 -2
  49. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +2 -1
  50. package/dist/types/nodefony/service/error-renderer.d.ts +5 -4
  51. package/dist/types/nodefony/service/http-kernel.d.ts +15 -2
  52. package/dist/types/nodefony/service/servers/clientError.d.ts +3 -2
  53. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +5 -3
  54. package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +9 -4
  55. package/dist/types/nodefony/src/context/http/ByteStream.d.ts +109 -0
  56. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +45 -3
  57. package/dist/types/nodefony/src/context/http/Request.d.ts +8 -1
  58. package/dist/types/nodefony/src/context/http/Response.d.ts +134 -8
  59. package/dist/types/nodefony/src/context/http/SseStream.d.ts +32 -7
  60. package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +53 -0
  61. package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +40 -0
  62. package/dist/types/nodefony/src/context/http2/Response.d.ts +5 -3
  63. package/dist/types/nodefony/src/context/transport.d.ts +17 -0
  64. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +40 -0
  65. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +4 -0
  66. package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +12 -2
  67. package/dist/types/nodefony/src/context/websocket/wsFrameRate.d.ts +69 -0
  68. package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +16 -8
  69. package/dist/types/nodefony/src/cookies/cookie.d.ts +20 -2
  70. package/dist/types/nodefony/src/profiler/Profiler.d.ts +11 -1
  71. package/dist/types/nodefony/src/proxy/forward.d.ts +30 -2
  72. package/dist/types/nodefony/src/proxy/rules.d.ts +20 -0
  73. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +41 -1
  74. package/docs/byte-stream.md +371 -0
  75. package/docs/cookies.md +13 -3
  76. package/docs/index.md +8 -0
  77. package/docs/reverse-proxy.md +15 -3
  78. package/docs/servers.md +26 -15
  79. package/docs/session.md +3 -3
  80. package/docs/sse.md +27 -20
  81. package/package.json +16 -16
@@ -1,10 +1,29 @@
1
1
  import { extend } from "nodefony";
2
2
  import crypto from "node:crypto";
3
- import { parseCookie } from "cookie";
3
+ import { parseCookie, stringifySetCookie } from "cookie";
4
4
  import MS from "ms";
5
5
  // nodefony/src/cookies/cookie.ts
6
6
  const encode = encodeURIComponent;
7
- const decode = decodeURIComponent;
7
+ /**
8
+ * Décode une valeur de cookie sans jamais lever : une séquence `%` invalide
9
+ * rend la valeur brute (règle du paquet `cookie`). Un `decodeURIComponent`
10
+ * nu levait dans le constructeur du contexte, et la requête restait sans
11
+ * réponse (`Cookie: a=50%25`).
12
+ */
13
+ function decode(value) {
14
+ if (!value.includes("%")) return value;
15
+ try {
16
+ return decodeURIComponent(value);
17
+ } catch {
18
+ return value;
19
+ }
20
+ }
21
+ /**
22
+ * Lecture de l'en-tête `Cookie` SANS décodage : la valeur est décodée une
23
+ * seule fois, par {@link Cookie.setValue}. Décoder aux deux endroits
24
+ * transformait `a%2541` en `aA` au lieu de `a%41`.
25
+ */
26
+ const RAW_VALUES = { decode: (value) => value };
8
27
  const cookieDefaultSettings = {
9
28
  maxAge: 0,
10
29
  path: "/",
@@ -15,9 +34,6 @@ const cookieDefaultSettings = {
15
34
  signed: false,
16
35
  secret: "!nodefony.secret!"
17
36
  };
18
- function parser(strToParse) {
19
- return parseCookie(strToParse);
20
- }
21
37
  function cookiesParser(context) {
22
38
  let cookies = null;
23
39
  let co;
@@ -25,7 +41,7 @@ function cookiesParser(context) {
25
41
  case "http":
26
42
  if (context.request.request.headers.cookie) cookies = context.request.request.headers.cookie;
27
43
  if (cookies) {
28
- const obj = parser(cookies);
44
+ const obj = parseCookie(cookies, RAW_VALUES);
29
45
  for (const cookie in obj) {
30
46
  co = new Cookie(cookie, obj[cookie]);
31
47
  context.addRequestCookie(co);
@@ -36,7 +52,7 @@ function cookiesParser(context) {
36
52
  case "websocket": {
37
53
  const cookieHeader = context.request?.headers.cookie;
38
54
  if (cookieHeader) {
39
- const parsed = parser(cookieHeader);
55
+ const parsed = parseCookie(cookieHeader, RAW_VALUES);
40
56
  for (const name in parsed) {
41
57
  co = new Cookie(name, parsed[name]);
42
58
  context.addRequestCookie(co);
@@ -165,14 +181,39 @@ var Cookie = class Cookie {
165
181
  setPriority(val) {
166
182
  return val;
167
183
  }
184
+ /**
185
+ * Durée de vie restante en SECONDES ENTIÈRES, `undefined` sous une seconde.
186
+ *
187
+ * RFC 6265 §4.1.1 : `max-age-av = "Max-Age=" non-zero-digit *DIGIT`. Un
188
+ * `Max-Age=3599.874` est ignoré par le navigateur, et le cookie devient un
189
+ * cookie de session. Arrondi INFÉRIEUR : le cookie ne survit jamais à son
190
+ * `Expires`.
191
+ */
168
192
  getMaxAge() {
169
- if (this.expires && this.expires instanceof Date) {
170
- const s = (this.expires.getTime() - (/* @__PURE__ */ new Date()).getTime()) / 1e3;
171
- if (s > 0) this.maxAge = s;
172
- else this.maxAge = void 0;
173
- } else this.maxAge = this.originalMaxAge;
193
+ const seconds = this.expires instanceof Date ? (this.expires.getTime() - Date.now()) / 1e3 : this.originalMaxAge;
194
+ const whole = seconds === void 0 ? 0 : Math.floor(seconds);
195
+ this.maxAge = whole >= 1 ? whole : void 0;
174
196
  return this.maxAge;
175
197
  }
198
+ /**
199
+ * Vérifie la grammaire RFC 6265 §4.1.1 du nom, du `Path` et du `Domain`,
200
+ * au moment où le cookie est POSÉ sur la réponse.
201
+ *
202
+ * Sans elle, `path: "/; Domain=evil.example"` injectait un attribut, et un
203
+ * nom porteur de `;` partait tel quel. La grammaire est celle du paquet
204
+ * `cookie` : une seule implémentation.
205
+ *
206
+ * @throws {TypeError} nommant l'attribut hors grammaire.
207
+ */
208
+ assertGrammar() {
209
+ const attributes = {
210
+ name: this.name,
211
+ value: ""
212
+ };
213
+ if (this.path !== void 0) attributes.path = this.path;
214
+ if (this.domain !== void 0) attributes.domain = this.domain;
215
+ stringifySetCookie(attributes);
216
+ }
176
217
  toString() {
177
218
  return `${this.name}=${encode(String(this.value))}`;
178
219
  }
@@ -251,4 +292,4 @@ var Cookie = class Cookie {
251
292
  return obj;
252
293
  }
253
294
  };
254
- export { cookiesParser, Cookie as default, parser };
295
+ export { cookiesParser, Cookie as default };
@@ -31,6 +31,14 @@ var Profiler = class {
31
31
  /** Dernier numéro d'ordre attribué (0 = aucun profil collecté). */
32
32
  _seq = 0;
33
33
  /**
34
+ * Démarrage du profileur, donc du processus (ms epoch, horloge du serveur) :
35
+ * aucune requête servie avant n'est connue. Rendu avec un profil absent, pour
36
+ * que le lecteur dise « le serveur a redémarré » plutôt que « évincé ».
37
+ */
38
+ startedAt = Date.now();
39
+ /** Dernier vidage des anneaux (ms epoch), `null` s'il n'y en a jamais eu. */
40
+ _clearedAt = null;
41
+ /**
34
42
  * @param cap - capacité de l'anneau de l'application.
35
43
  * @param sideCap - capacité de chacun des anneaux de l'outillage et des
36
44
  * assets relayés.
@@ -151,12 +159,17 @@ var Profiler = class {
151
159
  const { app, tooling, asset } = this._lanes;
152
160
  return app.size + tooling.size + asset.size;
153
161
  }
154
- /** Vide les anneaux. */
162
+ /** Vide les anneaux, et retient l'instant du vidage ({@link clearedAt}). */
155
163
  clear() {
156
164
  const { app, tooling, asset } = this._lanes;
157
165
  app.clear();
158
166
  tooling.clear();
159
167
  asset.clear();
168
+ this._clearedAt = Date.now();
169
+ }
170
+ /** Dernier vidage des anneaux (ms epoch, horloge du serveur), `null` si aucun. */
171
+ get clearedAt() {
172
+ return this._clearedAt;
160
173
  }
161
174
  /**
162
175
  * Profils de numéro supérieur à `from`, tous anneaux confondus, du plus
@@ -1,3 +1,4 @@
1
+ import { isUnsupportedTransferCoding } from "./rules.js";
1
2
  import http from "node:http";
2
3
  import https from "node:https";
3
4
  // nodefony/src/proxy/forward.ts
@@ -83,6 +84,71 @@ function viaLoops(via, pseudonym) {
83
84
  for (const hop of hops) if (hop.trim().split(/\s+/)[1] === pseudonym) return true;
84
85
  return false;
85
86
  }
87
+ /** `1*DIGIT` (RFC 9110 §7.6.2) — compilé une fois. */
88
+ const DIGITS = /^[0-9]+$/;
89
+ /**
90
+ * `Max-Forwards` reçu, tel qu'un intermédiaire doit le lire (RFC 9110 §7.6.2) :
91
+ * sur `OPTIONS` et `TRACE` seulement — la même section permet de l'ignorer
92
+ * pour toute autre méthode, et il part alors tel quel.
93
+ *
94
+ * @param method - méthode de la requête
95
+ * @param value - en-tête `Max-Forwards` reçu
96
+ * @returns `null` s'il n'y a rien à faire (autre méthode, en-tête absent) ;
97
+ * `-1` si la valeur n'est pas `1*DIGIT` — elle ne se décrémente pas, l'en-tête
98
+ * n'est donc pas transmis ; sinon la valeur reçue (`0` : le proxy répond
99
+ * lui-même, sans joindre l'amont).
100
+ */
101
+ function receivedMaxForwards(method, value) {
102
+ if (value === void 0 || method !== "OPTIONS" && method !== "TRACE") return null;
103
+ const raw = (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
104
+ return DIGITS.test(raw) ? Number(raw) : -1;
105
+ }
106
+ /**
107
+ * En-têtes qu'une réponse à `TRACE` ne renvoie pas : ils portent des secrets
108
+ * (RFC 9110 §9.3.8 : le destinataire final DEVRAIT exclure les champs
109
+ * susceptibles de contenir des données sensibles).
110
+ */
111
+ const TRACE_SENSITIVE = /^(?:authorization|proxy-authorization|cookie)$|token|secret|session|api-?key|csrf/i;
112
+ /**
113
+ * Corps `message/http` d'une réponse à `TRACE` : la requête reçue, en-têtes
114
+ * sensibles et pseudo-en-têtes HTTP/2 retirés (RFC 9110 §9.3.8).
115
+ */
116
+ function traceEcho(req) {
117
+ let echo = `TRACE ${req.url ?? "/"} HTTP/${req.httpVersion}\r\n`;
118
+ const authority = req.headers[":authority"];
119
+ if (req.headers.host === void 0 && typeof authority === "string") echo += `host: ${authority}\r\n`;
120
+ for (const name of Object.keys(req.headers)) {
121
+ if (name.charCodeAt(0) === 58 || TRACE_SENSITIVE.test(name)) continue;
122
+ echo += `${name}: ${flat(req.headers[name])}\r\n`;
123
+ }
124
+ return `${echo}\r\n`;
125
+ }
126
+ /**
127
+ * Réponse du proxy en DESTINATAIRE FINAL, quand `Max-Forwards` est épuisé
128
+ * (RFC 9110 §7.6.2) : `TRACE` renvoie la requête reçue (§9.3.8), `OPTIONS`
129
+ * rend 200 avec `Allow` si le montage restreint ses méthodes, et
130
+ * `Content-Length: 0` (§9.3.7). L'amont n'est jamais joint.
131
+ */
132
+ function answerAsFinalRecipient(req, out, mount) {
133
+ req.resume();
134
+ if (req.method === "TRACE") {
135
+ const body = traceEcho(req);
136
+ out.writeHead(200, {
137
+ "content-type": "message/http",
138
+ "content-length": Buffer.byteLength(body),
139
+ "cache-control": "no-store"
140
+ });
141
+ out.end(body);
142
+ return;
143
+ }
144
+ const headers = {
145
+ "content-length": 0,
146
+ "cache-control": "no-store"
147
+ };
148
+ if (mount.methods !== void 0) headers.allow = mount.methods.join(", ");
149
+ out.writeHead(200, headers);
150
+ out.end();
151
+ }
86
152
  /** Entrée `Via` de ce saut : `<version> <pseudonyme>` (RFC 9110 §7.6.3). */
87
153
  function viaEntry(httpVersion, pseudonym) {
88
154
  return `${httpVersion.startsWith("2") ? "2" : httpVersion || "1.1"} ${pseudonym}`;
@@ -98,10 +164,7 @@ function viaEntry(httpVersion, pseudonym) {
98
164
  */
99
165
  function bodyFramingOf(req) {
100
166
  const coding = req.headers["transfer-encoding"];
101
- if (coding !== void 0) {
102
- if (coding === "chunked") return "chunked";
103
- return coding.trim().toLowerCase() === "chunked" ? "chunked" : "unsupported";
104
- }
167
+ if (coding !== void 0) return isUnsupportedTransferCoding(coding) ? "unsupported" : "chunked";
105
168
  if (req.headers["content-length"] !== void 0) return "length";
106
169
  const stream = req.stream;
107
170
  if (stream !== void 0 && !stream.endAfterHeaders) return "chunked";
@@ -264,8 +327,9 @@ function armConnectTimeout(upstream, ms, onTimeout) {
264
327
  * Relaie une requête HTTP vers l'amont et renvoie sa réponse au client.
265
328
  *
266
329
  * Le corps est transmis avec un cadrage cohérent ({@link bodyFramingOf}). Un
267
- * message qui porte déjà notre `Via` rend 508 sans contacter l'amont. Un
268
- * client parti coupe l'échange amont ; un amont injoignable rend 502 ; une
330
+ * message qui porte déjà notre `Via` rend 508 sans contacter l'amont. Sur
331
+ * `OPTIONS` et `TRACE`, `Max-Forwards` est décrémenté ; à `0`, le proxy répond
332
+ * lui-même en destinataire final (RFC 9110 §7.6.2). Un client parti coupe l'échange amont ; un amont injoignable rend 502 ; une
269
333
  * connexion non établie à temps ou un amont muet rendent 504 — à condition
270
334
  * que rien n'ait encore été envoyé, sinon la réponse est interrompue (un
271
335
  * statut ne se rattrape pas). Un amont coupé en plein corps interrompt la
@@ -307,10 +371,21 @@ function forwardRequest(req, res, mount, target, from, relay) {
307
371
  fail(501, NOT_IMPLEMENTED);
308
372
  return;
309
373
  }
374
+ const method = req.method ?? "GET";
375
+ const hops = receivedMaxForwards(method, req.headers["max-forwards"]);
376
+ if (hops === 0) {
377
+ answerAsFinalRecipient(req, out, mount);
378
+ return;
379
+ }
310
380
  const options = requestOptions(target, mount, relay);
311
- options.method = req.method ?? "GET";
381
+ options.method = method;
312
382
  options.path = upstreamPath(req.url ?? "/", mount);
313
- options.headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, framing);
383
+ const headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, framing);
384
+ if (hops !== null) {
385
+ if (hops > 0) headers["max-forwards"] = String(Math.min(hops - 1, 10));
386
+ else delete headers["max-forwards"];
387
+ }
388
+ options.headers = headers;
314
389
  if (mount.timeoutMs !== void 0) options.timeout = mount.timeoutMs;
315
390
  const request = requestFn(target)(options);
316
391
  upstream = request;
@@ -414,6 +489,31 @@ function refuseUpgrade(socket, status, reason, headers) {
414
489
  }
415
490
  socket.end(`HTTP/1.1 ${status} ${reason}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
416
491
  }
492
+ /** En-têtes du refus d'une version WebSocket inconnue — figés, partagés. */
493
+ const WS_VERSION_REFUSAL_HEADERS = Object.freeze({
494
+ Upgrade: "websocket",
495
+ "Sec-WebSocket-Version": "13"
496
+ });
497
+ /**
498
+ * Refuse un handshake WebSocket non conforme — la réponse qui convient à la
499
+ * raison rendue par `websocketHandshakeProblem`, seule implémentation pour le
500
+ * serveur WebSocket ET le proxy inverse.
501
+ *
502
+ * Une version inconnue reçoit `426` avec `Sec-WebSocket-Version: 13` : le
503
+ * client apprend vers quelle version se rabattre (RFC 6455 §4.2.2 point 6,
504
+ * §4.4), et le 426 porte l'`Upgrade` qu'exige RFC 9110 §15.5.22. Tout autre
505
+ * défaut de forme reçoit `400`.
506
+ *
507
+ * @param socket - socket du client, encore en attente de sa réponse
508
+ * @param problem - la raison rendue par `websocketHandshakeProblem`
509
+ */
510
+ function refuseMalformedHandshake(socket, problem) {
511
+ if (problem === "Sec-WebSocket-Version ≠ 13") {
512
+ refuseUpgrade(socket, 426, "Upgrade Required", WS_VERSION_REFUSAL_HEADERS);
513
+ return;
514
+ }
515
+ refuseUpgrade(socket, 400, "Bad Request");
516
+ }
417
517
  /** En-têtes d'une réponse d'amont REFUSANT l'upgrade, à ne pas recopier tels quels. */
418
518
  const REFUSAL_SKIP = /* @__PURE__ */ new Set([
419
519
  "transfer-encoding",
@@ -497,4 +597,4 @@ function forwardUpgrade(req, socket, head, mount, target, from, relay) {
497
597
  });
498
598
  upstream.end();
499
599
  }
500
- export { MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, refuseUpgrade, requestHeadersFor, responseHeadersFor, rewriteLocation, upstreamPath, viaLoops };
600
+ export { MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, receivedMaxForwards, refuseMalformedHandshake, refuseUpgrade, requestHeadersFor, responseHeadersFor, rewriteLocation, upstreamPath, viaLoops };
@@ -152,6 +152,12 @@ defaults
152
152
  # \`timeout server\` ne s'applique plus au tunnel. Sans cette ligne, la valeur
153
153
  # implicite coupe des sockets que le heartbeat gardait pourtant vivantes.
154
154
  timeout tunnel ${idleSeconds}s
155
+ # Forwarded (RFC 7239) posé par haproxy lui-même (>= 2.8) : il met entre
156
+ # guillemets ce qui n'est pas un jeton — une IPv6 (\`for="[2001:db8::1]"\`),
157
+ # un \`host\` qui porte un port. Un \`set-header\` écrit à la main laissait
158
+ # \`for=2001:db8::1\`, valeur invalide (§6). \`proto\` suit \`ssl_fc\` : le
159
+ # scheme vu par le CLIENT. Vaut pour tous les backends.
160
+ option forwarded proto host for
155
161
 
156
162
  frontend fe_nodefony
157
163
  bind *:${intro.listen}
@@ -173,8 +179,6 @@ frontend fe_nodefony
173
179
  http-request set-header X-Forwarded-Proto http unless { ssl_fc }
174
180
  http-request set-header X-Forwarded-Host %[req.hdr(host)]
175
181
  http-request set-header X-Real-IP %[src]
176
- http-request set-header Forwarded "for=%[src];proto=https;host=%[req.hdr(host)]" if { ssl_fc }
177
- http-request set-header Forwarded "for=%[src];proto=http;host=%[req.hdr(host)]" unless { ssl_fc }
178
182
 
179
183
  ${h2cPort === null ? " default_backend be_nodefony\n" : " # h2c (servers.h2c) : HTTP/2 en clair vers le backend, plusieurs flux par\n # connexion. WebSocket reste en HTTP/1.1 sur le port HTTP : le port h2c\n # n'annonce pas le CONNECT étendu (RFC 8441).\n use_backend be_nodefony if { hdr(upgrade) -i websocket }\n default_backend be_nodefony_h2c\n"}
180
184
  backend be_nodefony
@@ -1,5 +1,7 @@
1
- import { isLoopbackHostname } from "nodefony";
1
+ import { isLoopbackHostname, parseBareOrigin } from "nodefony";
2
2
  // nodefony/src/proxy/rules.ts
3
+ /** Schémas d’amont d’un relais : un proxy inverse ne parle que HTTP. */
4
+ const PROXY_ORIGIN_PROTOCOLS = ["http:", "https:"];
3
5
  /**
4
6
  * Règles d'un montage du proxy inverse — UNIQUE source, lue par le schéma de
5
7
  * configuration (`proxy.mounts`) ET par `ReverseProxy.mount()` : un montage
@@ -49,17 +51,7 @@ function normalizePrefix(prefix) {
49
51
  * @returns l'URL analysée, ou `null` si ce n'est pas une origine http(s) nue
50
52
  */
51
53
  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;
54
+ return parseBareOrigin(origin, PROXY_ORIGIN_PROTOCOLS);
63
55
  }
64
56
  /**
65
57
  * Examine un montage. Rend TOUS les refus, pas le premier : une configuration
@@ -163,6 +155,29 @@ const UPGRADE_WEBSOCKET = /^\s*websocket\s*$/i;
163
155
  /** Le jeton `upgrade` dans une liste `Connection` (RFC 9110 §7.6.1). */
164
156
  const CONNECTION_UPGRADE = /(?:^|,)\s*upgrade\s*(?:,|$)/i;
165
157
  /**
158
+ * Le corps reçu porte-t-il un codage de transfert que le serveur ne sait pas
159
+ * défaire ? Seul `chunked` est compris : Node le retire, mais laisse en place
160
+ * tout codage qui le précède (`gzip, chunked`) — le corps livré serait encore
161
+ * compressé. RFC 9112 §6.1 : un tel message reçoit 501. Un `chunked` qui n'est
162
+ * pas le DERNIER codage est déjà refusé par Node en 400 (§6.3).
163
+ *
164
+ * Seule règle, pour le serveur d'origine et pour le proxy inverse. Chemin de
165
+ * chaque requête : une lecture d'en-tête, rien d'autre sans `Transfer-Encoding`.
166
+ *
167
+ * @param coding - valeur de l'en-tête `Transfer-Encoding`, absente le plus souvent
168
+ * @returns `true` quand le corps ne peut pas être lu tel qu'annoncé
169
+ */
170
+ function isUnsupportedTransferCoding(coding) {
171
+ if (coding === void 0 || coding === "chunked") return false;
172
+ return coding.trim().toLowerCase() !== "chunked";
173
+ }
174
+ /**
175
+ * Raison rendue par {@link websocketHandshakeProblem} quand le client parle une
176
+ * autre version du protocole : la seule qui appelle une réponse distincte —
177
+ * le client doit apprendre vers quelle version se rabattre (RFC 6455 §4.4).
178
+ */
179
+ const WS_UNSUPPORTED_VERSION = "Sec-WebSocket-Version ≠ 13";
180
+ /**
166
181
  * Un upgrade est-il un handshake WebSocket conforme (RFC 6455 §4.2.1) ? Seul
167
182
  * un tel handshake est relayé : un `Upgrade` quelconque (`h2c`…) raccordé
168
183
  * octet pour octet ouvrirait un tunnel que rien n'inspecte.
@@ -180,9 +195,9 @@ function websocketHandshakeProblem(method, headers) {
180
195
  if (typeof upgrade !== "string" || !UPGRADE_WEBSOCKET.test(upgrade)) return "Upgrade autre que websocket";
181
196
  const connection = headers.connection;
182
197
  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";
198
+ if (headers["sec-websocket-version"] !== "13") return WS_UNSUPPORTED_VERSION;
184
199
  const key = headers["sec-websocket-key"];
185
200
  if (typeof key !== "string" || !WS_KEY.test(key)) return "Sec-WebSocket-Key invalide";
186
201
  return null;
187
202
  }
188
- export { MODULE_PROXY_PREFIXES, RESERVED_PROXY_PREFIXES, isAmbiguousPath, normalizePrefix, parseProxyOrigin, proxyMountProblems, websocketHandshakeProblem };
203
+ export { MODULE_PROXY_PREFIXES, RESERVED_PROXY_PREFIXES, WS_UNSUPPORTED_VERSION, isAmbiguousPath, isUnsupportedTransferCoding, normalizePrefix, parseProxyOrigin, proxyMountProblems, websocketHandshakeProblem };
@@ -1,7 +1,60 @@
1
+ import { refuseMalformedHandshake, refuseUpgrade } from "../proxy/forward.js";
1
2
  import { websocketHandshakeProblem } from "../proxy/rules.js";
2
- import { refuseUpgrade } from "../proxy/forward.js";
3
3
  import { STATUS_CODES } from "node:http";
4
+ import { WebSocketServer } from "ws";
4
5
  // nodefony/src/servers/upgradeDispatch.ts
6
+ /**
7
+ * Sous-protocole choisi par handshake, entre {@link IUpgradeAdmission.selectSubprotocol}
8
+ * et le rappel `handleProtocols` de `ws` — qui ne reçoit que la requête. Une
9
+ * table faible plutôt qu'une variable « en cours » : un `verifyClient`
10
+ * asynchrone sépare les deux instants, et deux handshakes s'y croiseraient.
11
+ * Une entrée n'existe que pour un handshake qui a choisi.
12
+ */
13
+ const chosenSubprotocols = /* @__PURE__ */ new WeakMap();
14
+ /**
15
+ * Fabrique l'option `handleProtocols` du serveur `ws` de Nodefony : le choix
16
+ * retenu par l'admission ({@link IUpgradeAdmission.selectSubprotocol}) s'il y
17
+ * en a un, sinon `fallback` (celle de la configuration), sinon le premier
18
+ * offert — le défaut de `ws` 8, conservé pour tout contrôleur qui ne choisit
19
+ * pas.
20
+ *
21
+ * @param fallback - l'option `handleProtocols` de la configuration, s'il y en a une
22
+ * @returns le rappel à passer à `new WebSocketServer({ handleProtocols })`
23
+ */
24
+ function createSubprotocolHandler(fallback) {
25
+ return (protocols, request) => {
26
+ const chosen = chosenSubprotocols.get(request);
27
+ if (chosen !== void 0) {
28
+ chosenSubprotocols.delete(request);
29
+ return chosen !== false && protocols.has(chosen) ? chosen : false;
30
+ }
31
+ if (fallback !== void 0) return fallback(protocols, request);
32
+ const first = protocols.values().next();
33
+ return first.done === true ? false : first.value;
34
+ };
35
+ }
36
+ /**
37
+ * Construit le serveur `ws` de Nodefony à partir des options de la
38
+ * configuration, en forçant ce que Nodefony gère — seul endroit où ces
39
+ * options sont posées, pour le serveur clair comme pour le sécurisé :
40
+ *
41
+ * - `noServer` : l'upgrade passe d'abord par le proxy inverse, puis par
42
+ * {@link attachUpgradeDispatch} ;
43
+ * - `clientTracking` : requis par `broadcast()` et le heartbeat ;
44
+ * - `handleProtocols` : le sous-protocole CHOISI par le contrôleur
45
+ * ({@link createSubprotocolHandler}), celui de la configuration à défaut.
46
+ *
47
+ * @param options - options `ws` issues de la configuration du serveur WebSocket
48
+ * @returns le serveur, sans écoute propre
49
+ */
50
+ function createNodefonyWebSocketServer(options) {
51
+ return new WebSocketServer({
52
+ ...options,
53
+ noServer: true,
54
+ clientTracking: true,
55
+ handleProtocols: createSubprotocolHandler(options.handleProtocols)
56
+ });
57
+ }
5
58
  /** Défaut sans noyau : tout upgrade conforme est accepté puis émis en `connection`. */
6
59
  function acceptAll(wss) {
7
60
  return (req, admission) => {
@@ -38,6 +91,9 @@ var UpgradeAdmission = class {
38
91
  this.#onPendingError = () => socket.destroy();
39
92
  socket.on("error", this.#onPendingError);
40
93
  }
94
+ selectSubprotocol(protocol) {
95
+ if (!this.#settled) chosenSubprotocols.set(this.req, protocol);
96
+ }
41
97
  #settle() {
42
98
  if (this.#settled) return false;
43
99
  this.#settled = true;
@@ -71,6 +127,7 @@ var UpgradeAdmission = class {
71
127
  }
72
128
  refuse(status, headers) {
73
129
  if (!this.#settle()) return;
130
+ chosenSubprotocols.delete(this.req);
74
131
  refuseUpgrade(this.socket, status, STATUS_CODES[status] ?? "Refused", headers);
75
132
  }
76
133
  };
@@ -114,8 +171,9 @@ function attachUpgradeDispatch(server, wss, relay, refusal, admit = acceptAll(ws
114
171
  refuseUpgrade(socket, status, STATUS_CODES[status] ?? "Refused");
115
172
  return;
116
173
  }
117
- if (websocketHandshakeProblem(req.method, req.headers) !== null) {
118
- refuseUpgrade(socket, 400, STATUS_CODES[400] ?? "Bad Request");
174
+ const problem = websocketHandshakeProblem(req.method, req.headers);
175
+ if (problem !== null) {
176
+ refuseMalformedHandshake(socket, problem);
119
177
  return;
120
178
  }
121
179
  admit(req, new UpgradeAdmission(wss, req, socket, head));
@@ -125,4 +183,4 @@ function attachUpgradeDispatch(server, wss, relay, refusal, admit = acceptAll(ws
125
183
  server.removeListener("upgrade", onUpgrade);
126
184
  };
127
185
  }
128
- export { attachUpgradeDispatch };
186
+ export { attachUpgradeDispatch, createNodefonyWebSocketServer, createSubprotocolHandler };
@@ -68,9 +68,13 @@ declare class Http extends Module<IHttpConfig> {
68
68
  }
69
69
  export default Http;
70
70
  export { defineHttpConfig, httpConfigJsonSchema, } from "./nodefony/config/defineModuleConfig.js";
71
+ export { parseRequestTarget } from "./nodefony/src/context/http/urlFastPath.js";
72
+ export { isContentlessStatus } from "./nodefony/src/context/http/Response.js";
73
+ export type { ISplitTarget } from "./nodefony/src/context/http/urlFastPath.js";
71
74
  export { httpConfigSchema } from "./nodefony/config/config.js";
72
75
  export type { IHttpConfig, IHttpConfigInput, } from "./nodefony/interfaces/IHttpConfig.js";
73
76
  export { decideSend, readBackpressureOptions, type WsBackpressurePolicy, type WsSendDecision, type IWsBackpressureOptions, type IBackpressureSocket, type IBackpressureTarget, } from "./nodefony/src/context/websocket/wsBackpressure.js";
77
+ export { admitFrame, frameRateRejectedTotal, readFrameRateBound, readFrameRateCloseAfter, FRAME_RATE_WINDOW_MS, type IFrameRateWindow, type IWsFrameRateOptions, } from "./nodefony/src/context/websocket/wsFrameRate.js";
74
78
  export { isWebsocketContext } from "./nodefony/src/context/websocket/isWebsocketContext.js";
75
79
  export { REQUEST_TRACE_CHANNEL } from "./nodefony/src/context/requestTrace.js";
76
80
  export type { IRequestTrace, RequestTraceProtocol, } from "./nodefony/interfaces/IRequestTrace.js";
@@ -81,6 +85,8 @@ export type { IRateLimitStore, IRateLimitOptions, RateLimitVerdict, } from "./no
81
85
  export { Context, Session, SessionsService, HttpError, HttpKernel, Response, Http2Response, Response as HttpResponse, wsResponse, wsResponse as WebsocketResponse, Cookie, HttpContext, WebsocketContext, HttpRequest, Http2Request, DefaultErrorRenderer, DefaultRequestLogger, JsonAuditLogger, PrettyRequestLogger, Profiler, };
82
86
  export { SseStream, openSseStream, acceptsEventStream, SSE_HEARTBEAT_MS, } from "./nodefony/src/context/http/SseStream.js";
83
87
  export type { ISseStreamOptions, ISseSendOptions, } from "./nodefony/src/context/http/SseStream.js";
88
+ export { openByteStream } from "./nodefony/src/context/http/ByteStream.js";
89
+ export type { ByteStreamEndReason, ByteStreamSource, IByteStreamOptions, IByteStreamResult, } from "./nodefony/src/context/http/ByteStream.js";
84
90
  export { schemaMismatchOf, type SchemaMismatchConfidence, } from "./nodefony/service/error-renderer.js";
85
91
  export { toValidationFields, type IValidationField, } from "./nodefony/service/error-renderer.js";
86
92
  export type { ProfileEntry, ProfileSummary, ProfilePhase, ProfileQuery, ProfileSecurity, IProfileBatch, IProfilerReader, } from "./nodefony/src/profiler/Profiler.js";
@@ -115,6 +115,8 @@ export declare const httpConfigSchema: z.ZodObject<{
115
115
  close: "close";
116
116
  drop: "drop";
117
117
  }>>;
118
+ maxFramesPerSecond: z.ZodDefault<z.ZodNumber>;
119
+ frameRateCloseAfterRejects: z.ZodDefault<z.ZodNumber>;
118
120
  }, z.core.$loose>>;
119
121
  websocketSecure: z.ZodDefault<z.ZodObject<{
120
122
  keepaliveInterval: z.ZodDefault<z.ZodNumber>;
@@ -139,6 +141,8 @@ export declare const httpConfigSchema: z.ZodObject<{
139
141
  close: "close";
140
142
  drop: "drop";
141
143
  }>>;
144
+ maxFramesPerSecond: z.ZodDefault<z.ZodNumber>;
145
+ frameRateCloseAfterRejects: z.ZodDefault<z.ZodNumber>;
142
146
  }, z.core.$loose>>;
143
147
  statics: z.ZodDefault<z.ZodObject<{
144
148
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -40,12 +40,37 @@ export interface IFirewallGate {
40
40
  enforceCsrf(context: ContextType): void;
41
41
  /** Répond au preflight CORS ; `204` quand la réponse est déjà envoyée. */
42
42
  handleCors(context: ContextType): number | undefined;
43
- /** Pose les en-têtes de sécurité (CSP, HSTS…) de la réponse. */
44
- applySecurityHeaders(context: ContextType): void;
43
+ /**
44
+ * Pose les en-têtes de sécurité applicatifs (CSP, Permissions-Policy…) de la
45
+ * réponse, appelé par la réponse juste avant son `writeHead`.
46
+ *
47
+ * @param contentType - `Content-Type` de la réponse, qui choisit la politique ;
48
+ * `null` = réponse sans contenu ; omis = type inconnu (politique complète).
49
+ * @param servedAsFile - corps servi tel quel (fichier statique, relais) : aucun
50
+ * gabarit ne l'a écrit, il ne peut pas porter de nonce.
51
+ */
52
+ applySecurityHeaders(context: ContextType, contentType?: string | null, servedAsFile?: boolean): void;
45
53
  /**
46
54
  * Le revalidateur de l'identité authentifiée de la requête courante, pour une
47
55
  * connexion longue qui la gardera (flux SSE) : la même règle que les sockets
48
56
  * WebSocket. `null` pour un anonyme — rien à révoquer.
49
57
  */
50
58
  currentRevalidator?(): ((nowMs?: number) => Promise<boolean>) | null;
59
+ /**
60
+ * Prévenu quand le noyau HTTP a recalculé ses en-têtes de transport (édition à
61
+ * chaud de `securityHeaders`) : la politique de contenu suit `frameOptions`,
62
+ * qui pilote aussi `frame-ancestors`.
63
+ */
64
+ onTransportHeadersChanged?(): void;
65
+ /**
66
+ * Déclare les sources qu'un module ajoute à la politique de contenu
67
+ * (`{ "connect-src": ["wss://rt.example.com"] }`). Un second appel du même
68
+ * module REMPLACE sa contribution. Absent quand aucun pare-feu ne pose de CSP.
69
+ *
70
+ * @param moduleName - module qui contribue, clé de sa contribution
71
+ * @param fragment - sources par directive, origines exactes (sans joker)
72
+ */
73
+ registerCspOrigins?(moduleName: string, fragment: Readonly<Record<string, readonly string[]>>): void;
74
+ /** Retire la contribution CSP de `moduleName`. */
75
+ unregisterCspOrigins?(moduleName: string): void;
51
76
  }
@@ -13,7 +13,8 @@ import type { Profiler } from "../src/profiler/Profiler.js";
13
13
  * `?connection=<requestId>` : les seules frames de cette socket WebSocket
14
14
  * - `GET /nodefony/profiler/api/summary` → les profils de l'anneau agrégés par
15
15
  * route (percentiles, phases, formes SQL, N+1, erreurs — `aggregateProfiles`)
16
- * - `GET /nodefony/profiler/api/{id}` → profil complet (phases) d'un requestId
16
+ * - `GET /nodefony/profiler/api/{id}` → profil complet (phases) d'un requestId ;
17
+ * absent → 404 `{ startedAt, clearedAt }` : depuis quand l'anneau sait quelque chose
17
18
  * - `DELETE /nodefony/profiler/api/recent` → vide le ring buffer
18
19
  *
19
20
  * La debug bar (toute page, dev) lit `X-Request-Id` de SON appel AJAX puis
@@ -34,10 +34,11 @@ export declare function toValidationFields(error: Error): IValidationField[] | n
34
34
  /**
35
35
  * Reconnaît un identifiant MAL FORMÉ refusé par la base, et rend la valeur fautive.
36
36
  *
37
- * Deux moteurs le disent : MongoDB par une `CastError` de Mongoose (la clé est
38
- * un ObjectId), PostgreSQL par `22P02` (la clé est un `uuid` ou un entier).
39
- * SQLite et MySQL rangent la clé en texte : ils ne lèvent rien, la lecture rend
40
- * simplement « rien », d'où leur 404 — c'est la cohérence avec eux qui est visée.
37
+ * Trois sources le disent : MongoDB par une `CastError` de Mongoose (la clé est
38
+ * un ObjectId), PostgreSQL par `22P02` (la clé est un `uuid` ou un entier), et
39
+ * le kit de colonnes portable par une `ColumnValueError` de genre `uuid` ou
40
+ * `integer` — il refuse sur les trois moteurs la clé que PostgreSQL refuserait. Une clé rangée en
41
+ * texte NATIF ne lève rien, la lecture rend « rien » : le même 404.
41
42
  *
42
43
  * Même descente dans `cause` que {@link isUniqueViolation} (Drizzle enveloppe).
43
44
  *
@@ -147,11 +147,20 @@ declare class HttpKernel extends Service implements IHttpKernelInterface {
147
147
  * dans `onHttpRequest` (0 alloc/concat par requête).
148
148
  */
149
149
  private computeSecurityHeaderCaches;
150
+ /**
151
+ * En-têtes du transport d'une réponse, figés au démarrage — ce que
152
+ * `HttpResponse` place en tête de sa réserve.
153
+ *
154
+ * @param type - famille du serveur qui a reçu la requête.
155
+ * @returns la liste plate `[nom, valeur, …]`, noms en minuscules.
156
+ */
157
+ transportHeaders(type: ServerType): readonly string[];
150
158
  /**
151
159
  * Ce que ce kernel ÉMET réellement comme en-têtes de sécurité transport.
152
160
  *
153
161
  * Ces trois en-têtes (HSTS, X-Frame-Options, X-Content-Type-Options) sont posés
154
- * ici, à l'entrée brute, pour couvrir aussi les statiques et les pages d'erreur
162
+ * par `@nodefony/http` sur TOUTE réponse (en tête de la réserve de `HttpResponse`),
163
+ * statiques et pages d'erreur comprises
155
164
  * — pas par `@nodefony/security`, dont les clés homonymes sont inertes. Toute
156
165
  * console d'administration doit donc lire CE point, sinon elle affiche une
157
166
  * intention de configuration au lieu de l'état appliqué.
@@ -522,6 +531,10 @@ declare class HttpKernel extends Service implements IHttpKernelInterface {
522
531
  /**
523
532
  * Chemin d'erreur de {@link handleHttp} : libère le scope d'un contexte qui
524
533
  * n'a pas pu se construire, puis rend l'erreur au client (`onError`).
534
+ *
535
+ * Sans contexte, `onError` n'a rien sur quoi écrire : la réponse BRUTE
536
+ * reçoit alors un 500 nu. Sinon HTTP/1.1 coupait la connexion sans un octet
537
+ * et HTTP/2 laissait le flux pendu.
525
538
  */
526
539
  private failHttp;
527
540
  /**
@@ -715,7 +728,7 @@ declare class HttpKernel extends Service implements IHttpKernelInterface {
715
728
  once(event: "close", listener: () => void): unknown;
716
729
  }): boolean;
717
730
  /**
718
- * Re-valide tous les flux SSE et ferme ceux dont l'identité est morte.
731
+ * Re-valide tous les flux en cours et ferme ceux dont l'identité est morte.
719
732
  * Publique pour qu'un banc la déclenche sans attendre le tick.
720
733
  */
721
734
  revalidateStreams(): Promise<void>;