@nodefony/framework 10.0.0-alpha.8 → 10.0.0-beta.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.
- package/README.md +4 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +7 -6
- package/dist/nodefony/controller/AdminApiController.js +4 -3
- package/dist/nodefony/controller/ApiKeyController.js +1 -1
- package/dist/nodefony/controller/OAuth2Controller.js +11 -6
- package/dist/nodefony/controller/SessionAuthController.js +3 -3
- package/dist/nodefony/controller/TokenAuthController.js +2 -2
- package/dist/nodefony/controller/TotpController.js +5 -4
- package/dist/nodefony/controller/WebAuthnController.js +4 -4
- package/dist/nodefony/decorators/routerDecorators.js +41 -35
- package/dist/nodefony/service/AdminBroker.js +2 -2
- package/dist/nodefony/service/IdempotencyStore.js +4 -5
- package/dist/nodefony/service/router.js +75 -25
- package/dist/nodefony/src/Controller.js +184 -78
- package/dist/nodefony/src/FrameworkAdminApi.js +19 -6
- package/dist/nodefony/src/KernelAdminApi.js +60 -55
- package/dist/nodefony/src/PlaygroundAdminApi.js +3 -3
- package/dist/nodefony/src/RedisIdempotencyStore.js +2 -2
- package/dist/nodefony/src/Resolver.js +219 -63
- package/dist/nodefony/src/Route.js +38 -38
- package/dist/nodefony/src/SyslogAdminApi.js +4 -4
- package/dist/nodefony/src/docsReader.js +22 -18
- package/dist/nodefony/src/scopeCatalog.js +1 -1
- package/dist/nodefony/src/singletonGuard.js +76 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +2 -2
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +3 -3
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +1 -1
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +2 -1
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +49 -43
- package/dist/types/nodefony/interfaces/IController.d.ts +9 -7
- package/dist/types/nodefony/interfaces/IResolver.d.ts +10 -10
- package/dist/types/nodefony/interfaces/IRoute.d.ts +13 -13
- package/dist/types/nodefony/service/AdminBroker.d.ts +3 -3
- package/dist/types/nodefony/service/router.d.ts +33 -7
- package/dist/types/nodefony/src/Controller.d.ts +97 -24
- package/dist/types/nodefony/src/Resolver.d.ts +154 -15
- package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
- package/dist/types/nodefony/src/Route.d.ts +24 -22
- package/dist/types/nodefony/src/docsReader.d.ts +3 -3
- package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
- package/docs/admin.md +26 -26
- package/docs/controller.md +139 -121
- package/docs/decorateurs.md +108 -105
- package/docs/idempotence.md +78 -71
- package/docs/routing.md +57 -51
- package/docs/templates.md +17 -17
- package/package.json +12 -12
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:
|
|
58
|
+
`Route.ts:327`).
|
|
59
59
|
|
|
60
60
|
## 📖 Lexique
|
|
61
61
|
|
|
@@ -101,20 +101,20 @@ 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: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:
|
|
107
|
+
toute façon pas correspondre (`Router.resolve()`, `router.ts:237`). 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:720`). 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:743`).
|
|
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
|
|
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"
|
|
173
|
+
requirements: { methods: ["GET"] },
|
|
174
174
|
})
|
|
175
175
|
async files(rest: string) {
|
|
176
176
|
return this.renderJson({ rest });
|
|
@@ -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: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:
|
|
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
|
|
|
272
272
|
**Comment une déclaration devient une route.** Les décorateurs de méthode **accumulent** des métadonnées
|
|
273
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:
|
|
274
|
+
qui les lit et appelle `Router.createRoute()` pour chacune (`controller()`, `routerDecorators.ts:189`).
|
|
275
275
|
|
|
276
276
|
> [!WARNING]
|
|
277
277
|
> **`@route`/`@Get` doivent être SOUS `@controller`** — les décorateurs de classe s'évaluent après ceux
|
|
@@ -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:
|
|
284
|
+
ancrée et **insensible à la casse** (`Route.compile()`, `Route.ts:432`). La grammaire tient en cinq
|
|
285
285
|
briques (`REG_ROUTE`, `Route.ts:17`) :
|
|
286
286
|
|
|
287
287
|
| Écriture | Motif compilé | Capture | Exemple |
|
|
@@ -295,7 +295,7 @@ 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:303`). 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
301
|
(`decode()`, `Route.ts:79`).
|
|
@@ -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:
|
|
310
|
+
`Route.hydrateDefaultParameters()`, `Route.ts:529`).
|
|
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:200`), lu par le contexte pour
|
|
331
331
|
les métadonnées et par les décorateurs `@Param`.
|
|
332
332
|
|
|
333
333
|
> [!IMPORTANT]
|
|
@@ -360,7 +360,7 @@ 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:
|
|
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
|
|
@@ -376,9 +376,15 @@ 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:
|
|
380
|
-
|
|
381
|
-
accepte, pas seulement ce que la dernière route scannée
|
|
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:485`) : 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
|
| ----------------- | ---------------------------------------------- |
|
|
@@ -389,11 +395,11 @@ accepte, pas seulement ce que la dernière route scannée acceptait.
|
|
|
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:
|
|
398
|
+
au lieu de participer au match (`Route.matchHostname()`, `Route.ts:658`). 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:
|
|
396
|
-
exclues du calcul de `Allow` (`isDomainAllowed`, `router.ts:
|
|
401
|
+
(`Route.match()`, `Route.ts:327`). 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:352`).
|
|
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,7 +424,7 @@ 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:
|
|
427
|
+
(`acceptedProtocol`, `Route.ts:779`).
|
|
422
428
|
- **Le 405 ne s'applique pas au WebSocket.** La passe 2 est réservée au HTTP : sur un contexte WS,
|
|
423
429
|
l'exception d'origine est préservée (`Router.resolve()`, `router.ts:230`).
|
|
424
430
|
- **Un `Resolver` par connexion, réutilisé à chaque frame.** Il est créé au handshake, puis chaque
|
|
@@ -438,9 +444,9 @@ que fait le data plane d'administration pour toutes ses lectures (`AdminBroker.m
|
|
|
438
444
|
(`routing-nonregression.test.ts:164`).
|
|
439
445
|
- **Une invocation WS d'une mutation doit dire quelle méthode logique elle vise.** Sur une socket,
|
|
440
446
|
`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:
|
|
447
|
+
chemin. Le pont transporte donc une **méthode logique** (`methodOverride`, `Resolver.ts:138`) que la
|
|
442
448
|
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:
|
|
449
|
+
**injoignable** par socket (zéro contournement, `Route.ts:722`).
|
|
444
450
|
|
|
445
451
|
Le routage par **message** (invoquer un chemin porté par une frame, sans toucher l'URL de la connexion)
|
|
446
452
|
passe par le même `resolve()`, avec un chemin fourni en argument — l'état partagé de la socket n'est
|
|
@@ -451,7 +457,7 @@ jamais muté (`Router.resolve()`, `router.ts:230`). Détails côté socket :
|
|
|
451
457
|
|
|
452
458
|
`@Domain` restreint une méthode (ou tout un contrôleur) à un ou plusieurs noms d'hôte. Les motifs
|
|
453
459
|
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:
|
|
460
|
+
au boot en expressions ancrées (`Route.compileHost()`, `Route.ts:644`).
|
|
455
461
|
|
|
456
462
|
```ts ignore
|
|
457
463
|
@controller("/")
|
|
@@ -463,14 +469,14 @@ class MarseilleController extends Controller {
|
|
|
463
469
|
```
|
|
464
470
|
|
|
465
471
|
Précédence, du plus fort au plus faible : `@route({ host })` › `@Domain` sur la méthode › `@Domain` sur
|
|
466
|
-
la classe (`controller()`, `routerDecorators.ts:
|
|
472
|
+
la classe (`controller()`, `routerDecorators.ts:189`). Une route sans domaine est servie sur **tous** les
|
|
467
473
|
vhosts, et ne coûte rien au matching (`hostRegexp` absent → aucun test, `Route.matchHostname()`,
|
|
468
|
-
`Route.ts:
|
|
474
|
+
`Route.ts:658`).
|
|
469
475
|
|
|
470
476
|
> [!WARNING]
|
|
471
477
|
> `@Domain` déclare quels vhosts une route **sert** ; il ne remplace pas la barrière d'entrée. Un
|
|
472
478
|
> `Host` inconnu du serveur est rejeté en amont (421 Misdirected Request, `checkValidDomain()`,
|
|
473
|
-
> `http-kernel.ts:
|
|
479
|
+
> `http-kernel.ts:1821`) via la liste `trustedHosts` de `@nodefony/http`.
|
|
474
480
|
|
|
475
481
|
## Préfixes — contrôleur, module, data plane
|
|
476
482
|
|
|
@@ -478,14 +484,14 @@ Trois niveaux de préfixe coexistent, et un seul est à ta main.
|
|
|
478
484
|
|
|
479
485
|
1. **Le préfixe de contrôleur** — `@controller("/api/catalog")` est concaténé devant le chemin de
|
|
480
486
|
chaque route de la classe, puis le chemin est normalisé : les `//` sont réduits et le slash final
|
|
481
|
-
retiré (`Route.setPattern()`, `Route.ts:
|
|
487
|
+
retiré (`Route.setPattern()`, `Route.ts:619`). Un chemin vide (`@Get("")`) désigne donc le préfixe
|
|
482
488
|
lui-même.
|
|
483
489
|
2. **Le module propriétaire** — il n'ajoute **aucun** préfixe d'URL. `@controllers([…])` enregistre la
|
|
484
490
|
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:
|
|
491
|
+
logs (`Router.setController()`, `router.ts:462`). Un module tiers et ton app peuvent porter deux
|
|
486
492
|
contrôleurs homonymes sans collision : la clé du registre est `module:Classe` (`router.ts:164`).
|
|
487
493
|
3. **Le data plane d'administration** — réservé, non négociable : `/nodefony/<namespace>/api/<endpoint>`
|
|
488
|
-
(`AdminBroker.resolvePath()`, `AdminBroker.ts:
|
|
494
|
+
(`AdminBroker.resolvePath()`, `AdminBroker.ts:99`). Trois segments minimum, pour ne jamais entrer en
|
|
489
495
|
collision avec les routes de l'application ni avec la SPA de Studio.
|
|
490
496
|
|
|
491
497
|
> [!TIP]
|
|
@@ -502,10 +508,10 @@ d'une route — il survit à un changement de chemin.
|
|
|
502
508
|
| Besoin | Comment |
|
|
503
509
|
| ---------------------------------------- | ------------------------------------------------------------------------- |
|
|
504
510
|
| 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:
|
|
506
|
-
| Savoir quelles routes couvrent un chemin | `router.matchRoutes("/api/x")` → les résultats de regex (`router.ts:
|
|
507
|
-
| Appeler une autre action, en interne | `this.forward("module:Controller:action")` (`Controller.ts:
|
|
508
|
-
| Retirer une route | `router.removeRoutes("ma-route")` (`router.ts:
|
|
511
|
+
| Lister toutes les routes | `router.getRoutes("")` → la table complète (`router.ts:415`) |
|
|
512
|
+
| Savoir quelles routes couvrent un chemin | `router.matchRoutes("/api/x")` → les résultats de regex (`router.ts:404`) |
|
|
513
|
+
| Appeler une autre action, en interne | `this.forward("module:Controller:action")` (`Controller.ts:670`) |
|
|
514
|
+
| Retirer une route | `router.removeRoutes("ma-route")` (`router.ts:424`) |
|
|
509
515
|
|
|
510
516
|
**Il n'existe pas de générateur d'URL inverse côté serveur** (pas de `path("ma-route", {id})` à la
|
|
511
517
|
Symfony). Le chemin déclaré est lisible sur l'objet `Route` (`route.path`), et la substitution des
|
|
@@ -515,7 +521,7 @@ chemin ; pour un appel interne, utilise `forward()`.
|
|
|
515
521
|
|
|
516
522
|
**`forward()` n'est pas une redirection** : il résout `module:Controller:action` et exécute l'action
|
|
517
523
|
dans le **même** contexte de requête, sans repasser par le réseau (`Resolver.parsePathernController()`,
|
|
518
|
-
`Resolver.ts:
|
|
524
|
+
`Resolver.ts:228`). Une vraie redirection HTTP passe par `this.redirect(url, 302)` ou `@Redirect`.
|
|
519
525
|
|
|
520
526
|
## 🧰 API publique
|
|
521
527
|
|
|
@@ -529,9 +535,9 @@ modules qui montent des routes dynamiquement). Signatures complètes : `.ai/symb
|
|
|
529
535
|
| `router.resolve(context)` | Le cœur : rend un `Resolver` (`resolve === true` si trouvé). |
|
|
530
536
|
| `router.getRoutes(nom)` · `removeRoutes()` | Introspection et démontage. |
|
|
531
537
|
| `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:
|
|
538
|
+
| `Route#toObject()` · `#toLogLine()` | Sérialisation pour l'API admin · ligne de log lisible (`Route.ts:571`). |
|
|
533
539
|
| `Resolver#route` · `#variables` | Ce que la requête courante a matché. |
|
|
534
|
-
| `Resolver#getMatchedParams()` | Les variables en `nom → valeur` (`Resolver.ts:
|
|
540
|
+
| `Resolver#getMatchedParams()` | Les variables en `nom → valeur` (`Resolver.ts:200`). |
|
|
535
541
|
|
|
536
542
|
> [!CAUTION]
|
|
537
543
|
> **La table de routes est un état de processus, pas d'instance** : `Router.routes` est une liste
|
|
@@ -546,31 +552,32 @@ alloué par requête.
|
|
|
546
552
|
|
|
547
553
|
- **Compilation unique au montage** : motif d'URL, motifs de domaine, `Set` de méthodes en majuscules,
|
|
548
554
|
chaîne `Allow`, et regex des requirements par variable sont figés à la création de la route
|
|
549
|
-
(`Route.compileRequirements()`, `Route.ts:
|
|
555
|
+
(`Route.compileRequirements()`, `Route.ts:476`). Le matching ne fait plus que des lookups.
|
|
550
556
|
- **Un seul calcul de chemin par requête** : le `pathname` normalisé est calculé une fois puis passé à
|
|
551
557
|
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:
|
|
558
|
+
chaîne seraient refaits pour **chaque** route de la table (`Route.cleanPathname()`, `Route.ts:303`).
|
|
553
559
|
- **Lookup O(1) pour les chemins littéraux**, scan pour les seuls chemins à motif — sans changer la
|
|
554
560
|
séquence de candidats (`buildRouteIndex()`, `router.ts:130`). L'index est invalidé par toute mutation
|
|
555
561
|
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:
|
|
562
|
+
mutations directes de la liste (`routeIndex`, `router.ts:127`).
|
|
557
563
|
- **Zéro journalisation en production** : le log « route trouvée » est promu au niveau NOTICE hors
|
|
558
564
|
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:
|
|
565
|
+
n'est même construite (`routeNoticePromoted`, `router.ts:306`).
|
|
560
566
|
- **Métadonnées d'action mémoïsées par route** au premier passage (`@HttpCode`, `@Header`, `@Redirect`,
|
|
561
567
|
paramètres, intention de session) : plus aucune lecture `Reflect` par requête
|
|
562
|
-
(`resolveActionMeta`, `Resolver.ts:
|
|
568
|
+
(`resolveActionMeta`, `Resolver.ts:491`).
|
|
563
569
|
|
|
564
570
|
## 📜 Normes appliquées
|
|
565
571
|
|
|
566
572
|
| Sujet | Norme | Où le code s'y conforme |
|
|
567
573
|
| ---------------------------------------- | ----------------- | --------------------------------------------------------------- |
|
|
568
|
-
| 405 + en-tête `Allow` agrégé | RFC 9110 §15.5.6 | passe 2 (`collectSupportedMethods()`, `router.ts:
|
|
569
|
-
|
|
|
570
|
-
|
|
|
574
|
+
| 405 + en-tête `Allow` agrégé | RFC 9110 §15.5.6 | passe 2 (`collectSupportedMethods()`, `router.ts:37`) |
|
|
575
|
+
| `HEAD` servi par toute route `GET` | RFC 9110 §9.1 | `Route.compileRequirements` (`Route.ts:485`) |
|
|
576
|
+
| 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`) |
|
|
577
|
+
| 403 sur ressource d'un autre vhost | RFC 9110 §15.5.4 | `Route.matchHostname()` (`Route.ts:658`) |
|
|
571
578
|
| 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:
|
|
573
|
-
| Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts:
|
|
579
|
+
| 421 sur `Host` non servi | RFC 9110 §15.5.20 | `checkValidDomain()` (`http-kernel.ts:1821`) |
|
|
580
|
+
| Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts:720`) |
|
|
574
581
|
| Décodage pourcent des segments | RFC 3986 §2.1 | `decode()` (`Route.ts:79`) |
|
|
575
582
|
|
|
576
583
|
## 📡 Observabilité — Studio
|
|
@@ -581,14 +588,14 @@ La table de routes est introspectable en ligne, sans lire le code :
|
|
|
581
588
|
méthodes, contrôleur (`FrameworkAdminApi.ts:123`). Variante paginée/triée/filtrée côté serveur :
|
|
582
589
|
`routes/page`.
|
|
583
590
|
- **`GET /nodefony/framework/api/info`** — résumé : nombre de routes, méthodes servies, modules
|
|
584
|
-
propriétaires (`FrameworkAdminApi.ts:
|
|
591
|
+
propriétaires (`FrameworkAdminApi.ts:356`).
|
|
585
592
|
- **Écran Routes** de Studio (`/nodefony/routes`) — la même table, filtrable.
|
|
586
593
|
- **Playground** (`/nodefony/playground`, développement uniquement) — un formulaire par action, généré depuis la table :
|
|
587
594
|
transports (dont le duplex), paramètres décorés, gardes de sécurité. Il **exécute** de vraies actions,
|
|
588
595
|
donc il n'est monté qu'en développement (`PlaygroundAdminApi.ts`).
|
|
589
596
|
|
|
590
597
|
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:
|
|
598
|
+
`[MÉTHODES] chemin → @module/Controller.action` (`Route.toLogLine()`, `Route.ts:571`).
|
|
592
599
|
|
|
593
600
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
594
601
|
|
|
@@ -598,7 +605,6 @@ Au boot, avec le debug actif, chaque route est aussi journalisée en une ligne
|
|
|
598
605
|
| 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
606
|
| 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
607
|
| `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
608
|
| 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
609
|
| `/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
610
|
| `{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:
|
|
47
|
+
chemin, lit l'octet, puis passe la **source** à Eta (`Controller.renderView()`, `Controller.ts:524`).
|
|
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
|
`<`) avant d'entrer dans le HTML — c'est la protection XSS, active par défaut
|
|
@@ -104,7 +104,7 @@ Le branchement est **délibérément minimal** :
|
|
|
104
104
|
|
|
105
105
|
> [!NOTE]
|
|
106
106
|
> Le rendu **d'erreurs** ne passe **pas** par les templates : une exception devient un corps **JSON
|
|
107
|
-
> structuré** (`ErrorRenderer.renderHttp()`, `error-renderer.ts:
|
|
107
|
+
> structuré** (`ErrorRenderer.renderHttp()`, `error-renderer.ts:473`), jamais une page Eta. Le moteur
|
|
108
108
|
> de vues ne sert que le HTML **que tu rends explicitement**.
|
|
109
109
|
|
|
110
110
|
## 🚀 Démarrage rapide
|
|
@@ -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:
|
|
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:
|
|
222
|
-
| 2 | Ouverture de la phase mesurée `render` | `phaseStart("render")` (`Controller.ts:
|
|
223
|
-
| 3 | Injection des aides frontend dans les locals | `withFrontendLocals()` (`Controller.ts:
|
|
221
|
+
| 1 | Résolution + lecture async du fichier | `FileClass` dans `renderView()` (`Controller.ts:524`) |
|
|
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:
|
|
225
|
+
| 5 | `Content-Type: text/html` puis envoi | `setContextHtml()` + `renderResponse()` (`Controller.ts:503`) |
|
|
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:
|
|
229
|
+
priment (spread `param` en dernier, `Controller.ts:526`). 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:
|
|
272
|
-
| `render(data, encoding?, status?)` | Envoyer un corps quelconque (ex. HTML déjà prêt) | `Controller.ts:
|
|
273
|
-
| `renderJson(obj, status?)` | Réponse JSON explicite (pas un template) | `Controller.ts:
|
|
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:602` |
|
|
274
274
|
|
|
275
275
|
Les signatures exactes vivent dans le graphe symbolique `.ai/symbols.json` — jamais recopiées ici.
|
|
276
276
|
|
|
@@ -285,7 +285,7 @@ Le moteur est configuré **en dur**, pas via un bloc Zod exposé à `use()`. Tro
|
|
|
285
285
|
| `cache` | `true` en prod, `false` sinon | compile-once des templates en production ; recompile à chaud en dev | `Template.ts:20` |
|
|
286
286
|
|
|
287
287
|
Le cache n'est **pas** un booléen figé : `Template` le dérive de l'environnement du kernel
|
|
288
|
-
(`environment === "prod"`, `Template.ts:20`) puis `Eta` l'applique au moteur (`Eta.ts:
|
|
288
|
+
(`environment === "prod"`, `Template.ts:20`) puis `Eta` l'applique au moteur (`Eta.ts:34`). En
|
|
289
289
|
développement, un template modifié est donc pris en compte sans redémarrer.
|
|
290
290
|
|
|
291
291
|
## 🔌 HTTP et WebSocket — le même rendu
|
|
@@ -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:
|
|
312
|
+
| Type de média HTML | `text/html` | posé par `setContextHtml()` dans `renderView()` (`Controller.ts:524`) |
|
|
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:
|
|
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
|
-
`readFile` côté `renderFile`, `Eta.ts:
|
|
327
|
-
- **Aides frontend paresseuses** : `withFrontendLocals()` (`Controller.ts:
|
|
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: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:
|
|
347
|
+
| `renderView()` rejette et logge une ERROR | Fichier introuvable ou template invalide (le `catch` re-lève, `Controller.ts:524`) | 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-
|
|
3
|
+
"version": "10.0.0-beta.1",
|
|
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",
|
|
@@ -8,15 +8,16 @@
|
|
|
8
8
|
"types": "./dist/types/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
|
+
"nodefony-source": "./index.ts",
|
|
11
12
|
"types": "./dist/types/index.d.ts",
|
|
12
13
|
"import": "./dist/index.js",
|
|
13
14
|
"default": "./dist/index.js"
|
|
14
15
|
}
|
|
15
16
|
},
|
|
16
17
|
"scripts": {
|
|
17
|
-
"build": "
|
|
18
|
+
"build": "rolldown -c rolldown.config.ts && tsgo -p tsconfig.declarations.json",
|
|
18
19
|
"dev": "rolldown -c rolldown.config.ts --watch",
|
|
19
|
-
"clean": "
|
|
20
|
+
"clean": "node --eval \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
20
21
|
"test": "vitest run",
|
|
21
22
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
22
23
|
"coverage": "vitest run --coverage",
|
|
@@ -51,22 +52,21 @@
|
|
|
51
52
|
"tslib": "2.8.1"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
|
-
"@nodefony/http": "^10.0.0-
|
|
55
|
+
"@nodefony/http": "^10.0.0-beta.1",
|
|
55
56
|
"@types/chai": "5.2.3",
|
|
56
|
-
"@types/node": "26.6.
|
|
57
|
-
"@vitest/coverage-v8": "5.0.
|
|
57
|
+
"@types/node": "26.6.3",
|
|
58
|
+
"@vitest/coverage-v8": "5.0.2",
|
|
58
59
|
"chai": "6.2.2",
|
|
59
|
-
"nodefony": "^10.0.0-
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"vitest": "5.0.1"
|
|
60
|
+
"nodefony": "^10.0.0-beta.1",
|
|
61
|
+
"tsx": "4.23.15",
|
|
62
|
+
"vitest": "5.0.2"
|
|
63
63
|
},
|
|
64
64
|
"license": "Apache-2.0",
|
|
65
65
|
"readmeFilename": "README.md",
|
|
66
66
|
"contributors": [],
|
|
67
67
|
"peerDependencies": {
|
|
68
|
-
"@nodefony/http": "^10.0.0-
|
|
69
|
-
"nodefony": "^10.0.0-
|
|
68
|
+
"@nodefony/http": "^10.0.0-beta.1",
|
|
69
|
+
"nodefony": "^10.0.0-beta.1",
|
|
70
70
|
"zod": "^4.6.5"
|
|
71
71
|
},
|
|
72
72
|
"files": [
|