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

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
@@ -8,6 +8,21 @@ sert **HTTP et WebSocket dans le même contexte**, nativement.
8
8
  > (internals concis) · [`docs/`](./docs) (concepts). `@nodefony/http` ne peut PAS importer
9
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
+ ## ⚡ Le geste — un contrôleur ne s'écrit pas à la main
12
+
13
+ Dans une application, les générateurs posent le contrôleur, l'inscrivent dans `index.ts` et
14
+ écrivent son test. Depuis la racine de l'application :
15
+
16
+ ```bash
17
+ # une ressource REST adossée à une table : entité, service CRUD, contrôleur, tests, migration
18
+ npx nodefony create entity Post title:string content:text --route /api/posts
19
+ # un contrôleur seul : hello (GET + écho WebSocket, défaut), rest (CRUD) ou realtime (canal)
20
+ npx nodefony create controller Facture --kind rest --route /api/factures
21
+ ```
22
+
23
+ `--module <nom>` cible un module de l'application au lieu de sa racine. La suite de ce README
24
+ décrit ce que ces fichiers contiennent — de quoi les lire et les retoucher.
25
+
11
26
  ## Rôle
12
27
 
13
28
  - **Router** : matching de routes (path + méthode + domaine), `Route.match()`.
@@ -59,7 +59,7 @@ var AdminApiController = class AdminApiController extends Controller {
59
59
  }
60
60
  /**
61
61
  * Action générique appelée par le Resolver pour toute route admin — DUPLEX
62
- * (« API souveraine ») : même exécution sur les deux transports, seul
62
+ * (une action, deux transports) : même exécution sur les deux transports, seul
63
63
  * l'emballage diffère.
64
64
  *
65
65
  * - **HTTP** : rendu historique inchangé (`renderJson` + status + header
@@ -140,6 +140,16 @@ var Controller = class extends Service {
140
140
  this.#context = context ?? null;
141
141
  }
142
142
  /**
143
+ * Journalise au nom du module du contrôleur, pas du noyau : `moduleName`
144
+ * explicite, sinon le module posé sur la requête (`context.logModule`),
145
+ * sinon le module propriétaire du contrôleur.
146
+ *
147
+ * @param moduleName - module qui signe CETTE ligne
148
+ */
149
+ log(pci, severity, msgid, msg, moduleName) {
150
+ return super.log(pci, severity, msgid, msg, moduleName ?? this.context?.logModule ?? this.module?.name);
151
+ }
152
+ /**
143
153
  * Route de l'appel en cours. Par requête : posée par le Resolver
144
154
  * (`setRoute`). Singleton : celle du résolveur de l'APPEL quand le pont
145
155
  * `api.request` en a posé un dans l'ALS (clé `resolver` — un résolveur par
@@ -668,7 +668,9 @@ var Resolver = class {
668
668
  return this.context.render(result);
669
669
  }
670
670
  switch (this.context.type) {
671
- case "http": this.context.waitAsync = true;
671
+ case "http":
672
+ this.context.waitAsync = true;
673
+ this.unrendered = `une instance de ${(typeof result === "object" && result !== null ? result.constructor.name : void 0) ?? "classe"}`;
672
674
  }
673
675
  return;
674
676
  default: switch (this.context.type) {
@@ -677,6 +679,7 @@ var Resolver = class {
677
679
  if (this.context.isRedirect) return this.context.send();
678
680
  if (NO_BODY_STATUS.has(this.getResponseStatus())) return this.context.send();
679
681
  this.context.waitAsync = true;
682
+ this.unrendered = result === null ? "null" : typeof result;
680
683
  }
681
684
  }
682
685
  }
@@ -3,7 +3,7 @@ import { assertPageQuery } from "nodefony";
3
3
  import { HttpError } from "@nodefony/http";
4
4
  // nodefony/src/ResourceController.ts
5
5
  /**
6
- * Controller de ressource **souverain** (V4.2 — POC API souveraine, Phase 2) :
6
+ * Controller de ressource **duplex** (V4.2 — une action, deux transports) :
7
7
  * la logique métier est écrite UNE fois dans le service de ressource, les
8
8
  * actions de la sous-classe ne sont que des portes (REST, WS-RPC `invoke`,
9
9
  * GraphQL à venir) qui retournent la **valeur brute** — `returnController`
@@ -38,8 +38,12 @@ import { HttpError } from "@nodefony/http";
38
38
  * return this.listResource();
39
39
  * }
40
40
  * \@route("books-get", { path: "/{id}", requirements: { methods: ["GET", "WEBSOCKET"] } })
41
- * detail(\@Param("id") id: string) {
42
- * return this.getResource(id);
41
+ * async detail(\@Param("id") id: string) {
42
+ * // `null` rendu tel quel = « l'action répond elle-même » : la requête
43
+ * // pendrait jusqu'au délai d'expiration. La porte décide du 404.
44
+ * const found = await this.getResource(id);
45
+ * if (!found) throw new HttpError(`Book ${id} not found`, 404);
46
+ * return found;
43
47
  * }
44
48
  * }
45
49
  * ```
@@ -117,6 +121,10 @@ var ResourceController = class extends Controller {
117
121
  /**
118
122
  * Lit une entité par id — `null` si absente (la porte décide du 404).
119
123
  *
124
+ * ⚠️ Ne pas rendre ce `null` tel quel depuis une action : pour le Resolver,
125
+ * `null` veut dire « l'action répond elle-même », et la requête attend
126
+ * alors son délai d'expiration. Lever un `HttpError` 404.
127
+ *
120
128
  * `options.relations` charge les associations dans la foulée. La porte doit
121
129
  * n'y laisser passer que des relations qu'elle a DÉCLARÉES : un `include`
122
130
  * libre laisse le client nommer n'importe quelle association, donc lire des
@@ -29,6 +29,8 @@ const SYSLOG_FILTERS = {
29
29
  module: "string",
30
30
  /** Catégorie de message (msgid) — inclusion insensible à la casse. */
