@nodefony/framework 10.0.0-alpha.9 → 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 (46) hide show
  1. package/README.md +4 -1
  2. package/dist/index.js +2 -2
  3. package/dist/nodefony/controller/AdminApiController.js +4 -3
  4. package/dist/nodefony/controller/ApiKeyController.js +1 -1
  5. package/dist/nodefony/controller/OAuth2Controller.js +11 -6
  6. package/dist/nodefony/controller/SessionAuthController.js +3 -3
  7. package/dist/nodefony/controller/TokenAuthController.js +2 -2
  8. package/dist/nodefony/controller/TotpController.js +5 -4
  9. package/dist/nodefony/controller/WebAuthnController.js +4 -4
  10. package/dist/nodefony/decorators/routerDecorators.js +41 -35
  11. package/dist/nodefony/service/IdempotencyStore.js +2 -3
  12. package/dist/nodefony/service/router.js +73 -23
  13. package/dist/nodefony/src/Controller.js +184 -78
  14. package/dist/nodefony/src/FrameworkAdminApi.js +19 -6
  15. package/dist/nodefony/src/KernelAdminApi.js +49 -49
  16. package/dist/nodefony/src/PlaygroundAdminApi.js +3 -3
  17. package/dist/nodefony/src/RedisIdempotencyStore.js +2 -2
  18. package/dist/nodefony/src/Resolver.js +219 -63
  19. package/dist/nodefony/src/Route.js +38 -38
  20. package/dist/nodefony/src/SyslogAdminApi.js +4 -4
  21. package/dist/nodefony/src/docsReader.js +22 -18
  22. package/dist/nodefony/src/scopeCatalog.js +1 -1
  23. package/dist/nodefony/src/singletonGuard.js +76 -0
  24. package/dist/types/nodefony/controller/BenchController.d.ts +2 -2
  25. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +3 -3
  26. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +1 -1
  27. package/dist/types/nodefony/controller/SessionAuthController.d.ts +2 -1
  28. package/dist/types/nodefony/decorators/routerDecorators.d.ts +49 -43
  29. package/dist/types/nodefony/interfaces/IController.d.ts +9 -7
  30. package/dist/types/nodefony/interfaces/IResolver.d.ts +10 -10
  31. package/dist/types/nodefony/interfaces/IRoute.d.ts +13 -13
  32. package/dist/types/nodefony/service/AdminBroker.d.ts +3 -3
  33. package/dist/types/nodefony/service/router.d.ts +33 -7
  34. package/dist/types/nodefony/src/Controller.d.ts +97 -24
  35. package/dist/types/nodefony/src/Resolver.d.ts +154 -15
  36. package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
  37. package/dist/types/nodefony/src/Route.d.ts +24 -22
  38. package/dist/types/nodefony/src/docsReader.d.ts +3 -3
  39. package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
  40. package/docs/admin.md +1 -1
  41. package/docs/controller.md +133 -115
  42. package/docs/decorateurs.md +95 -92
  43. package/docs/idempotence.md +21 -19
  44. package/docs/routing.md +46 -40
  45. package/docs/templates.md +14 -14
  46. package/package.json +8 -8
@@ -1,6 +1,7 @@
1
+ import { guardSingletonState, isDevelopment } from "./singletonGuard.js";
1
2
  import { buildParamArgs, computeActionMeta, resolveActionMeta, resolveSessionIntent } from "../decorators/routerDecorators.js";
2
3
  import { computeFingerprint, evaluateIdempotency, isMutationMethod, resolveIdempotencyKey, resolveIdentity } from "./idempotency.js";
3
- import { RequestContext, identityHint, isArray, isPlainObject, isPromise, nodefonyError, typeOf } from "nodefony";
4
+ import { RequestContext, finallyMaybe, identityHint, isArray, isPlainObject, isPromise, nodefonyError, thenMaybe, typeOf } from "nodefony";
4
5
  import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
5
6
  //#region nodefony/src/Resolver.ts
6
7
  /**
@@ -21,6 +22,15 @@ import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
21
22
  * cohabitent dans une même suite de tests.
22
23
  */
23
24
  const areaRequirements = /* @__PURE__ */ new WeakMap();
