@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
@@ -21,6 +21,85 @@ const COLON = 58;
21
21
  * @returns les composants découpés, ou `null` (bail-out → vrai `new URL`).
22
22
  */
23
23
  function splitTarget(target) {
24
+ return splitTargetFast(target);
25
+ }
26
+ /**
27
+ * Origine fixe d'un request-target analysé SANS requête HTTP (pont
28
+ * `api.request`) : le chemin y est CONCATÉNÉ, jamais résolu comme relatif — un
29
+ * `//autre/x` reste un pathname `//autre/x`, comme le verrait la porte HTTP,
30
+ * au lieu de devenir une autorité.
31
+ */
32
+ const DETACHED_ORIGIN = "http://request-target.invalid";
33
+ const HASH = 35;
34
+ const AT = 64;
35
+ /**
36
+ * Découpe un request-target en forme absolue (`http://hôte:port/chemin?q`) :
37
+ * la forme qu'un client envoie à un proxy, et qu'un serveur d'origine DOIT
38
+ * accepter (RFC 9112 §3.2.2). Il DOIT alors ignorer l'en-tête `Host` et prendre
39
+ * l'hôte de la cible — le recoller à `Host` fabriquait un hôte qui n'existe pas.
40
+ *
41
+ * Seuls `http` et `https` sont reconnus (RFC 6455 §4.2.1 vaut aussi pour un
42
+ * handshake WebSocket) ; le schéma de la cible ne remplace jamais celui de la
43
+ * connexion. Une autorité porteuse d'identifiants (`user@hôte`, interdite par
44
+ * RFC 9110 §4.2.4) n'est pas reconnue : `Host` garde la main.
45
+ *
46
+ * Chemin de chaque requête : un seul test de caractère pour la forme origine,
47
+ * aucune allocation.
48
+ *
49
+ * @param target - le request-target brut, tel que reçu.
50
+ * @returns l'autorité et la forme origine, ou `null` pour toute autre forme.
51
+ */
52
+ function splitAbsoluteForm(target) {
53
+ if (target === void 0 || target.charCodeAt(0) === SLASH) return null;
54
+ const sep = target.indexOf("://");
55
+ if (sep !== 4 && sep !== 5) return null;
56
+ const scheme = target.slice(0, sep).toLowerCase();
57
+ if (scheme !== "http" && scheme !== "https") return null;
58
+ const start = sep + 3;
59
+ let end = start;
60
+ while (end < target.length) {
61
+ const code = target.charCodeAt(end);
62
+ if (code === SLASH || code === QMARK || code === HASH) break;
63
+ if (code === AT) return null;
64
+ end++;
65
+ }
66
+ if (end === start) return null;
67
+ const rest = target.slice(end);
68
+ return {
69
+ authority: target.slice(start, end),
70
+ target: rest.charCodeAt(0) === SLASH ? rest : `/${rest}`
71
+ };
72
+ }
73
+ /**
74
+ * Analyse un request-target origin-form (`/chemin[?query]`) dans la forme
75
+ * NORMALISÉE que la porte HTTP donne au routeur (`HttpRequest`) : découpe exacte
76
+ * quand le parse WHATWG serait l'identité ({@link splitTarget}), vrai parse
77
+ * WHATWG sinon — dot-segments résolus, `\` replié, percent-encoding posé.
78
+ *
79
+ * Pour qui reçoit un chemin hors d'une requête HTTP — le pont WebSocket
80
+ * `api.request` — et doit router exactement comme HTTP : la même chaîne, deux
81
+ * transports, une seule forme.
82
+ *
83
+ * @param target - le chemin reçu ; il doit commencer par `/`.
84
+ * @returns pathname et search normalisés, ou `null` si `target` n'est pas un
85
+ * chemin origin-form analysable.
86
+ */
87
+ function parseRequestTarget(target) {
88
+ if (target.charCodeAt(0) !== SLASH) return null;
89
+ const fast = splitTargetFast(target);
90
+ if (fast !== null) return fast;
91
+ let url;
92
+ try {
93
+ url = new URL(`${DETACHED_ORIGIN}${target}`);
94
+ } catch {
95
+ return null;
96
+ }
97
+ return {
98
+ pathname: url.pathname,
99
+ search: url.search
100
+ };
101
+ }
102
+ function splitTargetFast(target) {
24
103
  if (typeof target !== "string" || target.length === 0) return null;
25
104
  if (target.charCodeAt(0) !== SLASH) return null;
26
105
  let prev = 0;
@@ -123,4 +202,4 @@ function isDigitsFrom(s, from) {
123
202
  }
124
203
  return true;
125
204
  }
