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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorateParam.js +1 -1
  4. package/dist/index.js +29 -26
  5. package/dist/nodefony/command/proxyGenerateCommand.js +2 -2
  6. package/dist/nodefony/config/config.js +4 -2
  7. package/dist/nodefony/service/ProfilerAdminApi.js +5 -2
  8. package/dist/nodefony/service/error-renderer.js +6 -4
  9. package/dist/nodefony/service/http-kernel.js +112 -34
  10. package/dist/nodefony/service/pretty-request-logger.js +1 -1
  11. package/dist/nodefony/service/request-logger.js +1 -1
  12. package/dist/nodefony/service/reverse-proxy.js +7 -2
  13. package/dist/nodefony/service/servers/clientError.js +28 -4
  14. package/dist/nodefony/service/servers/server-h2c.js +5 -5
  15. package/dist/nodefony/service/servers/server-http.js +5 -3
  16. package/dist/nodefony/service/servers/server-https.js +12 -5
  17. package/dist/nodefony/service/servers/server-static.js +1 -6
  18. package/dist/nodefony/service/servers/server-websocket-secure.js +6 -10
  19. package/dist/nodefony/service/servers/server-websocket.js +6 -10
  20. package/dist/nodefony/service/servers/serverShutdown.js +63 -4
  21. package/dist/nodefony/service/sessions/sessions-service.js +3 -3
  22. package/dist/nodefony/service/upload/upload-service.js +2 -2
  23. package/dist/nodefony/src/assets/prebuiltUi.js +23 -5
  24. package/dist/nodefony/src/context/http/ByteStream.js +359 -0
  25. package/dist/nodefony/src/context/http/HttpContext.js +55 -26
  26. package/dist/nodefony/src/context/http/Request.js +15 -6
  27. package/dist/nodefony/src/context/http/Response.js +354 -35
  28. package/dist/nodefony/src/context/http/SseStream.js +81 -146
  29. package/dist/nodefony/src/context/http/StreamLifecycle.js +273 -0
  30. package/dist/nodefony/src/context/http/urlFastPath.js +80 -1
  31. package/dist/nodefony/src/context/http2/Request.js +1 -1
  32. package/dist/nodefony/src/context/http2/Response.js +8 -4
  33. package/dist/nodefony/src/context/transport.js +20 -1
  34. package/dist/nodefony/src/context/websocket/Response.js +4 -2
  35. package/dist/nodefony/src/context/websocket/WebsocketContext.js +71 -14
  36. package/dist/nodefony/src/context/websocket/websocketUrl.js +7 -0
  37. package/dist/nodefony/src/context/websocket/wsBackpressure.js +15 -15
  38. package/dist/nodefony/src/context/websocket/wsFrameRate.js +71 -0
  39. package/dist/nodefony/src/context/websocket/wsLogContent.js +55 -17
  40. package/dist/nodefony/src/cookies/cookie.js +54 -13
  41. package/dist/nodefony/src/profiler/Profiler.js +14 -1
  42. package/dist/nodefony/src/proxy/forward.js +109 -9
  43. package/dist/nodefony/src/proxy/generateProxyConfig.js +6 -2
  44. package/dist/nodefony/src/proxy/rules.js +29 -14
  45. package/dist/nodefony/src/servers/upgradeDispatch.js +62 -4
  46. package/dist/types/index.d.ts +6 -0
  47. package/dist/types/nodefony/config/config.d.ts +4 -0
  48. package/dist/types/nodefony/interfaces/ISecurity.d.ts +27 -2
  49. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +2 -1
  50. package/dist/types/nodefony/service/error-renderer.d.ts +5 -4
  51. package/dist/types/nodefony/service/http-kernel.d.ts +15 -2
  52. package/dist/types/nodefony/service/servers/clientError.d.ts +3 -2
  53. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +5 -3
  54. package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +9 -4
  55. package/dist/types/nodefony/src/context/http/ByteStream.d.ts +109 -0
  56. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +45 -3
  57. package/dist/types/nodefony/src/context/http/Request.d.ts +8 -1
  58. package/dist/types/nodefony/src/context/http/Response.d.ts +134 -8
  59. package/dist/types/nodefony/src/context/http/SseStream.d.ts +32 -7
  60. package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +53 -0
  61. package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +40 -0
  62. package/dist/types/nodefony/src/context/http2/Response.d.ts +5 -3
  63. package/dist/types/nodefony/src/context/transport.d.ts +17 -0
  64. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +40 -0
  65. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +4 -0
  66. package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +12 -2
  67. package/dist/types/nodefony/src/context/websocket/wsFrameRate.d.ts +69 -0
  68. package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +16 -8
  69. package/dist/types/nodefony/src/cookies/cookie.d.ts +20 -2
  70. package/dist/types/nodefony/src/profiler/Profiler.d.ts +11 -1
  71. package/dist/types/nodefony/src/proxy/forward.d.ts +30 -2
  72. package/dist/types/nodefony/src/proxy/rules.d.ts +20 -0
  73. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +41 -1
  74. package/docs/byte-stream.md +371 -0
  75. package/docs/cookies.md +13 -3
  76. package/docs/index.md +8 -0
  77. package/docs/reverse-proxy.md +15 -3
  78. package/docs/servers.md +26 -15
  79. package/docs/session.md +3 -3
  80. package/docs/sse.md +27 -20
  81. package/package.json +16 -16
