@nodefony/http 10.0.0-beta.6 → 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 (96) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +3 -2
  4. package/dist/nodefony/config/config.js +25 -2
  5. package/dist/nodefony/grpc/GrpcCall.js +298 -0
  6. package/dist/nodefony/grpc/GrpcEndpoint.js +528 -0
  7. package/dist/nodefony/grpc/index.js +4 -0
  8. package/dist/nodefony/interfaces/IRpcCall.js +1 -0
  9. package/dist/nodefony/service/audit-logger.js +52 -6
  10. package/dist/nodefony/service/error-renderer.js +41 -9
  11. package/dist/nodefony/service/http-kernel.js +198 -19
  12. package/dist/nodefony/service/pretty-request-logger.js +5 -3
  13. package/dist/nodefony/service/request-logger.js +21 -6
  14. package/dist/nodefony/service/reverse-proxy.js +1 -1
  15. package/dist/nodefony/service/servers/clientError.js +26 -1
  16. package/dist/nodefony/service/servers/server-h2c.js +3 -3
  17. package/dist/nodefony/service/servers/server-http.js +3 -3
  18. package/dist/nodefony/service/servers/server-https.js +4 -4
  19. package/dist/nodefony/service/servers/server-websocket-secure.js +3 -3
  20. package/dist/nodefony/service/servers/server-websocket.js +3 -3
  21. package/dist/nodefony/service/servers/serverShutdown.js +14 -36
  22. package/dist/nodefony/service/sessions/sessions-service.js +6 -6
  23. package/dist/nodefony/service/upload/upload-service.js +2 -2
  24. package/dist/nodefony/src/context/Context.js +12 -1
  25. package/dist/nodefony/src/context/http/ByteStream.js +9 -4
  26. package/dist/nodefony/src/context/http/HttpContext.js +86 -8
  27. package/dist/nodefony/src/context/http/Request.js +96 -16
  28. package/dist/nodefony/src/context/http/Response.js +120 -17
  29. package/dist/nodefony/src/context/http/SseStream.js +4 -4
  30. package/dist/nodefony/src/context/http/StreamLifecycle.js +328 -19
  31. package/dist/nodefony/src/context/http/parser.js +29 -11
  32. package/dist/nodefony/src/context/http/webResponse.js +56 -0
  33. package/dist/nodefony/src/context/http2/Response.js +47 -8
  34. package/dist/nodefony/src/context/requestTrace.js +6 -2
  35. package/dist/nodefony/src/context/rpcOutcome.js +20 -0
  36. package/dist/nodefony/src/context/websocket/Response.js +11 -1
  37. package/dist/nodefony/src/context/websocket/WebsocketContext.js +15 -4
  38. package/dist/nodefony/src/cookies/cookie.js +3 -1
  39. package/dist/nodefony/src/errors/httpError.js +58 -25
  40. package/dist/nodefony/src/grpc/cors.js +59 -0
  41. package/dist/nodefony/src/grpc/limits.js +16 -0
  42. package/dist/nodefony/src/grpc/refusal.js +271 -0
  43. package/dist/nodefony/src/profiler/aggregate.js +2 -2
  44. package/dist/nodefony/src/proxy/forward.js +1 -1
  45. package/dist/nodefony/src/proxy/generateProxyConfig.js +91 -15
  46. package/dist/nodefony/src/servers/http2Goaway.js +76 -0
  47. package/dist/nodefony/src/servers/http2Limits.js +41 -15
  48. package/dist/nodefony/src/servers/http2StreamGrace.js +100 -0
  49. package/dist/nodefony/src/servers/receiveDeadline.js +177 -0
  50. package/dist/types/index.d.ts +5 -2
  51. package/dist/types/nodefony/config/config.d.ts +10 -0
  52. package/dist/types/nodefony/grpc/GrpcCall.d.ts +142 -0
  53. package/dist/types/nodefony/grpc/GrpcEndpoint.d.ts +98 -0
  54. package/dist/types/nodefony/grpc/index.d.ts +15 -0
  55. package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
  56. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +14 -0
  57. package/dist/types/nodefony/interfaces/IRequest.d.ts +2 -1
  58. package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +10 -0
  59. package/dist/types/nodefony/interfaces/IRouting.d.ts +19 -0
  60. package/dist/types/nodefony/interfaces/IRpcCall.d.ts +83 -0
  61. package/dist/types/nodefony/interfaces/IUpload.d.ts +51 -4
  62. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  63. package/dist/types/nodefony/service/audit-logger.d.ts +33 -3
  64. package/dist/types/nodefony/service/error-renderer.d.ts +22 -1
  65. package/dist/types/nodefony/service/http-kernel.d.ts +21 -1
  66. package/dist/types/nodefony/service/request-logger.d.ts +10 -0
  67. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +2 -2
  68. package/dist/types/nodefony/src/context/Context.d.ts +9 -1
  69. package/dist/types/nodefony/src/context/http/ByteStream.d.ts +1 -1
  70. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +56 -2
  71. package/dist/types/nodefony/src/context/http/Request.d.ts +38 -8
  72. package/dist/types/nodefony/src/context/http/Response.d.ts +61 -7
  73. package/dist/types/nodefony/src/context/http/SseStream.d.ts +1 -1
  74. package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +53 -0
  75. package/dist/types/nodefony/src/context/http/parser.d.ts +17 -3
  76. package/dist/types/nodefony/src/context/http/webResponse.d.ts +31 -0
  77. package/dist/types/nodefony/src/context/http2/Response.d.ts +35 -2
  78. package/dist/types/nodefony/src/context/requestTrace.d.ts +3 -1
  79. package/dist/types/nodefony/src/context/rpcOutcome.d.ts +11 -0
  80. package/dist/types/nodefony/src/context/websocket/Response.d.ts +6 -1
  81. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +8 -0
  82. package/dist/types/nodefony/src/errors/httpError.d.ts +29 -4
  83. package/dist/types/nodefony/src/grpc/cors.d.ts +32 -0
  84. package/dist/types/nodefony/src/grpc/limits.d.ts +14 -0
  85. package/dist/types/nodefony/src/grpc/refusal.d.ts +136 -0
  86. package/dist/types/nodefony/src/proxy/forward.d.ts +7 -0
  87. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +15 -2
  88. package/dist/types/nodefony/src/servers/http2Goaway.d.ts +41 -0
  89. package/dist/types/nodefony/src/servers/http2Limits.d.ts +27 -14
  90. package/dist/types/nodefony/src/servers/http2StreamGrace.d.ts +34 -0
  91. package/dist/types/nodefony/src/servers/receiveDeadline.d.ts +77 -0
  92. package/docs/byte-stream.md +52 -17
  93. package/docs/diagnostics-channel.md +45 -1
  94. package/docs/servers.md +57 -44
  95. package/docs/upload.md +43 -17
  96. package/package.json +26 -6