126
- export { isCanonicalAuthority, splitTarget };
205
+ export { isCanonicalAuthority, parseRequestTarget, splitAbsoluteForm, splitTarget };
@@ -1,5 +1,5 @@
1
- import { requestAuthority } from "../transport.js";
2
1
  import HttpRequest from "../http/Request.js";
2
+ import { requestAuthority } from "../transport.js";
3
3
  // nodefony/src/context/http2/Request.ts
4
4
  var Http2Request = class extends HttpRequest {
5
5
  request;
@@ -10,8 +10,9 @@ const emptyHeaders = {
10
10
  getHeader: () => void 0
11
11
  };
12
12
  /**
13
- * Compose les en-têtes d'une réponse HTTP/2 : ceux posés sur la réponse, puis
14
- * ceux passés à `writeHead`, qui gagnent.
13
+ * Compose les en-têtes d'une réponse HTTP/2 : ceux qu'un tiers a écrits sur la
14
+ * réponse Node, puis ceux de la réserve de la réponse, puis ceux passés à
15
+ * `writeHead` — chacun l'emporte sur le précédent.
15
16
  *
16
17
  * Les clés sont ramenées en MINUSCULES : `getHeaders()` rend `content-type`,
17
18
  * un contrôleur passe `Content-Type` — fusionnées telles quelles, les deux
@@ -27,11 +28,13 @@ const emptyHeaders = {
27
28
  * @param statusMessage - message de statut, recopié en `x-status-message`
28
29
  * @param current - la réponse Node qui porte les en-têtes déjà posés
29
30
  * @param extra - en-têtes passés à `writeHead` (objet, ou liste plate clé/valeur)
31
+ * @param stored - la réserve de la réponse, liste plate aux noms en minuscules
30
32
  * @returns les en-têtes à transmettre à `respond()`
31
33
  */
32
- function mergeResponseHeaders(statusMessage, current, extra) {
34
+ function mergeResponseHeaders(statusMessage, current, extra, stored) {
33
35
  const merged = { "x-status-message": statusMessage };
34
36
  for (const name of current.getHeaderNames()) merged[name] = current.getHeader(name);
37
+ if (stored) for (let i = 0; i + 1 < stored.length; i += 2) merged[String(stored[i])] = stored[i + 1];
35
38
  if (Array.isArray(extra)) for (let i = 0; i + 1 < extra.length; i += 2) merged[String(extra[i]).toLowerCase()] = extra[i + 1];
36
39
  else if (extra) for (const key in extra) {
37
40
  const value = extra[key];
@@ -68,7 +71,8 @@ var Http2Response = class extends HttpResponse {
68
71
  this.statusMessage = this.getStatusMessage();
69
72
  this.setLength();
70
73
  this.ensureContentTypeHeader();
71
- this.headers = mergeResponseHeaders(this.statusMessage, this.response ?? emptyHeaders, headers);
74
+ this.applyHeaderPolicy(this.contentTypeForPolicy());
75
+ this.headers = mergeResponseHeaders(this.statusMessage, this.response ?? emptyHeaders, headers, this.headerList());
72
76
  this.headers[HTTP2_HEADER_STATUS] = this.statusCode;
73
77
  const requestId = this.context.requestId;
74
78
  if (requestId) this.headers["x-request-id"] = requestId;
@@ -42,4 +42,23 @@ function alpnProtocolOf(request) {
42
42
  function requestAuthority(headers) {
43
43
  return headers[":authority"] ?? headers.host;
44
44
  }
45
- export { alpnProtocolOf, protocolNameOf, requestAuthority };
45
+ /**
46
+ * Une requête HTTP/2 nomme-t-elle deux hôtes — `:authority` ET un `Host`
47
+ * différent ? RFC 9113 §8.3.1 : « SHOULD treat a request as malformed ». Le
48
+ * serveur lit `:authority` ({@link requestAuthority}), mais un code qui lit
49
+ * `headers.host` verrait un autre hôte que celui que le routage et le
50
+ * pare-feu ont jugé.
51
+ *
52
+ * Chemin de chaque requête : une lecture d'en-tête ; la comparaison n'a lieu
53
+ * que si les deux sont présents (un navigateur n'envoie pas `Host` en HTTP/2).
54
+ *
55
+ * @param headers - en-têtes de la requête Node.
56
+ * @returns `true` quand les deux autorités diffèrent (casse ignorée).
57
+ */
58
+ function hasConflictingAuthority(headers) {
59
+ const authority = headers[":authority"];
60
+ if (authority === void 0) return false;
61
+ const host = headers.host;
62
+ return host !== void 0 && host.toLowerCase() !== authority.toLowerCase();
63
+ }
64
+ export { alpnProtocolOf, hasConflictingAuthority, protocolNameOf, requestAuthority };
@@ -215,8 +215,10 @@ var WebsocketResponse = class {
215
215
  return true;
216
216
  }
217
217
  addCookie(cookie) {
218
- if (cookie instanceof Cookie) this.cookies[cookie.name] = cookie;
219
- else throw new Error("Response addCookies not valid cookies");
218
+ if (cookie instanceof Cookie) {
219
+ cookie.assertGrammar();
220
+ this.cookies[cookie.name] = cookie;
221
+ } else throw new Error("Response addCookies not valid cookies");
220
222
  }
221
223
  setCookies() {}
222
224
  setCookie(_cookie) {}
@@ -1,13 +1,14 @@
1
- import { hasForwardingHeaders, resolveForwarded } from "../forwarded.js";
2
- import { closeRequestTrace } from "../requestTrace.js";
3
1
  import Context from "../Context.js";
4
- import { toWsCloseReason } from "./wsCloseReason.js";
5
2
  import WebsocketResponse from "./Response.js";
3
+ import { toWsCloseReason } from "./wsCloseReason.js";
6
4
  import { sanitizeRequestId } from "../requestId.js";
7
5
  import { FrameProfile } from "../../profiler/FrameProfile.js";
8
- import { formatWsLogContent } from "./wsLogContent.js";
6
+ import { WS_LOG_CONTENT_CAP, formatWsLogContent } from "./wsLogContent.js";
7
+ import { admitFrame, readFrameRateBound, readFrameRateCloseAfter } from "./wsFrameRate.js";
8
+ import { closeRequestTrace } from "../requestTrace.js";
9
+ import { hasForwardingHeaders, resolveForwarded } from "../forwarded.js";
9
10
  import { parseWebsocketUrl } from "./websocketUrl.js";
10
- import { RequestContext, frameRequestId, logColor } from "nodefony";
11
+ import { IDENTITY_DEAD_CLOSE_CODE, NOT_FOUND_CLOSE_CODE, ORIGIN_REFUSED_CLOSE_CODE, RequestContext, frameRequestId, logColor } from "nodefony";
11
12
  import { AsyncResource } from "node:async_hooks";
12
13
  import url, { URL } from "node:url";
13
14
  import Ws from "ws";
@@ -51,7 +52,7 @@ function toWsCloseCode(code) {
51
52
  if (code === 1e3 || code >= 1001 && code <= 1003 || code >= 1007 && code <= 1011 || code >= 3e3 && code <= 4999) return code;
52
53
  if (code >= 500 && code < 600) return 1011;
53
54
  if (code === 401 || code === 403 || code === 421) return 1008;
54
- if (code >= 400 && code < 500) return 4004;
55
+ if (code >= 400 && code < 500) return NOT_FOUND_CLOSE_CODE;
55
56
  return 1011;
56
57
  }
57
58
  /**
@@ -72,11 +73,11 @@ function toHandshakeStatus(code) {
72
73
  case 1003:
73
74
  case 1007: return 400;
74
75
  case 1008:
75
- case 4003: return 403;
76
- case 4001: return 401;
76
+ case ORIGIN_REFUSED_CLOSE_CODE: return 403;
77
+ case IDENTITY_DEAD_CLOSE_CODE: return 401;
77
78
  case 1009: return 413;
78
79
  case 1013: return 503;
79
- case 4004: return 404;
80
+ case NOT_FOUND_CLOSE_CODE: return 404;
80
81
  default: return 500;
81
82
  }
82
83
  }
@@ -110,11 +111,23 @@ var WebsocketContext = class extends Context {
110
111
  request;
111
112
  response = null;
112
113
  acceptedProtocol;
114
+ /**
115
+ * Codec des trames BINAIRES de cette connexion, posé par la couche qui l'a négocié
116
+ * (le temps réel, ADR-0022 §2). Hors production, le traceur s'en sert pour
117
+ * afficher une trame binaire décodée plutôt que sa seule taille ; jamais lu en
118
+ * production. `null` = octets bruts, résumés à leur taille.
119
+ */
120
+ frameCodec = null;
113
121
  port;
114
122
  rejected = false;
115
123
  teardownWired = false;
116
124
  connection = null;
117
125
  /**
126
+ * Fenêtre de la borne de débit entrante — `null` tant que la borne est
127
+ * désactivée ou qu'aucune trame n'est arrivée (créée au premier usage).
128
+ */
129
+ frameRate = null;
130
+ /**
118
131
  * Le verdict du handshake encore à rendre : posé par le noyau tant que le
119
132
  * pipeline décide (pare-feu, garde de l'action), `null` dès que la socket
120
133
  * est acceptée ou refusée. Tant qu'il est là, AUCUN `101` n'est parti.
@@ -271,7 +284,7 @@ var WebsocketContext = class extends Context {
271
284
  }
272
285
  });
273
286
  } else if (!this.isRejected()) {
274
- this.reject(4004, "Not Found");
287
+ this.reject(NOT_FOUND_CLOSE_CODE, "Not Found");
275
288
  this.rejected = true;
276
289
  this.webSocketState = "error";
277
290
  }
@@ -311,6 +324,28 @@ var WebsocketContext = class extends Context {
311
324
  return null;
312
325
  }
313
326
  /**
327
+ * Le contenu des messages est-il journalisé ? Hors production seulement —
328
+ * décidé une fois pour le processus (P8 : runtime ∈ {development, production},
329
+ * `resolveRuntimeEnv`).
330
+ */
331
+ tracesMessageContent() {
332
+ wsContentLogging ??= this.kernel?.environment !== "production";
333
+ return wsContentLogging;
334
+ }
335
+ /**
336
+ * Traceur des trames SORTANTES écrites hors de {@link send} — le temps réel
337
+ * écrit droit sur la socket. À demander UNE fois par connexion : `null` en
338
+ * production, donc l'appelant choisit son chemin d'écriture à l'ouverture et
339
+ * ne paie rien par trame.
340
+ *
341
+ * @returns une fonction qui journalise une trame comme `send` (`WS SEND`),
342
+ * ou `null` quand le contenu des messages n'est pas journalisé.
343
+ */
344
+ outboundTracer() {
345
+ if (!this.tracesMessageContent()) return null;
346
+ return (data) => this.logMessageContent("SEND", data);
347
+ }
348
+ /**
314
349
  * Logge le CONTENU d'un message WS (corrélé `requestId` via l'override `log`),
315
350
  * gaté hors prod et borné — le Suivi de requête (Studio) le surface alors par
316
351
  * direction. Hot path : le gate booléen court-circuite en prod AVANT toute
@@ -320,9 +355,8 @@ var WebsocketContext = class extends Context {
320
355
  * @param data - charge utile (string, Buffer binaire, objet, ou null).
321
356
  */
322
357
  logMessageContent(dir, data) {
323
- wsContentLogging ??= this.kernel?.environment !== "production";
324
- if (!wsContentLogging) return;
325
- this.log(formatWsLogContent(data), "DEBUG", `WS ${dir}`);
358
+ if (!this.tracesMessageContent()) return;
359
+ this.log(formatWsLogContent(data, WS_LOG_CONTENT_CAP, this.frameCodec), "DEBUG", `WS ${dir}`);
326
360
  }
327
361
  /**
328
362
  * Ouvre le profil d'**une invocation** du pont RPC (une frame `api.request`).
@@ -390,7 +424,30 @@ var WebsocketContext = class extends Context {
390
424
  }
391
425
  super.phaseEnd(name);
392
426
  }
427
+ /**
428
+ * Applique la borne de débit entrante à une trame : `true` = l'admettre.
429
+ *
430
+ * Écartée → comptée (`frameRateRejectedTotal`) ; solde de refus atteint →
431
+ * connexion fermée en `1008` (RFC 6455 §7.4.1, violation de politique).
432
+ *
433
+ * @param bound - trames admises par seconde (`> 0`)
434
+ * @returns `true` si la trame poursuit son chemin
435
+ */
436
+ admitInboundFrame(bound) {
437
+ const now = Date.now();
438
+ this.frameRate ??= {
439
+ start: now,
440
+ count: 0,
441
+ balance: 0
442
+ };
443
+ const verdict = admitFrame(this.frameRate, now, bound, readFrameRateCloseAfter(this.server));
444
+ if (verdict === "send") return true;
445
+ if (verdict === "close") this.connection?.close(1008, "frame rate");
446
+ return false;
447
+ }
393
448
  async handleMessage(data, isBinary) {
449
+ const bound = readFrameRateBound(this.server);
450
+ if (bound > 0 && !this.admitInboundFrame(bound)) return void 0;
394
451
  this.webSocketState = "message";
395
452
  const message = isBinary ? data : data.toString();
396
453
  this.logMessageContent("RECEIVE", message);
@@ -406,7 +463,7 @@ var WebsocketContext = class extends Context {
406
463
  } } });
407
464
  return callAsPromise(this.resolver, [message]);
408
465
  } else if (!this.rejected) {
409
- this.reject(4004, "Not Found");
466
+ this.reject(NOT_FOUND_CLOSE_CODE, "Not Found");
410
467
  this.rejected = true;
411
468
  }
412
469
  return;
@@ -1,3 +1,4 @@
1
+ import { splitAbsoluteForm } from "../http/urlFastPath.js";
1
2
  // nodefony/src/context/websocket/websocketUrl.ts
2
3
  /**
3
4
  * URL d'une requête d'upgrade WebSocket, telle que le pipeline la lit.
@@ -8,6 +9,10 @@
8
9
  * un hôte de l'autre — et une route acceptée par l'une serait refusée par
9
10
  * l'autre.
10
11
  *
12
+ * Une cible en forme absolue (`http://hôte/chemin`, RFC 6455 §4.2.1) donne son
13
+ * hôte, et l'en-tête `Host` est ignoré (RFC 9112 §3.2.2) — la même règle que
14
+ * `HttpRequest.getHost`.
15
+ *
11
16
  * @param scheme - `ws` ou `wss`
12
17
  * @param host - en-tête `Host` de la requête (`localhost` s'il manque)
13
18
  * @param rawUrl - cible brute de la requête (`/` si elle manque)
@@ -15,6 +20,8 @@
15
20
  * @throws TypeError si l'hôte ou la cible ne forment pas une URL
16
21
  */
17
22
  function parseWebsocketUrl(scheme, host, rawUrl) {
23
+ const absolute = splitAbsoluteForm(rawUrl);
24
+ if (absolute !== null) return new URL(`${scheme}://${absolute.authority}${absolute.target}`);
18
25
  return new URL(`${scheme}://${host ?? "localhost"}${rawUrl ?? "/"}`);
19
26
  }
20
27
  export { parseWebsocketUrl };
@@ -1,3 +1,4 @@
1
+ import { TRY_AGAIN_LATER_CLOSE_CODE, decideBackpressure, nextDropBalance } from "nodefony";
1
2
  // nodefony/src/context/websocket/wsBackpressure.ts
2
3
  /**
3
4
  * Relit le seuil + la politique depuis les options du `WebSocketServer`.
@@ -29,27 +30,26 @@ const readBackpressureOptions = (wss) => {
29
30
  * « Try Again Later » → le client peut back-off + reconnecter). NE FAIT PAS le `send()`
30
31
  * — le caller émet uniquement si le retour vaut `"send"`.
31
32
  *
33
+ * La DÉCISION est celle du cœur (`decideBackpressure` / `nextDropBalance`, partagée
34
+ * avec `NodefonySocket`) ; cette fonction n'en porte que l'état (compteurs sur la
35
+ * socket) et le geste serveur (fermeture `1013`).
36
+ *
32
37
  * @param ws - la socket destinataire
33
38
  * @param max - seuil d'octets (`<= 0` → désactivé)
34
39
  * @param policy - action au dépassement
40
+ * @param closeAfterDrops - solde de refus qui ferme (`0` = jamais)
35
41
  * @returns `"send"` (émettre) · `"drop"` (sauter cette frame) · `"close"` (socket fermée)
36
42
  */
37
43
  const decideSend = (ws, max, policy, closeAfterDrops = 0) => {
38
- const sock = ws;
39
- if (max <= 0 || (ws.bufferedAmount ?? 0) <= max) {
40
- if (sock._nfDropStreak) sock._nfDropStreak -= 1;
41
- return "send";
42
- }
43
- sock._nfDrops = (sock._nfDrops ?? 0) + 1;
44
- const streak = sock._nfDropStreak = (sock._nfDropStreak ?? 0) + 1;
45
- if (policy === "close") {
46
- ws.close(1013, "backpressure");
47
- return "close";
48
- }
49
- if (closeAfterDrops > 0 && streak >= closeAfterDrops) {
50
- ws.close(1013, "backpressure");
51
- return "close";
44
+ const balance = ws._nfDropStreak ?? 0;
45
+ const verdict = decideBackpressure(ws.bufferedAmount ?? 0, max, policy, closeAfterDrops, balance);
46
+ if (verdict === "send") {
47
+ if (balance > 0) ws._nfDropStreak = balance - 1;
48
+ return verdict;
52
49
  }
53
- return "drop";
50
+ ws._nfDrops = (ws._nfDrops ?? 0) + 1;
51
+ ws._nfDropStreak = nextDropBalance(verdict, balance);
52
+ if (verdict === "close") ws.close(TRY_AGAIN_LATER_CLOSE_CODE, "backpressure");
53
+ return verdict;
54
54
  };
55
55
  export { decideSend, readBackpressureOptions };
@@ -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 };