@nodefony/framework 10.0.0-beta.2 → 10.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +9 -5
  4. package/dist/nodefony/controller/AdminApiController.js +7 -4
  5. package/dist/nodefony/controller/ApiKeyController.js +3 -3
  6. package/dist/nodefony/controller/LoginPageController.js +124 -0
  7. package/dist/nodefony/controller/OAuth2Controller.js +131 -14
  8. package/dist/nodefony/controller/SessionAuthController.js +37 -9
  9. package/dist/nodefony/controller/TokenAuthController.js +11 -4
  10. package/dist/nodefony/controller/TotpController.js +5 -5
  11. package/dist/nodefony/controller/WebAuthnController.js +26 -9
  12. package/dist/nodefony/decorators/routerDecorators.js +114 -13
  13. package/dist/nodefony/service/AdminBroker.js +2 -2
  14. package/dist/nodefony/service/IdempotencyStore.js +2 -2
  15. package/dist/nodefony/service/router.js +51 -22
  16. package/dist/nodefony/src/Controller.js +49 -4
  17. package/dist/nodefony/src/FrameworkAdminApi.js +2 -1
  18. package/dist/nodefony/src/KernelAdminApi.js +76 -10
  19. package/dist/nodefony/src/Resolver.js +39 -2
  20. package/dist/nodefony/src/Route.js +52 -5
  21. package/dist/nodefony/src/SecurityApiController.js +24 -0
  22. package/dist/nodefony/src/docsReader.js +38 -21
  23. package/dist/nodefony/src/idempotency.js +13 -20
  24. package/dist/nodefony/src/loginPage.js +431 -0
  25. package/dist/nodefony/src/requireAuthentication.js +36 -0
  26. package/dist/types/index.d.ts +4 -3
  27. package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -2
  28. package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
  29. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
  30. package/dist/types/nodefony/controller/SessionAuthController.d.ts +39 -8
  31. package/dist/types/nodefony/controller/TokenAuthController.d.ts +6 -4
  32. package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
  33. package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
  34. package/dist/types/nodefony/decorators/routerDecorators.d.ts +88 -3
  35. package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
  36. package/dist/types/nodefony/service/router.d.ts +13 -0
  37. package/dist/types/nodefony/src/Controller.d.ts +40 -1
  38. package/dist/types/nodefony/src/Resolver.d.ts +19 -0
  39. package/dist/types/nodefony/src/Route.d.ts +18 -1
  40. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
  41. package/dist/types/nodefony/src/docsReader.d.ts +10 -6
  42. package/dist/types/nodefony/src/idempotency.d.ts +12 -10
  43. package/dist/types/nodefony/src/loginPage.d.ts +144 -0
  44. package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
  45. package/docs/admin.md +28 -23
  46. package/docs/controller.md +76 -74
  47. package/docs/decorateurs.md +69 -67
  48. package/docs/idempotence.md +30 -28
  49. package/docs/routing.md +52 -52
  50. package/docs/templates.md +11 -11
  51. package/package.json +11 -11
