@nodefony/http 10.0.0-beta.2 → 10.0.0-beta.4

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 (139) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  3. package/dist/index.js +19 -11
  4. package/dist/nodefony/command/assetsPublishCommand.js +5 -3
  5. package/dist/nodefony/command/certificatesCommand.js +5 -3
  6. package/dist/nodefony/command/networkCommand.js +5 -3
  7. package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
  8. package/dist/nodefony/config/config.js +45 -6
  9. package/dist/nodefony/config/defineModuleConfig.js +2 -3
  10. package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
  11. package/dist/nodefony/service/HttpAdminApi.js +2 -3
  12. package/dist/nodefony/service/ProfilerAdminApi.js +1 -2
  13. package/dist/nodefony/service/audit-logger.js +22 -16
  14. package/dist/nodefony/service/certificates.js +228 -19
  15. package/dist/nodefony/service/error-renderer.js +30 -13
  16. package/dist/nodefony/service/http-kernel.js +331 -82
  17. package/dist/nodefony/service/pretty-request-logger.js +5 -4
  18. package/dist/nodefony/service/request-logger.js +24 -16
  19. package/dist/nodefony/service/reverse-proxy.js +336 -0
  20. package/dist/nodefony/service/servers/clientError.js +22 -3
  21. package/dist/nodefony/service/servers/server-h2c.js +126 -0
  22. package/dist/nodefony/service/servers/server-http.js +5 -8
  23. package/dist/nodefony/service/servers/server-https.js +12 -24
  24. package/dist/nodefony/service/servers/server-static.js +7 -10
  25. package/dist/nodefony/service/servers/server-websocket-secure.js +18 -18
  26. package/dist/nodefony/service/servers/server-websocket.js +18 -18
  27. package/dist/nodefony/service/servers/serverShutdown.js +78 -18
  28. package/dist/nodefony/service/servers/wsHeartbeat.js +32 -17
  29. package/dist/nodefony/service/sessions/sessions-service.js +34 -20
  30. package/dist/nodefony/service/trace.js +1 -2
  31. package/dist/nodefony/service/upload/upload-service.js +5 -8
  32. package/dist/nodefony/service/x509.js +1 -2
  33. package/dist/nodefony/src/assets/collectAssets.js +1 -2
  34. package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
  35. package/dist/nodefony/src/context/Context.js +51 -32
  36. package/dist/nodefony/src/context/domainMatcher.js +21 -4
  37. package/dist/nodefony/src/context/forwarded.js +3 -4
  38. package/dist/nodefony/src/context/http/HttpContext.js +98 -46
  39. package/dist/nodefony/src/context/http/Request.js +34 -38
  40. package/dist/nodefony/src/context/http/Response.js +39 -7
  41. package/dist/nodefony/src/context/http/SseStream.js +349 -0
  42. package/dist/nodefony/src/context/http/parser.js +36 -10
  43. package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
  44. package/dist/nodefony/src/context/http2/Request.js +4 -8
  45. package/dist/nodefony/src/context/http2/Response.js +22 -9
  46. package/dist/nodefony/src/context/metaData.js +1 -2
  47. package/dist/nodefony/src/context/requestId.js +1 -2
  48. package/dist/nodefony/src/context/responseEnded.js +26 -0
  49. package/dist/nodefony/src/context/transport.js +45 -0
  50. package/dist/nodefony/src/context/trustProxy.js +1 -2
  51. package/dist/nodefony/src/context/websocket/Response.js +42 -6
  52. package/dist/nodefony/src/context/websocket/WebsocketContext.js +94 -20
  53. package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
  54. package/dist/nodefony/src/context/websocket/websocketUrl.js +20 -0
  55. package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
  56. package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
  57. package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
  58. package/dist/nodefony/src/cookies/cookie.js +3 -7
  59. package/dist/nodefony/src/errors/httpError.js +1 -2
  60. package/dist/nodefony/src/errors/thrownMessage.js +1 -2
  61. package/dist/nodefony/src/profiler/FrameProfile.js +1 -2
  62. package/dist/nodefony/src/profiler/Profiler.js +108 -30
  63. package/dist/nodefony/src/profiler/aggregate.js +422 -0
  64. package/dist/nodefony/src/proxy/forward.js +500 -0
  65. package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
  66. package/dist/nodefony/src/proxy/rules.js +188 -0
  67. package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
  68. package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
  69. package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
  70. package/dist/nodefony/src/servers/http2Limits.js +50 -0
  71. package/dist/nodefony/src/servers/kernelServers.js +12 -3
  72. package/dist/nodefony/src/servers/portBinder.js +41 -10
  73. package/dist/nodefony/src/servers/upgradeDispatch.js +128 -0
  74. package/dist/nodefony/src/session/session.js +30 -15
  75. package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
  76. package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
  77. package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
  78. package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
  79. package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
  80. package/dist/types/index.d.ts +12 -3
  81. package/dist/types/nodefony/config/config.d.ts +16 -0
  82. package/dist/types/nodefony/interfaces/IContext.d.ts +65 -4
  83. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
  84. package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
  85. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
  86. package/dist/types/nodefony/interfaces/IRouting.d.ts +26 -0
  87. package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
  88. package/dist/types/nodefony/interfaces/index.d.ts +3 -2
  89. package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
  90. package/dist/types/nodefony/service/certificates.d.ts +147 -5
  91. package/dist/types/nodefony/service/error-renderer.d.ts +32 -1
  92. package/dist/types/nodefony/service/http-kernel.d.ts +157 -15
  93. package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
  94. package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
  95. package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
  96. package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
  97. package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
  98. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +2 -3
  99. package/dist/types/nodefony/service/servers/server-websocket.d.ts +2 -3
  100. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
  101. package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
  102. package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
  103. package/dist/types/nodefony/src/context/Context.d.ts +35 -6
  104. package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
  105. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +48 -1
  106. package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
  107. package/dist/types/nodefony/src/context/http/Response.d.ts +14 -0
  108. package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
  109. package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
  110. package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
  111. package/dist/types/nodefony/src/context/http2/Response.d.ts +15 -5
  112. package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
  113. package/dist/types/nodefony/src/context/transport.d.ts +53 -0
  114. package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
  115. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +57 -7
  116. package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
  117. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
  118. package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +13 -12
  119. package/dist/types/nodefony/src/profiler/Profiler.d.ts +69 -15
  120. package/dist/types/nodefony/src/profiler/aggregate.d.ts +215 -0
  121. package/dist/types/nodefony/src/proxy/forward.d.ts +168 -0
  122. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
  123. package/dist/types/nodefony/src/proxy/rules.d.ts +88 -0
  124. package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
  125. package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
  126. package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
  127. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +69 -0
  128. package/dist/types/nodefony/src/session/session.d.ts +2 -0
  129. package/docs/cookies.md +85 -78
  130. package/docs/index.md +5 -1
  131. package/docs/observabilite.md +81 -81
  132. package/docs/rate-limit.md +83 -83
  133. package/docs/reverse-proxy.md +522 -0
  134. package/docs/servers.md +378 -230
  135. package/docs/session.md +180 -180
  136. package/docs/sse.md +287 -0
  137. package/docs/upload.md +120 -117
  138. package/package.json +10 -11
  139. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,8 +1,9 @@
