@nodefony/http 10.0.0-beta.4 → 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 (103) 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 -25
  5. package/dist/nodefony/command/proxyGenerateCommand.js +2 -2
  6. package/dist/nodefony/config/config.js +6 -3
  7. package/dist/nodefony/interfaces/IRequestTrace.js +1 -0
  8. package/dist/nodefony/service/ProfilerAdminApi.js +17 -3
  9. package/dist/nodefony/service/audit-logger.js +3 -17
  10. package/dist/nodefony/service/error-renderer.js +6 -4
  11. package/dist/nodefony/service/http-kernel.js +191 -40
  12. package/dist/nodefony/service/pretty-request-logger.js +11 -10
  13. package/dist/nodefony/service/request-logger.js +1 -1
  14. package/dist/nodefony/service/reverse-proxy.js +7 -2
  15. package/dist/nodefony/service/servers/clientError.js +28 -4
  16. package/dist/nodefony/service/servers/server-h2c.js +5 -5
  17. package/dist/nodefony/service/servers/server-http.js +5 -3
  18. package/dist/nodefony/service/servers/server-https.js +12 -5
  19. package/dist/nodefony/service/servers/server-static.js +1 -6
  20. package/dist/nodefony/service/servers/server-websocket-secure.js +6 -10
  21. package/dist/nodefony/service/servers/server-websocket.js +6 -10
  22. package/dist/nodefony/service/servers/serverShutdown.js +63 -4
  23. package/dist/nodefony/service/sessions/sessions-service.js +3 -3
  24. package/dist/nodefony/service/upload/upload-service.js +2 -2
  25. package/dist/nodefony/src/assets/prebuiltUi.js +23 -5
  26. package/dist/nodefony/src/context/Context.js +56 -13
  27. package/dist/nodefony/src/context/http/ByteStream.js +359 -0
  28. package/dist/nodefony/src/context/http/HttpContext.js +75 -25
  29. package/dist/nodefony/src/context/http/Request.js +15 -6
  30. package/dist/nodefony/src/context/http/Response.js +354 -35
  31. package/dist/nodefony/src/context/http/SseStream.js +81 -146
  32. package/dist/nodefony/src/context/http/StreamLifecycle.js +273 -0
  33. package/dist/nodefony/src/context/http/urlFastPath.js +80 -1
  34. package/dist/nodefony/src/context/http2/Request.js +1 -1
  35. package/dist/nodefony/src/context/http2/Response.js +28 -5
  36. package/dist/nodefony/src/context/requestTrace.js +104 -0
  37. package/dist/nodefony/src/context/timingMode.js +21 -0
  38. package/dist/nodefony/src/context/transport.js +20 -1
  39. package/dist/nodefony/src/context/unsentResponse.js +30 -0
  40. package/dist/nodefony/src/context/websocket/Response.js +4 -2
  41. package/dist/nodefony/src/context/websocket/WebsocketContext.js +81 -20
  42. package/dist/nodefony/src/context/websocket/websocketUrl.js +7 -0
  43. package/dist/nodefony/src/context/websocket/wsBackpressure.js +15 -15
  44. package/dist/nodefony/src/context/websocket/wsFrameRate.js +71 -0
  45. package/dist/nodefony/src/context/websocket/wsLogContent.js +55 -17
  46. package/dist/nodefony/src/cookies/cookie.js +54 -13
  47. package/dist/nodefony/src/profiler/FrameProfile.js +22 -0
  48. package/dist/nodefony/src/profiler/Profiler.js +25 -22
  49. package/dist/nodefony/src/profiler/aggregate.js +26 -6
  50. package/dist/nodefony/src/proxy/forward.js +109 -9
  51. package/dist/nodefony/src/proxy/generateProxyConfig.js +6 -2
  52. package/dist/nodefony/src/proxy/rules.js +29 -14
  53. package/dist/nodefony/src/servers/upgradeDispatch.js +62 -4
  54. package/dist/types/index.d.ts +8 -0
  55. package/dist/types/nodefony/config/config.d.ts +8 -0
  56. package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
  57. package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +56 -0
  58. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +8 -0
  59. package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
  60. package/dist/types/nodefony/interfaces/ISecurity.d.ts +27 -2
  61. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  62. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +5 -1
  63. package/dist/types/nodefony/service/audit-logger.d.ts +3 -11
  64. package/dist/types/nodefony/service/error-renderer.d.ts +5 -4
  65. package/dist/types/nodefony/service/http-kernel.d.ts +65 -2
  66. package/dist/types/nodefony/service/servers/clientError.d.ts +3 -2
  67. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +5 -3
  68. package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +9 -4
  69. package/dist/types/nodefony/src/context/Context.d.ts +25 -1
  70. package/dist/types/nodefony/src/context/http/ByteStream.d.ts +109 -0
  71. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +60 -4
  72. package/dist/types/nodefony/src/context/http/Request.d.ts +8 -1
  73. package/dist/types/nodefony/src/context/http/Response.d.ts +134 -8
  74. package/dist/types/nodefony/src/context/http/SseStream.d.ts +32 -7
  75. package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +53 -0
  76. package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +40 -0
  77. package/dist/types/nodefony/src/context/http2/Response.d.ts +17 -3
  78. package/dist/types/nodefony/src/context/requestTrace.d.ts +64 -0
  79. package/dist/types/nodefony/src/context/timingMode.d.ts +23 -0
  80. package/dist/types/nodefony/src/context/transport.d.ts +17 -0
  81. package/dist/types/nodefony/src/context/unsentResponse.d.ts +16 -0
  82. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +41 -1
  83. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +4 -0
  84. package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +12 -2
  85. package/dist/types/nodefony/src/context/websocket/wsFrameRate.d.ts +69 -0
  86. package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +16 -8
  87. package/dist/types/nodefony/src/cookies/cookie.d.ts +20 -2
  88. package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +15 -0
  89. package/dist/types/nodefony/src/profiler/Profiler.d.ts +27 -102
  90. package/dist/types/nodefony/src/profiler/aggregate.d.ts +23 -106
  91. package/dist/types/nodefony/src/proxy/forward.d.ts +30 -2
  92. package/dist/types/nodefony/src/proxy/rules.d.ts +20 -0
  93. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +41 -1
  94. package/docs/byte-stream.md +371 -0
  95. package/docs/cookies.md +13 -3
  96. package/docs/diagnostics-channel.md +303 -0
  97. package/docs/index.md +14 -1
  98. package/docs/observabilite.md +94 -16
  99. package/docs/reverse-proxy.md +33 -19
  100. package/docs/servers.md +26 -15
  101. package/docs/session.md +3 -3
  102. package/docs/sse.md +27 -20
  103. package/package.json +16 -16
