@nodefony/framework 10.0.0-alpha.9 → 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.
Files changed (53) hide show
  1. package/README.md +5 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +4 -4
  5. package/dist/nodefony/controller/AdminApiController.js +4 -3
  6. package/dist/nodefony/controller/ApiKeyController.js +3 -2
  7. package/dist/nodefony/controller/OAuth2Controller.js +11 -6
  8. package/dist/nodefony/controller/SessionAuthController.js +3 -3
  9. package/dist/nodefony/controller/TokenAuthController.js +4 -4
  10. package/dist/nodefony/controller/TotpController.js +7 -5
  11. package/dist/nodefony/controller/WebAuthnController.js +4 -4
  12. package/dist/nodefony/decorators/routerDecorators.js +41 -35
  13. package/dist/nodefony/service/AdminBroker.js +2 -2
  14. package/dist/nodefony/service/IdempotencyStore.js +4 -5
  15. package/dist/nodefony/service/router.js +78 -26
  16. package/dist/nodefony/src/Controller.js +184 -78
  17. package/dist/nodefony/src/FrameworkAdminApi.js +19 -6
  18. package/dist/nodefony/src/KernelAdminApi.js +49 -49
  19. package/dist/nodefony/src/PlaygroundAdminApi.js +3 -3
  20. package/dist/nodefony/src/RedisIdempotencyStore.js +2 -2
  21. package/dist/nodefony/src/Resolver.js +221 -65
  22. package/dist/nodefony/src/Route.js +62 -39
  23. package/dist/nodefony/src/SyslogAdminApi.js +4 -4
  24. package/dist/nodefony/src/docsReader.js +49 -39
  25. package/dist/nodefony/src/scopeCatalog.js +1 -1
  26. package/dist/nodefony/src/singletonGuard.js +76 -0
  27. package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -1
  28. package/dist/types/nodefony/controller/BenchController.d.ts +2 -2
  29. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +3 -3
  30. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +1 -1
  31. package/dist/types/nodefony/controller/SessionAuthController.d.ts +2 -1
  32. package/dist/types/nodefony/controller/TokenAuthController.d.ts +2 -2
  33. package/dist/types/nodefony/controller/TotpController.d.ts +2 -1
  34. package/dist/types/nodefony/decorators/routerDecorators.d.ts +49 -43
  35. package/dist/types/nodefony/interfaces/IController.d.ts +9 -7
  36. package/dist/types/nodefony/interfaces/IResolver.d.ts +10 -10
  37. package/dist/types/nodefony/interfaces/IRoute.d.ts +13 -13
  38. package/dist/types/nodefony/service/AdminBroker.d.ts +3 -3
  39. package/dist/types/nodefony/service/router.d.ts +33 -7
  40. package/dist/types/nodefony/src/Controller.d.ts +97 -24
  41. package/dist/types/nodefony/src/Resolver.d.ts +155 -16
  42. package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
  43. package/dist/types/nodefony/src/Route.d.ts +39 -23
  44. package/dist/types/nodefony/src/docsReader.d.ts +19 -9
  45. package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
  46. package/docs/admin.md +1 -1
  47. package/docs/controller.md +139 -121
  48. package/docs/decorateurs.md +102 -98
  49. package/docs/idempotence.md +23 -21
  50. package/docs/index.md +1 -1
  51. package/docs/routing.md +60 -53
  52. package/docs/templates.md +14 -14
  53. package/package.json +10 -11
package/docs/routing.md CHANGED
@@ -55,7 +55,7 @@ Trois faits à retenir avant tout le reste :
55
55
  (`HttpError("Not Found", 404)`, `http-kernel.ts:798`).
56
56
  3. **Le chemin est vérifié avant la méthode, et le domaine entre les deux** — c'est ce qui produit un
57
57
  `403` plutôt qu'un `405` bavard quand la route appartient à un autre vhost (`Route.match()`,
58
- `Route.ts:327`).
58
+ `Route.ts:343`).
59
59
 
60
60
  ## 📖 Lexique
61
61
 
@@ -95,7 +95,7 @@ tranchent différemment :
95
95
  **L'arbitrage est explicite, pas calculé.** Beaucoup de routeurs trient les routes par « spécificité »
96
96
  (le motif le plus précis gagne) — pratique jusqu'au jour où l'on ne comprend plus pourquoi telle route
97
97
  passe devant telle autre. Nodefony garde l'**ordre de déclaration** : la table est parcourue de haut en
98
- bas, le premier motif satisfait l'emporte (`Router.resolve()`, `router.ts:230`). Le compromis assumé :
98
+ bas, le premier motif satisfait l'emporte (`Router.resolve()`, `router.ts:260`). Le compromis assumé :
99
99
  c'est à toi de déclarer le littéral avant le paramétré. En échange, tu peux **lire** l'ordre dans ton
100
100
  contrôleur.
101
101
 
@@ -104,17 +104,17 @@ contrôleur.
104
104
  O(1) ; les chemins **dynamiques** restent un scan regex (`buildRouteIndex()`, `router.ts:130`). À la
105
105
  résolution, les deux flux sont fusionnés **par position d'insertion** — la séquence de candidats est
106
106
  exactement celle du scan linéaire complet, moins les littérales d'un autre chemin, qui ne pouvaient de
