@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
@@ -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;
@@ -93,6 +97,7 @@ var Http2Response = class extends HttpResponse {
93
97
  const stream = this.stream;
94
98
  if (!stream) return super.send(chunk, encoding);
95
99
  if (stream.destroyed || !stream.writable) {
100
+ this.settleHeadResponse();
96
101
  this.log("HTTP/2 stream not writable before send — skip", "DEBUG");
97
102
  return this;
98
103
  }
@@ -116,11 +121,29 @@ var Http2Response = class extends HttpResponse {
116
121
  */
117
122
  end(chunk, encoding) {
118
123
  if (this.stream) {
119
- if (this.stream.writableEnded || this.stream.destroyed || this.stream.closed) return this.stream;
124
+ if (this.stream.writableEnded || this.stream.destroyed || this.stream.closed) {
125
+ this.settleHeadResponse();
126
+ return this.stream;
127
+ }
120
128
  return this.stream.end(chunk, encoding || this.encoding);
121
129
  }
122
130
  return super.end(chunk, encoding);
123
131
  }
132
+ /**
133
+ * Termine la réponse de compatibilité d'un HEAD, dont Node a clos le flux de
134
+ * lui-même au `respond()`.
135
+ *
136
+ * Sans ce geste, `Http2ServerResponse` n'émet ni `finish` ni `close` : le
137
+ * démontage de la requête, branché sur ce `close` (ligne d'accès, profil,
138
+ * hooks d'après-réponse, libération du scope DI), n'a jamais lieu. Les statuts
139
+ * que Node termine aussi d'office (204, 205, 304) émettent leur `close` seuls,
140
+ * et un flux détruit (client parti) aussi : ils ne sont pas touchés. Hors du
141
+ * chemin servi — appelé seulement quand le flux est déjà clos.
142
+ */
143
+ settleHeadResponse() {
144
+ const raw = this.response;
145
+ if (raw !== null && this.context.method === "HEAD" && this.stream?.destroyed === false && !raw.writableEnded) raw.end();
146
+ }
124
147
  getStatusMessage(code) {
125
148
  if (code) {
126
149
  if (this.response) return http.STATUS_CODES[code] || this.statusMessage;
@@ -0,0 +1,104 @@
1
+ import diagnosticsChannel from "node:diagnostics_channel";
2
+ // nodefony/src/context/requestTrace.ts
3
+ /**
4
+ * Nom du `TracingChannel` qui encadre chaque requête HTTP et chaque handshake
5
+ * WebSocket — forme `module.fonction` recommandée par la documentation de
6
+ * `node:diagnostics_channel` ; les sous-canaux sont
7
+ * `tracing:nodefony.http.request:start`, `:end`, `:asyncStart`, `:asyncEnd`,
8
+ * `:error`.
9
+ */
10
+ const REQUEST_TRACE_CHANNEL = "nodefony.http.request";
11
+ const tracing = diagnosticsChannel.tracingChannel(REQUEST_TRACE_CHANNEL);
12
+ const startChannel = tracing.start;
13
+ const endChannel = tracing.end;
14
+ const asyncStartChannel = tracing.asyncStart;
15
+ const asyncEndChannel = tracing.asyncEnd;
16
+ const errorChannel = tracing.error;
17
+ /**
18
+ * Dit si quelqu'un écoute le canal — LA garde du chemin chaud : ~1,6 ns
19
+ * mesurées, aucune allocation. Lue une seule fois, au début de la requête :
20
+ * un abonné arrivé ensuite ne voit que les requêtes suivantes (règle des
21
+ * `TracingChannel`, qui interdit une fin sans début).
22
+ *
23
+ * @returns `true` si au moins un sous-canal a un abonné
24
+ */
25
+ function hasRequestTraceSubscribers() {
26
+ return tracing.hasSubscribers;
27
+ }
28
+ /**
29
+ * Crée le message de la requête et le range sur son contexte. À n'appeler
30
+ * que sous {@link hasRequestTraceSubscribers} : c'est la seule allocation
31
+ * du canal, et elle n'a lieu que si quelqu'un écoute.
32
+ *
33
+ * @param context - le contexte qui vient d'être construit
34
+ * @param protocol - requête HTTP ou handshake WebSocket
35
+ * @returns le message, de forme fixe, dont les champs de résultat valent `null`
36
+ */
37
+ function openRequestTrace(context, protocol) {
38
+ const trace = {
39
+ requestId: context.requestId,
40
+ protocol,
41
+ method: context.method,
42
+ scheme: context.scheme,
43
+ url: context.url,
44
+ route: null,
45
+ routePath: null,
46
+ module: null,
47
+ statusCode: null,
48
+ durationMs: null,
49
+ error: null
50
+ };
51
+ context.requestTrace = trace;
52
+ return trace;
53
+ }
54
+ /**
55
+ * Exécute la partie synchrone du traitement sous `start.runStores` puis
56
+ * publie `end` — la séquence de `TracingChannel.tracePromise`. Les stores
57
+ * liés au canal (`bindStore`, ce que fait un agent APM) restent actifs pour
58
+ * toute la suite asynchrone de la requête.
59
+ *
60
+ * @param trace - le message de la requête
61
+ * @param fn - la fonction qui sert la requête
62
+ * @param thisArg - le receveur de `fn`
63
+ * @param args - les arguments de `fn`
64
+ * @returns ce que rend `fn` — un contexte ou sa promesse
65
+ */
66
+ function runTraced(trace, fn, thisArg, ...args) {
67
+ try {
68
+ return startChannel.runStores(trace, fn, thisArg, ...args);
69
+ } finally {
70
+ endChannel.publish(trace);
71
+ }
72
+ }
73
+ /**
74
+ * Termine la trace de la requête : remplit les champs de résultat puis
75
+ * publie `error` (s'il y a une exception), `asyncStart` et `asyncEnd`.
76
+ * Idempotent — le premier appel gagne, les suivants ne font rien : un
77
+ * handshake WebSocket a plusieurs sorties, une seule publie la fin.
78
+ *
79
+ * La durée est celle de l'horloge unique du contexte (`stopClock`), la même
80
+ * que celle de la ligne d'accès : le canal n'en mesure aucune.
81
+ *
82
+ * @param context - le contexte de la requête
83
+ * @param statusCode - le statut rendu au client, `null` s'il n'y en a pas eu
84
+ * @param error - l'exception attrapée par le pipeline, `null` ou `undefined` sinon
85
+ */
86
+ function closeRequestTrace(context, statusCode, error) {
87
+ const trace = context.requestTrace;
88
+ if (trace === null) return;
89
+ context.requestTrace = null;
90
+ const route = context.resolver?.route ?? null;
91
+ trace.route = route?.name ?? null;
92
+ const path = route?.path;
93
+ trace.routePath = path === void 0 ? null : path === "" ? "/" : path;
94
+ trace.module = context.logModule ?? route?.module?.name ?? null;
95
+ trace.statusCode = statusCode;
96
+ trace.durationMs = context.stopClock();
97
+ if (error !== null && error !== void 0) {
98
+ trace.error = error;
99
+ errorChannel.publish(trace);
100
+ }
101
+ asyncStartChannel.publish(trace);
102
+ asyncEndChannel.publish(trace);
103
+ }
104
+ export { REQUEST_TRACE_CHANNEL, closeRequestTrace, hasRequestTraceSubscribers, openRequestTrace, runTraced };
@@ -0,0 +1,21 @@
1
+ // nodefony/src/context/timingMode.ts
2
+ /**
3
+ * Résout le mode de chronométrage d'une requête depuis `timing.enabled` et
4
+ * l'environnement — la SEULE règle, appelée par le constructeur du `Context`.
5
+ *
6
+ * Défaut (`null`, pas d'avis) : la durée partout, les phases hors production.
7
+ * La durée coûte deux lectures d'horloge, l'équivalent de quelques pourcents
8
+ * de la ligne d'accès qu'elle complète ; les phases coûtent une lecture par
9
+ * phase, un tableau et un index par requête — réservées au développement, ou
10
+ * à un diagnostic à chaud (`true`). `false` coupe tout, durée comprise.
11
+ *
12
+ * @param enabled - valeur de `timing.enabled` (`true`, `false`, `null`)
13
+ * @param environment - environnement normalisé du noyau (`"production"`…)
14
+ * @returns le mode de la requête — une chaîne littérale, rien d'alloué
15
+ */
16
+ function resolveTimingMode(enabled, environment) {
17
+ if (enabled === false) return "off";
18
+ if (enabled === true) return "phases";
19
+ return environment === "production" ? "clock" : "phases";
20
+ }
21
+ export { resolveTimingMode };
@@ -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 };
@@ -0,0 +1,30 @@
1
+ // nodefony/src/context/unsentResponse.ts
2
+ /**
3
+ * Rédige l'avertissement de développement d'une requête restée sans réponse :
4
+ * l'action a rendu une valeur que le framework ne sait pas envoyer, et la
5
+ * requête a attendu son délai d'expiration.
6
+ *
7
+ * Le message nomme l'action fautive, ce qu'elle a rendu, et le geste qui
8
+ * convient à ce cas — `null` est presque toujours une entité absente, qui
9
+ * appelle un 404.
10
+ *
11
+ * @param resolver - le resolver de la requête (route, controller, action,
12
+ * valeur non rendue), `null` si aucune route n'a été résolue.
13
+ * @param url - l'URL de la requête, repli quand aucune route n'est connue.
14
+ * @returns le texte de l'avertissement.
15
+ */
16
+ function unsentResponseWarning(resolver, url) {
17
+ const controller = resolver?.controller?.name;
18
+ const actionName = resolver?.actionName;
19
+ const action = controller && actionName ? `${controller}.${actionName}` : null;
20
+ const route = resolver?.route?.name ?? null;
21
+ const where = action ? `L'action ${action}${route ? ` (route ${route})` : ""}` : `L'action de ${route ?? url}`;
22
+ const returned = resolver?.unrendered ?? null;
23
+ const consequence = "et n'a rien envoyé : la requête a attendu son délai d'expiration.";
24
+ if (returned === "null") return `${where} a rendu null ${consequence} Pour le framework, null veut dire « l'action répond elle-même ». Entité absente → lever HttpError(…, 404) ; sinon rendre la valeur, ou envoyer la réponse soi-même.`;
25
+ if (returned === "undefined") return `${where} n'a rien rendu ${consequence} Un « return » oublié ? Rendre la valeur (objet, chaîne, nombre, booléen, Buffer : envoyés automatiquement), ou envoyer la réponse soi-même.`;
26
+ if (returned?.startsWith("une instance de")) return `${where} a rendu ${returned}, qui ne s'envoie pas telle quelle, ${consequence} Rendre un objet simple (sa forme JSON), ou envoyer la réponse soi-même.`;
27
+ if (returned !== null) return `${where} a rendu une valeur de type ${returned} ${consequence} Rendre un objet, une chaîne, un nombre, un booléen ou un Buffer, ou envoyer la réponse soi-même.`;
28
+ return `${where} n'a rien envoyé : la requête a attendu son délai d'expiration. Rendre la valeur, ou envoyer la réponse soi-même.`;
29
+ }
30
+ export { unsentResponseWarning };
@@ -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,12 +1,14 @@
1
- import { hasForwardingHeaders, resolveForwarded } from "../forwarded.js";
2
1
  import Context from "../Context.js";
3
- import { toWsCloseReason } from "./wsCloseReason.js";
4
2
  import WebsocketResponse from "./Response.js";
3
+ import { toWsCloseReason } from "./wsCloseReason.js";
5
4
  import { sanitizeRequestId } from "../requestId.js";
6
5
  import { FrameProfile } from "../../profiler/FrameProfile.js";
7
- 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";
8
10
  import { parseWebsocketUrl } from "./websocketUrl.js";
9
- import { RequestContext, logColor } from "nodefony";
11
+ import { IDENTITY_DEAD_CLOSE_CODE, NOT_FOUND_CLOSE_CODE, ORIGIN_REFUSED_CLOSE_CODE, RequestContext, frameRequestId, logColor } from "nodefony";
10
12
  import { AsyncResource } from "node:async_hooks";
11
13
  import url, { URL } from "node:url";
12
14
  import Ws from "ws";
@@ -50,7 +52,7 @@ function toWsCloseCode(code) {
50
52
  if (code === 1e3 || code >= 1001 && code <= 1003 || code >= 1007 && code <= 1011 || code >= 3e3 && code <= 4999) return code;
51
53
  if (code >= 500 && code < 600) return 1011;
52
54
  if (code === 401 || code === 403 || code === 421) return 1008;
53
- if (code >= 400 && code < 500) return 4004;
55
+ if (code >= 400 && code < 500) return NOT_FOUND_CLOSE_CODE;
54
56
  return 1011;
55
57
  }
56
58
  /**
@@ -71,11 +73,11 @@ function toHandshakeStatus(code) {
71
73
  case 1003:
72
74
  case 1007: return 400;
73
75
  case 1008:
74
- case 4003: return 403;
75
- case 4001: return 401;
76
+ case ORIGIN_REFUSED_CLOSE_CODE: return 403;
77
+ case IDENTITY_DEAD_CLOSE_CODE: return 401;
76
78
  case 1009: return 413;
77
79
  case 1013: return 503;
78
- case 4004: return 404;
80
+ case NOT_FOUND_CLOSE_CODE: return 404;
79
81
  default: return 500;
80
82
  }
81
83
  }
@@ -109,11 +111,23 @@ var WebsocketContext = class extends Context {
109
111
  request;
110
112
  response = null;
111
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;
112
121
  port;
113
122
  rejected = false;
114
123
  teardownWired = false;
115
124
  connection = null;
116
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
+ /**
117
131
  * Le verdict du handshake encore à rendre : posé par le noyau tant que le
118
132
  * pipeline décide (pare-feu, garde de l'action), `null` dès que la socket
119
133
  * est acceptée ou refusée. Tant qu'il est là, AUCUN `101` n'est parti.
@@ -173,16 +187,16 @@ var WebsocketContext = class extends Context {
173
187
  this.log(`PROXY WEBSOCKET REQUEST ${fwd.fromStandard ? "Forwarded (RFC 7239)" : "x-forwarded"} VIA : ${this.proxy.proxyVia}`, "DEBUG");
174
188
  }
175
189
  }
176
- log(pci, severity, msgid, msg) {
190
+ log(pci, severity, msgid, msg, moduleName) {
177
191
  if (!msgid) msgid = "WEBSOCKET CONTEXT";
178
- return super.log(pci, severity, msgid, msg);
192
+ return super.log(pci, severity, msgid, msg, moduleName);
179
193
  }
180
194
  logRequest(httpError, acceptedProtocol) {
181
195
  try {
182
196
  const logger = this.httpKernel?.getRequestLogger();
183
197
  if (!logger) return;
184
198
  const entry = logger.renderWebsocket(this, httpError ?? null, acceptedProtocol ?? null);
185
- const pdu = this.log(entry.text, entry.severity, entry.msgid);
199
+ const pdu = this.log(entry.text, entry.severity, entry.msgid, void 0, this.logModule ?? this.resolver?.route?.module?.name);
186
200
  pdu.requestId ??= this.requestId;
187
201
  return pdu;
188
202
  } catch {
@@ -215,6 +229,7 @@ var WebsocketContext = class extends Context {
215
229
  this.rejected = true;
216
230
  this.webSocketState = "error";
217
231
  admission.refuse(status, this.response?.handshakeHeaders ?? null);
232
+ if (this.requestTrace !== null) closeRequestTrace(this, status, this.error);
218
233
  return true;
219
234
  }
220
235
  async connect() {
@@ -228,6 +243,7 @@ var WebsocketContext = class extends Context {
228
243
  this.requestEnded = true;
229
244
  this.connection.on("message", AsyncResource.bind(this.handleMessage.bind(this)));
230
245
  this.logRequest(null, this.acceptedProtocol ?? null);
246
+ if (this.requestTrace !== null) closeRequestTrace(this, 101, null);
231
247
  this.webSocketState = "connected";
232
248
  return this.connection;
233
249
  }
@@ -268,7 +284,7 @@ var WebsocketContext = class extends Context {
268
284
  }
269
285
  });
270
286
  } else if (!this.isRejected()) {
271
- this.reject(4004, "Not Found");
287
+ this.reject(NOT_FOUND_CLOSE_CODE, "Not Found");
272
288
  this.rejected = true;
273
289
  this.webSocketState = "error";
274
290
  }
@@ -308,6 +324,28 @@ var WebsocketContext = class extends Context {
308
324
  return null;
309
325
  }
310
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
+ /**
311
349
  * Logge le CONTENU d'un message WS (corrélé `requestId` via l'override `log`),
312
350
  * gaté hors prod et borné — le Suivi de requête (Studio) le surface alors par
313
351
  * direction. Hot path : le gate booléen court-circuite en prod AVANT toute
@@ -317,9 +355,8 @@ var WebsocketContext = class extends Context {
317
355
  * @param data - charge utile (string, Buffer binaire, objet, ou null).
318
356
  */
319
357
  logMessageContent(dir, data) {
320
- wsContentLogging ??= this.kernel?.environment !== "production";
321
- if (!wsContentLogging) return;
322
- 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}`);
323
360
  }
324
361
  /**
325
362
  * Ouvre le profil d'**une invocation** du pont RPC (une frame `api.request`).
@@ -337,10 +374,11 @@ var WebsocketContext = class extends Context {
337
374
  * zéro allocation dans ce cas.
338
375
  */
339
376
  beginFrame(method, framePath) {
340
- if (!this.profiling && !this.timingEnabled) return null;
377
+ const profiled = this.profiling && this.httpKernel?.shouldProfile(framePath) === true;
378
+ if (!profiled && !this.timingEnabled) return null;
341
379
  this.#frameSeq += 1;
342
380
  return new FrameProfile({
343
- requestId: `${this.requestId}.${this.#frameSeq}`,
381
+ requestId: frameRequestId(this.requestId, this.#frameSeq),
344
382
  type: this.type,
345
383
  scheme: this.scheme,
346
384
  method,
@@ -350,7 +388,7 @@ var WebsocketContext = class extends Context {
350
388
  security: this.security ?? null,
351
389
  securityTrace: this.securityTrace,
352
390
  timing: this.timingEnabled,
353
- queries: this.profiling
391
+ queries: profiled
354
392
  });
355
393
  }
356
394
  /**
@@ -358,7 +396,7 @@ var WebsocketContext = class extends Context {
358
396
  * Profiler (no-op hors dev, ou si aucun profil n'a été ouvert).
359
397
  */
360
398
  collectFrame(frame) {
361
- if (frame === null || !this.profiling) return;
399
+ if (frame === null || !frame.profiled) return;
362
400
  this.get("profiler")?.collect(frame);
363
401
  }
364
402
  /**
@@ -386,7 +424,30 @@ var WebsocketContext = class extends Context {
386
424
  }
387
425
  super.phaseEnd(name);
388
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
+ }
389
448
  async handleMessage(data, isBinary) {
449
+ const bound = readFrameRateBound(this.server);
450
+ if (bound > 0 && !this.admitInboundFrame(bound)) return void 0;
390
451
  this.webSocketState = "message";
391
452
  const message = isBinary ? data : data.toString();
392
453
  this.logMessageContent("RECEIVE", message);
@@ -402,7 +463,7 @@ var WebsocketContext = class extends Context {
402
463
  } } });
403
464
  return callAsPromise(this.resolver, [message]);
404
465
  } else if (!this.rejected) {
405
- this.reject(4004, "Not Found");
466
+ this.reject(NOT_FOUND_CLOSE_CODE, "Not Found");
406
467
  this.rejected = true;
407
468
  }
408
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 };