@nodefony/framework 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +50 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +211 -0
  6. package/dist/nodefony/config/config.js +61 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  8. package/dist/nodefony/controller/AdminApiController.js +163 -0
  9. package/dist/nodefony/controller/ApiKeyController.js +151 -0
  10. package/dist/nodefony/controller/BenchController.js +132 -0
  11. package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
  12. package/dist/nodefony/controller/OAuth2Controller.js +133 -0
  13. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
  14. package/dist/nodefony/controller/SessionAuthController.js +141 -0
  15. package/dist/nodefony/controller/TokenAuthController.js +113 -0
  16. package/dist/nodefony/controller/TotpController.js +129 -0
  17. package/dist/nodefony/controller/WebAuthnController.js +242 -0
  18. package/dist/nodefony/controller/oauthAuthority.js +74 -0
  19. package/dist/nodefony/decorators/routerDecorators.js +967 -0
  20. package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
  21. package/dist/nodefony/interfaces/IController.js +1 -0
  22. package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
  23. package/dist/nodefony/interfaces/IResolver.js +1 -0
  24. package/dist/nodefony/interfaces/IRoute.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/service/AdminBroker.js +106 -0
  27. package/dist/nodefony/service/Eta.js +68 -0
  28. package/dist/nodefony/service/IdempotencyStore.js +136 -0
  29. package/dist/nodefony/service/router.js +243 -0
  30. package/dist/nodefony/src/Controller.js +515 -0
  31. package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
  32. package/dist/nodefony/src/KernelAdminApi.js +1243 -0
  33. package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
  34. package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
  35. package/dist/nodefony/src/Resolver.js +416 -0
  36. package/dist/nodefony/src/ResourceController.js +148 -0
  37. package/dist/nodefony/src/Route.js +476 -0
  38. package/dist/nodefony/src/SyslogAdminApi.js +466 -0
  39. package/dist/nodefony/src/Template.js +15 -0
  40. package/dist/nodefony/src/configMutation.js +186 -0
  41. package/dist/nodefony/src/docsReader.js +929 -0
  42. package/dist/nodefony/src/idempotency.js +137 -0
  43. package/dist/nodefony/src/idempotencyGc.js +32 -0
  44. package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
  45. package/dist/nodefony/src/scopeCatalog.js +40 -0
  46. package/dist/nodefony/src/syslogFilters.js +51 -0
  47. package/dist/types/index.d.ts +96 -0
  48. package/dist/types/nodefony/config/config.d.ts +41 -0
  49. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  50. package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
  51. package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
  52. package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
  53. package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
  54. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
  55. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
  56. package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
  57. package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
  58. package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
  59. package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
  60. package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
  61. package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
  62. package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
  63. package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
  64. package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
  65. package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
  66. package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
  67. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  68. package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
  69. package/dist/types/nodefony/service/Eta.d.ts +25 -0
  70. package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
  71. package/dist/types/nodefony/service/router.d.ts +53 -0
  72. package/dist/types/nodefony/src/Controller.d.ts +193 -0
  73. package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
  74. package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
  75. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
  76. package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
  77. package/dist/types/nodefony/src/Resolver.d.ts +165 -0
  78. package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
  79. package/dist/types/nodefony/src/Route.d.ts +192 -0
  80. package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
  81. package/dist/types/nodefony/src/Template.d.ts +8 -0
  82. package/dist/types/nodefony/src/configMutation.d.ts +109 -0
  83. package/dist/types/nodefony/src/docsReader.d.ts +369 -0
  84. package/dist/types/nodefony/src/idempotency.d.ts +96 -0
  85. package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
  86. package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
  87. package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
  88. package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
  89. package/docs/admin.md +451 -0
  90. package/docs/controller.md +645 -0
  91. package/docs/decorateurs.md +845 -0
  92. package/docs/idempotence.md +741 -0
  93. package/docs/index.md +151 -0
  94. package/docs/routing.md +648 -0
  95. package/docs/templates.md +380 -0
  96. package/package.json +83 -0
