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

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 (130) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  4. package/dist/index.js +30 -26
  5. package/dist/nodefony/command/proxyGenerateCommand.js +2 -2
  6. package/dist/nodefony/config/config.js +29 -4
  7. package/dist/nodefony/grpc/GrpcCall.js +298 -0
  8. package/dist/nodefony/grpc/GrpcEndpoint.js +528 -0
  9. package/dist/nodefony/grpc/index.js +4 -0
  10. package/dist/nodefony/interfaces/IRpcCall.js +1 -0
  11. package/dist/nodefony/service/ProfilerAdminApi.js +5 -2
  12. package/dist/nodefony/service/audit-logger.js +52 -6
  13. package/dist/nodefony/service/error-renderer.js +47 -13
  14. package/dist/nodefony/service/http-kernel.js +306 -49
  15. package/dist/nodefony/service/pretty-request-logger.js +5 -3
  16. package/dist/nodefony/service/request-logger.js +21 -6
  17. package/dist/nodefony/service/reverse-proxy.js +8 -3
  18. package/dist/nodefony/service/servers/clientError.js +53 -4
  19. package/dist/nodefony/service/servers/server-h2c.js +6 -6
  20. package/dist/nodefony/service/servers/server-http.js +6 -4
  21. package/dist/nodefony/service/servers/server-https.js +14 -7
  22. package/dist/nodefony/service/servers/server-static.js +1 -6
  23. package/dist/nodefony/service/servers/server-websocket-secure.js +7 -11
  24. package/dist/nodefony/service/servers/server-websocket.js +7 -11
  25. package/dist/nodefony/service/servers/serverShutdown.js +43 -6
  26. package/dist/nodefony/service/sessions/sessions-service.js +7 -7
  27. package/dist/nodefony/service/upload/upload-service.js +2 -2
  28. package/dist/nodefony/src/assets/prebuiltUi.js +23 -5
  29. package/dist/nodefony/src/context/Context.js +12 -1
  30. package/dist/nodefony/src/context/http/ByteStream.js +364 -0
  31. package/dist/nodefony/src/context/http/HttpContext.js +139 -32
  32. package/dist/nodefony/src/context/http/Request.js +111 -22
  33. package/dist/nodefony/src/context/http/Response.js +459 -37
  34. package/dist/nodefony/src/context/http/SseStream.js +83 -148
  35. package/dist/nodefony/src/context/http/StreamLifecycle.js +582 -0
  36. package/dist/nodefony/src/context/http/parser.js +29 -11
  37. package/dist/nodefony/src/context/http/urlFastPath.js +80 -1
  38. package/dist/nodefony/src/context/http/webResponse.js +56 -0
  39. package/dist/nodefony/src/context/http2/Request.js +1 -1
  40. package/dist/nodefony/src/context/http2/Response.js +55 -12
  41. package/dist/nodefony/src/context/requestTrace.js +6 -2
  42. package/dist/nodefony/src/context/rpcOutcome.js +20 -0
  43. package/dist/nodefony/src/context/transport.js +20 -1
  44. package/dist/nodefony/src/context/websocket/Response.js +15 -3
  45. package/dist/nodefony/src/context/websocket/WebsocketContext.js +86 -18
  46. package/dist/nodefony/src/context/websocket/websocketUrl.js +7 -0
  47. package/dist/nodefony/src/context/websocket/wsBackpressure.js +15 -15
  48. package/dist/nodefony/src/context/websocket/wsFrameRate.js +71 -0
  49. package/dist/nodefony/src/context/websocket/wsLogContent.js +55 -17
  50. package/dist/nodefony/src/cookies/cookie.js +57 -14
  51. package/dist/nodefony/src/errors/httpError.js +58 -25
  52. package/dist/nodefony/src/grpc/cors.js +59 -0
  53. package/dist/nodefony/src/grpc/limits.js +16 -0
  54. package/dist/nodefony/src/grpc/refusal.js +271 -0
  55. package/dist/nodefony/src/profiler/Profiler.js +14 -1
  56. package/dist/nodefony/src/profiler/aggregate.js +2 -2
  57. package/dist/nodefony/src/proxy/forward.js +109 -9
  58. package/dist/nodefony/src/proxy/generateProxyConfig.js +97 -17
  59. package/dist/nodefony/src/proxy/rules.js +29 -14
  60. package/dist/nodefony/src/servers/http2Goaway.js +76 -0
  61. package/dist/nodefony/src/servers/http2Limits.js +41 -15
  62. package/dist/nodefony/src/servers/http2StreamGrace.js +100 -0
  63. package/dist/nodefony/src/servers/receiveDeadline.js +177 -0
  64. package/dist/nodefony/src/servers/upgradeDispatch.js +62 -4
  65. package/dist/types/index.d.ts +11 -2
  66. package/dist/types/nodefony/config/config.d.ts +14 -0
  67. package/dist/types/nodefony/grpc/GrpcCall.d.ts +142 -0
  68. package/dist/types/nodefony/grpc/GrpcEndpoint.d.ts +98 -0
  69. package/dist/types/nodefony/grpc/index.d.ts +15 -0
  70. package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
  71. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +14 -0
  72. package/dist/types/nodefony/interfaces/IRequest.d.ts +2 -1
  73. package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +10 -0
  74. package/dist/types/nodefony/interfaces/IRouting.d.ts +19 -0
  75. package/dist/types/nodefony/interfaces/IRpcCall.d.ts +83 -0
  76. package/dist/types/nodefony/interfaces/ISecurity.d.ts +27 -2
  77. package/dist/types/nodefony/interfaces/IUpload.d.ts +51 -4
  78. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  79. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +2 -1
  80. package/dist/types/nodefony/service/audit-logger.d.ts +33 -3
  81. package/dist/types/nodefony/service/error-renderer.d.ts +27 -5
  82. package/dist/types/nodefony/service/http-kernel.d.ts +36 -3
  83. package/dist/types/nodefony/service/request-logger.d.ts +10 -0
  84. package/dist/types/nodefony/service/servers/clientError.d.ts +3 -2
  85. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +7 -5
  86. package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +9 -4
  87. package/dist/types/nodefony/src/context/Context.d.ts +9 -1
  88. package/dist/types/nodefony/src/context/http/ByteStream.d.ts +109 -0
  89. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +99 -3
  90. package/dist/types/nodefony/src/context/http/Request.d.ts +46 -9
  91. package/dist/types/nodefony/src/context/http/Response.d.ts +188 -8
  92. package/dist/types/nodefony/src/context/http/SseStream.d.ts +33 -8
  93. package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +106 -0
  94. package/dist/types/nodefony/src/context/http/parser.d.ts +17 -3
  95. package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +40 -0
  96. package/dist/types/nodefony/src/context/http/webResponse.d.ts +31 -0
  97. package/dist/types/nodefony/src/context/http2/Response.d.ts +40 -5
  98. package/dist/types/nodefony/src/context/requestTrace.d.ts +3 -1
  99. package/dist/types/nodefony/src/context/rpcOutcome.d.ts +11 -0
  100. package/dist/types/nodefony/src/context/transport.d.ts +17 -0
  101. package/dist/types/nodefony/src/context/websocket/Response.d.ts +6 -1
  102. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +48 -0
  103. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +4 -0
  104. package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +12 -2
  105. package/dist/types/nodefony/src/context/websocket/wsFrameRate.d.ts +69 -0
  106. package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +16 -8
  107. package/dist/types/nodefony/src/cookies/cookie.d.ts +20 -2
  108. package/dist/types/nodefony/src/errors/httpError.d.ts +29 -4
  109. package/dist/types/nodefony/src/grpc/cors.d.ts +32 -0
  110. package/dist/types/nodefony/src/grpc/limits.d.ts +14 -0
  111. package/dist/types/nodefony/src/grpc/refusal.d.ts +136 -0
  112. package/dist/types/nodefony/src/profiler/Profiler.d.ts +11 -1
  113. package/dist/types/nodefony/src/proxy/forward.d.ts +37 -2
  114. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +15 -2
  115. package/dist/types/nodefony/src/proxy/rules.d.ts +20 -0
  116. package/dist/types/nodefony/src/servers/http2Goaway.d.ts +41 -0
  117. package/dist/types/nodefony/src/servers/http2Limits.d.ts +27 -14
  118. package/dist/types/nodefony/src/servers/http2StreamGrace.d.ts +34 -0
  119. package/dist/types/nodefony/src/servers/receiveDeadline.d.ts +77 -0
  120. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +41 -1
  121. package/docs/byte-stream.md +406 -0
  122. package/docs/cookies.md +13 -3
  123. package/docs/diagnostics-channel.md +45 -1
  124. package/docs/index.md +8 -0
  125. package/docs/reverse-proxy.md +15 -3
  126. package/docs/servers.md +67 -43
  127. package/docs/session.md +3 -3
  128. package/docs/sse.md +27 -20
  129. package/docs/upload.md +43 -17
  130. package/package.json +38 -18
