@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
@@ -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.
@@ -208,10 +214,11 @@ const PG_INVALID_INPUT = /^invalid input syntax for type (\w+): "(.*)"$/su;
208
214
  /**
209
215
  * Reconnaît un identifiant MAL FORMÉ refusé par la base, et rend la valeur fautive.
210
216
  *
211
- * Deux moteurs le disent : MongoDB par une `CastError` de Mongoose (la clé est
212
- * un ObjectId), PostgreSQL par `22P02` (la clé est un `uuid` ou un entier).
213
- * SQLite et MySQL rangent la clé en texte : ils ne lèvent rien, la lecture rend
214
- * simplement « rien », d'où leur 404 — c'est la cohérence avec eux qui est visée.
217
+ * Trois sources le disent : MongoDB par une `CastError` de Mongoose (la clé est
218
+ * un ObjectId), PostgreSQL par `22P02` (la clé est un `uuid` ou un entier), et
219
+ * le kit de colonnes portable par une `ColumnValueError` de genre `uuid` ou
220
+ * `integer` — il refuse sur les trois moteurs la clé que PostgreSQL refuserait. Une clé rangée en
221
+ * texte NATIF ne lève rien, la lecture rend « rien » : le même 404.
215
222
  *
216
223
  * Même descente dans `cause` que {@link isUniqueViolation} (Drizzle enveloppe).
217
224
  *
@@ -223,6 +230,7 @@ function malformedIdentifierOf(error) {
223
230
  for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
224
231
  const candidate = current;
225
232
  if (candidate.name === "CastError" && (candidate.kind === "ObjectId" || candidate.kind === "UUID") && (typeof candidate.value === "string" || typeof candidate.value === "number")) return String(candidate.value);
233
+ if (candidate.name === "ColumnValueError" && (candidate.kind === "uuid" || candidate.kind === "integer") && typeof candidate.value === "string") return candidate.value;
226
234
  if (String(candidate.code) === "22P02") {
227
235
  const match = PG_INVALID_INPUT.exec(candidate.message);
228
236
  if (match) {
@@ -374,12 +382,10 @@ var DefaultErrorRenderer = class {
374
382
  const status = this.normalizeHttpStatus(httpError.code);
375
383
  httpError.code = status;
376
384
  const obj = context.metaData;
377
- const serialized = httpError.toJSON();
378
- const message = isProduction() && status >= 500 ? OPAQUE_SERVER_ERROR : httpError.message;
379
- if (isProduction()) {
380
- for (const key of INTERNAL_ERROR_KEYS) delete serialized[key];
381
- serialized.message = message;
382
- }
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;
383
389
  obj.error = serialized;
384
390
  obj.code = status;
385
391
  obj.message = message;
@@ -403,6 +409,34 @@ var DefaultErrorRenderer = class {
403
409
  reason
404
410
  };
405
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
+ */
406
440
  toHttpError(error, context) {
407
441
  if (error instanceof HttpError) return error;
408
442
  const fields = toValidationFields(error);
@@ -1,34 +1,44 @@
1
- import { isProfiledUrl } from "../src/profiler/aggregate.js";
2
1
  import HttpError from "../src/errors/httpError.js";
2
+ import { isUnsupportedTransferCoding } from "../src/proxy/rules.js";
3
+ import { hasConflictingAuthority } from "../src/context/transport.js";
4
+ import { grpcWireOf, hasGrpcRoutes, prepareGrpcRefusal } from "../src/grpc/refusal.js";
3
5
  import { buildTrustProxy } from "../src/context/trustProxy.js";
4
6
  import { resolveForwarded } from "../src/context/forwarded.js";
5
7
  import { MemoryRateLimitStore } from "../src/rateLimit/MemoryRateLimitStore.js";
6
8
  import { WsConnectionCounter } from "../src/rateLimit/WsConnectionCounter.js";
7
9
  import { compileDomainPatterns, compileTrustedHosts, isDomainAllowed } from "../src/context/domainMatcher.js";
10
+ import { isProfiledUrl } from "../src/profiler/aggregate.js";
8
11
  import { responseEnded } from "../src/context/responseEnded.js";
9
12
  import { unsentResponseWarning } from "../src/context/unsentResponse.js";
10
13
  import { closeRequestTrace, hasRequestTraceSubscribers, openRequestTrace, runTraced } from "../src/context/requestTrace.js";
11
- import { parseWebsocketUrl } from "../src/context/websocket/websocketUrl.js";
14
+ import { SHUTDOWN_STREAMS_WAIT_MS, ShutdownRegistry, StallSweep } from "../src/context/http/StreamLifecycle.js";
12
15
  import WebsocketContext, { toHandshakeStatus } from "../src/context/websocket/WebsocketContext.js";
16
+ import { ReceiveDeadline } from "../src/servers/receiveDeadline.js";
13
17
  import { trackPong } from "./servers/wsHeartbeat.js";
18
+ import { parseWebsocketUrl } from "../src/context/websocket/websocketUrl.js";
14
19
  import HttpContext from "../src/context/http/HttpContext.js";
15
20
  import DefaultErrorRenderer from "./error-renderer.js";
16
- import JsonAuditLogger from "./audit-logger.js";
17
21
  import DefaultRequestLogger from "./request-logger.js";
18
22
  import PrettyRequestLogger from "./pretty-request-logger.js";
23
+ import JsonAuditLogger from "./audit-logger.js";
19
24
  import { resolveTraceparent } from "./trace.js";
20
25
  import { asServersConfig, configuredServerPort } from "../src/servers/kernelServers.js";
21
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
22
- import { GcScheduler, IDENTITY_CHANGED_EVENT, RequestContext, RevocationWatch, Service, finallyMaybe, injectable, isPromise, logColor, nodefonyError, servedUrl, thenMaybe, writeRuntimeState } from "nodefony";
26
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
27
+ import { BootConfigurationError, GcScheduler, IDENTITY_CHANGED_EVENT, RequestContext, RevocationWatch, Service, finallyMaybe, injectable, isPromise, logColor, nodefonyError, servedUrl, thenMaybe, writeRuntimeState } from "nodefony";
23
28
  import cluster from "node:cluster";
24
29
  import http from "node:http";
25
30
  // nodefony/service/http-kernel.ts
26
- const HEALTH_HEADERS = {
27
- "content-type": "application/json",
28
- "cache-control": "no-store"
29
- };
31
+ const HEALTH_HEADERS = [
32
+ "content-type",
33
+ "application/json",
34
+ "cache-control",
35
+ "no-store"
36
+ ];
30
37
  const HEALTH_BODY_OK = "{\"status\":\"ok\"}";
31
38
  const HEALTH_BODY_UNREADY = "{\"status\":\"unready\"}";
39
+ /** Corps du 429 du limiteur de débit — figé, rien à sérialiser par refus. */
40
+ const TOO_MANY_REQUESTS_BODY = "{\"code\":429,\"message\":\"Too Many Requests\"}";
41
+ const TOO_MANY_REQUESTS_LENGTH = Buffer.byteLength(TOO_MANY_REQUESTS_BODY);
32
42
  const perfProbe = process.env.NF_PERF_PROBE === "1" ? {
33
43
  count: 0,
34
44
  enterScopeNs: 0,
@@ -104,9 +114,33 @@ let HttpKernel = class HttpKernel extends Service {
104
114
  }
105
115
  responseTimeout;
106
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
+ };
107
127
  secContentTypeOptions = null;
108
128
  secFrameOptions = null;
109
129
  secHsts = null;
130
+ /**
131
+ * En-têtes du transport, tableau plat `[nom, valeur, …]` aux noms en
132
+ * minuscules, par famille de serveur (HSTS sur TLS seulement). Figés et
133
+ * VALIDÉS au calcul : chaque réponse les recopie sans les revalider.
134
+ */
135
+ #transportHeaders = {
136
+ http: [],
137
+ https: []
138
+ };
139
+ /** Réponse d'une sonde de santé : le transport, puis type et cache. */
140
+ #healthHeaders = {
141
+ http: [],
142
+ https: []
143
+ };
110
144
  sessionService;
