@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
@@ -1,31 +1,41 @@
1
1
  import HttpError from "../src/errors/httpError.js";
2
+ import { isUnsupportedTransferCoding } from "../src/proxy/rules.js";
3
+ import { hasConflictingAuthority } from "../src/context/transport.js";
2
4
  import { buildTrustProxy } from "../src/context/trustProxy.js";
3
5
  import { resolveForwarded } from "../src/context/forwarded.js";
4
6
  import { MemoryRateLimitStore } from "../src/rateLimit/MemoryRateLimitStore.js";
5
7
  import { WsConnectionCounter } from "../src/rateLimit/WsConnectionCounter.js";
6
8
  import { compileDomainPatterns, compileTrustedHosts, isDomainAllowed } from "../src/context/domainMatcher.js";
9
+ import { isProfiledUrl } from "../src/profiler/aggregate.js";
7
10
  import { responseEnded } from "../src/context/responseEnded.js";
8
- import { parseWebsocketUrl } from "../src/context/websocket/websocketUrl.js";
11
+ import { unsentResponseWarning } from "../src/context/unsentResponse.js";
12
+ import { closeRequestTrace, hasRequestTraceSubscribers, openRequestTrace, runTraced } from "../src/context/requestTrace.js";
9
13
  import WebsocketContext, { toHandshakeStatus } from "../src/context/websocket/WebsocketContext.js";
10
14
  import { trackPong } from "./servers/wsHeartbeat.js";
15
+ import { parseWebsocketUrl } from "../src/context/websocket/websocketUrl.js";
11
16
  import HttpContext from "../src/context/http/HttpContext.js";
12
17
  import DefaultErrorRenderer from "./error-renderer.js";
13
- import JsonAuditLogger from "./audit-logger.js";
14
18
  import DefaultRequestLogger from "./request-logger.js";
15
19
  import PrettyRequestLogger from "./pretty-request-logger.js";
20
+ import JsonAuditLogger from "./audit-logger.js";
16
21
  import { resolveTraceparent } from "./trace.js";
17
22
  import { asServersConfig, configuredServerPort } from "../src/servers/kernelServers.js";
18
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
19
- import { GcScheduler, IDENTITY_CHANGED_EVENT, RequestContext, RevocationWatch, Service, finallyMaybe, injectable, isPromise, logColor, nodefonyError, servedUrl, thenMaybe, writeRuntimeState } from "nodefony";
23
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
24
+ import { BootConfigurationError, GcScheduler, IDENTITY_CHANGED_EVENT, RequestContext, RevocationWatch, Service, finallyMaybe, injectable, isPromise, logColor, nodefonyError, servedUrl, thenMaybe, writeRuntimeState } from "nodefony";
20
25
  import cluster from "node:cluster";
21
26
  import http from "node:http";
22
27
  // nodefony/service/http-kernel.ts
23
- const HEALTH_HEADERS = {
24
- "content-type": "application/json",
25
- "cache-control": "no-store"
26
- };
28
+ const HEALTH_HEADERS = [
29
+ "content-type",
30
+ "application/json",
31
+ "cache-control",
32
+ "no-store"
33
+ ];
27
34
  const HEALTH_BODY_OK = "{\"status\":\"ok\"}";
28
35
  const HEALTH_BODY_UNREADY = "{\"status\":\"unready\"}";
36
+ /** Corps du 429 du limiteur de débit — figé, rien à sérialiser par refus. */
37
+ const TOO_MANY_REQUESTS_BODY = "{\"code\":429,\"message\":\"Too Many Requests\"}";
38
+ const TOO_MANY_REQUESTS_LENGTH = Buffer.byteLength(TOO_MANY_REQUESTS_BODY);
29
39
  const perfProbe = process.env.NF_PERF_PROBE === "1" ? {
30
40
  count: 0,
31
41
  enterScopeNs: 0,
@@ -104,12 +114,28 @@ let HttpKernel = class HttpKernel extends Service {
104
114
  secContentTypeOptions = null;
105
115
  secFrameOptions = null;
106
116
  secHsts = null;
117
+ /**
118
+ * En-têtes du transport, tableau plat `[nom, valeur, …]` aux noms en
119
+ * minuscules, par famille de serveur (HSTS sur TLS seulement). Figés et
120
+ * VALIDÉS au calcul : chaque réponse les recopie sans les revalider.
121
+ */
122
+ #transportHeaders = {
123
+ http: [],
124
+ https: []
125
+ };
126
+ /** Réponse d'une sonde de santé : le transport, puis type et cache. */
127
+ #healthHeaders = {
128
+ http: [],
129
+ https: []
130
+ };
107
131
  sessionService;
108
132
  router;
109
133
  firewall;
110
134
  errorRenderer = new DefaultErrorRenderer();
111
135
  requestLogger = new DefaultRequestLogger();
112
136
  profiler = null;