@@ -49,8 +49,8 @@ Trois idées à retenir :
49
49
  1. **Tu hérites de `Service`** — `Controller` étend `Service` (`Controller.ts:146`). 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:315`), jamais un `new` direct.
53
- 3. **Ton `return` EST la réponse** — `Resolver.returnController()` (`Resolver.ts:1034`) traduit la
52
+ (`Resolver.newController()`, `Resolver.ts:319`), jamais un `new` direct.
53
+ 3. **Ton `return` EST la réponse** — `Resolver.returnController()` (`Resolver.ts:1039`) 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
@@ -198,16 +198,16 @@ Le tableau ci-dessous donne la séquence exacte, avec l'ancre qui la prouve :
198
198
 
199
199
  | # | Étape | Où |
200
200
  | --- | --------------------------------------- | --------------------------------------------------- |
201
- | 1 | Appariement de la route | `router.resolve()` (`http-kernel.ts:807`) |
202
- | 2 | En-têtes de sécurité applicatifs | `applySecurityHeaders()` (`http-kernel.ts:1624`) |
203
- | 3 | Parse du corps (sauf `@Body({stream})`) | `http-kernel.ts:1606` |
204
- | 4 | Armement de la route (sans instance) | `prepareFrontController()` (`http-kernel.ts:811`) |
205
- | 5 | CSRF | `firewall.enforceCsrf()` (`http-kernel.ts:1747`) |
206
- | 6 | Session (reprise ou ouverture) | `HttpKernel.startSession()` (`http-kernel.ts:1246`) |
207
- | 7 | Firewall — **authentification** | `firewall.handleSecurity()` (`http-kernel.ts:1772`) |
208
- | 8 | Autorisation `@IsGranted` | `Resolver.executeAction()` (`Resolver.ts:446`) |
209
- | 9 | **Instanciation DI + `initialize()`** | `Resolver.executeAction()` (`Resolver.ts:446`) |
210
- | 10 | **Ton action** | `controller[methodKey]()` (`Resolver.ts:382`) |
201
+ | 1 | Appariement de la route | `router.resolve()` (`http-kernel.ts:1717`) |
202
+ | 2 | En-têtes de sécurité applicatifs | `applySecurityHeaders()` (`http-kernel.ts:1727`) |
203
+ | 3 | Parse du corps (sauf `@Body({stream})`) | `http-kernel.ts:1709` |
204
+ | 4 | Armement de la route (sans instance) | `prepareFrontController()` (`http-kernel.ts:876`) |
205
+ | 5 | CSRF | `firewall.enforceCsrf()` (`http-kernel.ts:1850`) |
206
+ | 6 | Session (reprise ou ouverture) | `HttpKernel.startSession()` (`http-kernel.ts:1330`) |
207
+ | 7 | Firewall — **authentification** | `firewall.handleSecurity()` (`http-kernel.ts:1865`) |
208
+ | 8 | Autorisation `@IsGranted` | `Resolver.executeAction()` (`Resolver.ts:450`) |
209
+ | 9 | **Instanciation DI + `initialize()`** | `Resolver.executeAction()` (`Resolver.ts:450`) |
210
+ | 10 | **Ton action** | `controller[methodKey]()` (`Resolver.ts:386`) |
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:330`). Son contrat est décrit par
226
- `ControllerWithInitialize` (`Resolver.ts:72`) : aucun argument, retour `Promise<this>`.
225
+ (`Resolver._createController()`, `Resolver.ts:334`). Son contrat est décrit par
226
+ `ControllerWithInitialize` (`Resolver.ts:76`) : 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
@@ -259,7 +259,7 @@ Si ton `initialize()` lève, l'exception remonte le pipeline et sort en réponse
259
259
  (`LifecycleController.initialize()`, `LifecycleController.ts:21`, 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:197`) — une base pas encore prête au premier appel ne
262
+ (`Router.getSingletonController()`, `router.ts:232`) — 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:112`).
270
+ DI **soit imputé à quelqu'un** au lieu de disparaître dans le bloc `action` (`Resolver.ts:116`).
271
271
 
272
272
  ## 🔌 HTTP et WebSocket — le même contrôleur
273
273
 
@@ -306,13 +306,13 @@ class ChatController extends Controller {
306
306
  | Nombre d'appels d'action | 1 | 1 au handshake (`WebsocketContext.handle()`, `WebsocketContext.ts:297`) + 1 par frame (`handleMessage()`, `WebsocketContext.ts:507`) |
307
307
  | Argument de l'action | Variables de route (ou paramètres décorés) | Idem + **le message** en dernier argument (`WebsocketContext.ts:508`) |
308
308
  | Rendu d'un `return` | Corps de la réponse | Frame envoyée sur la socket |
309
- | Échec | Statut HTTP + corps d'erreur | **Code de fermeture** RFC 6455 (401/403 → 1008, 5xx → 1011, autre → 4004) |
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:315`) : le contexte WS étant partagé par la connexion, le
313
+ (`Resolver.newController()`, `Resolver.ts:319`) : 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:344-347`) — sans quoi un message invoquant une autre
315
+ courante et la reconstruit sinon (`Resolver.ts:348-351`) — sans quoi un message invoquant une autre
316
316
  action se tromperait d'objet.
317
317
 
318
318
  > [!WARNING]
