@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
package/README.md CHANGED
@@ -12,7 +12,10 @@ sert **HTTP et WebSocket dans le même contexte**, nativement.
12
12
 
13
13
  - **Router** : matching de routes (path + méthode + domaine), `Route.match()`.
14
14
  - **Resolver** : résout route → controller → action, injecte les paramètres décorés.
15
- - **Controller** : classe de base ; cycle `initialize()` → action ; HTTP + WS.
15
+ - **Controller** : classe de base ; cycle `initialize()` → action ; HTTP + WS. Singleton par défaut :
16
+ une instance partagée par toutes les requêtes, sans état sur `this`, qui ne peut donc pas injecter
17
+ un service `@injectable({ scope: "request" })` — refusé au démarrage. `@Scope("request")` rend une
18
+ instance par requête (par connexion en WebSocket). `RealtimeController` est par connexion.
16
19
  - **Décorateurs** : `@controller`, `@route`, `@Get/@Post/...`, `@Param/@Body/@Query/@Headers`,
17
20
  `@Domain`, etc.
18
21
  - **Data plane admin** : `AdminApiController` + `IAdminBroker` (surface `/nodefony/<module>/api/*`).
package/dist/index.js CHANGED
@@ -87,7 +87,7 @@ let Framework = class Framework extends Module {
87
87
  * silencieux (le WARNING annonce la dégradation).
88
88
  */
89
89
  async onKernelBoot() {
90
- const configured = this.options?.idempotency?.store ?? AUTO_STORE;
90
+ const configured = this.options.idempotency.store;
91
91
  let name = configured;
92
92
  let reason = `store explicitement configuré ("${configured}")`;
93
93
  if (name === AUTO_STORE) {
@@ -167,7 +167,7 @@ let Framework = class Framework extends Module {
167
167
  if (!broker.has("kernel")) broker.register(createKernelAdminApi(this.kernel));
168
168
  if (!broker.has("framework")) broker.register(createFrameworkAdminApi(broker, { playground: this.kernel.environment === "development" || Boolean(this.kernel.debug) }));
169
169
  if (this.kernel.syslog && !broker.has("syslog")) {
170
- const logDir = path.resolve(process.cwd(), this.kernel.options?.log?.dir ?? "logs");
170
+ const logDir = path.resolve(process.cwd(), this.kernel.options.log?.dir ?? "logs");
171
171
  broker.register(createSyslogAdminApi(this.kernel.syslog, {
172
172
  logDir,
173
173
  enableFiles: this.kernel.environment !== "production",
@@ -43,7 +43,7 @@ var AdminApiController = class AdminApiController extends Controller {
43
43
  */
44
44
  async dispatch(...args) {
45
45
  const { status, headers, body } = await this.runAdmin(args);
46
- if (this.context?.type?.startsWith("websocket")) {
46
+ if (this.context?.type.startsWith("websocket")) {
47
47
  if (status >= 400) {
48
48
  const message = body?.error ?? "admin error";
49
49
  throw new RpcError(message, -32e3, {
@@ -140,14 +140,15 @@ var AdminApiController = class AdminApiController extends Controller {
140
140
  }
141
141
  const als = RequestContext.get();
142
142
  const user = als?.user ?? null;
143
+ const post = this.queryPost;
143
144
  return {
144
145
  params,
145
146
  query: this.query ?? {},
146
- body: als?.body !== void 0 ? als.body : this.queryPost ?? null,
147
+ body: als?.body !== void 0 ? als.body : post ?? null,
147
148
  user,
148
149
  roles: this.extractRoles(user),
149
150
  requestId: als?.requestId,
150
- idempotencyKey: resolveIdempotencyKey(als?.idempotencyKey, this.context?.request?.headers?.["idempotency-key"])
151
+ idempotencyKey: resolveIdempotencyKey(als?.idempotencyKey, this.context?.request?.headers["idempotency-key"])
151
152
  };
152
153
  }
153
154
  /**
@@ -40,7 +40,7 @@ var ApiKeyController = class extends Controller {
40
40
  scopes: body.scopes,
41
41
  expiresInDays: body.expiresInDays
42
42
  });
43
- return this.renderJson(created, 201);
43
+ return await this.renderJson(created, 201);
44
44
  } catch (e) {
45
45
  return this.#renderApiKeyError(e);
46
46
  }
@@ -66,7 +66,7 @@ var OAuth2Controller = class extends Controller {
66
66
  session.set(VERIFIER_KEY, auth.codeVerifier);
67
67
  session.set(PROVIDER_KEY, provider);
68
68
  await session.save();
69
- return this.redirect(auth.url, 302);
69
+ this.redirect(auth.url, 302);
70
70
  }
71
71
  /** Valide `state`, échange le `code`, ouvre la session BFF (302). */
72
72
  async callback(provider) {
@@ -85,26 +85,31 @@ var OAuth2Controller = class extends Controller {
85
85
  const code = this.#queryString("code");
86
86
  const returnedState = this.#queryString("state");
87
87
  const returnedIss = this.#queryString("iss");
88
- if (code === null || returnedState === null || typeof expectedState !== "string" || returnedState !== expectedState || expectedProvider !== provider) return this.redirect(failure, 302);
88
+ if (code === null || returnedState === null || typeof expectedState !== "string" || returnedState !== expectedState || expectedProvider !== provider) {
89
+ this.redirect(failure, 302);
90
+ return;
91
+ }
89
92
  try {
90
93
  const { identifier } = await svc.exchangeAndProvision(provider, code, typeof storedVerifier === "string" ? storedVerifier : null, returnedIss);
91
94
  await flow.establishSessionFor(this.context, identifier, "oauth");
92
- return this.redirect(success, 302);
95
+ this.redirect(success, 302);
96
+ return;
93
97
  } catch (error) {
94
98
  this.log(`oauth2 callback "${provider}" : ${error.message.slice(0, 200)}`, "WARNING");
95
- return this.redirect(failure, 302);
99
+ this.redirect(failure, 302);
100
+ return;
96
101
  }
97
102
  }
98
103
  #service() {
99
104
  const svc = this.get("oauth2");
100
- return svc && svc.isEnabled() ? svc : null;
105
+ return svc?.isEnabled() ? svc : null;
101
106
  }
102
107
  #flow() {
103
108
  return this.get("authFlow") ?? null;
104
109
  }
105
110
  /** Lit un paramètre de query string (GET), ou `null`. */
106
111
  #queryString(key) {
107
- const v = (this.queryGet ?? {})[key];
112
+ const v = this.queryGet?.[key];
108
113
  return typeof v === "string" && v.length > 0 ? v : null;
109
114
  }
110
115
  };
@@ -37,11 +37,11 @@ var SessionAuthController = class extends Controller {
37
37
  const body = this.queryPost ?? {};
38
38
  try {
39
39
  const outcome = await flow.login(this.context, body.username, body.password);
40
- if (outcome.status === "mfa_required") return this.renderJson({
40
+ if (outcome.status === "mfa_required") return await this.renderJson({
41
41
  mfaRequired: true,
42
42
  methods: outcome.methods
43
43
  }, 202);
44
- return this.renderJson({ user: outcome.user });
44
+ return await this.renderJson({ user: outcome.user });
45
45
  } catch (e) {
46
46
  return this.#renderAuthError(e);
47
47
  }
@@ -57,7 +57,7 @@ var SessionAuthController = class extends Controller {
57
57
  const body = this.queryPost ?? {};
58
58
  try {
59
59
  const user = await flow.completeMfaLogin(this.context, body.code);
60
- return this.renderJson({ user });
60
+ return await this.renderJson({ user });
61
61
  } catch (e) {
62
62
  return this.#renderAuthError(e);
63
63
  }
@@ -39,7 +39,7 @@ var TokenAuthController = class extends Controller {
39
39
  const body = this.queryPost ?? {};
40
40
  try {
41
41
  const tokens = await svc.issueForCredentials(body.username, body.password, parseScope(body.scope), body.resource);
42
- return this.renderJson(tokens);
42
+ return await this.renderJson(tokens);
43
43
  } catch (e) {
44
44
  return this.#renderAuthError(e);
45
45
  }
@@ -51,7 +51,7 @@ var TokenAuthController = class extends Controller {
51
51
  const body = this.queryPost ?? {};
52
52
  try {
53
53
  const tokens = await svc.refresh(body.refresh_token, body.resource);
54
- return this.renderJson(tokens);
54
+ return await this.renderJson(tokens);
55
55
  } catch (e) {
56
56
  return this.#renderAuthError(e);
57
57
  }
@@ -38,14 +38,15 @@ var TotpController = class extends Controller {
38
38
  if (!svc) return this.renderJson({ error: "2FA unavailable" }, 503);
39
39
  const subject = await this.#currentSubject();
40
40
  if (subject === null) return this.renderJson({ error: "Unauthorized" }, 401);
41
- const code = (this.queryPost ?? {}).code;
41
+ const code = this.queryPost?.code;
42
42
  if (typeof code !== "string" || code.length === 0) return this.renderJson({ error: "Invalid code" }, 400);
43
+ let activation;
43
44
  try {
44
- const activation = await svc.confirmEnrollment(subject, code);
45
- return this.renderJson(activation);
45
+ activation = await svc.confirmEnrollment(subject, code);
46
46
  } catch {
47
47
  return this.renderJson({ error: "Invalid or expired code" }, 400);
48
48
  }
49
+ return this.renderJson(activation);
49
50
  }
50
51
  /** Désactive le 2FA du porteur courant (retire secret + codes). */
51
52
  async disable() {
@@ -66,7 +67,7 @@ var TotpController = class extends Controller {
66
67
  }
67
68
  #service() {
68
69
  const svc = this.get("totp");
69
- return svc && svc.isEnabled() ? svc : null;
70
+ return svc?.isEnabled() ? svc : null;
70
71
  }
71
72
  #flow() {
72
73
  return this.get("authFlow") ?? null;
@@ -61,7 +61,7 @@ var WebAuthnController = class extends Controller {
61
61
  if (!challenge) return this.renderJson({ error: "No challenge" }, 400);
62
62
  try {
63
63
  const credential = await svc.verifyRegistration(this.#body().response, challenge, me.username, this.#origin());
64
- return this.renderJson({
64
+ return await this.renderJson({
65
65
  verified: true,
66
66
  credentialId: credential.id
67
67
  });
@@ -106,7 +106,7 @@ var WebAuthnController = class extends Controller {
106
106
  try {
107
107
  const { userId } = await svc.verifyAuthentication(this.#body().response, challenge, this.#origin());
108
108
  const user = await flow.establishSessionFor(this.context, userId, "webauthn");
109
- return this.renderJson({
109
+ return await this.renderJson({
110
110
  verified: true,
111
111
  user
112
112
  });
@@ -143,7 +143,7 @@ var WebAuthnController = class extends Controller {
143
143
  }
144
144
  #service() {
145
145
  const svc = this.get("webauthn");
146
- return svc && svc.isEnabled() ? svc : null;
146
+ return svc?.isEnabled() ? svc : null;
147
147
  }
148
148
  #flow() {
149
149
  return this.get("authFlow") ?? null;
@@ -158,7 +158,7 @@ var WebAuthnController = class extends Controller {
158
158
  }
159
159
  /** Origine HTTP de la requête (validée par le service contre le rpID). */
160
160
  #origin() {
161
- const origin = this.context.request?.headers?.origin;
161
+ const origin = this.context.request?.headers.origin;
162
162
  return typeof origin === "string" ? origin : void 0;
163
163
  }
164
164
  /** Lit le challenge en session puis l'INVALIDE (usage unique, anti-replay). */
@@ -118,20 +118,19 @@ function controllers(targets) {
118
118
  function controller(prefix) {
119
119
  return function(mycontroller) {
120
120
  mycontroller.prefix = prefix;
121
- const metadata = Reflect.getMetadata(metadataKey, mycontroller) || {};
122
- if (metadata && Object.keys(metadata).length !== 0) {
121
+ const metadata = Reflect.getMetadata(metadataKey, mycontroller) ?? {};
122
+ if (Object.keys(metadata).length !== 0) {
123
123
  let hasMagic = false;
124
- for (const name in metadata) {
125
- const options = metadata[name];
124
+ for (const [name, options] of Object.entries(metadata)) {
126
125
  options.prefix = prefix;
127
126
  if (options.host === void 0) {
128
- const methodDomain = Reflect.getMetadata(DOMAIN_METHOD_METADATA, mycontroller, options.classMethod);
127
+ const methodDomain = options.classMethod === void 0 ? void 0 : Reflect.getMetadata(DOMAIN_METHOD_METADATA, mycontroller, options.classMethod);
129
128
  const classDomain = Reflect.getMetadata(DOMAIN_CLASS_METADATA, mycontroller);
130
129
  const domain = methodDomain ?? classDomain;
131
130
  if (domain) options.host = domain;
132
131
  }
133
132
  if (options.bypassFirewall !== true) {
134
- const methodBypass = Reflect.getMetadata(BYPASS_FIREWALL_METHOD_METADATA, mycontroller, options.classMethod);
133
+ const methodBypass = options.classMethod === void 0 ? void 0 : Reflect.getMetadata(BYPASS_FIREWALL_METHOD_METADATA, mycontroller, options.classMethod);
135
134
  const classBypass = Reflect.getMetadata(BYPASS_FIREWALL_CLASS_METADATA, mycontroller);
136
135
  if (methodBypass === true || classBypass === true) options.bypassFirewall = true;
137
136
  }
@@ -184,7 +183,7 @@ function route(name, options) {
184
183
  } catch (error) {
185
184
  filePath = error;
186
185
  }
187
- const metadata = Reflect.getMetadata(metadataKey, target.constructor) || {};
186
+ const metadata = Reflect.getMetadata(metadataKey, target.constructor) ?? {};
188
187
  metadata[name] = {
189
188
  path,
190
189
  filePath,
@@ -205,7 +204,7 @@ function route(name, options) {
205
204
  function extractControllerFilePath(stackTrace) {
206
205
  for (const line of stackTrace) {
207
206
  const match = line.match(/\s+at file:\/\/(.*\/controllers?\/.*\.js)/);
208
- if (match && match[1]) return match[1];
207
+ if (match?.[1]) return match[1];
209
208
  }
210
209
  }
211
210
  const HTTP_CODE_METADATA = "route:httpCode";
@@ -313,7 +312,7 @@ function HttpCode(statusCode) {
313
312
  */
314
313
  function Header(key, value) {
315
314
  return function(target, propertyKey, descriptor) {
316
- const existing = Reflect.getMetadata("route:responseHeaders", target, propertyKey) || {};
315
+ const existing = Reflect.getMetadata("route:responseHeaders", target, propertyKey) ?? {};
317
316
  existing[key] = value;
318
317
  Reflect.defineMetadata(HEADERS_METADATA, existing, target, propertyKey);
319
318
  return descriptor;
@@ -420,25 +419,31 @@ function BypassFirewall(target, propertyKey, descriptor) {
420
419
  return descriptor;
421
420
  }
422
421
  /**
423
- * Déclare le scope d'instanciation d'un controller (V4.3) — pose le statique
424
- * `scope` de la classe (hérité de `Controller`, défaut `"request"`). Lu par le
425
- * constructor de `Controller` (`new.target`) et par le `Resolver` : 0 Reflect.
422
+ * Déclare la portée d'instanciation d'un contrôleur — pose le statique `scope`
423
+ * de la classe (hérité de `Controller`, défaut `"singleton"`). Lu par le
424
+ * constructeur de `Controller` (`new.target`) et par le `Resolver` : 0 Reflect.
426
425
  *
427
- * `@Scope("singleton")` : UNE instance partagée par toutes les requêtes
428
- * (cache kernel-scoped sur le Router, `initialize()` appelé 1× à la création).
429
- * **Contrat stateless strict** : l'action ne lit/n'écrit AUCUN état par requête
430
- * sur `this` — tout passe par les arguments décorés (`@Param`/`@Body`…) et les
431
- * helpers, qui retrouvent la requête courante via l'ALS (V4.1). Un champ muté
432
- * par requête sur un singleton = data race silencieuse entre deux requêtes
433
- * concurrentes. Le défaut per-request reste inchangé (0 breaking legacy).
426
+ * `@Scope("request")` : une instance par requête — par CONNEXION en
427
+ * WebSocket. À déclarer quand le contrôleur injecte un service de portée
428
+ * `request` à son constructeur (un singleton qui le tente est refusé au
429
+ * démarrage), prépare chaque requête dans `initialize()`, ou garde un état
430
+ * sur `this`.
431
+ *
432
+ * `@Scope("singleton")` redit le défaut : UNE instance partagée par toutes les
433
+ * requêtes (cache du Router, `initialize()` appelé une fois, à la création).
434
+ * **Contrat sans état** : l'action ne lit ni n'écrit aucun état de requête sur
435
+ * `this` — tout passe par les arguments décorés (`@Param`/`@Body`…) et les
436
+ * helpers, qui retrouvent la requête courante dans l'ALS. Les accesseurs
437
+ * d'état refusent l'écriture, et le développement refuse toute écriture sur
438
+ * un champ.
434
439
  *
435
440
  * ⚠️ Homonyme : le core `nodefony` exporte aussi `Scope` (le scope DI du
436
441
  * `Container`) — celui-ci s'importe depuis `@nodefony/framework`.
437
442
  *
438
443
  * @example
439
- * \@Scope("singleton")
440
- * \@controller("/api/books")
441
- * class BookController extends ResourceController { ... }
444
+ * \@Scope("request")
445
+ * \@controller("/me")
446
+ * class MeController extends Controller { ... }
442
447
  */
443
448
  function Scope(scope) {
444
449
  return function(target) {
@@ -517,12 +522,12 @@ function IsGranted(attribute, options) {
517
522
  };
518
523
  return function(target, propertyKey, descriptor) {
519
524
  if (propertyKey === void 0) {
520
- const existing = Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target) || [];
525
+ const existing = Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target) ?? [];
521
526
  existing.push(clause);
522
527
  Reflect.defineMetadata(SECURITY_CLAUSES_METADATA, existing, target);
523
528
  return target;
524
529
  }
525
- const existing = Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target, propertyKey) || [];
530
+ const existing = Reflect.getMetadata(SECURITY_CLAUSES_METADATA, target, propertyKey) ?? [];
526
531
  existing.push(clause);
527
532
  Reflect.defineMetadata(SECURITY_CLAUSES_METADATA, existing, target, propertyKey);
528
533
  return descriptor;
@@ -573,12 +578,12 @@ function RequireScope(scope) {
573
578
  const clause = { anyOf: Array.isArray(scope) ? [...scope] : [scope] };
574
579
  return function(target, propertyKey, descriptor) {
575
580
  if (propertyKey === void 0) {
576
- const existing = Reflect.getMetadata(SECURITY_SCOPES_METADATA, target) || [];
581
+ const existing = Reflect.getMetadata(SECURITY_SCOPES_METADATA, target) ?? [];
577
582
  existing.push(clause);
578
583
  Reflect.defineMetadata(SECURITY_SCOPES_METADATA, existing, target);
579
584
  return target;
580
585
  }
581
- const existing = Reflect.getMetadata(SECURITY_SCOPES_METADATA, target, propertyKey) || [];
586
+ const existing = Reflect.getMetadata(SECURITY_SCOPES_METADATA, target, propertyKey) ?? [];
582
587
  existing.push(clause);
583
588
  Reflect.defineMetadata(SECURITY_SCOPES_METADATA, existing, target, propertyKey);
584
589
  return descriptor;
@@ -593,9 +598,9 @@ function mergeCspDirectives(a, b) {
593
598
  const out = {};
594
599
  for (const src of [a, b]) {
595
600
  if (!src) continue;
596
- for (const name in src) {
601
+ for (const [name, values] of Object.entries(src)) {
597
602
  const list = out[name] ??= [];
598
- for (const v of src[name]) if (!list.includes(v)) list.push(v);
603
+ for (const v of values) if (!list.includes(v)) list.push(v);
599
604
  }
600
605
  }
601
606
  return out;
@@ -695,7 +700,7 @@ function Idempotent(options) {
695
700
  function paramDecoratorFactory(source) {
696
701
  return function(key) {
697
702
  return function(target, propertyKey, parameterIndex) {
698
- const existing = Reflect.getMetadata("route:paramArgs", target, propertyKey) || [];
703
+ const existing = Reflect.getMetadata("route:paramArgs", target, propertyKey) ?? [];
699
704
  existing.push({
700
705
  source,
701
706
  key,
@@ -746,11 +751,12 @@ const Query = paramDecoratorFactory("query");
746
751
  * ```
747
752
  */
748
753
  function Body(keyOrOptions) {
749
- const isOptions = typeof keyOrOptions === "object" && keyOrOptions !== null;
750
- const key = isOptions ? void 0 : keyOrOptions;
751
- const stream = isOptions ? keyOrOptions.stream === true : false;
754
+ const raw = keyOrOptions;
755
+ const isOptions = typeof raw === "object" && raw !== null;
756
+ const key = isOptions ? void 0 : raw ?? void 0;
757
+ const stream = isOptions ? raw.stream === true : false;
752
758
  return function(target, propertyKey, parameterIndex) {
753
- const existing = Reflect.getMetadata("route:paramArgs", target, propertyKey) || [];
759
+ const existing = Reflect.getMetadata("route:paramArgs", target, propertyKey) ?? [];
754
760
  const meta = {
755
761
  source: "body",
756
762
  key,
@@ -834,7 +840,7 @@ function routeExpectsBodyStream(routeDef) {
834
840
  let flag = false;
835
841
  const ctor = routeDef.controller;
836
842
  const method = routeDef.classMethod;
837
- if (ctor && method) flag = (Reflect.getMetadata("route:paramArgs", ctor.prototype, method) || []).some((m) => m.source === "body" && m.stream === true);
843
+ if (ctor && method) flag = (Reflect.getMetadata("route:paramArgs", ctor.prototype, method) ?? []).some((m) => m.source === "body" && m.stream === true);
838
844
  routeDef.bodyStream = flag;
839
845
  }
840
846
  return routeDef.bodyStream;
@@ -963,7 +969,7 @@ function computeActionMeta(ctor, method) {
963
969
  * (pas d'import `Route` → 0 cycle).
964
970
  */
965
971
  function resolveActionMeta(routeDef) {
966
- if (routeDef.actionMeta === void 0) routeDef.actionMeta = computeActionMeta(routeDef.controller, routeDef.classMethod);
972
+ routeDef.actionMeta ??= computeActionMeta(routeDef.controller, routeDef.classMethod);
967
973
  return routeDef.actionMeta;
968
974
  }
969
975
  //#endregion
@@ -96,7 +96,7 @@ let MemoryIdempotencyStore = class MemoryIdempotencyStore extends Service {
96
96
  const entries = this.entries;
97
97
  if (entries === null) return;
98
98
  const existing = entries.get(key);
99
- if (existing === void 0 || existing.kind !== "in-flight") return;
99
+ if (existing?.kind !== "in-flight") return;
100
100
  entries.set(key, {
101
101
  kind: "done",
102
102
  fingerprint: existing.fingerprint,
@@ -108,8 +108,7 @@ let MemoryIdempotencyStore = class MemoryIdempotencyStore extends Service {
108
108
  abort(key) {
109
109
  const entries = this.entries;
110
110
  if (entries === null) return;
111
- const existing = entries.get(key);
112
- if (existing !== void 0 && existing.kind === "in-flight") entries.delete(key);
111
+ if (entries.get(key)?.kind === "in-flight") entries.delete(key);
113
112
  }
114
113
  /**
115
114
  * Borne la taille : purge passive des expirées rencontrées, puis éviction FIFO
@@ -3,18 +3,21 @@ import { routeExpectsBodyStream } from "../decorators/routerDecorators.js";
3
3
  import Resolver from "../src/Resolver.js";
4
4
  import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
5
5
  import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
6
- import { Module, Service, injectable } from "nodefony";
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