@nodefony/framework 10.0.0-alpha.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 (96) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +50 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +211 -0
  6. package/dist/nodefony/config/config.js +61 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  8. package/dist/nodefony/controller/AdminApiController.js +163 -0
  9. package/dist/nodefony/controller/ApiKeyController.js +151 -0
  10. package/dist/nodefony/controller/BenchController.js +132 -0
  11. package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
  12. package/dist/nodefony/controller/OAuth2Controller.js +133 -0
  13. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
  14. package/dist/nodefony/controller/SessionAuthController.js +141 -0
  15. package/dist/nodefony/controller/TokenAuthController.js +113 -0
  16. package/dist/nodefony/controller/TotpController.js +129 -0
  17. package/dist/nodefony/controller/WebAuthnController.js +242 -0
  18. package/dist/nodefony/controller/oauthAuthority.js +74 -0
  19. package/dist/nodefony/decorators/routerDecorators.js +967 -0
  20. package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
  21. package/dist/nodefony/interfaces/IController.js +1 -0
  22. package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
  23. package/dist/nodefony/interfaces/IResolver.js +1 -0
  24. package/dist/nodefony/interfaces/IRoute.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/service/AdminBroker.js +106 -0
  27. package/dist/nodefony/service/Eta.js +68 -0
  28. package/dist/nodefony/service/IdempotencyStore.js +136 -0
  29. package/dist/nodefony/service/router.js +243 -0
  30. package/dist/nodefony/src/Controller.js +515 -0
  31. package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
  32. package/dist/nodefony/src/KernelAdminApi.js +1243 -0
  33. package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
  34. package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
  35. package/dist/nodefony/src/Resolver.js +416 -0
  36. package/dist/nodefony/src/ResourceController.js +148 -0
  37. package/dist/nodefony/src/Route.js +476 -0
  38. package/dist/nodefony/src/SyslogAdminApi.js +466 -0
  39. package/dist/nodefony/src/Template.js +15 -0
  40. package/dist/nodefony/src/configMutation.js +186 -0
  41. package/dist/nodefony/src/docsReader.js +929 -0
  42. package/dist/nodefony/src/idempotency.js +137 -0
  43. package/dist/nodefony/src/idempotencyGc.js +32 -0
  44. package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
  45. package/dist/nodefony/src/scopeCatalog.js +40 -0
  46. package/dist/nodefony/src/syslogFilters.js +51 -0
  47. package/dist/types/index.d.ts +96 -0
  48. package/dist/types/nodefony/config/config.d.ts +41 -0
  49. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  50. package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
  51. package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
  52. package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
  53. package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
  54. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
  55. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
  56. package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
  57. package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
  58. package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
  59. package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
  60. package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
  61. package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
  62. package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
  63. package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
  64. package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
  65. package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
  66. package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
  67. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  68. package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
  69. package/dist/types/nodefony/service/Eta.d.ts +25 -0
  70. package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
  71. package/dist/types/nodefony/service/router.d.ts +53 -0
  72. package/dist/types/nodefony/src/Controller.d.ts +193 -0
  73. package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
  74. package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
  75. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
  76. package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
  77. package/dist/types/nodefony/src/Resolver.d.ts +165 -0
  78. package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
  79. package/dist/types/nodefony/src/Route.d.ts +192 -0
  80. package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
  81. package/dist/types/nodefony/src/Template.d.ts +8 -0
  82. package/dist/types/nodefony/src/configMutation.d.ts +109 -0
  83. package/dist/types/nodefony/src/docsReader.d.ts +369 -0
  84. package/dist/types/nodefony/src/idempotency.d.ts +96 -0
  85. package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
  86. package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
  87. package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
  88. package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
  89. package/docs/admin.md +451 -0
  90. package/docs/controller.md +645 -0
  91. package/docs/decorateurs.md +845 -0
  92. package/docs/idempotence.md +741 -0
  93. package/docs/index.md +151 -0
  94. package/docs/routing.md +648 -0
  95. package/docs/templates.md +380 -0
  96. package/package.json +83 -0