25
+ /**
26
+ * Le controller expose-t-il un hook `initialize()` (appelé une fois à sa création) ?
27
+ *
28
+ * @param controller - instance fraîchement créée par l'injecteur.
29
+ * @returns `true` si `initialize` est une méthode.
30
+ */
31
+ function hasInitialize(controller) {
32
+ return typeof Reflect.get(controller, "initialize") === "function";
33
+ }
24
34
  var Resolver = class {
25
35
  injector;
26
36
  controller = null;
@@ -96,45 +106,84 @@ var Resolver = class {
96
106
  * `Context.setMetaData()` pour exposer `msg.nodefony.route.variablesMap`.
97
107
  */
98
108
  getMatchedParams() {
99
- const names = this.route?.variables ?? [];
100
- const params = {};
101
- for (let i = 0; i < names.length; i++) params[names[i]] = this.variables[i];
109
+ const params = this._zipRouteParams();
102
110
  const wildcard = this.variables["*"];
103
111
  if (wildcard !== void 0) params["*"] = wildcard;
104
112
  return params;
105
113
  }
114
+ /**
115
+ * Associe chaque nom de variable de la route à la valeur capturée au match.
116
+ *
117
+ * @returns la table nom → valeur (vide sans route)
118
+ */
119
+ _zipRouteParams() {
120
+ const names = this.route?.variables ?? [];
121
+ const params = {};
122
+ for (let i = 0; i < names.length; i++) {
123
+ const name = names[i];
124
+ if (name !== void 0) params[name] = this.variables[i];
125
+ }
126
+ return params;
127
+ }
106
128
  parsePathernController(name) {
107
- let module;
108
- let tab = [];
109
129
  if (typeof name !== "string") throw new Error(`Invalid name parameter: expected a string`);
110
- tab = name.split(":");
130
+ const tab = name.split(":");
131
+ const [moduleName = "", controllerName = "", actionName = ""] = tab;
111
132
  if (tab.length !== 3) throw new Error(`Invalid name format: expected "module:controller:action"`);
112
- module = this.context.kernel?.getModule(tab[0]);
113
- if (!module) throw new Error(`Module not found: ${tab[0]}`);
114
- this.controller = module.getController(tab[1]);
115
- if (!this.controller) throw new Error(`Controller not found in module: ${tab[1]}`);
116
- this.action = this.getAction(tab[2]);
117
- if (!this.action) throw new Error(`Action not found in controller ${tab[1]}: ${tab[2]}`);
118
- this.actionName = tab[2];
133
+ const module = this.context.kernel?.getModule(moduleName);
134
+ if (!module) throw new Error(`Module not found: ${moduleName}`);
135
+ this.controller = module.getController(controllerName);
136
+ const action = this.getAction(actionName);
137
+ if (!action) throw new Error(`Action not found in controller ${controllerName}: ${actionName}`);
138
+ this.action = action;
139
+ this.actionName = actionName;
119
140
  this.resolve = true;
120
- if (this.controller) this.context.sessionIntent = resolveSessionIntent(this.controller, this.actionName);
141
+ this.context.sessionIntent = resolveSessionIntent(this.controller, this.actionName);
121
142
  }
122
143
  getAction(name) {
123
144
  if (!this.controller) throw new Error(`Controller not set`);
124
- const methodNames = Object.getOwnPropertyNames(this.controller.prototype);
125
- for (const methodName of methodNames) if (typeof this.controller.prototype[methodName] === "function" && methodName === name) return this.controller.prototype[methodName];
126
- return null;
145
+ const proto = this.controller.prototype;
146
+ if (!Object.hasOwn(proto, name)) return null;
147
+ const member = Reflect.get(proto, name);
148
+ return typeof member === "function" ? member : null;
127
149
  }
128
- async newController(context) {
150
+ /**
151
+ * L'instance du controller de la route, posée sur le container du contexte.
152
+ *
153
+ * Singleton (le défaut) : l'instance partagée du cache du Router — rendue
154
+ * DIRECTEMENT une fois créée, sans promesse à attendre (#505). Portée
155
+ * `request` : une instance neuve, synchrone tant que son `initialize()` ne
156
+ * rend pas de promesse.
157
+ *
158
+ * @param context - le contexte à injecter (celui du résolveur par défaut).
159
+ * @returns l'instance, ou la promesse de l'instance quand sa création attend
160
+ * (`initialize()` asynchrone).
161
+ * @throws de façon SYNCHRONE si la route n'a pas de controller, ou si sa
162
+ * construction lève ; un `initialize()` qui échoue rompt la promesse rendue.
163
+ */
164
+ newController(context) {
129
165
  if (!this.controller) throw new Error(`Route Controller not found`);
166
+ let created;
130
167
  if (this.controller.scope === "singleton") {
131
168
  const router = this.context.router;
132
- const controller = router ? await router.getSingletonController(this.controller, () => this._createController(context)) : await this._createController(context);
133
- this.context.container?.set("controller", controller);
134
- return controller;
135
- }
136
- const controller = await this._createController(context);
137
- this.context.container?.set("controller", controller);
169
+ created = router ? router.getSingletonController(this.controller, () => this._createController(context)) : this._createController(context);
170
+ } else created = this._createController(context);
171
+ return thenMaybe(created, (controller) => this._pinController(controller));
172
+ }
173
+ /**
174
+ * Pose le pointeur `"controller"` sur le container du contexte : un message
175
+ * WS suivant ou un forward (`reload`) retrouve l'instance par ce chemin.
176
+ *
177
+ * SAUF pour une invocation par message (pont `api.request`) : le container
178
+ * est alors celui de la CONNEXION, et son pointeur désigne le contrôleur qui
179
+ * la sert (le hub). L'écraser par la cible de l'appel faisait échouer le test
180
+ * `instanceof` au message suivant — le hub était RÉINSTANCIÉ (DI +
181
+ * `initialize()`) à chaque frame qui suivait un `api.request`.
182
+ *
183
+ * @returns le controller reçu, pour enchaîner.
184
+ */
185
+ _pinController(controller) {
186
+ if (!this.messageInvocation) this.context.container?.set("controller", controller);
138
187
  return controller;
139
188
  }
140
189
  /**
@@ -143,19 +192,36 @@ var Resolver = class {
143
192
  * requête dans `executeAction`) puis `initialize()`. Pour un singleton,
144
193
  * `initialize()` n'est donc appelé qu'UNE fois, à la création (sémantique
145
194
  * boot) — le per-request y lit l'ALS s'il a besoin de la requête.
195
+ *
196
+ * @returns le controller prêt ; sa promesse seulement quand `initialize()`
197
+ * en rend une.
198
+ * @throws de façon SYNCHRONE ce que lèvent la DI, le constructeur ou un
199
+ * `initialize()` synchrone ; un `initialize()` asynchrone qui échoue rompt
200
+ * la promesse rendue.
146
201
  */
147
- async _createController(context) {
148
- const ctx = context || this.context;
202
+ _createController(context) {
203
+ const ctx = context ?? this.context;
149
204
  ctx.phaseStart("initialize");
150
- try {
205
+ return finallyMaybe(() => {
151
206
  const controller = this.injector?.instantiate(this.controller, ctx);
152
207
  if (!controller) throw new Error(`Route Controller not found`);
153
208
  if (this.controller?.prototype.module) controller.module = this.controller.prototype.module;
154
- if ("initialize" in controller && typeof controller.initialize === "function") await controller.initialize();
155
- return controller;
156
- } finally {
157
- ctx.phaseEnd("initialize");
158
- }
209
+ const initialized = hasInitialize(controller) ? controller.initialize() : void 0;
210
+ return thenMaybe(initialized, () => this._guardSingleton(controller, ctx));
211
+ }, () => ctx.phaseEnd("initialize"));
212
+ }
213
+ /**
214
+ * Filet de DÉVELOPPEMENT : un singleton sert toutes les requêtes, donc une
215
+ * écriture sur l'un de ses champs, à partir d'ici, fuirait vers la suivante.
216
+ * Posé APRÈS `initialize()`, qui prépare légitimement un état de démarrage.
217
+ * Jamais hors développement : les champs y deviennent des accesseurs, un coût
218
+ * que la production ne paie pas.
219
+ *
220
+ * @returns le controller reçu, pour enchaîner.
221
+ */
222
+ _guardSingleton(controller, ctx) {
223
+ if (this.controller.scope === "singleton" && isDevelopment(ctx.kernel?.environment)) guardSingletonState(controller, controller.constructor.name);
224
+ return controller;
159
225
  }
160
226
  /**
161
227
  * Exécute l'action résolue et retourne sa **valeur brute**, SANS la rendre sur
@@ -165,19 +231,72 @@ var Resolver = class {
165
231
  * (`{ id, result }`, champ GraphQL…). Le pipeline HTTP/WS normal passe par
166
232
  * {@link callController} (= `executeAction` + rendu).
167
233
  *
234
+ * Synchrone tant que rien n'est à attendre (#505) : la garde d'autorisation
235
+ * n'est attendue que sur une route gardée, l'instance que si elle est à
236
+ * créer. La valeur de l'action est rendue BRUTE — une promesse si l'action
237
+ * est asynchrone ; c'est le rendu qui la démêle.
238
+ *
168
239
  * @param data - args supplémentaires (message WS brut legacy) concaténés aux variables de route.
169
240
  * @param reload - force `newController()` (le container peut déjà porter un AUTRE controller).
170
- * @returns la valeur retournée par l'action + son `RedirectMeta` éventuel.
241
+ * @returns la valeur retournée par l'action + son `RedirectMeta` éventuel —
242
+ * ou la promesse de ce couple quand la garde ou la création attendent.
243
+ * @throws de façon SYNCHRONE ce que lève une action synchrone (ou l'absence
244
+ * d'action) ; un refus d'autorisation rompt la promesse rendue.
171
245
  */
172
- async executeAction(data, reload = false, metaArg) {
246
+ executeAction(data, reload = false, metaArg) {
173
247
  const meta = metaArg ?? (this.route ? resolveActionMeta(this.route) : computeActionMeta(this.controller, this.actionName));
174
- if (meta.security !== null) await this._enforceSecurity(meta.security);
175
- if (meta.security === null || !meta.security.hasRoleClause) {
176
- const area = this._areaSecurity();
177
- if (area !== null) await this._enforceSecurity(area);
178
- }
179
- let controller = this.context.container?.get("controller");
180
- if (!controller || reload || this.controller && !(controller instanceof this.controller)) controller = await this.newController();
248
+ const guarded = this._enforceActionSecurity(meta);
249
+ if (guarded !== void 0) return guarded.then(() => this._acquireAndInvoke(meta, data, reload));
250
+ return this._acquireAndInvoke(meta, data, reload);
251
+ }
252
+ /**
253
+ * Les gardes d'autorisation d'une action : `@IsGranted`/`@RequireScope`, puis
254
+ * le filet de la ZONE quand aucune garde n'a décidé du RÔLE.
255
+ *
256
+ * ⚠️ Vrai du trajet HTTP SEULEMENT. Le kernel y arme la route sans instancier
257
+ * (`http-kernel.ts` `prepareFrontController`), donc ce court-circuit est celui
258
+ * de bout en bout. En **WebSocket**, le controller est instancié au HANDSHAKE
259
+ * — avant qu'une frame ne soit gardée : sur ce transport, un refus n'évite
260
+ * ni la DI ni `initialize()`, qui ont déjà tourné.
261
+ *
262
+ * @returns `undefined` quand la route n'est pas gardée (le cas nominal : ni
263
+ * lookup, ni attente) ; sinon la promesse du verdict, rompue par un refus.
264
+ */
265
+ _enforceActionSecurity(meta) {
266
+ if (meta.security === null) return this._enforceAreaSecurity();
267
+ const granted = this._enforceSecurity(meta.security);
268
+ return meta.security.hasRoleClause ? granted : granted.then(() => this._enforceAreaSecurity());
269
+ }
270
+ /**
271
+ * Filet de la ZONE : une route dont personne n'a décidé le RÔLE hérite de
272
+ * celui de sa zone du firewall, au lieu d'être ouverte à tout compte
273
+ * authentifié.
274
+ *
275
+ * @returns `undefined` sur l'immense majorité des routes (aucune zone, ou
276
+ * zone sans rôle) ; sinon la promesse du verdict.
277
+ */
278
+ _enforceAreaSecurity() {
279
+ const area = this._areaSecurity();
280
+ return area !== null ? this._enforceSecurity(area) : void 0;
281
+ }
282
+ /**
283
+ * L'instance qui sert l'action — celle déjà posée sur le container quand
284
+ * c'est la bonne classe, sinon une création —, puis l'action elle-même.
285
+ * N'attend que la création, et seulement quand elle attend.
286
+ */
287
+ _acquireAndInvoke(meta, data, reload) {
288
+ const pinned = this.context.container?.get("controller");
289
+ if (!pinned || reload || this.controller && !(pinned instanceof this.controller)) return thenMaybe(this.newController(), (controller) => this._invokeAction(controller, meta, data));
290
+ return this._invokeAction(pinned, meta, data);
291
+ }
292
+ /**
293
+ * Appelle l'action sur l'instance : état de requête (portée `request`
294
+ * seulement), arguments, méta de réponse (`@HttpCode`, `@Header`), puis
295
+ * l'appel — synchrone, sa valeur rendue brute.
296
+ *
297
+ * @throws de façon SYNCHRONE ce que lève l'action, ou l'absence d'action.
298
+ */
299
+ _invokeAction(controller, meta, data) {
181
300
  if (this.controller?.scope !== "singleton") {
182
301
  controller.setRoute(this.route);
183
302
  if (this.queryOverride !== null) {
@@ -202,10 +321,22 @@ var Resolver = class {
202
321
  };
203
322
  throw new Error(`Route Action not found`);
204
323
  }
205
- async callController(data, reload = false) {
324
+ /**
325
+ * Exécute l'action PUIS rend sa valeur sur le transport.
326
+ *
327
+ * Synchrone de bout en bout quand rien n'attend — route non gardée,
328
+ * controller déjà créé, action synchrone, rendu synchrone (#505).
329
+ *
330
+ * @param data - args supplémentaires (message WS brut legacy).
331
+ * @param reload - force une nouvelle instance (forward).
332
+ * @returns le résultat du rendu, ou sa promesse dès qu'une étape attend.
333
+ * @throws de façon SYNCHRONE ce que lève une étape synchrone (action,
334
+ * construction) ; les échecs asynchrones rompent la promesse rendue.
335
+ */
336
+ callController(data, reload = false) {
206
337
  const meta = this.route ? resolveActionMeta(this.route) : computeActionMeta(this.controller, this.actionName);
207
- const { result, redirectMeta } = meta.idempotent !== null ? await this._callWithIdempotency(meta, data, reload) : await this.executeAction(data, reload, meta);
208
- return this._handleRedirect(result, redirectMeta);
338
+ const outcome = meta.idempotent !== null ? this._callWithIdempotency(meta, data, reload) : this.executeAction(data, reload, meta);
339
+ return thenMaybe(outcome, (done) => this._handleRedirect(done.result, done.redirectMeta));
209
340
  }
210
341
  /**
211
342
  * Exécute l'action AVEC la porte d'idempotence mais SANS rendu
@@ -249,9 +380,7 @@ var Resolver = class {
249
380
  if (!isMutationMethod(this.methodOverride ?? context.method)) return this.executeAction(data, reload, meta);
250
381
  const als = RequestContext.get();
251
382
  const httpReq = context.request;
252
- const names = this.route?.variables ?? [];
253
- const params = {};
254
- for (let i = 0; i < names.length; i++) params[names[i]] = this.variables[i];
383
+ const params = this._zipRouteParams();
255
384
  const body = als?.body !== void 0 ? als.body : httpReq?.queryPost ?? null;
256
385
  const store = context.container?.get("idempotencyStore");
257
386
  const verdict = await evaluateIdempotency({
@@ -271,7 +400,11 @@ var Resolver = class {
271
400
  const { status, headers, body: memo } = verdict.response;
272
401
  const response = context.response;
273
402
  response?.setStatusCode(status);
274
- if (headers) for (const k in headers) response?.setHeader(k, headers[k]);
403
+ if (headers) for (const k in headers) {
404
+ const value = headers[k];
405
+ if (value === void 0) continue;
406
+ response?.setHeader(k, value);
407
+ }
275
408
  return {
276
409
  result: memo,
277
410
  redirectMeta: void 0
@@ -355,12 +488,11 @@ var Resolver = class {
355
488
  const token = RequestContext.get()?.token;
356
489
  if (!authz || token === void 0) throw new nodefonyError(this._accessDenied(!authz ? `aucun moteur d'autorisation n'est posé sur cette requête` : "aucune identité n'est résolue sur cette requête — la route est-elle couverte par une zone du firewall ?"), 403);
357
490
  const clauses = req.clauses;
358
- for (let i = 0; i < clauses.length; i++) {
359
- const clause = clauses[i];
491
+ for (const clause of clauses) {
360
492
  const subject = clause.subjectParam !== void 0 ? this._resolveSubject(clause.subjectParam) : void 0;
361
493
  let ok = false;
362
494
  const anyOf = clause.anyOf;
363
- for (let j = 0; j < anyOf.length; j++) if (await authz.decide(token, anyOf[j], subject)) {
495
+ for (const attribute of anyOf) if (await authz.decide(token, attribute, subject)) {
364
496
  ok = true;
365
497
  break;
366
498
  }
@@ -395,17 +527,15 @@ var Resolver = class {
395
527
  }
396
528
  _buildParamArgs(metas) {
397
529
  const httpCtx = this.context;
398
- const varNames = this.route?.variables ?? [];
399
- const paramsMap = {};
400
- for (let i = 0; i < varNames.length; i++) paramsMap[varNames[i]] = this.variables[i];
530
+ const paramsMap = this._zipRouteParams();
401
531
  const ctx = this.context;
402
532
  return buildParamArgs(metas, {
403
533
  paramsMap,
404
- request: httpCtx?.request,
405
- response: httpCtx?.response,
406
- session: ctx?.session,
534
+ request: httpCtx.request,
535
+ response: httpCtx.response,
536
+ session: ctx.session,
407
537
  queryOverride: this.queryOverride ?? void 0,
408
- getRequestCookies: (name) => ctx?.getRequestCookies ? ctx.getRequestCookies(name) : void 0
538
+ getRequestCookies: (name) => ctx.getRequestCookies(name)
409
539
  });
410
540
  }
411
541
  /**
@@ -419,11 +549,23 @@ var Resolver = class {
419
549
  const response = this.context.response;
420
550
  if (meta.httpCode !== null) response?.setStatusCode(meta.httpCode);
421
551
  const entries = meta.headerEntries;
422
- if (entries) for (let i = 0; i < entries.length; i++) response?.setHeader(entries[i][0], entries[i][1]);
552
+ if (entries) for (const [name, value] of entries) response?.setHeader(name, value);
423
553
  }
424
- async _handleRedirect(actionResult, redirectMeta) {
554
+ /**
555
+ * Applique `@Redirect` à la valeur de l'action, puis la rend.
556
+ *
557
+ * Sans `@Redirect` (le cas nominal), rend directement. Avec, la valeur est
558
+ * attendue seulement si c'est une promesse — une action synchrone qui rend
559
+ * `undefined` redirige sans suspension.
560
+ *
561
+ * @returns le résultat du rendu, ou sa promesse.
562
+ */
563
+ _handleRedirect(actionResult, redirectMeta) {
425
564
  if (!redirectMeta) return this.returnController(actionResult);
426
- const resolved = await Promise.resolve(actionResult);
565
+ return thenMaybe(actionResult, (resolved) => this._redirectOrReturn(resolved, redirectMeta));
566
+ }
567
+ /** Suite de {@link _handleRedirect}, la valeur de l'action connue. */
568
+ _redirectOrReturn(resolved, redirectMeta) {
427
569
  if (resolved !== null && resolved !== void 0 && typeof resolved === "object" && "url" in resolved) {
428
570
  const override = resolved;
429
571
  this.context.redirect(override.url, override.statusCode ?? redirectMeta.statusCode);
@@ -435,7 +577,21 @@ var Resolver = class {
435
577
  }
436
578
  return this.returnController(resolved);
437
579
  }
438
- async returnController(result) {
580
+ /**
581
+ * Rend la valeur d'une action sur le transport, selon sa nature : promesse
582
+ * (démêlée puis re-rendue), texte, Buffer, réponse déjà construite, scalaire
583
+ * ou objet (auto-JSON), rien (l'action envoie elle-même).
584
+ *
585
+ * Pas `async` : l'envelopper dans une promesse de plus ne changeait rien au
586
+ * rendu, et coûtait une suspension à chaque requête (#505). La valeur n'est
587
+ * attendue que si c'en est une.
588
+ *
589
+ * @param result - la valeur rendue par l'action.
590
+ * @returns ce que rend l'envoi (souvent sa promesse), ou `undefined` quand il
591
+ * n'y a rien à envoyer.
592
+ * @throws de façon SYNCHRONE ce que lève un envoi synchrone.
593
+ */
594
+ returnController(result) {
439
595
  const type = typeOf(result);
440
596
  switch (true) {
441
597
  case result instanceof Promise:
@@ -177,7 +177,7 @@ var Route = class Route {
177
177
  this.method = obj.method;
178
178
  this.setHostname(obj.host);
179
179
  this.setDefaults(obj.defaults);
180
- this.requirements = obj.requirements || {};
180
+ this.requirements = obj.requirements ?? {};
181
181
  this.bypassFirewall = obj.bypassFirewall ?? false;
182
182
  this.areaRoleExempt = obj.areaRoleExempt ?? false;
183
183
  this.compile();
@@ -206,7 +206,7 @@ var Route = class Route {
206
206
  match(context, cleanPath, methodOverride) {
207
207
  let res;
208
208
  if (context.request && this.pattern) {
209
- const url = cleanPath !== void 0 ? cleanPath : Route.cleanPathname(context);
209
+ const url = cleanPath ?? Route.cleanPathname(context);
210
210
  if (url !== void 0) res = url.match(this.pattern);
211
211
  }
212
212
  if (!res) return res;
@@ -228,7 +228,7 @@ var Route = class Route {
228
228
  else throw { BreakException: `Requirement Routing config Exception variable : ${k} must be RegExp or string : ${typeOf(req)}` };
229
229
  }
230
230
  result = compiled.test(param ?? "");
231
- if (!result) throw { BreakException: `Requirement Exception variable : ${k} ==> ${param} doesn't match with ${String(req)}` };
231
+ if (!result) throw { BreakException: `Requirement Exception variable : ${k} ==> ${param} doesn't match with ${typeof req === "string" ? req : String(compiled)}` };
232
232
  }
233
233
  const index = map.push(param);
234
234
  map[k] = map[index - 1];
@@ -237,7 +237,7 @@ var Route = class Route {
237
237
  if (e && typeof e === "object" && "BreakException" in e) throw e.BreakException;
238
238
  throw e;
239
239
  }
240
- if (map && map.wildcard) map["*"] = map.wildcard;
240
+ if (map.wildcard) map["*"] = map.wildcard;
241
241
  return map;
242
242
  }
243
243
  /**
@@ -264,11 +264,12 @@ var Route = class Route {
264
264
  REG_ROUTE.lastIndex = 0;
265
265
  let found;
266
266
  while ((found = REG_ROUTE.exec(this.path)) !== null) {
267
+ const [match, slash, dot, key = "", capture, opt] = found;
267
268
  const literal = this.path.slice(from, found.index);
268
269
  this.#collectUnreachable(literal);
269
270
  pattern += compileLiteral(literal, wildcard);
270
- pattern += replaceCallback.call(this, found[0], found[1], found[2], found[3], found[4], found[5], found.index);
271
- from = found.index + found[0].length;
271
+ pattern += replaceCallback.call(this, match, slash, dot, key, capture, opt, found.index);
272
+ from = found.index + match.length;
272
273
  }
273
274
  const tail = this.path.slice(from);
274
275
  this.#collectUnreachable(tail);
@@ -290,13 +291,12 @@ var Route = class Route {
290
291
  this.methodsSet = void 0;
291
292
  this.methodsAllow = void 0;
292
293
  this.varRegexp = void 0;
293
- const methods = this.requirements?.methods;
294
- if (typeof methods === "string") {
295
- const list = methods.replace(/\s/g, "").toUpperCase().split(",");
296
- this.methodsSet = new Set(list);
297
- this.methodsAllow = list.join(",");
298
- } else if (Array.isArray(methods)) {
299
- const list = methods.map((m) => String(m).toUpperCase());
294
+ const methods = this.requirements.methods;
295
+ let list;
296
+ if (typeof methods === "string") list = methods.replace(/\s/g, "").toUpperCase().split(",");
297
+ else if (Array.isArray(methods)) list = methods.map((m) => m.toUpperCase());
298
+ if (list) {
299
+ if (list.includes("GET") && !list.includes("HEAD")) list.push("HEAD");
300
300
  this.methodsSet = new Set(list);
301
301
  this.methodsAllow = list.join(",");
302
302
  }
@@ -308,11 +308,13 @@ var Route = class Route {
308
308
  }
309
309
  }
310
310
  hydrateDefaultParameters(res) {
311
- if (this.variables.length) {
312
- for (let i = 0; i < this.variables.length; i++) if (this.defaults[this.variables[i]]) {
313
- if (res[i + 1] === "") res[i + 1] = this.defaults[this.variables[i]];
311
+ if (this.variables.length) for (let i = 0; i < this.variables.length; i++) {
312
+ const name = this.variables[i];
313
+ if (name !== void 0 && this.defaults[name]) {
314
+ if (res[i + 1] === "") res[i + 1] = this.defaults[name];
314
315
  }
315
- } else for (const def in this.defaults) switch (def) {
316
+ }
317
+ else for (const def in this.defaults) switch (def) {
316
318
  case "controller": continue;
317
319
  default: res.push(this.defaults[def]);
318
320
  }
@@ -336,8 +338,7 @@ var Route = class Route {
336
338
  * `this.module` à `onBoot` — appel via le décorateur `@controllers` du module.
337
339
  */
338
340
  toLogLine() {
339
- const m = Array.isArray(this.requirements?.methods) ? this.requirements.methods.join("|") : this.requirements?.methods || this.method || "ANY";
340
- const method = `[${String(m)}]`.padEnd(10);
341
+ const method = `[${Array.isArray(this.requirements.methods) ? this.requirements.methods.join("|") : this.requirements.methods || this.method || "ANY"}]`.padEnd(10);
341
342
  const ctrl = this.controller?.name || "?";
342
343
  const action = this.classMethod || this.name;
343
344
  const mod = this.module?.name ? `@${this.module.name}/` : "";
@@ -388,14 +389,14 @@ var Route = class Route {
388
389
  compileHost() {
389
390
  const patterns = [];
390
391
  if (this.host) patterns.push(this.host);
391
- const reqDomain = this.requirements?.domain;
392
+ const reqDomain = this.requirements.domain;
392
393
  if (reqDomain) patterns.push(reqDomain);
393
394
  this.hostRegexp = patterns.length ? compileDomainPatterns(patterns.flat()) : void 0;
394
395
  }
395
396
  matchHostname(context) {
396
397
  if (this.hostRegexp) {
397
398
  if (isDomainAllowed(this.hostRegexp, context.domain)) return true;
398
- const error = new HttpError(`Domain ${context.domain} Unauthorized`);
399
+ const error = new HttpError(`Domain ${context.domain} Forbidden for this route`);
399
400
  error.code = 403;
400
401
  error.type = "domain";
401
402
  throw error;
@@ -422,7 +423,7 @@ var Route = class Route {
422
423
  return this.hash;
423
424
  }
424
425
  addRequirement(key, value) {
425
- if (key && value) {
426
+ if (value) {
426
427
  this.requirements[key] = value;
427
428
  this.compileRequirements();
428
429
  return value;
@@ -437,10 +438,10 @@ var Route = class Route {
437
438
  matchRequirements(context, methodOverride) {
438
439
  if (this.hasRequirements()) for (const i in this.requirements) switch (i) {
439
440
  case "methods":
440
- if (!this.methodsSet) throw new Error(`Bad config route method : ${this.requirements[i]}`);
441
+ if (!this.methodsSet) throw new Error(`Bad config route method : ${String(this.requirements.methods)}`);
441
442
  if (methodOverride !== void 0) {
442
443
  if (!this.methodsSet.has("WEBSOCKET") || !this.methodsSet.has(methodOverride)) {
443
- const error = new HttpError(`Method ${methodOverride} Unauthorized`);
444
+ const error = new HttpError(`Method ${methodOverride} Not Allowed`);
444
445
  error.code = 405;
445
446
  error.type = "method";
446
447
  error.allow = this.methodsAllow ?? "";
@@ -449,7 +450,7 @@ var Route = class Route {
449
450
  break;
450
451
  }
451
452
  if (!this.methodsSet.has(context.method)) {
452
- const error = new HttpError(`Method ${context.method} Unauthorized`);
453
+ const error = new HttpError(`Method ${context.method} Not Allowed`);
453
454
  error.code = 405;
454
455
  error.type = "method";
455
456
  error.allow = this.methodsAllow ?? "";
@@ -457,23 +458,22 @@ var Route = class Route {
457
458
  }
458
459
  break;
459
460
  case "domain": break;
460
- case "protocol": switch (context.method) {
461
- case "WEBSOCKET":
462
- let requirement = this.requirements[i];
463
- if (!requirement) return true;
464
- if (typeof requirement === "string") {
465
- if (context.acceptedProtocol !== requirement) {
466
- const error = new HttpError(`Protocol ${context.acceptedProtocol} Unauthorized`);
467
- error.code = 1002;
468
- error.type = "protocol";
469
- throw error;
470
- }
471
- } else {
472
- const error = new HttpError(`Protocol ${context.acceptedProtocol} Unauthorized`);
461
+ case "protocol": if (context.method === "WEBSOCKET") {
462
+ const requirement = this.requirements[i];
463
+ if (!requirement) return true;
464
+ if (typeof requirement === "string") {
465
+ if (context.acceptedProtocol !== requirement) {
466
+ const error = new HttpError(`Protocol ${context.acceptedProtocol} Not Supported`);
473
467
  error.code = 1002;
474
468
  error.type = "protocol";
475
469
  throw error;
476
470
  }
471
+ } else {
472
+ const error = new HttpError(`Protocol ${context.acceptedProtocol} Not Supported`);
473
+ error.code = 1002;
474
+ error.type = "protocol";
475
+ throw error;
476
+ }
477
477
  }
478
478
  }
479
479
  return true;
@@ -36,7 +36,7 @@ function createSyslogAdminApi(syslog, options = {}) {
36
36
  /** Lit un entier de query (`?limit=50`), borné, avec défaut. */
37
37
  const intParam = (req, key, def, max) => {
38
38
  const raw = req.query[key];
39
- const v = Array.isArray(raw) ? raw[0] : raw;
39
+ const v = Array.isArray(raw) ? raw.at(0) : raw;
40
40
  const n = v !== void 0 ? Number.parseInt(v, 10) : NaN;
41
41
  if (Number.isNaN(n) || n <= 0) return def;
42
42
  return Math.min(n, max);
@@ -176,7 +176,7 @@ function createSyslogAdminApi(syslog, options = {}) {
176
176
  driver: driver?.name ?? wanted ?? null
177
177
  }
178
178
  };
179
- return await driver.query(buildCriteria(request, ["driver", "scope"]));
179
+ return driver.query(buildCriteria(request, ["driver", "scope"]));
180
180
  }
181
181
  },
182
182
  {
@@ -233,7 +233,7 @@ function createSyslogAdminApi(syslog, options = {}) {
233
233
  local: "destination locale (pas de réseau)"
234
234
  }
235
235
  };
236
- return await driver.probe();
236
+ return driver.probe();
237
237
  }
238
238
  },
239
239
  {
@@ -307,7 +307,7 @@ function createSyslogAdminApi(syslog, options = {}) {
307
307
  status: 400,
308
308
  body: { error: "missing enabled (boolean)" }
309
309
  };
310
- if (body.enabled === false) syslog.log(`sink texte « ${Syslog.logSinkName} » coupé (console/fichier)`, "NOTICE", "LOG-BACKPLANE");
310
+ if (!body.enabled) syslog.log(`sink texte « ${Syslog.logSinkName} » coupé (console/fichier)`, "NOTICE", "LOG-BACKPLANE");
311
311
  const changed = Syslog.setSinkEnabled(body.enabled);
312
312
  if (changed && body.enabled) syslog.log("sink texte rétabli", "NOTICE", "LOG-BACKPLANE");
313
313
  return {