@nodefony/framework 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 (47) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js +7 -0
  3. package/dist/index.js +14 -10
  4. package/dist/nodefony/config/config.js +16 -2
  5. package/dist/nodefony/controller/AdminApiController.js +18 -5
  6. package/dist/nodefony/controller/BenchController.js +1 -1
  7. package/dist/nodefony/controller/CspReportController.js +70 -0
  8. package/dist/nodefony/controller/IssuerMetadataController.js +1 -1
  9. package/dist/nodefony/controller/LoginPageController.js +5 -7
  10. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -1
  11. package/dist/nodefony/controller/SessionAuthController.js +6 -7
  12. package/dist/nodefony/controller/TokenAuthController.js +3 -3
  13. package/dist/nodefony/decorators/routerDecorators.js +2 -2
  14. package/dist/nodefony/service/AdminBroker.js +1 -1
  15. package/dist/nodefony/service/IdempotencyStore.js +1 -1
  16. package/dist/nodefony/service/router.js +5 -4
  17. package/dist/nodefony/src/Controller.js +311 -114
  18. package/dist/nodefony/src/PlaygroundAdminApi.js +1 -1
  19. package/dist/nodefony/src/Resolver.js +3 -9
  20. package/dist/nodefony/src/SecurityApiController.js +30 -7
  21. package/dist/nodefony/src/conditionalRequest.js +103 -0
  22. package/dist/nodefony/src/docsReader.js +1 -1
  23. package/dist/nodefony/src/idempotency.js +1 -1
  24. package/dist/nodefony/src/loginPage.js +2 -1
  25. package/dist/nodefony/src/scopeCatalog.js +1 -1
  26. package/dist/nodefony/src/viewNonce.js +58 -0
  27. package/dist/types/index.d.ts +3 -2
  28. package/dist/types/nodefony/config/config.d.ts +20 -0
  29. package/dist/types/nodefony/controller/AdminApiController.d.ts +4 -2
  30. package/dist/types/nodefony/controller/CspReportController.d.ts +35 -0
  31. package/dist/types/nodefony/controller/LoginPageController.d.ts +3 -2
  32. package/dist/types/nodefony/controller/SessionAuthController.d.ts +5 -6
  33. package/dist/types/nodefony/controller/TokenAuthController.d.ts +3 -3
  34. package/dist/types/nodefony/interfaces/IController.d.ts +2 -2
  35. package/dist/types/nodefony/service/router.d.ts +3 -2
  36. package/dist/types/nodefony/src/Controller.d.ts +110 -12
  37. package/dist/types/nodefony/src/Resolver.d.ts +1 -1
  38. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -4
  39. package/dist/types/nodefony/src/conditionalRequest.d.ts +82 -0
  40. package/dist/types/nodefony/src/loginPage.d.ts +1 -1
  41. package/dist/types/nodefony/src/viewNonce.d.ts +33 -0
  42. package/docs/controller.md +145 -26
  43. package/docs/decorateurs.md +1 -1
  44. package/docs/idempotence.md +2 -2
  45. package/docs/index.md +4 -2
  46. package/docs/templates.md +15 -0
  47. package/package.json +10 -9
@@ -1,18 +1,20 @@
1
1
  import { isDevelopment, singletonWriteMessage } from "./singletonGuard.js";
2
+ import { fileServingSchema } from "../config/config.js";
3
+ import { evaluatePreconditions, fileValidators, ifRangeHolds, parseHttpDate } from "./conditionalRequest.js";
4
+ import { VIEW_NONCE_LOCAL, signTemplateTags } from "./viewNonce.js";
2
5
  import { FileClass, RequestContext, Service } from "nodefony";
3
6
  import { HttpContext, HttpError, acceptsEventStream } from "@nodefony/http";
4
- import fs, { createReadStream } from "node:fs";
5
- import { promisify } from "node:util";
7
+ import { createReadStream } from "node:fs";
8
+ import { Readable } from "node:stream";
6
9
  // nodefony/src/Controller.ts
7
- const fsClose = promisify(fs.close);
8
10
  /**
9
- * Un `<script>` EN LIGNE qui ne porte pas d'attribut `nonce`.
11
+ * Un `<script>` ou un `<style>` qui ne porte pas d'attribut `nonce`.
10
12
  *
11
- * `(?![^>]*\bnonce=)` refuse la balise déjà signée ; `(?![^>]*\bsrc=)` refuse
12
- * le script SERVI depuis un fichier, qui n'a pas besoin de nonce et satisfait
13
- * `script-src 'self'`. Reste exactement le cas que le navigateur bloquera.
13
+ * Sous `'strict-dynamic'` (ADR-0024 §C1), un script SERVI depuis un fichier
14
+ * de même origine est refusé lui aussi sans nonce : `'self'` n'y compte plus.
15
+ * Un `<style>` sans nonce l'est sous la politique de styles (§C2).
14
16
  */