107
- toute façon pas correspondre (`Router.resolve()`, `router.ts:221`). C'est cette équivalence que fige le
107
+ toute façon pas correspondre (`Router.resolve()`, `router.ts:260`). C'est cette équivalence que fige le
108
108
  banc de non-régression : un refacto du routeur doit le repasser à l'identique.
109
109
 
110
110
  **Une seule table pour HTTP et WebSocket.** Il n'y a pas de « routeur WS » séparé : une action WS est
111
111
  une route dont les méthodes déclarées contiennent `WEBSOCKET` (`Route.matchRequirements()`,
112
- `Route.ts:700`). C'est le différenciateur du framework — le même contrôleur, le même contexte, les
112
+ `Route.ts:763`). C'est le différenciateur du framework — le même contrôleur, le même contexte, les
113
113
  mêmes décorateurs.
114
114
 
115
115
  **Le routeur passe avant les fichiers statiques.** Une requête qui correspond à une route ne paie
116
116
  jamais le `stat` du serveur de fichiers : le repli statique n'est tenté que si la résolution a échoué
117
- (`serverStatic.handle()`, `http-kernel.ts:725`).
117
+ (`serverStatic.handle()`, `http-kernel.ts:762`).
118
118
 
119
119
  > [!NOTE]
120
120
  > **Le routage n'a aucune option de configuration.** Le schéma Zod du module n'expose qu'un sac
@@ -167,10 +167,10 @@ class CatalogController extends Controller {
167
167
  }
168
168
 
169
169
  // `@route` = la forme explicite : nom choisi + contraintes libres.
170
- // HEAD n'est PAS déduit de GET — il se déclare (cf Pièges).
170
+ // HEAD est servi d'office par toute route GET (RFC 9110 §9.1) : inutile de le déclarer.
171
171
  @route("route-catalog-files", {
172
172
  path: "/files/*",
173
- requirements: { methods: ["GET", "HEAD"] },
173
+ requirements: { methods: ["GET"] },
174
174
  })