@@ -0,0 +1,515 @@
1
+ import { FileClass, RequestContext, Service } from "nodefony";
2
+ import { HttpError } from "@nodefony/http";
3
+ import fs, { createReadStream } from "node:fs";
4
+ import { promisify } from "node:util";
5
+ //#region nodefony/src/Controller.ts
6
+ const fsClose = promisify(fs.close);
7
+ /**
8
+ * Un `<script>` EN LIGNE qui ne porte pas d'attribut `nonce`.
9
+ *
10
+ * `(?![^>]*\bnonce=)` refuse la balise déjà signée ; `(?![^>]*\bsrc=)` refuse
11
+ * le script SERVI depuis un fichier, qui n'a pas besoin de nonce et satisfait
12
+ * `script-src 'self'`. Reste exactement le cas que le navigateur bloquera.
13
+ */
14
+ const INLINE_SCRIPT_SANS_NONCE = /<script(?![^>]*\bnonce=)(?![^>]*\bsrc=)[^>]*>\s*\S/iu;
15
+ /**
16
+ * Le CSP posé sur cette réponse exige-t-il un nonce sur les scripts ?
17
+ *
18
+ * Lu sur l'en-tête RÉELLEMENT posé, jamais recopié d'une configuration : le
19
+ * firewall a pu le composer (directives `@Csp` d'une route, fragments de
20
+ * modules), et une seconde idée de « la politique en vigueur » divergerait au
21
+ * premier de ces cas.
22
+ *
23
+ * ⚠️ Le test est volontairement GROSSIER — la présence d'un `'nonce-` où que ce
24
+ * soit dans la politique. Découper les directives pour ne regarder que
25
+ * `script-src` reviendrait à réimplémenter une grammaire CSP dans un chemin
26
+ * d'avertissement, pour un gain nul : le framework ne pose de nonce que sur les
27
+ * scripts, et le seul faux positif imaginable — une politique qui en poserait
28
+ * un ailleurs et pas sur les scripts — produirait un conseil inutile en
29
+ * développement, jamais un refus.
30
+ *
31
+ * @param csp - valeur de l'en-tête `Content-Security-Policy`, si posée.
32
+ * @returns `true` quand un script en ligne devra porter un nonce.
33
+ */
34
+ function cspExigeUnNonce(csp) {
35
+ return typeof csp === "string" && csp.includes("'nonce-");
36
+ }
37
+ /**
38
+ * Parse un header `Range` mono-plage en octets (RFC 9110 §14.1.2).
39
+ *
40
+ * @param range - valeur brute du header `Range` (ex. `bytes=0-499`, `bytes=-500`).
41
+ * @param length - taille de la représentation sélectionnée (octets).
42
+ * @returns bornes `{ start, end }` clampées à la représentation,
43
+ * `"unsatisfiable"` si la plage est valide mais hors représentation (→ 416,
44
+ * RFC 9110 §15.5.17), ou `null` si le header doit être ignoré — unité ≠
45
+ * `bytes`, multi-range non supporté ou syntaxe invalide (RFC 9110 §14.2 :
46
+ * un serveur PEUT ignorer un Range ; on répond alors 200 complet, jamais 500).
47
+ */
48
+ function parseByteRange(range, length) {
49
+ const unit = /^\s*bytes\s*=\s*(.+)$/i.exec(range);
50
+ if (!unit) return null;
51
+ const spec = (unit[1] ?? "").trim();
52
+ if (spec.includes(",")) return null;
53
+ const parts = /^(\d*)-(\d*)$/.exec(spec);
54
+ if (!parts) return null;
55
+ const first = parts[1] ?? "";
56
+ const last = parts[2] ?? "";
57
+ if (first === "" && last === "") return null;
58
+ if (first === "") {
59
+ const suffix = parseInt(last, 10);
60
+ if (suffix === 0 || length === 0) return "unsatisfiable";
61
+ return {
62
+ start: Math.max(0, length - suffix),
63
+ end: length - 1
64
+ };
65
+ }
66
+ const start = parseInt(first, 10);
67
+ if (last !== "" && start > parseInt(last, 10)) return null;
68
+ if (start >= length) return "unsatisfiable";
69
+ return {
70
+ start,
71
+ end: last === "" ? length - 1 : Math.min(parseInt(last, 10), length - 1)
72
+ };
73
+ }
74
+ var Controller = class extends Service {
75
+ static prefix = "/";
76
+ /**
77
+ * Scope d'instanciation de la classe — `"request"` par défaut, `"singleton"`
78
+ * posé par le décorateur `@Scope` (statique hérité, lu via `new.target` au
79
+ * constructor et par le Resolver : 0 Reflect). Cf {@link ControllerScope}.
80
+ */
81
+ static scope = "request";
82
+ #context = null;
83
+ #route = null;
84
+ #request = null;
85
+ #response = null;
86
+ #method = null;
87
+ #queryGet = null;
88
+ #query = null;
89
+ #queryFile = null;
90
+ #queryPost = null;
91
+ module;
92
+ template;
93
+ /**
94
+ * Contexte transport courant. Per-request : champ posé par `setContext`
95
+ * (constructor) — coût d'accès inchangé. Singleton stateless (V4.3) : champ
96
+ * jamais posé → lecture de l'ALS `RequestContext` (le `HttpKernel` y place
97
+ * le contexte à l'entrée du scope, V4.1) — chaque appel de helper retrouve
98
+ * LA requête en cours, jamais celle d'une requête concurrente.
99
+ */
100
+ get context() {
101
+ return this.#context ?? RequestContext.getContext();
102
+ }
103
+ set context(context) {
104
+ this.#context = context ?? null;
105
+ }
106
+ /**
107
+ * Route matchée. Per-request : posée par le Resolver via `setRoute`.
108
+ * Sans champ (singleton) : dérive du Resolver de la requête courante
109
+ * (`context.resolver`), donc toujours la route de CETTE requête.
110
+ */
111
+ get route() {
112
+ return this.#route ?? this.context?.resolver?.route ?? null;
113
+ }
114
+ get request() {
115
+ return this.#request ?? this.context?.request ?? null;
116
+ }
117
+ set request(request) {
118
+ this.#request = request;
119
+ }
120
+ get response() {
121
+ return this.#response ?? this.context?.response ?? null;
122
+ }
123
+ set response(response) {
124
+ this.#response = response;
125
+ }
126
+ get method() {
127
+ return this.#method ?? this.context?.method ?? void 0;
128
+ }
129
+ set method(method) {
130
+ this.#method = method ?? null;
131
+ }
132
+ get queryGet() {
133
+ return this.#queryGet ?? (this.context?.request)?.queryGet;
134
+ }
135
+ set queryGet(value) {
136
+ this.#queryGet = value;
137
+ }
138
+ get query() {
139
+ return this.#query ?? (this.context?.request)?.query;
140
+ }
141
+ set query(value) {
142
+ this.#query = value;
143
+ }
144
+ get queryFile() {
145
+ return this.#queryFile ?? (this.context?.request)?.queryFile;
146
+ }
147
+ set queryFile(value) {
148
+ this.#queryFile = value;
149
+ }
150
+ get queryPost() {
151
+ return this.#queryPost ?? (this.context?.request)?.queryPost;
152
+ }
153
+ set queryPost(value) {
154
+ this.#queryPost = value;
155
+ }
156
+ /**
157
+ * Le CORPS de la requête, parsé — nom universel de l'écosystème (Express,
158
+ * Fastify, NestJS), alias de {@link queryPost}.
159
+ *
160
+ * Ne pas confondre avec {@link query}, qui FUSIONNE la query string et le
161
+ * corps. Pour une action typée, préférer le décorateur `@Body()`.
162
+ *
163
+ * Getter (aucune allocation) : `queryPost` reste la source unique.
164
+ */
165
+ get body() {
166
+ return this.queryPost;
167
+ }
168
+ set body(value) {
169
+ this.queryPost = value;
170
+ }
171
+ /**
172
+ * Session courante, ou `null`. Getter direct sur `context.session` (peuplé au
173
+ * point d'activation unique du pipeline si la route déclare `@UseSession` /
174
+ * `@Session`, ou si un cookie de session est repris — L1). Remplace l'ancien
175
+ * pont via l'event `onSessionStart` : toujours à jour, zéro allocation.
176
+ */
177
+ get session() {
178
+ return this.context?.session ?? null;
179
+ }
180
+ constructor(name, context) {
181
+ const singleton = new.target.scope === "singleton";
182
+ const kernel = singleton ? context.kernel : null;
183
+ super(name, (singleton ? kernel?.container : null) ?? context.container, (singleton ? kernel?.notificationsCenter : null) ?? context.notificationsCenter);
184
+ this.template = this.get("template");
185
+ if (!singleton) this.setContext(context);
186
+ }
187
+ setContext(context) {
188
+ this.#context = context;
189
+ }
190
+ setContextJson(encoding = "utf-8") {
191
+ return this.context?.setContextJson(encoding);
192
+ }
193
+ setContextHtml(encoding = "utf-8") {
194
+ return this.context?.setContextHtml(encoding);
195
+ }
196
+ /**
197
+ * Dit, EN DÉVELOPPEMENT SEULEMENT, qu'un script en ligne ne s'exécutera pas.
198
+ *
199
+ * Le navigateur refuse un `<script>` en ligne sous une politique de contenu à
200
+ * nonce, et il le dit dans SA console — que personne ne lit quand la page est
201
+ * produite par un test, un `curl` ou un agent. Le serveur, lui, a les deux
202
+ * moitiés sous la main : l'en-tête qu'il vient de poser et le corps qu'il
203
+ * s'apprête à écrire. Il le dit donc au moment exact où le geste manque, avec
204
+ * le geste — c'est ce qui distingue un refus utilisable d'un refus juste.
205
+ *
206
+ * ⚠️ Coût nul hors développement : la garde d'environnement est le PREMIER
207
+ * test, avant toute lecture d'en-tête et toute expression régulière. Une page
208
+ * de production ne paie donc rien, pas même la lecture du CSP.
209
+ *
210
+ * @param data - le corps HTML sur le point d'être envoyé.
211
+ * @returns rien — cette sonde n'échoue jamais et ne change aucune réponse.
212
+ */
213
+ #warnScriptWithoutNonce(data) {
214
+ const kernel = this.context?.kernel;
215
+ if (kernel?.environment !== "development" && kernel?.environment !== "dev") return;
216
+ if (typeof data !== "string" || data.length === 0) return;
217
+ const response = this.response;
218
+ if (typeof response?.getHeader !== "function") return;
219
+ if (!cspExigeUnNonce(response.getHeader("Content-Security-Policy")) || !INLINE_SCRIPT_SANS_NONCE.test(data)) return;
220
+ 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");
221
+ }
222
+ async render(data, encoding, status, headers) {
223
+ this.#warnScriptWithoutNonce(data);
224
+ return this.context?.render(data, encoding, status, headers);
225
+ }
226
+ renderResponse(data, encoding, status, headers) {
227
+ if (headers) this.response?.setHeaders(headers);
228
+ if (status) this.response?.setStatusCode(status);
229
+ return this.context?.send(data, encoding);
230
+ }
231
+ async renderView(path, param = {}, status, headers) {
232
+ let data;
233
+ try {
234
+ const file = typeof path === "string" ? await FileClass.from(path) : path;
235
+ this.context?.phaseStart("render");
236
+ try {
237
+ data = await this.template?.render((await file.readAsync()).toString(), this.withFrontendLocals(param));
238
+ } finally {
239
+ this.context?.phaseEnd("render");
240
+ }
241
+ this.setContextHtml();
242
+ this.#warnScriptWithoutNonce(data);
243
+ return this.renderResponse(data, "utf8", status, headers);
244
+ } catch (e) {
245
+ this.log(e, "ERROR");
246
+ throw e;
247
+ }
248
+ }
249
+ /**
250
+ * Injecte les helpers frontend (`frontendTags`/`frontendDocument`) dans les
251
+ * locals du template (passés en data, pas de registre global de fonctions).
252
+ * Service `frontend` résolu par nom (pas d'import `@nodefony/frontend`). Les
253
+ * valeurs fournies par l'action priment (spread `param` en dernier).
254
+ */
255
+ withFrontendLocals(param) {
256
+ const fe = this.get("frontend");
257
+ if (!fe?.renderTags) return param;
258
+ const nonce = this.context?.cspNonce;
259
+ const host = this.context?.domain;
260
+ return {
261
+ frontendTags: (entry) => fe.renderTags(entry, nonce, host),
262
+ frontendDocument: (entry) => fe.renderDocument(entry, nonce, host),
263
+ asset: (p) => fe.assetUrl ? fe.assetUrl(p) : p,
264
+ ...param
265
+ };
266
+ }
267
+ async renderJson(obj, status, headers) {
268
+ const data = JSON.stringify(obj);
269
+ this.setContextJson();
270
+ return this.renderResponse(data, "utf8", status, headers);
271
+ }
272
+ setRoute(route) {
273
+ this.#route = route;
274
+ return route;
275
+ }
276
+ getSession() {
277
+ if (this.context?.session) return this.context?.session;
278
+ }
279
+ redirect(url, status, headers) {
280
+ if (!url) throw new Error("Redirect error no url !!!");
281
+ this.context.redirect(url, status, headers);
282
+ }
283
+ getFlashBag(key) {
284
+ const session = this.getSession();
285
+ if (session) return session.getFlashBag(key);
286
+ this.log("getFlashBag session not started !", "ERROR");
287
+ return null;
288
+ }
289
+ setFlashBag(key, value) {
290
+ const session = this.getSession();
291
+ if (session) return session.setFlashBag(key, value);
292
+ return null;
293
+ }
294
+ addFlash(key, value) {
295
+ return this.setFlashBag(key, value);
296
+ }
297
+ forward(name, param) {
298
+ return this.get("router").resolveController(this.context, name).callController(param, true);
299
+ }
300
+ /**
301
+ * @deprecated Bloque l'event-loop (`fs.lstatSync` via `new FileClass`).
302
+ * Utiliser {@link getFileAsync} dans tout pipeline. Conservé pour compat.
303
+ */
304
+ getFile(file) {
305
+ let File;
306
+ if (file instanceof FileClass) File = file;
307
+ else if (typeof file === "string") File = new FileClass(file);
308
+ else throw new Error(`File argument bad type for getFile :${typeof file}`);
309
+ if (File.type !== "File") throw new Error(`getFile bad type for :${file}`);
310
+ return File;
311
+ }
312
+ /**
313
+ * Variante **async** de `getFile()` — résout les stats via `FileClass.from`
314
+ * (pas de `lstatSync` bloquant). À préférer dans le pipeline (render/stream).
315
+ *
316
+ * Un chemin qui ne désigne aucun fichier rend **404**, pas 500 : la RFC 9110
317
+ * §15.5.5 définit le 404 comme l'absence de « représentation courante pour la
318
+ * ressource cible », alors que le 500 (§15.6.1) suppose une condition
319
+ * **inattendue**. Or le chemin vient presque toujours d'un paramètre d'URL :
320
+ * un nom qui ne correspond à rien est une entrée client banale, pas une panne.
321
+ * Un dossier reçoit le même traitement — il n'est pas servable comme fichier.
322
+ *
323
+ * Les autres échecs d'accès (permissions, E/S) remontent INCHANGÉS et valent
324
+ * 500 : là, la condition est bien inattendue. Le 403 ne conviendrait pas, la
325
+ * RFC le réservant à un refus « compris et délibéré » (§15.5.4).
326
+ *
327
+ * Le message rendu au client ne cite JAMAIS le chemin — la même section
328
+ * autorise le serveur à ne pas divulguer l'existence d'une ressource, et un
329
+ * chemin serveur dans un corps d'erreur est une fuite d'information. Le chemin
330
+ * va dans le journal, à la disposition de qui exploite l'application.
331
+ *
332
+ * @param file - `FileClass` déjà hydraté OU chemin string.
333
+ * @returns le `FileClass` (type `"File"` validé).
334
+ * @throws {HttpError} 404 si le chemin ne désigne aucun fichier.
335
+ * @throws Si le type de l'argument est invalide (bug d'appel, pas d'entrée client).
336
+ */
337
+ async getFileAsync(file) {
338
+ let File;
339
+ if (file instanceof FileClass) File = file;
340
+ else if (typeof file === "string") try {
341
+ File = await FileClass.from(file);
342
+ } catch (e) {
343
+ const code = e.code;
344
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw e;
345
+ this.log(`fichier introuvable : ${file}`, "DEBUG");
346
+ throw new HttpError("Not Found", 404, this.context);
347
+ }
348
+ else throw new Error(`File argument bad type for getFileAsync :${typeof file}`);
349
+ if (File.type !== "File") {
350
+ this.log(`ce chemin n'est pas un fichier : ${file}`, "DEBUG");
351
+ throw new HttpError("Not Found", 404, this.context);
352
+ }
353
+ return File;
354
+ }
355
+ /**
356
+ * Sert un fichier en TÉLÉCHARGEMENT (`Content-Disposition: attachment`).
357
+ *
358
+ * Comme {@link Controller.streamFile} dont il est l'habillage, il envoie le
359
+ * fichier ENTIER et n'honore pas `Range` — c'est le comportement voulu pour un
360
+ * téléchargement. Pour un média seekable, voir {@link Controller.renderMediaStream}.
361
+ *
362
+ * @param file - chemin ou `FileClass` du fichier à servir.
363
+ * @param options - options de `createReadStream`.
364
+ * @param headers - en-têtes ajoutés (écrasent ceux posés par défaut).
365
+ * @returns le flux de lecture, une fois la réponse écoulée.
366
+ */
367
+ async renderFileDownload(file, options, headers = {}) {
368
+ const File = await this.getFileAsync(file);
369
+ const length = File.stats.size;
370
+ const head = {
371
+ "Content-Disposition": `attachment; filename="${File.name}"`,
372
+ "Content-Length": length,
373
+ Expires: "0",
374
+ "Content-Description": "File Transfer",
375
+ "Content-Type": File.mimeType || "application/octet-stream",
376
+ ...headers
377
+ };
378
+ try {
379
+ return this.streamFile(File, head, options);
380
+ } catch (e) {
381
+ this.log(e, "ERROR");
382
+ throw e;
383
+ }
384
+ }
385
+ /**
386
+ * Envoie un fichier en FLUX, du disque vers la réponse, sans jamais le charger
387
+ * en mémoire.
388
+ *
389
+ * ⚠️ **N'honore PAS l'en-tête `Range`** : le fichier part toujours en entier,
390
+ * avec un statut 200. Pour un média dans lequel un lecteur doit pouvoir sauter
391
+ * (vidéo, audio, gros PDF), utiliser {@link Controller.renderMediaStream} —
392
+ * seul à implémenter les requêtes par plage (RFC 9110 §14 : 206, 416,
393
+ * `Content-Range`). Annoncer `Accept-Ranges: bytes` depuis ici est un piège :
394
+ * le client croit pouvoir se déplacer et reçoit tout le fichier.
395
+ *
396
+ * Ce que cette méthode garantit et qui justifie son existence : le NETTOYAGE.
397
+ * Le flux est ouvert en `autoClose: false` ; un client qui raccroche en plein
398
+ * téléchargement laisserait sinon un descripteur ouvert et une promesse pendue.
399
+ *
400
+ * @param file - chemin ou `FileClass` du fichier à servir.
401
+ * @param headers - en-têtes ajoutés à la réponse (le type MIME est déduit si absent).
402
+ * @param options - options de `createReadStream` (`start`/`end` pour un extrait).
403
+ * @returns le flux de lecture, une fois la réponse écoulée.
404
+ * @throws Si la réponse n'est pas disponible, ou si le fichier est illisible.
405
+ */
406
+ async streamFile(file, headers, options = {}) {
407
+ if (!this.response) throw new Error(`response not found`);
408
+ const contextResponse = this.response;
409
+ const response = contextResponse.response;
410
+ if (!response) throw new Error(`response not found`);
411
+ options.autoClose = false;
412
+ try {
413
+ const fileDetails = await this.getFileAsync(file);
414
+ this.response.response?.removeHeader("Content-Type");
415
+ this.response.response?.removeHeader("content-type");
416
+ if (!(headers && (headers["Content-Type"] || headers["content-type"]))) this.response.setFileMimeType(fileDetails.name);
417
+ if (!(headers && (headers["Content-Length"] || headers["content-length"]))) {
418
+ if (!headers) headers = {};
419
+ this.response.response?.removeHeader("Content-Length");
420
+ this.response.response?.removeHeader("content-length");
421
+ headers["Content-Length"] = fileDetails.stats.size;
422
+ }
423
+ const streamFile = createReadStream(fileDetails.path, options);
424
+ return new Promise((resolve, reject) => {
425
+ let handled = false;
426
+ const onResponseClose = () => {
427
+ if (!handled) streamFile.destroy();
428
+ };
429
+ response.once("close", onResponseClose);
430
+ streamFile.on("open", () => {
431
+ try {
432
+ this.context?.writeHead(contextResponse?.statusCode, headers);
433
+ streamFile.pipe(response, { end: false });
434
+ } catch (e) {
435
+ this.log(e, "ERROR");
436
+ return reject(e);
437
+ }
438
+ });
439
+ const handleStreamEnd = async () => {
440
+ try {
441
+ if (handled) return;
442
+ handled = true;
443
+ response.removeListener("close", onResponseClose);
444
+ if (streamFile) {
445
+ streamFile.unpipe(response);
446
+ if (streamFile.fd) await fsClose(streamFile.fd).catch((e) => {
447
+ return reject(e);
448
+ });
449
+ if (!this.context?.finished) this.context?.end();
450
+ return resolve(streamFile);
451
+ }
452
+ } catch (e) {
453
+ this.log(e, "ERROR");
454
+ return reject(e);
455
+ }
456
+ };
457
+ streamFile.on("end", handleStreamEnd);
458
+ streamFile.on("close", handleStreamEnd);
459
+ streamFile.on("error", (error) => {
460
+ this.log(error, "ERROR");
461
+ if (!this.context?.finished) this.context?.end();
462
+ return reject(error);
463
+ });
464
+ });
465
+ } catch (e) {
466
+ this.log(e, "ERROR");
467
+ throw e;
468
+ }
469
+ }
470
+ async renderMediaStream(file, headers = {}, options = {}) {
471
+ const File = await this.getFileAsync(file);
472
+ this.response?.setEncoding("binary");
473
+ const range = this.request?.headers?.range;
474
+ const length = File.stats.size;
475
+ let head;
476
+ let value;
477
+ const response = this.response.response;
478
+ const parsed = range ? parseByteRange(range, length) : null;
479
+ if (parsed === "unsatisfiable") return this.renderResponse("", "utf8", 416, {
480
+ "Content-Range": `bytes */${length}`,
481
+ "Accept-Ranges": "bytes"
482
+ });
483
+ if (parsed) {
484
+ const { start, end } = parsed;
485
+ const chunksize = end - start + 1;
486
+ value = {
487
+ ...options,
488
+ start,
489
+ end
490
+ };
491
+ head = {
492
+ "Content-Range": `bytes ${start}-${end}/${length}`,
493
+ "Accept-Ranges": "bytes",
494
+ "Content-Length": chunksize.toString(),
495
+ "Content-Type": File.mimeType || "application/octet-stream",
496
+ ...headers
497
+ };
498
+ response?.removeHeader("content-type");
499
+ this.response?.setStatusCode(206);
500
+ } else {
501
+ value = { ...options };
502
+ head = {
503
+ "Content-Type": File.mimeType || "application/octet-stream",
504
+ "Content-Length": length.toString(),
505
+ "Content-Disposition": ` inline; filename="${File.name}"`,
506
+ "Accept-Ranges": "bytes",
507
+ ...headers
508
+ };
509
+ response?.removeHeader("content-type");
510
+ }
511
+ return this.streamFile(File, head, value);
512
+ }
513
+ };
514
+ //#endregion
515
+ export { Controller as default, parseByteRange };