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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +4 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +15 -20
  4. package/dist/nodefony/config/config.js +1 -2
  5. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  6. package/dist/nodefony/controller/AdminApiController.js +45 -11
  7. package/dist/nodefony/controller/ApiKeyController.js +18 -10
  8. package/dist/nodefony/controller/BenchController.js +1 -2
  9. package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
  10. package/dist/nodefony/controller/LoginPageController.js +117 -0
  11. package/dist/nodefony/controller/OAuth2Controller.js +133 -17
  12. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
  13. package/dist/nodefony/controller/SessionAuthController.js +50 -13
  14. package/dist/nodefony/controller/TokenAuthController.js +14 -8
  15. package/dist/nodefony/controller/TotpController.js +7 -8
  16. package/dist/nodefony/controller/WebAuthnController.js +28 -12
  17. package/dist/nodefony/controller/oauthAuthority.js +1 -2
  18. package/dist/nodefony/decorators/routerDecorators.js +268 -65
  19. package/dist/nodefony/service/AdminBroker.js +4 -6
  20. package/dist/nodefony/service/Eta.js +1 -2
  21. package/dist/nodefony/service/IdempotencyStore.js +4 -6
  22. package/dist/nodefony/service/router.js +97 -34
  23. package/dist/nodefony/src/Controller.js +71 -43
  24. package/dist/nodefony/src/FrameworkAdminApi.js +33 -11
  25. package/dist/nodefony/src/KernelAdminApi.js +82 -18
  26. package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
  27. package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
  28. package/dist/nodefony/src/Resolver.js +105 -36
  29. package/dist/nodefony/src/ResourceController.js +2 -3
  30. package/dist/nodefony/src/Route.js +54 -8
  31. package/dist/nodefony/src/SecurityApiController.js +23 -0
  32. package/dist/nodefony/src/SyslogAdminApi.js +1 -2
  33. package/dist/nodefony/src/Template.js +1 -2
  34. package/dist/nodefony/src/configMutation.js +1 -2
  35. package/dist/nodefony/src/docsReader.js +39 -23
  36. package/dist/nodefony/src/idempotency.js +14 -22
  37. package/dist/nodefony/src/idempotencyGc.js +1 -2
  38. package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
  39. package/dist/nodefony/src/loginPage.js +430 -0
  40. package/dist/nodefony/src/requireAuthentication.js +35 -0
  41. package/dist/nodefony/src/routeSchema.js +81 -0
  42. package/dist/nodefony/src/scopeCatalog.js +1 -2
  43. package/dist/nodefony/src/singletonGuard.js +1 -2
  44. package/dist/nodefony/src/syslogFilters.js +1 -2
  45. package/dist/types/index.d.ts +8 -12
  46. package/dist/types/nodefony/controller/AdminApiController.d.ts +12 -0
  47. package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -2
  48. package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
  49. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
  50. package/dist/types/nodefony/controller/SessionAuthController.d.ts +46 -8
  51. package/dist/types/nodefony/controller/TokenAuthController.d.ts +6 -4
  52. package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
  53. package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
  54. package/dist/types/nodefony/decorators/routerDecorators.d.ts +221 -35
  55. package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
  56. package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
  57. package/dist/types/nodefony/service/router.d.ts +40 -1
  58. package/dist/types/nodefony/src/Controller.d.ts +54 -17
  59. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
  60. package/dist/types/nodefony/src/Resolver.d.ts +44 -1
  61. package/dist/types/nodefony/src/ResourceController.d.ts +1 -1
  62. package/dist/types/nodefony/src/Route.d.ts +21 -1
  63. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
  64. package/dist/types/nodefony/src/docsReader.d.ts +10 -6
  65. package/dist/types/nodefony/src/idempotency.d.ts +12 -10
  66. package/dist/types/nodefony/src/loginPage.d.ts +144 -0
  67. package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
  68. package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
  69. package/docs/admin.md +83 -78
  70. package/docs/controller.md +112 -111
  71. package/docs/decorateurs.md +212 -163
  72. package/docs/idempotence.md +137 -135
  73. package/docs/routing.md +96 -95
  74. package/docs/templates.md +55 -55
  75. package/package.json +10 -13
  76. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,13 +1,12 @@
1
1
  import Route from "../src/Route.js";
2
- import { routeExpectsBodyStream } from "../decorators/routerDecorators.js";
2
+ import { assertRouteSchemaDeclaration, routeExpectsBodyStream } from "../decorators/routerDecorators.js";
3
3
  import Resolver from "../src/Resolver.js";