175
175
  async files(rest: string) {
176
176
  return this.renderJson({ rest });
@@ -263,9 +263,9 @@ que chaque forme **produit dans la table**.
263
263
  | `@route(nom, options)` | **le tien** (stable, réutilisable) | `requirements.methods` (libre) | WebSocket, multi-méthodes, contraintes fines |
264
264
 
265
265
  - Les décorateurs de méthode HTTP délèguent tous à `@route` avec un nom auto `Classe::methode`, et
266
- posent `requirements: { methods }` (`httpMethodDecorator()`, `routerDecorators.ts:469`).
266
+ posent `requirements: { methods }` (`httpMethodDecorator()`, `routerDecorators.ts:489`).
267
267
  - `@All` n'émet **aucun** requirement de méthode : la route sert alors GET, POST, DELETE… et ne peut
268
- donc jamais produire un 405 sur la méthode (`All()`, `routerDecorators.ts:528`).
268
+ donc jamais produire un 405 sur la méthode (`All()`, `routerDecorators.ts:548`).
269
269
  - `@route` est la forme complète : elle seule permet `protocol` (sous-protocole WS), un nom lisible, et
270
270
  des requirements par variable.
271
271
 
@@ -281,7 +281,7 @@ qui les lit et appelle `Router.createRoute()` pour chacune (`controller()`, `rou
281
281
  ## Motifs de chemin et paramètres
282
282
 
283
283
  Le chemin déclaré est compilé **une fois**, à la création de la route, en une expression régulière
284
- ancrée et **insensible à la casse** (`Route.compile()`, `Route.ts:424`). La grammaire tient en cinq
284
+ ancrée et **insensible à la casse** (`Route.compile()`, `Route.ts:464`). La grammaire tient en cinq
285
285
  briques (`REG_ROUTE`, `Route.ts:17`) :
286
286
 
287
287
  | Écriture | Motif compilé | Capture | Exemple |
@@ -307,7 +307,7 @@ Et trois comportements qui surprennent la première fois :
307
307
  C'est le mécanisme le moins évident, et le plus utile. Déclarer un `defaults` pour une variable change
308
308
  le motif compilé : le segment devient facultatif (`[^/]*`) **et son slash aussi** (`/?`), puis la valeur
309
309
  par défaut est réinjectée quand la capture est vide (`checkDefaultParameters()`, `Route.ts:99` ·
310
- `Route.hydrateDefaultParameters()`, `Route.ts:513`).
310
+ `Route.hydrateDefaultParameters()`, `Route.ts:572`).
311
311
 
312
312
  ```ts ignore
313
313
  @route("route-page", { path: "/page/{slug}", defaults: { slug: "home" } })
@@ -327,7 +327,7 @@ async page(slug: string) {
327
327
  Les captures sont passées **positionnellement**, dans l'ordre des variables du chemin — c'est pourquoi
328
328
  la signature `async method6(metier: string, format: string)` suit l'ordre de `/{metier}/{format}`. Un
329
329
  wildcard est exposé sous les clés `wildcard` et `*`. Le `Resolver` en fabrique aussi un instantané
330
- nom → valeur par requête (`Resolver.getMatchedParams()`, `Resolver.ts:188`), lu par le contexte pour
330
+ nom → valeur par requête (`Resolver.getMatchedParams()`, `Resolver.ts:220`), lu par le contexte pour
331
331
  les métadonnées et par les décorateurs `@Param`.
332
332
 
333
333
  > [!IMPORTANT]
@@ -360,14 +360,14 @@ le suit — un `@All("*")` déclaré tôt masque le reste du contrôleur.
360
360
  > [!TIP]
361
361
  > Une exception utile : dans un contrôleur, une route dont le chemin vaut **exactement** `"*"` est
362
362
  > repoussée **en dernier** au moment du montage — la capture-tout d'un contrôleur ne masque donc jamais
363
- > ses propres routes, quel que soit l'ordre d'écriture (`hasMagic`, `routerDecorators.ts:237`). Ça ne
363
+ > ses propres routes, quel que soit l'ordre d'écriture (`hasMagic`, `routerDecorators.ts:253`). Ça ne
364
364
  > vaut **que** pour `"*"` seul : `/files/*` reste ordonné comme les autres.
365
365
 
366
366
  ### Situation 2 — le même chemin, deux méthodes
367
367
 
368
368
  Deux routes peuvent partager un chemin et se distinguer par la méthode. La passe 1 essaie la première,
369
369
  qui **lève** un 405 sur la méthode ; l'exception est mémorisée et le scan **continue** jusqu'à la route
370
- qui accepte la méthode (`Router.resolve()`, `router.ts:230`).
370
+ qui accepte la méthode (`Router.resolve()`, `router.ts:260`).
371
371
 
372
372
  ```ts ignore
373
373
  @Get("/book/{id}") show() {}
@@ -376,24 +376,30 @@ qui accepte la méthode (`Router.resolve()`, `router.ts:230`).
376
376
 
377
377
  Si **aucune** route n'accepte la méthode, la **passe 2** entre en scène : elle reparcourt la table,
378
378
  collecte toutes les méthodes servies par ce chemin **sur ce vhost**, et lève un 405 dont l'en-tête
379
- `Allow` est l'**agrégat** (`collectSupportedMethods()`, `router.ts:31` ; en-tête posé sur la réponse,
380
- `router.ts:31`). C'est la conformité RFC 9110 §15.5.6 : `Allow` liste tout ce que la ressource
381
- accepte, pas seulement ce que la dernière route scannée acceptait.
379
+ `Allow` est l'**agrégat** (`collectSupportedMethods()`, `router.ts:37`, qui lit le `methodsSet`
380
+ compilé de chaque route — la MÊME source que le match). C'est la conformité RFC 9110 §15.5.6 :
381
+ `Allow` liste tout ce que la ressource accepte, pas seulement ce que la dernière route scannée
382
+ acceptait — `HEAD` compris dès qu'une route sert `GET`.
383
+
384
+ **`HEAD` est implicite sur `GET`** (`Route.compileRequirements`, `Route.ts:518`) : RFC 9110 §9.1
385
+ impose à tout serveur généraliste de servir `GET` et `HEAD`, et `HEAD` rend la réponse de `GET`
386
+ sans corps (§9.3.2) — le serveur Node écarte le corps lui-même, en HTTP/1.1 comme en HTTP/2.
387
+ L'ajout se fait à la compilation de la route : aucun coût par requête.
382
388
 
383
389
  | Requête | Réponse |
384
390
  | ----------------- | ---------------------------------------------- |
385
391
  | `DELETE /book/42` | 200 — la 2ᵉ route accepte |
386
- | `PATCH /book/42` | **405**, `Allow: GET, DELETE` |
392
+ | `PATCH /book/42` | **405**, `Allow: GET, HEAD, DELETE` |
387
393
  | `GET /inexistant` | pas d'exception — repli statique, puis **404** |
388
394
 
389
395
  ### Situation 3 — une route réservée à un domaine
390
396
 
391
397
  Une route restreinte par `@Domain` est **invisible** aux requêtes des autres vhosts : elle lève un 403
392
- au lieu de participer au match (`Route.matchHostname()`, `Route.ts:641`). Le point de sécurité est
398
+ au lieu de participer au match (`Route.matchHostname()`, `Route.ts:701`). Le point de sécurité est
393
399
  l'**ordre des vérifications** : le domaine est vérifié **avant** la méthode. Sans cela, une route d'un
394
400
  autre vhost pourrait répondre 405 en révélant SES méthodes — une fuite d'information cross-domaine
395
- (`Route.match()`, `Route.ts:327`). La passe 2 applique la même règle : les routes d'un autre vhost sont
396
- exclues du calcul de `Allow` (`isDomainAllowed`, `router.ts:341`).
401
+ (`Route.match()`, `Route.ts:343`). La passe 2 applique la même règle : les routes d'un autre vhost sont
402
+ exclues du calcul de `Allow` (`isDomainAllowed`, `router.ts:379`).
397
403
 
398
404
  Si une autre route du même chemin sert **tous** les vhosts, le scan continue jusqu'à elle : le 403
399
405
  n'interrompt pas la recherche, il ne conclut que s'il ne reste aucune candidate.
@@ -418,13 +424,13 @@ Ce qui change par rapport au HTTP :
418
424
  inconnu ou un sous-protocole non conforme ferme la connexion **sans jamais l'ouvrir**.
419
425
  - **Le sous-protocole est un requirement de route.** Un `protocol` déclaré et non satisfait lève une
420
426
  erreur de code **1002** (Protocol Error, RFC 6455 §7.4) au lieu d'un statut HTTP
421
- (`acceptedProtocol`, `Route.ts:758`).
427
+ (`acceptedProtocol`, `Route.ts:822`).
422
428
  - **Le 405 ne s'applique pas au WebSocket.** La passe 2 est réservée au HTTP : sur un contexte WS,
423
- l'exception d'origine est préservée (`Router.resolve()`, `router.ts:230`).
429
+ l'exception d'origine est préservée (`Router.resolve()`, `router.ts:260`).
424
430
  - **Un `Resolver` par connexion, réutilisé à chaque frame.** Il est créé au handshake, puis chaque
425
431
  message rejoue `match()` sur la route déjà trouvée avant d'appeler l'action
426
- (`WebsocketContext.handle()`, `WebsocketContext.ts:265` · boucle message,
427
- `callController`, `WebsocketContext.ts:508`).
432
+ (`WebsocketContext.handle()`, `WebsocketContext.ts:297` · boucle message,
433
+ `callController`, `WebsocketContext.ts:59`).
428
434
  L'action est donc invoquée une fois au handshake (`message` vaut `null`), puis une fois par frame.
429
435
 
430
436
  ### Duplex — le même chemin en HTTP et en WS
@@ -433,25 +439,26 @@ Déclarer `methods: ["GET", "WEBSOCKET"]` rend une action joignable par les deux
433
439
  que fait le data plane d'administration pour toutes ses lectures (`AdminBroker.mountAll()` →
434
440
  `Router.createRoute()`, `AdminBroker.ts:125`). Deux conséquences :
435
441
 
436
- - **La pseudo-méthode `WEBSOCKET` apparaît dans l'agrégat `Allow`** d'un chemin duplex — décision
437
- assumée : c'est un jeton d'extension légal, et il révèle la surface duplex de la ressource
442
+ - **La pseudo-méthode `WEBSOCKET` n'apparaît jamais dans `Allow`** : ce n'est pas une méthode HTTP
443
+ (RFC 9110 §10.2.1), elle est retirée de l'agrégat. Un chemin qui n'existe qu'en WebSocket, appelé
444
+ en HTTP, répond `426 Upgrade Required` avec `Upgrade: websocket` plutôt qu'un 405
438
445
  (`routing-nonregression.test.ts:164`).
439
446
  - **Une invocation WS d'une mutation doit dire quelle méthode logique elle vise.** Sur une socket,
440
447
  `context.method` vaut toujours `WEBSOCKET` : insuffisant pour distinguer un GET d'un POST sur le même
441
- chemin. Le pont transporte donc une **méthode logique** (`methodOverride`, `Resolver.ts:126`) que la
448
+ chemin. Le pont transporte donc une **méthode logique** (`methodOverride`, `Resolver.ts:152`) que la
442
449
  route doit déclarer **en plus** du transport — une route `POST` qui n'annonce pas `WEBSOCKET` reste
443
- **injoignable** par socket (zéro contournement, `Route.ts:722`).
450
+ **injoignable** par socket (zéro contournement, `Route.ts:785`).
444
451
 
445
452
  Le routage par **message** (invoquer un chemin porté par une frame, sans toucher l'URL de la connexion)
446
453
  passe par le même `resolve()`, avec un chemin fourni en argument — l'état partagé de la socket n'est
447
- jamais muté (`Router.resolve()`, `router.ts:230`). Détails côté socket :
454
+ jamais muté (`Router.resolve()`, `router.ts:260`). Détails côté socket :
448
455
  [socket Nodefony](../../../../../docs/architecture/realtime-socket-nodefony.md).
449
456
 
450
457
  ## Vhosts — une route par domaine
451
458
 
452
459
  `@Domain` restreint une méthode (ou tout un contrôleur) à un ou plusieurs noms d'hôte. Les motifs
453
460
  acceptent l'exact (`"marseille.fr"`) et le joker d'un label (`"*.cdn.example.com"`), compilés une fois
454
- au boot en expressions ancrées (`Route.compileHost()`, `Route.ts:627`).
461
+ au boot en expressions ancrées (`Route.compileHost()`, `Route.ts:687`).
455
462
 
456
463
  ```ts ignore
457
464
  @controller("/")
@@ -465,12 +472,12 @@ class MarseilleController extends Controller {
465
472
  Précédence, du plus fort au plus faible : `@route({ host })` › `@Domain` sur la méthode › `@Domain` sur
466
473
  la classe (`controller()`, `routerDecorators.ts:189`). Une route sans domaine est servie sur **tous** les
467
474
  vhosts, et ne coûte rien au matching (`hostRegexp` absent → aucun test, `Route.matchHostname()`,
468
- `Route.ts:641`).
475
+ `Route.ts:701`).
469
476
 
470
477
  > [!WARNING]
471
478
  > `@Domain` déclare quels vhosts une route **sert** ; il ne remplace pas la barrière d'entrée. Un
472
479
  > `Host` inconnu du serveur est rejeté en amont (421 Misdirected Request, `checkValidDomain()`,
473
- > `http-kernel.ts:1749`) via la liste `trustedHosts` de `@nodefony/http`.
480
+ > `http-kernel.ts:2050`) via la liste `trustedHosts` de `@nodefony/http`.
474
481
 
475
482
  ## Préfixes — contrôleur, module, data plane
476
483
 
@@ -478,11 +485,11 @@ Trois niveaux de préfixe coexistent, et un seul est à ta main.
478
485
 
479
486
  1. **Le préfixe de contrôleur** — `@controller("/api/catalog")` est concaténé devant le chemin de
480
487
  chaque route de la classe, puis le chemin est normalisé : les `//` sont réduits et le slash final
481
- retiré (`Route.setPattern()`, `Route.ts:602`). Un chemin vide (`@Get("")`) désigne donc le préfixe
488
+ retiré (`Route.setPattern()`, `Route.ts:662`). Un chemin vide (`@Get("")`) désigne donc le préfixe
482
489
  lui-même.
483
490
  2. **Le module propriétaire** — il n'ajoute **aucun** préfixe d'URL. `@controllers([…])` enregistre la
484
491
  classe au boot et propage le nom du module sur les routes déjà créées, pour l'introspection et les
485
- logs (`Router.setController()`, `router.ts:421`). Un module tiers et ton app peuvent porter deux
492
+ logs (`Router.setController()`, `router.ts:489`). Un module tiers et ton app peuvent porter deux
486
493
  contrôleurs homonymes sans collision : la clé du registre est `module:Classe` (`router.ts:164`).
487
494
  3. **Le data plane d'administration** — réservé, non négociable : `/nodefony/<namespace>/api/<endpoint>`
488
495
  (`AdminBroker.resolvePath()`, `AdminBroker.ts:99`). Trois segments minimum, pour ne jamais entrer en
@@ -502,10 +509,10 @@ d'une route — il survit à un changement de chemin.
502
509
  | Besoin | Comment |
503
510
  | ---------------------------------------- | ------------------------------------------------------------------------- |
504
511
  | Retrouver une route par son nom | `router.getRoutes("ma-route")` → l'objet `Route` (`router.ts:326`) |
505
- | Lister toutes les routes | `router.getRoutes("")` → la table complète (`router.ts:391`) |
506
- | Savoir quelles routes couvrent un chemin | `router.matchRoutes("/api/x")` → les résultats de regex (`router.ts:380`) |
507
- | Appeler une autre action, en interne | `this.forward("module:Controller:action")` (`Controller.ts:534`) |
508
- | Retirer une route | `router.removeRoutes("ma-route")` (`router.ts:400`) |
512
+ | Lister toutes les routes | `router.getRoutes("")` → la table complète (`router.ts:442`) |
513
+ | Savoir quelles routes couvrent un chemin | `router.matchRoutes("/api/x")` → les résultats de regex (`router.ts:431`) |
514
+ | Appeler une autre action, en interne | `this.forward("module:Controller:action")` (`Controller.ts:697`) |
515
+ | Retirer une route | `router.removeRoutes("ma-route")` (`router.ts:451`) |
509
516
 
510
517
  **Il n'existe pas de générateur d'URL inverse côté serveur** (pas de `path("ma-route", {id})` à la
511
518
  Symfony). Le chemin déclaré est lisible sur l'objet `Route` (`route.path`), et la substitution des
@@ -515,7 +522,7 @@ chemin ; pour un appel interne, utilise `forward()`.
515
522
 
516
523
  **`forward()` n'est pas une redirection** : il résout `module:Controller:action` et exécute l'action
517
524
  dans le **même** contexte de requête, sans repasser par le réseau (`Resolver.parsePathernController()`,
518
- `Resolver.ts:203`). Une vraie redirection HTTP passe par `this.redirect(url, 302)` ou `@Redirect`.
525
+ `Resolver.ts:247`). Une vraie redirection HTTP passe par `this.redirect(url, 302)` ou `@Redirect`.
519
526
 
520
527
  ## 🧰 API publique
521
528
 
@@ -529,9 +536,9 @@ modules qui montent des routes dynamiquement). Signatures complètes : `.ai/symb
529
536
  | `router.resolve(context)` | Le cœur : rend un `Resolver` (`resolve === true` si trouvé). |
530
537
  | `router.getRoutes(nom)` · `removeRoutes()` | Introspection et démontage. |
531
538
  | `Route#path` · `#variables` · `#pattern` | Ce que la route déclare, après compilation. |