@@ -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 };
@@ -0,0 +1,56 @@
1
+ import { HOP_BY_HOP } from "../../proxy/forward.js";
2
+ import { Readable } from "node:stream";
3
+ // nodefony/src/context/http/webResponse.ts
4
+ /**
5
+ * En-têtes qu'une réponse RELAYÉE (issue de `fetch`) ne garde pas. Son corps a
6
+ * déjà été DÉCODÉ par `fetch` : `content-encoding` et `content-length`
7
+ * décrivent encore les octets compressés de l'amont, et les recopier
8
+ * annoncerait un corps qui n'est pas celui qu'on envoie. Ses cookies
9
+ * appartiennent à l'amont : les relayer les poserait sous le domaine de
10
+ * l'application.
11
+ */
12
+ const UPSTREAM_ONLY = /* @__PURE__ */ new Set([
13
+ "content-encoding",
14
+ "content-length",
15
+ "set-cookie"
16
+ ]);
17
+ /** Corps d'une `Response` sans corps (`new Response(null, { status: 204 })`). */
18
+ const emptyBody = () => Readable.from([], { objectMode: false });
19
+ /**
20
+ * Traduit une `Response` Web standard en statut, en-têtes et corps pour le flux
21
+ * d'octets — sans la lire : le corps reste un flux.
22
+ *
23
+ * Deux origines, deux règles, que `response.type` distingue :
24
+ * - **construite par l'application** (`new Response(…)`, `Response.json(…)`,
25
+ * `Response.redirect(…)` — `type === "default"`) : ses en-têtes sont les
26
+ * siens, cookies compris (`getSetCookie()`), et partent tels quels ;
27
+ * - **relayée** (issue de `fetch` — `"basic"`, `"cors"`…) : `content-encoding`,
28
+ * `content-length` et `set-cookie` de l'amont sont écartés (voir
29
+ * `UPSTREAM_ONLY`).
30
+ *
31
+ * Les en-têtes de connexion (RFC 9110 §7.6.1 — la liste du relais,
32
+ * `HOP_BY_HOP`) ne partent jamais : ils décrivent un saut, et HTTP/2 les
33
+ * refuse.
34
+ *
35
+ * @param response - une `Response` dont le corps n'a pas été consommé.
36
+ * @returns statut, en-têtes filtrés et corps.
37
+ */
38
+ function webResponseParts(response) {
39
+ const relayed = response.type !== "default";
40
+ const headers = {};
41
+ for (const [name, value] of response.headers) {
42
+ if (name === "set-cookie" || HOP_BY_HOP.has(name)) continue;
43
+ if (relayed && UPSTREAM_ONLY.has(name)) continue;
44
+ headers[name] = value;
45
+ }
46
+ if (!relayed) {
47
+ const cookies = response.headers.getSetCookie();
48
+ if (cookies.length > 0) headers["set-cookie"] = cookies;
49
+ }
50
+ return {
51
+ status: response.status,
52
+ headers,
53
+ source: response.body ?? emptyBody()
54
+ };
55
+ }
56
+ export { webResponseParts };
@@ -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;
@@ -1,4 +1,5 @@
1
1
  import HttpResponse from "../http/Response.js";