@@ -0,0 +1,71 @@
1
+ import { decideBackpressure, nextDropBalance } from "nodefony";
2
+ // nodefony/src/context/websocket/wsFrameRate.ts
3
+ /** Durée d'une fenêtre de comptage (ms) : la borne s'exprime en trames PAR SECONDE. */
4
+ const FRAME_RATE_WINDOW_MS = 1e3;
5
+ let rejectedTotal = 0;
6
+ /**
7
+ * Nombre cumulé de trames entrantes écartées par la borne de débit dans ce processus (unité : une trame reçue).
8
+ *
9
+ * Monotone, `0` tant que la borne est désactivée. Relayé par la sonde du hub
10
+ * temps réel sous le nom figé `frameRateRejectedTotal` (ADR-0021 §S9).
11
+ *
12
+ * @returns le cumul depuis le démarrage du processus
13
+ */
14
+ const frameRateRejectedTotal = () => rejectedTotal;
15
+ /**
16
+ * Lit une borne numérique dans les options du serveur WebSocket d'un contexte.
17
+ *
18
+ * Sans allocation ni conversion de type : un serveur sans `options` (HTTP) ou
19
+ * une clé absente rendent `0`.
20
+ */
21
+ const readOption = (server, key) => {
22
+ if (typeof server !== "object" || server === null) return 0;
23
+ if (!("options" in server)) return 0;
24
+ const opts = server.options;
25
+ if (typeof opts !== "object" || opts === null || !(key in opts)) return 0;
26
+ const value = Reflect.get(opts, key);
27
+ return typeof value === "number" ? value : 0;
28
+ };
29
+ /**
30
+ * Borne de débit entrante du serveur WebSocket (`maxFramesPerSecond`) ; `0` = désactivée.
31
+ *
32
+ * Appelée à chaque trame : une lecture de propriété, aucune allocation.
33
+ *
34
+ * @param server - le serveur du contexte (`context.server`)
35
+ * @returns trames admises par seconde, `0` si la borne est désactivée
36
+ */
37
+ const readFrameRateBound = (server) => readOption(server, "maxFramesPerSecond");
38
+ /**
39
+ * Solde de refus qui ferme la connexion (`frameRateCloseAfterRejects`) ; `0` = jamais.
40
+ *
41
+ * @param server - le serveur du contexte (`context.server`)
42
+ * @returns le solde de fermeture
43
+ */
44
+ const readFrameRateCloseAfter = (server) => readOption(server, "frameRateCloseAfterRejects");
45
+ /**
46
+ * Décide du sort d'une trame reçue, AVANT tout décodage : l'admettre, l'écarter, ou fermer la connexion.
47
+ *
48
+ * Compte des TRAMES, pas des octets (`maxPayload` borne déjà la taille). La
49
+ * décision est la règle unique du cœur (`decideBackpressure`), appliquée au
50
+ * nombre de trames de la fenêtre : écarter au-delà de la borne, fermer quand le
51
+ * solde de refus atteint `closeAfter` — jamais au premier dépassement. Une
52
+ * trame écartée est comptée dans {@link frameRateRejectedTotal}.
53
+ *
54
+ * @param win - la fenêtre de la connexion (mutée)
55
+ * @param now - l'instant de réception (ms)
56
+ * @param bound - trames admises par seconde (`> 0`)
57
+ * @param closeAfter - solde de refus qui ferme (`0` = jamais)
58
+ * @returns `"send"` (admettre), `"drop"` (écarter) ou `"close"` (fermer en `1008`)
59
+ */
60
+ const admitFrame = (win, now, bound, closeAfter) => {
61
+ if (now - win.start >= 1e3) {
62
+ win.start = now;
63
+ win.count = 0;
64
+ }
65
+ win.count += 1;
66
+ const verdict = decideBackpressure(win.count, bound, "drop", closeAfter, win.balance);
67
+ win.balance = nextDropBalance(verdict, win.balance);
68
+ if (verdict !== "send") rejectedTotal += 1;
69
+ return verdict;
70
+ };
71
+ export { FRAME_RATE_WINDOW_MS, admitFrame, frameRateRejectedTotal, readFrameRateBound, readFrameRateCloseAfter };
@@ -1,15 +1,18 @@
1
+ import { binaryPayloadLabel } from "nodefony";
1
2
  // nodefony/src/context/websocket/wsLogContent.ts