4
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
5
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
6
- import { Injector, Module, Service, injectable, isPromise } from "nodefony";
4
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
5
+ import { BootConfigurationError, Injector, Module, Service, injectable, isPromise, stripTrailingSlashes } from "nodefony";
7
6
  import { HttpError, isDomainAllowed } from "@nodefony/http";
8
- //#region nodefony/service/router.ts
7
+ // nodefony/service/router.ts
9
8
  var _Router;
10
- let routeNoticePromoted = null;
9
+ let routeMatchLogged = null;
11
10
  /**
12
11
  * Méthodes que sert une route — lues sur le `methodsSet` COMPILÉ par
13
12
  * `Route.compileRequirements`, jamais recalculées depuis la config brute.
@@ -52,25 +51,13 @@ function invalidateRouteIndex() {
52
51
  function buildRouteIndex() {
53
52
  const statics = /* @__PURE__ */ new Map();
54
53
  const dynamics = [];
55
- for (const [i, route] of routes.entries()) {
56
- const path = route.path;
57
- if (path !== void 0 && route.variables.length === 0 && !REG_NON_LITERAL.test(path)) {
58
- const key = path.toLowerCase();
59
- let list = statics.get(key);
60
- if (list === void 0) {
61
- list = [];
62
- statics.set(key, list);
63
- }
64
- list.push({
65
- route,
66
- pos: i,
67
- prefix: ""
68
- });
69
- } else dynamics.push({
70
- route,
71
- pos: i,
72
- prefix: literalPrefix(path)
73
- });
54
+ const total = routes.length;
55
+ for (let pass = 0; pass < 2; pass++) {
56
+ const fallbackPass = pass === 1;
57
+ for (const [rank, route] of routes.entries()) {
58
+ if (route.fallback !== fallbackPass) continue;
59
+ indexRoute(route, fallbackPass ? total + rank : rank, statics, dynamics);
60
+ }
74
61
  }
75
62
  return routeIndex = {
76
63
  statics,
@@ -80,10 +67,45 @@ function buildRouteIndex() {
80
67
  last: routes[routes.length - 1]
81
68
  };
82
69
  }
70
+ /**
71
+ * Range une route dans l'index — littérale par son chemin exact, dynamique
72
+ * sinon — à la position `i` qui décide de son rang dans la fusion de `resolve`.
73
+ */
74
+ function indexRoute(route, i, statics, dynamics) {
75
+ const path = route.path;
76
+ if (path !== void 0 && route.variables.length === 0 && !REG_NON_LITERAL.test(path)) {
77
+ const key = path.toLowerCase();
78
+ let list = statics.get(key);
79
+ if (list === void 0) {
80
+ list = [];
81
+ statics.set(key, list);
82
+ }
83
+ list.push({
84
+ route,
85
+ pos: i,
86
+ prefix: ""
87
+ });
88
+ } else dynamics.push({
89
+ route,
90
+ pos: i,
91
+ prefix: literalPrefix(path)
92
+ });
93
+ }
94
+ /**
95
+ * Marque du Router de `@nodefony/framework` — ce que {@link isFrameworkRouter}
96
+ * lit. `Symbol.for` : la même marque pour deux copies installées du framework,
97
+ * là où `instanceof` échouerait en silence. Un faux routeur de test s'en
98
+ * réclame explicitement.
99
+ */
100
+ const FRAMEWORK_ROUTER = Symbol.for("nodefony.framework.router");
83
101
  let Router = class Router extends Service {
84
102
  static {
85
103
  _Router = this;
86
104
  }
105
+ /** Marque {@link FRAMEWORK_ROUTER} — sur le prototype, aucun champ par instance. */
106
+ get [FRAMEWORK_ROUTER]() {
107
+ return true;
108
+ }
87
109
  static routes = routes;
88
110
  routes = _Router.routes;
89
111
  singletonControllers = null;
@@ -127,6 +149,23 @@ let Router = class Router extends Service {
127
149
  return pending;
128
150
  }
129
151
  /**
152
+ * Dit si au moins une route déclare le transport `WEBSOCKET` pour ce chemin
153
+ * — contrôle d'avant `101` du pipeline WebSocket (cf `IRequestRouter`).
154
+ *
155
+ * Parcours de la table entière, sans l'index de `resolve` : il est lu une
156
+ * fois par CONNEXION (pas par message), et une route dont les méthodes
157
+ * changent après coup (`addRequirement`) ne laisse ainsi aucun index périmé.
158
+ * Même normalisation que `resolve` (`stripTrailingSlashes`), même motif.
159
+ *
160
+ * @param pathname - chemin de l'URL d'upgrade
161
+ * @returns `true` si une route WebSocket a un motif qui couvre ce chemin
162
+ */
163
+ servesWebsocket(pathname) {
164
+ const path = stripTrailingSlashes(pathname);
165
+ for (const route of routes) if (route.methodsSet?.has("WEBSOCKET") === true && route.pattern?.test(path) === true) return true;
166
+ return false;
167
+ }
168
+ /**
130
169
  * Résout une route pour un contexte donné.
131
170
  *
132
171
  * @param context - le contexte HTTP/WS courant (porte container, méthode, URL…).
@@ -138,7 +177,10 @@ let Router = class Router extends Service {
138
177
  * WEBSOCKET (pont WS-RPC `api.request` d'une MUTATION) : lève l'ambiguïté
139
178
  * GET-via-WS / POST-via-WS sur un même chemin (`context.method` = "WEBSOCKET").
140
179
  * `undefined` (GET/HTTP) → match historique sur `context.method`.
141
- * @returns un `Resolver` (`.resolve === true` si une route a matché).
180
+ * @returns un `Resolver` (`.resolve === true` si une route a matché) — ou,
181
+ * pour un chemin servi en WebSocket SEULEMENT et demandé en HTTP, un
182
+ * `Resolver` dont `exception` porte le `426` (en-têtes `Upgrade` posés) :
183
+ * le pipeline le lève après le pare-feu.
142
184
  */
143
185
  resolve(context, cleanPathOverride, methodOverride) {
144
186
  const resolver = new Resolver(context);
@@ -171,8 +213,8 @@ let Router = class Router extends Service {
171
213
  }
172
214
  try {
173
215
  if (resolver.match(route, context, cleanPath, fromLiterals)) {
174
- routeNoticePromoted ??= this.kernel?.environment !== "production";
175
- if (routeNoticePromoted) this.log(`Match route : ${route.name}`, "NOTICE");
216
+ routeMatchLogged ??= this.kernel?.environment !== "production";
217
+ if (routeMatchLogged) this.log(`Match route : ${route.name}`, "DEBUG");
176
218
  resolver.exception = void 0;
177
219
  routeExpectsBodyStream(route);
178
220
  return resolver;
@@ -192,11 +234,13 @@ let Router = class Router extends Service {
192
234
  }
193
235
  const servesWebsocket = allowed.delete("WEBSOCKET");
194
236
  if (allowed.size === 0 && servesWebsocket) {
195
- context.response?.setHeaders({
237
+ const req = context.request;
238
+ if ((req && "request" in req ? req.request : req)?.httpVersionMajor !== 2) context.response?.setHeaders({
196
239
  Upgrade: "websocket",
197
240
  Connection: "Upgrade"
198
241
  });
199
- throw new HttpError("Upgrade Required: websocket", 426, context);
242
+ resolver.exception = new HttpError("Upgrade Required: websocket", 426, context);
243
+ return resolver;
200
244
  }
201
245
  if (allowed.size > 0) {
202
246
  const allowHeader = Array.from(allowed).join(", ");
@@ -247,6 +291,7 @@ let Router = class Router extends Service {
247
291
  }
248
292
  }
249
293
  static createRoute(name, obj) {
294
+ if ("schema" in obj || "bodyStream" in obj) throw new BootConfigurationError(`Route "${name}" : « schema » et « bodyStream » se déclarent par un décorateur de route (@Get, @Post, @route…), pas par createRoute.`);
250
295
  const routenew = new Route(name, obj);
251
296
  routes.push(routenew);
252
297
  invalidateRouteIndex();
@@ -278,7 +323,10 @@ let Router = class Router extends Service {
278
323
  });
279
324
  const key = `${module.name}:${myconstructor.name}`;
280
325
  if (Module.controllers[key]) module.log(/* @__PURE__ */ new Error(`Controller already exist ${key}`), "WARNING");
281
- for (const r of routes) if (r.controller === myconstructor) r.module = { name: module.name };
326
+ for (const r of routes) if (r.controller === myconstructor) {
327
+ assertRouteSchemaDeclaration(r);
328
+ r.module = { name: module.name };
329
+ }
282
330
  return Module.controllers[key] = myconstructor;
283
331
  }
284
332
  /**
@@ -290,7 +338,22 @@ let Router = class Router extends Service {
290
338
  return routes.filter((r) => r.controller === myconstructor);
291
339
  }
292
340
  };
293
- Router = _Router = __decorate([injectable(), __decorateMetadata("design:paramtypes", [typeof Module === "undefined" ? Object : Module])], Router);
341
+ Router = _Router = __decorate([injectable()], Router);
342
+ /**
343
+ * Dit si le service `router` d'un contexte est le Router de
344
+ * `@nodefony/framework` — la garde qui rétrécit le contrat `IRequestRouter` de
345
+ * `@nodefony/http` sans conversion, pour qui lit des membres propres au
346
+ * framework (le Resolver complet que rend `resolve`).
347
+ *
348
+ * Lit la marque {@link FRAMEWORK_ROUTER}, jamais `instanceof` : deux copies
349
+ * installées du framework feraient échouer `instanceof` en silence. Une
350
+ * lecture de propriété.
351
+ *
352
+ * @param router - le routeur lu sur le contexte (`context.router`).
353
+ * @returns `true` si c'est le Router du framework.
354
+ */
355
+ function isFrameworkRouter(router) {
356
+ return router != null && FRAMEWORK_ROUTER in router;
357
+ }
294
358
  var router_default = Router;
295
- //#endregion
296
- export { router_default as default };
359
+ export { FRAMEWORK_ROUTER, router_default as default, isFrameworkRouter };
@@ -1,9 +1,9 @@
1
1
  import { isDevelopment, singletonWriteMessage } from "./singletonGuard.js";
2
2
  import { FileClass, RequestContext, Service } from "nodefony";
3
- import { HttpError } from "@nodefony/http";
3
+ import { HttpContext, HttpError, acceptsEventStream } from "@nodefony/http";
4
4
  import fs, { createReadStream } from "node:fs";
5
5
  import { promisify } from "node:util";
6
- //#region nodefony/src/Controller.ts
6
+ // nodefony/src/Controller.ts
7
7
  const fsClose = promisify(fs.close);
8
8
  /**
9
9
  * Un `<script>` EN LIGNE qui ne porte pas d'attribut `nonce`.
@@ -108,10 +108,9 @@ var Controller = class extends Service {
108
108
  #request = null;
109
109
  #response = null;
110
110
  #method = null;
111
- #queryGet = null;
112
111
  #query = null;
113
- #queryFile = null;
114
- #queryPost = null;
112
+ #files = null;
113
+ #body = null;
115
114
  module;
116
115
  template;
117
116
  /**
@@ -167,6 +166,15 @@ var Controller = class extends Service {
167
166
  this.#assertPerRequest("response");
168
167
  this.#response = response;
169
168
  }
169
+ /**
170
+ * L'utilisateur CONNECTÉ, ou `null` — anonyme, ou route hors de toute zone
171
+ * du pare-feu. C'est l'identité que le pare-feu a établie pour cette
172
+ * requête : aucun appel de plus. Une page publique qui veut savoir qui la
173
+ * visite se place sous une zone `authenticators: ["session", "anonymous"]`.
174
+ */
175
+ get user() {
176
+ return RequestContext.getAuthenticatedUser();
177
+ }
170
178
  get method() {
171
179
  return this.#method ?? this.context?.method ?? void 0;
172
180
  }
@@ -175,21 +183,11 @@ var Controller = class extends Service {
175
183
  this.#method = method ?? null;
176
184
  }
177
185
  /**
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.
186
+ * Paramètres d'URL de l'appel en cours — jamais le corps, qui reste dans
187
+ * {@link body}. Pour un singleton invoqué par le pont `api.request`,
188
+ * ceux du chemin INVOQUÉ (résolveur d'appel dans l'ALS), pas ceux du
189
+ * handshake de la connexion. Une seule lecture de l'ALS.
181
190
  */
182
- get 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;
187
- }
188
- set queryGet(value) {
189
- this.#assertPerRequest("queryGet");
190
- this.#queryGet = value;
191
- }
192
- /** Comme {@link queryGet}, pour la query fusionnée (`query`). */
193
191
  get query() {
194
192
  if (this.#query !== null) return this.#query;
195
193
  if (this.#context !== null) return this.#context.request?.query;
@@ -200,35 +198,30 @@ var Controller = class extends Service {
200
198
  this.#assertPerRequest("query");
201
199
  this.#query = value;
202
200
  }
203
- get queryFile() {
204
- return this.#queryFile ?? (this.context?.request)?.queryFile;
205
- }
206
- set queryFile(value) {
207
- this.#assertPerRequest("queryFile");
208
- this.#queryFile = value;
209
- }
210
- get queryPost() {
211
- return this.#queryPost ?? (this.context?.request)?.queryPost;
201
+ /**
202
+ * Les fichiers reçus dans un corps multipart (`UploadedFile`) ; les champs
203
+ * texte du même corps sont dans {@link body}. Pour une action typée,
204
+ * préférer `@UploadedFiles()`.
205
+ */
206
+ get files() {
207
+ return this.#files ?? (this.context?.request)?.files;
212
208
  }
213
- set queryPost(value) {
214
- this.#assertPerRequest("queryPost");
215
- this.#queryPost = value;
209
+ set files(value) {
210
+ this.#assertPerRequest("files");
211
+ this.#files = value;
216
212
  }
217
213
  /**
218
214
  * Le CORPS de la requête, parsé — nom universel de l'écosystème (Express,
219
- * Fastify, NestJS), alias de {@link queryPost}.
220
- *
221
- * Ne pas confondre avec {@link query}, qui FUSIONNE la query string et le
222
- * corps. Pour une action typée, préférer le décorateur `@Body()`.
223
- *
224
- * Getter (aucune allocation) : `queryPost` reste la source unique.
215
+ * Fastify, NestJS). Jamais un paramètre d'URL : ceux-là sont dans
216
+ * {@link query}, et les deux ne fusionnent pas. Pour une action typée,
217
+ * préférer le décorateur `@Body()`.
225
218
  */
226
219
  get body() {
227
- return this.queryPost;
220
+ return this.#body ?? (this.context?.request)?.body;
228
221
  }
229
222
  set body(value) {
230
223
  this.#assertPerRequest("body");
231
- this.queryPost = value;
224
+ this.#body = value;
232
225
  }
233
226
  /**
234
227
  * Session courante, ou `null`. Getter direct sur `context.session` (peuplé au
@@ -345,14 +338,13 @@ var Controller = class extends Service {
345
338
  const fe = this.get("frontend");
346
339
  if (!fe?.renderTags) return param;
347
340
  const nonce = this.context?.cspNonce;
348
- const host = this.context?.domain;
349
341
  const renderTags = fe.renderTags;
350
342
  const renderDocument = fe.renderDocument;
351
343
  return {
352
- frontendTags: (entry) => renderTags(entry, nonce, host),
344
+ frontendTags: (entry) => renderTags(entry, nonce),
353
345
  frontendDocument: (entry) => {
354
346
  if (!renderDocument) throw new Error("frontend service does not expose renderDocument");
355
- return renderDocument(entry, nonce, host);
347
+ return renderDocument(entry, nonce);
356
348
  },
357
349
  asset: (p) => fe.assetUrl ? fe.assetUrl(p) : p,
358
350
  ...param
@@ -370,6 +362,43 @@ var Controller = class extends Service {
370
362
  this.setContextJson();
371
363
  return this.renderResponse(data, "utf8", status, headers);
372
364
  }
365
+ /**
366
+ * Le client demande-t-il un flux d'événements (`Accept: text/event-stream`) ?
367
+ *
368
+ * La route ne change pas de méthode pour autant : un même `POST` répond en
369
+ * JSON ou en flux selon cette réponse. Toujours `false` sur WebSocket.
370
+ *
371
+ * @returns `true` quand `text/event-stream` est accepté avec un poids non nul.
372
+ */
373
+ acceptsSse() {
374
+ if (!(this.context instanceof HttpContext)) return false;
375
+ return acceptsEventStream(this.context.request.headers.accept);
376
+ }
377
+ /**
378
+ * Répond par un flux d'événements serveur, en HTTP/1.1 comme en HTTP/2.
379
+ *
380
+ * L'action garde la main : elle `send()` à son rythme et `close()` quand elle
381
+ * a fini ; le départ du client ferme le flux (`sse.closed`, `sse.onClose`).
382
+ * Rendre le flux depuis l'action est sans effet — la réponse est déjà prise.
383
+ *
384
+ * @example
385
+ * ```ts
386
+ * @Get("/progress")
387
+ * async progress() {
388
+ * const sse = await this.renderSse();
389
+ * for (const step of steps) await sse.send(step, { event: "step" });
390
+ * return sse.close();
391
+ * }
392
+ * ```
393
+ *
394
+ * @param options - battement de cœur, délai `retry:` initial.
395
+ * @returns le flux, ou sa promesse quand une session est à sauver d'abord.
396
+ * @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie.
397
+ */
398
+ renderSse(options) {
399
+ if (!(this.context instanceof HttpContext)) throw new HttpError("SSE : un flux d'événements exige une requête HTTP", 500, this.context);
400
+ return this.context.openSse(options);
401
+ }
373
402
  setRoute(route) {
374
403
  this.#assertPerRequest("route");
375
404
  this.#route = route;
@@ -617,5 +646,4 @@ var Controller = class extends Service {
617
646
  return this.streamFile(File, head, value);
618
647
  }
619
648
  };
620
- //#endregion
621
649
  export { Controller as default, parseByteRange };
@@ -1,7 +1,7 @@
1
1
  import router_default from "../service/router.js";
2
2
  import { createPlaygroundEndpoints } from "./PlaygroundAdminApi.js";
3
- import { parseFilters, parsePageQuery } from "nodefony";
4
- //#region nodefony/src/FrameworkAdminApi.ts
3
+ import { PageQueryError, parseFilters, parsePageQuery } from "nodefony";
4
+ // nodefony/src/FrameworkAdminApi.ts
5
5
  /**
6
6
  * Capacités de page d'un endpoint, ÉVALUÉES sous filet.
7
7
  *
@@ -29,6 +29,34 @@ function readPageCapabilities(caps) {
29
29
  }
30
30
  }
31
31
  /**
32
+ * Lit le paramètre `filters` de `routes/page` : un tableau JSON de
33
+ * `{ key, op, value }`. Absent → aucun filtre.
34
+ *
35
+ * @throws PageQueryError (400) si le JSON est cassé ou d'une autre forme — un
36
+ * filtre ignoré rendrait TOUTES les routes, lues comme son résultat.
37
+ */
38
+ function parseRouteFilters(raw) {
39
+ if (!raw) return [];
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ } catch {
44
+ throw new PageQueryError(`Invalid "filters": not JSON.`);
45
+ }
46
+ if (!Array.isArray(parsed)) throw new PageQueryError(`Invalid "filters": expected a JSON array.`);
47
+ const entries = parsed;
48
+ const filters = [];
49
+ for (const item of entries) {
50
+ if (typeof item !== "object" || item === null || !("key" in item) || !("op" in item) || typeof item.key !== "string" || typeof item.op !== "string") throw new PageQueryError(`Invalid "filters": each entry needs a string "key" and "op".`);
51
+ filters.push({
52
+ key: item.key,
53
+ op: item.op,
54
+ value: "value" in item ? item.value : void 0
55
+ });
56
+ }
57
+ return filters;
58
+ }
59
+ /**
32
60
  * Producteur `IAdminApi` du module **framework** — exposé sous
33
61
  * `/nodefony/framework/api/*`.
34
62
  *
@@ -68,7 +96,8 @@ function createFrameworkAdminApi(broker, opts) {
68
96
  scope: route.controller?.scope ?? null,
69
97
  module: route.module?.name ?? null,
70
98
  host: route.host ?? null,
71
- bypassFirewall: route.bypassFirewall
99
+ bypassFirewall: route.bypassFirewall,
100
+ fallback: route.fallback
72
101
  });
73
102
  const descriptor = {
74
103
  label: "Routes",
@@ -204,13 +233,7 @@ function createFrameworkAdminApi(broker, opts) {
204
233
  });
205
234
  parseFilters(request.query, {}, { accepts: ["filters"] });
206
235
  const search = query.q?.toLowerCase() ?? "";
207
- let filters = [];
208
- try {
209
- const raw = one(request.query.filters);
210
- if (raw) filters = JSON.parse(raw);
211
- } catch {
212
- filters = [];
213
- }
236
+ const filters = parseRouteFilters(one(request.query.filters));
214
237
  let rows = router_default.routes.map(serializeRoute);
215
238
  if (search) rows = rows.filter((r) => matchesSearch(r, search));
216
239
  for (const f of filters) rows = rows.filter((r) => matchOp(cell(r, f.key), f.op, f.value));
@@ -296,5 +319,4 @@ function createFrameworkAdminApi(broker, opts) {
296
319
  adminEndpoints: () => endpoints
297
320
  };
298
321
  }
299
- //#endregion
300
322
  export { createFrameworkAdminApi };