2
+ import { closeStreamAfterGrace } from "../../servers/http2StreamGrace.js";
2
3
  import { thenMaybe } from "nodefony";
3
4
  import http from "node:http";
4
5
  import http2 from "node:http2";
@@ -10,8 +11,9 @@ const emptyHeaders = {
10
11
  getHeader: () => void 0
11
12
  };
12
13
  /**
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.
14
+ * Compose les en-têtes d'une réponse HTTP/2 : ceux qu'un tiers a écrits sur la
15
+ * réponse Node, puis ceux de la réserve de la réponse, puis ceux passés à
16
+ * `writeHead` — chacun l'emporte sur le précédent.
15
17
  *
16
18
  * Les clés sont ramenées en MINUSCULES : `getHeaders()` rend `content-type`,
17
19
  * un contrôleur passe `Content-Type` — fusionnées telles quelles, les deux
@@ -27,11 +29,13 @@ const emptyHeaders = {
27
29
  * @param statusMessage - message de statut, recopié en `x-status-message`
28
30
  * @param current - la réponse Node qui porte les en-têtes déjà posés
29
31
  * @param extra - en-têtes passés à `writeHead` (objet, ou liste plate clé/valeur)
32
+ * @param stored - la réserve de la réponse, liste plate aux noms en minuscules
30
33
  * @returns les en-têtes à transmettre à `respond()`
31
34
  */