532
- | `Route#toObject()` · `#toLogLine()` | Sérialisation pour l'API admin · ligne de log lisible (`Route.ts:554`). |
539
+ | `Route#toObject()` · `#toLogLine()` | Sérialisation pour l'API admin · ligne de log lisible (`Route.ts:614`). |
533
540
  | `Resolver#route` · `#variables` | Ce que la requête courante a matché. |
534
- | `Resolver#getMatchedParams()` | Les variables en `nom → valeur` (`Resolver.ts:170`). |
541
+ | `Resolver#getMatchedParams()` | Les variables en `nom → valeur` (`Resolver.ts:200`). |
535
542
 
536
543
  > [!CAUTION]
537
544
  > **La table de routes est un état de processus, pas d'instance** : `Router.routes` est une liste
@@ -546,7 +553,7 @@ alloué par requête.
546
553
 
547
554
  - **Compilation unique au montage** : motif d'URL, motifs de domaine, `Set` de méthodes en majuscules,
548
555
  chaîne `Allow`, et regex des requirements par variable sont figés à la création de la route
549
- (`Route.compileRequirements()`, `Route.ts:476`). Le matching ne fait plus que des lookups.
556
+ (`Route.compileRequirements()`, `Route.ts:518`). Le matching ne fait plus que des lookups.
550
557
  - **Un seul calcul de chemin par requête** : le `pathname` normalisé est calculé une fois puis passé à