137
+ /** `timing.tooling` : profiler aussi l'outillage (lu une fois, à onReady). */
138
+ profileTooling = false;
113
139
  _trustProxyChecker = null;
114
140
  _wsOriginPolicy = Object.create(null);
115
141
  rateLimiter = null;
@@ -152,12 +178,49 @@ let HttpKernel = class HttpKernel extends Service {
152
178
  if (hsts.preload) v += "; preload";
153
179
  this.secHsts = v;
154
180
  } else this.secHsts = null;
181
+ const headerServer = this.options.headerServer;
182
+ const http1 = [];
183
+ if (headerServer != null && headerServer !== "") http1.push("server", headerServer);
184
+ if (this.secContentTypeOptions !== null) http1.push("x-content-type-options", this.secContentTypeOptions);
185
+ if (this.secFrameOptions !== null) http1.push("x-frame-options", this.secFrameOptions);
186
+ const tls = this.secHsts === null ? http1 : [
187
+ ...http1,
188
+ "strict-transport-security",
189
+ this.secHsts
190
+ ];
191
+ for (let i = 0; i + 1 < tls.length; i += 2) {
192
+ const name = tls[i] ?? "";
193
+ try {
194
+ http.validateHeaderValue(name, tls[i + 1] ?? "");
195
+ } catch (error) {
196
+ throw new BootConfigurationError(`[@nodefony/http] en-tête « ${name} » refusé : ${error instanceof Error ? error.message : String(error)}`);
197
+ }
198
+ }
199
+ this.#transportHeaders = {
200
+ http: http1,
201
+ https: tls
202
+ };
203
+ this.#healthHeaders = {
204
+ http: [...http1, ...HEALTH_HEADERS],
205
+ https: [...tls, ...HEALTH_HEADERS]
206
+ };
207
+ }
208
+ /**
209
+ * En-têtes du transport d'une réponse, figés au démarrage — ce que
210
+ * `HttpResponse` place en tête de sa réserve.
211
+ *
212
+ * @param type - famille du serveur qui a reçu la requête.
213
+ * @returns la liste plate `[nom, valeur, …]`, noms en minuscules.
214
+ */
215
+ transportHeaders(type) {
216
+ return type === "https" ? this.#transportHeaders.https : this.#transportHeaders.http;
155
217
  }
156
218
  /**
157
219
  * Ce que ce kernel ÉMET réellement comme en-têtes de sécurité transport.
158
220
  *
159
221
  * Ces trois en-têtes (HSTS, X-Frame-Options, X-Content-Type-Options) sont posés
160
- * ici, à l'entrée brute, pour couvrir aussi les statiques et les pages d'erreur
222
+ * par `@nodefony/http` sur TOUTE réponse (en tête de la réserve de `HttpResponse`),
223
+ * statiques et pages d'erreur comprises
161
224
  * — pas par `@nodefony/security`, dont les clés homonymes sont inertes. Toute
162
225
  * console d'administration doit donc lire CE point, sinon elle affiche une
163
226
  * intention de configuration au lieu de l'état appliqué.
@@ -184,6 +247,7 @@ let HttpKernel = class HttpKernel extends Service {
184
247
  */