32
- function mergeResponseHeaders(statusMessage, current, extra) {
35
+ function mergeResponseHeaders(statusMessage, current, extra, stored) {
33
36
  const merged = { "x-status-message": statusMessage };
34
37
  for (const name of current.getHeaderNames()) merged[name] = current.getHeader(name);
38
+ if (stored) for (let i = 0; i + 1 < stored.length; i += 2) merged[String(stored[i])] = stored[i + 1];
35
39
  if (Array.isArray(extra)) for (let i = 0; i + 1 < extra.length; i += 2) merged[String(extra[i]).toLowerCase()] = extra[i + 1];
36
40
  else if (extra) for (const key in extra) {
37
41
  const value = extra[key];
@@ -66,15 +70,13 @@ var Http2Response = class extends HttpResponse {
66
70
  if (this.statusCode > 599) this.statusCode = 500;
67
71
  }
68
72
  this.statusMessage = this.getStatusMessage();
73
+ this.putCorrelationHeaders();
69
74
  this.setLength();
70
75
  this.ensureContentTypeHeader();
71
- this.headers = mergeResponseHeaders(this.statusMessage, this.response ?? emptyHeaders, headers);
76
+ this.applyHeaderPolicy(this.contentTypeForPolicy());
77
+ this.headers = mergeResponseHeaders(this.statusMessage, this.response ?? emptyHeaders, headers, this.headerList());
72
78
  this.headers[HTTP2_HEADER_STATUS] = this.statusCode;
73
- const requestId = this.context.requestId;
74
- if (requestId) this.headers["x-request-id"] = requestId;
75
- const traceparent = this.context.traceparent;
76
- if (traceparent) this.headers["traceparent"] = traceparent;
77
- this.stream.respond(this.headers, { endStream: false });
79
+ this.stream.respond(this.headers, { endStream: this.headers["grpc-status"] !== void 0 });
78
80
  } else this.log("Headers already sent !!", "WARNING");
79
81
  } else return super.writeHead(statusCode, headers);
80
82
  }