2
3
  /**
3
- * Formatage **pur** (0 état, 0 dépendance) du CONTENU d'un message WebSocket pour
4
- * le log de trace (Suivi de requête Studio). Séparé de `WebsocketContext` pour
5
- * être testable aux limites sans serveur.
4
+ * Formatage **pur** (0 état) du CONTENU d'un message WebSocket pour le log de trace
5
+ * (Suivi de requête Studio). Séparé de `WebsocketContext` pour être testable aux
6
+ * limites sans serveur.
6
7
  *
7
8
  * 🔒 Robustesse binaire (cf doc `ws` — `socket.send(data)` accepte
8
9
  * `String | Number | Object | Buffer | ArrayBuffer | TypedArray | DataView |
9
10
  * Buffer[] | Blob`, et l'event `message` livre `Buffer | ArrayBuffer | Buffer[]`).
10
- * **Toute** charge binaire est résumée `[binary N B]` — JAMAIS sérialisée
11
- * (`JSON.stringify(new Uint8Array(...))` produirait `{"0":..,"1":..}`, énorme et
12
- * faux). Seules string et objets « JSON » sont rendus en texte (borné).
11
+ * **Toute** charge binaire est résumée `[binary N B]` — JAMAIS vidée octet par
12
+ * octet (`JSON.stringify(new Uint8Array(...))` produirait `{"0":..,"1":..}`, énorme
13
+ * et faux). Quand la connexion a négocié un codec de frame (CBOR, ADR-0022 §2), la
14
+ * trame est en plus DÉCODÉE pour être lisible : le débogage se fait à l'observation,
15
+ * jamais en changeant le fil.
13
16
  */