@@ -321,10 +321,10 @@ 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:2008`)
324
+ Côté WebSocket, l'ordre est encore plus marqué : `HttpKernel.onConnect()` (`http-kernel.ts:2098`)
325
325
  appelle `handleFrontController()` (donc `initialize()`) **avant** `startSession()`
326
- (`http-kernel.ts:1246`), avant l'acceptation de la socket, et avant le firewall
327
- (`http-kernel.ts:1457`).
326
+ (`http-kernel.ts:1330`), avant l'acceptation de la socket, et avant le firewall
327
+ (`http-kernel.ts:1488`).
328
328
 
329
329
  ## 🧠 D'où viennent `request`, `response`, `session`
330
330
 
@@ -334,14 +334,14 @@ sont des accesseurs qui dérivent du contexte **vivant**, selon le motif `champ
334
334
  | Raccourci | Ce que tu obtiens | Ancre |
335
335
  | ---------------- | -------------------------------------------------- | ------------------- |
336
336
  | `this.context` | Le contexte transport de la requête courante | `Controller.ts:238` |
337
- | `this.route` | La route matchée | `Controller.ts:202` |
337
+ | `this.route` | La route matchée | `Controller.ts:256` |
338
338
  | `this.request` | La requête (HTTP, HTTP/2 ou WS) | `Controller.ts:269` |
339
339
  | `this.response` | La réponse du transport | `Controller.ts:277` |
340
340
  | `this.method` | La méthode HTTP (ou `WEBSOCKET`) | `Controller.ts:212` |
341
- | `this.queryGet` | Les paramètres de la query string | `Controller.ts:302` |
342
- | `this.queryPost` | Le corps parsé | `Controller.ts:351` |
343
- | `this.body` | Le corps parsé — alias de `queryPost` | `Controller.ts:351` |
344
- | `this.queryFile` | Les fichiers uploadés | `Controller.ts:341` |
341
+ | `this.queryGet` | Les paramètres de la query string | `Controller.ts:315` |
342
+ | `this.queryPost` | Le corps parsé | `Controller.ts:364` |
343
+ | `this.body` | Le corps parsé — alias de `queryPost` | `Controller.ts:364` |
344
+ | `this.queryFile` | Les fichiers uploadés | `Controller.ts:354` |
345
345
  | `this.session` | La session **ou `null`** si elle n'est pas activée | `Controller.ts:384` |
346
346
 
347
347
  Pourquoi des accesseurs plutôt que des champs recopiés au constructeur : **la fraîcheur et le coût**.
@@ -351,7 +351,7 @@ gratuitement.
351
351
 
352
352
  ### La session est **lazy** — elle n'existe que si tu la demandes
353
353
 
354
- `this.session` est un simple getter sur `context.session` (`Controller.ts:384`). Il n'y a **pas** de
354
+ `this.session` est un simple getter sur `context.session` (`Controller.ts:397`). Il n'y a **pas** de
355
355
  `startSession()` à appeler depuis un contrôleur : l'activation se déclare sur la route, avec
356
356
  `@UseSession()` (ou un paramètre `@Session()`, qui vaut déclaration implicite), et le pipeline
357
357
  l'exécute à son point unique. Sans déclaration et sans cookie de session entrant, **aucune session
@@ -360,16 +360,16 @@ n'est créée** — donc aucun coût de stockage.
360
360
  Deux corollaires :
361
361
 
