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

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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ sert **HTTP et WebSocket dans le même contexte**, nativement.
6
6
 
7
7
  > Docs IA (dans le dépôt) : [`CLAUDE.md`](https://github.com/nodefony/nodefony-core/blob/main/src/packages/@nodefony/framework/CLAUDE.md) (instructions session) · [`MEMORY.md`](https://github.com/nodefony/nodefony-core/blob/main/src/packages/@nodefony/framework/MEMORY.md)
8
8
  > (internals concis) · [`docs/`](./docs) (concepts). `@nodefony/http` ne peut PAS importer
9
- > ce module (dépendance circulaire) — l'accès se fait via `(context as any)?.resolver`.
9
+ > ce module (dépendance circulaire) — l'accès se fait via `context.resolver`, typé par le contrat `IRouteResolver` (défini par http, implémenté par framework).
10
10
 
11
11
  ## Rôle
12
12
 
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
package/dist/index.js CHANGED
@@ -7,8 +7,8 @@ import Route from "./nodefony/src/Route.js";
7
7
  import Controller from "./nodefony/src/Controller.js";
8
8
  import { All, Anonymous, Body, BypassFirewall, Cookie, Csp, CsrfExempt, CsrfProtect, CurrentUser, Delete, Domain, Get, Head, Header, Headers, HttpCode, Idempotent, IsGranted, Options, Param, Patch, Post, Put, Query, Redirect, Req, RequireScope, Res, Scope, Session, UploadedFile, UploadedFiles, UseSession, controller, controllers, route, routeExpectsBodyStream } from "./nodefony/decorators/routerDecorators.js";
9
9
  import Resolver from "./nodefony/src/Resolver.js";
10
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
11
- import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
10
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
11
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
12
12
  import router_default from "./nodefony/service/router.js";
13
13
  import ResourceController from "./nodefony/src/ResourceController.js";
14
14
  import AdminApiController from "./nodefony/controller/AdminApiController.js";
@@ -18,7 +18,8 @@ let mounted = false;
18
18
  * zone data plane `^/nodefony/[^/]+/api(/|$)` → **session BFF requise**. Le porteur
19
19
  * est TOUJOURS l'utilisateur courant (`authFlow.me`), jamais un paramètre — on ne
20
20
  * crée/révoque jamais une clé pour autrui. Montés seulement si le service `apiKeys`
21
- * existe (security chargé + clés activées) → 404, zéro surface, sinon.
21
+ * existe (security chargé) — sans security, 404 et zéro surface ; clés
22
+ * désactivées, les routes répondent 503.
22
23
  *
23
24
  * Erreurs mappées par DUCK-TYPING sur `code` (400/409/503) — framework ne peut pas
24
25
  * importer les classes d'erreur de security.
@@ -16,8 +16,8 @@ function parseScope(scope) {
16
16
  * - `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation
17
17
  *
18
18
  * Montés par le module framework UNIQUEMENT si le service `tokenService` existe
19
- * (module security chargé + JWT activé) — sans lui, les routes n'existent pas
20
- * (404, zéro surface).
19
+ * (module security chargé) — sans lui, les routes n'existent pas (404, zéro
20
+ * surface). JWT désactivé ou store indisponible : les routes répondent 503.
21
21
  *
22
22
  * Le JWT part en **réponse JSON** (Bearer), JAMAIS en cookie ni en URL (anti
23
23
  * fuite / non-révocable). Erreurs mappées par DUCK-TYPING sur `code` (401/429) —
@@ -17,7 +17,8 @@ let mounted = false;
17
17
  * plane `^/nodefony/[^/]+/api(/|$)` → **session BFF requise**. Le sujet est
18
18
  * TOUJOURS l'utilisateur courant (`authFlow.me`), jamais un paramètre — on n'active
19
19
  * /ne désactive jamais le 2FA d'autrui (anti-IDOR). Montés seulement si le service
20
- * `totp` existe (security chargé + 2FA activé) → 404, zéro surface, sinon.
20
+ * `totp` existe (security chargé) — sans security, 404 et zéro surface ; 2FA
21
+ * désactivé, les routes répondent 503.
21
22
  */
22
23
  var TotpController = class extends Controller {
23
24
  constructor(context) {
@@ -1,5 +1,5 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
3
3
  import router_default from "./router.js";
4
4
  import AdminApiController from "../controller/AdminApiController.js";
5
5
  import { ADMIN_DEFAULT_ROLE, Module, Service, injectable, resolveAdminRole } from "nodefony";
@@ -1,5 +1,5 @@
1
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
3
3
  import { Module, Service, assertPageQuery, injectable } from "nodefony";
4
4
  //#region nodefony/service/IdempotencyStore.ts
5
5
  const serviceName = "idempotencyStore";
@@ -1,8 +1,8 @@
1
1
  import Route from "../src/Route.js";
2
2
  import { routeExpectsBodyStream } from "../decorators/routerDecorators.js";
3
3
  import Resolver from "../src/Resolver.js";
4
- import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
5
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.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
6
  import { Injector, Module, Service, injectable, isPromise } from "nodefony";
7
7
  import { HttpError, isDomainAllowed } from "@nodefony/http";
8
8
  //#region nodefony/service/router.ts
@@ -156,11 +156,13 @@ let Router = class Router extends Service {
156
156
  let di = 0;
157
157
  while (li < litCount || di < dynCount) {
158
158
  let route;
159
+ let fromLiterals = false;
159
160
  const literal = literals[li];
160
161
  const candidate = dynamics[di];
161
162
  if (literal !== void 0 && (candidate === void 0 || literal.pos < candidate.pos)) {
162
163
  li++;
163
164
  route = literal.route;
165
+ fromLiterals = true;
164
166
  } else if (candidate === void 0) break;
165
167
  else {
166
168
  di++;
@@ -168,7 +170,7 @@ let Router = class Router extends Service {
168
170
  route = candidate.route;
169
171
  }
170
172
  try {
171
- if (resolver.match(route, context, cleanPath)) {
173
+ if (resolver.match(route, context, cleanPath, fromLiterals)) {
172
174
  routeNoticePromoted ??= this.kernel?.environment !== "production";
173
175
  if (routeNoticePromoted) this.log(`Match route : ${route.name}`, "NOTICE");
174
176
  resolver.exception = void 0;
@@ -78,8 +78,8 @@ var Resolver = class {
78
78
  this.context = context;
79
79
  this.injector = context.container?.get("injector") ?? null;
80
80
  }
81
- match(route, context, cleanPath) {
82
- const match = route.match(context, cleanPath, this.methodOverride ?? void 0);
81
+ match(route, context, cleanPath, literal) {
82
+ const match = route.match(context, cleanPath, this.methodOverride ?? void 0, literal);
83
83
  if (match) {
84
84
  this.variables = match;
85
85
  this.route = route;
@@ -203,10 +203,29 @@ var Route = class Route {
203
203
  if (!reqUrl) return;
204
204
  return stripTrailingSlashes(reqUrl.pathname);
205
205
  }
206
- match(context, cleanPath, methodOverride) {
206
+ /**
207
+ * Confronte la requête à cette route : chemin, hôte, puis exigences.
208
+ *
209
+ * @param context - contexte HTTP/WS courant.
210
+ * @param cleanPath - pathname déjà normalisé par l'appelant (sinon recalculé).
211
+ * @param methodOverride - méthode logique du pont WS-RPC (cf `Resolver`).
212
+ * @param literal - l'appelant GARANTIT que le chemin de la route ne porte
213
+ * aucune syntaxe de motif (`{}`, `*`…) — c'est le cas d'une route trouvée
214
+ * par l'index des littérales du `Router`. Le motif n'est alors pas exécuté
215
+ * quand le chemin est identique à la lettre.
216
+ * @returns les valeurs capturées (tableau + accès par nom), ou `null`/`undefined`
217
+ * quand le chemin ne correspond pas.
218
+ * @throws 403/405/1002 quand le chemin correspond mais pas l'hôte ou les exigences.
219
+ */
220
+ match(context, cleanPath, methodOverride, literal) {
207
221
  let res;
208
222
  if (context.request && this.pattern) {
209
223
  const url = cleanPath ?? Route.cleanPathname(context);
224
+ if (literal === true && url === this.path && !this.#hasPositionalDefaults()) {
225
+ this.matchHostname(context);
226
+ this.matchRequirements(context, methodOverride);
227
+ return [];
228
+ }
210
229
  if (url !== void 0) res = url.match(this.pattern);
211
230
  }
212
231
  if (!res) return res;
@@ -307,6 +326,10 @@ var Route = class Route {
307
326
  if (compiled) (this.varRegexp ??= Object.create(null))[k] = compiled;
308
327
  }
309
328
  }
329
+ #hasPositionalDefaults() {
330
+ for (const def in this.defaults) if (def !== "controller") return true;
331
+ return false;
332
+ }
310
333
  hydrateDefaultParameters(res) {
311
334
  if (this.variables.length) for (let i = 0; i < this.variables.length; i++) {
312
335
  const name = this.variables[i];
@@ -1,5 +1,5 @@
1
1
  import { basename, dirname, extname, join } from "node:path";
2
- import { findProjectRoot, resolveSymbolsFile } from "nodefony";
2
+ import { findProjectRoot, packageManagerCommandLines, packageManagerExecArgs, portableSpawn, resolvePackageManager, resolveSymbolsFile } from "nodefony";
3
3
  import { existsSync } from "node:fs";
4
4
  import { promisify } from "node:util";
5
5
  import { readFile, readdir, stat } from "node:fs/promises";
@@ -653,50 +653,54 @@ async function listTestGroups(modulePath) {
653
653
  * config du module (la liste `reporter` dupliquée par module divergeait).
654
654
  * - sinon (cœur : monocart, pas de `vitest.config.ts`) → `npm run coverage`.
655
655
  *
656
+ * Le binaire et le script passent par le gestionnaire de paquets du PROJET :
657
+ * `npx` n'existe pas chez bun, et sous pnpm il chercherait hors de l'arbre que
658
+ * le verrou décrit.
659
+ *
656
660
  * @param modulePath - racine du module (où vit `vitest.config.ts`)
657
661
  * @param file - chemin relatif d'UN fichier de test, déjà validé par l'appelant
662
+ * @param manager - gestionnaire du projet ; défaut : résolu depuis la racine
663
+ * de l'application qui contient le module
658
664
  * @returns commande, arguments en tableau (spawn sans shell) et libellé du mode
659
665
  */
660
- function testRunCommand(modulePath, file) {
666
+ function testRunCommand(modulePath, file, manager = resolvePackageManager({ dir: findProjectRoot(modulePath) ?? modulePath }).name) {
661
667
  const hasVitest = existsSync(join(modulePath, "vitest.config.ts"));
662
668
  if (file && hasVitest) return {
663
- cmd: "npx",
664
- args: [
665
- "vitest",
666
- "run",
667
- file
668
- ],
669
+ cmd: manager,
670
+ args: packageManagerExecArgs(manager, "vitest", ["run", file]),
669
671
  mode: `vitest run ${file}`
670
672
  };
671
673
  if (hasVitest) return {
672
- cmd: "npx",
673
- args: [
674
- "vitest",
674
+ cmd: manager,
675
+ args: packageManagerExecArgs(manager, "vitest", [
675
676
  "run",
676
677
  "--coverage",
677
678
  "--coverage.reporter=text-summary",
678
679
  "--coverage.reporter=json-summary",
679
680
  "--coverage.reporter=lcov",
680
681
  "--coverage.reportsDirectory=.coverage"
681
- ],
682
+ ]),
682
683
  mode: "vitest run --coverage (reporters forcés)"
683
684
  };
684
685
  return {
685
- cmd: "npm",
686
+ cmd: manager,
686
687
  args: ["run", "coverage"],
687
- mode: "npm run coverage (suite complète)"
688
+ mode: `${packageManagerCommandLines(manager).run("coverage")} (suite complète)`
688
689
  };
689
690
  }
690
691
  /**
691
692
  * Lance les tests d'un module et renvoie un résumé (pass/fail/durée + tail).
692
693
  *
693
- * - 1 fichier (module vitest) → `npx vitest run <file>` (rapide, pass/fail).
694
- * - sinon → `npm run coverage` (suite complète + refresh coverage ; marche
695
- * pour vitest comme pour monocart/core).
694
+ * - 1 fichier (module vitest) → `vitest run <file>` (rapide, pass/fail).
695
+ * - sinon → le script `coverage` (suite complète + refresh coverage ; marche
696
+ * pour vitest comme pour le cœur).
697
+ * Les deux par le gestionnaire du projet (cf {@link testRunCommand}).
696
698
  *
697
699
  * ⚠️ EXÉCUTE un process — appelé UNIQUEMENT derrière le garde dev-only de
698
- * l'endpoint (cf KernelAdminApi). spawn sans shell + args en tableau (pas
699
- * d'injection shell). `file` validé en amont (suffixe .test.ts, pas de `..`).
700
+ * l'endpoint (cf KernelAdminApi). `portableSpawn` : jamais de `shell`, args
701
+ * en tableau (pas d'injection), et le `.cmd` du gestionnaire se lance sous
702
+ * Windows — un `spawn("npx")` nu y rendait `ENOENT`. `file` validé en amont
703
+ * (suffixe .test.ts, pas de `..`).
700
704
  */
701
705
  function runModuleTests(modulePath, file) {
702
706
  const { cmd, args, mode } = testRunCommand(modulePath, file);
@@ -709,9 +713,11 @@ function runModuleTests(modulePath, file) {
709
713
  };
710
714
  let child;
711
715
  try {
712
- child = spawn(cmd, args, {
716
+ const run = portableSpawn(cmd, args);
717
+ child = spawn(run.file, run.args, {
713
718
  cwd: modulePath,
714
- env: process.env
719
+ env: process.env,
720
+ windowsVerbatimArguments: run.windowsVerbatimArguments
715
721
  });
716
722
  } catch (e) {
717
723
  return resolve({
@@ -16,7 +16,8 @@ import Controller from "../src/Controller.js";
16
16
  * zone data plane `^/nodefony/[^/]+/api(/|$)` → **session BFF requise**. Le porteur
17
17
  * est TOUJOURS l'utilisateur courant (`authFlow.me`), jamais un paramètre — on ne
18
18
  * crée/révoque jamais une clé pour autrui. Montés seulement si le service `apiKeys`
19
- * existe (security chargé + clés activées) → 404, zéro surface, sinon.
19
+ * existe (security chargé) — sans security, 404 et zéro surface ; clés
20
+ * désactivées, les routes répondent 503.
20
21
  *
21
22
  * Erreurs mappées par DUCK-TYPING sur `code` (400/409/503) — framework ne peut pas
22
23
  * importer les classes d'erreur de security.
@@ -23,8 +23,8 @@ export interface ITokenIssuer {
23
23
  * - `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation
24
24
  *
25
25
  * Montés par le module framework UNIQUEMENT si le service `tokenService` existe
26
- * (module security chargé + JWT activé) — sans lui, les routes n'existent pas
27
- * (404, zéro surface).
26
+ * (module security chargé) — sans lui, les routes n'existent pas (404, zéro
27
+ * surface). JWT désactivé ou store indisponible : les routes répondent 503.
28
28
  *
29
29
  * Le JWT part en **réponse JSON** (Bearer), JAMAIS en cookie ni en URL (anti
30
30
  * fuite / non-révocable). Erreurs mappées par DUCK-TYPING sur `code` (401/429) —
@@ -16,7 +16,8 @@ import Controller from "../src/Controller.js";
16
16
  * plane `^/nodefony/[^/]+/api(/|$)` → **session BFF requise**. Le sujet est
17
17
  * TOUJOURS l'utilisateur courant (`authFlow.me`), jamais un paramètre — on n'active
18
18
  * /ne désactive jamais le 2FA d'autrui (anti-IDOR). Montés seulement si le service
19
- * `totp` existe (security chargé + 2FA activé) → 404, zéro surface, sinon.
19
+ * `totp` existe (security chargé) — sans security, 404 et zéro surface ; 2FA
20
+ * désactivé, les routes répondent 503.
20
21
  */
21
22
  declare class TotpController extends Controller {
22
23
  #private;
@@ -71,7 +71,7 @@ declare class Resolver implements IResolver {
71
71
  */
72
72
  messageInvocation: boolean;
73
73
  constructor(context: ContextType);
74
- match(route: Route, context: ContextType, cleanPath?: string): ((string | null)[] & Record<string, unknown>) | null | undefined;
74
+ match(route: Route, context: ContextType, cleanPath?: string, literal?: boolean): ((string | null)[] & Record<string, unknown>) | null | undefined;
75
75
  /**
76
76
  * Snapshot per-requête des variables de route matchées (`{name}` → valeur,
77
77
  * + wildcard `*` éventuel). Construit depuis les valeurs de CETTE requête
@@ -151,7 +151,21 @@ declare class Route implements IRoute {
151
151
  * @returns le pathname sans slash final, ou `undefined` si la requête n'a pas d'URL.
152
152
  */
153
153
  static cleanPathname(context: ContextType): string | undefined;
154
- match(context: ContextType, cleanPath?: string, methodOverride?: string): ((string | null)[] & Record<string, unknown>) | null | undefined;
154
+ /**
155
+ * Confronte la requête à cette route : chemin, hôte, puis exigences.
156
+ *
157
+ * @param context - contexte HTTP/WS courant.
158
+ * @param cleanPath - pathname déjà normalisé par l'appelant (sinon recalculé).
159
+ * @param methodOverride - méthode logique du pont WS-RPC (cf `Resolver`).
160
+ * @param literal - l'appelant GARANTIT que le chemin de la route ne porte
161
+ * aucune syntaxe de motif (`{}`, `*`…) — c'est le cas d'une route trouvée
162
+ * par l'index des littérales du `Router`. Le motif n'est alors pas exécuté
163
+ * quand le chemin est identique à la lettre.
164
+ * @returns les valeurs capturées (tableau + accès par nom), ou `null`/`undefined`
165
+ * quand le chemin ne correspond pas.
166
+ * @throws 403/405/1002 quand le chemin correspond mais pas l'hôte ou les exigences.
167
+ */
168
+ match(context: ContextType, cleanPath?: string, methodOverride?: string, literal?: boolean): ((string | null)[] & Record<string, unknown>) | null | undefined;
155
169
  /**
156
170
  * Compile the route into a regular expression pattern.
157
171
  * @returns The compiled regular expression pattern.
@@ -1,3 +1,4 @@
1
+ import type { IPackageManagerResolution } from "nodefony";
1
2
  /**
2
3
  * Frontmatter d'un fichier de doc module. Tous les champs sont optionnels —
3
4
  * un `.md` sans frontmatter reste lisible (titre dérivé du premier `# H1`).
@@ -297,21 +298,30 @@ export interface TestRunCommand {
297
298
  * config du module (la liste `reporter` dupliquée par module divergeait).
298
299
  * - sinon (cœur : monocart, pas de `vitest.config.ts`) → `npm run coverage`.
299
300
  *
301
+ * Le binaire et le script passent par le gestionnaire de paquets du PROJET :
302
+ * `npx` n'existe pas chez bun, et sous pnpm il chercherait hors de l'arbre que
303
+ * le verrou décrit.
304
+ *
300
305
  * @param modulePath - racine du module (où vit `vitest.config.ts`)
301
306
  * @param file - chemin relatif d'UN fichier de test, déjà validé par l'appelant
307
+ * @param manager - gestionnaire du projet ; défaut : résolu depuis la racine
308
+ * de l'application qui contient le module
302
309
  * @returns commande, arguments en tableau (spawn sans shell) et libellé du mode
303
310
  */
304
- export declare function testRunCommand(modulePath: string, file?: string): TestRunCommand;
311
+ export declare function testRunCommand(modulePath: string, file?: string, manager?: IPackageManagerResolution["name"]): TestRunCommand;
305
312
  /**
306
313
  * Lance les tests d'un module et renvoie un résumé (pass/fail/durée + tail).
307
314
  *
308
- * - 1 fichier (module vitest) → `npx vitest run <file>` (rapide, pass/fail).
309
- * - sinon → `npm run coverage` (suite complète + refresh coverage ; marche
310
- * pour vitest comme pour monocart/core).
315
+ * - 1 fichier (module vitest) → `vitest run <file>` (rapide, pass/fail).
316
+ * - sinon → le script `coverage` (suite complète + refresh coverage ; marche
317
+ * pour vitest comme pour le cœur).
318
+ * Les deux par le gestionnaire du projet (cf {@link testRunCommand}).
311
319
  *
312
320
  * ⚠️ EXÉCUTE un process — appelé UNIQUEMENT derrière le garde dev-only de
313
- * l'endpoint (cf KernelAdminApi). spawn sans shell + args en tableau (pas
314
- * d'injection shell). `file` validé en amont (suffixe .test.ts, pas de `..`).
321
+ * l'endpoint (cf KernelAdminApi). `portableSpawn` : jamais de `shell`, args
322
+ * en tableau (pas d'injection), et le `.cmd` du gestionnaire se lance sous
323
+ * Windows — un `spawn("npx")` nu y rendait `ENOENT`. `file` validé en amont
324
+ * (suffixe .test.ts, pas de `..`).
315
325
  */
316
326
  export declare function runModuleTests(modulePath: string, file?: string): Promise<TestRunResult>;
317
327
  /**