14
17
  /** Cap de troncature du contenu loggé (octets/chars). Borne ring + JSONL. */
15
18
  const WS_LOG_CONTENT_CAP = 4096;
@@ -40,28 +43,63 @@ function binaryByteLength(data) {
40
43
  return -1;
41
44
  }
42
45
  /**
46
+ * Octets imbriqués dans une valeur JSON : leur étiquette, jamais leur vidage.
47
+ *
48
+ * Lit la valeur BRUTE chez son parent (`this[key]`) : `JSON.stringify` appelle
49
+ * `toJSON` avant le replacer, et un `Buffer` — ce que rend un décodage depuis la
50
+ * trame de `ws` — y deviendrait `{"type":"Buffer","data":[…]}`.
51
+ */
52
+ function binaryAsLabel(key, value) {
53
+ const raw = Reflect.get(this, key);
54
+ if (ArrayBuffer.isView(raw)) return binaryPayloadLabel(raw.byteLength);
55
+ if (raw instanceof ArrayBuffer) return binaryPayloadLabel(raw.byteLength);
56
+ return value;
57
+ }
58
+ /** Sérialisation d'une valeur pour une ligne de journal — jamais de levée. */
59
+ function stringifyForLog(value) {
60
+ let s;
61
+ try {
62
+ s = JSON.stringify(value, binaryAsLabel);
63
+ } catch {
64
+ return String(value);
65
+ }
66
+ return typeof s === "string" ? s : String(value);
67
+ }
68
+ /** Tronque à `cap` caractères, ellipse comprise au-delà. */
69
+ function bound(s, cap) {
70
+ return s.length > cap ? `${s.slice(0, cap)}…` : s;
71
+ }
72
+ /**
43
73
  * Formate une charge utile WS en chaîne **bornée** et **sûre** pour le log :
44
74
  * - `string` → tronquée à `cap` (+ ellipse) ;
45
- * - binaire (cf {@link binaryByteLength}) → `[binary N B]` (jamais de dump) ;
75
+ * - binaire (cf {@link binaryByteLength}) → `[binary N B]` (jamais de vidage) ;
76
+ * avec un `codec`, une trame d'un seul tenant est aussi décodée :
77
+ * `[binary N B] <protocole> <frame en JSON>`, octets imbriqués étiquetés ;
78
+ * illisible → `[binary N B] <protocole> illisible` ;
46
79
  * - `null`/`undefined` → `""` ;
47
80
  * - objet « JSON » → `JSON.stringify` compact tronqué (cycle, `bigint`, valeur
48
81
  * non sérialisable → repli `String(...)`).
49
82
  *
50
83
  * @param data - charge utile (RECEIVE/SEND/BROADCAST).
51
84
  * @param cap - longueur max avant troncature (défaut {@link WS_LOG_CONTENT_CAP}).
85
+ * @param codec - codec des trames binaires de la connexion, `null` = octets bruts.
86
+ * @returns la ligne à journaliser, jamais plus longue que `cap` + 1.
52
87
  */
