@nodefony/framework 10.0.0-beta.3 → 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 +19 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +9 -18
- package/dist/nodefony/config/config.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controller/AdminApiController.js +39 -8
- package/dist/nodefony/controller/ApiKeyController.js +16 -8
- package/dist/nodefony/controller/BenchController.js +1 -2
- package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
- package/dist/nodefony/controller/LoginPageController.js +4 -11
- package/dist/nodefony/controller/OAuth2Controller.js +3 -4
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
- package/dist/nodefony/controller/SessionAuthController.js +13 -4
- package/dist/nodefony/controller/TokenAuthController.js +3 -4
- package/dist/nodefony/controller/TotpController.js +2 -3
- package/dist/nodefony/controller/WebAuthnController.js +2 -3
- package/dist/nodefony/controller/oauthAuthority.js +1 -2
- package/dist/nodefony/decorators/routerDecorators.js +155 -53
- package/dist/nodefony/service/AdminBroker.js +4 -6
- package/dist/nodefony/service/Eta.js +1 -2
- package/dist/nodefony/service/IdempotencyStore.js +4 -6
- package/dist/nodefony/service/router.js +49 -15
- package/dist/nodefony/src/Controller.js +32 -39
- package/dist/nodefony/src/FrameworkAdminApi.js +31 -10
- package/dist/nodefony/src/KernelAdminApi.js +7 -9
- package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
- package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
- package/dist/nodefony/src/Resolver.js +68 -33
- package/dist/nodefony/src/ResourceController.js +13 -6
- package/dist/nodefony/src/Route.js +2 -3
- package/dist/nodefony/src/SecurityApiController.js +1 -2
- package/dist/nodefony/src/SyslogAdminApi.js +1 -2
- package/dist/nodefony/src/Template.js +1 -2
- package/dist/nodefony/src/configMutation.js +1 -2
- package/dist/nodefony/src/docsReader.js +1 -2
- package/dist/nodefony/src/idempotency.js +1 -2
- package/dist/nodefony/src/idempotencyGc.js +1 -2
- package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
- package/dist/nodefony/src/loginPage.js +2 -3
- package/dist/nodefony/src/requireAuthentication.js +1 -2
- package/dist/nodefony/src/routeSchema.js +81 -0
- package/dist/nodefony/src/scopeCatalog.js +1 -2
- package/dist/nodefony/src/singletonGuard.js +1 -2
- package/dist/nodefony/src/syslogFilters.js +7 -2
- package/dist/types/index.d.ts +5 -10
- package/dist/types/nodefony/controller/AdminApiController.d.ts +13 -1
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +7 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +133 -32
- package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
- package/dist/types/nodefony/service/router.d.ts +27 -1
- package/dist/types/nodefony/src/Controller.d.ts +23 -17
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
- package/dist/types/nodefony/src/Resolver.d.ts +33 -1
- package/dist/types/nodefony/src/ResourceController.d.ts +12 -4
- package/dist/types/nodefony/src/Route.d.ts +3 -0
- package/dist/types/nodefony/src/loginPage.d.ts +1 -1
- package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +6 -0
- package/docs/admin.md +131 -82
- package/docs/controller.md +109 -110
- package/docs/decorateurs.md +214 -161
- package/docs/idempotence.md +154 -136
- package/docs/routing.md +105 -90
- package/docs/templates.md +55 -55
- package/package.json +8 -11
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/routing.md
CHANGED
|
@@ -49,13 +49,13 @@ Trois faits à retenir avant tout le reste :
|
|
|
49
49
|
|
|
50
50
|
1. **L'ordre de déclaration EST la priorité.** Une route paramétrée déclarée avant une route
|
|
51
51
|
littérale gagne sur le chemin littéral — c'est figé par le banc de non-régression
|
|
52
|
-
(`routing-nonregression.test.ts
|
|
52
|
+
(`routing-nonregression.test.ts`).
|
|
53
53
|
2. **Le routeur ne lève jamais de 404.** Aucun match = `resolver.resolve === false`, sans exception ;
|
|
54
54
|
le 404 est décidé plus loin, après le repli sur les fichiers statiques
|
|
55
|
-
(`HttpError("Not Found", 404)`, `http-kernel.ts
|
|
55
|
+
(`HttpError("Not Found", 404)`, `http-kernel.ts#HttpKernel.prepareFrontController`).
|
|
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
|
|
58
|
+
`Route.ts#Route.match`).
|
|
59
59
|
|
|
60
60
|
## 📖 Lexique
|
|
61
61
|
|
|
@@ -95,30 +95,30 @@ 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
|
|
98
|
+
bas, le premier motif satisfait l'emporte (`Router.resolve()`, `router.ts#Router.resolve`). 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
|
|
|
102
102
|
**La performance ne change pas la sémantique.** Sous le capot, la table est partitionnée : les chemins
|
|
103
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
|
|
104
|
+
O(1) ; les chemins **dynamiques** restent un scan regex (`buildRouteIndex()`, `router.ts#buildRouteIndex`). À 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
|
|
107
|
+
toute façon pas correspondre (`Router.resolve()`, `router.ts#Router.resolve`). 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
|
|
112
|
+
`Route.ts#Route.matchRequirements`). 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
|
|
117
|
+
(`serverStatic.handle()`, `http-kernel.ts#HttpKernel.handle`).
|
|
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
|
|
121
|
-
> d'options de Service pour le `Router` (`config.ts
|
|
121
|
+
> d'options de Service pour le `Router` (`config.ts#serviceOptionsSchema`) — tout se déclare par **décorateurs**, dans le
|
|
122
122
|
> contrôleur, à côté du code qu'ils servent. Pas de `routes.yaml`, pas de table centrale à maintenir.
|
|
123
123
|
|
|
124
124
|
## 🚀 Démarrage rapide
|
|
@@ -172,7 +172,7 @@ class CatalogController extends Controller {
|
|
|
172
172
|
path: "/files/*",
|
|
173
173
|
requirements: { methods: ["GET"] },
|
|
174
174
|
})
|
|
175
|
-
async
|
|
175
|
+
async download(rest: string) {
|
|
176
176
|
return this.renderJson({ rest });
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -263,15 +263,15 @@ 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
|
|
266
|
+
posent `requirements: { methods }` (`httpMethodDecorator()`, `routerDecorators.ts#httpMethodDecorator`).
|
|
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
|
|
268
|
+
donc jamais produire un 405 sur la méthode (`All()`, `routerDecorators.ts#All`).
|
|
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
|
|
|
272
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
|
|
274
|
-
qui les lit et appelle `Router.createRoute()` pour chacune (`controller()`, `routerDecorators.ts
|
|
273
|
+
sur le constructeur (clé `routes:definitions`, `routerDecorators.ts`) ; c'est `@controller(prefix)`
|
|
274
|
+
qui les lit et appelle `Router.createRoute()` pour chacune (`controller()`, `routerDecorators.ts#controller`).
|
|
275
275
|
|
|
276
276
|
> [!WARNING]
|
|
277
277
|
> **`@route`/`@Get` doivent être SOUS `@controller`** — les décorateurs de classe s'évaluent après ceux
|
|
@@ -281,8 +281,8 @@ 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
|
|
285
|
-
briques (`REG_ROUTE`, `Route.ts
|
|
284
|
+
ancrée et **insensible à la casse** (`Route.compile()`, `Route.ts#Route.compile`). La grammaire tient en cinq
|
|
285
|
+
briques (`REG_ROUTE`, `Route.ts#REG_ROUTE`) :
|
|
286
286
|
|
|
287
287
|
| Écriture | Motif compilé | Capture | Exemple |
|
|
288
288
|
| ------------------ | ------------- | ----------------- | ----------------------------------------------------- |
|
|
@@ -295,10 +295,10 @@ briques (`REG_ROUTE`, `Route.ts:17`) :
|
|
|
295
295
|
Et trois comportements qui surprennent la première fois :
|
|
296
296
|
|
|
297
297
|
- **Le slash final est retiré avant le matching** — `/books/` et `/books` désignent la même route
|
|
298
|
-
(`Route.cleanPathname()`, `Route.ts
|
|
298
|
+
(`Route.cleanPathname()`, `Route.ts#Route.cleanPathname`). Corollaire : `/files/*` ne matche pas `/files/`, qui a été
|
|
299
299
|
normalisé en `/files`.
|
|
300
300
|
- **Les valeurs sont URL-décodées** — `%C3%A9t%C3%A9` arrive dans l'action comme `été`
|
|
301
|
-
(`decode()`, `Route.ts
|
|
301
|
+
(`decode()`, `Route.ts#decode`).
|
|
302
302
|
- **La query string n'entre jamais dans le matching** — seul le `pathname` est comparé. Les paramètres
|
|
303
303
|
de query se lisent avec `@Query` (voir [decorateurs](./decorateurs.md)).
|
|
304
304
|
|
|
@@ -306,8 +306,8 @@ Et trois comportements qui surprennent la première fois :
|
|
|
306
306
|
|
|
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
|
-
par défaut est réinjectée quand la capture est vide (`checkDefaultParameters()`, `Route.ts
|
|
310
|
-
`Route.hydrateDefaultParameters()`, `Route.ts
|
|
309
|
+
par défaut est réinjectée quand la capture est vide (`checkDefaultParameters()`, `Route.ts#checkDefaultParameters` ·
|
|
310
|
+
`Route.hydrateDefaultParameters()`, `Route.ts#Route.hydrateDefaultParameters`).
|
|
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
|
|
330
|
+
nom → valeur par requête (`Resolver.getMatchedParams()`, `Resolver.ts#Resolver.getMatchedParams`), lu par le contexte pour
|
|
331
331
|
les métadonnées et par les décorateurs `@Param`.
|
|
332
332
|
|
|
333
333
|
> [!IMPORTANT]
|
|
@@ -354,20 +354,34 @@ correspondent à `/books/new` : `{id}` capturerait `"new"` comme un identifiant.
|
|
|
354
354
|
```
|
|
355
355
|
|
|
356
356
|
Aucune spécificité n'est calculée : la première route déclarée qui correspond gagne
|
|
357
|
-
(`routing-nonregression.test.ts
|
|
357
|
+
(`routing-nonregression.test.ts`). La même règle vaut pour le catch-all `*`, qui absorbe tout ce qui
|
|
358
358
|
le suit — un `@All("*")` déclaré tôt masque le reste du contrôleur.
|
|
359
359
|
|
|
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
|
|
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,
|
|
369
383
|
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
|
|
384
|
+
qui accepte la méthode (`Router.resolve()`, `router.ts#Router.resolve`).
|
|
371
385
|
|
|
372
386
|
```ts ignore
|
|
373
387
|
@Get("/book/{id}") show() {}
|
|
@@ -376,12 +390,12 @@ qui accepte la méthode (`Router.resolve()`, `router.ts:313`).
|
|
|
376
390
|
|
|
377
391
|
Si **aucune** route n'accepte la méthode, la **passe 2** entre en scène : elle reparcourt la table,
|
|
378
392
|
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
|
|
393
|
+
`Allow` est l'**agrégat** (`collectSupportedMethods()`, `router.ts#collectSupportedMethods`, qui lit le `methodsSet`
|
|
380
394
|
compilé de chaque route — la MÊME source que le match). C'est la conformité RFC 9110 §15.5.6 :
|
|
381
395
|
`Allow` liste tout ce que la ressource accepte, pas seulement ce que la dernière route scannée
|
|
382
396
|
acceptait — `HEAD` compris dès qu'une route sert `GET`.
|
|
383
397
|
|
|
384
|
-
**`HEAD` est implicite sur `GET`** (`Route.compileRequirements`, `Route.ts
|
|
398
|
+
**`HEAD` est implicite sur `GET`** (`Route.compileRequirements`, `Route.ts#Route.compileRequirements`) : RFC 9110 §9.1
|
|
385
399
|
impose à tout serveur généraliste de servir `GET` et `HEAD`, et `HEAD` rend la réponse de `GET`
|
|
386
400
|
sans corps (§9.3.2) — le serveur Node écarte le corps lui-même, en HTTP/1.1 comme en HTTP/2.
|
|
387
401
|
L'ajout se fait à la compilation de la route : aucun coût par requête.
|
|
@@ -395,11 +409,11 @@ L'ajout se fait à la compilation de la route : aucun coût par requête.
|
|
|
395
409
|
### Situation 3 — une route réservée à un domaine
|
|
396
410
|
|
|
397
411
|
Une route restreinte par `@Domain` est **invisible** aux requêtes des autres vhosts : elle lève un 403
|
|
398
|
-
au lieu de participer au match (`Route.matchHostname()`, `Route.ts
|
|
412
|
+
au lieu de participer au match (`Route.matchHostname()`, `Route.ts#Route.matchHostname`). Le point de sécurité est
|
|
399
413
|
l'**ordre des vérifications** : le domaine est vérifié **avant** la méthode. Sans cela, une route d'un
|
|
400
414
|
autre vhost pourrait répondre 405 en révélant SES méthodes — une fuite d'information cross-domaine
|
|
401
|
-
(`Route.match()`, `Route.ts
|
|
402
|
-
exclues du calcul de `Allow` (`isDomainAllowed`, `router.ts
|
|
415
|
+
(`Route.match()`, `Route.ts#Route.match`). La passe 2 applique la même règle : les routes d'un autre vhost sont
|
|
416
|
+
exclues du calcul de `Allow` (`isDomainAllowed`, `router.ts#Router.resolve`).
|
|
403
417
|
|
|
404
418
|
Si une autre route du même chemin sert **tous** les vhosts, le scan continue jusqu'à elle : le 403
|
|
405
419
|
n'interrompt pas la recherche, il ne conclut que s'il ne reste aucune candidate.
|
|
@@ -420,45 +434,45 @@ async chat(room: string, message: string | Buffer | null) { /* … */ }
|
|
|
420
434
|
Ce qui change par rapport au HTTP :
|
|
421
435
|
|
|
422
436
|
- **La route est résolue AVANT l'acceptation du handshake.** Le contexte WS passe par le même
|
|
423
|
-
`handleFrontController()`, puis seulement `context.connect()` (`http-kernel.ts
|
|
437
|
+
`handleFrontController()`, puis seulement `context.connect()` (`http-kernel.ts#HttpKernel.handleHttp`). Un chemin
|
|
424
438
|
inconnu ou un sous-protocole non conforme ferme la connexion **sans jamais l'ouvrir**.
|
|
425
439
|
- **Le sous-protocole est un requirement de route.** Un `protocol` déclaré et non satisfait lève une
|
|
426
440
|
erreur de code **1002** (Protocol Error, RFC 6455 §7.4) au lieu d'un statut HTTP
|
|
427
|
-
(`acceptedProtocol`, `Route.ts
|
|
441
|
+
(`acceptedProtocol`, `Route.ts#Route.matchRequirements`).
|
|
428
442
|
- **Le 405 ne s'applique pas au WebSocket.** La passe 2 est réservée au HTTP : sur un contexte WS,
|
|
429
|
-
l'exception d'origine est préservée (`Router.resolve()`, `router.ts
|
|
443
|
+
l'exception d'origine est préservée (`Router.resolve()`, `router.ts#Router.resolve`).
|
|
430
444
|
- **Un `Resolver` par connexion, réutilisé à chaque frame.** Il est créé au handshake, puis chaque
|
|
431
445
|
message rejoue `match()` sur la route déjà trouvée avant d'appeler l'action
|
|
432
|
-
(`WebsocketContext.handle()`, `WebsocketContext.ts
|
|
433
|
-
`callController`, `WebsocketContext.ts
|
|
446
|
+
(`WebsocketContext.handle()`, `WebsocketContext.ts#WebsocketContext.handle` · boucle message,
|
|
447
|
+
`callController`, `WebsocketContext.ts#callAsPromise`).
|
|
434
448
|
L'action est donc invoquée une fois au handshake (`message` vaut `null`), puis une fois par frame.
|
|
435
449
|
|
|
436
450
|
### Duplex — le même chemin en HTTP et en WS
|
|
437
451
|
|
|
438
452
|
Déclarer `methods: ["GET", "WEBSOCKET"]` rend une action joignable par les deux transports. C'est ce
|
|
439
453
|
que fait le data plane d'administration pour toutes ses lectures (`AdminBroker.mountAll()` →
|
|
440
|
-
`Router.createRoute()`, `AdminBroker.ts
|
|
454
|
+
`Router.createRoute()`, `AdminBroker.ts#AdminBroker.mountAll`). Deux conséquences :
|
|
441
455
|
|
|
442
456
|
- **La pseudo-méthode `WEBSOCKET` n'apparaît jamais dans `Allow`** : ce n'est pas une méthode HTTP
|
|
443
457
|
(RFC 9110 §10.2.1), elle est retirée de l'agrégat. Un chemin qui n'existe qu'en WebSocket, appelé
|
|
444
458
|
en HTTP, répond `426 Upgrade Required` avec `Upgrade: websocket` plutôt qu'un 405
|
|
445
|
-
(`routing-nonregression.test.ts
|
|
459
|
+
(`routing-nonregression.test.ts`).
|
|
446
460
|
- **Une invocation WS d'une mutation doit dire quelle méthode logique elle vise.** Sur une socket,
|
|
447
461
|
`context.method` vaut toujours `WEBSOCKET` : insuffisant pour distinguer un GET d'un POST sur le même
|
|
448
|
-
chemin. Le pont transporte donc une **méthode logique** (`methodOverride`, `Resolver.ts
|
|
462
|
+
chemin. Le pont transporte donc une **méthode logique** (`methodOverride`, `Resolver.ts#Resolver.methodOverride`) que la
|
|
449
463
|
route doit déclarer **en plus** du transport — une route `POST` qui n'annonce pas `WEBSOCKET` reste
|
|
450
|
-
**injoignable** par socket (zéro contournement, `Route.ts
|
|
464
|
+
**injoignable** par socket (zéro contournement, `Route.ts#Route.matchRequirements`).
|
|
451
465
|
|
|
452
466
|
Le routage par **message** (invoquer un chemin porté par une frame, sans toucher l'URL de la connexion)
|
|
453
467
|
passe par le même `resolve()`, avec un chemin fourni en argument — l'état partagé de la socket n'est
|
|
454
|
-
jamais muté (`Router.resolve()`, `router.ts
|
|
468
|
+
jamais muté (`Router.resolve()`, `router.ts#Router.resolve`). Détails côté socket :
|
|
455
469
|
[socket Nodefony](../../../../../docs/architecture/realtime-socket-nodefony.md).
|
|
456
470
|
|
|
457
471
|
## Vhosts — une route par domaine
|
|
458
472
|
|
|
459
473
|
`@Domain` restreint une méthode (ou tout un contrôleur) à un ou plusieurs noms d'hôte. Les motifs
|
|
460
474
|
acceptent l'exact (`"marseille.fr"`) et le joker d'un label (`"*.cdn.example.com"`), compilés une fois
|
|
461
|
-
au boot en expressions ancrées (`Route.compileHost()`, `Route.ts
|
|
475
|
+
au boot en expressions ancrées (`Route.compileHost()`, `Route.ts#Route.compileHost`).
|
|
462
476
|
|
|
463
477
|
```ts ignore
|
|
464
478
|
@controller("/")
|
|
@@ -470,14 +484,14 @@ class MarseilleController extends Controller {
|
|
|
470
484
|
```
|
|
471
485
|
|
|
472
486
|
Précédence, du plus fort au plus faible : `@route({ host })` › `@Domain` sur la méthode › `@Domain` sur
|
|
473
|
-
la classe (`controller()`, `routerDecorators.ts
|
|
487
|
+
la classe (`controller()`, `routerDecorators.ts#controller`). Une route sans domaine est servie sur **tous** les
|
|
474
488
|
vhosts, et ne coûte rien au matching (`hostRegexp` absent → aucun test, `Route.matchHostname()`,
|
|
475
|
-
`Route.ts
|
|
489
|
+
`Route.ts#Route.matchHostname`).
|
|
476
490
|
|
|
477
491
|
> [!WARNING]
|
|
478
492
|
> `@Domain` déclare quels vhosts une route **sert** ; il ne remplace pas la barrière d'entrée. Un
|
|
479
493
|
> `Host` inconnu du serveur est rejeté en amont (421 Misdirected Request, `checkValidDomain()`,
|
|
480
|
-
> `http-kernel.ts
|
|
494
|
+
> `http-kernel.ts#HttpKernel.checkValidDomain`) via la liste `trustedHosts` de `@nodefony/http`.
|
|
481
495
|
|
|
482
496
|
## Préfixes — contrôleur, module, data plane
|
|
483
497
|
|
|
@@ -485,14 +499,14 @@ Trois niveaux de préfixe coexistent, et un seul est à ta main.
|
|
|
485
499
|
|
|
486
500
|
1. **Le préfixe de contrôleur** — `@controller("/api/catalog")` est concaténé devant le chemin de
|
|
487
501
|
chaque route de la classe, puis le chemin est normalisé : les `//` sont réduits et le slash final
|
|
488
|
-
retiré (`Route.setPattern()`, `Route.ts
|
|
502
|
+
retiré (`Route.setPattern()`, `Route.ts#Route.setPattern`). Un chemin vide (`@Get("")`) désigne donc le préfixe
|
|
489
503
|
lui-même.
|
|
490
504
|
2. **Le module propriétaire** — il n'ajoute **aucun** préfixe d'URL. `@controllers([…])` enregistre la
|
|
491
505
|
classe au boot et propage le nom du module sur les routes déjà créées, pour l'introspection et les
|
|
492
|
-
logs (`Router.setController()`, `router.ts
|
|
493
|
-
contrôleurs homonymes sans collision : la clé du registre est `module:Classe` (`router.ts
|
|
506
|
+
logs (`Router.setController()`, `router.ts#Router.setController`). Un module tiers et ton app peuvent porter deux
|
|
507
|
+
contrôleurs homonymes sans collision : la clé du registre est `module:Classe` (`router.ts#Router.constructor`).
|
|
494
508
|
3. **Le data plane d'administration** — réservé, non négociable : `/nodefony/<namespace>/api/<endpoint>`
|
|
495
|
-
(`AdminBroker.resolvePath()`, `AdminBroker.ts
|
|
509
|
+
(`AdminBroker.resolvePath()`, `AdminBroker.ts#AdminBroker.resolvePath`). Trois segments minimum, pour ne jamais entrer en
|
|
496
510
|
collision avec les routes de l'application ni avec la SPA de Studio.
|
|
497
511
|
|
|
498
512
|
> [!TIP]
|
|
@@ -503,46 +517,46 @@ Trois niveaux de préfixe coexistent, et un seul est à ta main.
|
|
|
503
517
|
## Nommer une route, la retrouver, l'appeler
|
|
504
518
|
|
|
505
519
|
Chaque route porte un **nom unique** dans le processus : celui que tu donnes à `@route`, ou l'auto-nom
|
|
506
|
-
`Classe::methode` des décorateurs de méthode (`routerDecorators.ts
|
|
520
|
+
`Classe::methode` des décorateurs de méthode (`routerDecorators.ts#extractControllerFilePath`). Le nom est le handle stable
|
|
507
521
|
d'une route — il survit à un changement de chemin.
|
|
508
522
|
|
|
509
|
-
| Besoin | Comment
|
|
510
|
-
| ---------------------------------------- |
|
|
511
|
-
| Retrouver une route par son nom | `router.getRoutes("ma-route")` → l'objet `Route` (`router.ts
|
|
512
|
-
| Lister toutes les routes | `router.getRoutes("")` → la table complète (`router.ts
|
|
513
|
-
| Savoir quelles routes couvrent un chemin | `router.matchRoutes("/api/x")` → les résultats de regex (`router.ts
|
|
514
|
-
| Appeler une autre action, en interne | `this.forward("module:Controller:action")` (`Controller.ts
|
|
515
|
-
| Retirer une route | `router.removeRoutes("ma-route")` (`router.ts
|
|
523
|
+
| Besoin | Comment |
|
|
524
|
+
| ---------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
525
|
+
| Retrouver une route par son nom | `router.getRoutes("ma-route")` → l'objet `Route` (`router.ts#IndexedRoute.route`) |
|
|
526
|
+
| Lister toutes les routes | `router.getRoutes("")` → la table complète (`router.ts#Router.getRoutes`) |
|
|
527
|
+
| Savoir quelles routes couvrent un chemin | `router.matchRoutes("/api/x")` → les résultats de regex (`router.ts#Router.matchRoutes`) |
|
|
528
|
+
| Appeler une autre action, en interne | `this.forward("module:Controller:action")` (`Controller.ts#Controller.forward`) |
|
|
529
|
+
| Retirer une route | `router.removeRoutes("ma-route")` (`router.ts#Router.removeRoutes`) |
|
|
516
530
|
|
|
517
531
|
**Il n'existe pas de générateur d'URL inverse côté serveur** (pas de `path("ma-route", {id})` à la
|
|
518
532
|
Symfony). Le chemin déclaré est lisible sur l'objet `Route` (`route.path`), et la substitution des
|
|
519
533
|
`{var}` est faite là où on en a besoin — par exemple par la console Studio, qui remplace chaque
|
|
520
|
-
variable par sa valeur encodée (`buildUrl()`, `PlaygroundModel.ts
|
|
534
|
+
variable par sa valeur encodée (`buildUrl()`, `PlaygroundModel.ts#buildUrl`). Pour un lien interne, écris le
|
|
521
535
|
chemin ; pour un appel interne, utilise `forward()`.
|
|
522
536
|
|
|
523
537
|
**`forward()` n'est pas une redirection** : il résout `module:Controller:action` et exécute l'action
|
|
524
538
|
dans le **même** contexte de requête, sans repasser par le réseau (`Resolver.parsePathernController()`,
|
|
525
|
-
`Resolver.ts
|
|
539
|
+
`Resolver.ts#Resolver.parsePathernController`). Une vraie redirection HTTP passe par `this.redirect(url, 302)` ou `@Redirect`.
|
|
526
540
|
|
|
527
541
|
## 🧰 API publique
|
|
528
542
|
|
|
529
543
|
Le routage s'utilise **par décorateurs** ; l'API impérative sert l'outillage (introspection, tests,
|
|
530
544
|
modules qui montent des routes dynamiquement). Signatures complètes : `.ai/symbols.json`.
|
|
531
545
|
|
|
532
|
-
| Symbole | Usage réel
|
|
533
|
-
| ------------------------------------------ |
|
|
534
|
-
| `Router.createRoute(nom, options)` | Monter une route sans décorateur (data plane, module dynamique).
|
|
535
|
-
| `Router.setController(classe, module)` | Rattacher une classe à un module (fait par `@controllers`).
|
|
536
|
-
| `router.resolve(context)` | Le cœur : rend un `Resolver` (`resolve === true` si trouvé).
|
|
537
|
-
| `router.getRoutes(nom)` · `removeRoutes()` | Introspection et démontage.
|
|
538
|
-
| `Route#path` · `#variables` · `#pattern` | Ce que la route déclare, après compilation.
|
|
539
|
-
| `Route#toObject()` · `#toLogLine()` | Sérialisation pour l'API admin · ligne de log lisible (`Route.ts
|
|
540
|
-
| `Resolver#route` · `#variables` | Ce que la requête courante a matché.
|
|
541
|
-
| `Resolver#getMatchedParams()` | Les variables en `nom → valeur` (`Resolver.ts
|
|
546
|
+
| Symbole | Usage réel |
|
|
547
|
+
| ------------------------------------------ | ----------------------------------------------------------------------------------- |
|
|
548
|
+
| `Router.createRoute(nom, options)` | Monter une route sans décorateur (data plane, module dynamique). |
|
|
549
|
+
| `Router.setController(classe, module)` | Rattacher une classe à un module (fait par `@controllers`). |
|
|
550
|
+
| `router.resolve(context)` | Le cœur : rend un `Resolver` (`resolve === true` si trouvé). |
|
|
551
|
+
| `router.getRoutes(nom)` · `removeRoutes()` | Introspection et démontage. |
|
|
552
|
+
| `Route#path` · `#variables` · `#pattern` | Ce que la route déclare, après compilation. |
|
|
553
|
+
| `Route#toObject()` · `#toLogLine()` | Sérialisation pour l'API admin · ligne de log lisible (`Route.ts#Route.toLogLine`). |
|
|
554
|
+
| `Resolver#route` · `#variables` | Ce que la requête courante a matché. |
|
|
555
|
+
| `Resolver#getMatchedParams()` | Les variables en `nom → valeur` (`Resolver.ts#Resolver.match`). |
|
|
542
556
|
|
|
543
557
|
> [!CAUTION]
|
|
544
558
|
> **La table de routes est un état de processus, pas d'instance** : `Router.routes` est une liste
|
|
545
|
-
> module-level partagée par tout le processus (`router.ts
|
|
559
|
+
> module-level partagée par tout le processus (`router.ts#routes`). `removeRoutes()` sans argument la
|
|
546
560
|
> **vide pour tout le monde** — réservé aux bancs de test, qui sauvegardent et restaurent la table
|
|
547
561
|
> autour de chaque cas.
|
|
548
562
|
|
|
@@ -553,50 +567,51 @@ alloué par requête.
|
|
|
553
567
|
|
|
554
568
|
- **Compilation unique au montage** : motif d'URL, motifs de domaine, `Set` de méthodes en majuscules,
|
|
555
569
|
chaîne `Allow`, et regex des requirements par variable sont figés à la création de la route
|
|
556
|
-
(`Route.compileRequirements()`, `Route.ts
|
|
570
|
+
(`Route.compileRequirements()`, `Route.ts#Route.compileRequirements`). Le matching ne fait plus que des lookups.
|
|
557
571
|
- **Un seul calcul de chemin par requête** : le `pathname` normalisé est calculé une fois puis passé à
|
|
558
572
|
chaque route scannée — sinon le getter `URL.pathname`, la regex de normalisation et l'allocation de
|
|
559
|
-
chaîne seraient refaits pour **chaque** route de la table (`Route.cleanPathname()`, `Route.ts
|
|
573
|
+
chaîne seraient refaits pour **chaque** route de la table (`Route.cleanPathname()`, `Route.ts#Route.cleanPathname`).
|
|
560
574
|
- **Lookup O(1) pour les chemins littéraux**, scan pour les seuls chemins à motif — sans changer la
|
|
561
|
-
séquence de candidats (`buildRouteIndex()`, `router.ts
|
|
575
|
+
séquence de candidats (`buildRouteIndex()`, `router.ts#buildRouteIndex`). L'index est invalidé par toute mutation
|
|
562
576
|
de la table, avec un garde-fou sur une photo `longueur/première/dernière` qui rattrape même les
|
|
563
|
-
mutations directes de la liste (`routeIndex`, `router.ts
|
|
564
|
-
- **Zéro journalisation en production** : le log « route trouvée » est
|
|
565
|
-
production seulement
|
|
566
|
-
n'est même construite
|
|
577
|
+
mutations directes de la liste (`routeIndex`, `router.ts#invalidateRouteIndex`).
|
|
578
|
+
- **Zéro journalisation en production** : le log « route trouvée » est émis en DEBUG hors
|
|
579
|
+
production seulement — imprimé avec `-d`, toujours lu par le suivi de requête de Studio — et le
|
|
580
|
+
test est résolu une fois puis mémoïsé : en production, aucune chaîne n'est même construite
|
|
581
|
+
(`routeMatchLogged`, `router.ts#routeMatchLogged`).
|
|
567
582
|
- **Métadonnées d'action mémoïsées par route** au premier passage (`@HttpCode`, `@Header`, `@Redirect`,
|
|
568
583
|
paramètres, intention de session) : plus aucune lecture `Reflect` par requête
|
|
569
|
-
(`resolveActionMeta`, `Resolver.ts
|
|
584
|
+
(`resolveActionMeta`, `Resolver.ts#Resolver.executeAction`).
|
|
570
585
|
|
|
571
586
|
## 📜 Normes appliquées
|
|
572
587
|
|
|
573
|
-
| Sujet | Norme | Où le code s'y conforme
|
|
574
|
-
| ---------------------------------------- | ----------------- |
|
|
575
|
-
| 405 + en-tête `Allow` agrégé | RFC 9110 §15.5.6 | passe 2 (`collectSupportedMethods()`, `router.ts
|
|
576
|
-
| `HEAD` servi par toute route `GET` | RFC 9110 §9.1 | `Route.compileRequirements` (`Route.ts
|
|
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
|
|
578
|
-
| 403 sur ressource d'un autre vhost | RFC 9110 §15.5.4 | `Route.matchHostname()` (`Route.ts
|
|
579
|
-
| 404 quand rien ne correspond | RFC 9110 §15.5.5 | après repli statique (`http-kernel.ts
|
|
580
|
-
| 421 sur `Host` non servi | RFC 9110 §15.5.20 | `checkValidDomain()` (`http-kernel.ts
|
|
581
|
-
| Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts
|
|
582
|
-
| Décodage pourcent des segments | RFC 3986 §2.1 | `decode()` (`Route.ts
|
|
588
|
+
| Sujet | Norme | Où le code s'y conforme |
|
|
589
|
+
| ---------------------------------------- | ----------------- | --------------------------------------------------------------------------- |
|
|
590
|
+
| 405 + en-tête `Allow` agrégé | RFC 9110 §15.5.6 | passe 2 (`collectSupportedMethods()`, `router.ts#collectSupportedMethods`) |
|
|
591
|
+
| `HEAD` servi par toute route `GET` | RFC 9110 §9.1 | `Route.compileRequirements` (`Route.ts#Route.compileRequirements`) |
|
|
592
|
+
| 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#Route.match`) |
|
|
593
|
+
| 403 sur ressource d'un autre vhost | RFC 9110 §15.5.4 | `Route.matchHostname()` (`Route.ts#Route.matchHostname`) |
|
|
594
|
+
| 404 quand rien ne correspond | RFC 9110 §15.5.5 | après repli statique (`http-kernel.ts#HttpKernel.isWebsocketOriginAllowed`) |
|
|
595
|
+
| 421 sur `Host` non servi | RFC 9110 §15.5.20 | `checkValidDomain()` (`http-kernel.ts#HttpKernel.checkValidDomain`) |
|
|
596
|
+
| Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts#Route.matchRequirements`) |
|
|
597
|
+
| Décodage pourcent des segments | RFC 3986 §2.1 | `decode()` (`Route.ts#decode`) |
|
|
583
598
|
|
|
584
599
|
## 📡 Observabilité — Studio
|
|
585
600
|
|
|
586
601
|
La table de routes est introspectable en ligne, sans lire le code :
|
|
587
602
|
|
|
588
603
|
- **`GET /nodefony/framework/api/routes`** — dump de toutes les routes enregistrées : nom, chemin,
|
|
589
|
-
méthodes, contrôleur (`FrameworkAdminApi.ts
|
|
604
|
+
méthodes, contrôleur (`FrameworkAdminApi.ts#createFrameworkAdminApi`). Variante paginée/triée/filtrée côté serveur :
|
|
590
605
|
`routes/page`.
|
|
591
606
|
- **`GET /nodefony/framework/api/info`** — résumé : nombre de routes, méthodes servies, modules
|
|
592
|
-
propriétaires (`FrameworkAdminApi.ts
|
|
607
|
+
propriétaires (`FrameworkAdminApi.ts#createFrameworkAdminApi`).
|
|
593
608
|
- **Écran Routes** de Studio (`/nodefony/routes`) — la même table, filtrable.
|
|
594
609
|
- **Playground** (`/nodefony/playground`, développement uniquement) — un formulaire par action, généré depuis la table :
|
|
595
610
|
transports (dont le duplex), paramètres décorés, gardes de sécurité. Il **exécute** de vraies actions,
|
|
596
611
|
donc il n'est monté qu'en développement (`PlaygroundAdminApi.ts`).
|
|
597
612
|
|
|
598
613
|
Au boot, avec le debug actif, chaque route est aussi journalisée en une ligne
|
|
599
|
-
`[MÉTHODES] chemin → @module/Controller.action` (`Route.toLogLine()`, `Route.ts
|
|
614
|
+
`[MÉTHODES] chemin → @module/Controller.action` (`Route.toLogLine()`, `Route.ts#Route.toLogLine`).
|
|
600
615
|
|
|
601
616
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
602
617
|
|
|
@@ -609,7 +624,7 @@ Au boot, avec le debug actif, chaque route est aussi journalisée en une ligne
|
|
|
609
624
|
| 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é |
|
|
610
625
|
| `/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 |
|
|
611
626
|
| `{id}` ne capture pas `a/b` | Une variable vaut `[^/]+` — un seul segment, par construction | Utiliser un wildcard `*` si le `/` doit être capturé |
|
|
612
|
-
| `500` au lieu d'un non-match sur une contrainte | Un requirement par variable non satisfait **lève** (chaîne brute, `Route.ts
|
|
627
|
+
| `500` au lieu d'un non-match sur une contrainte | Un requirement par variable non satisfait **lève** (chaîne brute, `Route.ts#Route.methodsSet`) | Préférer la contrainte inline `{id}(\d+)`, qui ne matche pas |
|
|
613
628
|
| `403` inattendu sur une route qui « existe » | La route est restreinte à un autre vhost (`@Domain`) | Retirer la restriction, ou servir ce vhost |
|
|
614
629
|
| Action WebSocket jamais atteinte | Transport `WEBSOCKET` absent des méthodes déclarées | `requirements: { methods: ["WEBSOCKET"] }` |
|
|
615
630
|
| 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`…) |
|