@@ -126,8 +128,9 @@ var Http2Response = class extends HttpResponse {
126
128
  return super.end(chunk, encoding);
127
129
  }
128
130
  /**
129
- * Termine la réponse de compatibilité d'un HEAD, dont Node a clos le flux de
130
- * lui-même au `respond()`.
131
+ * Termine la réponse de compatibilité d'un HEAD, ou d'une réponse gRPC
132
+ * _Trailers-Only_ (statut gRPC dans les en-têtes), dont le flux s'est clos
133
+ * au `respond()`.
131
134
  *
132
135
  * Sans ce geste, `Http2ServerResponse` n'émet ni `finish` ni `close` : le
133
136
  * démontage de la requête, branché sur ce `close` (ligne d'accès, profil,
@@ -138,7 +141,7 @@ var Http2Response = class extends HttpResponse {
138
141
  */
139
142
  settleHeadResponse() {
140
143
  const raw = this.response;
141
- if (raw !== null && this.context.method === "HEAD" && this.stream?.destroyed === false && !raw.writableEnded) raw.end();
144
+ if (raw !== null && (this.context.method === "HEAD" || this.headers["grpc-status"] !== void 0) && this.stream?.destroyed === false && !raw.writableEnded) raw.end();
142
145
  }
143
146
  getStatusMessage(code) {
144
147
  if (code) {
@@ -153,5 +156,45 @@ var Http2Response = class extends HttpResponse {
153
156
  message: ""
154
157
  };
155
158
  }
159
+ /**
160
+ * Règle le sort d'un corps non lu quand la réponse part avant qu'il soit
161
+ * consommé (408 délai, 413 trop gros, refus précoce) : les octets en tampon
162
+ * sont jetés, puis — une fois la réponse écrite — le flux est fermé en
163
+ * `NO_ERROR` si le client ÉMET ENCORE.
164
+ *
165
+ * RFC 9113 §8.1 : après une réponse complète, le serveur PEUT demander au
166
+ * client d'arrêter d'émettre par un `RST_STREAM` `NO_ERROR`. `node:http2` ne
167
+ * le fait pas : le flux resterait ouvert tant que le client émet, et un
168
+ * client lent garderait sa place de flux sur la session. Attendre `finish`
169
+ * (fin d'écriture) garantit que la réponse elle-même n'est pas tronquée.
170
+ *
171
+ * « Émet encore » se décide après une GRÂCE d'un aller-retour
172
+ * ({@link closeStreamAfterGrace} : un `PING` par session pour tous les
173
+ * flux qui attendent, puis l'état du flux). Fini → le flux se ferme seul,
174
+ * sans `RST_STREAM` ; sinon — client qui émet encore, ou muet — fermé.
175
+ * Jamais de coupe avant la grâce : un `RST_STREAM` soumis pendant que la
176
+ * réponse attend dans la file de nghttp2 la fait jeter, et le client reçoit
177
+ * une fin de flux sans statut. grpc-go, Go `net/http2` et nginx coupent AUSSITÔT : Node, lui,
178
+ * répond avant de lire les trames DATA déjà en route, et la coupe
179
+ * immédiate frappait des clients qui finissaient d'émettre — le client
180
+ * HTTP/2 de Go la lit en erreur (ADR-0026 D13 point 1, constaté par la
181
+ * suite de conformité de Connect). `request.complete` ne dit rien ici : il
182
+ * ne passe à `true` qu'une fois le corps CONSOMMÉ.
183
+ *
184
+ * Chemin d'erreur seulement : un `PING` par aller-retour et par session,
185
+ * quel que soit le nombre de refus en attente.
186
+ *
187
+ * Sans effet si le corps est entièrement consommé, ou le flux déjà fermé.
188
+ * Peut s'appeler avant comme après l'écriture de la réponse.
189
+ */
190
+ closeUnfinishedRequest() {
191
+ const stream = this.stream;
192
+ if (stream === null || stream.closed || stream.destroyed) return;
193
+ const request = this.context.request.request;
194
+ if (request.complete) return;
195
+ request.resume();
196
+ if (stream.writableFinished) closeStreamAfterGrace(stream);
197
+ else stream.once("finish", () => closeStreamAfterGrace(stream));
198
+ }
156
199
  };