362
362
  - Dans `initialize()`, `this.session` vaut `null` (l'activation vient plus tard — étape 6 du cycle).
363
- - `this.getSession()` (`Controller.ts:657`) ne « démarre » rien : il retourne la session existante,
363
+ - `this.getSession()` (`Controller.ts:704`) ne « démarre » rien : il retourne la session existante,
364
364
  ou `undefined`.
365
365
 
366
- Les messages flash s'appuient dessus : sans session active, `getFlashBag()` (`Controller.ts:677`)
367
- journalise une **erreur** et retourne `null`, et `setFlashBag()`/`addFlash()` (`Controller.ts:693`)
366
+ Les messages flash s'appuient dessus : sans session active, `getFlashBag()` (`Controller.ts:724`)
367
+ journalise une **erreur** et retourne `null`, et `setFlashBag()`/`addFlash()` (`Controller.ts:740`)
368
368
  retournent `null` sans rien journaliser — pas de crash, mais rien n'est mémorisé.
369
369
 
370
370
  ### Contrôleur singleton — le défaut : `this` n'est pas à toi
371
371
 
372
- Par défaut, `Controller.scope` vaut `"singleton"` (`Controller.ts:182`) : UNE instance par classe,
372
+ Par défaut, `Controller.scope` vaut `"singleton"` (`Controller.ts:412`) : UNE instance par classe,
373
373
  construite à sa première requête puis partagée par toutes les suivantes, concurrentes comprises.
374
374
 
375
375
  - **une seule instance** pour tout le process, liée au container du **kernel**, pas à celui de la
@@ -404,26 +404,26 @@ class MeController extends Controller {
404
404
 
405
405
  ## 🧰 Répondre — ce que ton `return` déclenche
406
406
 
407
- Le traducteur unique est `Resolver.returnController()` (`Resolver.ts:1034`). Il regarde le **type**
407
+ Le traducteur unique est `Resolver.returnController()` (`Resolver.ts:1039`). Il regarde le **type**
408
408
  de ce que tu as retourné :
409
409
 
410
410
  <!-- prettier-ignore -->
411
411
  | Tu retournes… | Ce qui se passe | Ancre |
412
412
  | --- | --- | --- |
413
- | Une `Promise` / un thenable | Déballée puis re-traitée (récursif) | `Resolver.ts:830-840` |
414
- | Une `string` | Envoyée telle quelle en corps | `Resolver.ts:711` |
415
- | Un objet simple ou un tableau | **Auto-JSON** : `application/json` + sérialisation | `Resolver.ts:1081` |
416
- | Un `number` / un `boolean` | Auto-JSON scalaire (RFC 8259 §2 : `42`, `true` sont des documents valides) | `Resolver.ts:734` |
417
- | Un `Buffer` | Envoyé brut | `Resolver.ts:1074` |
418
- | Une `Response` (via un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts:581` |
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:1168`) |
420
- | `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts:1147` |
421
- | Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts:1108-1117` |
413
+ | Une `Promise` / un thenable | Déballée puis re-traitée (récursif) | `Resolver.ts:848-858` |
414
+ | Une `string` | Envoyée telle quelle en corps | `Resolver.ts:716` |
415
+ | Un objet simple ou un tableau | **Auto-JSON** : `application/json` + sérialisation | `Resolver.ts:1086` |
416
+ | Un `number` / un `boolean` | Auto-JSON scalaire (RFC 8259 §2 : `42`, `true` sont des documents valides) | `Resolver.ts:739` |
417
+ | Un `Buffer` | Envoyé brut | `Resolver.ts:1079` |
418
+ | Une `Response` (via un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts:599` |
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:1229`) |
420
+ | `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts:1165` |
421
+ | Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts:1113-1122` |
422
422
 
423
423
  > [!WARNING]
424
424
  > **Le piège n° 1 : `return null` sur un statut à corps.** Le framework l'interprète comme « je
425
425
  > répondrai moi-même » et attend — jusqu'au timeout. La distinction se fait sur le **statut** :
426
- > `NO_BODY_STATUS` (`Resolver.ts:1168`) contient 204, 205 et 304. Donc un `@Delete` qui fait
426
+ > `NO_BODY_STATUS` (`Resolver.ts:1229`) contient 204, 205 et 304. Donc un `@Delete` qui fait
427
427
  > `@HttpCode(204)` puis `return null` répond bien 204 vide ; le même `return null` sans `@HttpCode`
428
428
  > laisse la requête pendue.
429
429
 
@@ -436,25 +436,25 @@ Quand tu veux piloter l'envoi plutôt que retourner une valeur :
436
436
 
437
437
  | Helper | Pour… | Ancre |
438
438
  | -------------------------------------------- | -------------------------------------------------------- | ------------------- |
439
- | `renderJson(obj, status?, headers?)` | JSON explicite avec statut/en-têtes | `Controller.ts:629` |
440
- | `render(data, encoding?, status?, headers?)` | Envoyer un corps quelconque via le contexte | `Controller.ts:485` |
441
- | `renderView(path, params, status?)` | Rendre un template **Eta** (avec les helpers frontend) | `Controller.ts:544` |
442
- | `renderResponse(data, encoding?, …)` | Poser statut + en-têtes, puis envoyer | `Controller.ts:523` |
443
- | `redirect(url, status?, headers?)` | Rediriger | `Controller.ts:663` |
444
- | `forward("module:controller:action")` | Déléguer à une autre action **sans** aller-retour réseau | `Controller.ts:697` |
445
- | `setContextJson()` / `setContextHtml()` | Choisir le type de contenu avant d'envoyer | `Controller.ts:430` |
439
+ | `renderJson(obj, status?, headers?)` | JSON explicite avec statut/en-têtes | `Controller.ts:631` |
440
+ | `render(data, encoding?, status?, headers?)` | Envoyer un corps quelconque via le contexte | `Controller.ts:499` |
441
+ | `renderView(path, params, status?)` | Rendre un template **Eta** (avec les helpers frontend) | `Controller.ts:556` |
442
+ | `renderResponse(data, encoding?, …)` | Poser statut + en-têtes, puis envoyer | `Controller.ts:535` |
443
+ | `redirect(url, status?, headers?)` | Rediriger | `Controller.ts:710` |
444
+ | `forward("module:controller:action")` | Déléguer à une autre action **sans** aller-retour réseau | `Controller.ts:744` |
445
+ | `setContextJson()` / `setContextHtml()` | Choisir le type de contenu avant d'envoyer | `Controller.ts:443` |
446
446
 
447
447
  `renderView()` mesure sa propre phase `render` et injecte automatiquement les aides frontend
448
448
  (`frontendTags`, `frontendDocument`, `asset`) dans les variables du template
449
- (`withFrontendLocals()`, `Controller.ts:562`) — tes propres valeurs restent prioritaires.
449
+ (`withFrontendLocals()`, `Controller.ts:594`) — tes propres valeurs restent prioritaires.
450
450
 
451
451
  `forward()` re-résout un contrôleur sur le **même** contexte et rappelle son action
452
- (`Controller.ts:697`) : c'est une délégation interne, la requête cliente reste unique.
452
+ (`Controller.ts:744`) : c'est une délégation interne, la requête cliente reste unique.
453
453
 
454
454
  > [!TIP]
455
455
  > **Redirection : le code par défaut est 302** (Found), pas 301. Un statut absent ou hors de la liste
456
456
  > RFC 9110 §15.4 (301, 302, 303, 307, 308) retombe sur 302 avec un log d'avertissement
457
- > (`Response.redirect()`, `Response.ts:735`). Un 301 par défaut piégeait : les navigateurs le mettent
457
+ > (`Response.redirect()`, `Response.ts:755`). Un 301 par défaut piégeait : les navigateurs le mettent
458
458
  > en cache de façon quasi irréversible.
459
459
 
460
460
  ## 📁 Servir un fichier — téléchargement et flux média
@@ -463,15 +463,15 @@ Deux besoins distincts, deux helpers.
463
463
 
464
464
  ### Téléchargement — `renderFileDownload()`
465
465
 
466
- `renderFileDownload(file, options?, headers?)` (`Controller.ts:788`) pose
466
+ `renderFileDownload(file, options?, headers?)` (`Controller.ts:825`) pose
467
467
  `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:749`) ;
468
+ de flux. Le fichier est résolu **sans bloquer l'event loop** (`getFileAsync()`, `Controller.ts:786`) ;
469
469
  la variante synchrone `getFile()` existe encore mais est marquée obsolète — elle appelle `lstatSync`
470
470
  et gèle le process le temps du stat.
471
471
 
472
472
  ### Lecture en continu — `renderMediaStream()`
473
473
 
474
- `renderMediaStream(file, headers?, options?)` (`Controller.ts:952`) implémente les **requêtes par
474
+ `renderMediaStream(file, headers?, options?)` (`Controller.ts:999`) implémente les **requêtes par
475
475
  plage** (RFC 9110 §14), ce qui permet à un lecteur vidéo de sauter dans le flux :
476
476
 
477
477
  | Le client envoie… | Réponse |
@@ -487,11 +487,11 @@ donc testable sans serveur.
487
487
 
488
488
  ### Ce que `streamFile()` garantit
489
489
 
490
- `streamFile()` (`Controller.ts:828`) est le moteur commun. Sa subtilité n'est pas le pipe, c'est le
490
+ `streamFile()` (`Controller.ts:865`) est le moteur commun. Sa subtilité n'est pas le pipe, c'est le
491
491
  **nettoyage** : le flux est ouvert avec `autoClose: false`, et un client qui raccroche en plein
492
492
  téléchargement laisserait sinon un descripteur de fichier ouvert et une promesse pendue à jamais. Un
493
493
  é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:762-804`), puis se retire lui-même (`Controller.ts:746`). Un
494
+ résout la promesse (`Controller.ts:931-969`), puis se retire lui-même (`Controller.ts:961`). Un
495
495
  téléchargement interrompu ne coûte donc **rien** en ressource retenue.
496
496
 
497
497
  ## ⚠️ Erreurs — lever, rendre, observer
@@ -506,7 +506,7 @@ throw new nodefonyError("Article introuvable", 404); // statut porté par l'erre
506
506
  throw new HttpError("Not Found", 404, this.context); // variante enrichie du contexte
507
507
  ```
508
508
 
509
- L'exception remonte jusqu'à `HttpKernel.onError()` (`http-kernel.ts:918`), qui délègue la mise en
509
+ L'exception remonte jusqu'à `HttpKernel.onError()` (`http-kernel.ts:994`), qui délègue la mise en
510
510
  forme au rendeur d'erreurs. Ce qui en sort :
511
511
 
512
512
  - **statut normalisé** — un code absent (ou l'ancien quirk `200`) devient **500**
@@ -515,16 +515,18 @@ forme au rendeur d'erreurs. Ce qui en sort :
515
515
  `nodefony` portant l'environnement, l'URL et l'**identifiant de requête** — de quoi retrouver la
516
516
  trace complète dans les logs ;
517
517
  - **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:770-775`).
518
+ ne tente pas de rendre — il journalise et s'arrête (`http-kernel.ts:782-787`).
519
519
 
520
520
  En **WebSocket**, il n'y a pas de statut : l'erreur devient un **code de fermeture** RFC 6455
521
521
  (`renderWebsocket()`, `error-renderer.ts:518`) — 401/403 → 1008 (violation de politique),
522
522
  5xx → 1011 (erreur interne), le reste → 4004 (plage privée). Si la socket n'est pas encore acceptée,
523
- c'est un **rejet** de handshake.
523
+ c'est un **rejet** de handshake. Un chemin qu'aucune route WebSocket ne sert n'atteint jamais ton
524
+ contrôleur : il reçoit un **404 HTTP** avant l'ouverture (RFC 6455 §4.2.2), le client ne voit donc
525
+ jamais d'`open`.
524
526
 
525
527
  > [!NOTE]
526
528
  > Les erreurs de ton action remontent **seules** : le Resolver n'enveloppe pas l'appel dans un
527
- > `try/catch` inutile (`Resolver.ts:625-626`). Inutile d'attraper pour re-lever — sauf si tu veux
529
+ > `try/catch` inutile (`Resolver.ts:630-631`). Inutile d'attraper pour re-lever — sauf si tu veux
528
530
  > vraiment traduire l'erreur en un autre statut.
529
531
 
530
532
  ## 🧩 Services injectés — trois façons
@@ -585,9 +587,9 @@ code du framework applique — et attend de toi — les règles suivantes :
585
587
  (`Controller.ts:574`).
586
588
  - **Métadonnées d'action figées** : `@HttpCode`, `@Header`, les paramètres décorés et l'intention de
587
589
  session sont calculés **une fois** par route puis mémorisés, au lieu d'être relus par `Reflect` à
588
- chaque requête (`resolveActionMeta()` appelé en `Resolver.ts:458`).
590
+ chaque requête (`resolveActionMeta()` appelé en `Resolver.ts:462`).
589
591
  - **Gardes payées seulement si présentes** : sans `@IsGranted`, la vérification d'autorisation est
590
- un test de nullité (`Resolver.ts:890`) — 0 lookup, 0 `await`, 0 allocation.
592
+ un test de nullité (`Resolver.ts:895`) — 0 lookup, 0 `await`, 0 allocation.
591
593
  - **Ta part du contrat** : pas de structure allouée « au cas où » dans le constructeur ni dans
592
594
  `initialize()`. Une valeur utile à 5 % des requêtes s'alloue à la demande.
593
595
 
@@ -595,12 +597,12 @@ code du framework applique — et attend de toi — les règles suivantes :
595
597
 
596
598
  | Domaine | Norme | Comment le code s'y conforme |
597
599
  | -------------------------------- | ------------------------ | --------------------------------------------------------------- |
598
- | Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts:1168`) |
600
+ | Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts:1229`) |
599
601
  | Requêtes par plage | RFC 9110 §14.1.2, §14.2 | `parseByteRange()` (`Controller.ts:107`) |
600
- | Plage insatisfiable → 416 | RFC 9110 §15.5.17 | `renderResponse()` avec 416 (`Controller.ts:523`) |
602
+ | Plage insatisfiable → 416 | RFC 9110 §15.5.17 | `renderResponse()` avec 416 (`Controller.ts:535`) |
601
603
  | Redirections | RFC 9110 §15.4 | Liste blanche + repli 302 (`Response.ts:666`) |
602
- | Média JSON sans `charset` | RFC 8259 §11 | Auto-JSON (`Resolver.ts:1081`), vérifié par le banc `auto-json` |
603
- | Scalaire JSON de premier niveau | RFC 8259 §2 | `number`/`boolean` rendus (`Resolver.ts:734`) |
604
+ | Média JSON sans `charset` | RFC 8259 §11 | Auto-JSON (`Resolver.ts:1086`), vérifié par le banc `auto-json` |
605
+ | Scalaire JSON de premier niveau | RFC 8259 §2 | `number`/`boolean` rendus (`Resolver.ts:739`) |
604
606
  | Codes de fermeture WebSocket | RFC 6455 §7.4 | `renderWebsocket()` (`error-renderer.ts:518`) |
605
607
 
606
608
  ## 📡 Observabilité — Studio
@@ -618,17 +620,17 @@ code du framework applique — et attend de toi — les règles suivantes :
618
620
  <!-- prettier-ignore -->
619
621
  | Symptôme | Cause (dans le code) | Correction |
620
622
  | --- | --- | --- |
621
- | La requête pend puis expire, alors que l'action a bien tourné | `return null`/`undefined` avec un statut à corps → `waitAsync` (`Resolver.ts:1147`) | Retourner une valeur, ou poser `@HttpCode(204)` |
622
- | Réponse vide alors qu'on retourne une entité ORM | Instance de classe **non** sérialisée → `waitAsync` (`Resolver.ts:1117`) | Retourner un objet simple, ou `renderJson(entity.toJSON())` |
623
+ | La requête pend puis expire, alors que l'action a bien tourné | `return null`/`undefined` avec un statut à corps → `waitAsync` (`Resolver.ts:1165`) | Retourner une valeur, ou poser `@HttpCode(204)` |
624
+ | Réponse vide alors qu'on retourne une entité ORM | Instance de classe **non** sérialisée → `waitAsync` (`Resolver.ts:1122`) | Retourner un objet simple, ou `renderJson(entity.toJSON())` |
623
625
  | `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 |
624
- | `this.session` est `null` dans `initialize()` | La session est activée **après** (`http-kernel.ts:1752`) | Lire la session dans l'action, pas dans le hook |
625
- | Effet de bord exécuté pour une requête finalement 401 | `initialize()` tourne avant `firewall.handleSecurity()` (`http-kernel.ts:1772`) | Déplacer l'effet de bord dans l'action |
626
+ | `this.session` est `null` dans `initialize()` | La session est activée **après** (`http-kernel.ts:1783`) | Lire la session dans l'action, pas dans le hook |
627
+ | Effet de bord exécuté pour une requête finalement 401 | `initialize()` tourne avant `firewall.handleSecurity()` (`http-kernel.ts:1865`) | Déplacer l'effet de bord dans l'action |
626
628
  | Redirection permanente non voulue | Un statut invalide retombe sur 302, un `301` explicite reste 301 | Passer le code voulu : `this.redirect(url, 302)` |
627
- | 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:805`) | Porter l'état par message, ou sur le contexte de la connexion |
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:960`) | Porter l'état par message, ou sur le contexte de la connexion |
628
630
  | WS : l'action n'est jamais appelée | Route sans transport `WEBSOCKET` déclaré | `requirements: { methods: ["WEBSOCKET"] }` |
629
631
  | 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")` |
630
632
  | « 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 |
631
- | Event loop figé sur une route de fichier | `getFile()` synchrone (`lstatSync`, `Controller.ts:709`) | Utiliser `getFileAsync()` (`Controller.ts:749`) |
633
+ | Event loop figé sur une route de fichier | `getFile()` synchrone (`lstatSync`, `Controller.ts:746`) | Utiliser `getFileAsync()` (`Controller.ts:786`) |
632
634
 
633
635
  ## 🧪 Tests & couverture
634
636