551
558
  chaque route scannée — sinon le getter `URL.pathname`, la regex de normalisation et l'allocation de
552
559
  chaîne seraient refaits pour **chaque** route de la table (`Route.cleanPathname()`, `Route.ts:303`).
@@ -556,21 +563,22 @@ alloué par requête.
556
563
  mutations directes de la liste (`routeIndex`, `router.ts:127`).
557
564
  - **Zéro journalisation en production** : le log « route trouvée » est promu au niveau NOTICE hors
558
565
  production seulement, et le test est résolu une fois puis mémoïsé — en production, aucune chaîne
559
- n'est même construite (`routeNoticePromoted`, `router.ts:299`).
566
+ n'est même construite (`routeNoticePromoted`, `router.ts:342`).
560
567
  - **Métadonnées d'action mémoïsées par route** au premier passage (`@HttpCode`, `@Header`, `@Redirect`,
561
568
  paramètres, intention de session) : plus aucune lecture `Reflect` par requête
562
- (`resolveActionMeta`, `Resolver.ts:469`).
569
+ (`resolveActionMeta`, `Resolver.ts:458`).
563
570
 
564
571
  ## 📜 Normes appliquées
565
572
 
566
573
  | Sujet | Norme | Où le code s'y conforme |
567
574
  | ---------------------------------------- | ----------------- | --------------------------------------------------------------- |