111
145
  router;
112
146
  firewall;
@@ -121,9 +155,11 @@ let HttpKernel = class HttpKernel extends Service {
121
155
  rateLimitGc = null;
122
156
  #wsConnCounter = null;
123
157
  #streams = null;
158
+ #stallSweep = null;
159
+ #openStreams = null;
124
160
  #healthPaths = null;
125
161
  #healthShutdownDelay = 0;
126
- #terminating = false;
162
+ #shutdownSteps = null;
127
163
  constructor(module) {
128
164
  super(serviceName, module.container, module.notificationsCenter, module.options);
129
165
  this.module = module;
@@ -157,12 +193,49 @@ let HttpKernel = class HttpKernel extends Service {
157
193
  if (hsts.preload) v += "; preload";
158
194
  this.secHsts = v;
159
195
  } else this.secHsts = null;
196
+ const headerServer = this.options.headerServer;
197
+ const http1 = [];
198
+ if (headerServer != null && headerServer !== "") http1.push("server", headerServer);
199
+ if (this.secContentTypeOptions !== null) http1.push("x-content-type-options", this.secContentTypeOptions);
200
+ if (this.secFrameOptions !== null) http1.push("x-frame-options", this.secFrameOptions);
201
+ const tls = this.secHsts === null ? http1 : [
202
+ ...http1,
203
+ "strict-transport-security",
204
+ this.secHsts
205
+ ];
206
+ for (let i = 0; i + 1 < tls.length; i += 2) {
207
+ const name = tls[i] ?? "";
208
+ try {
209
+ http.validateHeaderValue(name, tls[i + 1] ?? "");
210
+ } catch (error) {
211
+ throw new BootConfigurationError(`[@nodefony/http] en-tête « ${name} » refusé : ${error instanceof Error ? error.message : String(error)}`);
212
+ }
213
+ }
214
+ this.#transportHeaders = {
215
+ http: http1,
216
+ https: tls
217
+ };
218
+ this.#healthHeaders = {
219
+ http: [...http1, ...HEALTH_HEADERS],
220
+ https: [...tls, ...HEALTH_HEADERS]
221
+ };
222
+ }
223
+ /**
224
+ * En-têtes du transport d'une réponse, figés au démarrage — ce que
225
+ * `HttpResponse` place en tête de sa réserve.
226
+ *
227
+ * @param type - famille du serveur qui a reçu la requête.
228
+ * @returns la liste plate `[nom, valeur, …]`, noms en minuscules.
229
+ */
230
+ transportHeaders(type) {
231
+ return type === "https" ? this.#transportHeaders.https : this.#transportHeaders.http;
160
232
  }