15
- const INLINE_SCRIPT_SANS_NONCE = /<script(?![^>]*\bnonce=)(?![^>]*\bsrc=)[^>]*>\s*\S/iu;
17
+ const UNSIGNED_SCRIPT_OR_STYLE = /<(?:script|style)\b(?![^>]*\bnonce\s*=)/iu;
16
18
  /**
17
19
  * Le CSP posé sur cette réponse exige-t-il un nonce sur les scripts ?
18
20
  *
@@ -32,7 +34,7 @@ const INLINE_SCRIPT_SANS_NONCE = /<script(?![^>]*\bnonce=)(?![^>]*\bsrc=)[^>]*>\
32
34
  * @param csp - valeur de l'en-tête `Content-Security-Policy`, si posée.
33
35
  * @returns `true` quand un script en ligne devra porter un nonce.
34
36
  */
35
- function cspExigeUnNonce(csp) {
37
+ function cspRequiresNonce(csp) {
36
38
  return typeof csp === "string" && csp.includes("'nonce-");
37
39
  }
38
40
  /**
@@ -72,15 +74,71 @@ function parseByteRange(range, length) {
72
74
  end: last === "" ? length - 1 : Math.min(parseInt(last, 10), length - 1)
73
75
  };
74
76
  }
77
+ /** Une HTTP-date déjà passée (RFC 9110 §5.6.7) : le début de l'ère Unix. */
78
+ const EXPIRED_HTTP_DATE = "Thu, 01 Jan 1970 00:00:00 GMT";
75
79
  /**
76
- * Garantit une `Error` comme motif de rejet : une valeur levée par un tiers
77
- * (`fs`, flux) peut être n'importe quoi.
80
+ * Défaut des fichiers servis par une action, pour TOUTE l'application — la
81
+ * section `fileServing` de la config du module, posée au boot par
82
+ * {@link configureFileServing}. Initialisé depuis le schéma (seule source des
83
+ * défauts) : un contrôleur testé sans noyau reçoit les mêmes valeurs.
84
+ */
85
+ let fileServing = fileServingSchema.parse({});
86
+ /**
87
+ * Fixe le défaut des fichiers servis (validateurs, `Cache-Control`) pour
88
+ * toute l'application. Appelé par le module au boot avec sa config validée.
89
+ *
90
+ * @param config - section `fileServing` validée
91
+ */
92
+ function configureFileServing(config) {
93
+ fileServing = config;
94
+ }
95
+ /** Aucun validateur : `If-Range` ne tient jamais, aucune précondition n'est évaluée. */
96
+ const NO_VALIDATORS = Object.freeze({
97
+ etag: null,
98
+ lastModified: null,
99
+ lastModifiedStrong: false
100
+ });
101
+ /**
102
+ * En-têtes qu'un 304 doit porter, ceux qu'aurait portés le 200 (RFC 9110
103
+ * §15.4.5) — en minuscules.
104
+ */
105
+ const NOT_MODIFIED_FIELDS = /* @__PURE__ */ new Set([
106
+ "cache-control",
107
+ "content-location",
108
+ "etag",
109
+ "expires",
110
+ "last-modified",
111
+ "vary"
112
+ ]);
113
+ /** Valeur d'un en-tête d'un objet d'en-têtes, quelle que soit la casse de sa clé. */
114
+ function headerIn(headers, name) {
115
+ if (headers === void 0) return void 0;
116
+ for (const key of Object.keys(headers)) if (key.toLowerCase() === name) return headers[key];
117
+ }
118
+ /**
119
+ * Encode une valeur en `ext-value` UTF-8 (RFC 8187 §3.2) : tout ce qui n'est
120
+ * pas un `attr-char` part en pourcentage — `encodeURIComponent` laisse passer
121
+ * `'`, `(`, `)` et `*`, qui n'en sont pas.
122
+ */
123
+ function encodeExtValue(value) {
124
+ return encodeURIComponent(value).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
125
+ }
126
+ /**
127
+ * Valeur d'un en-tête `Content-Disposition` (RFC 6266) pour un nom de fichier
128
+ * QUELCONQUE. `filename` seul quand le nom s'y écrit fidèlement ; sinon un
129
+ * repli ASCII, suivi de `filename*` en UTF-8 (RFC 6266 §4.3 : le destinataire
130
+ * prend `filename*`). Le repli remplace par `_` ce que l'annexe D écarte :
131
+ * hors ASCII imprimable, `"`, `\` et `%` — un nom accentué ne lève donc
132
+ * jamais `ERR_INVALID_CHAR`, et un guillemet ne casse pas la valeur.
78
133
  *
79
- * @param e - valeur interceptée.
80
- * @returns `e` si c'est déjà une `Error`, sinon une `Error` qui en porte le texte.
134
+ * @param type - `attachment` (téléchargement) ou `inline` (affiché).
135
+ * @param name - le nom du fichier, tel quel.
136
+ * @returns la valeur de l'en-tête.
81
137
  */
82
- function asError(e) {
83
- return e instanceof Error ? e : new Error(String(e));
138
+ function contentDisposition(type, name) {
139
+ const fallback = name.replace(/[^\x20-\x7e]|["\\%]/g, "_");
140
+ if (fallback === name) return `${type}; filename="${name}"`;
141
+ return `${type}; filename="${fallback}"; filename*=UTF-8''${encodeExtValue(name)}`;
84
142
  }
85
143
  var Controller = class extends Service {
86
144
  static prefix = "/";
@@ -268,14 +326,17 @@ var Controller = class extends Service {
268
326
  return this.context?.setContextHtml(encoding);
269
327
  }
270
328
  /**
271
- * Dit, EN DÉVELOPPEMENT SEULEMENT, qu'un script en ligne ne s'exécutera pas.
329
+ * Dit, EN DÉVELOPPEMENT SEULEMENT, qu'un script ou un style ne s'appliquera pas.
272
330
  *
273
- * Le navigateur refuse un `<script>` en ligne sous une politique de contenu à
274
- * nonce, et il le dit dans SA console — que personne ne lit quand la page est
331
+ * Une vue rendue par {@link Controller.renderView} est signée par le
332
+ * framework (`viewNonce.ts#signTemplateTags`) : ce qui reste ici, c'est du
333
+ * HTML produit autrement — une chaîne bâtie dans l'action, une valeur insérée
334
+ * brute. Le navigateur refuse alors la balise, et il le dit dans SA console — que personne ne lit quand la page est
275
335
  * produite par un test, un `curl` ou un agent. Le serveur, lui, a les deux
276
- * moitiés sous la main : l'en-tête qu'il vient de poser et le corps qu'il
277
- * s'apprête à écrire. Il le dit donc au moment exact où le geste manque, avec
278
- * le geste — c'est ce qui distingue un refus utilisable d'un refus juste.
336
+ * moitiés sous la main : l'en-tête qu'il vient d'émettre et le corps qu'il
337
+ * vient d'écrire — lus APRÈS l'envoi, la politique n'étant posée qu'au
338
+ * `writeHead`. Il le dit donc à la requête même où le geste manque, avec le
339
+ * geste — c'est ce qui distingue un refus utilisable d'un refus juste.
279
340
  *
280
341
  * ⚠️ Coût nul hors développement : la garde d'environnement est le PREMIER
281
342
  * test, avant toute lecture d'en-tête et toute expression régulière. Une page
@@ -284,13 +345,13 @@ var Controller = class extends Service {
284
345
  * @param data - le corps HTML sur le point d'être envoyé.
285
346
  * @returns rien — cette sonde n'échoue jamais et ne change aucune réponse.
286
347
  */
287
- #warnScriptWithoutNonce(data) {
348
+ #warnUnsignedTags(data) {
288
349
  if (!isDevelopment(this.context?.kernel?.environment)) return;
289
350
  if (typeof data !== "string" || data.length === 0) return;
290
351
  const response = this.response;
291
352
  if (typeof response?.getHeader !== "function") return;
292
- if (!cspExigeUnNonce(response.getHeader("Content-Security-Policy")) || !INLINE_SCRIPT_SANS_NONCE.test(data)) return;
293
- this.log("Cette réponse porte un <script> EN LIGNE sans attribut `nonce`, et la politique de contenu de cette application exige un nonce sur les scripts : le navigateur REFUSERA de l'exécuter (« Refused to execute inline script »). Deux gestes, au choix :\n • signer le script — `<script nonce=\"${this.context?.cspNonce}\">` (dans une vue Eta : `<script nonce=\"<%= it.nonce %>\">`, le nonce étant passé en donnée depuis `this.context?.cspNonce`) ;\n • sortir le script dans un FICHIER servi par l'application — un `<script src=…>` de même origine satisfait `script-src 'self'` sans nonce.\n Ne desserre PAS la politique (`'unsafe-inline'`) : le détail vit dans `@nodefony/security/docs/headers.md`.", "WARNING", "CSP");
353
+ if (!cspRequiresNonce(response.getHeader("Content-Security-Policy")) || !UNSIGNED_SCRIPT_OR_STYLE.test(data)) return;
354
+ this.log("Cette réponse porte un <script> ou un <style> sans attribut `nonce`, et la politique de contenu de cette application exige un nonce : le navigateur REFUSERA la balise (« violates the following Content Security Policy directive »). Deux gestes, au choix :\n • rendre la page par une VUE (`this.renderView(…)`) : le framework signe chaque <script> et <style> écrit dans le gabarit ;\n • signer la balise à la main — `<script nonce=\"${this.context?.cspNonce}\">`.\n Ne desserre PAS la politique (`'unsafe-inline'`) : le détail vit dans `@nodefony/security/docs/headers.md`.", "WARNING", "CSP");
294
355
  }
295
356
  /**
296
357
  * Rend une donnée sur la réponse de la requête courante (JSON, HTML ou
@@ -304,8 +365,13 @@ var Controller = class extends Service {
304
365
  * @returns la réponse, ou sa promesse.
305
366
  */
306
367
  render(data, encoding, status, headers) {
307
- this.#warnScriptWithoutNonce(data);
308
- return this.context?.render(data, encoding, status, headers);
368
+ const sent = this.context?.render(data, encoding, status, headers);
369
+ if (sent instanceof Promise && isDevelopment(this.context?.kernel?.environment)) return sent.then((response) => {
370
+ this.#warnUnsignedTags(data);
371
+ return response;
372
+ });
373
+ this.#warnUnsignedTags(data);
374
+ return sent;
309
375
  }
310
376
  /**
311
377
  * Envoie une donnée déjà sérialisée sur le transport de la requête (HTTP ou
@@ -326,19 +392,34 @@ var Controller = class extends Service {
326
392
  const file = typeof path === "string" ? await FileClass.from(path) : path;
327
393
  this.context?.phaseStart("render");
328
394
  try {
329
- data = await this.template?.render((await file.readAsync()).toString(), this.withFrontendLocals(param));
395
+ data = await this.template?.render(signTemplateTags((await file.readAsync()).toString()), this.withNonceLocal(this.withFrontendLocals(param)));
330
396
  } finally {
331
397
  this.context?.phaseEnd("render");
332
398
  }
333
399
  this.setContextHtml();
334
- this.#warnScriptWithoutNonce(data);
335
- return await this.renderResponse(data, "utf8", status, headers);
400
+ const response = await this.renderResponse(data, "utf8", status, headers);
401
+ this.#warnUnsignedTags(data);
402
+ return response;
336
403
  } catch (e) {
337
404
  this.log(e, "ERROR");
338
405
  throw e;
339
406
  }
340
407
  }
341
408
  /**
409
+ * Expose le nonce CSP de la requête à la vue (`cspNonce`), lu seulement si
410
+ * le gabarit s'en sert : un accesseur, pas une valeur — une vue sans
411
+ * `<script>` ni `<style>` ne tire aucun nonce. Une valeur `cspNonce`
412
+ * fournie par l'action est gardée.
413
+ */
414
+ withNonceLocal(param) {
415
+ if ("cspNonce" in param) return param;
416
+ const context = this.context;
417
+ return Object.defineProperty({ ...param }, VIEW_NONCE_LOCAL, {
418
+ get: () => context?.cspNonce ?? "",
419
+ enumerable: false
420
+ });
421
+ }
422
+ /**
342
423
  * Injecte les helpers frontend (`frontendTags`/`frontendDocument`) dans les
343
424
  * locals du template (passés en data, pas de registre global de fonctions).
344
425
  * Service `frontend` résolu par nom (pas d'import `@nodefony/frontend`). Les
@@ -401,6 +482,12 @@ var Controller = class extends Service {
401
482
  * }
402
483
  * ```
403
484
  *
485
+ * Typé par le contrat temps réel de l'application, le flux lit la MÊME carte
486
+ * que la socket : `this.renderSse<ContractServerEmit<ChatContract>>()`, puis
487
+ * `sse.send(m, { event: "chat:room" })`. Sans paramètre de type, il reste
488
+ * libre — un flux hors contrat est valide.
489
+ *
490
+ * @typeParam Events - carte des événements du flux (défaut : libre).
404
491
  * @param options - battement de cœur, délai `retry:` initial.
405
492
  * @returns le flux, ou sa promesse quand une session est à sauver d'abord.
406
493
  * @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie.
@@ -409,6 +496,57 @@ var Controller = class extends Service {
409
496
  if (!(this.context instanceof HttpContext)) throw new HttpError("SSE : un flux d'événements exige une requête HTTP", 500, this.context);
410
497
  return this.context.openSse(options);
411
498
  }
499
+ /**
500
+ * Répond par un FLUX D'OCTETS : un `<video src>`, un `fetch()`, un
501
+ * téléchargement ou un outil tiers (curl) le consomment comme n'importe quelle
502
+ * réponse HTTP, une réponse par consommateur. Pour des ÉVÉNEMENTS texte lus
503
+ * par `EventSource`, voir {@link Controller.renderSse}. Pour UNE source
504
+ * d'octets diffusée à N lecteurs par la socket, publier un `Uint8Array` sur
505
+ * un canal temps réel (`@nodefony/realtime`), pas une réponse HTTP.
506
+ *
507
+ * La réponse est prise à l'appel ; les en-têtes partent au premier morceau.
508
+ * Une erreur de la source AVANT lui rend la page d'erreur ordinaire (500, ou
509
+ * le code d'une `HttpError`) ; APRÈS, le client voit une coupure. La source
510
+ * appartient au flux dès l'appel : détruite à la fin, quelle qu'en soit la
511
+ * raison, et détruite aussi quand l'appel lève (un flux web est annulé). La
512
+ * promesse ne rejette jamais : elle rend la raison de la fin et les octets
513
+ * envoyés.
514
+ *
515
+ * @example
516
+ * ```ts
517
+ * const ff = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
518
+ * try {
519
+ * await this.renderStream(ff.stdout, { headers: { "content-type": "video/mp4" } });
520
+ * } finally {
521
+ * ff.kill("SIGKILL"); // le flux est fini, quelle qu'en soit la raison
522
+ * }
523
+ * ```
524
+ *
525
+ * Relais d'un autre service — le signal coupe l'amont au départ du client ;
526
+ * ne recopier que `content-type` (`fetch` a déjà décompressé le corps) :
527
+ * ```ts
528
+ * const r = await fetch(url, { signal: this.context.signal });
529
+ * if (!r.ok || r.body === null) throw new HttpError(502);
530
+ * return this.renderStream(r.body, {
531
+ * headers: { "content-type": r.headers.get("content-type") ?? "application/octet-stream" },
532
+ * });
533
+ * ```
534
+ *
535
+ * @param source - flux Node, ou flux web ; jamais en mode objet.
536
+ * @param options - statut, en-têtes, `idleTimeout`, `stallTimeout`,
537
+ * `maxDuration`, `onClose(reason)`.
538
+ * @returns la raison de la fin et le nombre d'octets envoyés.
539
+ * @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie ;
540
+ * 429 au-delà du plafond de connexions par IP. La source est alors détruite.
541
+ */
542
+ renderStream(source, options) {
543
+ if (!(this.context instanceof HttpContext)) {
544
+ if (source instanceof Readable) source.destroy();
545
+ else source.cancel().catch(() => void 0);
546
+ throw new HttpError("Flux : une réponse en flux exige une requête HTTP", 500, this.context);
547
+ }
548
+ return this.context.openStream(source, options);
549
+ }
412
550
  setRoute(route) {
413
551
  this.#assertPerRequest("route");
414
552
  this.#route = route;
@@ -504,15 +642,16 @@ var Controller = class extends Service {
504
642
  * @param file - chemin ou `FileClass` du fichier à servir.
505
643
  * @param options - options de `createReadStream`.
506
644
  * @param headers - en-têtes ajoutés (écrasent ceux posés par défaut).
507
- * @returns le flux de lecture, une fois la réponse écoulée.
645
+ * @returns le flux de lecture, une fois la réponse écoulée — `null` quand
646
+ * les préconditions ont répondu 304 ou 412 (cf {@link Controller.streamFile}).
508
647
  */
509
648
  async renderFileDownload(file, options, headers = {}) {
510
649
  const File = await this.getFileAsync(file);
511
650
  const length = File.stats.size;
512
651
  const head = {
513
- "Content-Disposition": `attachment; filename="${File.name}"`,
652
+ "Content-Disposition": contentDisposition("attachment", File.name),
514
653
  "Content-Length": length,
515
- Expires: "0",
654
+ Expires: EXPIRED_HTTP_DATE,
516
655
  "Content-Description": "File Transfer",
517
656
  "Content-Type": File.mimeType || "application/octet-stream",
518
657
  ...headers
@@ -530,98 +669,156 @@ var Controller = class extends Service {
530
669
  * `Content-Range`). Annoncer `Accept-Ranges: bytes` depuis ici est un piège :
531
670
  * le client croit pouvoir se déplacer et reçoit tout le fichier.
532
671
  *
533
- * Ce que cette méthode garantit et qui justifie son existence : le NETTOYAGE.
534
- * Le flux est ouvert en `autoClose: false` ; un client qui raccroche en plein
535
- * téléchargement laisserait sinon un descripteur ouvert et une promesse pendue.
672
+ * Le cycle de vie est celui de toute réponse en flux
673
+ * ({@link Controller.renderStream}) : la réponse est prise à l'appel, les
674
+ * en-têtes partent au premier morceau, le flux est DÉTRUIT (descripteur
675
+ * fermé) quand le client raccroche. Un fichier illisible à l'ouverture
676
+ * (`EACCES`) rend la page d'erreur ordinaire, 500 ; une lecture qui échoue
677
+ * en cours de route coupe la connexion. Sans délai d'inactivité (un disque
678
+ * n'est pas une source muette).
679
+ *
680
+ * **Requêtes conditionnelles** (RFC 9110 §13) : le fichier ENTIER porte une
681
+ * `ETag` forte et un `Last-Modified`, tirés de sa taille et de sa date de
682
+ * modification ; `If-Match`, `If-Unmodified-Since`, `If-None-Match` et
683
+ * `If-Modified-Since` y sont évalués dans l'ordre de §13.2.2 → 304 ou 412,
684
+ * sans ouvrir le fichier. Une `ETag` ou un `Last-Modified` passés dans
685
+ * `headers` (ou déjà posés sur la réponse) l'emportent, et servent à la
686
+ * comparaison. `Cache-Control: private, no-cache` est posé si l'action n'en
687
+ * fixe aucun — en passer un dans `headers` le remplace. Un EXTRAIT
688
+ * (`start`/`end`) n'est pas la représentation entière : il ne reçoit ni
689
+ * validateur ni évaluation.
690
+ * `{ validators: false }` dans `options` coupe le tout — validateurs,
691
+ * évaluation et `Cache-Control` par défaut — pour des fichiers dont la date
692
+ * n'est pas fiable ; une `ETag` tirée du contenu garde, elle, le 304.
536
693
  *
537
694
  * @param file - chemin ou `FileClass` du fichier à servir.
538
695
  * @param headers - en-têtes ajoutés à la réponse (le type MIME est déduit si absent).
539
696
  * @param options - options de `createReadStream` (`start`/`end` pour un extrait).
540
- * @returns le flux de lecture, une fois la réponse écoulée.
541
- * @throws Si la réponse n'est pas disponible, ou si le fichier est illisible.
697
+ * @returns le flux de lecture, une fois la réponse écoulée — détruit, quelle
698
+ * que soit la fin ; `null` quand les préconditions ont répondu 304 ou 412 :
699
+ * aucun fichier n'a alors été ouvert.
700
+ * @throws HttpError 404 si le fichier n'existe pas ; 500 hors HTTP
701
+ * (WebSocket) ou quand la réponse est déjà partie.
702
+ */
703
+ async streamFile(file, headers, options) {
704
+ const fileDetails = await this.getFileAsync(file);
705
+ if (options?.start !== void 0 || options?.end !== void 0 || !(options?.validators ?? fileServing.validators)) return this.#sendFile(fileDetails, headers, options);
706
+ const condition = this.#fileCondition(fileDetails, headers, true);
707
+ if (condition.outcome !== 200) return this.#answerPrecondition(condition);
708
+ return this.#sendFile(fileDetails, condition.head, options);
709
+ }
710
+ /**
711
+ * Validateurs d'un fichier servi, et verdict des préconditions de la
712
+ * requête (RFC 9110 §13.2.2). Hors HTTP, rien n'est évalué : l'envoi
713
+ * refusera de lui-même.
714
+ *
715
+ * @param file - fichier servi
716
+ * @param headers - en-têtes fixés par l'action
717
+ * @param enabled - `false` (option `validators`) : ni validateur, ni
718
+ * évaluation, ni `Cache-Control` par défaut — les en-têtes de l'action
719
+ * seuls
542
720
  */
543
- async streamFile(file, headers, options = {}) {
721
+ #fileCondition(file, headers, enabled) {
544
722
  const context = this.context;
545
- if (!this.response) throw new Error(`response not found`);
546
- const contextResponse = this.response;
547
- const response = contextResponse.response;
548
- if (!response) throw new Error(`response not found`);
549
- options.autoClose = false;
550
- let streamFile;
551
- try {
552
- const fileDetails = await this.getFileAsync(file);
553
- this.response.response?.removeHeader("Content-Type");
554
- this.response.response?.removeHeader("content-type");
555
- if (!(headers && (headers["Content-Type"] || headers["content-type"]))) this.response.setFileMimeType(fileDetails.name);
556
- if (!(headers && (headers["Content-Length"] || headers["content-length"]))) {
557
- headers ??= {};
558
- this.response.response?.removeHeader("Content-Length");
559
- this.response.response?.removeHeader("content-length");
560
- headers["Content-Length"] = fileDetails.stats.size;
561
- }
562
- streamFile = createReadStream(fileDetails.path, options);
563
- } catch (e) {
564
- this.log(e, "ERROR");
565
- throw e;
723
+ if (!(context instanceof HttpContext)) return {
724
+ head: headers ?? {},
725
+ validators: NO_VALIDATORS,
726
+ outcome: 200,
727
+ request: {}
728
+ };
729
+ const request = context.request.headers;
730
+ if (!enabled) return {
731
+ head: headers ?? {},
732
+ validators: NO_VALIDATORS,
733
+ outcome: 200,
734
+ request
735
+ };
736
+ const computed = fileValidators(file.stats, Date.now());
737
+ const response = context.response;
738
+ const given = (name) => headerIn(headers, name) ?? response.getHeader(name);
739
+ const head = {};
740
+ const etag = given("etag");
741
+ const lastModified = given("last-modified");
742
+ if (etag === void 0 && computed.etag !== null) head.ETag = computed.etag;
743
+ if (lastModified === void 0 && computed.lastModified !== null) head["Last-Modified"] = new Date(computed.lastModified).toUTCString();
744
+ const cacheControl = fileServing.cacheControl;
745
+ if (cacheControl !== null && given("cache-control") === void 0) head["Cache-Control"] = cacheControl;
746
+ const validators = etag === void 0 && lastModified === void 0 ? computed : {
747
+ etag: etag === void 0 ? computed.etag : String(etag),
748
+ lastModified: lastModified === void 0 ? computed.lastModified : parseHttpDate(String(lastModified)),
749
+ lastModifiedStrong: lastModified === void 0 ? computed.lastModifiedStrong : false
750
+ };
751
+ Object.assign(head, headers);
752
+ return {
753
+ head,
754
+ validators,
755
+ outcome: evaluatePreconditions(this.method ?? "GET", request, validators),
756
+ request
757
+ };
758
+ }
759
+ /**
760
+ * Répond 304 (rien n'a changé) ou 412 (une condition est fausse) sans
761
+ * ouvrir le fichier. Le 304 porte les en-têtes qu'aurait portés le 200 :
762
+ * validateurs, `Cache-Control`, `Expires`, `Vary` (RFC 9110 §15.4.5).
763
+ */
764
+ async #answerPrecondition(condition) {
765
+ if (condition.outcome === 304) {
766
+ const kept = {};
767
+ for (const key of Object.keys(condition.head)) if (NOT_MODIFIED_FIELDS.has(key.toLowerCase())) kept[key] = condition.head[key];
768
+ await this.renderResponse("", "utf8", 304, kept);
769
+ return null;
566
770
  }
567
- return new Promise((resolve, reject) => {
568
- let handled = false;
569
- const onResponseClose = () => {
570
- if (!handled) streamFile.destroy();
571
- };
572
- response.once("close", onResponseClose);
573
- streamFile.on("open", () => {
574
- try {
575
- context?.writeHead(contextResponse.statusCode, headers);
576
- streamFile.pipe(response, { end: false });
577
- } catch (e) {
578
- this.log(e, "ERROR");
579
- reject(asError(e));
580
- }
581
- });
582
- const endContext = () => {
583
- if (context && !context.finished) Promise.resolve(context.end()).catch((e) => {
584
- this.log(e, "ERROR");
585
- });
586
- };
587
- const handleStreamEnd = async () => {
588
- try {
589
- if (handled) return;
590
- handled = true;
591
- response.removeListener("close", onResponseClose);
592
- streamFile.unpipe(response);
593
- if (streamFile.fd) await fsClose(streamFile.fd).catch((e) => {
594
- reject(asError(e));
595
- });
596
- endContext();
597
- resolve(streamFile);
598
- } catch (e) {
599
- this.log(e, "ERROR");
600
- reject(asError(e));
601
- }
602
- };
603
- const onStreamEnd = () => {
604
- handleStreamEnd();
771
+ await this.renderResponse("Precondition Failed", "utf8", 412, { "Content-Type": "text/plain; charset=utf-8" });
772
+ return null;
773
+ }
774
+ /**
775
+ * Envoie le fichier déjà résolu sur la brique de flux — le cœur de
776
+ * {@link Controller.streamFile}, sans préconditions.
777
+ */
778
+ async #sendFile(fileDetails, headers, options) {
779
+ const context = this.context;
780
+ if (!(context instanceof HttpContext)) throw new HttpError("Flux : un fichier en flux exige une requête HTTP", 500, context);
781
+ const response = context.response;
782
+ response.removeHeader("content-type");
783
+ let head = headers;
784
+ if (!(head && (head["Content-Type"] || head["content-type"]))) response.setFileMimeType(fileDetails.name);
785
+ const size = fileDetails.stats.size;
786
+ const start = options?.start ?? 0;
787
+ const end = Math.min(options?.end ?? size - 1, size - 1);
788
+ const length = Math.max(0, end - start + 1);
789
+ if (!(head && (head["Content-Length"] || head["content-length"]))) {
790
+ response.removeHeader("content-length");
791
+ head = {
792
+ ...head,
793
+ "Content-Length": length
605
794
  };
606
- streamFile.on("end", onStreamEnd);
607
- streamFile.on("close", onStreamEnd);
608
- streamFile.on("error", (error) => {
609
- this.log(error, "ERROR");
610
- endContext();
611
- reject(error);
612
- });
795
+ }
796
+ const stream = createReadStream(fileDetails.path, length === 0 ? options : {
797
+ ...options,
798
+ end
613
799
  });
800
+ await context.openStream(stream, {
801
+ status: response.statusCode,
802
+ headers: head,
803
+ live: false,
804
+ idleTimeout: 0
805
+ });
806
+ return stream;
614
807
  }
615
808
  async renderMediaStream(file, headers = {}, options = {}) {
616
809
  const File = await this.getFileAsync(file);
810
+ const condition = this.#fileCondition(File, headers, options.validators ?? fileServing.validators);
811
+ if (condition.outcome !== 200) return this.#answerPrecondition(condition);
617
812
  this.response?.setEncoding("binary");
618
- const range = this.request?.headers.range;
813
+ const ifRange = condition.request["if-range"];
814
+ const range = this.method === "GET" && (ifRange === void 0 || ifRangeHolds(ifRange, condition.validators)) ? condition.request.range : void 0;
619
815
  const length = File.stats.size;
620
816
  let head;
621
817
  let value;
622
- const response = this.response.response;
818
+ const contextResponse = this.response;
623
819
  const parsed = range ? parseByteRange(range, length) : null;
624
- if (parsed === "unsatisfiable") return this.renderResponse("", "utf8", 416, {
820
+ if (parsed === "unsatisfiable") return this.renderResponse(`Range Not Satisfiable (representation length ${length})`, "utf8", 416, {
821
+ "Content-Type": "text/plain; charset=utf-8",
625
822
  "Content-Range": `bytes */${length}`,
626
823
  "Accept-Ranges": "bytes"
627
824
  });
@@ -638,22 +835,22 @@ var Controller = class extends Service {
638
835
  "Accept-Ranges": "bytes",
639
836
  "Content-Length": chunksize.toString(),
640
837
  "Content-Type": File.mimeType || "application/octet-stream",
641
- ...headers
838
+ ...condition.head
642
839
  };
643
- response?.removeHeader("content-type");
840
+ contextResponse.removeHeader("content-type");
644
841
  this.response?.setStatusCode(206);
645
842
  } else {
646
843
  value = { ...options };
647
844
  head = {
648
845
  "Content-Type": File.mimeType || "application/octet-stream",
649
846
  "Content-Length": length.toString(),
650
- "Content-Disposition": ` inline; filename="${File.name}"`,
847
+ "Content-Disposition": contentDisposition("inline", File.name),
651
848
  "Accept-Ranges": "bytes",
652
- ...headers
849
+ ...condition.head
653
850
  };
654
- response?.removeHeader("content-type");
851
+ contextResponse.removeHeader("content-type");
655
852
  }
656
- return this.streamFile(File, head, value);
853
+ return this.#sendFile(File, head, value);
657
854
  }
658
855
  };
659
- export { Controller as default, parseByteRange };
856
+ export { configureFileServing, Controller as default, parseByteRange };
@@ -1,5 +1,5 @@
1
- import { extractActionScopes, resolveActionMeta } from "../decorators/routerDecorators.js";
2
1
  import router_default from "../service/router.js";
2
+ import { extractActionScopes, resolveActionMeta } from "../decorators/routerDecorators.js";
3
3
  import AdminApiController from "../controller/AdminApiController.js";
4
4
  import { standardJsonSchemaOf } from "nodefony";
5
5
  // nodefony/src/PlaygroundAdminApi.ts
@@ -4,7 +4,7 @@ import { buildParamArgs, computeActionMeta, readInvocationBody, readRouteInput,
4
4
  import { computeFingerprint, evaluateIdempotency, isMutationMethod, resolveIdempotencyKey, resolveIdentity } from "./idempotency.js";
5
5
  import { authenticationShortfall, stepUpChallenge } from "./requireAuthentication.js";
6
6
  import { RequestContext, finallyMaybe, identityHint, isArray, isPlainObject, isPromise, nodefonyError, thenMaybe, typeOf } from "nodefony";
7
- import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
7
+ import { Http2Response, HttpResponse, WebsocketResponse, isContentlessStatus } from "@nodefony/http";
8
8
  // nodefony/src/Resolver.ts
9
9
  /**
10
10
  * Résout une route vers son couple controller/action et exécute l'action —
@@ -396,7 +396,7 @@ var Resolver = class {
396
396
  * (`returnController`) — le chemin du **pont `api.request`** (WS) : la valeur
397
397
  * nue est enveloppée `{id, result}` par le peer, jamais rendue sur le
398
398
  * transport. La méthode HTTP logique d'une mutation du pont voyage dans
399
- * {@link methodOverride} ; sans porte ici, un rejeu `socket.mutate` (socket
399
+ * {@link methodOverride} ; sans porte ici, un rejeu `socket.post` (socket
400
400
  * qui reconnecte) ré-exécuterait la mutation (doublon — vécu au banc duplex).
401
401
  *
402
402
  * @returns `{ result }` — la valeur retournée par l'action (ou la réponse
@@ -677,7 +677,7 @@ var Resolver = class {
677
677
  case "http":
678
678
  if (this.context.sended) return;
679
679
  if (this.context.isRedirect) return this.context.send();
680
- if (NO_BODY_STATUS.has(this.getResponseStatus())) return this.context.send();
680
+ if (isContentlessStatus(this.getResponseStatus())) return this.context.send();
681
681
  this.context.waitAsync = true;
682
682
  this.unrendered = result === null ? "null" : typeof result;
683
683
  }
@@ -713,10 +713,4 @@ var Resolver = class {
713
713
  throw new nodefonyError(identityHint(this.context.kernel) === null ? "Authentication required" : `Authentication required — ${cause}. Reconnectez-vous (\`@RequireAuthentication\` de la route).`, 401);
714
714
  }
715
715
  };
716
- /** Statuts dont la réponse n'a, par définition, pas de corps (RFC 9110). */
717
- const NO_BODY_STATUS = /* @__PURE__ */ new Set([
718
- 204,
719
- 205,
720
- 304
721
- ]);
722
716
  export { Resolver as default };