1
1
  import HttpError from "../../errors/httpError.js";
2
2
  import { hasForwardingHeaders, resolveForwarded } from "../forwarded.js";
3
- import { Parser, ParserJson, ParserQs, ParserXml, acceptParser } from "./parser.js";
3
+ import { hostnameOf } from "../domainMatcher.js";
4
+ import { Parser, ParserJson, ParserQs, ParserRaw, ParserXml, acceptParser } from "./parser.js";
4
5
  import { isCanonicalAuthority, splitTarget } from "./urlFastPath.js";
5
- import { RequestContext, extend, useConfig } from "nodefony";
6
+ import { RequestContext, useConfig } from "nodefony";
6
7
  import { createHash, randomUUID } from "node:crypto";
7
8
  import { URL } from "node:url";
8
9
  import fs from "node:fs";
@@ -11,7 +12,7 @@ import os from "node:os";
11
12
  import path from "node:path";
12
13
  import QS from "qs";
13
14
  import { Busboy } from "@fastify/busboy";
14
- //#region nodefony/src/context/http/Request.ts
15
+ // nodefony/src/context/http/Request.ts
15
16
  const reg = /(.*)[[][\]]$/u;
16
17
  /**
17
18
  * Supprime un fichier temporaire d'upload sans jamais faire échouer le nettoyage :
@@ -33,6 +34,7 @@ const CHARSET_ALIASES = {
33
34
  "utf-16le": "utf16le"
34
35
  };
35
36
  const QUOTE_TRIM = /^["']|["']$/gu;
37
+ const NO_FILES = Object.freeze([]);
36
38
  const parse = {
37
39
  POST: true,
38
40
  PUT: true,
@@ -52,15 +54,6 @@ function perfMark(field) {
52
54
  const p = globalThis.__nfPerfProbe;
53
55
  if (p && p.t0 !== 0n) p[field] += Number(process.hrtime.bigint() - p.t0);
54
56
  }
55
- /**
56
- * Nom d'hôte d'une autorité brute (`host[:port]`) : tout ce qui précède le
57
- * premier `:`. Même résultat que `host.split(":")[0]`, sans tableau (#508).
58
- */
59
- function hostnameOf(host) {
60
- if (!host) return "";
61
- const colon = host.indexOf(":");
62
- return colon === -1 ? host : host.slice(0, colon);
63
- }
64
57
  var HttpRequest = class {
65
58
  context;
66
59
  request;
@@ -112,7 +105,7 @@ var HttpRequest = class {
112
105
  let u = this.#url;
113
106
  if (u === null) {
114
107
  u = this.getUrl(this.sUrl);
115
- u.query = this.queryGet;
108
+ u.query = this.query;
116
109
  this.#url = u;
117
110
  }
118
111
  return u;
@@ -121,24 +114,28 @@ var HttpRequest = class {
121
114
  this.#url = u;
122
115
  }
123
116
  parser = null;
124
- queryPost = {};
125
- queryGet = {};
126
- queryFile = [];
127
- query = {};
128
117
  /**
129
- * Le CORPS de la requête, parsé — nom universel de l'écosystème (Express,
130
- * Fastify, NestJS), alias de {@link queryPost}.
131
- *
132
- * Ne pas confondre avec {@link query}, qui FUSIONNE la query string et le
133
- * corps : `body` ne rend jamais un paramètre d'URL. Le data plane admin
134
- * expose déjà cette sémantique (`IAdminRequest.body`) ; ce getter la rend
135
- * disponible partout.
118
+ * Le CORPS de la requête, parsé (JSON, urlencoded, XML, champs multipart) —
119
+ * nom universel de l'écosystème (Express, Fastify, NestJS) et celui
120
+ * d'`IAdminRequest.body`. Jamais un paramètre d'URL : ceux-là sont dans
121
+ * {@link query}, et les deux ne fusionnent pas.
122
+ */
123
+ body = {};
124
+ /** Fichiers reçus, alloués au PREMIER fichier seulement (cf {@link files}). */
125
+ #uploads = null;
126
+ /**
127
+ * Les fichiers reçus dans un corps multipart, déjà écrits dans le répertoire
128
+ * temporaire — lus par `@UploadedFile()` / `@UploadedFiles()`. Les champs
129
+ * texte du même corps sont dans {@link body}.
136
130
  *
137
- * Getter (aucune allocation) : `queryPost` reste la source unique.
131
+ * Lecture seule : une requête sans upload rend un vide partagé et gelé,
132
+ * aucune allocation.
138
133
  */
139
- get body() {
140
- return this.queryPost;
134
+ get files() {
135
+ return this.#uploads ?? NO_FILES;
141
136
  }
137
+ /** Les paramètres d'URL, seuls — jamais un champ du corps ({@link body}). */
138
+ query = {};
142
139
  queryStringOptions;
143
140
  charset = "utf8";
144
141
  uploadOption = {};
@@ -193,7 +190,6 @@ var HttpRequest = class {
193
190
  if (this.search) query = QS.parse(this.search.slice(1), this.queryStringOptions ?? {});
194
191
  else query = {};
195
192
  if (this.#url !== null) this.#url.query = query;
196
- this.queryGet = query;
197
193
  this.query = query;
198
194
  if (PERF_PROBE_SUB) perfMark("reqQueryNs");
199
195
  this.contentType = this.getContentType(this.request);
@@ -209,11 +205,11 @@ var HttpRequest = class {
209
205
  * remplace le `once("onRequestEnd")` du ctor qui allouait 1 onceWrapper +
210
206
  * 1 closure à CHAQUE requête.
211
207
  *
212
- * Il porte le CORPS (`queryPost`), pas la fusion `query` : un seul sens de
208
+ * Il porte le CORPS (`body`), jamais les paramètres d'URL : un seul sens de
213
209
  * `body` dans tout le framework, celui de l'écosystème et d'`IAdminRequest`.
214
210
  */
215
211
  fireRequestEnd() {
216
- this.request.body = this.queryPost;
212
+ this.request.body = this.body;
217
213
  if (this.context.listenerCount("onRequestEnd") === 0) return false;
218
214
  return this.context.fireAsync("onRequestEnd", this);
219
215
  }
@@ -323,7 +319,7 @@ var HttpRequest = class {
323
319
  await this.fireRequestEnd();
324
320
  return BODY_DONE;
325
321
  }
326
- this.parser = new Parser(this);
322
+ this.parser = new ParserRaw(this);
327
323
  return this.parser;
328
324
  }
329
325
  }
@@ -370,8 +366,7 @@ var HttpRequest = class {
370
366
  return this.parser;
371
367
  }
372
368
  const { fields, files } = await this.streamMultipart(bb);
373
- this.queryPost = fields;
374
- this.query = extend({}, this.query, this.queryPost);
369
+ this.body = fields;
375
370
  for (const pf of files) await this.createFileUpload(pf.field, pf.file);
376
371
  this.context.requestEnded = true;
377
372
  await this.fireRequestEnd();
@@ -486,7 +481,7 @@ var HttpRequest = class {
486
481
  }
487
482
  accepts(Type) {
488
483
  let parts = [];
489
- let subtype = "*";
484
+ let subtype;
490
485
  let type = "*";
491
486
  if (Type) parts = Type.split("/");
492
487
  switch (parts.length) {
@@ -504,7 +499,7 @@ var HttpRequest = class {
504
499
  }
505
500
  async createFileUpload(name, file) {
506
501
  const fileUpload = await this.context.uploadService?.createUploadFile(file, name);
507
- if (fileUpload) this.queryFile.push(fileUpload);
502
+ if (fileUpload) (this.#uploads ??= []).push(fileUpload);
508
503
  return fileUpload;
509
504
  }
510
505
  getMethod() {
@@ -569,7 +564,9 @@ var HttpRequest = class {
569
564
  /**
570
565
  * Scheme effectif côté client : `Forwarded`/`X-Forwarded-*` résolu de façon
571
566
  * canonique (this.forwarded, gated proxy de confiance) ; sinon le transport
572
- * réel. HTTP/2 remplace la lecture du socket par le pseudo-header `:scheme`.
567
+ * réel. Vaut pour HTTP/2 aussi : le pseudo-en-tête `:scheme` vient du client
568
+ * (RFC 9113 §8.3.1) et n'est jamais cru — sur h2c, `:scheme: https` ferait
569
+ * passer une connexion claire pour chiffrée.
573
570
  */
574
571
  resolveScheme(request) {
575
572
  if (this.forwarded?.proto) return this.forwarded.proto;
@@ -597,9 +594,8 @@ var HttpRequest = class {
597
594
  return new URL(sUrl, baseUrl);
598
595
  }
599
596
  log(pci, severity, msgid, msg) {
600
- if (!msgid) msgid = `${this.context.type} REQUEST `;
597
+ if (!msgid) msgid = `${this.context.serverType} REQUEST `;
601
598
  return this.context.log(pci, severity, msgid, msg);
602
599
  }
603
600
  };
604
- //#endregion
605
601
  export { HttpRequest as default };
@@ -2,7 +2,7 @@ import Cookie from "../../cookies/cookie.js";
2
2
  import { thenMaybe, typeOf } from "nodefony";
3
3
  import http from "node:http";
4
4
  import mime from "mime-types";
5
- //#region nodefony/src/context/http/Response.ts
5
+ // nodefony/src/context/http/Response.ts
6
6
  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
7
  const stripAnsi = function(val) {
8
8
  return typeof val === "string" ? val.replace(ANSI_REGEX, "") : val;
@@ -55,16 +55,32 @@ var HttpResponse = class {
55
55
  constructor(response, context) {
56
56
  this.context = context;
57
57
  this.response = response;
58
- this.timeout = this.context.httpKernel?.responseTimeout[this.context.type];
58
+ const responseTimeout = this.context.httpKernel?.responseTimeout;
59
+ this.timeout = this.context.serverType === "https" ? responseTimeout?.https : responseTimeout?.http;
59
60
  }
60
61
  /**
61
62
  * Filet posé juste avant l'émission des en-têtes : si aucun `Content-Type`
62
63
  * n'a été choisi (ni négociation, ni `render`, ni controller), émet le défaut
63
64
  * `this.contentType` (application/octet-stream) — comportement identique à
64
65
  * l'ancienne pose au constructeur, sans le ping-pong set/remove/re-set.
66
+ *
67
+ * Une réponse SANS corps n'annonce aucun type : `Content-Type` décrit le
68
+ * contenu (RFC 9110 §8.3), et il n'y en a pas.
65
69
  */
66
70
  ensureContentTypeHeader() {
67
- if (this.response && !this.response.hasHeader("content-type")) this.response.setHeader("content-type", this.contentType);
71
+ if (this.response && !this.response.hasHeader("content-type") && !this.#isBodyless()) this.response.setHeader("content-type", this.contentType);
72
+ }
73
+ /**
74
+ * Vrai quand la réponse n'a pas de corps : 204 et 304 n'en ont jamais
75
+ * (RFC 9110 §15.3.5, §15.4.5) ; ailleurs, un corps posé et VIDE. Un corps
76
+ * jamais posé ne dit rien — un flux peut suivre l'en-tête — et `HEAD` rend
77
+ * les en-têtes du `GET` (§9.3.2) : tous deux gardent le défaut.
78
+ */
79
+ #isBodyless() {
80
+ if (NO_CONTENT_LENGTH_STATUS.has(this.statusCode)) return true;
81
+ if (this.context.method === "HEAD") return false;
82
+ if (this.#text !== null) return this.#text === "" && !this.flushing;
83
+ return this.#buffer?.length === 0 && !this.flushing;
68
84
  }
69
85
  /**
70
86
  * Corps de la réponse en octets. Un corps posé en texte n'est encodé qu'à la
@@ -138,16 +154,33 @@ var HttpResponse = class {
138
154
  const serialized = [];
139
155
  for (const cookie of cookies) {
140
156
  const s = cookie.serialize();
141
- this.log(`ADD COOKIE ==> ${s}`, "DEBUG");
157
+ this.logCookie(cookie, s);
142
158
  serialized.push(s);
143
159
  }
144
160
  return this.setHeader("Set-Cookie", serialized);
145
161
  }
146
162
  setCookie(cookie) {
147
163
  const serialize = cookie.serialize();
148
- this.log(`ADD COOKIE ==> ${serialize}`, "DEBUG");
164
+ this.logCookie(cookie, serialize);
149
165
  return this.setHeader("Set-Cookie", serialize);
150
166
  }
167
+ /**
168
+ * Journalise en DEBUG un cookie posé, sa VALEUR MASQUÉE : nom et attributs
169
+ * seulement. Le journal de développement se lit par le plan d'administration
170
+ * — un identifiant de session y entrerait chez quiconque le consulte.
171
+ * La ligne n'est construite que si DEBUG passe le gate du syslog pour ce
172
+ * `msgid` (debug ciblé compris) : rien n'est alloué en production.
173
+ *
174
+ * @param cookie - le cookie posé.
175
+ * @param serialized - sa forme `Set-Cookie`, déjà calculée pour l'en-tête.
176
+ */
177
+ logCookie(cookie, serialized) {
178
+ const msgid = `${this.context.serverType} RESPONSE `;
179
+ const syslog = this.context.syslog;
180
+ if (syslog && !syslog.severityEnabled("DEBUG", msgid)) return;
181
+ const attrs = serialized.indexOf("; ", cookie.name.length + 1);
182
+ this.log(`ADD COOKIE ==> ${cookie.name}=<masqué>${attrs === -1 ? "" : serialized.slice(attrs)}`, "DEBUG", msgid);
183
+ }
151
184
  setHeader(name, value) {
152
185
  if (this.response) {
153
186
  if (this.flushing) {
@@ -444,9 +477,8 @@ var HttpResponse = class {
444
477
  return this;
445
478
  }
446
479
  log(pci, severity, msgid, msg) {
447
- if (!msgid) msgid = `${this.context.type} RESPONSE `;
480
+ if (!msgid) msgid = `${this.context.serverType} RESPONSE `;
448
481
  return this.context.log(pci, severity, msgid, msg);
449
482
  }
450
483
  };
451
- //#endregion
452
484
  export { HttpResponse as default };
@@ -0,0 +1,349 @@
1
+ import HttpError from "../../errors/httpError.js";
2
+ import Http2Response from "../http2/Response.js";
3
+ import { RequestContext } from "nodefony";
4
+ import http from "node:http";
5
+ import { AsyncResource } from "node:async_hooks";
6
+ // nodefony/src/context/http/SseStream.ts
7
+ /** Battement de cœur par défaut, en ms (§9.2.7 : « every 15 seconds or so »). */
8
+ const SSE_HEARTBEAT_MS = 15e3;
9
+ /** Délai de blocage d'écriture par défaut, en ms. */
10
+ const SSE_STALL_TIMEOUT_MS = 3e4;
11
+ /** Taille maximale d'un événement par défaut, en octets (1 Mio). */
12
+ 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
+ /** Commentaire de battement de cœur — ignoré par tout client conforme. */
20
+ const HEARTBEAT = ":\n\n";
21
+ /** Fin de ligne : CRLF, CR ou LF (§9.2.5). */
22
+ const LINE_BREAK = /\r\n|\r|\n/;
23
+ /**
24
+ * Refuse un champ d'une seule ligne qui contiendrait une fin de ligne.
25
+ *
26
+ * Laisser passer un CR ou un LF dans `event` ou `id` permettrait à une valeur
27
+ * venue d'un utilisateur de clore l'événement et d'en FORGER un autre dans le
28
+ * flux — l'équivalent SSE d'une injection d'en-tête.
29
+ *
30
+ * @throws TypeError quand la valeur contient CR, LF ou NUL.
31
+ */
32
+ function singleLine(name, value) {
33
+ if (/[\r\n\0]/.test(value)) throw new TypeError(`Champ SSE « ${name} » : fin de ligne ou NUL interdits`);
34
+ return value;
35
+ }
36
+ /**
37
+ * Texte d'un événement : la chaîne telle quelle, sinon son JSON.
38
+ *
39
+ * `JSON.stringify` rend `undefined` pour `undefined`, une fonction ou un
40
+ * symbole, et son type `string` le tait : on l'élargit avant de le lire.
41
+ */
42
+ function serialize(data) {
43
+ if (typeof data === "string") return data;
44
+ const json = JSON.stringify(data);
45
+ return typeof json === "string" ? json : "";
46
+ }
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
+ /** Préfixe chaque ligne d'un texte par `prefix` — `data: ` ou `: `. */
57
+ function lines(prefix, text) {
58
+ let out = "";
59
+ for (const line of text.split(LINE_BREAK)) out += `${prefix}${line}\n`;
60
+ return out;
61
+ }
62
+ /**
63
+ * Un flux `text/event-stream` ouvert sur une réponse.
64
+ *
65
+ * Il s'obtient par {@link openSseStream} (ou `this.renderSse()` dans un
66
+ * contrôleur), jamais par `new` : l'ouverture écrit les en-têtes, et un flux
67
+ * sans en-têtes ne se rattrape pas.
68
+ */
69
+ 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;
75
+ #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
+ #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
+ /** Attente de `drain` en cours, partagée par tous les `send()` sous pression. */
94
+ #drain = null;
95
+ #releaseDrain = null;
96
+ #onResponseClose = () => this.#finish();
97
+ #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
+ /** @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;
112
+ this.#maxEventBytes = settings.maxEventBytes;
113
+ this.#revalidate = settings.revalidate;
114
+ this.identifier = settings.identifier;
115
+ response.on("close", this.#onResponseClose);
116
+ 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) {
122
+ this.#heartbeat = setInterval(() => {
123
+ if (this.#drain === null) this.#target.write(HEARTBEAT);
124
+ }, heartbeat);
125
+ this.#heartbeat.unref();
126
+ }
127
+ }
128
+ /** Le flux est-il fermé — par le serveur (`close()`) ou par le client parti ? */
129
+ get closed() {
130
+ return this.#closed;
131
+ }
132
+ /**
133
+ * L'identité qui a ouvert le flux est-elle toujours valable ? Lu par le
134
+ * registre de révocation du noyau HTTP.
135
+ *
136
+ * @internal
137
+ */
138
+ revalidate() {
139
+ return this.#revalidate === null ? Promise.resolve(true) : this.#revalidate();
140
+ }
141
+ /**
142
+ * Envoie un événement.
143
+ *
144
+ * @param data - texte envoyé tel quel, ou valeur sérialisée en JSON ; un
145
+ * texte de plusieurs lignes part en autant de champs `data:`.
146
+ * @param options - type (`event`) et identifiant (`id`) de l'événement.
147
+ * @returns rien quand l'écriture est acceptée ; une promesse réglée au
148
+ * `drain` quand le client lit moins vite qu'on écrit — l'attendre borne la
149
+ * mémoire du serveur. Sans effet une fois le flux fermé.
150
+ * @throws TypeError quand `event` ou `id` contient une fin de ligne.
151
+ * @throws RangeError quand l'événement dépasse `maxEventBytes`.
152
+ */
153
+ send(data, options) {
154
+ let frame = "";
155
+ if (options?.event !== void 0) frame += `event: ${singleLine("event", options.event)}\n`;
156
+ if (options?.id !== void 0) frame += `id: ${singleLine("id", options.id)}\n`;
157
+ frame += `${lines("data: ", serialize(data))}\n`;
158
+ return this.#write(frame);
159
+ }
160
+ /**
161
+ * Envoie un commentaire — ignoré par le client, utile pour garder une
162
+ * connexion vivante ou marquer le flux à la lecture.
163
+ *
164
+ * @returns comme {@link send}.
165
+ */
166
+ comment(text = "") {
167
+ return this.#write(`${lines(": ", text)}\n`);
168
+ }
169
+ /**
170
+ * Impose au client un délai de reconnexion (champ `retry:`).
171
+ *
172
+ * @param ms - délai en millisecondes, entier positif.
173
+ * @returns comme {@link send}.
174
+ * @throws RangeError quand `ms` n'est pas un entier entre 0 et 2^31-1.
175
+ */
176
+ 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`);
178
+ return this.#write(`retry: ${ms}\n\n`);
179
+ }
180
+ /**
181
+ * Appelle `listener` à la fermeture du flux, quelle qu'en soit la cause.
182
+ *
183
+ * L'écouteur est lié au contexte asynchrone de la requête au moment de
184
+ * l'enregistrement : la fermeture par le client part de la SOCKET, hors de
185
+ * la bulle de la requête, et un `RequestContext.get()` y rendrait `undefined`.
186
+ * Un flux déjà fermé appelle l'écouteur au tour suivant.
187
+ */
188
+ 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);
196
+ }
197
+ /**
198
+ * Ferme le flux et termine la requête. Idempotent.
199
+ *
200
+ * @returns la fin de la requête (hooks `onClose` du contexte compris).
201
+ */
202
+ 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;
207
+ }
208
+ /** Écrit une trame ; une promesse seulement sous contre-pression. */
209
+ #write(frame) {
210
+ if (this.#closed) return void 0;
211
+ const max = this.#maxEventBytes;
212
+ 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;
214
+ if (this.#drain === null) {
215
+ this.#drain = new Promise((resolve) => {
216
+ this.#releaseDrain = resolve;
217
+ });
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
+ }
223
+ }
224
+ return this.#drain;
225
+ }
226
+ #settleDrain() {
227
+ if (this.#stall !== null) {
228
+ clearTimeout(this.#stall);
229
+ this.#stall = null;
230
+ }
231
+ const release = this.#releaseDrain;
232
+ this.#drain = null;
233
+ this.#releaseDrain = null;
234
+ release?.();
235
+ }
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);
242
+ if (this.#heartbeat !== null) {
243
+ clearInterval(this.#heartbeat);
244
+ this.#heartbeat = null;
245
+ }
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
+ this.#settleDrain();
252
+ const listeners = this.#onClose;
253
+ this.#onClose = null;
254
+ if (listeners !== null) for (const listener of listeners) listener();
255
+ }
256
+ };
257
+ /**
258
+ * Le client accepte-t-il un flux d'événements ? Lit l'en-tête `Accept`.
259
+ *
260
+ * Un même `POST` répond en JSON ou en flux selon ce que demande le client
261
+ * (porte MCP) : c'est cette fonction qui tranche, et elle seule. Une plage
262
+ * `q=0` est un REFUS explicite (RFC 9110 §12.4.2) ; `*\/*` ne suffit pas — un
263
+ * client qui ne nomme pas le flux ne sait pas le lire.
264
+ *
265
+ * @param accept - valeur de l'en-tête `Accept`, absente ou multiple.
266
+ * @returns `true` quand `text/event-stream` est nommé avec un poids non nul.
267
+ */
268
+ function acceptsEventStream(accept) {
269
+ if (accept === void 0) return false;
270
+ const value = Array.isArray(accept) ? accept.join(",") : accept;
271
+ for (const range of value.split(",")) {
272
+ const [type, ...params] = range.split(";");
273
+ if (type?.trim().toLowerCase() !== "text/event-stream") continue;
274
+ const q = params.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith("q="));
275
+ if (q === void 0 || Number.parseFloat(q.slice(2)) > 0) return true;
276
+ }
277
+ return false;
278
+ }
279
+ /**
280
+ * Ouvre un flux d'événements sur la réponse d'un contexte HTTP.
281
+ *
282
+ * Sauve la session d'abord — ses cookies partent avec les en-têtes, qu'on ne
283
+ * pourra plus toucher —, puis écrit les en-têtes du flux et désarme le délai
284
+ * d'inactivité de la requête.
285
+ *
286
+ * @param context - le contexte de la requête, pas encore répondu.
287
+ * @param options - battement de cœur, délai `retry:` initial.
288
+ * @returns le flux ouvert, ou sa promesse quand une session est à sauver.
289
+ * @throws HttpError 500 quand la réponse est déjà partie.
290
+ * @throws HttpError 429 quand l'IP du client a déjà tous ses flux et sockets
291
+ * (`wsMaxConnectionsPerIp`) — refusé AVANT d'écrire quoi que ce soit.
292
+ * @throws RangeError quand un délai n'est ni `false` ni un entier entre 1 et
293
+ * 2^31-1, ou que `maxEventBytes` n'est pas un entier positif.
294
+ */
295
+ function openSseStream(context, options = {}) {
296
+ checkTimer("heartbeat", options.heartbeat);
297
+ checkTimer("stallTimeout", options.stallTimeout);
298
+ checkTimer("maxDuration", options.maxDuration);
299
+ const maxEventBytes = options.maxEventBytes ?? 1048576;
300
+ 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
+ const settings = {
308
+ heartbeat: options.heartbeat ?? 15e3,
309
+ stallTimeout: options.stallTimeout ?? 3e4,
310
+ maxEventBytes,
311
+ maxDuration: options.maxDuration ?? false,
312
+ revalidate,
313
+ identifier
314
+ };
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"
337
+ });
338
+ context.writeHead(200);
339
+ if (stream !== null) stream.setTimeout(0);
340
+ else if (h1 !== null) {
341
+ h1.flushHeaders();
342
+ h1.socket?.setTimeout(0);
343
+ }
344
+ const sse = new SseStream(context, target, nodeResponse, settings);
345
+ if (settings.revalidate !== null) context.httpKernel?.watchStream(sse);
346
+ if (options.retry !== void 0) sse.retry(options.retry);
347
+ return sse;
348
+ }
349
+ export { SSE_HEARTBEAT_MS, SSE_MAX_EVENT_BYTES, SSE_STALL_TIMEOUT_MS, SseStream, acceptsEventStream, openSseStream };