@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
@@ -3,6 +3,41 @@ import { thenMaybe, typeOf } from "nodefony";
3
3
  import http, { validateHeaderName, validateHeaderValue } from "node:http";
4
4
  import mime from "mime-types";
5
5
  // nodefony/src/context/http/Response.ts
6
+ /**
7
+ * Plafond, en octets, d'un corps de requête HTTP/1.1 non lu que le serveur
8
+ * accepte de vider pour garder la connexion réutilisable — 256 Kio, le geste du
9
+ * serveur HTTP de Go que Connect reprend (ADR-0026, D5). Au-delà, la réponse
10
+ * ferme la connexion.
11
+ */
12
+ const UNREAD_BODY_DRAIN_LIMIT = 262144;
13
+ /**
14
+ * Silence, en millisecondes, au bout duquel une connexion HTTP/1.1 fermée sur
15
+ * un corps non lu est détruite — 5 s, le `lingering_timeout` par défaut de
16
+ * nginx. La durée TOTALE reste bornée par le délai de réception de Node
17
+ * (`requestTimeout`), qui court tant que le corps n'est pas fini.
18
+ */
19
+ const UNREAD_BODY_LINGER_MS = 5e3;
20
+ /**
21
+ * Fermeture ATTARDÉE (RFC 9112 §9.6) d'une connexion qui part en
22
+ * `connection: close` avec un corps encore en route : la réponse écrite, le
23
+ * serveur ferme son côté écriture et continue de LIRE — Node jette les octets
24
+ * du corps non consommé — jusqu'à ce que le client ferme, ou après
25
+ * {@link UNREAD_BODY_LINGER_MS} de silence.
26
+ *
27
+ * Sans elle, Node détruit la socket aussitôt la réponse écrite
28
+ * (`destroySoon`) : le client qui écrit encore son corps prend une
29
+ * réinitialisation (`EPIPE`, `ECONNRESET`) et ne lit jamais la réponse — un
30
+ * 413 sur un envoi de 5 Mo devenait une erreur réseau. Posée sur CETTE socket
31
+ * seulement, qui ne sert plus d'autre requête.
32
+ *
33
+ * @param socket - la socket de la connexion qui se ferme.
34
+ */
35
+ function lingerBeforeClose(socket) {
36
+ socket.destroySoon = () => {
37
+ socket.setTimeout(UNREAD_BODY_LINGER_MS, () => socket.destroy());
38
+ socket.end();
39
+ };
40
+ }
6
41
  const ANSI_REGEX = new RegExp(["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|"), "g");
