@nodefony/framework 10.0.0-alpha.8 → 10.0.0-beta.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 (49) hide show
  1. package/README.md +4 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +7 -6
  5. package/dist/nodefony/controller/AdminApiController.js +4 -3
  6. package/dist/nodefony/controller/ApiKeyController.js +1 -1
  7. package/dist/nodefony/controller/OAuth2Controller.js +11 -6
  8. package/dist/nodefony/controller/SessionAuthController.js +3 -3
  9. package/dist/nodefony/controller/TokenAuthController.js +2 -2
  10. package/dist/nodefony/controller/TotpController.js +5 -4
  11. package/dist/nodefony/controller/WebAuthnController.js +4 -4
  12. package/dist/nodefony/decorators/routerDecorators.js +41 -35
  13. package/dist/nodefony/service/AdminBroker.js +2 -2
  14. package/dist/nodefony/service/IdempotencyStore.js +4 -5
  15. package/dist/nodefony/service/router.js +75 -25
  16. package/dist/nodefony/src/Controller.js +184 -78
  17. package/dist/nodefony/src/FrameworkAdminApi.js +19 -6
  18. package/dist/nodefony/src/KernelAdminApi.js +60 -55
  19. package/dist/nodefony/src/PlaygroundAdminApi.js +3 -3
  20. package/dist/nodefony/src/RedisIdempotencyStore.js +2 -2
  21. package/dist/nodefony/src/Resolver.js +219 -63
  22. package/dist/nodefony/src/Route.js +38 -38
  23. package/dist/nodefony/src/SyslogAdminApi.js +4 -4
  24. package/dist/nodefony/src/docsReader.js +22 -18
  25. package/dist/nodefony/src/scopeCatalog.js +1 -1
  26. package/dist/nodefony/src/singletonGuard.js +76 -0
  27. package/dist/types/nodefony/controller/BenchController.d.ts +2 -2
  28. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +3 -3
  29. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +1 -1
  30. package/dist/types/nodefony/controller/SessionAuthController.d.ts +2 -1
  31. package/dist/types/nodefony/decorators/routerDecorators.d.ts +49 -43
  32. package/dist/types/nodefony/interfaces/IController.d.ts +9 -7
  33. package/dist/types/nodefony/interfaces/IResolver.d.ts +10 -10
  34. package/dist/types/nodefony/interfaces/IRoute.d.ts +13 -13
  35. package/dist/types/nodefony/service/AdminBroker.d.ts +3 -3
  36. package/dist/types/nodefony/service/router.d.ts +33 -7
  37. package/dist/types/nodefony/src/Controller.d.ts +97 -24
  38. package/dist/types/nodefony/src/Resolver.d.ts +154 -15
  39. package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
  40. package/dist/types/nodefony/src/Route.d.ts +24 -22
  41. package/dist/types/nodefony/src/docsReader.d.ts +3 -3
  42. package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
  43. package/docs/admin.md +26 -26
  44. package/docs/controller.md +139 -121
  45. package/docs/decorateurs.md +108 -105
  46. package/docs/idempotence.md +78 -71
  47. package/docs/routing.md +57 -51
  48. package/docs/templates.md +17 -17
  49. package/package.json +12 -12
@@ -1,20 +1,23 @@
1
1
  import Route from "../src/Route.js";
2
2
  import { routeExpectsBodyStream } from "../decorators/routerDecorators.js";
3
3
  import Resolver from "../src/Resolver.js";
4
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js";
5
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
6
- import { Module, Service, injectable } from "nodefony";
4
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
5
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
6
+ import { Injector, Module, Service, injectable, isPromise } from "nodefony";
7
7
  import { HttpError, isDomainAllowed } from "@nodefony/http";
8
8
  //#region nodefony/service/router.ts
9
9
  var _Router;
10
10
  let routeNoticePromoted = null;