@@ -1,4 +1,4 @@
1
- import HttpError from "../src/errors/httpError.js";
1
+ import HttpError, { serializeHttpError } from "../src/errors/httpError.js";
2
2
  import { toWsCloseCode } from "../src/context/websocket/WebsocketContext.js";
3
3
  import { Nodefony, nodefonyError } from "nodefony";
4
4
  // nodefony/service/error-renderer.ts
@@ -49,17 +49,23 @@ const UNIQUE_VIOLATION_MESSAGE = "Conflict — a resource with these unique valu
49
49
  */
50
50
  const OPAQUE_SERVER_ERROR = "Internal Server Error";
51
51
  /**
52
+ * Le message rendu au client : celui de l'erreur, sauf une panne 5xx en
53
+ * production, qui devient opaque. Un 4xx garde le sien — c'est un message
54
+ * écrit POUR le client (validation, refus de politique), pas une fuite.
55
+ */
56
+ const clientMessage = (status, message, production) => production && status >= 500 ? OPAQUE_SERVER_ERROR : message;
57
+ /**
52
58
  * Clés d'un `HttpError` sérialisé qui décrivent les ENTRAILLES du serveur et ne
53
59
  * doivent jamais franchir la frontière en production.
54
60
  */
55
- const INTERNAL_ERROR_KEYS = [
61
+ const INTERNAL_ERROR_KEYS = /* @__PURE__ */ new Set([
56
62
  "stack",
57
63
  "controller",
58
64
  "action",
59
65
  "bundle",
60
66
  "url",
61
67
  "pdu"
62
- ];
68
+ ]);
63
69
  /**
64
70
  * Vrai si le runtime tourne en production — le détail des erreurs reste alors au
65
71
  * journal, seul le serveur le voit.
@@ -376,12 +382,10 @@ var DefaultErrorRenderer = class {
376
382
  const status = this.normalizeHttpStatus(httpError.code);
377
383
  httpError.code = status;
378
384
  const obj = context.metaData;
379
- const serialized = httpError.toJSON();
380
- const message = isProduction() && status >= 500 ? OPAQUE_SERVER_ERROR : httpError.message;
381
- if (isProduction()) {
382
- for (const key of INTERNAL_ERROR_KEYS) delete serialized[key];
383
- serialized.message = message;
384
- }
385
+ const production = isProduction();
386
+ const serialized = serializeHttpError(httpError, production ? INTERNAL_ERROR_KEYS : null);
387
+ const message = clientMessage(status, httpError.message, production);
388
+ if (production) serialized.message = message;
385
389
  obj.error = serialized;
386
390
  obj.code = status;
387
391
  obj.message = message;
@@ -405,6 +409,34 @@ var DefaultErrorRenderer = class {
405
409
  reason
406
410
  };
407
411
  }
412
+ /**
413
+ * Le statut HTTP et le message client qu'une route HTTP rendrait pour cette
414
+ * erreur — mêmes classements que {@link renderHttp} (validation 422, conflit
415
+ * 409, identifiant malformé, code numérique) et même opacité d'un 5xx en
416
+ * production —, sans RIEN écrire : ni statut sur la réponse, ni `metaData`,
417
+ * ni pile formatée. Sert les transports sans corps d'erreur : un appel gRPC
418
+ * traduit ce statut en code (ADR-0026 D7).
419
+ *
420
+ * Un identifiant malformé y vaut 400 : sans contexte, aucun chemin ne le
421
+ * désigne.
422
+ *
423
+ * @param error - l'erreur levée.
424
+ * @returns son statut émettable et le message destiné au client.
425
+ *
426
+ * @experimental Surface gRPC, ADR-0026 D11.
427
+ */
428
+ describe(error) {
429
+ const httpError = this.toHttpError(error, void 0);
430
+ const status = this.normalizeHttpStatus(httpError.code);
431
+ return {
432
+ status,
433
+ message: clientMessage(status, httpError.message, isProduction())
434
+ };
435
+ }
436
+ /**
437
+ * Ramène une erreur à une `HttpError`. Sans contexte (`describe`), rien
438
+ * n'est écrit : `HttpError` ne pose le statut sur la réponse qu'avec lui.
439
+ */
408
440
  toHttpError(error, context) {
409
441
  if (error instanceof HttpError) return error;
410
442
  const fields = toValidationFields(error);
@@ -1,6 +1,7 @@
1
1
  import HttpError from "../src/errors/httpError.js";
2
2
  import { isUnsupportedTransferCoding } from "../src/proxy/rules.js";
3
3
  import { hasConflictingAuthority } from "../src/context/transport.js";
4
+ import { grpcWireOf, hasGrpcRoutes, prepareGrpcRefusal } from "../src/grpc/refusal.js";
4
5
  import { buildTrustProxy } from "../src/context/trustProxy.js";
5
6
  import { resolveForwarded } from "../src/context/forwarded.js";
6
7
  import { MemoryRateLimitStore } from "../src/rateLimit/MemoryRateLimitStore.js";
@@ -10,7 +11,9 @@ import { isProfiledUrl } from "../src/profiler/aggregate.js";
10
11
  import { responseEnded } from "../src/context/responseEnded.js";
11
12
  import { unsentResponseWarning } from "../src/context/unsentResponse.js";
12
13
  import { closeRequestTrace, hasRequestTraceSubscribers, openRequestTrace, runTraced } from "../src/context/requestTrace.js";
14
+ import { SHUTDOWN_STREAMS_WAIT_MS, ShutdownRegistry, StallSweep } from "../src/context/http/StreamLifecycle.js";
13
15
  import WebsocketContext, { toHandshakeStatus } from "../src/context/websocket/WebsocketContext.js";
16
+ import { ReceiveDeadline } from "../src/servers/receiveDeadline.js";
14
17
  import { trackPong } from "./servers/wsHeartbeat.js";
15
18
  import { parseWebsocketUrl } from "../src/context/websocket/websocketUrl.js";
16
19
  import HttpContext from "../src/context/http/HttpContext.js";
@@ -20,7 +23,7 @@ import PrettyRequestLogger from "./pretty-request-logger.js";
20
23
  import JsonAuditLogger from "./audit-logger.js";
21
24
  import { resolveTraceparent } from "./trace.js";
22
25
  import { asServersConfig, configuredServerPort } from "../src/servers/kernelServers.js";
23
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
26
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
24
27
  import { BootConfigurationError, GcScheduler, IDENTITY_CHANGED_EVENT, RequestContext, RevocationWatch, Service, finallyMaybe, injectable, isPromise, logColor, nodefonyError, servedUrl, thenMaybe, writeRuntimeState } from "nodefony";
25
28
  import cluster from "node:cluster";
26
29
  import http from "node:http";
@@ -111,6 +114,16 @@ let HttpKernel = class HttpKernel extends Service {
111
114
  }
112
115
  responseTimeout;
113
116
  closeTimeOutWs;
117
+ /**
118
+ * Échéance de réception du corps HTTP/2, par serveur (`requestTimeout` de sa
119
+ * section) : `node:http2` ne borne pas la réception, HTTP/1.1 l'est par Node.
120
+ * `undefined` tant qu'aucun corps HTTP/2 n'a été lu, `null` si le délai est
121
+ * désactivé (0). Cf {@link ReceiveDeadline}.
122
+ */
123
+ #h2Receive = {
124
+ http: void 0,
125
+ https: void 0
126
+ };
114
127
  secContentTypeOptions = null;