@@ -1,8 +1,5 @@
1
- import HttpError from "../../errors/httpError.js";
2
- import Http2Response from "../http2/Response.js";
3
- import { RequestContext } from "nodefony";
1
+ import { MAX_TIMER_MS, checkStreamTimer, openStreamLifecycle } from "./StreamLifecycle.js";
4
2
  import http from "node:http";
5
- import { AsyncResource } from "node:async_hooks";
6
3
  // nodefony/src/context/http/SseStream.ts
7
4
  /** Battement de cœur par défaut, en ms (§9.2.7 : « every 15 seconds or so »). */
8
5
  const SSE_HEARTBEAT_MS = 15e3;
@@ -10,12 +7,6 @@ const SSE_HEARTBEAT_MS = 15e3;
10
7
  const SSE_STALL_TIMEOUT_MS = 3e4;
11
8
  /** Taille maximale d'un événement par défaut, en octets (1 Mio). */
12
9
  const SSE_MAX_EVENT_BYTES = 1048576;
13
- /**
14
- * Plus long délai qu'un minuteur sait tenir (2^31-1 ms). Au-delà, Node le
15
- * ramène à 1 ms : un `retry` qui le dépasse ferait reconnecter le client en
16
- * rafale, un battement qui le dépasse battrait mille fois par seconde.
17
- */
18
- const MAX_TIMER_MS = 2147483647;
19
10
  /** Commentaire de battement de cœur — ignoré par tout client conforme. */
20
11
  const HEARTBEAT = ":\n\n";
21
12
  /** Fin de ligne : CRLF, CR ou LF (§9.2.5). */
@@ -38,21 +29,19 @@ function singleLine(name, value) {
38
29
  *
39
30
  * `JSON.stringify` rend `undefined` pour `undefined`, une fonction ou un
40
31
  * symbole, et son type `string` le tait : on l'élargit avant de le lire.
32
+ *
33
+ * Des octets sont refusés : un flux SSE est du texte UTF-8 (WHATWG HTML
34
+ * §9.2), et `JSON.stringify` les rendrait en objet (`[1,2,255]` →
35
+ * `{"0":1,"1":2,"2":255}`) sans erreur (ADR-0021 S8).
36
+ *
37
+ * @throws TypeError quand `data` est un `ArrayBuffer` ou une vue sur octets.
41
38
  */
42
39
  function serialize(data) {
43
40
  if (typeof data === "string") return data;
41
+ if (ArrayBuffer.isView(data) || data instanceof ArrayBuffer) throw new TypeError("SSE : un flux SSE ne porte que du texte UTF-8, des octets n'y passent pas — les encoder en base64 dans la charge, avec leur type MIME (ex. { mimeType: \"image/png\", data: \"<base64>\" }).");
44
42
  const json = JSON.stringify(data);
45
43
  return typeof json === "string" ? json : "";
46
44
  }
47
- /**
48
- * Refuse une option de délai hors de ce qu'un minuteur sait tenir.
49
- *
50
- * @throws RangeError quand la valeur n'est ni `false` ni un entier entre 1 et 2^31-1.
51
- */
52
- function checkTimer(name, value) {
53
- if (value === void 0 || value === false) return;
54
- if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_MS) throw new RangeError(`SSE : ${name} entier entre 1 et ${MAX_TIMER_MS} ms, ou false, attendu`);
55
- }
56
45
  /** Préfixe chaque ligne d'un texte par `prefix` — `data: ` ou `: `. */