568
- | 405 + en-tête `Allow` agrégé | RFC 9110 §15.5.6 | passe 2 (`collectSupportedMethods()`, `router.ts:31`) |
569
- | Cible identifiée par l'URI, hôte compris | RFC 9110 §7.2 | hôte vérifié avant la méthode (`Route.match()`, `Route.ts:327`) |
570
- | 403 sur ressource d'un autre vhost | RFC 9110 §15.5.4 | `Route.matchHostname()` (`Route.ts:641`) |
575
+ | 405 + en-tête `Allow` agrégé | RFC 9110 §15.5.6 | passe 2 (`collectSupportedMethods()`, `router.ts:37`) |
576
+ | `HEAD` servi par toute route `GET` | RFC 9110 §9.1 | `Route.compileRequirements` (`Route.ts:518`) |
577
+ | Cible identifiée par l'URI, hôte compris | RFC 9110 §7.2 | hôte vérifié avant la méthode (`Route.match()`, `Route.ts:343`) |
578
+ | 403 sur ressource d'un autre vhost | RFC 9110 §15.5.4 | `Route.matchHostname()` (`Route.ts:701`) |
571
579
  | 404 quand rien ne correspond | RFC 9110 §15.5.5 | après repli statique (`http-kernel.ts:688`) |
572
- | 421 sur `Host` non servi | RFC 9110 §15.5.20 | `checkValidDomain()` (`http-kernel.ts:1749`) |
573
- | Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts:700`) |
580
+ | 421 sur `Host` non servi | RFC 9110 §15.5.20 | `checkValidDomain()` (`http-kernel.ts:2050`) |
581
+ | Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts:763`) |
574
582
  | Décodage pourcent des segments | RFC 3986 §2.1 | `decode()` (`Route.ts:79`) |
575
583
 
576
584
  ## 📡 Observabilité — Studio
@@ -581,14 +589,14 @@ La table de routes est introspectable en ligne, sans lire le code :
581
589
  méthodes, contrôleur (`FrameworkAdminApi.ts:123`). Variante paginée/triée/filtrée côté serveur :
582
590
  `routes/page`.
583
591
  - **`GET /nodefony/framework/api/info`** — résumé : nombre de routes, méthodes servies, modules
584
- propriétaires (`FrameworkAdminApi.ts:335`).
592
+ propriétaires (`FrameworkAdminApi.ts:356`).
585
593
  - **Écran Routes** de Studio (`/nodefony/routes`) — la même table, filtrable.
586
594
  - **Playground** (`/nodefony/playground`, développement uniquement) — un formulaire par action, généré depuis la table :
587
595
  transports (dont le duplex), paramètres décorés, gardes de sécurité. Il **exécute** de vraies actions,
588
596
  donc il n'est monté qu'en développement (`PlaygroundAdminApi.ts`).
589
597
 
590
598
  Au boot, avec le debug actif, chaque route est aussi journalisée en une ligne
591
- `[MÉTHODES] chemin → @module/Controller.action` (`Route.toLogLine()`, `Route.ts:554`).
599
+ `[MÉTHODES] chemin → @module/Controller.action` (`Route.toLogLine()`, `Route.ts:614`).
592
600
 
593
601
  ## ⚠️ Pièges (symptôme → cause → correction)
594
602
 
@@ -598,7 +606,6 @@ Au boot, avec le debug actif, chaque route est aussi journalisée en une ligne
598
606
  | 404 sur toutes les routes d'un contrôleur | `@controller` évalué avant les `@route`/`@Get` de la classe | Placer `@controller` **au-dessus** de la classe, décorateurs de méthode dans la classe |
599
607
  | 404 sur une route pourtant écrite | Le fichier du contrôleur n'est jamais importé — les routes naissent à l'import | Le déclarer dans `@controllers([…])` du module |
600
608
  | `405` alors que la méthode « est déclarée » | `method: "GET"` dans `@route` n'est **pas** filtrant | Utiliser `requirements: { methods: ["GET"] }` ou `@Get` |
601
- | `405` sur une requête `HEAD` d'une route `@Get` | `HEAD` n'est pas déduit de `GET` : c'est une méthode distincte | Déclarer `requirements: { methods: ["GET", "HEAD"] }` |
602
609
  | Une route paramétrée avale un chemin littéral | Premier match dans l'ordre de déclaration, aucune spécificité | Déclarer le littéral **avant** le paramétré |