@@ -0,0 +1,648 @@
1
+ ---
2
+ title: "Routage — de l'URL à l'action"
3
+ lang: fr
4
+ module: "@nodefony/framework"
5
+ topic: routing
6
+ section: "Cœur runtime"
7
+ audience: [developer]
8
+ tags: [routing, router, route, resolver, url, websocket, vhost, 405]
9
+ version: "doc"
10
+ status: stable
11
+ updated: 2026-07-19
12
+ source: "src/packages/@nodefony/framework/docs/routing.md"
13
+ coverageModule: framework
14
+ coverageFiles: Route.ts,router.ts,Resolver.ts,routerDecorators.ts
15
+ ---
16
+
17
+ # Routage — de l'URL à l'action
18
+
19
+ > Le routage répond à **une** question, sur chaque requête : quel bout de ton code doit traiter cette
20
+ > URL ? Nodefony y répond avec une **table ordonnée de routes** où le **premier motif qui correspond
21
+ > gagne** — pas de score de spécificité, pas de magie. La même table sert le **HTTP et le WebSocket** :
22
+ > une action WS se déclare comme une action HTTP, avec un transport différent. Tout ci-dessous est
23
+ > ancré sur le code.
24
+
25
+ 📍 [Documentation](../../../../../docs/index.md) › [Framework](index.md) › **Routage**
26
+
27
+ ## 🧠 Le modèle mental — une table ordonnée, le premier match gagne
28
+
29
+ Une route, c'est un **motif d'URL** + des **contraintes** (méthode, domaine, sous-protocole) + une
30
+ **action de contrôleur**. Le `Router` garde toutes les routes du processus dans **une seule liste**,
31
+ dans leur **ordre de déclaration**, et la parcourt jusqu'au premier motif satisfait.
32
+
33
+ ```mermaid
34
+ flowchart TD
35
+ REQ["Requête HTTP<br/>ou handshake WS"] --> CP["pathname normalisé<br/>(slash final retiré)"]
36
+ CP --> IDX{"index de routes"}
37
+ IDX -->|"chemin littéral"| LIT["candidates O(1)<br/>Map path → routes"]
38
+ IDX -->|"{var} · * · regex"| DYN["scan ordonné"]
39
+ LIT --> P1["PASSE 1 — 1er match gagne<br/>chemin › vhost › méthode"]
40
+ DYN --> P1
41
+ P1 -->|"match"| OK["Resolver : route + variables<br/>→ contrôleur → action"]
42
+ P1 -->|"vhost interdit"| E403["403"]
43
+ P1 -->|"aucun match"| P2["PASSE 2 — ce chemin existe-t-il<br/>pour une AUTRE méthode ?"]
44
+ P2 -->|"oui"| E405["405 + en-tête Allow agrégé"]
45
+ P2 -->|"non"| FB["fichiers statiques<br/>puis 404"]
46
+ ```
47
+
48
+ Trois faits à retenir avant tout le reste :
49
+
50
+ 1. **L'ordre de déclaration EST la priorité.** Une route paramétrée déclarée avant une route
51
+ littérale gagne sur le chemin littéral — c'est figé par le banc de non-régression
52
+ (`routing-nonregression.test.ts:83`).
53
+ 2. **Le routeur ne lève jamais de 404.** Aucun match = `resolver.resolve === false`, sans exception ;
54
+ le 404 est décidé plus loin, après le repli sur les fichiers statiques
55
+ (`HttpError("Not Found", 404)`, `http-kernel.ts:798`).
56
+ 3. **Le chemin est vérifié avant la méthode, et le domaine entre les deux** — c'est ce qui produit un
57
+ `403` plutôt qu'un `405` bavard quand la route appartient à un autre vhost (`Route.match()`,
58
+ `Route.ts:212`).
59
+
60
+ ## 📖 Lexique
61
+
62
+ | Terme | Sens (dans cette page) |
63
+ | -------------------- | -------------------------------------------------------------------------------------------------- |
64
+ | Route | Un motif d'URL + ses contraintes + l'action de contrôleur qui la sert. |
65
+ | Table de routes | La liste `Route[]` unique du processus, dans l'ordre de déclaration. |
66
+ | Motif (`pattern`) | L'expression régulière compilée depuis le chemin déclaré. |
67
+ | Variable de route | Un segment capturé, noté `{nom}` — jamais à cheval sur un `/`. |
68
+ | Wildcard / catch-all | Le `*` final, qui absorbe tout le reste du chemin (y compris les `/`). |
69
+ | Requirement | Contrainte attachée à la route : `methods`, `protocol`, `domain`, ou une regex par variable. |
70
+ | Littérale/dynamique | Partition interne : chemin sans métacaractère (lookup direct) vs chemin à motif (scan). |
71
+ | Passe 1 / Passe 2 | Recherche du match, puis (si échec) calcul de l'en-tête `Allow` d'un 405. |
72
+ | `Allow` | En-tête listant les méthodes servies par un chemin (RFC 9110 §15.5.6). |
73
+ | Vhost | Hôte virtuel : le même serveur sert plusieurs noms de domaine, avec des routes différentes. |
74
+ | Duplex | Un même chemin servi en HTTP **et** en WebSocket. |
75
+ | `methodOverride` | Méthode HTTP **logique** d'une invocation WS, quand le transport seul (`WEBSOCKET`) ne suffit pas. |
76
+ | Resolver | L'objet par requête qui porte la route trouvée, ses variables, puis appelle l'action. |
77
+
78
+ ## Qu'est-ce que le routage ?
79
+
80
+ Imagine le standard téléphonique d'un immeuble. Un appel arrive avec un numéro (`/api/books/42`) ;
81
+ le standard consulte **son tableau**, ligne par ligne, et passe la communication au premier poste dont
82
+ le numéro correspond. Si personne ne correspond, il essaie la boîte aux lettres (les fichiers
83
+ statiques), et sinon il répond « ce numéro n'existe pas » (404).
84
+
85
+ Le routage, c'est ce tableau. Trois problèmes qu'il doit résoudre, et que tous les frameworks
86
+ tranchent différemment :
87
+
88
+ - **Correspondre** — reconnaître `/api/books/42` comme « la fiche du livre 42 » et en extraire `42`.
89
+ - **Arbitrer** — quand deux lignes du tableau correspondent, laquelle gagne ?
90
+ - **Expliquer un refus** — un chemin connu appelé avec la mauvaise méthode ne mérite pas un 404
91
+ (« ça n'existe pas »), mais un **405 avec la liste des méthodes acceptées**.
92
+
93
+ ## La vision Nodefony
94
+
95
+ **L'arbitrage est explicite, pas calculé.** Beaucoup de routeurs trient les routes par « spécificité »
96
+ (le motif le plus précis gagne) — pratique jusqu'au jour où l'on ne comprend plus pourquoi telle route
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é :
99
+ c'est à toi de déclarer le littéral avant le paramétré. En échange, tu peux **lire** l'ordre dans ton
100
+ contrôleur.
101
+
102
+ **La performance ne change pas la sémantique.** Sous le capot, la table est partitionnée : les chemins
103
+ **littéraux** (aucun `{var}`, aucun métacaractère) vivent dans une `Map path → candidates` en lookup
104
+ O(1) ; les chemins **dynamiques** restent un scan regex (`buildRouteIndex()`, `router.ts:92`). À la
105
+ résolution, les deux flux sont fusionnés **par position d'insertion** — la séquence de candidats est
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
108
+ banc de non-régression : un refacto du routeur doit le repasser à l'identique.
109
+
110
+ **Une seule table pour HTTP et WebSocket.** Il n'y a pas de « routeur WS » séparé : une action WS est
111
+ une route dont les méthodes déclarées contiennent `WEBSOCKET` (`Route.matchRequirements()`,
112
+ `Route.ts:649`). C'est le différenciateur du framework — le même contrôleur, le même contexte, les
113
+ mêmes décorateurs.
114
+
115
+ **Le routeur passe avant les fichiers statiques.** Une requête qui correspond à une route ne paie
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:1200`).
118
+
119
+ > [!NOTE]
120
+ > **Le routage n'a aucune option de configuration.** Le schéma Zod du module n'expose qu'un sac
121
+ > d'options de Service pour le `Router` (`config.ts:36`) — tout se déclare par **décorateurs**, dans le
122
+ > contrôleur, à côté du code qu'ils servent. Pas de `routes.yaml`, pas de table centrale à maintenir.
123
+
124
+ ## 🚀 Démarrage rapide
125
+
126
+ Dans une app générée par `nodefony create app`, le routage est déjà actif : `@nodefony/framework` est
127
+ dans le manifeste `modules` de `nodefony.config.ts`. Il ne reste qu'à écrire un contrôleur.
128
+
129
+ ### Le contrôleur — cinq routes qui couvrent tous les cas
130
+
131
+ ```ts
132
+ // nodefony/controllers/CatalogController.ts — complet, compile tel quel
133
+ import {
134
+ Controller,
135
+ controller,
136
+ route,
137
+ Get,
138
+ Post,
139
+ Param,
140
+ Query,
141
+ } from "@nodefony/framework";
142
+ import type { ContextType } from "@nodefony/http";
143
+
144
+ // Le préfixe s'ajoute DEVANT le chemin de chaque route de la classe.
145
+ @controller("/api/catalog")
146
+ class CatalogController extends Controller {
147
+ constructor(context: ContextType) {
148
+ super("catalog", context);
149
+ }
150
+
151
+ // GET /api/catalog — chemin littéral, lookup O(1)
152
+ @Get("")
153
+ async list(@Query("page") page?: string) {
154
+ return this.renderJson({ page: Number(page ?? 1) });
155
+ }
156
+
157
+ // GET /api/catalog/book/{isbn} — `{isbn}` = UN segment, jamais deux
158
+ @Get("/book/{isbn}")
159
+ async one(@Param("isbn") isbn: string) {
160
+ return this.renderJson({ isbn });
161
+ }
162
+
163
+ // POST sur le MÊME chemin qu'aucun GET ne sert → un GET ici renverra 405
164
+ @Post("/book")
165
+ async create() {
166
+ return this.renderJson({ created: true });
167
+ }
168
+
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).
171
+ @route("route-catalog-files", {
172
+ path: "/files/*",
173
+ requirements: { methods: ["GET", "HEAD"] },
174
+ })
175
+ async files(rest: string) {
176
+ return this.renderJson({ rest });
177
+ }
178
+
179
+ // MÊME contrôleur, transport WebSocket : `message` vaut null au handshake,
180
+ // puis porte chaque frame reçue.
181
+ @route("route-catalog-live", {
182
+ path: "/live",
183
+ requirements: { methods: ["WEBSOCKET"] },
184
+ })
185
+ async live(message: string | Buffer | null) {
186
+ if (!message) return this.renderJson({ handshake: true });
187
+ return this.render(message.toString());
188
+ }
189
+ }
190
+
191
+ export default CatalogController;
192
+ ```
193
+
194
+ ### Le câblage — déclarer le contrôleur au module de l'app
195
+
196
+ Les routes sont créées à l'**import** du fichier (les décorateurs s'évaluent alors) ; `@controllers`
197
+ rattache la classe au module au boot. `nodefony create controller` écrit ces deux lignes pour toi.
198
+
199
+ ```ts ignore
200
+ // index.ts (racine de l'app) — extrait
201
+ import { Kernel, Module } from "nodefony";
202
+ import { controllers } from "@nodefony/framework";
203
+ import config from "./nodefony.config.js";
204
+ import CatalogController from "./nodefony/controllers/CatalogController.js";
205
+
206
+ @controllers([CatalogController])
207
+ class App extends Module {
208
+ constructor(kernel: Kernel) {
209
+ super("app", kernel, import.meta.url, config);
210
+ }
211
+ }
212
+
213
+ export default App;
214
+ ```
215
+
216
+ ### Ce qu'on observe
217
+
218
+ ```bash
219
+ # 1) Chemin littéral + query string (la query n'entre PAS dans le matching)
220
+ curl -s 'http://localhost:5151/api/catalog?page=2'
221
+ # {"page":2}
222
+
223
+ # 2) Variable de route, valeur URL-décodée
224
+ curl -s http://localhost:5151/api/catalog/book/978-2-1234
225
+ # {"isbn":"978-2-1234"}
226
+
227
+ # 3) Slash final ignoré, casse ignorée — même route
228
+ curl -so /dev/null -w '%{http_code}\n' http://localhost:5151/API/Catalog/
229
+ # 200
230
+
231
+ # 4) Chemin connu, mauvaise méthode → 405 + Allow (RFC 9110 §15.5.6)
232
+ curl -si http://localhost:5151/api/catalog/book | grep -Ei '^(HTTP|allow)'
233
+ # HTTP/1.1 405 Method Not Allowed
234
+ # Allow: POST
235
+
236
+ # 5) Wildcard : tout le reste du chemin, séparateurs compris
237
+ curl -s http://localhost:5151/api/catalog/files/2026/rapport.pdf
238
+ # {"rest":"2026/rapport.pdf"}
239
+
240
+ # 6) Chemin inconnu → repli statique, puis 404
241
+ curl -so /dev/null -w '%{http_code}\n' http://localhost:5151/api/catalog/nope/nope
242
+ # 404
243
+ ```
244
+
245
+ Le WebSocket, sur le **même serveur** et la même table :
246
+
247
+ ```bash
248
+ npx wscat -c ws://localhost:5151/api/catalog/live
249
+ # < {"handshake":true}
250
+ # > bonjour
251
+ # < bonjour
252
+ ```
253
+
254
+ ## Déclarer une route — trois formes
255
+
256
+ La **syntaxe** des décorateurs est détaillée dans [decorateurs](./decorateurs.md) ; ce qui suit est ce
257
+ que chaque forme **produit dans la table**.
258
+
259
+ | Forme | Nom de la route | Méthodes déclarées | Quand l'utiliser |
260
+ | ----------------------------------------- | ---------------------------------- | -------------------------------- | -------------------------------------------- |
261
+ | `@Get` `@Post` `@Put` `@Delete` `@Patch`… | auto : `` `Classe::methode` `` | exactement une | le cas courant, REST |
262
+ | `@All(path)` | auto : `` `Classe::methode` `` | **aucune** → toutes les méthodes | proxy, capture-tout, page de repli |
263
+ | `@route(nom, options)` | **le tien** (stable, réutilisable) | `requirements.methods` (libre) | WebSocket, multi-méthodes, contraintes fines |
264
+
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:340`).
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:374`).
269
+ - `@route` est la forme complète : elle seule permet `protocol` (sous-protocole WS), un nom lisible, et
270
+ des requirements par variable.
271
+
272
+ **Comment une déclaration devient une route.** Les décorateurs de méthode **accumulent** des métadonnées
273
+ sur le constructeur (clé `routes:definitions`, `routerDecorators.ts:16`) ; c'est `@controller(prefix)`
274
+ qui les lit et appelle `Router.createRoute()` pour chacune (`controller()`, `routerDecorators.ts:129`).
275
+
276
+ > [!WARNING]
277
+ > **`@route`/`@Get` doivent être SOUS `@controller`** — les décorateurs de classe s'évaluent après ceux
278
+ > de méthode, et `@controller` doit trouver les métadonnées déjà posées. Un `@controller` placé au
279
+ > mauvais endroit ne crée **aucune** route, sans erreur : symptôme = 404 partout sur ce contrôleur.
280
+
281
+ ## Motifs de chemin et paramètres
282
+
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:395`). La grammaire tient en cinq
285
+ briques (`REG_ROUTE`, `Route.ts:17`) :
286
+
287
+ | Écriture | Motif compilé | Capture | Exemple |
288
+ | ------------------ | ------------- | ----------------- | ----------------------------------------------------- |
289
+ | `/books` | littéral | — | `/books` (et `/BOOKS`, et `/books/`) |
290
+ | `/books/{id}` | `([^/]+)` | `id` | `/books/42` ✅ · `/books/a/b` ❌ (un seul segment) |
291
+ | `/books/{id}(\d+)` | `(\d+)` | `id`, contrainte | `/books/42` ✅ · `/books/abc` ❌ (**ne matche pas**) |
292
+ | `/files/*` | `(.*)/?` | `*` et `wildcard` | `/files/a/b.txt` ✅ · `/files` ❌ (le `/` est requis) |
293
+ | `/report.{fmt}` | `\.([^/]+)` | `fmt` | `/report.json` → `fmt = "json"` |
294
+
295
+ Et trois comportements qui surprennent la première fois :
296
+
297
+ - **Le slash final est retiré avant le matching** — `/books/` et `/books` désignent la même route
298
+ (`Route.cleanPathname()`, `Route.ts:274`). Corollaire : `/files/*` ne matche pas `/files/`, qui a été
299
+ normalisé en `/files`.
300
+ - **Les valeurs sont URL-décodées** — `%C3%A9t%C3%A9` arrive dans l'action comme `été`
301
+ (`decode()`, `Route.ts:79`).
302
+ - **La query string n'entre jamais dans le matching** — seul le `pathname` est comparé. Les paramètres
303
+ de query se lisent avec `@Query` (voir [decorateurs](./decorateurs.md)).
304
+
305
+ ### Une valeur par défaut rend le segment OPTIONNEL
306
+
307
+ C'est le mécanisme le moins évident, et le plus utile. Déclarer un `defaults` pour une variable change
308
+ le motif compilé : le segment devient facultatif (`[^/]*`) **et son slash aussi** (`/?`), puis la valeur
309
+ par défaut est réinjectée quand la capture est vide (`checkDefaultParameters()`, `Route.ts:99` ·
310
+ `Route.hydrateDefaultParameters()`, `Route.ts:469`).
311
+
312
+ ```ts ignore
313
+ @route("route-page", { path: "/page/{slug}", defaults: { slug: "home" } })
314
+ async page(slug: string) {
315
+ return this.renderJson({ slug });
316
+ }
317
+ ```
318
+
319
+ | Requête | `slug` reçu | Pourquoi |
320
+ | ----------- | ----------- | --------------------------------------------- |
321
+ | `/page/faq` | `"faq"` | capture normale |
322
+ | `/page` | `"home"` | segment absent → défaut réinjecté |
323
+ | `/page/` | `"home"` | slash final retiré, puis même cas que `/page` |
324
+
325
+ ### Comment les valeurs arrivent dans l'action
326
+
327
+ Les captures sont passées **positionnellement**, dans l'ordre des variables du chemin — c'est pourquoi
328
+ la signature `async method6(metier: string, format: string)` suit l'ordre de `/{metier}/{format}`. Un
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:170`), lu par le contexte pour
331
+ les métadonnées et par les décorateurs `@Param`.
332
+
333
+ > [!IMPORTANT]
334
+ > Dès qu'**un seul** décorateur de paramètre (`@Param`, `@Query`, `@Body`…) est présent sur l'action,
335
+ > les arguments positionnels sont **remplacés** par les valeurs des décorateurs. On ne mélange pas les
336
+ > deux conventions dans une même signature.
337
+
338
+ ## ⚙️ Ordre de résolution — trois situations
339
+
340
+ L'ordre n'est pas un détail d'implémentation : c'est **ta** politique de routage. Trois situations
341
+ concrètes, tirées du banc de non-régression.
342
+
343
+ ### Situation 1 — une fiche par identifiant, et une page « nouveau »
344
+
345
+ Tu sers `/books/{id}` et tu veux aussi `/books/new` pour le formulaire de création. Les deux motifs
346
+ correspondent à `/books/new` : `{id}` capturerait `"new"` comme un identifiant.
347
+
348
+ ```ts ignore
349
+ // ✅ le littéral D'ABORD — il gagne, et /books/42 tombe ensuite sur la paramétrée
350
+ @Get("/books/new") newForm() {}
351
+ @Get("/books/{id}") show(@Param("id") id: string) {}
352
+
353
+ // ❌ l'inverse : `show` reçoit id = "new", `newForm` n'est JAMAIS atteinte
354
+ ```
355
+
356
+ Aucune spécificité n'est calculée : la première route déclarée qui correspond gagne
357
+ (`routing-nonregression.test.ts:83`). La même règle vaut pour le catch-all `*`, qui absorbe tout ce qui
358
+ le suit — un `@All("*")` déclaré tôt masque le reste du contrôleur.
359
+
360
+ > [!TIP]
361
+ > Une exception utile : dans un contrôleur, une route dont le chemin vaut **exactement** `"*"` est
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
364
+ > vaut **que** pour `"*"` seul : `/files/*` reste ordonné comme les autres.
365
+
366
+ ### Situation 2 — le même chemin, deux méthodes
367
+
368
+ Deux routes peuvent partager un chemin et se distinguer par la méthode. La passe 1 essaie la première,
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`).
371
+
372
+ ```ts ignore
373
+ @Get("/book/{id}") show() {}
374
+ @Delete("/book/{id}") remove() {} // DELETE /book/42 → arrive bien ici
375
+ ```
376
+
377
+ Si **aucune** route n'accepte la méthode, la **passe 2** entre en scène : elle reparcourt la table,
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.
382
+
383
+ | Requête | Réponse |
384
+ | ----------------- | ---------------------------------------------- |
385
+ | `DELETE /book/42` | 200 — la 2ᵉ route accepte |
386
+ | `PATCH /book/42` | **405**, `Allow: GET, DELETE` |
387
+ | `GET /inexistant` | pas d'exception — repli statique, puis **404** |
388
+
389
+ ### Situation 3 — une route réservée à un domaine
390
+
391
+ 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:605`). Le point de sécurité est
393
+ l'**ordre des vérifications** : le domaine est vérifié **avant** la méthode. Sans cela, une route d'un
394
+ autre vhost pourrait répondre 405 en révélant SES méthodes — une fuite d'information cross-domaine
395
+ (`Route.match()`, `Route.ts:298`). 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:270`).
397
+
398
+ Si une autre route du même chemin sert **tous** les vhosts, le scan continue jusqu'à elle : le 403
399
+ n'interrompt pas la recherche, il ne conclut que s'il ne reste aucune candidate.
400
+
401
+ ## 🔌 HTTP et WebSocket — la même table
402
+
403
+ Une action WebSocket est une route ordinaire dont les méthodes déclarées contiennent la pseudo-méthode
404
+ `WEBSOCKET`. C'est tout ce qui la distingue.
405
+
406
+ ```ts ignore
407
+ @route("route-chat", {
408
+ path: "/chat/{room}",
409
+ requirements: { methods: ["WEBSOCKET"], protocol: "chat-v1" },
410
+ })
411
+ async chat(room: string, message: string | Buffer | null) { /* … */ }
412
+ ```
413
+
414
+ Ce qui change par rapport au HTTP :
415
+
416
+ - **La route est résolue AVANT l'acceptation du handshake.** Le contexte WS passe par le même
417
+ `handleFrontController()`, puis seulement `context.connect()` (`http-kernel.ts:1537`). Un chemin
418
+ inconnu ou un sous-protocole non conforme ferme la connexion **sans jamais l'ouvrir**.
419
+ - **Le sous-protocole est un requirement de route.** Un `protocol` déclaré et non satisfait lève une
420
+ erreur de code **1002** (Protocol Error, RFC 6455 §7.4) au lieu d'un statut HTTP
421
+ (`acceptedProtocol`, `Route.ts:722`).
422
+ - **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`).
424
+ - **Un `Resolver` par connexion, réutilisé à chaque frame.** Il est créé au handshake, puis chaque
425
+ message rejoue `match()` sur la route déjà trouvée avant d'appeler l'action
426
+ (`WebsocketContext.handle()`, `WebsocketContext.ts:271` · boucle message,
427
+ `callController`, `WebsocketContext.ts:508`).
428
+ L'action est donc invoquée une fois au handshake (`message` vaut `null`), puis une fois par frame.
429
+
430
+ ### Duplex — le même chemin en HTTP et en WS
431
+
432
+ Déclarer `methods: ["GET", "WEBSOCKET"]` rend une action joignable par les deux transports. C'est ce
433
+ que fait le data plane d'administration pour toutes ses lectures (`AdminBroker.mountAll()` →
434
+ `Router.createRoute()`, `AdminBroker.ts:125`). Deux conséquences :
435
+
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
438
+ (`routing-nonregression.test.ts:164`).
439
+ - **Une invocation WS d'une mutation doit dire quelle méthode logique elle vise.** Sur une socket,
440
+ `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:116`) que la
442
+ 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:678`).
444
+
445
+ Le routage par **message** (invoquer un chemin porté par une frame, sans toucher l'URL de la connexion)
446
+ 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 :
448
+ [socket Nodefony](../../../../../docs/architecture/realtime-socket-nodefony.md).
449
+
450
+ ## Vhosts — une route par domaine
451
+
452
+ `@Domain` restreint une méthode (ou tout un contrôleur) à un ou plusieurs noms d'hôte. Les motifs
453
+ 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:468`).
455
+
456
+ ```ts ignore
457
+ @controller("/")
458
+ @Domain("marseille.fr") // SOUS @controller : les décorateurs de classe
459
+ class MarseilleController extends Controller {
460
+ // s'appliquent de bas en haut
461
+ @Get("/") home() {} // marseille.fr/ → 200 · autre-vhost/ → 403
462
+ }
463
+ ```
464
+
465
+ Précédence, du plus fort au plus faible : `@route({ host })` › `@Domain` sur la méthode › `@Domain` sur
466
+ la classe (`controller()`, `routerDecorators.ts:89`). Une route sans domaine est servie sur **tous** les
467
+ vhosts, et ne coûte rien au matching (`hostRegexp` absent → aucun test, `Route.matchHostname()`,
468
+ `Route.ts:605`).
469
+
470
+ > [!WARNING]
471
+ > `@Domain` déclare quels vhosts une route **sert** ; il ne remplace pas la barrière d'entrée. Un
472
+ > `Host` inconnu du serveur est rejeté en amont (421 Misdirected Request, `checkValidDomain()`,
473
+ > `http-kernel.ts:1697`) via la liste `trustedHosts` de `@nodefony/http`.
474
+
475
+ ## Préfixes — contrôleur, module, data plane
476
+
477
+ Trois niveaux de préfixe coexistent, et un seul est à ta main.
478
+
479
+ 1. **Le préfixe de contrôleur** — `@controller("/api/catalog")` est concaténé devant le chemin de
480
+ chaque route de la classe, puis le chemin est normalisé : les `//` sont réduits et le slash final
481
+ retiré (`Route.setPattern()`, `Route.ts:551`). Un chemin vide (`@Get("")`) désigne donc le préfixe
482
+ lui-même.
483
+ 2. **Le module propriétaire** — il n'ajoute **aucun** préfixe d'URL. `@controllers([…])` enregistre la
484
+ 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:417`). Un module tiers et ton app peuvent porter deux
486
+ contrôleurs homonymes sans collision : la clé du registre est `module:Classe` (`router.ts:164`).
487
+ 3. **Le data plane d'administration** — réservé, non négociable : `/nodefony/<namespace>/api/<endpoint>`
488
+ (`AdminBroker.resolvePath()`, `AdminBroker.ts:91`). Trois segments minimum, pour ne jamais entrer en
489
+ collision avec les routes de l'application ni avec la SPA de Studio.
490
+
491
+ > [!TIP]
492
+ > Les routes de tes modules ne sont **pas** préfixées par leur nom de module — deux modules peuvent
493
+ > déclarer `/api/users`. C'est le premier déclaré (ordre du manifeste `modules`) qui gagne. Préfixe tes
494
+ > contrôleurs applicatifs pour éviter la collision silencieuse.
495
+
496
+ ## Nommer une route, la retrouver, l'appeler
497
+
498
+ Chaque route porte un **nom unique** dans le processus : celui que tu donnes à `@route`, ou l'auto-nom
499
+ `Classe::methode` des décorateurs de méthode (`routerDecorators.ts:348`). Le nom est le handle stable
500
+ d'une route — il survit à un changement de chemin.
501
+
502
+ | Besoin | Comment |
503
+ | ---------------------------------------- | ------------------------------------------------------------------------- |
504
+ | 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:387`) |
506
+ | Savoir quelles routes couvrent un chemin | `router.matchRoutes("/api/x")` → les résultats de regex (`router.ts:376`) |
507
+ | Appeler une autre action, en interne | `this.forward("module:Controller:action")` (`Controller.ts:445`) |
508
+ | Retirer une route | `router.removeRoutes("ma-route")` (`router.ts:335`) |
509
+
510
+ **Il n'existe pas de générateur d'URL inverse côté serveur** (pas de `path("ma-route", {id})` à la
511
+ Symfony). Le chemin déclaré est lisible sur l'objet `Route` (`route.path`), et la substitution des
512
+ `{var}` est faite là où on en a besoin — par exemple par la console Studio, qui remplace chaque
513
+ variable par sa valeur encodée (`buildUrl()`, `PlaygroundModel.ts:88`). Pour un lien interne, écris le
514
+ chemin ; pour un appel interne, utilise `forward()`.
515
+
516
+ **`forward()` n'est pas une redirection** : il résout `module:Controller:action` et exécute l'action
517
+ dans le **même** contexte de requête, sans repasser par le réseau (`Resolver.parsePathernController()`,
518
+ `Resolver.ts:185`). Une vraie redirection HTTP passe par `this.redirect(url, 302)` ou `@Redirect`.
519
+
520
+ ## 🧰 API publique
521
+
522
+ Le routage s'utilise **par décorateurs** ; l'API impérative sert l'outillage (introspection, tests,
523
+ modules qui montent des routes dynamiquement). Signatures complètes : `.ai/symbols.json`.
524
+
525
+ | Symbole | Usage réel |
526
+ | ------------------------------------------ | ----------------------------------------------------------------------- |
527
+ | `Router.createRoute(nom, options)` | Monter une route sans décorateur (data plane, module dynamique). |
528
+ | `Router.setController(classe, module)` | Rattacher une classe à un module (fait par `@controllers`). |
529
+ | `router.resolve(context)` | Le cœur : rend un `Resolver` (`resolve === true` si trouvé). |
530
+ | `router.getRoutes(nom)` · `removeRoutes()` | Introspection et démontage. |
531
+ | `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:525`). |
533
+ | `Resolver#route` · `#variables` | Ce que la requête courante a matché. |
534
+ | `Resolver#getMatchedParams()` | Les variables en `nom → valeur` (`Resolver.ts:170`). |
535
+
536
+ > [!CAUTION]
537
+ > **La table de routes est un état de processus, pas d'instance** : `Router.routes` est une liste
538
+ > module-level partagée par tout le processus (`router.ts:48`). `removeRoutes()` sans argument la
539
+ > **vide pour tout le monde** — réservé aux bancs de test, qui sauvegardent et restaurent la table
540
+ > autour de chaque cas.
541
+
542
+ ## ⚡ Performance & mémoire
543
+
544
+ Le routage est sur le chemin chaud de **chaque** requête : tout y est précalculé au boot, rien n'y est
545
+ alloué par requête.
546
+
547
+ - **Compilation unique au montage** : motif d'URL, motifs de domaine, `Set` de méthodes en majuscules,
548
+ chaîne `Allow`, et regex des requirements par variable sont figés à la création de la route
549
+ (`Route.compileRequirements()`, `Route.ts:324`). Le matching ne fait plus que des lookups.
550
+ - **Un seul calcul de chemin par requête** : le `pathname` normalisé est calculé une fois puis passé à
551
+ chaque route scannée — sinon le getter `URL.pathname`, la regex de normalisation et l'allocation de
552
+ chaîne seraient refaits pour **chaque** route de la table (`Route.cleanPathname()`, `Route.ts:204`).
553
+ - **Lookup O(1) pour les chemins littéraux**, scan pour les seuls chemins à motif — sans changer la
554
+ séquence de candidats (`buildRouteIndex()`, `router.ts:130`). L'index est invalidé par toute mutation
555
+ de la table, avec un garde-fou sur une photo `longueur/première/dernière` qui rattrape même les
556
+ mutations directes de la liste (`routeIndex`, `router.ts:201`).
557
+ - **Zéro journalisation en production** : le log « route trouvée » est promu au niveau NOTICE hors
558
+ 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`).
560
+ - **Métadonnées d'action mémoïsées par route** au premier passage (`@HttpCode`, `@Header`, `@Redirect`,
561
+ paramètres, intention de session) : plus aucune lecture `Reflect` par requête
562
+ (`resolveActionMeta`, `Resolver.ts:142`).
563
+
564
+ ## 📜 Normes appliquées
565
+
566
+ | Sujet | Norme | Où le code s'y conforme |
567
+ | ---------------------------------------- | ----------------- | --------------------------------------------------------------- |
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:298`) |
570
+ | 403 sur ressource d'un autre vhost | RFC 9110 §15.5.4 | `Route.matchHostname()` (`Route.ts:605`) |
571
+ | 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:1697`) |
573
+ | Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts:649`) |
574
+ | Décodage pourcent des segments | RFC 3986 §2.1 | `decode()` (`Route.ts:79`) |
575
+
576
+ ## 📡 Observabilité — Studio
577
+
578
+ La table de routes est introspectable en ligne, sans lire le code :
579
+
580
+ - **`GET /nodefony/framework/api/routes`** — dump de toutes les routes enregistrées : nom, chemin,
581
+ méthodes, contrôleur (`FrameworkAdminApi.ts:123`). Variante paginée/triée/filtrée côté serveur :
582
+ `routes/page`.
583
+ - **`GET /nodefony/framework/api/info`** — résumé : nombre de routes, méthodes servies, modules
584
+ propriétaires (`FrameworkAdminApi.ts:183`).
585
+ - **Écran Routes** de Studio (`/nodefony/routes`) — la même table, filtrable.
586
+ - **Playground** (`/nodefony/playground`, développement uniquement) — un formulaire par action, généré depuis la table :
587
+ transports (dont le duplex), paramètres décorés, gardes de sécurité. Il **exécute** de vraies actions,
588
+ donc il n'est monté qu'en développement (`PlaygroundAdminApi.ts`).
589
+
590
+ 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:402`).
592
+
593
+ ## ⚠️ Pièges (symptôme → cause → correction)
594
+
595
+ <!-- prettier-ignore -->
596
+ | Symptôme | Cause (dans le code) | Correction |
597
+ | --- | --- | --- |
598
+ | 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
+ | 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
+ | `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
+ | 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
+ | `/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
+ | `{id}` ne capture pas `a/b` | Une variable vaut `[^/]+` — un seul segment, par construction | Utiliser un wildcard `*` si le `/` doit être capturé |
605
+ | `500` au lieu d'un non-match sur une contrainte | Un requirement par variable non satisfait **lève** (chaîne brute, `Route.ts:286`) | Préférer la contrainte inline `{id}(\d+)`, qui ne matche pas |
606
+ | `403` inattendu sur une route qui « existe » | La route est restreinte à un autre vhost (`@Domain`) | Retirer la restriction, ou servir ce vhost |
607
+ | Action WebSocket jamais atteinte | Transport `WEBSOCKET` absent des méthodes déclarées | `requirements: { methods: ["WEBSOCKET"] }` |
608
+ | Une action nommée `session`/`request`/`method` est refusée | Le décorateur refuse tout nom déjà porté par `Controller` — il masquerait l'action | Renommer l'action (réservés : tout membre de `Controller`/`Service` — `session`, `get`, `set`, `remove`, `request`, `response`, `method`…) |
609
+ | Les routes d'un test « fuient » sur le test suivant | `Router.routes` est un état de processus partagé | Sauvegarder/restaurer la table autour de chaque cas |
610
+
611
+ ## 🧪 Tests & couverture
612
+
613
+ Le routage est le sous-système du framework le plus densément couvert — les chiffres exacts vivent dans
614
+ la carte de tests de la page (régénérée depuis vitest, jamais figés dans la prose).
615
+
616
+ - **Unitaires — la grammaire et l'objet `Route`** : `Route.test.ts` (compilation du motif, matching,
617
+ variables, décodage, défauts, requirements, préfixe, hôte, hash) et `Router.test.ts` (création,
618
+ lecture, suppression, `matchRoutes`).
619
+ - **Unitaires — la déclaration** : `routerDecorators.test.ts` (les métadonnées posées par `@route`,
620
+ `@controller`, `@Param`/`@Body`/`@Query`) et `httpMethodDecorators.test.ts` (auto-nommage,
621
+ `requirements.methods`).
622
+ - **Banc de contrat — la sémantique observable** : `routing-nonregression.test.ts` fige onze familles
623
+ d'invariants (A→K) : ordre d'insertion, 405 agrégé, absence de throw sur non-match, restriction de
624
+ domaine, exemption WS de la passe 2, routage par message, normalisation, extraction des variables,
625
+ table vivante, contrat du resolver, désambiguïsation `methodOverride`. **Tout refacto du routeur doit
626
+ le repasser à l'identique.**
627
+ - **Unitaires — l'optimisation** : `routing-index.test.ts` prouve que l'index littérales/dynamiques
628
+ n'altère pas la séquence de candidats (dont le garde-fou contre les mutations directes de la table).
629
+ - **Intégration (serveur réel)** : `tests/routing/Router.test.ts` de `@nodefony/http` exerce les routes
630
+ du module de test — variables, défauts, contraintes de méthode, wildcard.
631
+
632
+ **Ce qui manque, dit franchement** : aucun banc d'attaque dédié au routage (`*.attack.test.ts`) et
633
+ aucun test de charge dédié — le coût de la résolution est mesuré indirectement par les bancs HTTP de
634
+ `tests/load/**`. La couverture du vhost est portée par `tests/integration/domain-routing.test.ts`
635
+ (`@nodefony/http`), hors périmètre compté ici.
636
+
637
+ Lancer : `npm test` (unitaires) et `npm run test:integration` (serveur requis) dans
638
+ `@nodefony/framework` ; couverture via `npm run coverage`. Pour la charge, voir le skill
639
+ `nodefony-load-test`.
640
+
641
+ ## 🔗 Pour aller plus loin
642
+
643
+ - ⬆️ **Retour au hub** : [@nodefony/framework — vue du module](index.md) · [Toute la documentation](../../../../../docs/index.md)
644
+ - 🧭 **Pages sœurs** : [Décorateurs](./decorateurs.md) (la syntaxe de déclaration) · [Contrôleur](./controller.md) (ce qui se passe après la résolution) · [Idempotence](./idempotence.md) (protéger les mutations rejouées)
645
+ - Où le routage s'insère dans le traitement d'une requête → [pipeline de requête](../../../../../docs/architecture/pipeline-requete.md)
646
+ - Le contexte et les transports qui alimentent le routeur → [@nodefony/http](../../http/docs/index.md)
647
+ - Qui a le droit d'atteindre une route → [firewall](../../security/docs/firewall.md)
648
+ - Le routage par message sur une socket → [socket Nodefony](../../../../../docs/architecture/realtime-socket-nodefony.md)