157
200
  export { Http2Response as default, mergeResponseHeaders };
@@ -1,3 +1,4 @@
1
+ import { rpcOutcomeOf } from "./rpcOutcome.js";
1
2
  import diagnosticsChannel from "node:diagnostics_channel";
2
3
  // nodefony/src/context/requestTrace.ts
3
4
  /**
@@ -46,7 +47,8 @@ function openRequestTrace(context, protocol) {
46
47
  module: null,
47
48
  statusCode: null,
48
49
  durationMs: null,
49
- error: null
50
+ error: null,
51
+ rpc: null
50
52
  };
51
53
  context.requestTrace = trace;
52
54
  return trace;
@@ -82,8 +84,9 @@ function runTraced(trace, fn, thisArg, ...args) {
82
84
  * @param context - le contexte de la requête
83
85
  * @param statusCode - le statut rendu au client, `null` s'il n'y en a pas eu
84
86
  * @param error - l'exception attrapée par le pipeline, `null` ou `undefined` sinon
87
+ * @param rpc - l'appel RPC de la requête, `null` hors RPC : copié, jamais publié tel quel
85
88
  */
86
- function closeRequestTrace(context, statusCode, error) {
89
+ function closeRequestTrace(context, statusCode, error, rpc = null) {
87
90
  const trace = context.requestTrace;
88
91
  if (trace === null) return;
89
92
  context.requestTrace = null;
@@ -93,6 +96,7 @@ function closeRequestTrace(context, statusCode, error) {
93
96
  trace.routePath = path === void 0 ? null : path === "" ? "/" : path;
94
97
  trace.module = context.logModule ?? route?.module?.name ?? null;
95
98
  trace.statusCode = statusCode;
99
+ trace.rpc = rpcOutcomeOf(rpc);
96
100
  trace.durationMs = context.stopClock();
97
101
  if (error !== null && error !== void 0) {
98
102
  trace.error = error;
@@ -0,0 +1,20 @@
1
+ // nodefony/src/context/rpcOutcome.ts
2
+ /**
3
+ * Copie le résultat d'un appel RPC — les quatre champs, jamais la fiche
4
+ * d'appel entière, qui porte le contrôleur et le contexte (ADR-0026 D10). La
5
+ * forme est fixe : un objet par appel, alloué seulement par qui publie (audit
6
+ * JSON, canal de traçage avec abonné).
7
+ *
8
+ * @param call - l'appel RPC de la requête, `null` hors RPC.
9
+ * @returns sa copie, `null` hors RPC.
10
+ */
11
+ function rpcOutcomeOf(call) {
12
+ if (call === null) return null;
13
+ return {
14
+ system: call.system,
15
+ method: call.method,
16
+ code: call.code,
17
+ codeName: call.codeName
18
+ };
19
+ }
20
+ export { rpcOutcomeOf };
@@ -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 };
@@ -194,13 +194,23 @@ var WebsocketResponse = class {
194
194
  handshakeHeaders = null;
195
195
  /**
196
196
  * Retient un en-tête du handshake (cf {@link handshakeHeaders}) ; sans effet
197
- * une fois la socket ouverte, où il n'y a plus de réponse HTTP.
197
+ * une fois la socket ouverte, où il n'y a plus de réponse HTTP — sauf
198
+ * `Retry-After` posé par une action invoquée par le pont `api.request` : il
199
+ * va au puits de CETTE invocation, et le pont le sert avec le refus. Le même
200
+ * code de contrôleur dit donc « réessaie dans N s » sur les deux transports.
201
+ * Seul cet en-tête passe : un `Set-Cookie` ou un `WWW-Authenticate` n'a
202
+ * aucun sens sur une trame, et rien d'autre ne doit fuir par là.
198
203
  *
199
204
  * @param name - nom de l'en-tête
200
205
  * @param value - valeur
201
206
  * @returns toujours `true`
202
207
  */
203
208
  setHeader(name, value) {
209
+ const sink = RequestContext.get()?.renderSink;
210
+ if (sink) {
211
+ if (name.toLowerCase() === "retry-after" && typeof value !== "object") sink.retryAfter = String(value);
212
+ return true;
213
+ }
204
214
  if (this.connection !== null) return true;
205
215
  this.handshakeHeaders ??= {};
206
216
  this.handshakeHeaders[name] = typeof value === "object" ? value.join(", ") : value;
@@ -215,8 +225,10 @@ var WebsocketResponse = class {
215
225
  return true;
216
226
  }
217
227
  addCookie(cookie) {
218
- if (cookie instanceof Cookie) this.cookies[cookie.name] = cookie;
219
- else throw new Error("Response addCookies not valid cookies");
228
+ if (cookie instanceof Cookie) {
229
+ cookie.assertGrammar();
230
+ this.cookies[cookie.name] = cookie;
231
+ } else throw new Error("Response addCookies not valid cookies");
220
232
  }
221
233
  setCookies() {}
222
234
  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
  }
@@ -106,15 +107,27 @@ function handshakeRefusalStatus(context, error) {
106
107
  * le gate court-circuite AVANT toute allocation/concat (0 surcoût hot path).
107
108
  */
108
109
  let wsContentLogging = null;
109
- var WebsocketContext = class extends Context {
110
+ var WebsocketContext = class WebsocketContext 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
  }
@@ -284,7 +297,7 @@ var WebsocketContext = class extends Context {
284
297
  }
285
298
  async send(data, encoding) {
286
299
  if (this.response) {
287
- const payload = data ?? this.response.body;
300
+ const payload = data ?? WebsocketContext.takeRenderedBody(this.response);
288
301
  const sink = RequestContext.get()?.renderSink;
289
302
  if (sink) {
290
303
  sink.body = payload;
@@ -297,9 +310,21 @@ var WebsocketContext = class extends Context {
297
310
  }
298
311
  throw new Error("No response found");
299
312
  }
313
+ /**
314
+ * Rend le corps rendu (`setBody`) en le RETIRANT de la réponse : une trame
315
+ * envoyée n'a plus à vivre aussi longtemps que la connexion.
316
+ *
317
+ * @param response - la réponse de ce contexte
318
+ * @returns le corps rendu, ou `null` s'il n'y en a pas
319
+ */
320
+ static takeRenderedBody(response) {
321
+ const body = response.body;
322
+ response.body = null;
323
+ return body;
324
+ }
300
325
  broadcast(data, encoding) {
301
326
  if (this.response) {
302
- const payload = data ?? this.response.body;
327
+ const payload = data ?? WebsocketContext.takeRenderedBody(this.response);
303
328
  if (payload) {
304
329
  this.logMessageContent("BROADCAST", payload);
305
330
  this.fire("onMessage", payload, this, "BROADCAST");
@@ -311,6 +336,28 @@ var WebsocketContext = class extends Context {
311
336
  return null;
312
337
  }
313
338
  /**
339
+ * Le contenu des messages est-il journalisé ? Hors production seulement —
340
+ * décidé une fois pour le processus (P8 : runtime ∈ {development, production},
341
+ * `resolveRuntimeEnv`).
342
+ */
343
+ tracesMessageContent() {
344
+ wsContentLogging ??= this.kernel?.environment !== "production";
345
+ return wsContentLogging;
346
+ }
347
+ /**
348
+ * Traceur des trames SORTANTES écrites hors de {@link send} — le temps réel
349
+ * écrit droit sur la socket. À demander UNE fois par connexion : `null` en
350
+ * production, donc l'appelant choisit son chemin d'écriture à l'ouverture et
351
+ * ne paie rien par trame.
352
+ *
353
+ * @returns une fonction qui journalise une trame comme `send` (`WS SEND`),
354
+ * ou `null` quand le contenu des messages n'est pas journalisé.
355
+ */
356
+ outboundTracer() {
357
+ if (!this.tracesMessageContent()) return null;
358
+ return (data) => this.logMessageContent("SEND", data);
359
+ }
360
+ /**
314
361
  * Logge le CONTENU d'un message WS (corrélé `requestId` via l'override `log`),
315
362
  * gaté hors prod et borné — le Suivi de requête (Studio) le surface alors par
316
363
  * direction. Hot path : le gate booléen court-circuite en prod AVANT toute
@@ -320,9 +367,8 @@ var WebsocketContext = class extends Context {
320
367
  * @param data - charge utile (string, Buffer binaire, objet, ou null).
321
368
  */
322
369
  logMessageContent(dir, data) {
323
- wsContentLogging ??= this.kernel?.environment !== "production";
324
- if (!wsContentLogging) return;
325
- this.log(formatWsLogContent(data), "DEBUG", `WS ${dir}`);
370
+ if (!this.tracesMessageContent()) return;
371
+ this.log(formatWsLogContent(data, WS_LOG_CONTENT_CAP, this.frameCodec), "DEBUG", `WS ${dir}`);
326
372
  }
327
373
  /**
328
374
  * Ouvre le profil d'**une invocation** du pont RPC (une frame `api.request`).
@@ -390,11 +436,33 @@ var WebsocketContext = class extends Context {
390
436
  }
391
437
  super.phaseEnd(name);
392
438
  }
439
+ /**
440
+ * Applique la borne de débit entrante à une trame : `true` = l'admettre.
441
+ *
442
+ * Écartée → comptée (`frameRateRejectedTotal`) ; solde de refus atteint →
443
+ * connexion fermée en `1008` (RFC 6455 §7.4.1, violation de politique).
444
+ *
445
+ * @param bound - trames admises par seconde (`> 0`)
446
+ * @returns `true` si la trame poursuit son chemin
447
+ */
448
+ admitInboundFrame(bound) {
449
+ const now = Date.now();
450
+ this.frameRate ??= {
451
+ start: now,
452
+ count: 0,
453
+ balance: 0
454
+ };
455
+ const verdict = admitFrame(this.frameRate, now, bound, readFrameRateCloseAfter(this.server));
456
+ if (verdict === "send") return true;
457
+ if (verdict === "close") this.connection?.close(1008, "frame rate");
458
+ return false;
459
+ }
393
460
  async handleMessage(data, isBinary) {
461
+ const bound = readFrameRateBound(this.server);
462
+ if (bound > 0 && !this.admitInboundFrame(bound)) return void 0;
394
463
  this.webSocketState = "message";
395
464
  const message = isBinary ? data : data.toString();
396
465
  this.logMessageContent("RECEIVE", message);
397
- if (this.response) this.response.body = Buffer.isBuffer(data) ? data : Buffer.from(data);
398
466
  try {
399
467
  if (!this.resolver) this.resolver = this.router?.resolve(this) ?? null;
400
468
  else this.resolver.match(this.resolver.route, this);
@@ -406,7 +474,7 @@ var WebsocketContext = class extends Context {
406
474
  } } });
407
475
  return callAsPromise(this.resolver, [message]);
408
476
  } else if (!this.rejected) {
409
- this.reject(4004, "Not Found");
477
+ this.reject(NOT_FOUND_CLOSE_CODE, "Not Found");
410
478
  this.rejected = true;
411
479
  }
412
480
  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 };