603
610
  | `/files/*` ne répond pas sur `/files` | Le slash final est retiré avant le matching ; le motif exige `/files/` | Déclarer une seconde route pour le chemin nu |
604
611
  | `{id}` ne capture pas `a/b` | Une variable vaut `[^/]+` — un seul segment, par construction | Utiliser un wildcard `*` si le `/` doit être capturé |
package/docs/templates.md CHANGED
@@ -44,7 +44,7 @@ flowchart LR
44
44
  Deux idées à retenir :
45
45
 
46
46
  1. **Le contrôleur lit le fichier, le moteur ne fait que rendre une chaîne.** `renderView()` résout le
47
- chemin, lit l'octet, puis passe la **source** à Eta (`Controller.renderView()`, `Controller.ts:410`).
47
+ chemin, lit l'octet, puis passe la **source** à Eta (`Controller.renderView()`, `Controller.ts:544`).
48
48
  Il n'y a **pas** de dossier `views/` magique connu du moteur.
49
49
  2. **L'échappement est automatique.** Une donnée interpolée par `<%= %>` est neutralisée (`<` devient
50
50
  `&lt;`) avant d'entrer dans le HTML — c'est la protection XSS, active par défaut
@@ -187,7 +187,7 @@ curl -s 'http://localhost:5151/hello/%3Cb%3Ex%3C%2Fb%3E'
187
187
  > [!TIP]
188
188
  > Tu n'as écrit **aucun** appel d'envoi (`send`, `res.end`). `renderView()` produit le corps **et**
189
189
  > l'envoie. Pour piloter l'envoi toi-même, retourne plutôt une chaîne via `render()`
190
- > (`Controller.render()`, `Controller.ts:377`).
190
+ > (`Controller.render()`, `Controller.ts:485`).
191
191
 
192
192
  ## 🏗️ Architecture interne — le parcours d'un `renderView()`
193
193
 
@@ -218,15 +218,15 @@ sequenceDiagram
218
218
 
219
219
  | # | Étape | Où |
220
220
  | --- | -------------------------------------------- | ------------------------------------------------------------- |
221
- | 1 | Résolution + lecture async du fichier | `FileClass` dans `renderView()` (`Controller.ts:410`) |
222
- | 2 | Ouverture de la phase mesurée `render` | `phaseStart("render")` (`Controller.ts:377`) |
223
- | 3 | Injection des aides frontend dans les locals | `withFrontendLocals()` (`Controller.ts:448`) |
221
+ | 1 | Résolution + lecture async du fichier | `FileClass` dans `renderView()` (`Controller.ts:544`) |
222
+ | 2 | Ouverture de la phase mesurée `render` | `phaseStart("render")` (`Controller.ts:485`) |
223
+ | 3 | Injection des aides frontend dans les locals | `withFrontendLocals()` (`Controller.ts:562`) |
224
224
  | 4 | Rendu de la source par le moteur | `Eta.render()` → `renderStringAsync` (`Eta.ts:51`) |
225
- | 5 | `Content-Type: text/html` puis envoi | `setContextHtml()` + `renderResponse()` (`Controller.ts:392`) |
225
+ | 5 | `Content-Type: text/html` puis envoi | `setContextHtml()` + `renderResponse()` (`Controller.ts:523`) |
226
226
 
227
227
  Le point notable de l'étape 3 : `withFrontendLocals()` ajoute automatiquement `frontendTags`,
228
228
  `frontendDocument` et `asset` aux locals **si** le service `frontend` est présent — et **tes** valeurs
229
- priment (spread `param` en dernier, `Controller.ts:412`). Si le module frontend n'est pas chargé, la
229
+ priment (spread `param` en dernier, `Controller.ts:546`). Si le module frontend n'est pas chargé, la
230
230
  fonction retourne les locals inchangés : zéro couplage dur.
231
231
 
232
232
  ## 🔐 Sécurité — échappement HTML et XSS
@@ -268,9 +268,9 @@ scaffold). Les deux sont **asynchrones** (I/O non bloquante).
268
268
 
269
269
  | Helper | Pour… | Ancre |
270
270
  | ----------------------------------- | ------------------------------------------------------- | ------------------- |
271
- | `renderView(path, locals, status?)` | Rendre une vue `.eta` (lit le fichier + aides frontend) | `Controller.ts:308` |
272
- | `render(data, encoding?, status?)` | Envoyer un corps quelconque (ex. HTML déjà prêt) | `Controller.ts:377` |
273
- | `renderJson(obj, status?)` | Réponse JSON explicite (pas un template) | `Controller.ts:481` |
271
+ | `renderView(path, locals, status?)` | Rendre une vue `.eta` (lit le fichier + aides frontend) | `Controller.ts:524` |
272
+ | `render(data, encoding?, status?)` | Envoyer un corps quelconque (ex. HTML déjà prêt) | `Controller.ts:485` |
273
+ | `renderJson(obj, status?)` | Réponse JSON explicite (pas un template) | `Controller.ts:629` |
274
274
 
275
275
  Les signatures exactes vivent dans le graphe symbolique `.ai/symbols.json` — jamais recopiées ici.
276
276
 
@@ -309,7 +309,7 @@ c'est le différenciateur Nodefony : une classe, deux transports, le même moteu
309
309
  | Domaine | Norme / référence | Comment le code s'y conforme |