161
233
  /**
162
234
  * Ce que ce kernel ÉMET réellement comme en-têtes de sécurité transport.
163
235
  *
164
236
  * Ces trois en-têtes (HSTS, X-Frame-Options, X-Content-Type-Options) sont posés
165
- * ici, à l'entrée brute, pour couvrir aussi les statiques et les pages d'erreur
237
+ * par `@nodefony/http` sur TOUTE réponse (en tête de la réserve de `HttpResponse`),
238
+ * statiques et pages d'erreur comprises
166
239
  * — pas par `@nodefony/security`, dont les clés homonymes sont inertes. Toute
167
240
  * console d'administration doit donc lire CE point, sinon elle affiche une
168
241
  * intention de configuration au lieu de l'état appliqué.
@@ -189,6 +262,7 @@ let HttpKernel = class HttpKernel extends Service {
189
262
  */
190
263
  onConfigChanged() {
191
264
  this.computeSecurityHeaderCaches();
265
+ this.firewall?.onTransportHeadersChanged?.();
192
266
  this._trustProxyChecker = null;
193
267
  this._wsOriginPolicy = Object.create(null);
194
268
  this.trustedHosts = this.options.trustedHosts;
@@ -233,7 +307,7 @@ let HttpKernel = class HttpKernel extends Service {
233
307
  * Le message nomme l'action, ce qu'elle a rendu et le geste qui convient
234
308
  * (`unsentResponseWarning`).
235
309
  *
236
- * 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
237
311
  * plus rien ne se signalerait — et au démontage, pour un client parti avant.
238
312
  * Gratuit en production : rien n'est lu au-delà de `waitAsync`.
239
313
  *
@@ -296,9 +370,9 @@ let HttpKernel = class HttpKernel extends Service {
296
370
  * @param response - réponse serveur brute (HTTP/1 ou H2 compat)
297
371
  * @param ok - `true` → 200, `false` → 503 (readiness pendant boot/shutdown)
298
372
  */
299
- #respondHealth(response, ok) {
373
+ #respondHealth(response, ok, type) {
300
374
  const res = response;
301
- res.writeHead(ok ? 200 : 503, HEALTH_HEADERS);
375
+ res.writeHead(ok ? 200 : 503, type === "https" ? this.#healthHeaders.https : this.#healthHeaders.http);
302
376
  res.end(ok ? HEALTH_BODY_OK : HEALTH_BODY_UNREADY);
303
377
  }
304
378
  async init() {
@@ -320,13 +394,10 @@ let HttpKernel = class HttpKernel extends Service {
320
394
  });
321
395
  this.once("onTerminate", () => {
322
396
  this.rateLimitGc?.stop();
397
+ this.#h2Receive.http?.close();
398
+ this.#h2Receive.https?.close();
323
399
  });
324
- this.kernel?.once("onPostReady", () => {
325
- this.kernel?.prependOnceListener("onTerminate", async () => {
326
- this.#terminating = true;
327
- if (this.#healthShutdownDelay > 0) await new Promise((r) => setTimeout(r, this.#healthShutdownDelay));
328
- });
329
- });
400
+ this.kernel?.once("onDrain", () => this.drainTraffic());
330
401
  this.kernel?.prependOnceListener("onBoot", () => {
331
402
  this.router = this.get("router");
332
403
  this.firewall = this.get("firewall");
@@ -419,7 +490,7 @@ let HttpKernel = class HttpKernel extends Service {
419
490
  const secure = context.serverType === "wss";
420
491
  const raw = context.origin;
421
492
  if (this.isWebsocketOriginAllowed(raw, context.domain, secure)) return;
422
- 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);
423
494
  error.type = "origin";
424
495
  throw error;
425
496
  }
@@ -523,7 +594,7 @@ let HttpKernel = class HttpKernel extends Service {
523
594
  }
524
595
  throw resolver.exception;
525
596
  }
526
- throw new HttpError("Not Found", 404, context);
597
+ throw HttpError.withoutStack("Not Found", 404, context);
527
598
  }
528
599
  /**
529
600
  * {@link prepareFrontController} + instanciation immédiate du controller
@@ -608,10 +679,14 @@ let HttpKernel = class HttpKernel extends Service {
608
679
  if (!context.response.isHeaderSent()) {
609
680
  if (context.isRedirect) {
610
681
  context.isRedirect = false;
611
- context.response.response?.removeHeader("Location");
682
+ context.response.removeHeader("Location");
612
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");
613
687
  try {
614
- 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"]));
615
690
  } catch (e) {
616
691
  this.log(e, "CRITIC");
617
692
  throw e;
@@ -678,19 +753,14 @@ let HttpKernel = class HttpKernel extends Service {
678
753
  * pipeline.
679
754
  */
680
755
  dispatchHttpRequest(request, response, type) {
681
- const headerServer = this.options.headerServer;
682
- if (headerServer != null && headerServer !== "") response.setHeader("Server", headerServer);
683
- if (this.secContentTypeOptions !== null) response.setHeader("X-Content-Type-Options", this.secContentTypeOptions);
684
- if (this.secFrameOptions !== null) response.setHeader("X-Frame-Options", this.secFrameOptions);
685
- if (this.secHsts !== null && type === "https") response.setHeader("Strict-Transport-Security", this.secHsts);
686
756
  if (this.#healthPaths !== null) {
687
757
  const url = request.url;
688
758
  if (url === this.#healthPaths.liveness) {
689
- this.#respondHealth(response, true);
759
+ this.#respondHealth(response, true, type);
690
760
  return;
691
761
  }
692
762
  if (url === this.#healthPaths.readiness) {
693
- this.#respondHealth(response, !this.#terminating && this.kernel?.servable === true);
763
+ this.#respondHealth(response, this.kernel?.isTerminating !== true && this.kernel?.servable === true, type);
694
764
  return;
695
765
  }
696
766
  }
@@ -704,13 +774,19 @@ let HttpKernel = class HttpKernel extends Service {
704
774
  if (verdict.limited) {
705
775
  response.setHeader("Retry-After", verdict.retryAfterS);
706
776
  const res = response;
707
- res.writeHead(429);
708
- res.end();
777
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
778
+ response.setHeader("Content-Length", TOO_MANY_REQUESTS_LENGTH);
779
+ res.writeHead(429, type === "https" ? this.#transportHeaders.https : this.#transportHeaders.http);
780
+ res.end(TOO_MANY_REQUESTS_BODY);
709
781
  return;
710
782
  }
711
783
  }
712
784
  }
713
- if (this.listenerCount("onServerRequest")) return this.fireAsync("onServerRequest", request, response, type).then(() => this.handle(request, response, type));
785
+ if (this.listenerCount("onServerRequest")) {
786
+ const transport = type === "https" ? this.#transportHeaders.https : this.#transportHeaders.http;
787
+ for (let i = 0; i + 1 < transport.length; i += 2) response.setHeader(transport[i] ?? "", transport[i + 1] ?? "");
788
+ return this.fireAsync("onServerRequest", request, response, type).then(() => this.handle(request, response, type));
789
+ }
714
790
  return this.handle(request, response, type);
715
791
  }
716
792
  async initServers() {
@@ -850,13 +926,29 @@ let HttpKernel = class HttpKernel extends Service {
850
926
  * @returns `undefined` quand le démontage est terminé ; sinon la promesse de
851
927
  * sa fin, qui ne rejette pas.
852
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
+ }
853
944
  teardownHttp(context, scope) {
854
945
  if (context.finished) return void 0;
855
946
  try {
856
947
  context.stopClock();
857
948
  if (context.waitAsync) this.warnUnsentResponse(context);
949
+ if (context.rpc !== null) this.settleRpc(context, context.rpc);
858
950
  context.logRequest();
859
- 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);
860
952
  context.logPhasesVerbose();
861
953
  if (this.shouldProfile(context.url)) this.profiler?.collect(context);
862
954
  const afterResponse = context._runAfterResponse();
@@ -946,7 +1038,7 @@ let HttpKernel = class HttpKernel extends Service {
946
1038
  if (!isPromise(served)) return served;
947
1039
  return served.catch((e) => this.failHttp(e, created, scope));
948
1040
  } catch (e) {
949
- return this.failHttp(e, context, scope);
1041
+ return this.failHttp(e, context, scope, response);
950
1042
  }
951
1043
  }
952
1044
  /**
@@ -986,9 +1078,19 @@ let HttpKernel = class HttpKernel extends Service {
986
1078
  /**
987
1079
  * Chemin d'erreur de {@link handleHttp} : libère le scope d'un contexte qui
988
1080
  * n'a pas pu se construire, puis rend l'erreur au client (`onError`).
1081
+ *
1082
+ * Sans contexte, `onError` n'a rien sur quoi écrire : la réponse BRUTE
1083
+ * reçoit alors un 500 nu. Sinon HTTP/1.1 coupait la connexion sans un octet
1084
+ * et HTTP/2 laissait le flux pendu.
989
1085
  */
990
- failHttp(e, context, scope) {
991
- if (context === null) this.container?.leaveScope(scope);
1086
+ failHttp(e, context, scope, response) {
1087
+ if (context === null) {
1088
+ this.container?.leaveScope(scope);
1089
+ if (response && !response.headersSent) {
1090
+ response.statusCode = 500;
1091
+ response.end();
1092
+ }
1093
+ }
992
1094
  return this.onError(e, context);
993
1095
  }
994
1096
  /**
@@ -1020,6 +1122,7 @@ let HttpKernel = class HttpKernel extends Service {
1020
1122
  */
1021
1123
  routeHttpRequest(httpContext, request, response) {
1022
1124
  if (this.reverseProxy !== null && this.reverseProxy.mounts !== null && (httpContext.validDomain || !this.domainCheck)) {
1125
+ if (this.reverseProxy.match(request.url) !== void 0) httpContext.response.commitHeaders();
1023
1126
  const relayed = this.reverseProxy.forward(request, response, httpContext.scheme);
1024
1127
  if (relayed !== void 0) {
1025
1128
  httpContext.relayAccessLog = this.reverseProxy.match(request.url)?.accessLog ?? "all";
@@ -1029,21 +1132,64 @@ let HttpKernel = class HttpKernel extends Service {
1029
1132
  httpContext.phaseStart("resolve");
1030
1133
  httpContext.resolver = this.router ? this.router.resolve(httpContext) : null;
1031
1134
  httpContext.phaseEnd("resolve");
1032
- this.firewall?.applySecurityHeaders(httpContext);
1135
+ if (this.firewall) httpContext.response.headerPolicy = this.firewall;
1033
1136
  if (this.serverStatic && httpContext.resolver?.resolve !== true && !httpContext.resolver?.exception && (asServersConfig(this.kernel?.options.servers)?.statics || this.kernel?.options.statics || this.serverStatic.hasMounts())) {
1034
- response.removeHeader("Content-Type");
1035
- return this.serverStatic.handle(request, response).catch(() => void 0).then(() => this.serveHttpRequest(httpContext));
1137
+ httpContext.response.removeHeader("Content-Type");
1138
+ httpContext.response.commitBeforeNativeWrite();
1139
+ return this.serverStatic.handle(request, response).catch(() => void 0).then(() => {
1140
+ httpContext.response.disarmNativeWrite();
1141
+ return this.serveHttpRequest(httpContext);
1142
+ });
1036
1143
  }
1037
1144
  return this.serveHttpRequest(httpContext);
1038
1145
  }
1039
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
+ /**
1040
1168
  * Le corps — lu seulement s'il y en a un à lire —, puis les gardes
1041
1169
  * ({@link onRequestEnd}) et l'action. Chacun n'est attendu que s'il attend.
1042
1170
  */
1043
1171
  serveHttpRequest(httpContext) {
1044
- const streamBody = httpContext.resolver?.resolve === true && httpContext.resolver.route?.bodyStream === true;
1172
+ const headers = httpContext.request.headers;
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;
1045
1178
  httpContext.phaseStart("parse");
1046
- 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
+ });
1047
1193
  return thenMaybe(parsed, () => thenMaybe(this.onRequestEnd(httpContext), (guarded) => {
1048
1194
  guarded.phaseStart("action");
1049
1195
  return finallyMaybe(() => guarded.handle(), () => guarded.phaseEnd("action"));
@@ -1098,8 +1244,8 @@ let HttpKernel = class HttpKernel extends Service {
1098
1244
  if (this.listenerCount("afterAuth")) await this.fireAsync("afterAuth", context);
1099
1245
  } catch (authError) {
1100
1246
  if (context.resolver?.exception) {
1101
- context.response.response?.removeHeader("Upgrade");
1102
- context.response.response?.removeHeader("Connection");
1247
+ context.response.removeHeader("Upgrade");
1248
+ context.response.removeHeader("Connection");
1103
1249
  }
1104
1250
  await this.fireAsync("onAuthFailure", context, authError).catch((e) => this.log(e, "ERROR", "onAuthFailure"));
1105
1251
  throw authError;
@@ -1424,17 +1570,17 @@ let HttpKernel = class HttpKernel extends Service {
1424
1570
  return true;
1425
1571
  }
1426
1572
  /**
1427
- * Inscrit un flux SSE à identité révocable : il sera fermé dans la fenêtre de
1573
+ * Inscrit un flux (événements ou octets) à identité révocable : il sera fermé dans la fenêtre de
1428
1574
  * re-validation qui suit la mort de son identité (déconnexion, session
1429
1575
  * révoquée, jeton expiré ou annulé) — le même registre et la même règle que
1430
1576
  * les sockets WebSocket.
1431
1577
  *
1432
- * @internal — appelé par le flux lui-même.
1578
+ * @internal — appelé par le cycle de vie du flux (`StreamLifecycle`).
1433
1579
  */
1434
1580
  watchStream(stream) {
1435
1581
  (this.#streams ??= new RevocationWatch({
1436
1582
  isValid: (entry) => entry.revalidate(),
1437
- revoke: (entry) => void entry.close(),
1583
+ revoke: (entry) => void entry.revoke(),
1438
1584
  identityOf: (entry) => entry.identifier ?? void 0
1439
1585
  })).register(stream);
1440
1586
  }
@@ -1443,7 +1589,118 @@ let HttpKernel = class HttpKernel extends Service {
1443
1589
  this.#streams?.unregister(stream);
1444
1590
  }
1445
1591
  /**
1446
- * Re-valide tous les flux SSE et ferme ceux dont l'identité est morte.
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
+ /**
1703
+ * Re-valide tous les flux en cours et ferme ceux dont l'identité est morte.
1447
1704
  * Publique pour qu'un banc la déclenche sans attendre le tick.
1448
1705
  */
1449
1706
  revalidateStreams() {