185
248
  onConfigChanged() {
186
249
  this.computeSecurityHeaderCaches();
250
+ this.firewall?.onTransportHeadersChanged?.();
187
251
  this._trustProxyChecker = null;
188
252
  this._wsOriginPolicy = Object.create(null);
189
253
  this.trustedHosts = this.options.trustedHosts;
@@ -207,6 +271,36 @@ let HttpKernel = class HttpKernel extends Service {
207
271
  get rateLimitStore() {
208
272
  return this.rateLimiter;
209
273
  }
274
+ /**
275
+ * Cette requête — ou cette frame du pont — se profile-t-elle ? Jamais sans
276
+ * profileur (production). L'outillage (console, profileur, journaux, Vite :
277
+ * `isProfiledUrl`, sur la règle `isConsolePath` du cœur) seulement sous
278
+ * `timing.tooling` — on ne
279
+ * mesure pas la façon dont on mesure : ni tampon SQL, ni instantané, ni place
280
+ * dans les anneaux du profileur.
281
+ *
282
+ * @param url - URL ou chemin de la requête
283
+ * @returns `true` quand le profil doit être pris
284
+ */
285
+ shouldProfile(url) {
286
+ return this.profiler !== null && isProfiledUrl(url, this.profileTooling);
287
+ }
288
+ /**
289
+ * En développement, signale une action qui a rendu une valeur que le
290
+ * Resolver ne sait pas envoyer (`null`, `undefined`, instance de classe)
291
+ * et n'a rien envoyé elle-même : la requête attend son délai d'expiration.
292
+ * Le message nomme l'action, ce qu'elle a rendu et le geste qui convient
293
+ * (`unsentResponseWarning`).
294
+ *
295
+ * Appelé au timeout, AVANT que le 504/408 parte — après, `sended` est vrai et
296
+ * plus rien ne se signalerait — et au démontage, pour un client parti avant.
297
+ * Gratuit en production : rien n'est lu au-delà de `waitAsync`.
298
+ *
299
+ * @param context - le contexte HTTP de la requête
300
+ */
301
+ warnUnsentResponse(context) {
302
+ if (context.waitAsync && !context.sended && this.kernel?.environment === "development") this.log(unsentResponseWarning(context.resolver, context.url), "WARNING");
303
+ }
210
304
  configureRateLimit() {
211
305
  this.rateLimitGc?.stop();
212
306
  this.rateLimitGc = null;
@@ -261,9 +355,9 @@ let HttpKernel = class HttpKernel extends Service {
261
355
  * @param response - réponse serveur brute (HTTP/1 ou H2 compat)
262
356
  * @param ok - `true` → 200, `false` → 503 (readiness pendant boot/shutdown)
263
357
  */
264
- #respondHealth(response, ok) {
358
+ #respondHealth(response, ok, type) {
265
359
  const res = response;
266
- res.writeHead(ok ? 200 : 503, HEALTH_HEADERS);
360
+ res.writeHead(ok ? 200 : 503, type === "https" ? this.#healthHeaders.https : this.#healthHeaders.http);
267
361
  res.end(ok ? HEALTH_BODY_OK : HEALTH_BODY_UNREADY);
268
362
  }
269
363
  async init() {
@@ -277,6 +371,8 @@ let HttpKernel = class HttpKernel extends Service {
277
371
  this.regAlias = this.compileAlias();
278
372
  this.sessionService = this.get("sessions");
279
373
  this.profiler = this.get("profiler");
374
+ const timing = this.kernel?.options.timing;
375
+ this.profileTooling = typeof timing === "object" && timing !== null && "tooling" in timing && timing.tooling === true;
280
376
  this.configureRateLimit();
281
377
  this.configureWsConnectionLimit();
282
378
  this.configureHealth();
@@ -571,7 +667,7 @@ let HttpKernel = class HttpKernel extends Service {
571
667
  if (!context.response.isHeaderSent()) {
572
668
  if (context.isRedirect) {
573
669
  context.isRedirect = false;
574
- context.response.response?.removeHeader("Location");
670
+ context.response.removeHeader("Location");
575
671
  }
576
672
  try {
577
673
  await context.render(result.body);
@@ -641,19 +737,14 @@ let HttpKernel = class HttpKernel extends Service {
641
737
  * pipeline.
642
738
  */
643
739
  dispatchHttpRequest(request, response, type) {
644
- const headerServer = this.options.headerServer;
645
- if (headerServer != null && headerServer !== "") response.setHeader("Server", headerServer);
646
- if (this.secContentTypeOptions !== null) response.setHeader("X-Content-Type-Options", this.secContentTypeOptions);
647
- if (this.secFrameOptions !== null) response.setHeader("X-Frame-Options", this.secFrameOptions);
648
- if (this.secHsts !== null && type === "https") response.setHeader("Strict-Transport-Security", this.secHsts);
649
740
  if (this.#healthPaths !== null) {
650
741
  const url = request.url;
651
742
  if (url === this.#healthPaths.liveness) {
652
- this.#respondHealth(response, true);
743
+ this.#respondHealth(response, true, type);
653
744
  return;
654
745
  }
655
746
  if (url === this.#healthPaths.readiness) {
656
- this.#respondHealth(response, !this.#terminating && this.kernel?.servable === true);
747
+ this.#respondHealth(response, !this.#terminating && this.kernel?.servable === true, type);
657
748
  return;
658
749
  }
659
750
  }
@@ -667,13 +758,19 @@ let HttpKernel = class HttpKernel extends Service {
667
758
  if (verdict.limited) {
668
759
  response.setHeader("Retry-After", verdict.retryAfterS);
669
760
  const res = response;
670
- res.writeHead(429);
671
- res.end();
761
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
762
+ response.setHeader("Content-Length", TOO_MANY_REQUESTS_LENGTH);
763
+ res.writeHead(429, type === "https" ? this.#transportHeaders.https : this.#transportHeaders.http);
764
+ res.end(TOO_MANY_REQUESTS_BODY);
672
765
  return;
673
766
  }
674
767
  }
675
768
  }
676
- if (this.listenerCount("onServerRequest")) return this.fireAsync("onServerRequest", request, response, type).then(() => this.handle(request, response, type));
769
+ if (this.listenerCount("onServerRequest")) {
770
+ const transport = type === "https" ? this.#transportHeaders.https : this.#transportHeaders.http;
771
+ for (let i = 0; i + 1 < transport.length; i += 2) response.setHeader(transport[i] ?? "", transport[i + 1] ?? "");
772
+ return this.fireAsync("onServerRequest", request, response, type).then(() => this.handle(request, response, type));
773
+ }
677
774
  return this.handle(request, response, type);
678
775
  }
679
776
  async initServers() {
@@ -816,10 +913,12 @@ let HttpKernel = class HttpKernel extends Service {
816
913
  teardownHttp(context, scope) {
817
914
  if (context.finished) return void 0;
818
915
  try {
819
- if (context.waitAsync && !context.sended && this.kernel?.environment === "development") this.log(`Action "${context.resolver?.route?.name ?? context.url}" returned a non-renderable value (void/null/class instance) and never sent a response. Use 'return <object|string|number|boolean|Buffer>' (auto-JSON) or send/stream manually.`, "WARNING");
916
+ context.stopClock();
917
+ if (context.waitAsync) this.warnUnsentResponse(context);
820
918
  context.logRequest();
919
+ if (context.requestTrace !== null) closeRequestTrace(context, context.response.statusCode, context.error);
821
920
  context.logPhasesVerbose();
822
- this.profiler?.collect(context);
921
+ if (this.shouldProfile(context.url)) this.profiler?.collect(context);
823
922
  const afterResponse = context._runAfterResponse();
824
923
  if (afterResponse !== void 0 || context.listenerCount("onFinish")) return this.finishTeardownHttp(context, scope, afterResponse);
825
924
  this.releaseHttpContext(context, scope);
@@ -903,20 +1002,34 @@ let HttpKernel = class HttpKernel extends Service {
903
1002
  try {
904
1003
  context = this.createHttpContext(scope, request, response, type);
905
1004
  const created = context;
906
- const served = this.listenerCount("onCreateContext") ? this.fireAsync("onCreateContext", created).then(() => this.serveHttpContext(created, scope, request, response)) : this.serveHttpContext(created, scope, request, response);
1005
+ const served = hasRequestTraceSubscribers() ? runTraced(openRequestTrace(created, "http"), this.dispatchHttpContext, this, created, scope, request, response) : this.dispatchHttpContext(created, scope, request, response);
907
1006
  if (!isPromise(served)) return served;
908
1007
  return served.catch((e) => this.failHttp(e, created, scope));
909
1008
  } catch (e) {
910
- return this.failHttp(e, context, scope);
1009
+ return this.failHttp(e, context, scope, response);
911
1010
  }
912
1011
  }
913
1012
  /**
1013
+ * Sert un contexte HTTP fraîchement créé : le crochet `onCreateContext`
1014
+ * s'il a des écouteurs, puis le pipeline.
1015
+ *
1016
+ * Hot path : `fireAsync` est une fonction async → 1 Promise + 1 microtask
1017
+ * MÊME à 0 listener. En prod sans @nodefony/security ces seams
1018
+ * (onCreateContext/beforeResolve/afterAuth) n'ont aucun listener → garde
1019
+ * `listenerCount` (O(1), 0 alloc) pour ne RIEN planifier.
1020
+ *
1021
+ * @returns le contexte servi, ou sa promesse
1022
+ */
1023
+ dispatchHttpContext(context, scope, request, response) {
1024
+ return this.listenerCount("onCreateContext") ? this.fireAsync("onCreateContext", context).then(() => this.serveHttpContext(context, scope, request, response)) : this.serveHttpContext(context, scope, request, response);
1025
+ }
1026
+ /**
914
1027
  * Traceparent, tampon du profileur, puis le pipeline dans la bulle ALS de la
915
1028
  * requête (`RequestContext.run`).
916
1029
  */
917
1030
  serveHttpContext(context, scope, request, response) {
918
1031
  context.traceparent = resolveTraceparent(request.headers.traceparent);
919
- const profilerQueries = this.profiler ? [] : null;
1032
+ const profilerQueries = this.shouldProfile(context.url) ? [] : null;
920
1033
  context.profilerQueries = profilerQueries;
921
1034
  context.profiling = profilerQueries !== null;
922
1035
  const store = {
@@ -933,9 +1046,19 @@ let HttpKernel = class HttpKernel extends Service {
933
1046
  /**
934
1047
  * Chemin d'erreur de {@link handleHttp} : libère le scope d'un contexte qui
935
1048
  * n'a pas pu se construire, puis rend l'erreur au client (`onError`).
1049
+ *
1050
+ * Sans contexte, `onError` n'a rien sur quoi écrire : la réponse BRUTE
1051
+ * reçoit alors un 500 nu. Sinon HTTP/1.1 coupait la connexion sans un octet
1052
+ * et HTTP/2 laissait le flux pendu.
936
1053
  */
937
- failHttp(e, context, scope) {
938
- if (context === null) this.container?.leaveScope(scope);
1054
+ failHttp(e, context, scope, response) {
1055
+ if (context === null) {
1056
+ this.container?.leaveScope(scope);
1057
+ if (response && !response.headersSent) {
1058
+ response.statusCode = 500;
1059
+ response.end();
1060
+ }
1061
+ }
939
1062
  return this.onError(e, context);
940
1063
  }
941
1064
  /**
@@ -967,16 +1090,24 @@ let HttpKernel = class HttpKernel extends Service {
967
1090
  */
968
1091
  routeHttpRequest(httpContext, request, response) {
969
1092
  if (this.reverseProxy !== null && this.reverseProxy.mounts !== null && (httpContext.validDomain || !this.domainCheck)) {
1093
+ if (this.reverseProxy.match(request.url) !== void 0) httpContext.response.commitHeaders();
970
1094
  const relayed = this.reverseProxy.forward(request, response, httpContext.scheme);
971
- if (relayed !== void 0) return relayed.then(() => httpContext);
1095
+ if (relayed !== void 0) {
1096
+ httpContext.relayAccessLog = this.reverseProxy.match(request.url)?.accessLog ?? "all";
1097
+ return relayed.then(() => httpContext);
1098
+ }
972
1099
  }
973
1100
  httpContext.phaseStart("resolve");
974
1101
  httpContext.resolver = this.router ? this.router.resolve(httpContext) : null;
975
1102
  httpContext.phaseEnd("resolve");
976
- this.firewall?.applySecurityHeaders(httpContext);
1103
+ if (this.firewall) httpContext.response.headerPolicy = this.firewall;
977
1104
  if (this.serverStatic && httpContext.resolver?.resolve !== true && !httpContext.resolver?.exception && (asServersConfig(this.kernel?.options.servers)?.statics || this.kernel?.options.statics || this.serverStatic.hasMounts())) {
978
- response.removeHeader("Content-Type");
979
- return this.serverStatic.handle(request, response).catch(() => void 0).then(() => this.serveHttpRequest(httpContext));
1105
+ httpContext.response.removeHeader("Content-Type");
1106
+ httpContext.response.commitBeforeNativeWrite();
1107
+ return this.serverStatic.handle(request, response).catch(() => void 0).then(() => {
1108
+ httpContext.response.disarmNativeWrite();
1109
+ return this.serveHttpRequest(httpContext);
1110
+ });
980
1111
  }
981
1112
  return this.serveHttpRequest(httpContext);
982
1113
  }
@@ -985,6 +1116,9 @@ let HttpKernel = class HttpKernel extends Service {
985
1116
  * ({@link onRequestEnd}) et l'action. Chacun n'est attendu que s'il attend.
986
1117
  */
987
1118
  serveHttpRequest(httpContext) {
1119
+ const headers = httpContext.request.headers;
1120
+ if (isUnsupportedTransferCoding(headers["transfer-encoding"])) return Promise.reject(new HttpError("Transfer-Encoding not implemented", 501, httpContext));
1121
+ if (hasConflictingAuthority(headers)) return Promise.reject(new HttpError("Host differs from :authority", 400, httpContext));
988
1122
  const streamBody = httpContext.resolver?.resolve === true && httpContext.resolver.route?.bodyStream === true;
989
1123
  httpContext.phaseStart("parse");
990
1124
  const parsed = finallyMaybe(() => streamBody ? void 0 : httpContext.request.initialize(), () => httpContext.phaseEnd("parse"));
@@ -1042,8 +1176,8 @@ let HttpKernel = class HttpKernel extends Service {
1042
1176
  if (this.listenerCount("afterAuth")) await this.fireAsync("afterAuth", context);
1043
1177
  } catch (authError) {
1044
1178
  if (context.resolver?.exception) {
1045
- context.response.response?.removeHeader("Upgrade");
1046
- context.response.response?.removeHeader("Connection");
1179
+ context.response.removeHeader("Upgrade");
1180
+ context.response.removeHeader("Connection");
1047
1181
  }
1048
1182
  await this.fireAsync("onAuthFailure", context, authError).catch((e) => this.log(e, "ERROR", "onAuthFailure"));
1049
1183
  throw authError;
@@ -1146,7 +1280,7 @@ let HttpKernel = class HttpKernel extends Service {
1146
1280
  scope
1147
1281
  };
1148
1282
  context?.holdRequestStore(store);
1149
- return await RequestContext.run(store, async () => {
1283
+ const pipeline = async () => {
1150
1284
  await this.onConnect(context, error);
1151
1285
  if (this.firewall && context?.secure) {
1152
1286
  context.phaseStart("firewall");
@@ -1168,6 +1302,7 @@ let HttpKernel = class HttpKernel extends Service {
1168
1302
  const accepted = admission.accept();
1169
1303
  const ws = accepted instanceof Promise ? await accepted : accepted;
1170
1304
  if (ws === null) {
1305
+ if (context.requestTrace !== null) closeRequestTrace(context, null, null);
1171
1306
  this.releaseOrphanWsScope(scope, context);
1172
1307
  return;
1173
1308
  }
@@ -1180,20 +1315,36 @@ let HttpKernel = class HttpKernel extends Service {
1180
1315
  } finally {
1181
1316
  context.phaseEnd("action");
1182
1317
  }
1183
- });
1318
+ };
1319
+ return await (context !== null && hasRequestTraceSubscribers() ? runTraced(openRequestTrace(context, "websocket"), RequestContext.run, RequestContext, store, pipeline) : RequestContext.run(store, pipeline));
1184
1320
  } catch (e) {
1185
1321
  try {
1186
1322
  await this.onError(e, context);
1187
1323
  } catch (errorHandlingError) {
1188
1324
  admission.refuse(500);
1325
+ this.closeFailedWebsocketTrace(context, e);
1189
1326
  this.releaseOrphanWsScope(scope, context);
1190
1327
  throw errorHandlingError;
1191
1328
  }
1192
1329
  admission.refuse(500);
1330
+ this.closeFailedWebsocketTrace(context, e);
1193
1331
  this.releaseOrphanWsScope(scope, context);
1194
1332
  throw e;
1195
1333
  }
1196
1334
  }
1335
+ /**
1336
+ * Fin du canal `nodefony.http.request` pour un handshake interrompu par une
1337
+ * exception — sans effet si un refus l'a déjà publiée (`refuseHandshake`).
1338
+ * Le statut est un fait, pas une déduction : une socket rattachée dit que
1339
+ * le `101` est parti ; sinon c'est le `500` du filet qui a été rendu.
1340
+ *
1341
+ * @param context - le contexte du handshake, `null` s'il n'a pu être construit
1342
+ * @param error - l'exception qui a interrompu le pipeline
1343
+ */
1344
+ closeFailedWebsocketTrace(context, error) {
1345
+ if (!context?.requestTrace) return;
1346
+ closeRequestTrace(context, context.connection !== null ? 101 : 500, error);
1347
+ }
1197
1348
  releaseOrphanWsScope(scope, context) {
1198
1349
  if (!context) {
1199
1350
  this.container?.leaveScope(scope);
@@ -1351,12 +1502,12 @@ let HttpKernel = class HttpKernel extends Service {
1351
1502
  return true;
1352
1503
  }
1353
1504
  /**
1354
- * Inscrit un flux SSE à identité révocable : il sera fermé dans la fenêtre de
1505
+ * Inscrit un flux (événements ou octets) à identité révocable : il sera fermé dans la fenêtre de
1355
1506
  * re-validation qui suit la mort de son identité (déconnexion, session
1356
1507
  * révoquée, jeton expiré ou annulé) — le même registre et la même règle que
1357
1508
  * les sockets WebSocket.
1358
1509
  *
1359
- * @internal — appelé par le flux lui-même.
1510
+ * @internal — appelé par le cycle de vie du flux (`StreamLifecycle`).
1360
1511
  */
1361
1512
  watchStream(stream) {
1362
1513
  (this.#streams ??= new RevocationWatch({
@@ -1370,7 +1521,7 @@ let HttpKernel = class HttpKernel extends Service {
1370
1521
  this.#streams?.unregister(stream);
1371
1522
  }
1372
1523
  /**
1373
- * Re-valide tous les flux SSE et ferme ceux dont l'identité est morte.
1524
+ * Re-valide tous les flux en cours et ferme ceux dont l'identité est morte.
1374
1525
  * Publique pour qu'un banc la déclenche sans attendre le tick.
1375
1526
  */
1376
1527
  revalidateStreams() {
@@ -1,6 +1,6 @@
1
+ import { severityFromStatus, statusOf } from "./audit-logger.js";
1
2
  import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
2
- import { computeDurationMs, severityFromStatus } from "./audit-logger.js";
3
- import { logColor } from "nodefony";
3
+ import { accessStatusFamily, logColor } from "nodefony";
4
4
  // nodefony/service/pretty-request-logger.ts
5
5
  /**
6
6
  * Pretty single-line logger for dev (P3.2) — the biggest gain for humans.
@@ -24,9 +24,9 @@ var PrettyRequestLogger = class {
24
24
  renderHttp(context, error) {
25
25
  const ctx = context;
26
26
  const err = error ?? ctx.error ?? null;
27
- const status = ctx.response?.statusCode ?? (err ? 500 : null);
27
+ const status = statusOf(ctx) ?? (err ? 500 : null);
28
28
  const method = ctx.method ?? "?";
29
- const duration = computeDurationMs(ctx.phases);
29
+ const duration = ctx.stopClock();
30
30
  return {
31
31
  text: `${logColor.cyan(method.padEnd(4))} ${colorizeStatus(status)} ${ctx.url} ${logColor.blackBright(formatDuration(duration))} ${logColor.blackBright(ctx.remoteAddress ?? "-")} ` + logColor.magenta(`[${shortId(ctx.requestId)}]`) + (err ? ` ${logColor.red(err.message)}` : ""),
32
32
  severity: severityFromStatus(status),
@@ -36,8 +36,8 @@ var PrettyRequestLogger = class {
36
36
  renderWebsocket(context, error, acceptedProtocol) {
37
37
  const ctx = context;
38
38
  const refused = error ? handshakeRefusalStatus(context, error) : null;
39
- const status = refused ?? ctx.response?.statusCode ?? (error ? 500 : null);
40
- const duration = computeDurationMs(ctx.phases);
39
+ const status = refused ?? statusOf(ctx) ?? (error ? 500 : null);
40
+ const duration = ctx.stopClock();
41
41
  const proto = acceptedProtocol ? `[${acceptedProtocol}]` : "";
42
42
  return {
43
43
  text: `${logColor.cyan("WS ")} ${colorizeStatus(status)} ${ctx.url} ${logColor.blackBright(proto)} ${logColor.blackBright(formatDuration(duration))} ${logColor.blackBright(ctx.remoteAddress ?? "-")} ` + logColor.magenta(`[${shortId(ctx.requestId)}]`) + (error ? ` ${logColor.red(error.message)}` : ""),
@@ -47,11 +47,12 @@ var PrettyRequestLogger = class {
47
47
  }
48
48
  };
49
49
  function colorizeStatus(status) {
50
- if (status === null) return logColor.blackBright("---");
50
+ const family = accessStatusFamily(status);
51
+ if (status === null || family === null) return logColor.blackBright("---");
51
52
  const s = String(status);
52
- if (status >= 500) return logColor.red(s);
53
- if (status >= 400) return logColor.yellowBold(s);
54
- if (status >= 300) return logColor.yellow(s);
53
+ if (family >= 5) return logColor.red(s);
54
+ if (family === 4) return logColor.yellowBold(s);
55
+ if (family === 3) return logColor.yellow(s);
55
56
  return logColor.green(s);
56
57
  }
57
58
  function formatDuration(ms) {
@@ -1,5 +1,5 @@
1
- import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
2
1
  import { severityFromStatus } from "./audit-logger.js";
2
+ import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
3
3
  import { logColor } from "nodefony";
4
4
  // nodefony/service/request-logger.ts
5
5
  /**
@@ -1,6 +1,6 @@
1
+ import { forwardRequest, forwardUpgrade, refuseMalformedHandshake, refuseUpgrade } from "../src/proxy/forward.js";
1
2
  import { isAmbiguousPath, normalizePrefix, parseProxyOrigin, proxyMountProblems, websocketHandshakeProblem } from "../src/proxy/rules.js";
2
3
  import { requestAuthority } from "../src/context/transport.js";
3
- import { forwardRequest, forwardUpgrade, refuseUpgrade } from "../src/proxy/forward.js";
4
4
  import { Service } from "nodefony";
5
5
  import http from "node:http";
6
6
  import { randomBytes } from "node:crypto";
@@ -275,10 +275,15 @@ var ReverseProxy = class extends Service {
275
275
  refuseUpgrade(socket, 421, "Misdirected Request");
276
276
  return true;
277
277
  }
278
- if (hostname === null || isAmbiguousPath(req.url ?? "/") || websocketHandshakeProblem(req.method, req.headers) !== null) {
278
+ if (hostname === null || isAmbiguousPath(req.url ?? "/")) {
279
279
  refuseUpgrade(socket, 400, "Bad Request");
280
280
  return true;
281
281
  }
282
+ const problem = websocketHandshakeProblem(req.method, req.headers);
283
+ if (problem !== null) {
284
+ refuseMalformedHandshake(socket, problem);
285
+ return true;
286
+ }
282
287
  const secure = req.socket instanceof TLSSocket;
283
288
  if (rules !== void 0 && !rules.isWebsocketOriginAllowed(req.headers.origin, hostname, secure)) {
284
289
  refuseUpgrade(socket, 403, "Forbidden");
@@ -1,7 +1,8 @@
1
1
  import { isPeerGoneError } from "../../src/context/websocket/Response.js";
2
2
  // nodefony/service/servers/clientError.ts
3
3
  /**
4
- * Gère l'event `clientError` d'un `http.Server` / `https.Server`.
4
+ * Gère l'event `clientError` d'un `http.Server` / `https.Server`, et des connexions
5
+ * HTTP/1.1 d'un serveur HTTP/2 à `allowHTTP1`.
5
6
  *
6
7
  * ⚠️ Doc Node (`http.Server` event `'clientError'`) : dès qu'un listener
7
8
  * `clientError` est attaché, Node **cesse de fermer le socket automatiquement**
@@ -13,9 +14,32 @@ import { isPeerGoneError } from "../../src/context/websocket/Response.js";
13
14
  * @param socket - flux client brut (l'event `clientError` fournit un `Duplex`).
14
15
  */
15
16
  function handleClientError(error, socket) {
16
- if (error.code === "ECONNRESET" || !socket.writable) return;
17
- const statusLine = error.code === "HPE_HEADER_OVERFLOW" ? "431 Request Header Fields Too Large" : "400 Bad Request";
18
- socket.end(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\n\r\n`);
17
+ const code = "code" in error ? error.code : void 0;
18
+ if (code === "ECONNRESET" || !socket.writable) return;
19
+ const statusLine = code === "HPE_HEADER_OVERFLOW" ? overflowStatusLine(error) : "400 Bad Request";
20
+ socket.end(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
21
+ }
22
+ /**
23
+ * Le statut d'un `HPE_HEADER_OVERFLOW` : Node lève la même erreur pour une
24
+ * cible trop longue et pour des en-têtes trop longs, alors que la norme les
25
+ * distingue — 414 pour la cible (RFC 9112 §3), 431 pour les en-têtes
26
+ * (RFC 6585 §5).
27
+ *
28
+ * Le parseur ne constate le débordement qu'à la FIN d'un élément (la cible,
29
+ * une valeur d'en-tête), au point `bytesParsed` du paquet reçu (`rawPacket`).
30
+ * Si la ligne de requête était finie, son `CRLF` — ou celui d'un en-tête —
31
+ * précède ce point : ce sont les en-têtes qui débordent. Sinon, c'est la cible.
32
+ * Constaté sur Node 26, en un paquet comme en plusieurs. Faute de ces deux
33
+ * champs, 431 : le statut d'avant.
34
+ */
35
+ function overflowStatusLine(error) {
36
+ const raw = "rawPacket" in error ? error.rawPacket : void 0;
37
+ const parsed = "bytesParsed" in error ? error.bytesParsed : void 0;
38
+ if (Buffer.isBuffer(raw) && typeof parsed === "number") {
39
+ const eol = raw.indexOf("\r\n");
40
+ if (eol === -1 || eol >= parsed) return "414 URI Too Long";
41
+ }
42
+ return "431 Request Header Fields Too Large";
19
43
  }
20
44
  /**
21
45
  * Gère l'event `sessionError` d'un serveur HTTP/2 (h2c ou TLS) : un pair PARTI
@@ -1,10 +1,10 @@
1
- import { asServersConfig, configuredServerPort, isH2cEnabled } from "../../src/servers/kernelServers.js";
2
- import __decorate from "../../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
3
- import { handleSessionError } from "./clientError.js";
4
1
  import { createDrainTerminator } from "./serverShutdown.js";
5
- import { bindWithFallback, buildBindPlan, h2cPortConflict } from "../../src/servers/portBinder.js";
6
- import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorateParam.js";
2
+ import { handleSessionError } from "./clientError.js";
7
3
  import { http2ServerLimits, setHttp2SessionTimeout } from "../../src/servers/http2Limits.js";
4
+ import { bindWithFallback, buildBindPlan, h2cPortConflict } from "../../src/servers/portBinder.js";
5
+ import { asServersConfig, configuredServerPort, isH2cEnabled } from "../../src/servers/kernelServers.js";
6
+ import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js";
7
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
8
8
  import { BootConfigurationError, Inject, Service, nodefonyError } from "nodefony";
9
9
  import http2 from "node:http2";
10
10
  // nodefony/service/servers/server-h2c.ts
@@ -1,9 +1,10 @@
1
- import { asServersConfig, configuredServerPort } from "../../src/servers/kernelServers.js";
2
- import __decorate from "../../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
3
1
  import { handleClientError } from "./clientError.js";
2
+ import { routeSocketTimeout } from "../../src/context/http/HttpContext.js";
4
3
  import { createDrainTerminator } from "./serverShutdown.js";
5
4
  import { bindWithFallback, buildBindPlan } from "../../src/servers/portBinder.js";
6
- import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorateParam.js";
5
+ import { asServersConfig, configuredServerPort } from "../../src/servers/kernelServers.js";
6
+ import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js";
7
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
7
8
  import { Inject, Service, extend, nodefonyError } from "nodefony";
8
9
  import http from "node:http";
9
10
  // nodefony/service/servers/server-http.ts
@@ -41,6 +42,7 @@ let ServerHttp = class ServerHttp extends Service {
41
42
  this.server = http.createServer(opt);
42
43
  this.httpTerminator = createDrainTerminator(this.server, this.options.shutdownTimeout);
43
44
  if (this.options.maxHeadersCount) this.server.maxHeadersCount = this.options.maxHeadersCount;
45
+ this.server.on("timeout", routeSocketTimeout);
44
46
  if (this.options.timeout) {
45
47
  this.server.setTimeout(this.options.timeout, () => {
46
48
  this.fire("onTimeout", this);