11
+ /**
12
+ * Méthodes que sert une route — lues sur le `methodsSet` COMPILÉ par
13
+ * `Route.compileRequirements`, jamais recalculées depuis la config brute.
14
+ * Deux lectures de la même règle avaient divergé : la route acceptait HEAD
15
+ * (implicite sur GET, RFC 9110 §9.1) quand le `Allow` du 405 l'omettait.
16
+ */
11
17
  function collectSupportedMethods(route) {
12
- const set = /* @__PURE__ */ new Set();
13
- const m = route.requirements?.methods;
14
- if (typeof m === "string") m.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean).forEach((x) => set.add(x));
15
- else if (Array.isArray(m)) m.map((s) => String(s).toUpperCase()).forEach((x) => set.add(x));
16
- return set;
18
+ return route.methodsSet ?? NO_METHODS;
17
19
  }
20
+ const NO_METHODS = /* @__PURE__ */ new Set();
18
21
  const routes = [];
19
22
  const serviceName = "router";
20
23
  const REG_NON_LITERAL = /[{}*+?()[\]^$|\\]/;
@@ -49,8 +52,7 @@ function invalidateRouteIndex() {
49
52
  function buildRouteIndex() {
50
53
  const statics = /* @__PURE__ */ new Map();
51
54
  const dynamics = [];
52
- for (let i = 0; i < routes.length; i++) {
53
- const route = routes[i];
55
+ for (const [i, route] of routes.entries()) {
54
56
  const path = route.path;
55
57
  if (path !== void 0 && route.variables.length === 0 && !REG_NON_LITERAL.test(path)) {
56
58
  const key = path.toLowerCase();
@@ -90,23 +92,39 @@ let Router = class Router extends Service {
90
92
  }
91
93
  /**
92
94
  * Retourne l'instance singleton d'une classe controller `@Scope("singleton")`,
93
- * en la créant au premier appel via `create`. On cache la **promesse** (pas
94
- * l'instance) : N requêtes concurrentes pendant la création (`initialize()`
95
- * async) attendent le MÊME travail — jamais deux instances (race de création
96
- * éliminée structurellement).
95
+ * en la créant au premier appel via `create`.
96
+ *
97
+ * Tant que la création est en cours (`initialize()` async), c'est la
98
+ * **promesse** qui est en cache : N requêtes concurrentes attendent le MÊME
99
+ * travail — jamais deux instances (race de création éliminée
100
+ * structurellement). Dès que l'instance est prête, elle REMPLACE la promesse :
101
+ * les requêtes suivantes la reçoivent directement, au lieu d'attendre à
102
+ * chaque fois une promesse déjà tenue (#505).
97
103
  *
98
104
  * @param ctor - la classe controller (clé du cache).
99
105
  * @param create - fabrique exécutée une seule fois (instantiate + initialize).
100
- * @returns la promesse de l'instance partagée.
106
+ * @returns l'instance partagée quand elle existe — ou que `create` la rend
107
+ * sans rien attendre —, sinon la promesse de sa création.
108
+ * @throws ce que `create` lève de façon synchrone ; rien n'est alors mis en
109
+ * cache, la requête suivante recrée.
101
110
  */
102
111
  getSingletonController(ctor, create) {
103
- if (this.singletonControllers == null) this.singletonControllers = /* @__PURE__ */ new Map();
104
- let instance = this.singletonControllers.get(ctor);
105
- if (!instance) {
106
- instance = create();
107
- this.singletonControllers.set(ctor, instance);
112
+ this.singletonControllers ??= /* @__PURE__ */ new Map();
113
+ const cached = this.singletonControllers.get(ctor);
114
+ if (cached !== void 0) return cached;
115
+ const created = create();
116
+ if (!isPromise(created)) {
117
+ this.singletonControllers.set(ctor, created);
118
+ return created;
108
119
  }
109
- return instance;
120
+ const pending = Promise.resolve(created);
121
+ this.singletonControllers.set(ctor, pending);
122
+ pending.then((instance) => {
123
+ if (this.singletonControllers?.get(ctor) === pending) this.singletonControllers.set(ctor, instance);
124
+ }, () => {
125
+ if (this.singletonControllers?.get(ctor) === pending) this.singletonControllers.delete(ctor);
126
+ });
127
+ return pending;
110
128
  }
111
129
  /**
112
130
  * Résout une route pour un contexte donné.
@@ -138,22 +156,27 @@ let Router = class Router extends Service {
138
156
  let di = 0;
139
157
  while (li < litCount || di < dynCount) {
140
158
  let route;
141
- if (li < litCount && (di >= dynCount || literals[li].pos < dynamics[di].pos)) route = literals[li++].route;
159
+ const literal = literals[li];
160
+ const candidate = dynamics[di];
161
+ if (literal !== void 0 && (candidate === void 0 || literal.pos < candidate.pos)) {
162
+ li++;
163
+ route = literal.route;
164
+ } else if (candidate === void 0) break;
142
165
  else {
143
- const candidate = dynamics[di++];
166
+ di++;
144
167
  if (candidate.prefix !== "" && cleanPathLower !== void 0 && !cleanPathLower.startsWith(candidate.prefix)) continue;
145
168
  route = candidate.route;
146
169
  }
147
170
  try {
148
171
  if (resolver.match(route, context, cleanPath)) {
149
- if (routeNoticePromoted === null) routeNoticePromoted = this.kernel?.environment !== "production";
172
+ routeNoticePromoted ??= this.kernel?.environment !== "production";
150
173
  if (routeNoticePromoted) this.log(`Match route : ${route.name}`, "NOTICE");
151
174
  resolver.exception = void 0;
152
175
  routeExpectsBodyStream(route);
153
176
  return resolver;
154
177
  }
155
178
  } catch (e) {
156
- this.log(`Match route exception : ${route.name} ${e}`, "DEBUG");
179
+ this.log(`Match route exception : ${route.name} ${String(e)}`, "DEBUG");
157
180
  resolver.exception = e;
158
181
  continue;
159
182
  }
@@ -165,6 +188,14 @@ let Router = class Router extends Service {
165
188
  const servesDomain = !route.hostRegexp || isDomainAllowed(route.hostRegexp, context.domain);
166
189
  if (route.pattern && route.pattern.test(path) && servesDomain) collectSupportedMethods(route).forEach((x) => allowed.add(x));
167
190
  }
191
+ const servesWebsocket = allowed.delete("WEBSOCKET");
192
+ if (allowed.size === 0 && servesWebsocket) {
193
+ context.response?.setHeaders({
194
+ Upgrade: "websocket",
195
+ Connection: "Upgrade"
196
+ });
197
+ throw new HttpError("Upgrade Required: websocket", 426, context);
198
+ }
168
199
  if (allowed.size > 0) {
169
200
  const allowHeader = Array.from(allowed).join(", ");
170
201
  const err = new HttpError(`Method ${context.method} Not Allowed`, 405, context);
@@ -219,7 +250,26 @@ let Router = class Router extends Service {
219
250
  invalidateRouteIndex();
220
251
  return routenew;
221
252
  }
253
+ /**
254
+ * Enregistre une classe contrôleur pour un module — l'entonnoir UNIQUE : le
255
+ * décorateur `@controllers` y passe, comme les contrôleurs internes que le
256
+ * framework enregistre directement (plan d'administration, OAuth,
257
+ * WebAuthn…).
258
+ *
259
+ * C'est donc ICI que se refusent, au démarrage, les fautes de déclaration
260
+ * qu'un contrôleur ne révélerait qu'à sa première requête : une dépendance
261
+ * captive (un singleton qui détient un service de portée `request`), et une
262
+ * portée que sa classe de base n'admet pas (`Controller.assertScope`).
263
+ *
264
+ * @param myconstructor - la classe contrôleur.
265
+ * @param module - le module qui la porte.
266
+ * @returns la classe, rangée sous `module:Classe`.
267
+ * @throws BootConfigurationError sur une dépendance captive ou une portée
268
+ * refusée — fatale dans tous les environnements.
269
+ */
222
270
  static setController(myconstructor, module) {
271
+ Injector.assertNoCaptiveDependency(myconstructor);
272
+ myconstructor.assertScope?.();
223
273
  Object.defineProperty(myconstructor.prototype, "module", {
224
274
  value: module,
225
275
  writable: false
@@ -1,3 +1,4 @@
1
+ import { isDevelopment, singletonWriteMessage } from "./singletonGuard.js";
1
2
  import { FileClass, RequestContext, Service } from "nodefony";
2
3
  import { HttpError } from "@nodefony/http";
3
4
  import fs, { createReadStream } from "node:fs";
@@ -48,12 +49,12 @@ function cspExigeUnNonce(csp) {
48
49
  function parseByteRange(range, length) {
49
50
  const unit = /^\s*bytes\s*=\s*(.+)$/i.exec(range);
50
51
  if (!unit) return null;
51
- const spec = (unit[1] ?? "").trim();
52
+ const [, rawSpec = ""] = unit;
53
+ const spec = rawSpec.trim();
52
54
  if (spec.includes(",")) return null;
53
55
  const parts = /^(\d*)-(\d*)$/.exec(spec);
54
56
  if (!parts) return null;
55
- const first = parts[1] ?? "";
56
- const last = parts[2] ?? "";
57
+ const [, first = "", last = ""] = parts;
57
58
  if (first === "" && last === "") return null;
58
59
  if (first === "") {
59
60
  const suffix = parseInt(last, 10);
@@ -71,14 +72,37 @@ function parseByteRange(range, length) {
71
72
  end: last === "" ? length - 1 : Math.min(parseInt(last, 10), length - 1)
72
73
  };
73
74
  }
75
+ /**
76
+ * Garantit une `Error` comme motif de rejet : une valeur levée par un tiers
77
+ * (`fs`, flux) peut être n'importe quoi.
78
+ *
79
+ * @param e - valeur interceptée.
80
+ * @returns `e` si c'est déjà une `Error`, sinon une `Error` qui en porte le texte.
81
+ */
82
+ function asError(e) {
83
+ return e instanceof Error ? e : new Error(String(e));
84
+ }
74
85
  var Controller = class extends Service {
75
86
  static prefix = "/";
76
87
  /**
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}.
88
+ * Portée d'instanciation de la classe — `"singleton"` par défaut,
89
+ * `"request"` posé par le décorateur `@Scope` (statique hérité, lu via
90
+ * `new.target` au constructeur et par le Resolver : 0 Reflect).
91
+ * Cf {@link ControllerScope}.
80
92
  */
81
- static scope = "request";
93
+ static scope = "singleton";
94
+ /**
95
+ * Vérifie que la portée de la classe est admise par sa lignée. Appelée à
96
+ * l'enregistrement du contrôleur (`Router.setController`), donc au
97
+ * démarrage.
98
+ *
99
+ * N'impose rien par défaut. Une classe de base qui n'admet qu'une portée la
100
+ * fait respecter ici — `RealtimeController`, lié à SA connexion par
101
+ * construction, refuse le singleton.
102
+ *
103
+ * @throws BootConfigurationError quand la portée déclarée est refusée.
104
+ */
105
+ static assertScope() {}
82
106
  #context = null;
83
107
  #route = null;
84
108
  #request = null;
@@ -91,66 +115,103 @@ var Controller = class extends Service {
91
115
  module;
92
116
  template;
93
117
  /**
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.
118
+ * Refuse d'écrire un état de requête sur un contrôleur singleton : la valeur
119
+ * serait lue par la requête suivante. Coût nul sur le chemin chaud — ces
120
+ * accesseurs n'y sont jamais écrits.
121
+ *
122
+ * @param member - le membre écrit, pour le message.
123
+ * @throws TypeError qui nomme le contrôleur, le membre et le remède.
124
+ */
125
+ #assertPerRequest(member) {
126
+ const ctor = this.constructor;
127
+ if (ctor.scope === "singleton") throw new TypeError(singletonWriteMessage(ctor.name, member));
128
+ }
129
+ /**
130
+ * Contexte transport courant. Par requête : champ posé par `setContext`
131
+ * (constructeur). Singleton : champ jamais posé → lecture de l'ALS
132
+ * `RequestContext`, où le `HttpKernel` place le contexte à l'entrée de la
133
+ * requête — chaque helper retrouve LA requête en cours, jamais une requête
134
+ * concurrente.
99
135
  */
100
136
  get context() {
101
137
  return this.#context ?? RequestContext.getContext();
102
138
  }
103
139
  set context(context) {
140
+ this.#assertPerRequest("context");
104
141
  this.#context = context ?? null;
105
142
  }
106
143
  /**
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.
144
+ * Route de l'appel en cours. Par requête : posée par le Resolver
145
+ * (`setRoute`). Singleton : celle du résolveur de l'APPEL quand le pont
146
+ * `api.request` en a posé un dans l'ALS (clé `resolver` — un résolveur par
147
+ * appel, jamais rangé sur le contexte de la connexion), sinon celle du
148
+ * contexte.
110
149
  */
111
150
  get route() {
112
- return this.#route ?? this.context?.resolver?.route ?? null;
151
+ if (this.#route !== null) return this.#route;
152
+ if (this.#context !== null) return this.#context.resolver?.route ?? null;
153
+ const store = RequestContext.get();
154
+ return (store?.resolver)?.route ?? (store?.context)?.resolver?.route ?? null;
113
155
  }
114
156
  get request() {
115
157
  return this.#request ?? this.context?.request ?? null;
116
158
  }
117
159
  set request(request) {
160
+ this.#assertPerRequest("request");
118
161
  this.#request = request;
119
162
  }
120
163
  get response() {
121
164
  return this.#response ?? this.context?.response ?? null;
122
165
  }
123
166
  set response(response) {
167
+ this.#assertPerRequest("response");
124
168
  this.#response = response;
125
169
  }
126
170
  get method() {
127
171
  return this.#method ?? this.context?.method ?? void 0;
128
172
  }
129
173
  set method(method) {
174
+ this.#assertPerRequest("method");
130
175
  this.#method = method ?? null;
131
176
  }
177
+ /**
178
+ * Query string de l'appel en cours — pour un singleton invoqué par le pont
179
+ * `api.request`, celle du chemin INVOQUÉ (résolveur d'appel dans l'ALS), pas
180
+ * celle du handshake de la connexion. Une seule lecture de l'ALS.
181
+ */
132
182
  get queryGet() {
133
- return this.#queryGet ?? (this.context?.request)?.queryGet;
183
+ if (this.#queryGet !== null) return this.#queryGet;
184
+ if (this.#context !== null) return this.#context.request?.queryGet;
185
+ const store = RequestContext.get();
186
+ return (store?.resolver)?.queryOverride ?? ((store?.context)?.request)?.queryGet;
134
187
  }
135
188
  set queryGet(value) {
189
+ this.#assertPerRequest("queryGet");
136
190
  this.#queryGet = value;
137
191
  }
192
+ /** Comme {@link queryGet}, pour la query fusionnée (`query`). */
138
193
  get query() {
139
- return this.#query ?? (this.context?.request)?.query;
194
+ if (this.#query !== null) return this.#query;
195
+ if (this.#context !== null) return this.#context.request?.query;
196
+ const store = RequestContext.get();
197
+ return (store?.resolver)?.queryOverride ?? ((store?.context)?.request)?.query;
140
198
  }
141
199
  set query(value) {
200
+ this.#assertPerRequest("query");
142
201
  this.#query = value;
143
202
  }
144
203
  get queryFile() {
145
204
  return this.#queryFile ?? (this.context?.request)?.queryFile;
146
205
  }
147
206
  set queryFile(value) {
207
+ this.#assertPerRequest("queryFile");
148
208
  this.#queryFile = value;
149
209
  }
150
210
  get queryPost() {
151
211
  return this.#queryPost ?? (this.context?.request)?.queryPost;
152
212
  }
153
213
  set queryPost(value) {
214
+ this.#assertPerRequest("queryPost");
154
215
  this.#queryPost = value;
155
216
  }
156
217
  /**
@@ -166,6 +227,7 @@ var Controller = class extends Service {
166
227
  return this.queryPost;
167
228
  }
168
229
  set body(value) {
230
+ this.#assertPerRequest("body");
169
231
  this.queryPost = value;
170
232
  }
171
233
  /**
@@ -184,7 +246,16 @@ var Controller = class extends Service {
184
246
  this.template = this.get("template");
185
247
  if (!singleton) this.setContext(context);
186
248
  }
249
+ /**
250
+ * Lie l'instance au contexte de SA requête — contrôleur par requête
251
+ * seulement : tout l'état de la requête (`request`, `response`, `method`,
252
+ * `query*`) en dérive ensuite, toujours frais.
253
+ *
254
+ * @param context - le contexte de la requête servie par cette instance.
255
+ * @throws TypeError sur un singleton, qui sert toutes les requêtes.
256
+ */
187
257
  setContext(context) {
258
+ this.#assertPerRequest("context");
188
259
  this.#context = context;
189
260
  }
190
261
  setContextJson(encoding = "utf-8") {
@@ -211,18 +282,36 @@ var Controller = class extends Service {
211
282
  * @returns rien — cette sonde n'échoue jamais et ne change aucune réponse.
212
283
  */
213
284
  #warnScriptWithoutNonce(data) {
214
- const kernel = this.context?.kernel;
215
- if (kernel?.environment !== "development" && kernel?.environment !== "dev") return;
285
+ if (!isDevelopment(this.context?.kernel?.environment)) return;
216
286
  if (typeof data !== "string" || data.length === 0) return;
217
287
  const response = this.response;
218
288
  if (typeof response?.getHeader !== "function") return;
219
289
  if (!cspExigeUnNonce(response.getHeader("Content-Security-Policy")) || !INLINE_SCRIPT_SANS_NONCE.test(data)) return;
220
290
  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
291
  }
222
- async render(data, encoding, status, headers) {
292
+ /**
293
+ * Rend une donnée sur la réponse de la requête courante (JSON, HTML ou
294
+ * texte selon son type).
295
+ *
296
+ * Synchrone quand l'envoi l'est — réponse unique, sans session à sauver ni
297
+ * hook d'envoi (#505). Un `await` reste valable dans tous les cas ; un
298
+ * `.then` chaîné ne l'est plus : la valeur n'est une promesse que quand
299
+ * l'envoi attend.
300
+ *
301
+ * @returns la réponse, ou sa promesse.
302
+ */
303
+ render(data, encoding, status, headers) {
223
304
  this.#warnScriptWithoutNonce(data);
224
305
  return this.context?.render(data, encoding, status, headers);
225
306
  }
307
+ /**
308
+ * Envoie une donnée déjà sérialisée sur le transport de la requête (HTTP ou
309
+ * WebSocket), après statut et en-têtes éventuels.
310
+ *
311
+ * Synchrone quand l'envoi l'est (#505) — cf {@link render}.
312
+ *
313
+ * @returns la réponse, ou sa promesse.
314
+ */
226
315
  renderResponse(data, encoding, status, headers) {
227
316
  if (headers) this.response?.setHeaders(headers);
228
317
  if (status) this.response?.setStatusCode(status);
@@ -240,7 +329,7 @@ var Controller = class extends Service {
240
329
  }
241
330
  this.setContextHtml();
242
331
  this.#warnScriptWithoutNonce(data);
243
- return this.renderResponse(data, "utf8", status, headers);
332
+ return await this.renderResponse(data, "utf8", status, headers);
244
333
  } catch (e) {
245
334
  this.log(e, "ERROR");
246
335
  throw e;
@@ -257,24 +346,38 @@ var Controller = class extends Service {
257
346
  if (!fe?.renderTags) return param;
258
347
  const nonce = this.context?.cspNonce;
259
348
  const host = this.context?.domain;
349
+ const renderTags = fe.renderTags;
350
+ const renderDocument = fe.renderDocument;
260
351
  return {
261
- frontendTags: (entry) => fe.renderTags(entry, nonce, host),
262
- frontendDocument: (entry) => fe.renderDocument(entry, nonce, host),
352
+ frontendTags: (entry) => renderTags(entry, nonce, host),
353
+ frontendDocument: (entry) => {
354
+ if (!renderDocument) throw new Error("frontend service does not expose renderDocument");
355
+ return renderDocument(entry, nonce, host);
356
+ },
263
357
  asset: (p) => fe.assetUrl ? fe.assetUrl(p) : p,
264
358
  ...param
265
359
  };
266
360
  }
267
- async renderJson(obj, status, headers) {
361
+ /**
362
+ * Sérialise en JSON puis envoie ({@link renderResponse}). Synchrone quand
363
+ * l'envoi l'est (#505).
364
+ *
365
+ * @returns la réponse, ou sa promesse.
366
+ * @throws de façon SYNCHRONE ce que lève `JSON.stringify` (cycle, BigInt).
367
+ */
368
+ renderJson(obj, status, headers) {
268
369
  const data = JSON.stringify(obj);
269
370
  this.setContextJson();
270
371
  return this.renderResponse(data, "utf8", status, headers);
271
372
  }
272
373
  setRoute(route) {
374
+ this.#assertPerRequest("route");
273
375
  this.#route = route;
274
376
  return route;
275
377
  }
276
378
  getSession() {
277
- if (this.context?.session) return this.context?.session;
379
+ const session = this.context?.session;
380
+ if (session) return session;
278
381
  }
279
382
  redirect(url, status, headers) {
280
383
  if (!url) throw new Error("Redirect error no url !!!");
@@ -306,7 +409,7 @@ var Controller = class extends Service {
306
409
  if (file instanceof FileClass) File = file;
307
410
  else if (typeof file === "string") File = new FileClass(file);
308
411
  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}`);
412
+ if (File.type !== "File") throw new Error(`getFile bad type for :${String(file)}`);
310
413
  return File;
311
414
  }
312
415
  /**
@@ -347,7 +450,7 @@ var Controller = class extends Service {
347
450
  }
348
451
  else throw new Error(`File argument bad type for getFileAsync :${typeof file}`);
349
452
  if (File.type !== "File") {
350
- this.log(`ce chemin n'est pas un fichier : ${file}`, "DEBUG");
453
+ this.log(`ce chemin n'est pas un fichier : ${String(file)}`, "DEBUG");
351
454
  throw new HttpError("Not Found", 404, this.context);
352
455
  }
353
456
  return File;
@@ -375,12 +478,7 @@ var Controller = class extends Service {
375
478
  "Content-Type": File.mimeType || "application/octet-stream",
376
479
  ...headers
377
480
  };
378
- try {
379
- return this.streamFile(File, head, options);
380
- } catch (e) {
381
- this.log(e, "ERROR");
382
- throw e;
383
- }
481
+ return this.streamFile(File, head, options);
384
482
  }
385
483
  /**
386
484
  * Envoie un fichier en FLUX, du disque vers la réponse, sans jamais le charger
@@ -404,73 +502,81 @@ var Controller = class extends Service {
404
502
  * @throws Si la réponse n'est pas disponible, ou si le fichier est illisible.
405
503
  */
406
504
  async streamFile(file, headers, options = {}) {
505
+ const context = this.context;
407
506
  if (!this.response) throw new Error(`response not found`);
408
507
  const contextResponse = this.response;
409
508
  const response = contextResponse.response;
410
509
  if (!response) throw new Error(`response not found`);
411
510
  options.autoClose = false;
511
+ let streamFile;
412
512
  try {
413
513
  const fileDetails = await this.getFileAsync(file);
414
514
  this.response.response?.removeHeader("Content-Type");
415
515
  this.response.response?.removeHeader("content-type");
416
516
  if (!(headers && (headers["Content-Type"] || headers["content-type"]))) this.response.setFileMimeType(fileDetails.name);
417
517
  if (!(headers && (headers["Content-Length"] || headers["content-length"]))) {
418
- if (!headers) headers = {};
518
+ headers ??= {};
419
519
  this.response.response?.removeHeader("Content-Length");
420
520
  this.response.response?.removeHeader("content-length");
421
521
  headers["Content-Length"] = fileDetails.stats.size;
422
522
  }
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
- });
523
+ streamFile = createReadStream(fileDetails.path, options);
465
524
  } catch (e) {
466
525
  this.log(e, "ERROR");
467
526
  throw e;
468
527
  }
528
+ return new Promise((resolve, reject) => {
529
+ let handled = false;
530
+ const onResponseClose = () => {
531
+ if (!handled) streamFile.destroy();
532
+ };
533
+ response.once("close", onResponseClose);
534
+ streamFile.on("open", () => {
535
+ try {
536
+ context?.writeHead(contextResponse.statusCode, headers);
537
+ streamFile.pipe(response, { end: false });
538
+ } catch (e) {
539
+ this.log(e, "ERROR");
540
+ reject(asError(e));
541
+ }
542
+ });
543
+ const endContext = () => {
544
+ if (context && !context.finished) Promise.resolve(context.end()).catch((e) => {
545
+ this.log(e, "ERROR");
546
+ });
547
+ };
548
+ const handleStreamEnd = async () => {
549
+ try {
550
+ if (handled) return;
551
+ handled = true;
552
+ response.removeListener("close", onResponseClose);
553
+ streamFile.unpipe(response);
554
+ if (streamFile.fd) await fsClose(streamFile.fd).catch((e) => {
555
+ reject(asError(e));
556
+ });
557
+ endContext();
558
+ resolve(streamFile);
559
+ } catch (e) {
560
+ this.log(e, "ERROR");
561
+ reject(asError(e));
562
+ }
563
+ };
564
+ const onStreamEnd = () => {
565
+ handleStreamEnd();
566
+ };
567
+ streamFile.on("end", onStreamEnd);
568
+ streamFile.on("close", onStreamEnd);
569
+ streamFile.on("error", (error) => {
570
+ this.log(error, "ERROR");
571
+ endContext();
572
+ reject(error);
573
+ });
574
+ });
469
575
  }
470
576
  async renderMediaStream(file, headers = {}, options = {}) {
471
577
  const File = await this.getFileAsync(file);
472
578
  this.response?.setEncoding("binary");
473
- const range = this.request?.headers?.range;
579
+ const range = this.request?.headers.range;
474
580
  const length = File.stats.size;
475
581
  let head;
476
582
  let value;
@@ -54,8 +54,8 @@ function readPageCapabilities(caps) {
54
54
  function createFrameworkAdminApi(broker, opts) {
55
55
  /** Normalise `requirements.methods` (string | string[]) → tableau majuscule. */
56
56
  const methodsOf = (route) => {
57
- const m = route.requirements?.methods ?? route.method;
58
- if (Array.isArray(m)) return m.map((x) => String(x).toUpperCase());
57
+ const m = route.requirements.methods ?? route.method;
58
+ if (Array.isArray(m)) return m.map((x) => x.toUpperCase());
59
59
  if (typeof m === "string") return m.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
60
60
  return ["ANY"];
61
61
  };
@@ -65,6 +65,7 @@ function createFrameworkAdminApi(broker, opts) {
65
65
  methods: methodsOf(route),
66
66
  controller: route.controller?.name ?? null,
67
67
  action: route.classMethod ?? null,
68
+ scope: route.controller?.scope ?? null,
68
69
  module: route.module?.name ?? null,
69
70
  host: route.host ?? null,
70
71
  bypassFirewall: route.bypassFirewall
@@ -129,7 +130,7 @@ function createFrameworkAdminApi(broker, opts) {
129
130
  switch (key) {
130
131
  case "methods": return r.methods.join(",");
131
132
  case "path": return r.path ?? "";
132
- case "name": return r.name ?? "";
133
+ case "name": return r.name;
133
134
  case "controller": return [r.controller, r.action].filter(Boolean).join(".");
134
135
  case "module": return r.module ?? "";
135
136
  case "firewall": return r.bypassFirewall ? "bypass" : "protected";
@@ -146,9 +147,21 @@ function createFrameworkAdminApi(broker, opts) {
146
147
  * adossé à une ressource persistée déclare un `IFilterSpec` et laisse le cœur
147
148
  * valider — il ne recopie pas ce `matchOp`.
148
149
  */
150
+ const filterValueText = (value) => {
151
+ switch (typeof value) {
152
+ case "string": return value;
153
+ case "number":
154
+ case "boolean":
155
+ case "bigint": return String(value);
156
+ case "object": return Array.isArray(value) ? value.map(filterValueText).join(",") : "";
157
+ case "undefined":
158
+ case "symbol":
159
+ case "function": return "";
160
+ }
161
+ };
149
162
  const matchOp = (raw, op, value) => {
150
- const s = String(raw ?? "");
151
- const v = String(value ?? "");
163
+ const s = raw;
164
+ const v = filterValueText(value);
152
165
  if (op !== "isEmpty" && op !== "notEmpty" && v === "") return true;
153
166
  switch (op) {
154
167
  case "contains": return s.toLowerCase().includes(v.toLowerCase());
@@ -270,7 +283,7 @@ function createFrameworkAdminApi(broker, opts) {
270
283
  icon: d?.icon ?? null,
271
284
  order: d?.order ?? 99,
272
285
  role: d?.role ?? null,
273
- endpoints: byNs.get(ns)
286
+ endpoints: byNs.get(ns) ?? []
274
287
  };
275
288
  }).sort((a, b) => a.order - b.order) };
276
289
  }