53
- function formatWsLogContent(data, cap = WS_LOG_CONTENT_CAP) {
54
- if (typeof data === "string") return data.length > cap ? `${data.slice(0, cap)}…` : data;
88
+ function formatWsLogContent(data, cap = WS_LOG_CONTENT_CAP, codec = null) {
89
+ if (typeof data === "string") return bound(data, cap);
55
90
  if (data === null || data === void 0) return "";
56
91
  const bytes = binaryByteLength(data);
57
- if (bytes >= 0) return `[binary ${bytes} B]`;
58
- let s;
59
- try {
60
- s = JSON.stringify(data);
61
- } catch {
62
- return String(data);
92
+ if (bytes >= 0) {
93
+ const label = binaryPayloadLabel(bytes);
94
+ if (codec === null || !(data instanceof Uint8Array)) return label;
95
+ let frame;
96
+ try {
97
+ frame = codec.decode(data);
98
+ } catch {
99
+ return `${label} ${codec.protocol} illisible`;
100
+ }
101
+ return bound(`${label} ${codec.protocol} ${stringifyForLog(frame)}`, cap);
63
102
  }
64
- if (typeof s !== "string") return String(data);
65
- return s.length > cap ? `${s.slice(0, cap)}…` : s;
103
+ return bound(stringifyForLog(data), cap);
66
104
  }
67
105
  export { WS_LOG_CONTENT_CAP, binaryByteLength, formatWsLogContent };
@@ -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 };
@@ -37,12 +37,22 @@ var FrameProfile = class {
37
37
  * invocation, il redevient exact, et le SQL se replace dans le waterfall.
38
38
  */
39
39
  profilerQueries;
40
+ /**
41
+ * Ce profil sera-t-il gardé par le Profiler ? Faux quand seul le timing
42
+ * l'a ouvert (chemin d'outillage) : son id ne désigne alors aucun profil
43
+ * lisible, et ne doit pas être annoncé au client.
44
+ */
45
+ get profiled() {
46
+ return this.profilerQueries !== null;
47
+ }
40
48
  /** Resolver de la frame — porte route / controller / action. */
41
49
  resolver = null;
42
50
  /** Statut HTTP-équivalent de l'invocation (200, 403, 404…). */
43
51
  response = null;
44
52
  error = null;
45
53
  timing;
54
+ clockStartMs;
55
+ durationMs = null;
46
56
  constructor(init) {
47
57
  this.requestId = init.requestId;
48
58
  this.type = init.type;
@@ -54,9 +64,20 @@ var FrameProfile = class {
54
64
  this.security = init.security;
55
65
  this.securityTrace = init.securityTrace;
56
66
  this.timing = init.timing;
67
+ this.clockStartMs = init.timing ? performance.now() : 0;
57
68
  this.profilerQueries = init.queries ? [] : null;
58
69
  }
59
70
  /**
71
+ * Arrête l'horloge de la frame (au premier appel) et rend sa durée en ms ;
72
+ * `null` si le timing est éteint. Contrat de `IContext.stopClock`.
73
+ *
74
+ * @returns la durée de l'invocation, ou `null`
75
+ */
76
+ stopClock() {
77
+ if (!this.timing) return null;
78
+ return this.durationMs ??= performance.now() - this.clockStartMs;
79
+ }
80
+ /**
60
81
  * Ouvre une phase. Contrairement au `Context`, l'index est un **scan arrière**
61
82
  * (pas de `Map<nom, index>`) : une frame porte une poignée de phases, et une
62
83
  * phase peut être ré-entrante (une action qui en déclenche une autre) — la
@@ -88,6 +109,7 @@ var FrameProfile = class {
88
109
  * @param error - l'erreur qui a interrompu la frame, le cas échéant.
89
110
  */
90
111
  finish(status, error) {
112
+ this.stopClock();
91
113
  this.response = { statusCode: status };
92
114
  if (error !== void 0 && error !== null) this.error = { message: thrownMessage(error) };
93
115
  }
@@ -1,24 +1,9 @@
1
1
  import { profileLane } from "./aggregate.js";
2
+ import { frameIndexOf } from "nodefony";
2
3
  // nodefony/src/profiler/Profiler.ts
3
- /**
4
- * Durée totale = (fin de la dernière phase) − (début de la 1ère).
5
- *
6
- * Une phase encore OUVERTE à la collecte (action interrompue par le départ du
7
- * client : flux SSE, outil MCP annulé) court jusqu'à la collecte — la compter
8
- * pour zéro publiait 0,3 ms pour un appel coupé à 3 s.
9
- */
10
- function totalDuration(phases) {
11
- if (phases.length === 0) return null;
12
- const first = phases.at(0);
13
- if (first === void 0 || typeof first.startMs !== "number") return null;
14
- let end = first.startMs;
15
- let now = null;
16
- for (const p of phases) {
17
- const e = p.durationMs === void 0 ? now ??= performance.now() : p.startMs + p.durationMs;
18
- if (e > end) end = e;
19
- }
20
- const d = end - first.startMs;
21
- return d >= 0 ? Math.round(d * 1e3) / 1e3 : null;
4
+ /** Arrondit une durée à la microseconde (affichage du Suivi). */
5
+ function roundMicro(ms) {
6
+ return ms === null ? null : Math.round(ms * 1e3) / 1e3;
22
7
  }
23
8
  /** Capacité par défaut du ring buffer (assez pour une session de debug). */
24
9
  const DEFAULT_CAP = 500;
@@ -46,6 +31,14 @@ var Profiler = class {
46
31
  /** Dernier numéro d'ordre attribué (0 = aucun profil collecté). */
47
32
  _seq = 0;
48
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
+ /**
49
42
  * @param cap - capacité de l'anneau de l'application.
50
43
  * @param sideCap - capacité de chacun des anneaux de l'outillage et des
51
44
  * assets relayés.
@@ -77,7 +70,7 @@ var Profiler = class {
77
70
  url: ctx.url ?? "",
78
71
  scheme: ctx.scheme ?? "",
79
72
  status: ctx.response?.statusCode ?? (ctx.error ? 500 : null),
80
- durationMs: totalDuration(phases),
73
+ durationMs: roundMicro(ctx.stopClock()),
81
74
  route: ctx.resolver?.route?.name ?? null,
82
75
  controller: ctx.resolver?.controller?.name ?? null,
83
76
  action: ctx.resolver?.actionName ?? null,
@@ -112,12 +105,17 @@ var Profiler = class {
112
105
  }
113
106
  /**
114
107
  * Résumés des requêtes les plus récentes (récent → ancien), capés à `limit`.
108
+ *
109
+ * @param limit - nombre maximal de résumés
110
+ * @param connection - le requestId d'une connexion WebSocket : ses SEULES
111
+ * frames du pont (`<connection>.<n>`, règle `frameIndexOf` du cœur)
115
112
  */
116
- recent(limit = 60) {
113
+ recent(limit = 60, connection) {
117
114
  const out = [];
118
115
  const entries = this.#collectAfter(0).reverse();
119
116
  for (const e of entries) {
120
117
  if (out.length >= limit) break;
118
+ if (connection && frameIndexOf(e.requestId, connection) === null) continue;
121
119
  out.push({
122
120
  requestId: e.requestId,
123
121
  ts: e.ts,
@@ -161,12 +159,17 @@ var Profiler = class {
161
159
  const { app, tooling, asset } = this._lanes;
162
160
  return app.size + tooling.size + asset.size;
163
161
  }
164
- /** Vide les anneaux. */
162
+ /** Vide les anneaux, et retient l'instant du vidage ({@link clearedAt}). */
165
163
  clear() {
166
164
  const { app, tooling, asset } = this._lanes;
167
165
  app.clear();
168
166
  tooling.clear();
169
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;
170
173
  }
171
174
  /**
172
175
  * Profils de numéro supérieur à `from`, tous anneaux confondus, du plus
@@ -1,4 +1,5 @@
1
1
  import { MODULE_PROXY_PREFIXES } from "../proxy/rules.js";
2
+ import { isConsolePath } from "nodefony";
2
3
  // nodefony/src/profiler/aggregate.ts
3
4
  /**
4
5
  * Agrégation des profils de requêtes — règle PURE, sans horloge ni I/O.
@@ -22,20 +23,28 @@ const ROUTE_SAMPLE_CAP = 256;
22
23
  const SQL_SHAPE_CAP = 200;
23
24
  /** Exemples d'erreurs retenus (statut ≥ 500 ou exception). */
24
25
  const ERROR_SAMPLE_CAP = 10;
25
- /** Préfixe des routes de l'outillage (console, API d'administration, porte MCP). */
26
+ /**
27
+ * Préfixe de l'espace réservé au framework.
28
+ *
29
+ * @deprecated Ne dit pas ce qui est de l'outillage : une connexion
30
+ * d'utilisateur (`/nodefony/security/api/auth/…`) ou une route d'application
31
+ * (`/nodefony/test/index`) passent sous ce préfixe. La règle est
32
+ * `isConsolePath` (`nodefony`), que `profileLane` appelle.
33
+ */
26
34
  const TOOLING_URL_PREFIX = "/nodefony/";
27
35
  /**
28
- * Range un profil par provenance, d'après son URL. Seule règle du partage —
29
- * les anneaux du profileur et le filtre de l'agrégation la lisent tous deux.
36
+ * Range un profil par provenance, d'après son URL. L'outillage est reconnu par
37
+ * la règle du cœur, `isConsolePath` — la même que les Journaux, la barre de
38
+ * débogage et le Studio : une connexion d'utilisateur reste de l'application.
39
+ * Les anneaux du profileur et le filtre de l'agrégation la lisent tous deux.
30
40
  *
31
41
  * @param url - URL du profil, absolue ou chemin.
32
42
  * @returns la provenance du profil.
33
43
  */
34
44
  function profileLane(url) {
35
45
  const path = urlPath(url);
36
- if (path.startsWith("/nodefony/")) return "tooling";
37
46
  for (const prefix of MODULE_PROXY_PREFIXES) if (path.startsWith(prefix)) return "asset";
38
- return "app";
47
+ return isConsolePath(path) ? "tooling" : "app";
39
48
  }
40
49
  /**
41
50
  * Le profil vient-il de l'outillage (routes `/nodefony/…` ou assets relayés)
@@ -46,6 +55,17 @@ function profileLane(url) {
46
55
  function isToolingUrl(url) {
47
56
  return profileLane(url) !== "app";
48
57
  }
58
+ /**
59
+ * Une requête de cette URL se profile-t-elle ? L'application toujours ;
60
+ * l'outillage et les assets relayés seulement sur demande (`timing.tooling`)
61
+ * — on ne mesure pas la façon dont on mesure.
62
+ *
63
+ * @param url - URL ou chemin de la requête (ou de la frame du pont)
64
+ * @param tooling - profiler aussi l'outillage
65
+ */
66
+ function isProfiledUrl(url, tooling) {
67
+ return tooling || profileLane(url) === "app";
68
+ }
49
69
  /** Groupe des requêtes qu'aucune route n'a servies (404, statique, sondes). */
50
70
  const NO_ROUTE = "(aucune route)";
51
71
  /** Pseudo-phase : le temps de l'action hors phases imbriquées et hors SQL. */
@@ -419,4 +439,4 @@ function aggregateProfiles(entries, options = {}) {
419
439
  for (const entry of entries) aggregator.add(entry);
420
440
  return aggregator.result();
421
441
  }
422
- export { ACTION_SELF_PHASE, ERROR_SAMPLE_CAP, NO_ROUTE, N_PLUS_ONE_MIN_REPEATS, ROUTE_SAMPLE_CAP, SQL_SHAPE_CAP, TOOLING_URL_PREFIX, aggregateProfiles, createProfileAggregator, isFailedProfile, isToolingUrl, profileLane, sqlShape, urlPath };
442
+ export { ACTION_SELF_PHASE, ERROR_SAMPLE_CAP, NO_ROUTE, N_PLUS_ONE_MIN_REPEATS, ROUTE_SAMPLE_CAP, SQL_SHAPE_CAP, TOOLING_URL_PREFIX, aggregateProfiles, createProfileAggregator, isFailedProfile, isProfiledUrl, isToolingUrl, profileLane, sqlShape, urlPath };
@@ -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