310
310
  | ------------------ | ----------------------- | --------------------------------------------------------------------- |
311
311
  | Neutralisation XSS | OWASP — Output Encoding | échappement HTML par défaut (`autoEscape`, `Eta.ts:16`) |
312
- | Type de média HTML | `text/html` | posé par `setContextHtml()` dans `renderView()` (`Controller.ts:410`) |
312
+ | Type de média HTML | `text/html` | posé par `setContextHtml()` dans `renderView()` (`Controller.ts:544`) |
313
313
  | I/O non bloquante | Node.js async fs | lecture async du fichier (`readFile`, `Eta.ts:71`) |
314
314
 
315
315
  ## ⚡ Performance et mémoire
@@ -318,13 +318,13 @@ Le rendu de vue est la partie **réellement coûteuse** d'une réponse (lecture
318
318
  template), et le framework l'isole pour ça :
319
319
 
320
320
  - **Phase dédiée** : `renderView()` chronomètre le rendu sous la phase `render`
321
- (`phaseStart("render")`, `Controller.ts:410`) — distincte de `action` et de `send`. On voit ainsi si
321
+ (`phaseStart("render")`, `Controller.ts:524`) — distincte de `action` et de `send`. On voit ainsi si
322
322
  le temps part dans le moteur ou dans l'écriture réseau.
323
323
  - **Cache en prod** : les templates sont compilés une fois (`cache` vrai en production,
324
324
  `Template.ts:20`) ; le coût de parsing n'est payé qu'au premier rendu.
325
325
  - **Lecture non bloquante** : le fichier est lu en async (`FileClass.readAsync()` côté `renderView`,
326
326
  `readFile` côté `renderFile`, `Eta.ts:66`) — l'event loop n'est jamais gelé par un `readFileSync`.
327
- - **Aides frontend paresseuses** : `withFrontendLocals()` (`Controller.ts:448`) ne construit les
327
+ - **Aides frontend paresseuses** : `withFrontendLocals()` (`Controller.ts:562`) ne construit les
328
328
  fonctions `frontendTags`/`asset` que si le service `frontend` répond — sinon il rend les locals tels
329
329
  quels, zéro allocation superflue.
330
330
 
@@ -344,7 +344,7 @@ template), et le framework l'isole pour ça :
344
344
  | `<%= it.name %>` requis alors qu'on attend `<%= name %>` | `useWith` mal compris — Nodefony l'active (`Eta.ts:17`), les locals sont nus | Écrire `<%= name %>` directement |
345
345
  | Modif de template ignorée en prod | `cache: true` en production (`Template.ts:20`) | Redémarrer le pod ; en dev le cache est off, recompile à chaud |
346
346
  | La réponse d'erreur n'est pas ma vue Eta | Les erreurs rendent du JSON, pas un template (`error-renderer.ts:109`) | Pour une page d'erreur HTML, rendre explicitement une vue dans un handler |
347
- | `renderView()` rejette et logge une ERROR | Fichier introuvable ou template invalide (le `catch` re-lève, `Controller.ts:410`) | Vérifier le chemin résolu (`resolve(module.path, …)`) |
347
+ | `renderView()` rejette et logge une ERROR | Fichier introuvable ou template invalide (le `catch` re-lève, `Controller.ts:544`) | Vérifier le chemin résolu (`resolve(module.path, …)`) |
348
348
 
349
349
  ## 🧪 Tests et couverture
350
350
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/framework",
3
- "version": "10.0.0-alpha.9",
3
+ "version": "10.0.0-beta.2",
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",
@@ -44,29 +44,28 @@
44
44
  "directory": "src/packages/@nodefony/framework"
45
45
  },
46
46
  "dependencies": {
47
- "@graphql-tools/merge": "9.2.4",
48
- "@graphql-tools/schema": "10.1.1",
47
+ "@graphql-tools/merge": "9.2.6",
48
+ "@graphql-tools/schema": "10.1.3",
49
49
  "eta": "4.6.0",
50
50
  "graphql": "17.0.2",
51
51
  "reflect-metadata": "0.2.2",
52
52
  "tslib": "2.8.1"
53
53
  },
54
54
  "devDependencies": {
55
- "@nodefony/http": "^10.0.0-alpha.9",
55
+ "@nodefony/http": "^10.0.0-beta.2",
56
56
  "@types/chai": "5.2.3",
57
- "@types/node": "26.6.2",
58
- "@vitest/coverage-v8": "5.0.1",
59
- "chai": "6.2.2",
60
- "nodefony": "^10.0.0-alpha.9",
57
+ "@types/node": "26.6.3",
58
+ "@vitest/coverage-v8": "5.0.3",
59
+ "nodefony": "^10.0.0-beta.2",
61
60
  "tsx": "4.23.15",
62
- "vitest": "5.0.1"
61
+ "vitest": "5.0.3"
63
62
  },
64
63
  "license": "Apache-2.0",
65
64
  "readmeFilename": "README.md",
66
65
  "contributors": [],
67
66
  "peerDependencies": {
68
- "@nodefony/http": "^10.0.0-alpha.9",
69
- "nodefony": "^10.0.0-alpha.9",
67
+ "@nodefony/http": "^10.0.0-beta.2",
68
+ "nodefony": "^10.0.0-beta.2",
70
69
  "zod": "^4.6.5"
71
70
  },
72
71
  "files": [