31
31
  msgid: "string",
32
+ /** Module exclu — égalité insensible à la casse (`NODEFONY` = le framework). */
33
+ excludeModule: "string",
32
34
  /** Protocole d'origine ; absent = les deux. */
33
35
  protocol: ["ws", "http"],
34
36
  /**
@@ -42,6 +44,10 @@ const SYSLOG_FILTERS = {
42
44
  * seconde liste ne peut se périmer.
43
45
  */
44
46
  flow: { each: Object.keys(FLOW_STEPS) },
47
+ /** Écarte les lignes d'accès de la console d'administration elle-même. */
48
+ hideConsole: "boolean",
49
+ /** Une ligne par requête (sa ligne d'accès), avec le nombre de lignes regroupées. */
50
+ groupByRequest: "boolean",
45
51
  /** Borne basse d'horodatage (epoch ms, incluse). */
46
52
  from: "int",
47
53
  /** Borne haute d'horodatage (epoch ms, incluse). */
@@ -35,7 +35,7 @@ declare class AdminApiController extends Controller {
35
35
  constructor(context: ContextType);
36
36
  /**
37
37
  * Action générique appelée par le Resolver pour toute route admin — DUPLEX
38
- * (« API souveraine ») : même exécution sur les deux transports, seul
38
+ * (une action, deux transports) : même exécution sur les deux transports, seul
39
39
  * l'emballage diffère.
40
40
  *
41
41
  * - **HTTP** : rendu historique inchangé (`renderJson` + status + header
@@ -1,4 +1,4 @@
1
- import { Service, Module, FileClass } from "nodefony";
1
+ import { Service, Module, type Pci, type Severity, type Msgid, type Message, FileClass } from "nodefony";
2
2
  import type { IRequestUser, MaybePromise } from "nodefony";
3
3
  import type { IController } from "../interfaces/index.js";
4
4
  import Route from "./Route.js";
@@ -83,6 +83,14 @@ declare class Controller extends Service implements IController {
83
83
  */
84
84
  get context(): ContextType | undefined;
85
85
  set context(context: ContextType | undefined);
86
+ /**
87
+ * Journalise au nom du module du contrôleur, pas du noyau : `moduleName`
88
+ * explicite, sinon le module posé sur la requête (`context.logModule`),
89
+ * sinon le module propriétaire du contrôleur.
90
+ *
91
+ * @param moduleName - module qui signe CETTE ligne
92
+ */
93
+ log(pci: Pci, severity?: Severity, msgid?: Msgid, msg?: Message, moduleName?: string): import("nodefony").Pdu;
86
94
  /**
87
95
  * Route de l'appel en cours. Par requête : posée par le Resolver
88
96
  * (`setRoute`). Singleton : celle du résolveur de l'APPEL quand le pont
@@ -37,6 +37,14 @@ declare class Resolver implements IResolver {
37
37
  resolve: boolean;
38
38
  variables: unknown[];
39
39
  exception?: HttpError | Error | null | undefined;
40
+ /**
41
+ * Ce que l'action a rendu quand le Resolver n'a rien pu envoyer — absent
42
+ * sinon. Lu par l'avertissement de développement
43
+ * (`IRouteResolver.unrendered`). `declare` : AUCUN champ émis — un Resolver
44
+ * naît à chaque requête, et la propriété n'apparaît que sur la branche qui
45
+ * attend déjà son délai d'expiration (`micro-resolver-field.mjs`).
46
+ */
47
+ unrendered?: string;
40
48
  acceptedProtocol: string | null;
41
49
  bypassFirewall: boolean;
42
50
  /**
@@ -67,7 +67,7 @@ export interface IResourceService<T = unknown> {
67
67
  delete?(criteria: Record<string, unknown>): Promise<number> | number;
68
68
  }
69
69
  /**
70
- * Controller de ressource **souverain** (V4.2 — POC API souveraine, Phase 2) :
70
+ * Controller de ressource **duplex** (V4.2 — une action, deux transports) :
71
71
  * la logique métier est écrite UNE fois dans le service de ressource, les
72
72
  * actions de la sous-classe ne sont que des portes (REST, WS-RPC `invoke`,
73
73
  * GraphQL à venir) qui retournent la **valeur brute** — `returnController`
@@ -102,8 +102,12 @@ export interface IResourceService<T = unknown> {
102
102
  * return this.listResource();
103
103
  * }
104
104
  * \@route("books-get", { path: "/{id}", requirements: { methods: ["GET", "WEBSOCKET"] } })
105
- * detail(\@Param("id") id: string) {
106
- * return this.getResource(id);
105
+ * async detail(\@Param("id") id: string) {
106
+ * // `null` rendu tel quel = « l'action répond elle-même » : la requête
107
+ * // pendrait jusqu'au délai d'expiration. La porte décide du 404.
108
+ * const found = await this.getResource(id);
109
+ * if (!found) throw new HttpError(`Book ${id} not found`, 404);
110
+ * return found;
107
111
  * }
108
112
  * }
109
113
  * ```
@@ -155,6 +159,10 @@ declare class ResourceController<T = unknown> extends Controller {
155
159
  /**
156
160
  * Lit une entité par id — `null` si absente (la porte décide du 404).
157
161
  *
162
+ * ⚠️ Ne pas rendre ce `null` tel quel depuis une action : pour le Resolver,
163
+ * `null` veut dire « l'action répond elle-même », et la requête attend
164
+ * alors son délai d'expiration. Lever un `HttpError` 404.
165
+ *
158
166
  * `options.relations` charge les associations dans la foulée. La porte doit
159
167
  * n'y laisser passer que des relations qu'elle a DÉCLARÉES : un `include`
160
168
  * libre laisse le client nommer n'importe quelle association, donc lire des
@@ -28,6 +28,8 @@ export declare const SYSLOG_FILTERS: {
28
28
  readonly module: "string";
29
29
  /** Catégorie de message (msgid) — inclusion insensible à la casse. */
30
30
  readonly msgid: "string";
31
+ /** Module exclu — égalité insensible à la casse (`NODEFONY` = le framework). */
32
+ readonly excludeModule: "string";
31
33
  /** Protocole d'origine ; absent = les deux. */
32
34
  readonly protocol: readonly ["ws", "http"];
33
35
  /**
@@ -45,6 +47,10 @@ export declare const SYSLOG_FILTERS: {
45
47
  readonly flow: {
46
48
  readonly each: FlowStepId[];
47
49
  };
50
+ /** Écarte les lignes d'accès de la console d'administration elle-même. */
51
+ readonly hideConsole: "boolean";
52
+ /** Une ligne par requête (sa ligne d'accès), avec le nombre de lignes regroupées. */
53
+ readonly groupByRequest: "boolean";
48
54
  /** Borne basse d'horodatage (epoch ms, incluse). */
49
55
  readonly from: "int";
50
56
  /** Borne haute d'horodatage (epoch ms, incluse). */
package/docs/admin.md CHANGED
@@ -82,7 +82,7 @@ Trois idées à retenir :
82
82
  | RBAC | _Role-Based Access Control_ : l'accès dépend des rôles de l'appelant. |
83
83
  | `ROLE_NODEFONY_*` | Rôles de la **plateforme** (admin du framework), distincts des rôles applicatifs `ROLE_*` d'un tenant. |
84
84
  | Pont / dispatch | Le controller unique qui, à chaque requête, retrouve l'endpoint et exécute son handler. |
85
- | API souveraine | Toute action admin déclare AUSSI le transport WebSocket → invocable par le pont WS-RPC `api.request`. |
85
+ | Deux transports | Toute action admin déclare AUSSI le transport WebSocket → invocable par le pont WS-RPC `api.request`. |
86
86
  | Duplex | Une même action servie sur HTTP **et** WebSocket (le différenciateur Nodefony). |
87
87
  | Catalogue / discovery | La liste des producteurs + endpoints, exposée en JSON pour que Studio bâtisse sa navigation. |
88
88
  | Playground | La console dev qui joue n'importe quel controller depuis le navigateur (`/nodefony/playground`). |
@@ -327,7 +327,7 @@ laissez-passer.
327
327
  > applicatifs `ROLE_*` d'un tenant. Un endpoint peut exiger un rôle plus fin (`role: "ROLE_…"`) ou
328
328
  > graduer l'information **dans** son handler en lisant `request.roles`.
329
329
 
330
- ## 🔌 HTTP et WebSocket — la même action (API souveraine)
330
+ ## 🔌 HTTP et WebSocket — la même action
331
331
 
332
332
  Toute action admin déclare **aussi** le transport `WEBSOCKET` (`AdminBroker.ts#AdminBroker.mountAll`) : la route est
333
333
  montée avec `[method, "WEBSOCKET"]`. Elle devient donc invocable par le pont WS-RPC `api.request`
@@ -362,6 +362,55 @@ Trois pas, du point de vue d'un module :
362
362
  **Handlers lazy** : résous tes services **dans** le handler (à la requête), jamais au montage — un
363
363
  service désactivable renvoie alors `503` proprement au lieu de casser le boot.
364
364
 
365
+ ### Ce que publie un endpoint — l'état réel, aucun secret, ses capacités déclarées
366
+
367
+ Tout ce que renvoie un handler part tel quel vers la console, le pont socket et les agents. Trois
368
+ règles en découlent.
369
+
370
+ **1. Un DTO ne porte aucun secret : il en dit la présence.** Un mot de passe, une clé ou un jeton
371
+ se décrivent par un booléen. Le résumé d'un utilisateur expose `hasPassword`, jamais le hash
372
+ (`UserAdminApi.ts#IUserSummary`). La description du pare-feu expose `synchronizerToken: boolean`
373
+ (`IFirewallDescription.ts#IFirewallDescription`). La redaction se fait **en construisant** le DTO,
374
+ champ par champ. Copier l'objet puis retirer les clés sensibles laisserait passer la prochaine qui
375
+ sera ajoutée. Un test le prouve : `JSON.stringify(dto)` ne contient pas la valeur sentinelle posée
376
+ dans la configuration.
377
+
378
+ **2. Une description montre ce qui TOURNE, pas la configuration relue.** Une méthode `describe()`
379
+ lit les structures que le service a bâties au démarrage
380
+ (`IFirewall.ts#IFirewall.describe`, `IBackplane.ts#IBackplane.describe`). Un authenticator mal
381
+ orthographié dans la configuration n'y figure pas, puisqu'il n'est pas monté. Relire la
382
+ configuration montrerait l'intention, et la console afficherait comme active une défense qui ne
383
+ tourne pas.
384
+
385
+ **3. Une capacité de liste non déclarée est refusée en `400`, jamais ignorée.** Tri, filtres,
386
+ recherche et facettes se déclarent dans `page` (`IAdminApi.ts#IAdminPageCapabilities`), et le
387
+ handler lit **la même donnée** : il passe ses champs triables à `parsePageQuery()`
388
+ (`pageQuery.ts#parsePageQuery`) et son vocabulaire de filtres à `parseFilters()`
389
+ (`pageFilters.ts#parseFilters`). Ces deux lecteurs refusent en `400` un tri hors liste, un `?q=`
390
+ sur un endpoint qui ne cherche pas, une valeur mal formée et un paramètre que personne ne
391
+ reconnaît. Lire `request.query.order` à la main renvoie une page non triée que le client prend pour
392
+ triée. Avec un filtre mal orthographié, c'est la collection entière qui revient.
393
+
394
+ ```typescript ignore
395
+ import { parseFilters, parsePageQuery, type IAdminEndpoint } from "nodefony";
396
+
397
+ // Une constante, deux lectures : le catalogue et le handler ne peuvent pas diverger.
398
+ const SORTABLE = ["createdAt", "total"] as const;
399
+ const FILTERS = { status: ["paid", "pending", "refunded"] } as const;
400
+
401
+ const listOrders: IAdminEndpoint = {
402
+ path: "orders",
403
+ method: "GET",
404
+ summary: "Commandes, paginées.",
405
+ page: { sortable: () => SORTABLE, filters: FILTERS },
406
+ handler: async (request) => {
407
+ const query = parsePageQuery(request.query, { sortable: SORTABLE }); // ?order=x → 400
408
+ const filters = parseFilters(request.query, FILTERS); // ?status=zzz, ?stauts= → 400
409
+ return orders.listPage({ ...query, ...filters });
410
+ },
411
+ };
412
+ ```
413
+
365
414
  ### Les producteurs réels (à imiter, sans les redocumenter)
366
415
 
367
416
  Le broker lui-même est déclaré comme service du framework (`index.ts`). Les producteurs internes
@@ -306,7 +306,7 @@ class ChatController extends Controller {
306
306
  | Nombre d'appels d'action | 1 | 1 au handshake (`WebsocketContext.handle()`, `WebsocketContext.ts#WebsocketContext.handle`) + 1 par frame (`handleMessage()`, `WebsocketContext.ts#WebsocketContext.handleMessage`) |
307
307
  | Argument de l'action | Variables de route (ou paramètres décorés) | Idem + **le message** en dernier argument (`WebsocketContext.ts#WebsocketContext.handleMessage`) |
308
308
  | Rendu d'un `return` | Corps de la réponse | Frame envoyée sur la socket |
309
- | Échec | Statut HTTP + corps d'erreur | **Code de fermeture** RFC 6455 (401/403 → 1008, 5xx → 1011, autre → 4004) ; chemin sans route WebSocket → **404 HTTP avant l'ouverture** |
309
+ | Échec | Statut HTTP + corps d'erreur | **Au handshake** : une garde refusée répond par un **statut HTTP** (401/403) **avant l'ouverture** (`Resolver.authorizeAction()`, `Resolver.ts#Resolver.authorizeAction`), chemin sans route WebSocket → **404** ; **après l'ouverture** : un **code de fermeture** RFC 6455 (401/403 → 1008, 5xx → 1011, autre → 4004) |
310
310
  | `initialize()` | Singleton : **une fois**, à la création · `@Scope("request")` : à chaque requête | Singleton : une fois, à la création · `@Scope("request")` : **une fois par connexion**, au handshake |
311
311
 
312
312
  La réutilisation de l'instance vient du cache posé sur le container du contexte
@@ -334,7 +334,7 @@ Deux points qu'un dev découvre sinon à ses dépens :
334
334
  méthodes, ne serait-ce que pour l'en-tête `Allow`.
335
335
 
336
336
  Le second argument accepte les options de route non redondantes — `Omit<RouteOptions, "path" | "method">`
337
- (`routerDecorators.ts#route`), soit `defaults`, `requirements`, `host`, `bypassFirewall`
337
+ (`routerDecorators.ts#route`), notamment `defaults`, `requirements`, `host`, `bypassFirewall` et `fallback`
338
338
  (`RouteOptions`, `Route.ts#RouteOptions`) :
339
339
 
340
340
  ```typescript
@@ -342,6 +342,12 @@ Le second argument accepte les options de route non redondantes — `Omit<RouteO
342
342
  async index(@Param("page") page: string) { /* … */ }
343
343
  ```
344
344
 
345
+ `requirements.scheme` se compare au scheme **effectif** de la requête — celui qu'annonce un proxy de
346
+ confiance compris : hors exigence, la route répond **403**, jamais une redirection
347
+ (`Route.ts#Route.matchRequirements`). Rediriger `http` vers `https` relève de la bordure (HSTS, proxy
348
+ inverse), pas du routage. `fallback: true` fait de la route un **repli**, servi seulement si aucune
349
+ autre ne correspond — voir [le routage](routing.md#-ordre-de-résolution--trois-situations).
350
+
345
351
  ### Paramètres — ce que l'action reçoit
346
352
 
347
353
  Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts#paramDecoratorFactory`) sauf
@@ -619,14 +619,32 @@ registerIdempotencyStore("mon-backend", (ctx) => {
619
619
  });
620
620
  ```
621
621
 
622
- Trois règles héritées du code existant, à respecter sous peine de double-effet :
622
+ Quatre règles héritées du code existant, à respecter sous peine de double-effet :
623
623
 
624
624
  1. **`begin` doit être atomique côté backend.** Un `GET` puis `SET` séparés laissent deux retries
625
625
  concurrents obtenir `fresh` — précisément ce que l'idempotence doit empêcher.
626
626
  2. **Ne jamais rendre `fresh` hors réservation gagnée.** En cas de doute (course, état illisible),
627
- rendre `in-flight` : le client réessaiera, c'est sans danger.
627
+ rendre `in-flight` : le client réessaiera, c'est sans danger. La seule exception est la coupure
628
+ du backend, et elle se décide (règle 4).
628
629
  3. **`complete` doit préserver l'empreinte** de l'entrée in-flight, sinon un rejeu avec un autre
629
630
  payload après complétion ne produit plus de 422.
631
+ 4. **Une fabrique qui ne peut pas construire son store LÈVE — elle ne rend jamais un store qui se
632
+ tait.** Module manquant, client du mauvais type, configuration incohérente : tout ce qui est
633
+ certain dès le démarrage se refuse dans la fabrique. Le framework en fait un arrêt de boot en
634
+ production, et un `WARNING` suivi d'un repli sur la mémoire en développement
635
+ ([contrat de dégradation](#le-contrat-de-dégradation--fail-loud-jamais-silencieux)). Un store
636
+ construit malgré tout, qui garde chaque appel par `if (!connected) return`, échappe à ce
637
+ contrat : le registre affiche ton backend pendant que plus rien n'est dédoublonné. C'est un
638
+ échec **ouvert** — la garde a disparu, et rien ne le dit.
639
+
640
+ La connexion, elle, ne peut pas être exigée là : la fabrique est appelée à `onKernelBoot`
641
+ (`index.ts#Framework.onKernelBoot`), avant que l'ORM ne soit connecté. Elle se résout donc à
642
+ chaque appel, comme le fait `DrizzleIdempotencyStore.from()`
643
+ (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.from`), et la conduite pendant une coupure
644
+ est un **choix à écrire**. Les stores livrés rendent `fresh` — exécuter sans dédoublonnage
645
+ plutôt que bloquer — et l'annoncent comme un compromis
646
+ ([`redis`](#redis--le-choix-cluster)). Si ton domaine ne tolère aucun double effet, rends
647
+ `in-flight` (le client réessaiera) ou lève : la requête échoue, rien n'est exécuté deux fois.
630
648
 
631
649
  Fonctions du registre : `registerIdempotencyStore()` (`idempotencyStoreRegistry.ts#registerIdempotencyStore`),
632
650
  `getIdempotencyStoreFactory()` (`idempotencyStoreRegistry.ts#getIdempotencyStoreFactory`), `listIdempotencyStores()`
package/docs/routing.md CHANGED
@@ -172,7 +172,7 @@ class CatalogController extends Controller {
172
172
  path: "/files/*",
173
173
  requirements: { methods: ["GET"] },
174
174
  })
175
- async files(rest: string) {
175
+ async download(rest: string) {
176
176
  return this.renderJson({ rest });
177
177
  }
178
178
 
@@ -363,6 +363,20 @@ le suit — un `@All("*")` déclaré tôt masque le reste du contrôleur.
363
363
  > ses propres routes, quel que soit l'ordre d'écriture (`hasMagic`, `routerDecorators.ts#controller`). Ça ne
364
364
  > vaut **que** pour `"*"` seul : `/files/*` reste ordonné comme les autres.
365
365
 
366
+ Pour un repli qui doit céder **entre modules** — la page d'une application monopage, servie sur
367
+ `/app/{page}` ou `/app/*` —, l'option `fallback: true` range la route après toutes les autres, dans
368
+ leur ordre relatif, au moment de construire l'index (`Route.ts#RouteOptions.fallback`) : une route
369
+ littérale ou dynamique de même forme, déclarée par un module chargé après, garde la main. Aucun coût
370
+ par requête. Sans ce drapeau, un repli déclaré tôt capture en silence les routes des modules
371
+ suivants.
372
+
373
+ ```ts ignore
374
+ @Get("/app/{page}", { fallback: true })
375
+ renderSpa(): unknown {
376
+ return this.renderApp();
377
+ }
378
+ ```
379
+
366
380
  ### Situation 2 — le même chemin, deux méthodes
367
381
 
368
382
  Deux routes peuvent partager un chemin et se distinguer par la méthode. La passe 1 essaie la première,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/framework",
3
- "version": "10.0.0-beta.4",
3
+ "version": "10.0.0-beta.5",
4
4
  "description": "Le modèle de programmation Nodefony : routeur, contrôleurs, décorateurs et vues — HTTP et WebSocket dans le même contexte",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "main": "./dist/index.js",
@@ -49,11 +49,11 @@
49
49
  "tslib": "2.8.1"
50
50
  },
51
51
  "devDependencies": {
52
- "@nodefony/http": "^10.0.0-beta.4",
52
+ "@nodefony/http": "^10.0.0-beta.5",
53
53
  "@types/chai": "5.2.3",
54
54
  "@types/node": "26.6.5",
55
55
  "@vitest/coverage-v8": "5.0.5",
56
- "nodefony": "^10.0.0-beta.4",
56
+ "nodefony": "^10.0.0-beta.5",
57
57
  "tsx": "4.23.15",
58
58
  "vitest": "5.0.5"
59
59
  },
@@ -61,8 +61,8 @@
61
61
  "readmeFilename": "README.md",
62
62
  "contributors": [],
63
63
  "peerDependencies": {
64
- "@nodefony/http": "^10.0.0-beta.4",
65
- "nodefony": "^10.0.0-beta.4",
64
+ "@nodefony/http": "^10.0.0-beta.5",
65
+ "nodefony": "^10.0.0-beta.5",
66
66
  "zod": "^4.6.7"
67
67
  },
68
68
  "files": [