@nodefony/framework 10.0.0-beta.3 → 10.0.0-beta.4
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 -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 +38 -7
- 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 +22 -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 +66 -34
- package/dist/nodefony/src/ResourceController.js +2 -3
- 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 +1 -2
- package/dist/types/index.d.ts +5 -10
- package/dist/types/nodefony/controller/AdminApiController.d.ts +12 -0
- 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 +14 -16
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
- package/dist/types/nodefony/src/Resolver.d.ts +25 -1
- package/dist/types/nodefony/src/ResourceController.d.ts +1 -1
- 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/docs/admin.md +80 -80
- package/docs/controller.md +108 -109
- package/docs/decorateurs.md +208 -161
- package/docs/idempotence.md +133 -133
- package/docs/routing.md +90 -89
- 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/controller.md
CHANGED
|
@@ -41,16 +41,16 @@ flowchart LR
|
|
|
41
41
|
CT -->|"return valeur"| RC["returnController<br/>traduit le retour"]
|
|
42
42
|
RC --> OUT["réponse HTTP<br/>ou frame WS"]
|
|
43
43
|
CTX["Context HTTP / WS"] -.->|"request · response · session"| CT
|
|
44
|
-
DI["Container DI<br/>tes services"] -.->|"this.get() · @
|
|
44
|
+
DI["Container DI<br/>tes services"] -.->|"this.get() · @Inject"| CT
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Trois idées à retenir :
|
|
48
48
|
|
|
49
|
-
1. **Tu hérites de `Service`** — `Controller` étend `Service` (`Controller.ts
|
|
49
|
+
1. **Tu hérites de `Service`** — `Controller` étend `Service` (`Controller.ts#ControllerScope`). Tu récupères
|
|
50
50
|
donc gratuitement le container (`this.get()`), les logs (`this.log()`) et les événements.
|
|
51
51
|
2. **Tu ne construis rien toi-même** — le `Resolver` instancie ta classe via l'injecteur
|
|
52
|
-
(`Resolver.newController()`, `Resolver.ts
|
|
53
|
-
3. **Ton `return` EST la réponse** — `Resolver.returnController()` (`Resolver.ts
|
|
52
|
+
(`Resolver.newController()`, `Resolver.ts#Resolver.newController`), jamais un `new` direct.
|
|
53
|
+
3. **Ton `return` EST la réponse** — `Resolver.returnController()` (`Resolver.ts#Resolver.returnController`) traduit la
|
|
54
54
|
valeur retournée : objet → JSON, string → corps brut, `void` → « j'ai répondu moi-même ».
|
|
55
55
|
|
|
56
56
|
## 📖 Lexique
|
|
@@ -196,18 +196,18 @@ sequenceDiagram
|
|
|
196
196
|
|
|
197
197
|
Le tableau ci-dessous donne la séquence exacte, avec l'ancre qui la prouve :
|
|
198
198
|
|
|
199
|
-
| # | Étape | Où
|
|
200
|
-
| --- | --------------------------------------- |
|
|
201
|
-
| 1 | Appariement de la route | `router.resolve()` (`http-kernel.ts
|
|
202
|
-
| 2 | En-têtes de sécurité applicatifs | `applySecurityHeaders()` (`http-kernel.ts
|
|
203
|
-
| 3 | Parse du corps (sauf `@Body({stream})`) | `http-kernel.ts
|
|
204
|
-
| 4 | Armement de la route (sans instance) | `prepareFrontController()` (`http-kernel.ts
|
|
205
|
-
| 5 | CSRF | `firewall.enforceCsrf()` (`http-kernel.ts
|
|
206
|
-
| 6 | Session (reprise ou ouverture) | `HttpKernel.startSession()` (`http-kernel.ts
|
|
207
|
-
| 7 | Firewall — **authentification** | `firewall.handleSecurity()` (`http-kernel.ts
|
|
208
|
-
| 8 | Autorisation `@IsGranted` | `Resolver.executeAction()` (`Resolver.ts
|
|
209
|
-
| 9 | **Instanciation DI + `initialize()`** | `Resolver.executeAction()` (`Resolver.ts
|
|
210
|
-
| 10 | **Ton action** | `controller[methodKey]()` (`Resolver.ts
|
|
199
|
+
| # | Étape | Où |
|
|
200
|
+
| --- | --------------------------------------- | ------------------------------------------------------------------------------- |
|
|
201
|
+
| 1 | Appariement de la route | `router.resolve()` (`http-kernel.ts#HttpKernel.routeHttpRequest`) |
|
|
202
|
+
| 2 | En-têtes de sécurité applicatifs | `applySecurityHeaders()` (`http-kernel.ts#HttpKernel.routeHttpRequest`) |
|
|
203
|
+
| 3 | Parse du corps (sauf `@Body({stream})`) | `http-kernel.ts#HttpKernel.routeHttpRequest` |
|
|
204
|
+
| 4 | Armement de la route (sans instance) | `prepareFrontController()` (`http-kernel.ts#HttpKernel.prepareFrontController`) |
|
|
205
|
+
| 5 | CSRF | `firewall.enforceCsrf()` (`http-kernel.ts#HttpKernel.armAndGuard`) |
|
|
206
|
+
| 6 | Session (reprise ou ouverture) | `HttpKernel.startSession()` (`http-kernel.ts#HttpKernel.startSession`) |
|
|
207
|
+
| 7 | Firewall — **authentification** | `firewall.handleSecurity()` (`http-kernel.ts#HttpKernel.armAndGuard`) |
|
|
208
|
+
| 8 | Autorisation `@IsGranted` | `Resolver.executeAction()` (`Resolver.ts#Resolver.executeAction`) |
|
|
209
|
+
| 9 | **Instanciation DI + `initialize()`** | `Resolver.executeAction()` (`Resolver.ts#Resolver.executeAction`) |
|
|
210
|
+
| 10 | **Ton action** | `controller[methodKey]()` (`Resolver.ts#Resolver._createController`) |
|
|
211
211
|
|
|
212
212
|
> [!IMPORTANT]
|
|
213
213
|
> **Rien de ton contrôleur ne s'exécute pour une requête qui sera refusée.** L'appariement de route
|
|
@@ -222,8 +222,8 @@ Le tableau ci-dessous donne la séquence exacte, avec l'ancre qui la prouve :
|
|
|
222
222
|
C'est **sa raison d'être** : un `constructor` ne peut pas être `async`, et la résolution DI est
|
|
223
223
|
synchrone. Tout ce qui demande un `await` à la mise en place de l'instance n'a pas d'autre endroit
|
|
224
224
|
où aller. Le hook est **optionnel** — le Resolver ne l'appelle que s'il existe
|
|
225
|
-
(`Resolver._createController()`, `Resolver.ts
|
|
226
|
-
`ControllerWithInitialize` (`Resolver.ts
|
|
225
|
+
(`Resolver._createController()`, `Resolver.ts#Resolver._createController`). Son contrat est décrit par
|
|
226
|
+
`ControllerWithInitialize` (`Resolver.ts#ControllerWithInitialize`) : aucun argument, retour `Promise<this>`.
|
|
227
227
|
|
|
228
228
|
> [!IMPORTANT]
|
|
229
229
|
> **Sur un contrôleur singleton — le défaut —, `initialize()` tourne UNE fois**, à la création, dans
|
|
@@ -256,10 +256,10 @@ async initialize(): Promise<this> {
|
|
|
256
256
|
|
|
257
257
|
Si ton `initialize()` lève, l'exception remonte le pipeline et sort en réponse d'erreur cohérente :
|
|
258
258
|
**500 JSON**, serveur toujours sain. C'est prouvé par une sonde dédiée du dépôt
|
|
259
|
-
(`LifecycleController.initialize()`, `LifecycleController.ts
|
|
259
|
+
(`LifecycleController.initialize()`, `LifecycleController.ts#LifecycleController.initialize`, exercée par
|
|
260
260
|
`lifecycle-init-crash.test.ts`). Aucune requête pendue, aucun timeout muet. Pour un singleton,
|
|
261
261
|
une création en échec ne reste pas en cache : la requête suivante recrée l'instance
|
|
262
|
-
(`Router.getSingletonController()`, `router.ts
|
|
262
|
+
(`Router.getSingletonController()`, `router.ts#Router.getSingletonController`) — une base pas encore prête au premier appel ne
|
|
263
263
|
tue pas le contrôleur jusqu'au redémarrage.
|
|
264
264
|
|
|
265
265
|
### Les phases mesurées
|
|
@@ -267,7 +267,7 @@ tue pas le contrôleur jusqu'au redémarrage.
|
|
|
267
267
|
Chaque étape est chronométrée sous le nom d'une **phase**, lisible dans la debug bar et le profileur :
|
|
268
268
|
`resolve` · `initialize` (DI + ton hook) · `parse` · `firewall` · `action` · `render` · `send`.
|
|
269
269
|
La phase `initialize` existe précisément pour que le temps passé dans ton hook et dans la résolution
|
|
270
|
-
DI **soit imputé à quelqu'un** au lieu de disparaître dans le bloc `action` (`Resolver.ts
|
|
270
|
+
DI **soit imputé à quelqu'un** au lieu de disparaître dans le bloc `action` (`Resolver.ts#Resolver.action`).
|
|
271
271
|
|
|
272
272
|
## 🔌 HTTP et WebSocket — le même contrôleur
|
|
273
273
|
|
|
@@ -303,16 +303,16 @@ class ChatController extends Controller {
|
|
|
303
303
|
| --- | --- | --- |
|
|
304
304
|
| Durée de vie du contexte | Une requête | **Toute la connexion** |
|
|
305
305
|
| Instance du contrôleur | Singleton (défaut) : **la même** pour toutes les requêtes · `@Scope("request")` : une par requête | Singleton : **la même** pour toutes les connexions · `@Scope("request")` : **une par connexion**, réutilisée à chaque frame |
|
|
306
|
-
| Nombre d'appels d'action | 1 | 1 au handshake (`WebsocketContext.handle()`, `WebsocketContext.ts
|
|
307
|
-
| Argument de l'action | Variables de route (ou paramètres décorés) | Idem + **le message** en dernier argument (`WebsocketContext.ts
|
|
306
|
+
| Nombre d'appels d'action | 1 | 1 au handshake (`WebsocketContext.handle()`, `WebsocketContext.ts#WebsocketContext.handle`) + 1 par frame (`handleMessage()`, `WebsocketContext.ts#WebsocketContext.handleMessage`) |
|
|
307
|
+
| Argument de l'action | Variables de route (ou paramètres décorés) | Idem + **le message** en dernier argument (`WebsocketContext.ts#WebsocketContext.handleMessage`) |
|
|
308
308
|
| Rendu d'un `return` | Corps de la réponse | Frame envoyée sur la socket |
|
|
309
309
|
| Échec | Statut HTTP + corps d'erreur | **Code de fermeture** RFC 6455 (401/403 → 1008, 5xx → 1011, autre → 4004) ; chemin sans route WebSocket → **404 HTTP avant l'ouverture** |
|
|
310
310
|
| `initialize()` | Singleton : **une fois**, à la création · `@Scope("request")` : à chaque requête | Singleton : une fois, à la création · `@Scope("request")` : **une fois par connexion**, au handshake |
|
|
311
311
|
|
|
312
312
|
La réutilisation de l'instance vient du cache posé sur le container du contexte
|
|
313
|
-
(`Resolver.newController()`, `Resolver.ts
|
|
313
|
+
(`Resolver.newController()`, `Resolver.ts#Resolver.newController`) : le contexte WS étant partagé par la connexion, le
|
|
314
314
|
contrôleur l'est aussi. Un garde-fou vérifie que l'instance cachée est bien de la classe de la route
|
|
315
|
-
courante et la reconstruit sinon (`Resolver.ts
|
|
315
|
+
courante et la reconstruit sinon (`Resolver.ts#Resolver._pinController`) — sans quoi un message invoquant une autre
|
|
316
316
|
action se tromperait d'objet.
|
|
317
317
|
|
|
318
318
|
> [!WARNING]
|
|
@@ -321,28 +321,27 @@ action se tromperait d'objet.
|
|
|
321
321
|
> `@Scope("request")`, chaque requête HTTP repart d'une instance neuve ; un singleton, lui, refuse
|
|
322
322
|
> tout état de requête sur `this`.
|
|
323
323
|
|
|
324
|
-
Côté WebSocket, l'ordre est encore plus marqué : `HttpKernel.onConnect()` (`http-kernel.ts
|
|
324
|
+
Côté WebSocket, l'ordre est encore plus marqué : `HttpKernel.onConnect()` (`http-kernel.ts#HttpKernel.onConnect`)
|
|
325
325
|
appelle `handleFrontController()` (donc `initialize()`) **avant** `startSession()`
|
|
326
|
-
(`http-kernel.ts
|
|
327
|
-
(`http-kernel.ts
|
|
326
|
+
(`http-kernel.ts#HttpKernel.startSession`), avant l'acceptation de la socket, et avant le firewall
|
|
327
|
+
(`http-kernel.ts#HttpKernel.failTeardownHttp`).
|
|
328
328
|
|
|
329
329
|
## 🧠 D'où viennent `request`, `response`, `session`
|
|
330
330
|
|
|
331
331
|
Ton contrôleur expose des raccourcis vers le transport. Ils ne sont **pas** des copies figées : ce
|
|
332
332
|
sont des accesseurs qui dérivent du contexte **vivant**, selon le motif `champ ?? dérivation`.
|
|
333
333
|
|
|
334
|
-
| Raccourci
|
|
335
|
-
|
|
|
336
|
-
| `this.context`
|
|
337
|
-
| `this.route`
|
|
338
|
-
| `this.request`
|
|
339
|
-
| `this.response`
|
|
340
|
-
| `this.method`
|
|
341
|
-
| `this.
|
|
342
|
-
| `this.
|
|
343
|
-
| `this.
|
|
344
|
-
| `this.
|
|
345
|
-
| `this.session` | La session **ou `null`** si elle n'est pas activée | `Controller.ts:384` |
|
|
334
|
+
| Raccourci | Ce que tu obtiens | Ancre |
|
|
335
|
+
| --------------- | -------------------------------------------------- | ----------------------------------- |
|
|
336
|
+
| `this.context` | Le contexte transport de la requête courante | `Controller.ts#Controller.context` |
|
|
337
|
+
| `this.route` | La route matchée | `Controller.ts#Controller.route` |
|
|
338
|
+
| `this.request` | La requête (HTTP, HTTP/2 ou WS) | `Controller.ts#Controller.request` |
|
|
339
|
+
| `this.response` | La réponse du transport | `Controller.ts#Controller.response` |
|
|
340
|
+
| `this.method` | La méthode HTTP (ou `WEBSOCKET`) | `Controller.ts#Controller.method` |
|
|
341
|
+
| `this.query` | Les paramètres d'URL — **jamais** le corps | `Controller.ts#Controller.query` |
|
|
342
|
+
| `this.body` | Le corps parsé — **jamais** un paramètre d'URL | `Controller.ts#Controller.body` |
|
|
343
|
+
| `this.files` | Les fichiers uploadés | `Controller.ts#Controller.files` |
|
|
344
|
+
| `this.session` | La session **ou `null`** si elle n'est pas activée | `Controller.ts#Controller.session` |
|
|
346
345
|
|
|
347
346
|
Pourquoi des accesseurs plutôt que des champs recopiés au constructeur : **la fraîcheur et le coût**.
|
|
348
347
|
Une valeur recopiée vieillit dès que le pipeline modifie le contexte, et recopier quatre structures
|
|
@@ -351,7 +350,7 @@ gratuitement.
|
|
|
351
350
|
|
|
352
351
|
### La session est **lazy** — elle n'existe que si tu la demandes
|
|
353
352
|
|
|
354
|
-
`this.session` est un simple getter sur `context.session` (`Controller.ts
|
|
353
|
+
`this.session` est un simple getter sur `context.session` (`Controller.ts#Controller.session`). Il n'y a **pas** de
|
|
355
354
|
`startSession()` à appeler depuis un contrôleur : l'activation se déclare sur la route, avec
|
|
356
355
|
`@UseSession()` (ou un paramètre `@Session()`, qui vaut déclaration implicite), et le pipeline
|
|
357
356
|
l'exécute à son point unique. Sans déclaration et sans cookie de session entrant, **aucune session
|
|
@@ -360,26 +359,26 @@ n'est créée** — donc aucun coût de stockage.
|
|
|
360
359
|
Deux corollaires :
|
|
361
360
|
|
|
362
361
|
- Dans `initialize()`, `this.session` vaut `null` (l'activation vient plus tard — étape 6 du cycle).
|
|
363
|
-
- `this.getSession()` (`Controller.ts
|
|
362
|
+
- `this.getSession()` (`Controller.ts#Controller.getSession`) ne « démarre » rien : il retourne la session existante,
|
|
364
363
|
ou `undefined`.
|
|
365
364
|
|
|
366
|
-
Les messages flash s'appuient dessus : sans session active, `getFlashBag()` (`Controller.ts
|
|
367
|
-
journalise une **erreur** et retourne `null`, et `setFlashBag()`/`addFlash()` (`Controller.ts
|
|
365
|
+
Les messages flash s'appuient dessus : sans session active, `getFlashBag()` (`Controller.ts#Controller.getFlashBag`)
|
|
366
|
+
journalise une **erreur** et retourne `null`, et `setFlashBag()`/`addFlash()` (`Controller.ts#Controller.addFlash`)
|
|
368
367
|
retournent `null` sans rien journaliser — pas de crash, mais rien n'est mémorisé.
|
|
369
368
|
|
|
370
369
|
### Contrôleur singleton — le défaut : `this` n'est pas à toi
|
|
371
370
|
|
|
372
|
-
Par défaut, `Controller.scope` vaut `"singleton"` (`Controller.ts
|
|
371
|
+
Par défaut, `Controller.scope` vaut `"singleton"` (`Controller.ts#Controller.constructor`) : UNE instance par classe,
|
|
373
372
|
construite à sa première requête puis partagée par toutes les suivantes, concurrentes comprises.
|
|
374
373
|
|
|
375
374
|
- **une seule instance** pour tout le process, liée au container du **kernel**, pas à celui de la
|
|
376
|
-
requête (`Controller.ts
|
|
375
|
+
requête (`Controller.ts#Controller`) — capturer le container de requête serait fatal, il est nettoyé
|
|
377
376
|
à la fin de la requête ;
|
|
378
377
|
- **`initialize()` n'est appelé qu'une fois**, à la création (sémantique « démarrage ») ;
|
|
379
378
|
- le contexte n'est pas posé sur l'objet : `this.context` **lit l'ALS**
|
|
380
|
-
(`RequestContext.getContext()`, `Controller.ts
|
|
379
|
+
(`RequestContext.getContext()`, `Controller.ts#Controller.#assertPerRequest`) et retrouve donc la requête réellement en
|
|
381
380
|
cours, jamais celle d'une requête concurrente — y compris la route et la query d'un appel du pont
|
|
382
|
-
`api.request` (`Controller.ts
|
|
381
|
+
`api.request` (`Controller.ts#Controller.route`).
|
|
383
382
|
|
|
384
383
|
Deux filets rendent visible l'état de requête posé sur `this` : les accesseurs (`this.query = …`,
|
|
385
384
|
`setRoute()`…) **lèvent** dans tous les environnements, et **en développement** toute écriture sur
|
|
@@ -404,26 +403,26 @@ class MeController extends Controller {
|
|
|
404
403
|
|
|
405
404
|
## 🧰 Répondre — ce que ton `return` déclenche
|
|
406
405
|
|
|
407
|
-
Le traducteur unique est `Resolver.returnController()` (`Resolver.ts
|
|
406
|
+
Le traducteur unique est `Resolver.returnController()` (`Resolver.ts#Resolver.returnController`). Il regarde le **type**
|
|
408
407
|
de ce que tu as retourné :
|
|
409
408
|
|
|
410
409
|
<!-- prettier-ignore -->
|
|
411
410
|
| Tu retournes… | Ce qui se passe | Ancre |
|
|
412
411
|
| --- | --- | --- |
|
|
413
|
-
| Une `Promise` / un thenable | Déballée puis re-traitée (récursif) | `Resolver.ts
|
|
414
|
-
| Une `string` | Envoyée telle quelle en corps | `Resolver.ts
|
|
415
|
-
| Un objet simple ou un tableau | **Auto-JSON** : `application/json` + sérialisation | `Resolver.ts
|
|
416
|
-
| Un `number` / un `boolean` | Auto-JSON scalaire (RFC 8259 §2 : `42`, `true` sont des documents valides) | `Resolver.ts
|
|
417
|
-
| Un `Buffer` | Envoyé brut | `Resolver.ts
|
|
418
|
-
| Une `Response` (via un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts
|
|
419
|
-
| `void`/`null` **et** statut 204/205/304 | Réponse **vide envoyée** (RFC 9110 : ces statuts n'ont pas de corps) | `NO_BODY_STATUS` (`Resolver.ts
|
|
420
|
-
| `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts
|
|
421
|
-
| Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts
|
|
412
|
+
| Une `Promise` / un thenable | Déballée puis re-traitée (récursif) | `Resolver.ts#Resolver._areaSecurity` |
|
|
413
|
+
| Une `string` | Envoyée telle quelle en corps | `Resolver.ts#Resolver._callWithIdempotency` |
|
|
414
|
+
| Un objet simple ou un tableau | **Auto-JSON** : `application/json` + sérialisation | `Resolver.ts#Resolver.returnController` |
|
|
415
|
+
| Un `number` / un `boolean` | Auto-JSON scalaire (RFC 8259 §2 : `42`, `true` sont des documents valides) | `Resolver.ts#Resolver._callWithIdempotency` |
|
|
416
|
+
| Un `Buffer` | Envoyé brut | `Resolver.ts#Resolver.returnController` |
|
|
417
|
+
| Une `Response` (via un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts#Resolver._invokeAction` |
|
|
418
|
+
| `void`/`null` **et** statut 204/205/304 | Réponse **vide envoyée** (RFC 9110 : ces statuts n'ont pas de corps) | `NO_BODY_STATUS` (`Resolver.ts#NO_BODY_STATUS`) |
|
|
419
|
+
| `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts#Resolver.returnController` |
|
|
420
|
+
| Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts#Resolver.returnController` |
|
|
422
421
|
|
|
423
422
|
> [!WARNING]
|
|
424
423
|
> **Le piège n° 1 : `return null` sur un statut à corps.** Le framework l'interprète comme « je
|
|
425
424
|
> répondrai moi-même » et attend — jusqu'au timeout. La distinction se fait sur le **statut** :
|
|
426
|
-
> `NO_BODY_STATUS` (`Resolver.ts
|
|
425
|
+
> `NO_BODY_STATUS` (`Resolver.ts#NO_BODY_STATUS`) contient 204, 205 et 304. Donc un `@Delete` qui fait
|
|
427
426
|
> `@HttpCode(204)` puis `return null` répond bien 204 vide ; le même `return null` sans `@HttpCode`
|
|
428
427
|
> laisse la requête pendue.
|
|
429
428
|
|
|
@@ -434,27 +433,27 @@ volontairement pas passée à `JSON.stringify`. Retourne un objet simple — ou
|
|
|
434
433
|
|
|
435
434
|
Quand tu veux piloter l'envoi plutôt que retourner une valeur :
|
|
436
435
|
|
|
437
|
-
| Helper | Pour… | Ancre
|
|
438
|
-
| -------------------------------------------- | -------------------------------------------------------- |
|
|
439
|
-
| `renderJson(obj, status?, headers?)` | JSON explicite avec statut/en-têtes | `Controller.ts
|
|
440
|
-
| `render(data, encoding?, status?, headers?)` | Envoyer un corps quelconque via le contexte | `Controller.ts
|
|
441
|
-
| `renderView(path, params, status?)` | Rendre un template **Eta** (avec les helpers frontend) | `Controller.ts
|
|
442
|
-
| `renderResponse(data, encoding?, …)` | Poser statut + en-têtes, puis envoyer | `Controller.ts
|
|
443
|
-
| `redirect(url, status?, headers?)` | Rediriger | `Controller.ts
|
|
444
|
-
| `forward("module:controller:action")` | Déléguer à une autre action **sans** aller-retour réseau | `Controller.ts
|
|
445
|
-
| `setContextJson()` / `setContextHtml()` | Choisir le type de contenu avant d'envoyer | `Controller.ts
|
|
436
|
+
| Helper | Pour… | Ancre |
|
|
437
|
+
| -------------------------------------------- | -------------------------------------------------------- | ----------------------------------------- |
|
|
438
|
+
| `renderJson(obj, status?, headers?)` | JSON explicite avec statut/en-têtes | `Controller.ts#Controller.renderJson` |
|
|
439
|
+
| `render(data, encoding?, status?, headers?)` | Envoyer un corps quelconque via le contexte | `Controller.ts#Controller.render` |
|
|
440
|
+
| `renderView(path, params, status?)` | Rendre un template **Eta** (avec les helpers frontend) | `Controller.ts#Controller.renderView` |
|
|
441
|
+
| `renderResponse(data, encoding?, …)` | Poser statut + en-têtes, puis envoyer | `Controller.ts#Controller.renderResponse` |
|
|
442
|
+
| `redirect(url, status?, headers?)` | Rediriger | `Controller.ts#Controller.redirect` |
|
|
443
|
+
| `forward("module:controller:action")` | Déléguer à une autre action **sans** aller-retour réseau | `Controller.ts#Controller.forward` |
|
|
444
|
+
| `setContextJson()` / `setContextHtml()` | Choisir le type de contenu avant d'envoyer | `Controller.ts#Controller.setContextHtml` |
|
|
446
445
|
|
|
447
446
|
`renderView()` mesure sa propre phase `render` et injecte automatiquement les aides frontend
|
|
448
447
|
(`frontendTags`, `frontendDocument`, `asset`) dans les variables du template
|
|
449
|
-
(`withFrontendLocals()`, `Controller.ts
|
|
448
|
+
(`withFrontendLocals()`, `Controller.ts#Controller.withFrontendLocals`) — tes propres valeurs restent prioritaires.
|
|
450
449
|
|
|
451
450
|
`forward()` re-résout un contrôleur sur le **même** contexte et rappelle son action
|
|
452
|
-
(`Controller.ts
|
|
451
|
+
(`Controller.ts#Controller.forward`) : c'est une délégation interne, la requête cliente reste unique.
|
|
453
452
|
|
|
454
453
|
> [!TIP]
|
|
455
454
|
> **Redirection : le code par défaut est 302** (Found), pas 301. Un statut absent ou hors de la liste
|
|
456
455
|
> RFC 9110 §15.4 (301, 302, 303, 307, 308) retombe sur 302 avec un log d'avertissement
|
|
457
|
-
> (`Response.redirect()`, `Response.ts
|
|
456
|
+
> (`Response.redirect()`, `Response.ts#HttpResponse.redirect`). Un 301 par défaut piégeait : les navigateurs le mettent
|
|
458
457
|
> en cache de façon quasi irréversible.
|
|
459
458
|
|
|
460
459
|
## 📁 Servir un fichier — téléchargement et flux média
|
|
@@ -463,15 +462,15 @@ Deux besoins distincts, deux helpers.
|
|
|
463
462
|
|
|
464
463
|
### Téléchargement — `renderFileDownload()`
|
|
465
464
|
|
|
466
|
-
`renderFileDownload(file, options?, headers?)` (`Controller.ts
|
|
465
|
+
`renderFileDownload(file, options?, headers?)` (`Controller.ts#Controller.renderFileDownload`) pose
|
|
467
466
|
`Content-Disposition: attachment`, `Content-Length`, le type MIME du fichier, puis délègue au moteur
|
|
468
|
-
de flux. Le fichier est résolu **sans bloquer l'event loop** (`getFileAsync()`, `Controller.ts
|
|
467
|
+
de flux. Le fichier est résolu **sans bloquer l'event loop** (`getFileAsync()`, `Controller.ts#Controller.getFileAsync`) ;
|
|
469
468
|
la variante synchrone `getFile()` existe encore mais est marquée obsolète — elle appelle `lstatSync`
|
|
470
469
|
et gèle le process le temps du stat.
|
|
471
470
|
|
|
472
471
|
### Lecture en continu — `renderMediaStream()`
|
|
473
472
|
|
|
474
|
-
`renderMediaStream(file, headers?, options?)` (`Controller.ts
|
|
473
|
+
`renderMediaStream(file, headers?, options?)` (`Controller.ts#Controller.renderMediaStream`) implémente les **requêtes par
|
|
475
474
|
plage** (RFC 9110 §14), ce qui permet à un lecteur vidéo de sauter dans le flux :
|
|
476
475
|
|
|
477
476
|
| Le client envoie… | Réponse |
|
|
@@ -482,16 +481,16 @@ plage** (RFC 9110 §14), ce qui permet à un lecteur vidéo de sauter dans le fl
|
|
|
482
481
|
| Plage hors fichier | **416** + `Content-Range: bytes */<taille>` (RFC 9110 §15.5.17) |
|
|
483
482
|
| Syntaxe invalide, multi-plage, unité inconnue | En-tête **ignoré** → 200 complet (jamais un 500) |
|
|
484
483
|
|
|
485
|
-
La logique est isolée dans une fonction pure exportée, `parseByteRange()` (`Controller.ts
|
|
484
|
+
La logique est isolée dans une fonction pure exportée, `parseByteRange()` (`Controller.ts#parseByteRange`) —
|
|
486
485
|
donc testable sans serveur.
|
|
487
486
|
|
|
488
487
|
### Ce que `streamFile()` garantit
|
|
489
488
|
|
|
490
|
-
`streamFile()` (`Controller.ts
|
|
489
|
+
`streamFile()` (`Controller.ts#Controller.streamFile`) est le moteur commun. Sa subtilité n'est pas le pipe, c'est le
|
|
491
490
|
**nettoyage** : le flux est ouvert avec `autoClose: false`, et un client qui raccroche en plein
|
|
492
491
|
téléchargement laisserait sinon un descripteur de fichier ouvert et une promesse pendue à jamais. Un
|
|
493
492
|
écouteur `close` sur la réponse détruit le flux, ce qui déclenche la fermeture du descripteur et
|
|
494
|
-
résout la promesse (`Controller.ts
|
|
493
|
+
résout la promesse (`Controller.ts#Controller.streamFile`), puis se retire lui-même (`Controller.ts#Controller.streamFile`). Un
|
|
495
494
|
téléchargement interrompu ne coûte donc **rien** en ressource retenue.
|
|
496
495
|
|
|
497
496
|
## ⚠️ Erreurs — lever, rendre, observer
|
|
@@ -506,19 +505,19 @@ throw new nodefonyError("Article introuvable", 404); // statut porté par l'erre
|
|
|
506
505
|
throw new HttpError("Not Found", 404, this.context); // variante enrichie du contexte
|
|
507
506
|
```
|
|
508
507
|
|
|
509
|
-
L'exception remonte jusqu'à `HttpKernel.onError()` (`http-kernel.ts
|
|
508
|
+
L'exception remonte jusqu'à `HttpKernel.onError()` (`http-kernel.ts#HttpKernel.onError`), qui délègue la mise en
|
|
510
509
|
forme au rendeur d'erreurs. Ce qui en sort :
|
|
511
510
|
|
|
512
511
|
- **statut normalisé** — un code absent (ou l'ancien quirk `200`) devient **500**
|
|
513
|
-
(`normalizeHttpStatus()`, `error-renderer.ts
|
|
512
|
+
(`normalizeHttpStatus()`, `error-renderer.ts#DefaultErrorRenderer.normalizeHttpStatus`) ;
|
|
514
513
|
- **corps structuré** : `{ code, message, result: null, error: {…}, nodefony: {…} }`, l'enveloppe
|
|
515
514
|
`nodefony` portant l'environnement, l'URL et l'**identifiant de requête** — de quoi retrouver la
|
|
516
515
|
trace complète dans les logs ;
|
|
517
516
|
- **course gérée** : si le client est déjà parti (contexte terminé ou réponse envoyée), le framework
|
|
518
|
-
ne tente pas de rendre — il journalise et s'arrête (`http-kernel.ts
|
|
517
|
+
ne tente pas de rendre — il journalise et s'arrête (`http-kernel.ts#HttpKernel.applyRequestLoggerFromConfig`).
|
|
519
518
|
|
|
520
519
|
En **WebSocket**, il n'y a pas de statut : l'erreur devient un **code de fermeture** RFC 6455
|
|
521
|
-
(`renderWebsocket()`, `error-renderer.ts
|
|
520
|
+
(`renderWebsocket()`, `error-renderer.ts#DefaultErrorRenderer.renderWebsocket`) — 401/403 → 1008 (violation de politique),
|
|
522
521
|
5xx → 1011 (erreur interne), le reste → 4004 (plage privée). Si la socket n'est pas encore acceptée,
|
|
523
522
|
c'est un **rejet** de handshake. Un chemin qu'aucune route WebSocket ne sert n'atteint jamais ton
|
|
524
523
|
contrôleur : il reçoit un **404 HTTP** avant l'ouverture (RFC 6455 §4.2.2), le client ne voit donc
|
|
@@ -526,7 +525,7 @@ jamais d'`open`.
|
|
|
526
525
|
|
|
527
526
|
> [!NOTE]
|
|
528
527
|
> Les erreurs de ton action remontent **seules** : le Resolver n'enveloppe pas l'appel dans un
|
|
529
|
-
> `try/catch` inutile (`Resolver.ts
|
|
528
|
+
> `try/catch` inutile (`Resolver.ts#Resolver.callController`). Inutile d'attraper pour re-lever — sauf si tu veux
|
|
530
529
|
> vraiment traduire l'erreur en un autre statut.
|
|
531
530
|
|
|
532
531
|
## 🧩 Services injectés — trois façons
|
|
@@ -540,30 +539,30 @@ explicite :
|
|
|
540
539
|
const catalog = this.get<CatalogService>("catalog"); // null si absent ou container nettoyé
|
|
541
540
|
```
|
|
542
541
|
|
|
543
|
-
`Service.get()` (`Service.ts
|
|
542
|
+
`Service.get()` (`Service.ts#Service.get`) est une **façade sûre** : elle retourne `null` au lieu de lever si
|
|
544
543
|
le container a déjà été détaché. C'est le style à privilégier dans `initialize()`.
|
|
545
544
|
|
|
546
|
-
### 2. Injection par le constructeur — `@
|
|
545
|
+
### 2. Injection par le constructeur — `@Inject`
|
|
547
546
|
|
|
548
547
|
```typescript
|
|
549
|
-
import {
|
|
548
|
+
import { Inject, Fetch } from "nodefony";
|
|
550
549
|
|
|
551
550
|
@controller("/demo")
|
|
552
551
|
class DemoController extends Controller {
|
|
553
552
|
constructor(
|
|
554
553
|
context: ContextType,
|
|
555
|
-
@
|
|
554
|
+
@Inject("Fetch") private fetchService: Fetch,
|
|
556
555
|
) {
|
|
557
556
|
super("DemoController", context);
|
|
558
557
|
}
|
|
559
558
|
}
|
|
560
559
|
```
|
|
561
560
|
|
|
562
|
-
L'injecteur lit les noms déclarés par `@
|
|
563
|
-
de construire (`Injector._instantiateWithStack()`, `injector.ts
|
|
561
|
+
L'injecteur lit les noms déclarés par `@Inject` et résout chaque dépendance dans le container avant
|
|
562
|
+
de construire (`Injector._instantiateWithStack()`, `injector.ts#Injector._instantiateWithStack`). Le **contexte n'est pas une
|
|
564
563
|
dépendance** : il est passé en argument par le Resolver, et les paramètres non annotés le reçoivent
|
|
565
|
-
dans l'ordre (`injector.ts
|
|
566
|
-
chemin complet (`injector.ts
|
|
564
|
+
dans l'ordre (`injector.ts#Injector._resolveWithStack`). Les **dépendances circulaires sont détectées** et signalées avec le
|
|
565
|
+
chemin complet (`injector.ts#Injector._resolveWithStack`), jamais silencieusement.
|
|
567
566
|
|
|
568
567
|
### 3. Depuis le contexte — pour un service optionnel
|
|
569
568
|
|
|
@@ -580,30 +579,30 @@ Un contrôleur est sur le **hot path** : ce qu'il alloue est multiplié par le n
|
|
|
580
579
|
code du framework applique — et attend de toi — les règles suivantes :
|
|
581
580
|
|
|
582
581
|
- **Zéro recopie au constructeur** : l'état per-requête vit en champs privés `null` par défaut, et
|
|
583
|
-
les accesseurs dérivent du contexte (`Controller.ts
|
|
582
|
+
les accesseurs dérivent du contexte (`Controller.ts#parseByteRange`). Quatre allocations par requête ont
|
|
584
583
|
disparu de cette façon.
|
|
585
584
|
- **Zéro écouteur résiduel** : plus aucun `once("onRequestEnd")` n'est posé pour ré-échantillonner
|
|
586
585
|
l'état. Le seul écouteur restant est celui du flux de fichiers, explicitement retiré
|
|
587
|
-
(`Controller.ts
|
|
586
|
+
(`Controller.ts#Controller.renderView`).
|
|
588
587
|
- **Métadonnées d'action figées** : `@HttpCode`, `@Header`, les paramètres décorés et l'intention de
|
|
589
588
|
session sont calculés **une fois** par route puis mémorisés, au lieu d'être relus par `Reflect` à
|
|
590
|
-
chaque requête (`resolveActionMeta()` appelé en `Resolver.ts
|
|
589
|
+
chaque requête (`resolveActionMeta()` appelé en `Resolver.ts#Resolver.executeAction`).
|
|
591
590
|
- **Gardes payées seulement si présentes** : sans `@IsGranted`, la vérification d'autorisation est
|
|
592
|
-
un test de nullité (`Resolver.ts
|
|
591
|
+
un test de nullité (`Resolver.ts#Resolver._enforceSecurity`) — 0 lookup, 0 `await`, 0 allocation.
|
|
593
592
|
- **Ta part du contrat** : pas de structure allouée « au cas où » dans le constructeur ni dans
|
|
594
593
|
`initialize()`. Une valeur utile à 5 % des requêtes s'alloue à la demande.
|
|
595
594
|
|
|
596
595
|
## 📜 Normes appliquées
|
|
597
596
|
|
|
598
|
-
| Domaine | Norme | Comment le code s'y conforme
|
|
599
|
-
| -------------------------------- | ------------------------ |
|
|
600
|
-
| Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts
|
|
601
|
-
| Requêtes par plage | RFC 9110 §14.1.2, §14.2 | `parseByteRange()` (`Controller.ts
|
|
602
|
-
| Plage insatisfiable → 416 | RFC 9110 §15.5.17 | `renderResponse()` avec 416 (`Controller.ts
|
|
603
|
-
| Redirections | RFC 9110 §15.4 | Liste blanche + repli 302 (`Response.ts
|
|
604
|
-
| Média JSON sans `charset` | RFC 8259 §11 | Auto-JSON (`Resolver.ts
|
|
605
|
-
| Scalaire JSON de premier niveau | RFC 8259 §2 | `number`/`boolean` rendus (`Resolver.ts
|
|
606
|
-
| Codes de fermeture WebSocket | RFC 6455 §7.4 | `renderWebsocket()` (`error-renderer.ts
|
|
597
|
+
| Domaine | Norme | Comment le code s'y conforme |
|
|
598
|
+
| -------------------------------- | ------------------------ | ------------------------------------------------------------------------------------ |
|
|
599
|
+
| Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts#NO_BODY_STATUS`) |
|
|
600
|
+
| Requêtes par plage | RFC 9110 §14.1.2, §14.2 | `parseByteRange()` (`Controller.ts#parseByteRange`) |
|
|
601
|
+
| Plage insatisfiable → 416 | RFC 9110 §15.5.17 | `renderResponse()` avec 416 (`Controller.ts#Controller.renderResponse`) |
|
|
602
|
+
| Redirections | RFC 9110 §15.4 | Liste blanche + repli 302 (`Response.ts#HttpResponse.send`) |
|
|
603
|
+
| Média JSON sans `charset` | RFC 8259 §11 | Auto-JSON (`Resolver.ts#Resolver.returnController`), vérifié par le banc `auto-json` |
|
|
604
|
+
| Scalaire JSON de premier niveau | RFC 8259 §2 | `number`/`boolean` rendus (`Resolver.ts#Resolver._callWithIdempotency`) |
|
|
605
|
+
| Codes de fermeture WebSocket | RFC 6455 §7.4 | `renderWebsocket()` (`error-renderer.ts#DefaultErrorRenderer.renderWebsocket`) |
|
|
607
606
|
|
|
608
607
|
## 📡 Observabilité — Studio
|
|
609
608
|
|
|
@@ -620,17 +619,17 @@ code du framework applique — et attend de toi — les règles suivantes :
|
|
|
620
619
|
<!-- prettier-ignore -->
|
|
621
620
|
| Symptôme | Cause (dans le code) | Correction |
|
|
622
621
|
| --- | --- | --- |
|
|
623
|
-
| La requête pend puis expire, alors que l'action a bien tourné | `return null`/`undefined` avec un statut à corps → `waitAsync` (`Resolver.ts
|
|
624
|
-
| Réponse vide alors qu'on retourne une entité ORM | Instance de classe **non** sérialisée → `waitAsync` (`Resolver.ts
|
|
622
|
+
| La requête pend puis expire, alors que l'action a bien tourné | `return null`/`undefined` avec un statut à corps → `waitAsync` (`Resolver.ts#Resolver.returnController`) | Retourner une valeur, ou poser `@HttpCode(204)` |
|
|
623
|
+
| Réponse vide alors qu'on retourne une entité ORM | Instance de classe **non** sérialisée → `waitAsync` (`Resolver.ts#Resolver.returnController`) | Retourner un objet simple, ou `renderJson(entity.toJSON())` |
|
|
625
624
|
| `Route Action not found` | L'action porte un nom déjà utilisé par un membre de `Controller` | Renommer : `session`, `request`, `response`, `context`, `route`, `method`, `query*`, `get`, `set`, `render*`, `redirect`, `forward` sont réservés |
|
|
626
|
-
| `this.session` est `null` dans `initialize()` | La session est activée **après** (`http-kernel.ts
|
|
627
|
-
| Effet de bord exécuté pour une requête finalement 401 | `initialize()` tourne avant `firewall.handleSecurity()` (`http-kernel.ts
|
|
625
|
+
| `this.session` est `null` dans `initialize()` | La session est activée **après** (`http-kernel.ts#HttpKernel.serveHttpRequest`) | Lire la session dans l'action, pas dans le hook |
|
|
626
|
+
| Effet de bord exécuté pour une requête finalement 401 | `initialize()` tourne avant `firewall.handleSecurity()` (`http-kernel.ts#HttpKernel.armAndGuard`) | Déplacer l'effet de bord dans l'action |
|
|
628
627
|
| Redirection permanente non voulue | Un statut invalide retombe sur 302, un `301` explicite reste 301 | Passer le code voulu : `this.redirect(url, 302)` |
|
|
629
|
-
| WS : l'état d'une frame « bave » sur la suivante | L'instance est partagée — par TOUTES les connexions en singleton (le défaut), par toute la connexion en `@Scope("request")` (`Resolver.ts
|
|
628
|
+
| WS : l'état d'une frame « bave » sur la suivante | L'instance est partagée — par TOUTES les connexions en singleton (le défaut), par toute la connexion en `@Scope("request")` (`Resolver.ts#Resolver._buildParamArgs`) | Porter l'état par message, ou sur le contexte de la connexion |
|
|
630
629
|
| WS : l'action n'est jamais appelée | Route sans transport `WEBSOCKET` déclaré | `requirements: { methods: ["WEBSOCKET"] }` |
|
|
631
630
|
| Contrôleur singleton : données d'un autre utilisateur | État muté que les filets ne voient pas (`#privé`, objet muté) sur l'instance partagée | Passer par les arguments décorés, ou déclarer `@Scope("request")` |
|
|
632
631
|
| « Contrôleur … : écriture de « this.x » refusée » | Un état de requête écrit sur un singleton (le défaut) | Argument décoré, service `request`, ou `@Scope("request")` sur la classe |
|
|
633
|
-
| Event loop figé sur une route de fichier | `getFile()` synchrone (`lstatSync`, `Controller.ts
|
|
632
|
+
| Event loop figé sur une route de fichier | `getFile()` synchrone (`lstatSync`, `Controller.ts#Controller.forward`) | Utiliser `getFileAsync()` (`Controller.ts#Controller.getFileAsync`) |
|
|
634
633
|
|
|
635
634
|
## 🧪 Tests & couverture
|
|
636
635
|
|