57
46
  function lines(prefix, text) {
58
47
  let out = "";
@@ -65,69 +54,53 @@ function lines(prefix, text) {
65
54
  * Il s'obtient par {@link openSseStream} (ou `this.renderSse()` dans un
66
55
  * contrôleur), jamais par `new` : l'ouverture écrit les en-têtes, et un flux
67
56
  * sans en-têtes ne se rattrape pas.
57
+ *
58
+ * Typé par une carte d'événements (`ContractServerEmit<C>` : le MÊME contrat
59
+ * que la socket temps réel) : `event:` est un canal de la carte et la charge est
60
+ * celle du canal. Deux formes se lisent sur la même carte — **multiplexé**
61
+ * (`send(data, { event: canal })`, plusieurs canaux sur une connexion) ou **un
62
+ * flux = un canal** (carte d'un seul canal, `send(data)` sans `event`). Sans
63
+ * carte, le flux reste libre (porte MCP, flux hors contrat). Le type n'est pas
64
+ * une validation, et ne coûte rien à l'exécution.
65
+ *
66
+ * @typeParam Events - carte des événements du flux.
68
67
  */
69
68
  var SseStream = class {
70
- #context;
71
- #target;
72
- /** La réponse dont on écoute la fermeture — en HTTP/2, l'objet de compatibilité. */
73
- #response;
74
- #stallTimeout;
69
+ /** Cycle de vie partagé avec le flux d'octets : fin, gardes, registre. */
70
+ #lifecycle;
75
71
  #maxEventBytes;
76
- #revalidate;
77
- /**
78
- * Identifiant de connexion de l'identité qui a ouvert le flux (`null` pour
79
- * un anonyme) — le registre de révocation ferme par lui les flux d'une
80
- * identité dont les droits ont changé.
81
- *
82
- * @internal
83
- */
84
- identifier;
85
- #closed = false;
86
72
  #heartbeat = null;
87
- /** Coupure d'un client qui ne lit plus — armée seulement sous pression. */
88
- #stall = null;
89
- /** Fin de vie du flux — armée seulement si `maxDuration` est posé. */
90
- #lifetime = null;
91
- /** Écouteurs de fermeture — `null` tant que personne n'en pose (lazy). */
92
- #onClose = null;
93
73
  /** Attente de `drain` en cours, partagée par tous les `send()` sous pression. */
94
74
  #drain = null;
95
75
  #releaseDrain = null;
96
- #onResponseClose = () => this.#finish();
97
76
  #onDrain = () => this.#settleDrain();
98
- /**
99
- * Le client ne lit plus depuis `stallTimeout` : on coupe la connexion, comme
100
- * s'il était parti — la fermeture de la réponse termine le flux.
101
- */
102
- #onStall = () => {
103
- this.#stall = null;
104
- this.#target.destroy();
105
- };
106
77
  /** @internal — passer par {@link openSseStream}. */
107
- constructor(context, target, response, settings) {
108
- this.#context = context;
109
- this.#target = target;
110
- this.#response = response;
111
- this.#stallTimeout = settings.stallTimeout;
78
+ constructor(lifecycle, settings) {
79
+ this.#lifecycle = lifecycle;
112
80
  this.#maxEventBytes = settings.maxEventBytes;
113
- this.#revalidate = settings.revalidate;
114
- this.identifier = settings.identifier;
115
- response.on("close", this.#onResponseClose);
81
+ lifecycle.onRelease(() => this.#release());
116
82
  const heartbeat = settings.heartbeat;
117
- if (settings.maxDuration !== false) {
118
- this.#lifetime = setTimeout(() => void this.close(), settings.maxDuration);
119
- this.#lifetime.unref();
120
- }
121
- if (heartbeat !== false && heartbeat > 0) {
83
+ if (!lifecycle.closed && heartbeat !== false && heartbeat > 0) {
84
+ const target = lifecycle.target;
122
85
  this.#heartbeat = setInterval(() => {
123
- if (this.#drain === null) this.#target.write(HEARTBEAT);
86
+ if (this.#drain === null) target.write(HEARTBEAT);
124
87
  }, heartbeat);
125
88
  this.#heartbeat.unref();
126
89
  }
127
90
  }
91
+ /**
92
+ * Identifiant de connexion de l'identité qui a ouvert le flux (`null` pour
93
+ * un anonyme) — le registre de révocation ferme par lui les flux d'une
94
+ * identité dont les droits ont changé.
95
+ *
96
+ * @internal
97
+ */
98
+ get identifier() {
99
+ return this.#lifecycle.identifier;
100
+ }
128
101
  /** Le flux est-il fermé — par le serveur (`close()`) ou par le client parti ? */
129
102
  get closed() {
130
- return this.#closed;
103
+ return this.#lifecycle.closed;
131
104
  }
132
105
  /**
133
106
  * L'identité qui a ouvert le flux est-elle toujours valable ? Lu par le
@@ -136,7 +109,7 @@ var SseStream = class {
136
109
  * @internal
137
110
  */
138
111
  revalidate() {
139
- return this.#revalidate === null ? Promise.resolve(true) : this.#revalidate();
112
+ return this.#lifecycle.revalidate();
140
113
  }
141
114
  /**
142
115
  * Envoie un événement.
@@ -147,7 +120,9 @@ var SseStream = class {
147
120
  * @returns rien quand l'écriture est acceptée ; une promesse réglée au
148
121
  * `drain` quand le client lit moins vite qu'on écrit — l'attendre borne la
149
122
  * mémoire du serveur. Sans effet une fois le flux fermé.
150
- * @throws TypeError quand `event` ou `id` contient une fin de ligne.
123
+ * @throws TypeError quand `event` ou `id` contient une fin de ligne, ou
124
+ * quand `data` est fait d'octets (`Uint8Array`, `Buffer`, `ArrayBuffer`) :
125
+ * un flux SSE ne porte que du texte, les encoder soi-même (base64).
151
126
  * @throws RangeError quand l'événement dépasse `maxEventBytes`.
152
127
  */
153
128
  send(data, options) {
@@ -174,7 +149,7 @@ var SseStream = class {
174
149
  * @throws RangeError quand `ms` n'est pas un entier entre 0 et 2^31-1.
175
150
  */
176
151
  retry(ms) {
177
- if (!Number.isInteger(ms) || ms < 0 || ms > MAX_TIMER_MS) throw new RangeError(`retry : entier entre 0 et ${MAX_TIMER_MS} ms attendu`);
152
+ if (!Number.isInteger(ms) || ms < 0 || ms > 2147483647) throw new RangeError(`retry : entier entre 0 et ${MAX_TIMER_MS} ms attendu`);
178
153
  return this.#write(`retry: ${ms}\n\n`);
179
154
  }
180
155
  /**
@@ -186,13 +161,7 @@ var SseStream = class {
186
161
  * Un flux déjà fermé appelle l'écouteur au tour suivant.
187
162
  */
188
163
  onClose(listener) {
189
- const bound = AsyncResource.bind(listener);
190
- if (this.#closed) {
191
- queueMicrotask(bound);
192
- return;
193
- }
194
- this.#onClose ??= [];
195
- this.#onClose.push(bound);
164
+ this.#lifecycle.onClose(listener);
196
165
  }
197
166
  /**
198
167
  * Ferme le flux et termine la requête. Idempotent.
@@ -200,58 +169,40 @@ var SseStream = class {
200
169
  * @returns la fin de la requête (hooks `onClose` du contexte compris).
201
170
  */
202
171
  close() {
203
- if (this.#closed) return void 0;
204
- this.#finish();
205
- const done = this.#context.close();
206
- return done instanceof Promise ? done.then(() => void 0) : void 0;
172
+ return this.#lifecycle.close();
207
173
  }
208
174
  /** Écrit une trame ; une promesse seulement sous contre-pression. */
209
175
  #write(frame) {
210
- if (this.#closed) return void 0;
176
+ const lifecycle = this.#lifecycle;
177
+ if (lifecycle.closed) return void 0;
211
178
  const max = this.#maxEventBytes;
212
179
  if (frame.length * 3 > max && Buffer.byteLength(frame) > max) throw new RangeError(`SSE : événement de plus de ${max} octets refusé`);
213
- if (this.#target.write(frame)) return void 0;
180
+ const target = lifecycle.target;
181
+ if (target.write(frame)) return void 0;
214
182
  if (this.#drain === null) {
215
183
  this.#drain = new Promise((resolve) => {
216
184
  this.#releaseDrain = resolve;
217
185
  });
218
- this.#target.once("drain", this.#onDrain);
219
- if (this.#stallTimeout !== false) {
220
- this.#stall = setTimeout(this.#onStall, this.#stallTimeout);
221
- this.#stall.unref();
222
- }
186
+ target.once("drain", this.#onDrain);
187
+ lifecycle.armStall();
223
188
  }
224
189
  return this.#drain;
225
190
  }
226
191
  #settleDrain() {
227
- if (this.#stall !== null) {
228
- clearTimeout(this.#stall);
229
- this.#stall = null;
230
- }
192
+ this.#lifecycle.clearStall();
231
193
  const release = this.#releaseDrain;
232
194
  this.#drain = null;
233
195
  this.#releaseDrain = null;
234
196
  release?.();
235
197
  }
236
- /** Fin du flux : minuteur, écouteurs et attentes libérés, une seule fois. */
237
- #finish() {
238
- if (this.#closed) return;
239
- this.#closed = true;
240
- this.#response.removeListener("close", this.#onResponseClose);
241
- this.#target.removeListener("drain", this.#onDrain);
198
+ /** Fin du flux, appelée UNE fois par le cycle de vie : battement et attentes libérés. */
199
+ #release() {
200
+ this.#lifecycle.target.removeListener("drain", this.#onDrain);
242
201
  if (this.#heartbeat !== null) {
243
202
  clearInterval(this.#heartbeat);
244
203
  this.#heartbeat = null;
245
204
  }
246
- if (this.#lifetime !== null) {
247
- clearTimeout(this.#lifetime);
248
- this.#lifetime = null;
249
- }
250
- if (this.#revalidate !== null) this.#context.httpKernel?.unwatchStream(this);
251
205
  this.#settleDrain();
252
- const listeners = this.#onClose;
253
- this.#onClose = null;
254
- if (listeners !== null) for (const listener of listeners) listener();
255
206
  }
256
207
  };
257
208
  /**
@@ -293,56 +244,40 @@ function acceptsEventStream(accept) {
293
244
  * 2^31-1, ou que `maxEventBytes` n'est pas un entier positif.
294
245
  */
295
246
  function openSseStream(context, options = {}) {
296
- checkTimer("heartbeat", options.heartbeat);
297
- checkTimer("stallTimeout", options.stallTimeout);
298
- checkTimer("maxDuration", options.maxDuration);
247
+ checkStreamTimer("SSE", "heartbeat", options.heartbeat);
248
+ checkStreamTimer("SSE", "stallTimeout", options.stallTimeout);
249
+ checkStreamTimer("SSE", "maxDuration", options.maxDuration);
299
250
  const maxEventBytes = options.maxEventBytes ?? 1048576;
300
251
  if (!Number.isInteger(maxEventBytes) || maxEventBytes < 1) throw new RangeError("SSE : maxEventBytes entier positif attendu");
301
- if (context.sended || context.finished || context.response.isHeaderSent()) throw new HttpError("SSE : la réponse est déjà partie", 500, context);
302
- const kernel = context.httpKernel;
303
- const nodeResponse = context.response.response;
304
- if (kernel !== null && nodeResponse !== null && !kernel.acquireConnectionSlot(context.request.request, nodeResponse)) throw new HttpError("SSE : trop de connexions ouvertes depuis cette adresse", 429, context);
305
- const revalidate = kernel?.firewall?.currentRevalidator?.() ?? null;
306
- const identifier = revalidate === null ? null : RequestContext.getAuthenticatedUser()?.identifier ?? null;
307
252
  const settings = {
308
253
  heartbeat: options.heartbeat ?? 15e3,
309
- stallTimeout: options.stallTimeout ?? 3e4,
310
- maxEventBytes,
311
- maxDuration: options.maxDuration ?? false,
312
- revalidate,
313
- identifier
254
+ maxEventBytes
314
255
  };
315
- context.sended = true;
316
- if (context.session != null) return context.saveSession().catch((e) => {
317
- context.log(e, "ERROR", "SESSION-STORE");
318
- return null;
319
- }).then(() => startSseStream(context, options, settings));
320
- return startSseStream(context, options, settings);
321
- }
322
- /** En-têtes, désarmement du délai, puis le flux. */
323
- function startSseStream(context, options, settings) {
324
- const response = context.response;
325
- const nodeResponse = response.response;
326
- if (nodeResponse === null) throw new HttpError("SSE : réponse introuvable", 500, context);
327
- const stream = response instanceof Http2Response ? response.stream : null;
328
- const h1 = nodeResponse instanceof http.ServerResponse ? nodeResponse : null;
329
- let target;
330
- if (stream !== null) target = stream;
331
- else if (h1 !== null) target = h1;
332
- else throw new HttpError("SSE : transport de réponse inconnu", 500, context);
333
- response.setHeaders({
334
- "Content-Type": "text/event-stream; charset=utf-8",
335
- "Cache-Control": "no-cache, no-transform",
336
- "X-Accel-Buffering": "no"
256
+ const opened = openStreamLifecycle(context, {
257
+ label: "SSE",
258
+ stallTimeout: options.stallTimeout ?? 3e4,
259
+ maxDuration: options.maxDuration ?? false
337
260
  });
338
- context.writeHead(200);
339
- if (stream !== null) stream.setTimeout(0);
340
- else if (h1 !== null) {
341
- h1.flushHeaders();
342
- h1.socket?.setTimeout(0);
261
+ return opened instanceof Promise ? opened.then((lifecycle) => startSseStream(lifecycle, options, settings)) : startSseStream(opened, options, settings);
262
+ }
263
+ /**
264
+ * En-têtes, désarmement du délai, puis le flux. Un client parti pendant
265
+ * l'ouverture rend un flux déjà fermé, sans rien écrire.
266
+ */
267
+ function startSseStream(lifecycle, options, settings) {
268
+ if (!lifecycle.closed) {
269
+ const context = lifecycle.context;
270
+ context.response.setHeaders({
271
+ "Content-Type": "text/event-stream; charset=utf-8",
272
+ "Cache-Control": "no-cache, no-transform",
273
+ "X-Accel-Buffering": "no"
274
+ });
275
+ context.writeHead(200);
276
+ const target = lifecycle.target;
277
+ if (target instanceof http.ServerResponse) target.flushHeaders();
278
+ lifecycle.disarmIdleTimeout();
343
279
  }
344
- const sse = new SseStream(context, target, nodeResponse, settings);
345
- if (settings.revalidate !== null) context.httpKernel?.watchStream(sse);
280
+ const sse = new SseStream(lifecycle, settings);
346
281
  if (options.retry !== void 0) sse.retry(options.retry);
347
282
  return sse;
348
283
  }
@@ -0,0 +1,273 @@
1
+ import Http2Response from "../http2/Response.js";
2
+ import HttpError from "../../errors/httpError.js";
3
+ import { RequestContext } from "nodefony";
4
+ import http from "node:http";
5
+ import { AsyncResource } from "node:async_hooks";
6
+ // nodefony/src/context/http/StreamLifecycle.ts
7
+ /**
8
+ * Cycle de vie d'une réponse en flux — la brique commune au flux d'événements
9
+ * (`SseStream`) et au flux d'octets, NON exportée.
10
+ *
11
+ * Elle porte ce qu'un flux doit à la requête qui l'ouvre, quel que soit son
12
+ * format :
13
+ * - la PRISE de la réponse (`sended`), avant toute attente : le noyau ne peut
14
+ * plus répondre par-dessus, et l'avertissement « réponse non envoyée » se tait ;
15
+ * - la session sauvée AVANT les en-têtes — ses cookies partent avec eux ;
16
+ * - la fermeture lue sur la RÉPONSE, écouteur posé de façon SYNCHRONE à
17
+ * l'ouverture : un client parti pendant la sauvegarde de la session est vu ;
18
+ * - le délai d'inactivité de la requête désarmé, remplacé par les gardes du
19
+ * flux : coupure d'un client qui ne lit plus (`stallTimeout`), durée de vie
20
+ * maximale (`maxDuration`) ;
21
+ * - le plafond de connexions longues par IP (`wsMaxConnectionsPerIp`, le budget
22
+ * des sockets WebSocket) et la fermeture à la révocation de l'identité ;
23
+ * - une fin unique, et des écouteurs de fin liés au contexte asynchrone.
24
+ *
25
+ * Le FORMAT (en-têtes, écriture, battement de cœur) appartient au flux qui la
26
+ * porte.
27
+ */
28
+ /**
29
+ * Plus long délai qu'un minuteur sait tenir (2^31-1 ms). Au-delà, Node le
30
+ * ramène à 1 ms : un délai qui le dépasse ferait battre ou couper le flux
31
+ * mille fois par seconde.
32
+ */
33
+ const MAX_TIMER_MS = 2147483647;
34
+ /**
35
+ * Refuse une option de délai hors de ce qu'un minuteur sait tenir.
36
+ *
37
+ * @param label - préfixe du message (`SSE`, `Flux`).
38
+ * @param name - nom de l'option.
39
+ * @param value - valeur reçue ; `undefined` et `false` passent.
40
+ * @throws RangeError quand la valeur n'est ni `false` ni un entier entre 1 et 2^31-1.
41
+ *
42
+ * @internal
43
+ */
44
+ function checkStreamTimer(label, name, value) {
45
+ if (value === void 0 || value === false) return;
46
+ 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
+ }
48
+ /**
49
+ * Le cycle de vie d'UN flux ouvert sur une réponse.
50
+ *
51
+ * S'obtient par {@link openStreamLifecycle} ; le constructeur n'est appelé
52
+ * directement que par les tests.
53
+ *
54
+ * @internal
55
+ */
56
+ var StreamLifecycle = class {
57
+ context;
58
+ /** Où l'on écrit : la réponse HTTP/1.1, ou le flux HTTP/2. */
59
+ target;
60
+ /** La réponse dont on écoute la fermeture — en HTTP/2, l'objet de compatibilité. */
61
+ #response;
62
+ #stallTimeout;
63
+ #revalidate;
64
+ identifier;
65
+ #closed = false;
66
+ #cause = null;
67
+ /**
68
+ * Minuteur de la coupure d'un client qui ne lit plus — créé à la PREMIÈRE
69
+ * contre-pression, puis réarmé au reste du délai : jamais un minuteur par
70
+ * épisode (un flux d'octets en connaît un par morceau sur une socket rapide).
71
+ */
72
+ #stall = null;
73
+ /** Début de l'attente de `drain` en cours (`Date.now()`) ; `0` : aucune. */
74
+ #stallSince = 0;
75
+ /** Fin de vie du flux — armée seulement si `maxDuration` est posé. */
76
+ #lifetime = null;
77
+ /** Nettoyage du flux qui porte la brique — posé une fois, appelé en premier. */
78
+ #release = null;
79
+ /** Écouteurs de fermeture — `null` tant que personne n'en pose (lazy). */
80
+ #onClose = null;
81
+ #onResponseClose = () => this.finish();
82
+ /**
83
+ * Tour du minuteur de blocage. Le client ne lit plus depuis `stallTimeout` :
84
+ * on coupe la connexion, comme s'il était parti — la fermeture de la réponse
85
+ * termine le flux. Une attente plus récente (le client a lu entre-temps) est
86
+ * réarmée au reste de son délai ; plus d'attente du tout : le minuteur s'éteint.
87
+ */
88
+ #onStall = () => {
89
+ this.#stall = null;
90
+ const limit = this.#stallTimeout;
91
+ if (this.#stallSince === 0 || this.#closed || limit === false) return;
92
+ const waited = Date.now() - this.#stallSince;
93
+ if (waited < limit) {
94
+ this.#stall = setTimeout(this.#onStall, limit - waited);
95
+ this.#stall.unref();
96
+ return;
97
+ }
98
+ this.#cause = "stall";
99
+ this.target.destroy();
100
+ };
101
+ constructor(context, target, response, settings) {
102
+ this.context = context;
103
+ this.target = target;
104
+ this.#response = response;
105
+ this.#stallTimeout = settings.stallTimeout;
106
+ this.#revalidate = settings.revalidate;
107
+ this.identifier = settings.identifier;
108
+ response.on("close", this.#onResponseClose);
109
+ if (settings.maxDuration !== false) {
110
+ this.#lifetime = setTimeout(() => void this.close(), settings.maxDuration);
111
+ this.#lifetime.unref();
112
+ }
113
+ if (this.#revalidate !== null) context.httpKernel?.watchStream(this);
114
+ }
115
+ /** Le flux est-il fermé — par le serveur, ou par le client parti ? */
116
+ get closed() {
117
+ return this.#closed;
118
+ }
119
+ /**
120
+ * Qui a fermé le flux : le serveur, la coupure d'un client qui ne lit plus,
121
+ * ou `null` — client parti, ou flux encore ouvert. Lu par le flux qui porte
122
+ * la brique, dans son nettoyage, pour nommer la raison de la fin.
123
+ */
124
+ get cause() {
125
+ return this.#cause;
126
+ }
127
+ /** L'identité qui a ouvert le flux est-elle toujours valable ? */
128
+ revalidate() {
129
+ return this.#revalidate === null ? Promise.resolve(true) : this.#revalidate();
130
+ }
131
+ /**
132
+ * Pose le nettoyage du flux qui porte la brique (minuteurs, attentes) : il
133
+ * passe AVANT les écouteurs de l'application. Un cycle déjà fermé l'appelle
134
+ * aussitôt.
135
+ */
136
+ onRelease(release) {
137
+ if (this.#closed) {
138
+ release();
139
+ return;
140
+ }
141
+ this.#release = release;
142
+ }
143
+ /**
144
+ * Appelle `listener` à la fermeture du flux, quelle qu'en soit la cause.
145
+ *
146
+ * L'écouteur est lié au contexte asynchrone de la requête au moment de
147
+ * l'enregistrement : la fermeture par le client part de la SOCKET, hors de
148
+ * la bulle de la requête. Un flux déjà fermé appelle l'écouteur au tour
149
+ * suivant.
150
+ */
151
+ onClose(listener) {
152
+ const bound = AsyncResource.bind(listener);
153
+ if (this.#closed) {
154
+ queueMicrotask(bound);
155
+ return;
156
+ }
157
+ this.#onClose ??= [];
158
+ this.#onClose.push(bound);
159
+ }
160
+ /**
161
+ * Arme la coupure d'un client qui ne lit plus — à appeler quand une écriture
162
+ * attend `drain`. Sans effet si elle est déjà armée ou désactivée.
163
+ */
164
+ armStall() {
165
+ if (this.#stallTimeout === false || this.#stallSince !== 0 || this.#closed) return;
166
+ this.#stallSince = Date.now();
167
+ if (this.#stall !== null) return;
168
+ this.#stall = setTimeout(this.#onStall, this.#stallTimeout);
169
+ this.#stall.unref();
170
+ }
171
+ /**
172
+ * Désarme la coupure : le client a repris sa lecture. Le minuteur n'est pas
173
+ * détruit — il s'éteindra seul à son tour s'il n'y a plus d'attente, ou
174
+ * resservira à la suivante sans nouvelle allocation.
175
+ */
176
+ clearStall() {
177
+ this.#stallSince = 0;
178
+ }
179
+ /**
180
+ * Désarme le délai d'inactivité de la requête : un flux n'a pas de fin
181
+ * attendue, ses propres gardes le remplacent.
182
+ */
183
+ disarmIdleTimeout() {
184
+ const target = this.target;
185
+ if (target instanceof http.ServerResponse) target.socket?.setTimeout(0);
186
+ else target.setTimeout(0);
187
+ }
188
+ /** Ferme le flux et termine la requête. Idempotent. */
189
+ close() {
190
+ if (this.#closed) return void 0;
191
+ this.#cause = "server";
192
+ this.finish();
193
+ const done = this.context.close();
194
+ return done instanceof Promise ? done.then(() => void 0) : void 0;
195
+ }
196
+ /** Fin du flux : minuteurs, écouteurs et registre libérés, une seule fois. */
197
+ finish() {
198
+ if (this.#closed) return;
199
+ this.#closed = true;
200
+ this.#response.removeListener("close", this.#onResponseClose);
201
+ if (this.#lifetime !== null) {
202
+ clearTimeout(this.#lifetime);
203
+ this.#lifetime = null;
204
+ }
205
+ this.#stallSince = 0;
206
+ if (this.#stall !== null) {
207
+ clearTimeout(this.#stall);
208
+ this.#stall = null;
209
+ }
210
+ if (this.#revalidate !== null) this.context.httpKernel?.unwatchStream(this);
211
+ const release = this.#release;
212
+ this.#release = null;
213
+ release?.();
214
+ const listeners = this.#onClose;
215
+ this.#onClose = null;
216
+ if (listeners !== null) for (const listener of listeners) listener();
217
+ }
218
+ };
219
+ /**
220
+ * Ouvre le cycle de vie d'un flux sur la réponse d'un contexte HTTP.
221
+ *
222
+ * Prend la réponse, pose l'écoute de sa fermeture, puis sauve la session —
223
+ * sans rien écrire : les en-têtes appartiennent au flux. Un client parti
224
+ * pendant la sauvegarde rend un cycle déjà fermé (`closed`) : le flux n'écrit
225
+ * alors rien.
226
+ *
227
+ * @param context - le contexte de la requête, pas encore répondu.
228
+ * @param options - gardes du flux et préfixe des messages.
229
+ * @returns le cycle de vie, ou sa promesse quand une session est à sauver.
230
+ * @throws HttpError 500 quand la réponse est déjà partie, ou que son
231
+ * transport n'est pas reconnu.
232
+ * @throws HttpError 429 quand l'IP du client a déjà tous ses flux et sockets
233
+ * (`wsMaxConnectionsPerIp`) — refusé AVANT d'écrire quoi que ce soit.
234
+ *
235
+ * @internal
236
+ */
237
+ function openStreamLifecycle(context, options) {
238
+ const { label } = options;
239
+ 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;
242
+ 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);
248
+ const gone = target.destroyed;
249
+ const kernel = context.httpKernel;
250
+ if (!gone && kernel !== null && !kernel.acquireConnectionSlot(context.request.request, nodeResponse)) throw new HttpError(`${label} : trop de connexions ouvertes depuis cette adresse`, 429, context);
251
+ const revalidate = kernel?.firewall?.currentRevalidator?.() ?? null;
252
+ const identifier = revalidate === null ? null : RequestContext.getAuthenticatedUser()?.identifier ?? null;
253
+ context.sended = true;
254
+ const lifecycle = new StreamLifecycle(context, target, nodeResponse, {
255
+ stallTimeout: options.stallTimeout,
256
+ maxDuration: options.maxDuration,
257
+ revalidate,
258
+ identifier
259
+ });
260
+ if (gone) {
261
+ lifecycle.finish();
262
+ return lifecycle;
263
+ }
264
+ if (context.session == null) return lifecycle;
265
+ return context.saveSession().catch((e) => {
266
+ context.log(e, "ERROR", "SESSION-STORE");
267
+ return null;
268
+ }).then(() => {
269
+ if (target.destroyed) lifecycle.finish();
270
+ return lifecycle;
271
+ });
272
+ }
273
+ export { MAX_TIMER_MS, StreamLifecycle, checkStreamTimer, openStreamLifecycle };