115
128
  secFrameOptions = null;
116
129
  secHsts = null;
@@ -142,9 +155,11 @@ let HttpKernel = class HttpKernel extends Service {
142
155
  rateLimitGc = null;
143
156
  #wsConnCounter = null;
144
157
  #streams = null;
158
+ #stallSweep = null;
159
+ #openStreams = null;
145
160
  #healthPaths = null;
146
161
  #healthShutdownDelay = 0;
147
- #terminating = false;
162
+ #shutdownSteps = null;
148
163
  constructor(module) {
149
164
  super(serviceName, module.container, module.notificationsCenter, module.options);
150
165
  this.module = module;
@@ -292,7 +307,7 @@ let HttpKernel = class HttpKernel extends Service {
292
307
  * Le message nomme l'action, ce qu'elle a rendu et le geste qui convient
293
308
  * (`unsentResponseWarning`).
294
309
  *
295
- * Appelé au timeout, AVANT que le 504/408 parte — après, `sended` est vrai et
310
+ * Appelé au timeout, AVANT que le 503 parte — après, `sended` est vrai et
296
311
  * plus rien ne se signalerait — et au démontage, pour un client parti avant.
297
312
  * Gratuit en production : rien n'est lu au-delà de `waitAsync`.
298
313
  *
@@ -379,13 +394,10 @@ let HttpKernel = class HttpKernel extends Service {
379
394
  });
380
395
  this.once("onTerminate", () => {
381
396
  this.rateLimitGc?.stop();
397
+ this.#h2Receive.http?.close();
398
+ this.#h2Receive.https?.close();
382
399
  });
383
- this.kernel?.once("onPostReady", () => {
384
- this.kernel?.prependOnceListener("onTerminate", async () => {
385
- this.#terminating = true;
386
- if (this.#healthShutdownDelay > 0) await new Promise((r) => setTimeout(r, this.#healthShutdownDelay));
387
- });
388
- });
400
+ this.kernel?.once("onDrain", () => this.drainTraffic());
389
401
  this.kernel?.prependOnceListener("onBoot", () => {
390
402
  this.router = this.get("router");
391
403
  this.firewall = this.get("firewall");
@@ -478,7 +490,7 @@ let HttpKernel = class HttpKernel extends Service {
478
490
  const secure = context.serverType === "wss";
479
491
  const raw = context.origin;
480
492
  if (this.isWebsocketOriginAllowed(raw, context.domain, secure)) return;
481
- const error = new HttpError(`WebSocket Origin "${raw}" not allowed (CSWSH protection)`, 1008, context);
493
+ const error = HttpError.withoutStack(`WebSocket Origin "${raw}" not allowed (CSWSH protection)`, 1008, context);
482
494
  error.type = "origin";
483
495
  throw error;
484
496
  }
@@ -582,7 +594,7 @@ let HttpKernel = class HttpKernel extends Service {
582
594
  }
583
595
  throw resolver.exception;
584
596
  }
585
- throw new HttpError("Not Found", 404, context);
597
+ throw HttpError.withoutStack("Not Found", 404, context);
586
598
  }
587
599
  /**
588
600
  * {@link prepareFrontController} + instanciation immédiate du controller
@@ -669,8 +681,12 @@ let HttpKernel = class HttpKernel extends Service {
669
681
  context.isRedirect = false;
670
682
  context.response.removeHeader("Location");
671
683
  }
684
+ context.response.closeUnfinishedRequest();
685
+ const headers = context.request.headers;
686
+ const wire = grpcWireOf(headers, context.method === "GET" && context.request.url.searchParams.get("connect") === "v1");
672
687
  try {
673
- await context.render(result.body);
688
+ if (wire === 0) await context.render(result.body);
689
+ else await context.send(prepareGrpcRefusal(context.response, wire, result.status, context.authenticatedUser() !== null, headers["content-type"]));
674
690
  } catch (e) {
675
691
  this.log(e, "CRITIC");
676
692
  throw e;
@@ -744,7 +760,7 @@ let HttpKernel = class HttpKernel extends Service {
744
760
  return;
745
761
  }
746
762
  if (url === this.#healthPaths.readiness) {
747
- this.#respondHealth(response, !this.#terminating && this.kernel?.servable === true, type);
763
+ this.#respondHealth(response, this.kernel?.isTerminating !== true && this.kernel?.servable === true, type);
748
764
  return;
749
765
  }
750
766
  }
@@ -910,13 +926,29 @@ let HttpKernel = class HttpKernel extends Service {
910
926
  * @returns `undefined` quand le démontage est terminé ; sinon la promesse de
911
927
  * sa fin, qui ne rejette pas.
912
928
  */
929
+ /**
930
+ * Fixe le code d'un appel RPC au `close` : « réponse finie » est la règle
931
+ * unique du noyau ({@link responseEnded}, celle du 499), jamais
932
+ * `writableFinished`, qui dit « finie » sur une annulation HTTP/2. Une cause
933
+ * rendue par l'appel devient l'erreur de la requête : la ligne d'accès la
934
+ * porte, le canal la publie — une ligne par appel en erreur (D10 point 3).
935
+ *
936
+ * @param context - le contexte de la requête.
937
+ * @param rpc - son appel RPC.
938
+ */
939
+ settleRpc(context, rpc) {
940
+ const raw = context.response.response;
941
+ const cause = rpc.settle(raw !== null && responseEnded(raw));
942
+ if (cause !== null && !context.error) context.error = cause;
943
+ }
913
944
  teardownHttp(context, scope) {
914
945
  if (context.finished) return void 0;
915
946
  try {
916
947
  context.stopClock();
917
948
  if (context.waitAsync) this.warnUnsentResponse(context);
949
+ if (context.rpc !== null) this.settleRpc(context, context.rpc);
918
950
  context.logRequest();
919
- if (context.requestTrace !== null) closeRequestTrace(context, context.response.statusCode, context.error);
951
+ if (context.requestTrace !== null) closeRequestTrace(context, context.response.statusCode, context.error, context.rpc);
920
952
  context.logPhasesVerbose();
921
953
  if (this.shouldProfile(context.url)) this.profiler?.collect(context);
922
954
  const afterResponse = context._runAfterResponse();
@@ -1112,16 +1144,52 @@ let HttpKernel = class HttpKernel extends Service {
1112
1144
  return this.serveHttpRequest(httpContext);
1113
1145
  }
1114
1146
  /**
1147
+ * L'échéance de réception HTTP/2 du serveur qui sert (`requestTimeout` de sa
1148
+ * section), créée au premier corps HTTP/2 lu ; `null` si le délai vaut 0.
1149
+ *
1150
+ * À l'échéance, la réception en cours est interrompue (`abortBody`) et la
1151
+ * requête rendue en 408 par le chemin d'erreur habituel — comme un corps qui
1152
+ * dépasse sa limite (413).
1153
+ */
1154
+ http2ReceiveDeadline(serverType) {
1155
+ const key = serverType === "https" ? "https" : "http";
1156
+ let deadline = this.#h2Receive[key];
1157
+ if (deadline === void 0) {
1158
+ const timeoutMs = this.options[key].requestTimeout;
1159
+ deadline = timeoutMs > 0 ? new ReceiveDeadline(timeoutMs, (context) => {
1160
+ if (context.finished) return;
1161
+ context.request.abortBody(HttpError.withoutStack("Request Timeout", 408, context));
1162
+ }) : null;
1163
+ this.#h2Receive[key] = deadline;
1164
+ }
1165
+ return deadline;
1166
+ }
1167
+ /**
1115
1168
  * Le corps — lu seulement s'il y en a un à lire —, puis les gardes
1116
1169
  * ({@link onRequestEnd}) et l'action. Chacun n'est attendu que s'il attend.
1117
1170
  */
1118
1171
  serveHttpRequest(httpContext) {
1119
1172
  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));
1122
- const streamBody = httpContext.resolver?.resolve === true && httpContext.resolver.route?.bodyStream === true;
1173
+ if (isUnsupportedTransferCoding(headers["transfer-encoding"])) return Promise.reject(HttpError.withoutStack("Transfer-Encoding not implemented", 501, httpContext));
1174
+ if (hasConflictingAuthority(headers)) return Promise.reject(HttpError.withoutStack("Host differs from :authority", 400, httpContext));
1175
+ const resolver = httpContext.resolver;
1176
+ if (hasGrpcRoutes() && resolver?.resolve === true && httpContext.method === "POST" && resolver.route?.grpc !== true && grpcWireOf(headers) === 1) resolver.resolve = false;
1177
+ const skipBody = !(resolver?.resolve === true) || resolver.route?.bodyStream === true;
1123
1178
  httpContext.phaseStart("parse");
1124
- const parsed = finallyMaybe(() => streamBody ? void 0 : httpContext.request.initialize(), () => httpContext.phaseEnd("parse"));
1179
+ let receiving = null;
1180
+ let receivingSeq = 0;
1181
+ const parsed = finallyMaybe(() => {
1182
+ if (skipBody) return void 0;
1183
+ const pending = httpContext.request.initialize();
1184
+ if (pending !== void 0 && (httpContext.alpnProtocol === "h2" || httpContext.alpnProtocol === "h2c")) {
1185
+ receiving = this.http2ReceiveDeadline(httpContext.serverType);
1186
+ if (receiving !== null) receivingSeq = receiving.track(httpContext);
1187
+ }
1188
+ return pending;
1189
+ }, () => {
1190
+ httpContext.phaseEnd("parse");
1191
+ if (receiving !== null) receiving.release(receivingSeq, httpContext);
1192
+ });
1125
1193
  return thenMaybe(parsed, () => thenMaybe(this.onRequestEnd(httpContext), (guarded) => {
1126
1194
  guarded.phaseStart("action");
1127
1195
  return finallyMaybe(() => guarded.handle(), () => guarded.phaseEnd("action"));
@@ -1512,7 +1580,7 @@ let HttpKernel = class HttpKernel extends Service {
1512
1580
  watchStream(stream) {
1513
1581
  (this.#streams ??= new RevocationWatch({
1514
1582
  isValid: (entry) => entry.revalidate(),
1515
- revoke: (entry) => void entry.close(),
1583
+ revoke: (entry) => void entry.revoke(),
1516
1584
  identityOf: (entry) => entry.identifier ?? void 0
1517
1585
  })).register(stream);
1518
1586
  }
@@ -1521,6 +1589,117 @@ let HttpKernel = class HttpKernel extends Service {
1521
1589
  this.#streams?.unregister(stream);
1522
1590
  }
1523
1591
  /**
1592
+ * Inscrit au balayage de blocage un flux dont un tiers tient les écritures :
1593
+ * un client qui ne lit plus y est coupé (`StreamLifecycle.sweepStall`).
1594
+ *
1595
+ * @internal — appelé par le cycle de vie du flux (`StreamLifecycle`).
1596
+ */
1597
+ sweepStall(stream) {
1598
+ (this.#stallSweep ??= new StallSweep()).add(stream);
1599
+ }
1600
+ /** Retire un flux du balayage de blocage (à sa fermeture). @internal */
1601
+ unsweepStall(stream) {
1602
+ this.#stallSweep?.delete(stream);
1603
+ }
1604
+ /**
1605
+ * Inscrit un flux sans fin à lui (SSE, flux RPC, octets vivants) au registre
1606
+ * de l'arrêt : il sera terminé AVANT le drain ({@link shutdownStreams}).
1607
+ * Ouvert pendant l'arrêt, il l'est au tour suivant — une fois son appel RPC
1608
+ * relié, qui ne l'est qu'après l'ouverture.
1609
+ *
1610
+ * @internal — appelé par le cycle de vie du flux (`StreamLifecycle`).
1611
+ */
1612
+ trackStream(stream) {
1613
+ (this.#openStreams ??= new ShutdownRegistry()).add(stream);
1614
+ }
1615
+ /** Retire un flux du registre de l'arrêt (à sa fermeture). @internal */
1616
+ untrackStream(stream) {
1617
+ this.#openStreams?.delete(stream);
1618
+ }
1619
+ /**
1620
+ * Inscrit une étape de l'arrêt du trafic, appelée par {@link drainTraffic}.
1621
+ *
1622
+ * Un serveur s'inscrit à sa création au lieu d'écouter le noyau : l'ordre
1623
+ * (congé des connexions longues AVANT le drain, ADR-0026 D8) vit alors dans
1624
+ * une seule fonction, plus dans des moments d'inscription.
1625
+ *
1626
+ * @param step - `dismiss` : congédier les connexions longues (WebSocket
1627
+ * 1001) ; `drain` : cesser d'accepter et laisser finir les requêtes.
1628
+ * @param action - l'étape ; une promesse rendue est attendue.
1629
+ *
1630
+ * @internal
1631
+ */
1632
+ addShutdownStep(step, action) {
1633
+ this.#shutdownSteps ??= {
1634
+ dismiss: [],
1635
+ drain: []
1636
+ };
1637
+ this.#shutdownSteps[step].push(action);
1638
+ }
1639
+ /**
1640
+ * L'arrêt du trafic (ADR-0027 D2), écouteur unique d'`onDrain` :
1641
+ *
1642
+ * 1. si le pod a été annoncé disponible, attendre `health.shutdownDelay` —
1643
+ * la sonde répond déjà 503 (`kernel.isTerminating`), la répartition
1644
+ * retire le pod ;
1645
+ * 2. congédier ensemble les connexions longues : WebSocket (1001) et flux
1646
+ * longs (fin au format de leur protocole, {@link shutdownStreams}) ;
1647
+ * 3. drainer ensemble les serveurs HTTP, HTTPS et h2c (ports distincts).
1648
+ *
1649
+ * Une étape qui échoue est journalisée sans arrêter les autres.
1650
+ *
1651
+ * @internal
1652
+ */
1653
+ async drainTraffic() {
1654
+ if (this.kernel?.postReady === true && this.#healthShutdownDelay > 0) await new Promise((r) => setTimeout(r, this.#healthShutdownDelay));
1655
+ const steps = this.#shutdownSteps;
1656
+ await this.#runShutdownSteps([...steps?.dismiss ?? [], () => this.shutdownStreams()]);
1657
+ if (steps !== null) await this.#runShutdownSteps(steps.drain);
1658
+ }
1659
+ /** Lance des étapes d'arrêt ensemble, attend la dernière, journalise les échecs. */
1660
+ async #runShutdownSteps(actions) {
1661
+ const settled = await Promise.allSettled(actions.map(async (action) => action()));
1662
+ for (const outcome of settled) if (outcome.status === "rejected") this.log(outcome.reason, "ERROR", "TERMINATE");
1663
+ }
1664
+ /**
1665
+ * L'arrêt du serveur : termine chaque flux long ouvert, puis attend leur
1666
+ * fermeture, au plus `waitMs` (ADR-0026 D8).
1667
+ *
1668
+ * Un appel RPC se termine au format de son protocole (gRPC : 14
1669
+ * `UNAVAILABLE`, le client le rejoue ailleurs), un flux SSE ou d'octets
1670
+ * vivant par une fin simple (`EventSource` se reconnecte). Un gestionnaire
1671
+ * qui n'entend rien laisse l'attente aller à son terme : sa connexion tombe
1672
+ * ensuite au drain, à `shutdownTimeout`.
1673
+ *
1674
+ * Appelé par {@link drainTraffic}, avec les fermetures 1001, avant le
1675
+ * drain : le client lit la fin du flux AVANT le GOAWAY définitif. Un appel unaire en vol n'est pas touché — il finit
1676
+ * pendant le drain.
1677
+ *
1678
+ * @param waitMs - attente maximale des fermetures.
1679
+ * @returns la promesse de l'attente, ou rien quand aucun flux n'est ouvert.
1680
+ *
1681
+ * @internal
1682
+ */
1683
+ shutdownStreams(waitMs = SHUTDOWN_STREAMS_WAIT_MS) {
1684
+ const registry = this.#openStreams ??= new ShutdownRegistry();
1685
+ const count = registry.size;
1686
+ const left = registry.shutdown(waitMs);
1687
+ if (count === 0) return void 0;
1688
+ if (typeof left === "number") {
1689
+ this.#logStreamsShutdown(count, left, waitMs);
1690
+ return;
1691
+ }
1692
+ return left.then((still) => this.#logStreamsShutdown(count, still, waitMs));
1693
+ }
1694
+ /** Une ligne pour l'arrêt des flux longs ; un avertissement s'il en reste. */
1695
+ #logStreamsShutdown(count, still, waitMs) {
1696
+ if (still === 0) {
1697
+ this.log(`Arrêt : ${count} flux long(s) terminé(s)`, "INFO");
1698
+ return;
1699
+ }
1700
+ this.log(`Arrêt : ${still} flux long(s) sur ${count} toujours ouvert(s) après ${waitMs} ms — gestionnaire qui n'écoute pas son signal ; coupé(s) par le drain à shutdownTimeout`, "WARNING");
1701
+ }
1702
+ /**
1524
1703
  * Re-valide tous les flux en cours et ferme ceux dont l'identité est morte.
1525
1704
  * Publique pour qu'un banc la déclenche sans attendre le tick.
1526
1705
  */
@@ -1,4 +1,5 @@
1
- import { severityFromStatus, statusOf } from "./audit-logger.js";
1
+ import { severityFromStatus, severityOfHttp, statusOf } from "./audit-logger.js";
2
+ import { rpcSegment } from "./request-logger.js";
2
3
  import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
3
4
  import { accessStatusFamily, logColor } from "nodefony";
4
5
  // nodefony/service/pretty-request-logger.ts
@@ -27,9 +28,10 @@ var PrettyRequestLogger = class {
27
28
  const status = statusOf(ctx) ?? (err ? 500 : null);
28
29
  const method = ctx.method ?? "?";
29
30
  const duration = ctx.stopClock();
31
+ const rpc = ctx.rpc ?? null;
30
32
  return {
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
- severity: severityFromStatus(status),
33
+ text: `${logColor.cyan(method.padEnd(4))} ${colorizeStatus(status)} ${ctx.url} ${logColor.blackBright(formatDuration(duration))} ${logColor.blackBright(ctx.remoteAddress ?? "-")} ` + logColor.magenta(`[${shortId(ctx.requestId)}]`) + (rpc === null ? "" : ` ${rpcSegment(rpc)}`) + (err ? ` ${logColor.red(err.message)}` : ""),
34
+ severity: severityOfHttp(ctx, status),
33
35
  msgid: "req"
34
36
  };
35
37
  }
@@ -1,4 +1,4 @@
1
- import { severityFromStatus } from "./audit-logger.js";
1
+ import { severityFromRpcCode, severityFromStatus } from "./audit-logger.js";
2
2
  import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
3
3
  import { logColor } from "nodefony";
4
4
  // nodefony/service/request-logger.ts
@@ -11,6 +11,18 @@ const statusOf = (context) => {
11
11
  return context.response?.statusCode;
12
12
  };
13
13
  /**
14
+ * Segment d'un appel RPC sur la ligne d'accès : `RPC : <méthode> <NOM>`
15
+ * (ADR-0026 D10 point 4) — le statut HTTP reste à sa place. Sans code (appel
16
+ * jamais commencé), la méthode seule.
17
+ *
18
+ * @param rpc - le résultat de l'appel.
19
+ * @returns le segment, sans espace de tête.
20
+ */
21
+ function rpcSegment(rpc) {
22
+ const name = rpc.codeName;
23
+ return name === null ? `${logColor.cyan("RPC")} : ${rpc.method}` : `${logColor.cyan("RPC")} : ${rpc.method} ${name}`;
24
+ }
25
+ /**
14
26
  * Default Nodefony per-request logger — preserves the legacy colored format.
15
27
  *
16
28
  * HTTP success: `URL : <url> FROM : <remote> ORIGIN : <host> ID : <uuid>`
@@ -23,21 +35,24 @@ const statusOf = (context) => {
23
35
  */
24
36
  var DefaultRequestLogger = class {
25
37
  renderHttp(context, error) {
26
- const txt = `${logColor.cyan("URL")} : ${context.url} ${logColor.cyan("FROM")} : ${context.remoteAddress} ${logColor.cyan("ORIGIN")} : ${context.originUrl?.host} ${logColor.cyan("ID")} : ${context.requestId}`;
38
+ const rpc = context.rpc ?? null;
39
+ const txt = `${logColor.cyan("URL")} : ${context.url} ${logColor.cyan("FROM")} : ${context.remoteAddress} ${logColor.cyan("ORIGIN")} : ${context.originUrl?.host} ${logColor.cyan("ID")} : ${context.requestId}` + (rpc === null ? "" : ` ${rpcSegment(rpc)}`);
40
+ const code = rpc === null ? null : rpc.code;
27
41
  const err = error ?? context.error;
28
42
  if (err) {
29
43
  const errCode = err.code ?? statusOf(context) ?? 500;
30
44
  const msgid = `${context.serverType} ${logColor.magenta(errCode)} ${logColor.red(context.method ?? "")}`;
31
45
  return {
32
46
  text: context.kernel?.environment === "production" ? `${txt} ${err}` : `${txt}\n ${err}`,
33
- severity: "ERROR",
47
+ severity: code === null ? "ERROR" : severityFromRpcCode(code),
34
48
  msgid
35
49
  };
36
50
  }
51
+ const msgid = `${context.serverType} ${logColor.magenta(statusOf(context) ?? "")} ${context.method}`;
37
52
  return {
38
53
  text: txt,
39
- severity: "INFO",
40
- msgid: `${context.serverType} ${logColor.magenta(statusOf(context) ?? "")} ${context.method}`
54
+ severity: code === null ? "INFO" : severityFromRpcCode(code),
55
+ msgid
41
56
  };
42
57
  }
43
58
  renderWebsocket(context, error, acceptedProtocol) {
@@ -59,4 +74,4 @@ var DefaultRequestLogger = class {
59
74
  };
60
75
  }
61
76
  };
62
- export { DefaultRequestLogger as default };
77
+ export { DefaultRequestLogger as default, rpcSegment };
@@ -67,7 +67,7 @@ var ReverseProxy = class extends Service {
67
67
  this.defaultTimeoutMs = section?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
68
68
  this.connectTimeoutMs = section?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
69
69
  this.maxSockets = section?.maxSockets ?? DEFAULT_MAX_SOCKETS;
70
- this.kernel?.prependOnceListener("onTerminate", () => this.closeAll());
70
+ this.kernel?.once("onTerminate", () => this.closeAll());
71
71
  for (const [prefix, mount] of Object.entries(section?.mounts ?? {})) this.#add(prefix, mount, "config");
72
72
  }
73
73
  /**
@@ -1,6 +1,27 @@
1
1
  import { isPeerGoneError } from "../../src/context/websocket/Response.js";
2
+ import { STATUS_CODES } from "node:http";
2
3
  // nodefony/service/servers/clientError.ts
3
4
  /**
5
+ * Le statut que Node lui-même aurait répondu (`_http_server.js#socketOnError`),
6
+ * hors débordement d'en-têtes ({@link overflowStatusLine}) ; 400 sinon.
7
+ * Brancher un écouteur `clientError` retire cette réponse par défaut : sans
8
+ * cette table, un délai de réception dépassé partait en `400 Bad Request` —
9
+ * le client lisait sa requête malformée alors qu'elle était seulement lente.
10
+ */
11
+ const CLIENT_ERROR_STATUS = /* @__PURE__ */ new Map([["ERR_HTTP_REQUEST_TIMEOUT", 408], ["HPE_CHUNK_EXTENSIONS_OVERFLOW", 413]]);
12
+ function statusLineOf(status) {
13
+ return `${status} ${STATUS_CODES[status] ?? ""}`;
14
+ }
15
+ /**
16
+ * La réponse en vol sur cette socket a-t-elle déjà écrit ses en-têtes ? La
17
+ * garde même de Node (`socketOnError` : `_httpMessage._headerSent`), lue par
18
+ * réflexion — deux champs internes, sans équivalent public sur la socket.
19
+ */
20
+ function inFlightHeaderSent(socket) {
21
+ const message = Reflect.get(socket, "_httpMessage");
22
+ return message !== null && typeof message === "object" && Reflect.get(message, "_headerSent") === true;
23
+ }
24
+ /**
4
25
  * Gère l'event `clientError` d'un `http.Server` / `https.Server`, et des connexions
5
26
  * HTTP/1.1 d'un serveur HTTP/2 à `allowHTTP1`.
6
27
  *
@@ -16,7 +37,11 @@ import { isPeerGoneError } from "../../src/context/websocket/Response.js";
16
37
  function handleClientError(error, socket) {
17
38
  const code = "code" in error ? error.code : void 0;
18
39
  if (code === "ECONNRESET" || !socket.writable) return;
19
- const statusLine = code === "HPE_HEADER_OVERFLOW" ? overflowStatusLine(error) : "400 Bad Request";
40
+ if (inFlightHeaderSent(socket)) {
41
+ socket.destroy();
42
+ return;
43
+ }
44
+ const statusLine = code === "HPE_HEADER_OVERFLOW" ? overflowStatusLine(error) : statusLineOf(CLIENT_ERROR_STATUS.get(code) ?? 400);
20
45
  socket.end(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
21
46
  }
22
47
  /**
@@ -3,8 +3,8 @@ import { handleSessionError } from "./clientError.js";
3
3
  import { http2ServerLimits, setHttp2SessionTimeout } from "../../src/servers/http2Limits.js";
4
4
  import { bindWithFallback, buildBindPlan, h2cPortConflict } from "../../src/servers/portBinder.js";
5
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";
6
+ import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorateParam.js";
7
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.158.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
@@ -98,7 +98,7 @@ let ServerH2c = class ServerH2c extends Service {
98
98
  server.close();
99
99
  reject(error);
100
100
  });
101
- this.kernel?.once("onTerminate", async () => {
101
+ this.httpKernel.addShutdownStep("drain", async () => {
102
102
  if (!this.server) return;
103
103
  try {
104
104
  await this.httpTerminator?.terminate();
@@ -3,8 +3,8 @@ import { routeSocketTimeout } from "../../src/context/http/HttpContext.js";
3
3
  import { createDrainTerminator } from "./serverShutdown.js";
4
4
  import { bindWithFallback, buildBindPlan } from "../../src/servers/portBinder.js";
5
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";
6
+ import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorateParam.js";
7
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
8
8
  import { Inject, Service, extend, nodefonyError } from "nodefony";
9
9
  import http from "node:http";
10
10
  // nodefony/service/servers/server-http.ts
@@ -69,7 +69,7 @@ let ServerHttp = class ServerHttp extends Service {
69
69
  this.reportBindError(error);
70
70
  reject(error);
71
71
  });
72
- this.kernel?.once("onTerminate", async () => {
72
+ this.httpKernel.addShutdownStep("drain", async () => {
73
73
  if (!this.server) return;
74
74
  try {
75
75
  await this.httpTerminator?.terminate();
@@ -4,8 +4,8 @@ import { handleClientError, handleSessionError } from "./clientError.js";
4
4
  import { routeSocketTimeout } from "../../src/context/http/HttpContext.js";
5
5
  import { bindWithFallback, buildBindPlan } from "../../src/servers/portBinder.js";
6
6
  import { asServersConfig, configuredServerPort } from "../../src/servers/kernelServers.js";
7
- import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js";
8
- import __decorate from "../../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
7
+ import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorateParam.js";
8
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
9
9
  import { Inject, Service, extend, nodefonyError } from "nodefony";
10
10
  import http2 from "node:http2";
11
11
  import https from "node:https";
@@ -76,7 +76,7 @@ let ServerHttps = class ServerHttps extends Service {
76
76
  this.server.on("request", (request, response) => {
77
77
  this.httpKernel.onHttpRequest(request, response, this.type);
78
78
  });
79
- this.kernel?.once("onTerminate", async () => {
79
+ this.httpKernel.addShutdownStep("drain", async () => {
80
80
  if (!this.server) return;
81
81
  try {
82
82
  await this.httpTerminator?.terminate();
@@ -120,7 +120,7 @@ let ServerHttps = class ServerHttps extends Service {
120
120
  this.reportBindError(error);
121
121
  reject(error);
122
122
  });
123
- this.kernel?.once("onTerminate", async () => {
123
+ this.httpKernel.addShutdownStep("drain", async () => {
124
124
  if (!this.server) return;
125
125
  try {
126
126
  await this.httpTerminator?.terminate();
@@ -1,8 +1,8 @@
1
1
  import { asServersConfig, configuredServerPort } from "../../src/servers/kernelServers.js";
2
2
  import { startHeartbeat } from "./wsHeartbeat.js";
3
3
  import { attachUpgradeDispatch, createNodefonyWebSocketServer } from "../../src/servers/upgradeDispatch.js";
4
- import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js";
5
- import __decorate from "../../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
4
+ import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorateParam.js";
5
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
6
6
  import { Inject, Service } from "nodefony";
7
7
  import Ws from "ws";
8
8
  // nodefony/service/servers/server-websocket-secure.ts
@@ -50,7 +50,7 @@ let WebsocketSecure = class WebsocketSecure extends Service {
50
50
  this.httpKernel.onWebsocketUpgrade(req, admission, this.type).catch(() => {});
51
51
  });
52
52
  this.heartbeatTimer = startHeartbeat(this.server, this.options);
53
- this.kernel?.prependOnceListener("onTerminate", this.terminate.bind(this));
53
+ this.httpKernel.addShutdownStep("dismiss", () => this.terminate());
54
54
  this.ready = true;
55
55
  this.module.fire("onServersReady", this.type, this);
56
56
  return resolve(this.server);