7
42
  const stripAnsi = function(val) {
8
43
  return typeof val === "string" ? val.replace(ANSI_REGEX, "") : val;
@@ -100,7 +135,7 @@ var HttpResponse = class {
100
135
  flushing = false;
101
136
  encoding = "utf-8";
102
137
  #text = null;
103
- #textEncoding = "utf-8";
138
+ #textEncoding = "utf8";
104
139
  #buffer = null;
105
140
  contentType = "application/octet-stream";
106
141
  headers = {};
@@ -127,6 +162,11 @@ var HttpResponse = class {
127
162
  * le transport y est déjà, il ne s'amorce plus.
128
163
  */
129
164
  #committed = false;
165
+ /**
166
+ * Méthode `setHeader` native retenue pendant que l'interception du repli
167
+ * statique est armée ; `null` sinon (et sur toute réponse routée).
168
+ */
169
+ #nativeSetHeader = null;
130
170
  constructor(response, context) {
131
171
  this.context = context;
132
172
  this.response = response;
@@ -387,19 +427,37 @@ var HttpResponse = class {
387
427
  else list[at + 1] = value;
388
428
  }
389
429
  /**
430
+ * Range les en-têtes de corrélation du contexte — `X-Request-Id` et le
431
+ * `traceparent` W3C — que toute réponse renvoie, quel qu'en soit l'émetteur.
432
+ * Validés par Node à la remise, comme le reste de la réserve.
433
+ */
434
+ putCorrelationHeaders() {
435
+ if (this.context.requestId) this.#put("x-request-id", this.context.requestId);
436
+ if (this.context.traceparent) this.#put("traceparent", this.context.traceparent);
437
+ }
438
+ /**
390
439
  * Recopie la réserve sur la réponse Node, pour un émetteur qui écrit sans
391
- * passer par {@link writeHead} — fichier statique, relais vers un amont. La
392
- * réserve se vide ; ce qui se pose ensuite y retourne, et Node fusionne les
393
- * deux au `writeHead`.
440
+ * passer par {@link writeHead} — fichier statique, relais vers un amont,
441
+ * adaptateur RPC. La réserve se vide ; ce qui se pose ensuite y retourne, et
442
+ * Node fusionne les deux au `writeHead` (l'émetteur l'emporte, nom par nom).
443
+ *
444
+ * @param contentType - type de la réponse que l'émetteur écrira, quand il
445
+ * est connu d'avance (adaptateur RPC). Omis : il se lit à l'extension du
446
+ * chemin, comme le serveur statique le fera.
394
447
  */
395
- commitHeaders() {
448
+ commitHeaders(contentType) {
396
449
  const res = this.response;
397
450
  if (res === null || res.headersSent) return;
398
451
  if (this.headerPolicy !== null) {
399
- const pathname = this.context.request.pathname;
400
- const type = pathname.endsWith("/") ? false : mime.lookup(pathname);
401
- this.applyHeaderPolicy(type === false ? void 0 : type, true);
452
+ let type = contentType;
453
+ if (type === void 0) {
454
+ const pathname = this.context.request.pathname;
455
+ const found = pathname.endsWith("/") ? false : mime.lookup(pathname);
456
+ if (found !== false) type = found;
457
+ }
458
+ this.applyHeaderPolicy(type, true);
402
459
  }
460
+ this.putCorrelationHeaders();
403
461
  const list = this.#store();
404
462
  for (let i = 0; i + 1 < list.length; i += 2) {
405
463
  const name = list[i];
@@ -416,15 +474,22 @@ var HttpResponse = class {
416
474
  * rien, sa réponse part ensuite en un seul `writeHead`. À désarmer par
417
475
  * {@link disarmNativeWrite} quand le tiers rend la main sans avoir écrit.
418
476
  *
419
- * L'interception est une propriété PROPRE de la réponse, ajoutée puis
420
- * retirée : V8 annule alors la dernière transition de forme de l'objet.
477
+ * L'interception est une propriété PROPRE de la réponse, posée une fois avec
478
+ * des attributs constants — toutes les réponses interceptées partagent donc
479
+ * la même forme — et désarmée en lui RÉAFFECTANT la méthode native, jamais
480
+ * en la supprimant : un `delete` fait basculer la réponse Node en mode
481
+ * dictionnaire (constaté par `%HasFastProperties` sur Node 26, 200 réponses
482
+ * sur 200), et chaque accès suivant de `node:http` le paie — mesuré in situ
483
+ * sur un 404 : `writeHead` +1,1 µs, `end` +4,3 µs.
421
484
  */
422
485
  commitBeforeNativeWrite() {
423
486
  const res = this.response;
424
- if (res === null || res.headersSent || Object.hasOwn(res, "setHeader")) return;
487
+ if (res === null || res.headersSent || this.#nativeSetHeader !== null) return;
425
488
  const native = res.setHeader;
489
+ this.#nativeSetHeader = native;
426
490
  Object.defineProperty(res, "setHeader", {
427
491
  configurable: true,
492
+ enumerable: false,
428
493
  writable: true,
429
494
  value: (name, value) => {
430
495
  this.disarmNativeWrite();
@@ -433,10 +498,17 @@ var HttpResponse = class {
433
498
  }
434
499
  });
435
500
  }
436
- /** Retire l'interception posée par {@link commitBeforeNativeWrite}. */
501
+ /**
502
+ * Retire l'interception posée par {@link commitBeforeNativeWrite} : la
503
+ * propriété propre reçoit la méthode native — la forme de l'objet ne change
504
+ * pas, la réponse reste en mode rapide pour V8.
505
+ */
437
506
  disarmNativeWrite() {
507
+ const native = this.#nativeSetHeader;
508
+ if (native === null) return;
509
+ this.#nativeSetHeader = null;
438
510
  const res = this.response;
439
- if (res !== null && Object.hasOwn(res, "setHeader")) Reflect.deleteProperty(res, "setHeader");
511
+ if (res !== null) Object.defineProperty(res, "setHeader", { value: native });
440
512
  }
441
513
  /**
442
514
  * Retire un en-tête, de la réserve comme de la réponse Node.
@@ -595,7 +667,7 @@ var HttpResponse = class {
595
667
  #setText(text, encoding) {
596
668
  this.#buffer = null;
597
669
  this.#text = text;
598
- this.#textEncoding = encoding;
670
+ this.#textEncoding = encoding === "utf-8" ? "utf8" : encoding;
599
671
  }
600
672
  setLength(body) {
601
673
  if (this.response?.headersSent) throw new Error("Headers already sended");
@@ -625,8 +697,7 @@ var HttpResponse = class {
625
697
  }
626
698
  this.statusMessage = this.getStatusMessage();
627
699
  if (headers) this.#apply(headers);
628
- if (this.context.requestId) this.#put("x-request-id", this.context.requestId);
629
- if (this.context.traceparent) this.#put("traceparent", this.context.traceparent);
700
+ this.putCorrelationHeaders();
630
701
  this.setLength();
631
702
  this.ensureContentTypeHeader();
632
703
  this.applyHeaderPolicy(this.contentTypeForPolicy());
@@ -750,6 +821,38 @@ var HttpResponse = class {
750
821
  return this.response.end(chunk, encoding || this.encoding);
751
822
  }
752
823
  /**
824
+ * Règle le sort d'un corps de requête resté NON LU au moment de répondre —
825
+ * route inconnue (404, 405), refus avant l'action, 408, 413. À appeler AVANT
826
+ * l'écriture des en-têtes ; sans effet si le corps est entièrement reçu.
827
+ *
828
+ * HTTP/1.1 : Node vide lui-même un corps non consommé une fois la réponse
829
+ * écrite, pour réutiliser la connexion — sans borne. On ne le laisse faire
830
+ * que pour un corps DÉCLARÉ d'au plus {@link UNREAD_BODY_DRAIN_LIMIT}
831
+ * octets ; au-delà, ou sans longueur déclarée (`transfer-encoding`), la
832
+ * réponse part avec `connection: close` (RFC 9112 §9.6) et la connexion
833
+ * n'est pas réutilisée — fermée par étapes ({@link lingerBeforeClose}),
834
+ * pour que le client qui écrit encore lise la réponse. La décision se prend
835
+ * sur la longueur ANNONCÉE, sans attendre un octet : la réponse n'est jamais
836
+ * retardée par le corps.
837
+ *
838
+ * HTTP/2 surcharge cette méthode (`Http2Response`) : fermeture du flux en
839
+ * `NO_ERROR` une fois la réponse écrite. Une seule entrée pour les deux
840
+ * protocoles, partagée par tout refus qui laisse un corps non lu (ADR-0026,
841
+ * D5).
842
+ */
843
+ closeUnfinishedRequest() {
844
+ const response = this.response;
845
+ if (response === null || response.headersSent) return;
846
+ if (this.context.request.request.complete) return;
847
+ const headers = this.context.request.headers;
848
+ if (headers["transfer-encoding"] === void 0) {
849
+ if (Number(headers["content-length"] ?? 0) <= 262144) return;
850
+ }
851
+ this.setHeader("connection", "close");
852
+ const socket = response.socket;
853
+ if (socket !== null) lingerBeforeClose(socket);
854
+ }
855
+ /**
753
856
  * Valeur d'un en-tête posé — dans la réserve, sinon sur la réponse Node (ce
754
857
  * qu'un tiers y a écrit directement : `send`, quota).
755
858
  *
@@ -800,4 +903,4 @@ var HttpResponse = class {
800
903
  return this.context.log(pci, severity, msgid, msg);
801
904
  }
802
905
  };
803
- export { HttpResponse as default, isContentlessStatus };
906
+ export { UNREAD_BODY_DRAIN_LIMIT, UNREAD_BODY_LINGER_MS, HttpResponse as default, isContentlessStatus };
@@ -1,10 +1,10 @@
1
- import { MAX_TIMER_MS, checkStreamTimer, openStreamLifecycle } from "./StreamLifecycle.js";
1
+ import { MAX_TIMER_MS, STREAM_STALL_TIMEOUT_MS, checkStreamTimer, openStreamLifecycle } from "./StreamLifecycle.js";
2
2
  import http from "node:http";
3
3
  // nodefony/src/context/http/SseStream.ts
4
4
  /** Battement de cœur par défaut, en ms (§9.2.7 : « every 15 seconds or so »). */
5
5
  const SSE_HEARTBEAT_MS = 15e3;
6
- /** Délai de blocage d'écriture par défaut, en ms. */
7
- const SSE_STALL_TIMEOUT_MS = 3e4;
6
+ /** Délai de blocage d'écriture par défaut, en ms — celui de tout flux. */
7
+ const SSE_STALL_TIMEOUT_MS = STREAM_STALL_TIMEOUT_MS;
8
8
  /** Taille maximale d'un événement par défaut, en octets (1 Mio). */
9
9
  const SSE_MAX_EVENT_BYTES = 1048576;
10
10
  /** Commentaire de battement de cœur — ignoré par tout client conforme. */
@@ -255,7 +255,7 @@ function openSseStream(context, options = {}) {
255
255
  };
256
256
  const opened = openStreamLifecycle(context, {
257
257
  label: "SSE",
258
- stallTimeout: options.stallTimeout ?? 3e4,
258
+ stallTimeout: options.stallTimeout ?? SSE_STALL_TIMEOUT_MS,
259
259
  maxDuration: options.maxDuration ?? false
260
260
  });
261
261
  return opened instanceof Promise ? opened.then((lifecycle) => startSseStream(lifecycle, options, settings)) : startSseStream(opened, options, settings);
@@ -1,6 +1,7 @@
1
1
  import Http2Response from "../http2/Response.js";
2
2
  import HttpError from "../../errors/httpError.js";
3
- import { RequestContext } from "nodefony";
3
+ import { ANNOUNCE_CAP_MS } from "../../servers/http2Goaway.js";
4
+ import { REVOCATION_REVALIDATE_MS, RequestContext } from "nodefony";
4
5
  import http from "node:http";
5
6
  import { AsyncResource } from "node:async_hooks";
6
7
  // nodefony/src/context/http/StreamLifecycle.ts
@@ -32,6 +33,29 @@ import { AsyncResource } from "node:async_hooks";
32
33
  */
33
34
  const MAX_TIMER_MS = 2147483647;
34
35
  /**
36
+ * Délai de blocage par défaut d'un flux, en ms : un client qui ne lit plus
37
+ * depuis ce délai est coupé. Valeur commune aux flux d'événements, d'octets et
38
+ * aux flux RPC.
39
+ */
40
+ const STREAM_STALL_TIMEOUT_MS = 3e4;
41
+ /**
42
+ * Période du balayage de blocage ({@link StallSweep}) : la moitié du délai de
43
+ * blocage. Un client bloqué est coupé entre `stallTimeout` et
44
+ * `stallTimeout + période` après la première contre-pression vue — jamais
45
+ * avant le délai réglé.
46
+ */
47
+ const STALL_SWEEP_PERIOD_MS = STREAM_STALL_TIMEOUT_MS / 2;
48
+ /**
49
+ * Attente maximale, à l'arrêt du serveur, des flux longs qu'on vient de
50
+ * terminer (`HttpKernel.shutdownStreams`), avant le drain des connexions.
51
+ *
52
+ * Un flux qui écoute son signal se ferme en quelques millisecondes : l'attente
53
+ * finit avec lui. Elle ne dure jusqu'à son terme que pour un gestionnaire qui
54
+ * n'écoute rien — et ce temps s'AJOUTE au drain (`shutdownTimeout`), d'où une
55
+ * borne courte, celle de l'annonce du GOAWAY.
56
+ */
57
+ const SHUTDOWN_STREAMS_WAIT_MS = ANNOUNCE_CAP_MS;
58
+ /**
35
59
  * Refuse une option de délai hors de ce qu'un minuteur sait tenir.
36
60
  *
37
61
  * @param label - préfixe du message (`SSE`, `Flux`).
@@ -46,6 +70,42 @@ function checkStreamTimer(label, name, value) {
46
70
  if (!Number.isInteger(value) || value < 1 || value > 2147483647) throw new RangeError(`${label} : ${name} entier entre 1 et ${MAX_TIMER_MS} ms, ou false, attendu`);
47
71
  }
48
72
  /**
73
+ * Constate le transport de la réponse d'un contexte : le flux en HTTP/2, la
74
+ * réponse en HTTP/1.1. Seule implémentation de ce constat — un flux SSE,
75
+ * d'octets ou un appel gRPC le lisent ici.
76
+ *
77
+ * @param context - le contexte de la requête.
78
+ * @returns la cible d'écriture, ou `null` quand la réponse est absente ou que
79
+ * son transport n'est pas reconnu.
80
+ *
81
+ * @internal
82
+ */
83
+ function resolveStreamTarget(context) {
84
+ const response = context.response;
85
+ const stream = response instanceof Http2Response ? response.stream : null;
86
+ if (stream !== null) return stream;
87
+ const nodeResponse = response.response;
88
+ return nodeResponse instanceof http.ServerResponse ? nodeResponse : null;
89
+ }
90
+ /**
91
+ * Désarme le délai de réponse que Nodefony arme sur toute requête
92
+ * (`HttpContext.setTimeout`) : celui du flux en HTTP/2, celui de la socket en
93
+ * HTTP/1.1. Seule implémentation de cette règle — un flux long l'appelle, et
94
+ * une réponse possédée par un tiers aussi, pour qu'à l'expiration Nodefony
95
+ * n'écrive pas un 503 par-dessus (un seul écrivain).
96
+ *
97
+ * En HTTP/1.1, la requête suivante de la même connexion réarme la socket
98
+ * (`HttpContext.setTimeout` compare `socket.timeout` à son délai).
99
+ *
100
+ * @param target - la cible d'écriture constatée par {@link resolveStreamTarget}.
101
+ *
102
+ * @internal
103
+ */
104
+ function disarmResponseTimeout(target) {
105
+ if (target instanceof http.ServerResponse) target.socket?.setTimeout(0);
106
+ else target.setTimeout(0);
107
+ }
108
+ /**
49
109
  * Le cycle de vie d'UN flux ouvert sur une réponse.
50
110
  *
51
111
  * S'obtient par {@link openStreamLifecycle} ; le constructeur n'est appelé
@@ -53,7 +113,7 @@ function checkStreamTimer(label, name, value) {
53
113
  *
54
114
  * @internal
55
115
  */
56
- var StreamLifecycle = class {
116
+ var StreamLifecycle = class StreamLifecycle {
57
117
  context;
58
118
  /** Où l'on écrit : la réponse HTTP/1.1, ou le flux HTTP/2. */
59
119
  target;
@@ -62,6 +122,27 @@ var StreamLifecycle = class {
62
122
  #stallTimeout;
63
123
  #revalidate;
64
124
  identifier;
125
+ #foreignWriter;
126
+ /**
127
+ * Le noyau des registres où le flux s'inscrit, pris À L'OUVERTURE : le
128
+ * nettoyage du contexte (`Context.clean`) remet `context.httpKernel` à
129
+ * `null`, et il passe à la fermeture AVANT celui du flux — relu là, le
130
+ * retrait ne se ferait pas, et le flux fermé resterait dans les registres
131
+ * (révocation, balayage, arrêt) avec son contexte.
132
+ */
133
+ #kernel;
134
+ /** Terminé par le noyau à l'arrêt du serveur — et donc inscrit à son registre. */
135
+ #endsAtShutdown;
136
+ /**
137
+ * Fin d'attente d'un flux balayé : posée sur `drain` quand le balayage voit
138
+ * une contre-pression — créée à la première, `null` avant (lazy).
139
+ */
140
+ #onSweptDrain = null;
141
+ /**
142
+ * Fenêtre laissée à un appel RPC révoqué pour se terminer à son format avant
143
+ * la destruction du transport — armée seulement à la révocation.
144
+ */
145
+ #revokeGrace = null;
65
146
  #closed = false;
66
147
  #cause = null;
67
148
  /**
@@ -95,8 +176,7 @@ var StreamLifecycle = class {
95
176
  this.#stall.unref();
96
177
  return;
97
178
  }
98
- this.#cause = "stall";
99
- this.target.destroy();
179
+ this.#cutStalled();
100
180
  };
101
181
  constructor(context, target, response, settings) {
102
182
  this.context = context;
@@ -105,12 +185,18 @@ var StreamLifecycle = class {
105
185
  this.#stallTimeout = settings.stallTimeout;
106
186
  this.#revalidate = settings.revalidate;
107
187
  this.identifier = settings.identifier;
188
+ this.#foreignWriter = settings.foreignWriter;
189
+ this.#endsAtShutdown = settings.endsAtShutdown;
190
+ const kernel = context.httpKernel;
191
+ this.#kernel = kernel;
108
192
  response.on("close", this.#onResponseClose);
109
193
  if (settings.maxDuration !== false) {
110
194
  this.#lifetime = setTimeout(() => void this.close(), settings.maxDuration);
111
195
  this.#lifetime.unref();
112
196
  }
113
- if (this.#revalidate !== null) context.httpKernel?.watchStream(this);
197
+ if (this.#revalidate !== null) kernel?.watchStream(this);
198
+ if (this.#endsAtShutdown) kernel?.trackStream(this);
199
+ if (this.#foreignWriter && this.#stallTimeout !== false) kernel?.sweepStall(this);
114
200
  }
115
201
  /** Le flux est-il fermé — par le serveur, ou par le client parti ? */
116
202
  get closed() {
@@ -177,22 +263,104 @@ var StreamLifecycle = class {
177
263
  this.#stallSince = 0;
178
264
  }
179
265
  /**
266
+ * Un tour du balayage de blocage, pour un flux dont un tiers tient les
267
+ * écritures ({@link StallSweep}).
268
+ *
269
+ * Une contre-pression vue (`writableNeedDrain`) ouvre l'attente et pose un
270
+ * `drain` : seul ce progrès RÉEL la ferme, jamais un tour qui verrait le
271
+ * tampon vide — un client lent qui lit voit son tampon se remplir à nouveau
272
+ * aussitôt, et serait sinon pris pour bloqué. Une attente qui dure
273
+ * `stallTimeout` coupe le client.
274
+ *
275
+ * @param now - l'heure du tour (`Date.now()`), lue une fois pour tous les flux.
276
+ */
277
+ sweepStall(now) {
278
+ const limit = this.#stallTimeout;
279
+ if (this.#closed || limit === false) return;
280
+ if (this.#stallSince !== 0) {
281
+ if (now - this.#stallSince >= limit) this.#cutStalled();
282
+ return;
283
+ }
284
+ if (!this.target.writableNeedDrain) return;
285
+ this.#stallSince = now;
286
+ this.target.once("drain", this.#onSweptDrain ??= () => {
287
+ this.#stallSince = 0;
288
+ });
289
+ }
290
+ /** Coupe un client qui ne lit plus : la fermeture de la réponse termine le flux. */
291
+ #cutStalled() {
292
+ this.#cause = "stall";
293
+ this.target.destroy();
294
+ }
295
+ /**
180
296
  * Désarme le délai d'inactivité de la requête : un flux n'a pas de fin
181
297
  * attendue, ses propres gardes le remplacent.
182
298
  */
183
299
  disarmIdleTimeout() {
184
- const target = this.target;
185
- if (target instanceof http.ServerResponse) target.socket?.setTimeout(0);
186
- else target.setTimeout(0);
300
+ disarmResponseTimeout(this.target);
187
301
  }
188
- /** Ferme le flux et termine la requête. Idempotent. */
302
+ /**
303
+ * Ferme le flux et termine la requête. Idempotent. Quand un tiers tient les
304
+ * écritures, le transport est détruit : terminer la réponse en ferait un
305
+ * second écrivain.
306
+ */
189
307
  close() {
190
308
  if (this.#closed) return void 0;
191
309
  this.#cause = "server";
192
310
  this.finish();
311
+ if (this.#foreignWriter) {
312
+ this.target.destroy();
313
+ return;
314
+ }
193
315
  const done = this.context.close();
194
316
  return done instanceof Promise ? done.then(() => void 0) : void 0;
195
317
  }
318
+ /**
319
+ * L'identité qui a ouvert le flux est morte (révocation, droits changés) :
320
+ * le flux prend fin. Idempotent.
321
+ *
322
+ * Quand un adaptateur RPC tient les écritures, c'est LUI qui termine l'appel,
323
+ * au format de son protocole (`HttpContext.rpc.revoke`, gRPC : 16) : le
324
+ * client lit un statut, pas une coupure, et aucun second écrivain ne touche
325
+ * la réponse. Un gestionnaire qui n'entend rien — bloqué, sans message à
326
+ * produire — est détruit à la fenêtre de re-validation suivante. Sinon :
327
+ * {@link close}.
328
+ */
329
+ revoke() {
330
+ if (this.#closed) return void 0;
331
+ const rpc = this.#foreignWriter ? this.context.rpc : null;
332
+ if (rpc === null) return this.close();
333
+ if (this.#revokeGrace !== null) return void 0;
334
+ this.#cause = "server";
335
+ rpc.revoke();
336
+ this.#revokeGrace = setTimeout(StreamLifecycle.#destroyRevoked, REVOCATION_REVALIDATE_MS, this);
337
+ this.#revokeGrace.unref();
338
+ }
339
+ /**
340
+ * Le serveur s'arrête : le flux prend fin AVANT le drain des connexions
341
+ * (ADR-0026 D8). Idempotent.
342
+ *
343
+ * Quand un adaptateur RPC tient les écritures, c'est LUI qui termine l'appel,
344
+ * au format de son protocole (`HttpContext.rpc.shutdown`, gRPC : 14
345
+ * `UNAVAILABLE`) : le client le rejoue ailleurs. Un gestionnaire qui n'entend
346
+ * rien tombe avec sa connexion, détruite par le drain à `shutdownTimeout` —
347
+ * aucun minuteur de plus. Sinon : {@link close} — `EventSource` se
348
+ * reconnecte, vers un autre exemplaire.
349
+ */
350
+ shutdown() {
351
+ if (this.#closed) return void 0;
352
+ const rpc = this.#foreignWriter ? this.context.rpc : null;
353
+ if (rpc === null) return this.close();
354
+ this.#cause = "server";
355
+ rpc.shutdown();
356
+ }
357
+ /** Fenêtre écoulée : l'appel révoqué n'a pas fini, le transport est détruit. */
358
+ static #destroyRevoked(lifecycle) {
359
+ lifecycle.#revokeGrace = null;
360
+ if (lifecycle.#closed) return;
361
+ lifecycle.finish();
362
+ lifecycle.target.destroy();
363
+ }
196
364
  /** Fin du flux : minuteurs, écouteurs et registre libérés, une seule fois. */
197
365
  finish() {
198
366
  if (this.#closed) return;
@@ -202,12 +370,22 @@ var StreamLifecycle = class {
202
370
  clearTimeout(this.#lifetime);
203
371
  this.#lifetime = null;
204
372
  }
373
+ if (this.#revokeGrace !== null) {
374
+ clearTimeout(this.#revokeGrace);
375
+ this.#revokeGrace = null;
376
+ }
205
377
  this.#stallSince = 0;
206
378
  if (this.#stall !== null) {
207
379
  clearTimeout(this.#stall);
208
380
  this.#stall = null;
209
381
  }
210
- if (this.#revalidate !== null) this.context.httpKernel?.unwatchStream(this);
382
+ const kernel = this.#kernel;
383
+ if (this.#revalidate !== null) kernel?.unwatchStream(this);
384
+ if (this.#endsAtShutdown) kernel?.untrackStream(this);
385
+ if (this.#foreignWriter) {
386
+ kernel?.unsweepStall(this);
387
+ if (this.#onSweptDrain !== null) this.target.removeListener("drain", this.#onSweptDrain);
388
+ }
211
389
  const release = this.#release;
212
390
  this.#release = null;
213
391
  release?.();
@@ -217,6 +395,139 @@ var StreamLifecycle = class {
217
395
  }
218
396
  };
219
397
  /**
398
+ * Le balayage de blocage des flux dont un tiers tient les écritures : UN
399
+ * minuteur pour tous, armé au premier flux inscrit, arrêté au dernier retiré.
400
+ *
401
+ * Pourquoi un balayage : l'adaptateur écrit et attend `drain` lui-même, la
402
+ * brique ne voit aucune écriture. Le délai d'inactivité que Node tient sur un
403
+ * flux HTTP/2 ne suffit pas : il est repoussé tant que la SESSION avance
404
+ * (`internal/http2/core#callTimeout`), si bien qu'un flux bloqué ne serait
405
+ * jamais coupé tant qu'un voisin de la même connexion est lu.
406
+ *
407
+ * Le seuil reste dans {@link StreamLifecycle.sweepStall} : une règle, une
408
+ * implémentation.
409
+ *
410
+ * Coût mesuré (300 flux HTTP/2 réels, médiane de 9 passes) : ~6 ns par flux et
411
+ * par tour ; ~60 ns pour inscrire puis retirer un flux ; 0 par message.
412
+ *
413
+ * @internal
414
+ */
415
+ var StallSweep = class {
416
+ #entries = null;
417
+ #timer = null;
418
+ #periodMs;
419
+ #tick = () => {
420
+ this.sweep(Date.now());
421
+ };
422
+ /** @param periodMs - période du balayage (défaut {@link STALL_SWEEP_PERIOD_MS}). */
423
+ constructor(periodMs = STALL_SWEEP_PERIOD_MS) {
424
+ this.#periodMs = periodMs;
425
+ }
426
+ /** Nombre de flux balayés. */
427
+ get size() {
428
+ return this.#entries?.size ?? 0;
429
+ }
430
+ /** Le minuteur est-il armé ? */
431
+ get running() {
432
+ return this.#timer !== null;
433
+ }
434
+ /** Inscrit un flux ; arme le minuteur au premier. */
435
+ add(lifecycle) {
436
+ (this.#entries ??= /* @__PURE__ */ new Set()).add(lifecycle);
437
+ if (this.#timer === null) {
438
+ this.#timer = setInterval(this.#tick, this.#periodMs);
439
+ this.#timer.unref();
440
+ }
441
+ }
442
+ /** Retire un flux (idempotent) ; arrête le minuteur quand il n'en reste aucun. */
443
+ delete(lifecycle) {
444
+ if (this.#entries === null) return;
445
+ this.#entries.delete(lifecycle);
446
+ if (this.#entries.size === 0 && this.#timer !== null) {
447
+ clearInterval(this.#timer);
448
+ this.#timer = null;
449
+ }
450
+ }
451
+ /**
452
+ * Un tour : chaque flux inscrit juge son propre blocage.
453
+ *
454
+ * @param now - l'heure du tour (`Date.now()`).
455
+ */
456
+ sweep(now) {
457
+ if (this.#entries === null) return;
458
+ for (const lifecycle of this.#entries) lifecycle.sweepStall(now);
459
+ }
460
+ };
461
+ /**
462
+ * Le registre de l'arrêt : TOUS les flux longs ouverts qui n'ont pas de fin à
463
+ * eux — SSE, flux RPC, flux d'octets vivants (ADR-0026 D8). À l'arrêt du
464
+ * serveur, le noyau les termine AVANT le drain des connexions, au lieu de les
465
+ * laisser détruire sans un mot à `shutdownTimeout`.
466
+ *
467
+ * Distinct du registre de révocation (`RevocationWatch`), qui ne tient que les
468
+ * flux à identité : y mettre les anonymes ferait travailler chaque fenêtre de
469
+ * re-validation pour rien.
470
+ *
471
+ * Coût : un ajout et un retrait par flux long, constants (44 à 77 ns mesurés
472
+ * de 0 à 10 000 flux ouverts, ADR-0026 D8) ; 0 par appel unaire, 0 par
473
+ * message.
474
+ *
475
+ * @internal
476
+ */
477
+ var ShutdownRegistry = class {
478
+ #entries = /* @__PURE__ */ new Set();
479
+ /** L'arrêt a commencé : un flux inscrit désormais est terminé au tour suivant. */
480
+ #closing = false;
481
+ /** Fin de l'attente de l'arrêt, appelée au retrait du dernier flux. */
482
+ #onEmpty = null;
483
+ /** Nombre de flux ouverts. */
484
+ get size() {
485
+ return this.#entries.size;
486
+ }
487
+ /**
488
+ * Inscrit un flux. Pendant l'arrêt, il est terminé au tour suivant — une
489
+ * fois son appel RPC relié, qui ne l'est qu'après l'ouverture.
490
+ */
491
+ add(stream) {
492
+ this.#entries.add(stream);
493
+ if (this.#closing) setImmediate(shutdownLate, stream);
494
+ }
495
+ /** Retire un flux (idempotent) ; le dernier retiré met fin à l'attente. */
496
+ delete(stream) {
497
+ if (!this.#entries.delete(stream) || this.#entries.size !== 0) return;
498
+ const done = this.#onEmpty;
499
+ this.#onEmpty = null;
500
+ done?.();
501
+ }
502
+ /**
503
+ * L'arrêt : termine chaque flux, puis attend que tous se soient retirés, au
504
+ * plus `waitMs`.
505
+ *
506
+ * @param waitMs - attente maximale des fermetures.
507
+ * @returns le nombre de flux encore ouverts à la fin de l'attente — tout de
508
+ * suite quand il n'y a rien à attendre, sinon sa promesse.
509
+ */
510
+ shutdown(waitMs) {
511
+ this.#closing = true;
512
+ for (const stream of this.#entries) stream.shutdown();
513
+ if (this.#entries.size === 0) return 0;
514
+ return new Promise((resolve) => {
515
+ const timer = setTimeout(() => {
516
+ this.#onEmpty = null;
517
+ resolve(this.#entries.size);
518
+ }, waitMs);
519
+ this.#onEmpty = () => {
520
+ clearTimeout(timer);
521
+ resolve(0);
522
+ };
523
+ });
524
+ }
525
+ };
526
+ /** Termine un flux inscrit pendant l'arrêt (rappel de `setImmediate`). */
527
+ function shutdownLate(stream) {
528
+ stream.shutdown();
529
+ }
530
+ /**
220
531
  * Ouvre le cycle de vie d'un flux sur la réponse d'un contexte HTTP.
221
532
  *
222
533
  * Prend la réponse, pose l'écoute de sa fermeture, puis sauve la session —
@@ -237,14 +548,10 @@ var StreamLifecycle = class {
237
548
  function openStreamLifecycle(context, options) {
238
549
  const { label } = options;
239
550
  if (context.sended || context.finished || context.response.isHeaderSent()) throw new HttpError(`${label} : la réponse est déjà partie`, 500);
240
- const response = context.response;
241
- const nodeResponse = response.response;
551
+ const nodeResponse = context.response.response;
242
552
  if (nodeResponse === null) throw new HttpError(`${label} : réponse introuvable`, 500, context);
243
- const stream = response instanceof Http2Response ? response.stream : null;
244
- let target;
245
- if (stream !== null) target = stream;
246
- else if (nodeResponse instanceof http.ServerResponse) target = nodeResponse;
247
- else throw new HttpError(`${label} : transport de réponse inconnu`, 500, context);
553
+ const target = resolveStreamTarget(context);
554
+ if (target === null) throw new HttpError(`${label} : transport de réponse inconnu`, 500, context);
248
555
  const gone = target.destroyed;
249
556
  const kernel = context.httpKernel;
250
557
  if (!gone && kernel !== null && !kernel.acquireConnectionSlot(context.request.request, nodeResponse)) throw new HttpError(`${label} : trop de connexions ouvertes depuis cette adresse`, 429, context);
@@ -255,7 +562,9 @@ function openStreamLifecycle(context, options) {
255
562
  stallTimeout: options.stallTimeout,
256
563
  maxDuration: options.maxDuration,
257
564
  revalidate,
258
- identifier
565
+ identifier,
566
+ foreignWriter: options.foreignWriter ?? false,
567
+ endsAtShutdown: options.endsAtShutdown ?? true
259
568
  });
260
569
  if (gone) {
261
570
  lifecycle.finish();
@@ -270,4 +579,4 @@ function openStreamLifecycle(context, options) {
270
579
  return lifecycle;
271
580
  });
272
581
  }
273
- export { MAX_TIMER_MS, StreamLifecycle, checkStreamTimer, openStreamLifecycle };
582
+ export { MAX_TIMER_MS, SHUTDOWN_STREAMS_WAIT_MS, STALL_SWEEP_PERIOD_MS, STREAM_STALL_TIMEOUT_MS, ShutdownRegistry, StallSweep, StreamLifecycle, checkStreamTimer, disarmResponseTimeout, openStreamLifecycle, resolveStreamTarget };