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

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.
@@ -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:282`), jamais un `new` direct.
53
- 3. **Ton `return` EST la réponse** — `Resolver.returnController()` (`Resolver.ts:857`) traduit la
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
54
54
  valeur retournée : objet → JSON, string → corps brut, `void` → « j'ai répondu moi-même ».
55
55
 
56
56
  ## 📖 Lexique
@@ -199,14 +199,14 @@ Le tableau ci-dessous donne la séquence exacte, avec l'ancre qui la prouve :
199
199
  | # | Étape | Où |
200
200
  | --- | --------------------------------------- | --------------------------------------------------- |
201
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:1418`) |
203
- | 3 | Parse du corps (sauf `@Body({stream})`) | `http-kernel.ts:1449` |
204
- | 4 | Armement de la route (sans instance) | `prepareFrontController()` (`http-kernel.ts:789`) |
205
- | 5 | CSRF | `firewall.enforceCsrf()` (`http-kernel.ts:1524`) |
206
- | 6 | Session (reprise ou ouverture) | `HttpKernel.startSession()` (`http-kernel.ts:1168`) |
207
- | 7 | Firewall — **authentification** | `firewall.handleSecurity()` (`http-kernel.ts:1535`) |
208
- | 8 | Autorisation `@IsGranted` | `Resolver.executeAction()` (`Resolver.ts:372`) |
209
- | 9 | **Instanciation DI + `initialize()`** | `Resolver.executeAction()` (`Resolver.ts:372`) |
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
210
  | 10 | **Ton action** | `controller[methodKey]()` (`Resolver.ts:382`) |
211
211
 
212
212
  > [!IMPORTANT]
@@ -303,14 +303,14 @@ 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:271`) + 1 par frame (`handleMessage()`, `WebsocketContext.ts:479`) |
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
309
  | Échec | Statut HTTP + corps d'erreur | **Code de fermeture** RFC 6455 (401/403 → 1008, 5xx → 1011, autre → 4004) |
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:282`) : le contexte WS étant partagé par la connexion, le
313
+ (`Resolver.newController()`, `Resolver.ts:315`) : 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
315
  courante et la reconstruit sinon (`Resolver.ts:344-347`) — sans quoi un message invoquant une autre
316
316
  action se tromperait d'objet.
@@ -321,9 +321,9 @@ 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:1779`)
324
+ Côté WebSocket, l'ordre est encore plus marqué : `HttpKernel.onConnect()` (`http-kernel.ts:2008`)
325
325
  appelle `handleFrontController()` (donc `initialize()`) **avant** `startSession()`
326
- (`http-kernel.ts:1168`), avant l'acceptation de la socket, et avant le firewall
326
+ (`http-kernel.ts:1246`), avant l'acceptation de la socket, et avant le firewall
327
327
  (`http-kernel.ts:1457`).
328
328
 
329
329
  ## 🧠 D'où viennent `request`, `response`, `session`
@@ -360,12 +360,12 @@ 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:630`) ne « démarre » rien : il retourne la session existante,
363
+ - `this.getSession()` (`Controller.ts:657`) ne « démarre » rien : il retourne la session existante,
364
364
  ou `undefined`.
365
365
 
366
- Les messages flash s'appuient dessus : `setFlashBag()`/`addFlash()` (`Controller.ts:666`) et
367
- `getFlashBag()` (`Controller.ts:650`) journalisent une **erreur** et retournent `null` si aucune
368
- session n'est active — pas de crash, mais rien n'est mémorisé.
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`)
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
 
@@ -404,7 +404,7 @@ 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:857`). Il regarde le **type**
407
+ Le traducteur unique est `Resolver.returnController()` (`Resolver.ts:1034`). Il regarde le **type**
408
408
  de ce que tu as retourné :
409
409
 
410
410
  <!-- prettier-ignore -->
@@ -412,18 +412,18 @@ de ce que tu as retourné :
412
412
  | --- | --- | --- |
413
413
  | Une `Promise` / un thenable | Déballée puis re-traitée (récursif) | `Resolver.ts:830-840` |
414
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:936` |
415
+ | Un objet simple ou un tableau | **Auto-JSON** : `application/json` + sérialisation | `Resolver.ts:1081` |
416
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:900` |
417
+ | Un `Buffer` | Envoyé brut | `Resolver.ts:1074` |
418
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:1002`) |
420
- | `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts:981` |
421
- | Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts:942-951` |
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` |
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:1002`) contient 204, 205 et 304. Donc un `@Delete` qui fait
426
+ > `NO_BODY_STATUS` (`Resolver.ts:1168`) 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,12 +436,12 @@ 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:602` |
439
+ | `renderJson(obj, status?, headers?)` | JSON explicite avec statut/en-têtes | `Controller.ts:629` |
440
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:524` |
442
- | `renderResponse(data, encoding?, …)` | Poser statut + en-têtes, puis envoyer | `Controller.ts:503` |
443
- | `redirect(url, status?, headers?)` | Rediriger | `Controller.ts:636` |
444
- | `forward("module:controller:action")` | Déléguer à une autre action **sans** aller-retour réseau | `Controller.ts:670` |
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
445
  | `setContextJson()` / `setContextHtml()` | Choisir le type de contenu avant d'envoyer | `Controller.ts:430` |
446
446
 
447
447
  `renderView()` mesure sa propre phase `render` et injecte automatiquement les aides frontend
@@ -449,12 +449,12 @@ Quand tu veux piloter l'envoi plutôt que retourner une valeur :
449
449
  (`withFrontendLocals()`, `Controller.ts:562`) — 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:670`) : c'est une délégation interne, la requête cliente reste unique.
452
+ (`Controller.ts:697`) : 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:666`). Un 301 par défaut piégeait : les navigateurs le mettent
457
+ > (`Response.redirect()`, `Response.ts:735`). 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:761`) pose
466
+ `renderFileDownload(file, options?, headers?)` (`Controller.ts:788`) 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:722`) ;
468
+ de flux. Le fichier est résolu **sans bloquer l'event loop** (`getFileAsync()`, `Controller.ts:749`) ;
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:928`) implémente les **requêtes par
474
+ `renderMediaStream(file, headers?, options?)` (`Controller.ts:952`) 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,7 +487,7 @@ donc testable sans serveur.
487
487
 
488
488
  ### Ce que `streamFile()` garantit
489
489
 
490
- `streamFile()` (`Controller.ts:801`) est le moteur commun. Sa subtilité n'est pas le pipe, c'est le
490
+ `streamFile()` (`Controller.ts:828`) 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
@@ -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:896`), qui délègue la mise en
509
+ L'exception remonte jusqu'à `HttpKernel.onError()` (`http-kernel.ts:918`), 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**
@@ -524,7 +524,7 @@ c'est un **rejet** de handshake.
524
524
 
525
525
  > [!NOTE]
526
526
  > Les erreurs de ton action remontent **seules** : le Resolver n'enveloppe pas l'appel dans un
527
- > `try/catch` inutile (`Resolver.ts:493-494`). Inutile d'attraper pour re-lever — sauf si tu veux
527
+ > `try/catch` inutile (`Resolver.ts:625-626`). Inutile d'attraper pour re-lever — sauf si tu veux
528
528
  > vraiment traduire l'erreur en un autre statut.
529
529
 
530
530
  ## 🧩 Services injectés — trois façons
@@ -538,7 +538,7 @@ explicite :
538
538
  const catalog = this.get<CatalogService>("catalog"); // null si absent ou container nettoyé
539
539
  ```
540
540
 
541
- `Service.get()` (`Service.ts:525`) est une **façade sûre** : elle retourne `null` au lieu de lever si
541
+ `Service.get()` (`Service.ts:588`) est une **façade sûre** : elle retourne `null` au lieu de lever si
542
542
  le container a déjà été détaché. C'est le style à privilégier dans `initialize()`.
543
543
 
544
544
  ### 2. Injection par le constructeur — `@inject`
@@ -585,23 +585,23 @@ code du framework applique — et attend de toi — les règles suivantes :
585
585
  (`Controller.ts:574`).
586
586
  - **Métadonnées d'action figées** : `@HttpCode`, `@Header`, les paramètres décorés et l'intention de
587
587
  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:491`).
588
+ chaque requête (`resolveActionMeta()` appelé en `Resolver.ts:458`).
589
589
  - **Gardes payées seulement si présentes** : sans `@IsGranted`, la vérification d'autorisation est
590
- un test de nullité (`Resolver.ts:406`) — 0 lookup, 0 `await`, 0 allocation.
590
+ un test de nullité (`Resolver.ts:890`) — 0 lookup, 0 `await`, 0 allocation.
591
591
  - **Ta part du contrat** : pas de structure allouée « au cas où » dans le constructeur ni dans
592
592
  `initialize()`. Une valeur utile à 5 % des requêtes s'alloue à la demande.
593
593
 
594
594
  ## 📜 Normes appliquées
595
595
 
596
- | Domaine | Norme | Comment le code s'y conforme |
597
- | -------------------------------- | ------------------------ | -------------------------------------------------------------- |
598
- | Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts:1002`) |
599
- | 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:503`) |
601
- | 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:936`), vérifié par le banc `auto-json` |
603
- | Scalaire JSON de premier niveau | RFC 8259 §2 | `number`/`boolean` rendus (`Resolver.ts:734`) |
604
- | Codes de fermeture WebSocket | RFC 6455 §7.4 | `renderWebsocket()` (`error-renderer.ts:518`) |
596
+ | Domaine | Norme | Comment le code s'y conforme |
597
+ | -------------------------------- | ------------------------ | --------------------------------------------------------------- |
598
+ | Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts:1168`) |
599
+ | 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`) |
601
+ | 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
+ | Codes de fermeture WebSocket | RFC 6455 §7.4 | `renderWebsocket()` (`error-renderer.ts:518`) |
605
605
 
606
606
  ## 📡 Observabilité — Studio
607
607
 
@@ -618,17 +618,17 @@ code du framework applique — et attend de toi — les règles suivantes :
618
618
  <!-- prettier-ignore -->
619
619
  | Symptôme | Cause (dans le code) | Correction |
620
620
  | --- | --- | --- |
621
- | La requête pend puis expire, alors que l'action a bien tourné | `return null`/`undefined` avec un statut à corps → `waitAsync` (`Resolver.ts:981`) | 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:951`) | Retourner un objet simple, ou `renderJson(entity.toJSON())` |
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
623
  | `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:1210`) | 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:1715`) | Déplacer l'effet de bord dans l'action |
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
626
  | Redirection permanente non voulue | Un statut invalide retombe sur 302, un `301` explicite reste 301 | Passer le code voulu : `this.redirect(url, 302)` |
627
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 |
628
628
  | WS : l'action n'est jamais appelée | Route sans transport `WEBSOCKET` déclaré | `requirements: { methods: ["WEBSOCKET"] }` |
629
629
  | 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
630
  | « 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:682`) | Utiliser `getFileAsync()` (`Controller.ts:722`) |
631
+ | Event loop figé sur une route de fichier | `getFile()` synchrone (`lstatSync`, `Controller.ts:709`) | Utiliser `getFileAsync()` (`Controller.ts:749`) |
632
632
 
633
633
  ## 🧪 Tests & couverture
634
634
 
@@ -126,10 +126,10 @@ jamais surprendre.
126
126
  **1 — Un décorateur n'écrit QUE des métadonnées.** Aucun décorateur du framework ne contient de
127
127
  logique de sécurité, de session ou d'idempotence. `IsGranted()` (`routerDecorators.ts:903`) pose une
128
128
  clause ; c'est le `Resolver` qui appellera le moteur d'autorisation, **résolu par son nom** dans le
129
- conteneur (`Resolver._enforceSecurity()`, `Resolver.ts:719`). Pourquoi ce détour : `@nodefony/framework`
129
+ conteneur (`Resolver._enforceSecurity()`, `Resolver.ts:853`). Pourquoi ce détour : `@nodefony/framework`
130
130
  ne dépend **pas** de `@nodefony/security` — sans ça, les deux modules formeraient un cycle. Le prix à
131
131
  payer est visible : une route gardée alors que le module `security` est absent renvoie **403**, pas
132
- une erreur de démarrage (fail-closed, `Resolver.ts:724`).
132
+ une erreur de démarrage (fail-closed, `Resolver.ts:860`).
133
133
 
134
134
  **2 — Tout est figé une fois, puis relu en O(1).** Les métadonnées de l'action sont consolidées au
135
135
  premier passage dans `computeActionMeta()` (`routerDecorators.ts:1632`) puis gelées sur la route.
@@ -259,7 +259,7 @@ curl -si -b /tmp/jar -X POST http://localhost:5151/api/books \
259
259
  # 4) Méthode non déclarée pour ce chemin → 405 avec l'agrégat des méthodes
260
260
  curl -si -X PUT http://localhost:5151/api/books/42 | head -2
261
261
  # HTTP/1.1 405 Method Not Allowed
262
- # Allow: GET, DELETE
262
+ # Allow: GET, HEAD, DELETE
263
263
  ```
264
264
 
265
265
  ## 🧰 La table de référence — toute la surface décorateur
@@ -456,12 +456,12 @@ ne s'auto-promeut pas.
456
456
  | `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts:603`) | `@Header("Cache-Control","no-store")` |
457
457
  | `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts:649`) | `@Redirect("/login", 302)` |
458
458
 
459
- Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:821`) **avant**
459
+ Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:955`) **avant**
460
460
  l'appel de l'action : ton code peut donc les écraser ensuite (`this.renderJson(data, 202)` gagne).
461
461
 
462
462
  `@Redirect` a une subtilité utile : si l'action **retourne un objet** portant `url` (et
463
463
  éventuellement `statusCode`), cet objet **prend le dessus** sur les valeurs du décorateur
464
- (`Resolver._handleRedirect()`, `Resolver.ts:837`) — la cible peut donc être calculée à l'exécution :
464
+ (`Resolver._handleRedirect()`, `Resolver.ts:980`) — la cible peut donc être calculée à l'exécution :
465
465
 
466
466
  ```typescript
467
467
  @Get("/go")
@@ -542,15 +542,16 @@ héritées de la classe.
542
542
 
543
543
  ### Cycle de la requête — session et anti-rejeu
544
544
 
545
- | Décorateur | Cible | Effet | Ancre |
546
- | ------------------------------------ | ----- | ---------------------------------------------------------------------------------------------------------- | --------------------------------- |
547
- | `@UseSession({ readOnly?, eager? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts:800`) | `@UseSession({ readOnly: true })` |
548
- | `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts:1171`) | `@Idempotent()` |
545
+ | Décorateur | Cible | Effet | Ancre |
546
+ | ---------------------------- | ----- | ---------------------------------------------------------------------------------------------------------- | --------------------------------- |
547
+ | `@UseSession({ readOnly? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts:800`) | `@UseSession({ readOnly: true })` |
548
+ | `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts:1171`) | `@Idempotent()` |
549
549
 
550
550
  **`@UseSession` est la seule façon d'ouvrir une session** (avec un paramètre `@Session`, ou la reprise
551
551
  d'un cookie existant). Il n'existe plus de « démarrer partout » global : une route qui ne déclare rien
552
- ne coûte aucune lecture de stockage. Les deux options sont `readOnly` (lire sans jamais persister —
553
- zéro écriture) et `eager` (activer tôt, pour régénérer l'identifiant juste après une authentification).
552
+ ne coûte aucune lecture de stockage. Une seule option : `readOnly` (lire sans jamais persister —
553
+ zéro écriture). La régénération de l'identifiant après authentification est faite par le login
554
+ (`AuthFlow`), pas par une option du décorateur.
554
555
  La forme exacte est celle de `SessionIntent` (`ISession.ts:18`).
555
556
 
556
557
  **`@Idempotent` est strict par défaut** : une mutation sans `Idempotency-Key` reçoit **400**. Le mode
@@ -623,7 +624,7 @@ Trois faits à retenir :
623
624
  - **Les décorateurs de paramètre fonctionnent pareil.** Pour une invocation par socket, le corps de
624
625
  la mutation voyage dans l'ALS et **prime** sur le corps HTTP (vide dans ce cas) — c'est traité dans
625
626
  `resolveParamArg()` (`routerDecorators.ts:1326`), et `@Query` lit la query du chemin **invoqué**,
626
- pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:795`).
627
+ pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:929`).
627
628
  - **Les gardes s'appliquent identiquement.** `@IsGranted` protège une action joignable par socket
628
629
  exactement comme une action HTTP : la décision est prise avant l'instanciation, quel que soit le
629
630
  transport.
@@ -709,10 +710,10 @@ ont dit de l'action :
709
710
 
710
711
  Le `Resolver` consomme ce snapshot dans un ordre qui a du sens sécurité :
711
712
  **garde d'abord, instanciation ensuite**. `security !== null` déclenche
712
- `_enforceSecurity()` (`Resolver.ts:719`) **avant** `newController()` — un `403` n'instancie pas le
713
+ `_enforceSecurity()` (`Resolver.ts:853`) **avant** `newController()` — un `403` n'instancie pas le
713
714
  contrôleur et n'exécute pas son `initialize()`. Puis viennent les arguments
714
- (`_buildParamArgs()`, `Resolver.ts:795`), les métadonnées de réponse
715
- (`_applyResponseMeta()`, `Resolver.ts:821`), l'action, et enfin la redirection éventuelle.
715
+ (`_buildParamArgs()`, `Resolver.ts:929`), les métadonnées de réponse
716
+ (`_applyResponseMeta()`, `Resolver.ts:955`), l'action, et enfin la redirection éventuelle.
716
717
 
717
718
  Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts:1520`) parcourt
718
719
  les routes au démarrage pour bâtir le **catalogue des scopes déclarés** — le formulaire de création
@@ -728,7 +729,7 @@ Un décorateur non employé doit coûter **zéro**. C'est tenu par trois mécani
728
729
  requête. Le même schéma vaut pour la détection du flux brut
729
730
  (`routeExpectsBodyStream()`, `routerDecorators.ts:1408`).
730
731
  - **`null` plutôt que structure vide.** Une action sans garde a `security: null` : le `Resolver` teste
731
- un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts:406`). Idem
732
+ un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts:486`). Idem
732
733
  pour `idempotent`, `cspDirectives`, `paramsMeta`.
733
734
  - **Objets gelés et partagés.** Les exigences de sécurité et d'idempotence sont créées **une fois** et
734
735
  `Object.freeze`-ées (`routerDecorators.ts:1496`, `:1340`) : une seule instance pour la durée de vie
@@ -795,7 +796,7 @@ L'écran **Routes** (`/nodefony/routes`) et le point d'API `/nodefony/framework/
795
796
  | Symptôme | Cause (dans le code) | Correction |
796
797
  | --- | --- | --- |
797
798
  | `404` sur une route pourtant décorée | Contrôleur jamais importé, ou absent de `@controllers([…])` | L'ajouter au tableau `@controllers` du module |
798
- | `Action « remove » … : ce nom est RÉSERVÉ` au démarrage — ou `TS2416` au build | L'action reprend le nom d'un membre de `Controller` : la classe étend `Service`, qui expose déjà `remove(name): boolean` ([`Service.ts:545`](../../../../nodefony/src/Service.ts)), `set`, `get`, `clean`… Le décorateur refuse le nom avant que le conflit n'atteigne le compilateur. | Renommer l'action (`destroy`, `deleteOne`…). Le nom d'une action est libre : c'est le chemin du décorateur qui fait l'URL. |
799
+ | `Action « remove » … : ce nom est RÉSERVÉ` au démarrage — ou `TS2416` au build | L'action reprend le nom d'un membre de `Controller` : la classe étend `Service`, qui expose déjà `remove(name): boolean` ([`Service.ts:608`](../../../../nodefony/src/Service.ts)), `set`, `get`, `clean`… Le décorateur refuse le nom avant que le conflit n'atteigne le compilateur. | Renommer l'action (`destroy`, `deleteOne`…). Le nom d'une action est libre : c'est le chemin du décorateur qui fait l'URL. |
799
800
  | `404` après avoir déplacé `@controller` sous `@Domain` | `@controller` monte les routes ; les décorateurs lus au montage doivent être **sous** | Remettre `@controller` en **premier** (le plus haut) |
800
801
  | Le vhost de `@Domain` classe est ignoré | `@Domain` placé **au-dessus** de `@controller` → posé trop tard | Placer `@Domain` sous `@controller` |
801
802
  | `@BypassFirewall` n'ouvre rien | Écrit **avec** parenthèses — c'est un drapeau, pas une fabrique | `@BypassFirewall` (sans `()`) |
@@ -121,7 +121,7 @@ que **deux** appelants traduisent dans leur monde :
121
121
  - le **data plane admin** — `AdminApiController.idempotencyGate()`
122
122
  (`AdminApiController.ts:131`) → réponse `{status, headers, body}` ;
123
123
  - les **controllers userland** décorés `@Idempotent` — seam `Resolver._callWithIdempotency()`
124
- (`Resolver.ts:537`) → `nodefonyError` typée, ou réponse rejouée.
124
+ (`Resolver.ts:682`) → `nodefonyError` typée, ou réponse rejouée.
125
125
 
126
126
  Conséquence pratique : il est **impossible** que l'idempotence HTTP et l'idempotence WebSocket
127
127
  divergent — elles partagent la même fonction. C'est le différenciateur du framework (HTTP + WS
@@ -223,7 +223,7 @@ curl -si -X POST http://localhost:5151/api/payments/charge \
223
223
  > [!WARNING]
224
224
  > **Le piège n°1** : en verdict `fresh`, la réservation est _in-flight_ tant que `complete` **ou**
225
225
  > `abort` n'a pas été appelé. Le seam `@Idempotent` gère ce couple pour toi (`try/catch`,
226
- > `Resolver.ts:578` et `Resolver.ts:563`). Si tu appelles le store **à la main** (cas avancé), c'est
226
+ > `Resolver.ts:759` et `Resolver.ts:783`). Si tu appelles le store **à la main** (cas avancé), c'est
227
227
  > **ta** responsabilité : sans `abort` sur erreur, la clé reste bloquée jusqu'à l'expiration du bail
228
228
  > (60 s), et tout rejeu identique reçoit `409` pendant ce temps.
229
229
 
@@ -266,7 +266,7 @@ Deux mécanismes rendent ça possible côté socket :
266
266
  frame `socket.mutate` créerait un doublon. Le Resolver teste donc
267
267
  `isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:126`).
268
268
 
269
- Le pont utilise `executeActionGuarded()` (`Resolver.ts:502`) : porte d'idempotence **sans** rendu HTTP
269
+ Le pont utilise `executeActionGuarded()` (`Resolver.ts:647`) : porte d'idempotence **sans** rendu HTTP
270
270
  — la valeur nue est enveloppée par le peer WS, jamais écrite sur un transport HTTP.
271
271
 
272
272
  ## 🏗️ Architecture interne
@@ -299,11 +299,11 @@ sequenceDiagram
299
299
 
300
300
  ### Le parcours d'une mutation, étape par étape
301
301
 
302
- 1. **Court-circuit hot path.** `callController()` (`Resolver.ts:484`) lit `meta.idempotent` sur les
302
+ 1. **Court-circuit hot path.** `callController()` (`Resolver.ts:613`) lit `meta.idempotent` sur les
303
303
  métadonnées d'action **figées par route**. `null` sur la quasi-totalité des routes → une
304
304
  comparaison, flux normal, **zéro** lookup de store et zéro allocation.
305
305
  2. **No-op sur méthode sûre.** Une action `GET` sous une classe `@Idempotent` repart directement en
306
- exécution (`Resolver.ts:473`).
306
+ exécution (`Resolver.ts:688`).
307
307
  3. **Empreinte du payload.** `computeFingerprint()` (`idempotency.ts:123`) hache
308
308
  `[nom de route, params de route, corps]` (`Resolver.ts:497`). Le corps vient de l'ALS (pont WS) ou
309
309
  du body HTTP parsé.
@@ -312,7 +312,7 @@ sequenceDiagram
312
312
  le verdict devient `execute` (`idempotency.ts:176`) — jamais de partage cross-identité.
313
313
  5. **Réservation.** `store.begin()` compose la clé scopée et tranche.
314
314
  6. **Mémorisation.** En succès, `complete(clé, {status, body})` où `status` est le code de réponse
315
- courant et `body` la **valeur retournée** par l'action (`Resolver.ts:570`). En erreur,
315
+ courant et `body` la **valeur retournée** par l'action (`Resolver.ts:704`). En erreur,
316
316
  `abort(clé)` libère la clé : **un échec ne se mémorise pas**, il doit rester réessayable.
317
317
 
318
318
  > [!CAUTION]
@@ -327,8 +327,8 @@ sequenceDiagram
327
327
 
328
328
  | Appelant | Point d'entrée | Traduction du verdict |
329
329
  | ---------------------------- | -------------------------------------------------------------------- | ---------------------------------- |
330
- | Controller userland HTTP | `callController()` (`Resolver.ts:484`) | `nodefonyError` + rendu normal |
331
- | Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:502`) | valeur nue, enveloppée par le peer |
330
+ | Controller userland HTTP | `callController()` (`Resolver.ts:613`) | `nodefonyError` + rendu normal |
331
+ | Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:647`) | valeur nue, enveloppée par le peer |
332
332
  | Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:131`) | `{status, headers, body}` |
333
333
 
334
334
  ## ⚙️ Configuration
@@ -472,7 +472,7 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
472
472
  renvoie `fresh` (sans dédup), le reste est no-op.
473
473
 
474
474
  Le câblage est **automatique** : charger `@nodefony/drizzle` enregistre l'entité **et** la fabrique
475
- (`registerStores.ts:316`). Activation = `store: "drizzle"` (ou `NF_IDEMPOTENCY_STORE=drizzle`), rien
475
+ (`registerStores.ts:316`). Activation = `store: "drizzle"` (ou `NF_STORE=drizzle`, qui force toutes les briques `auto`), rien
476
476
  d'autre à écrire.
477
477
 
478
478
  ### Le GC — armé pour un seul store
@@ -648,7 +648,7 @@ pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
648
648
 
649
649
  Le coût est **nul hors mutations décorées**. Sans `@Idempotent`, `RouteActionMeta.idempotent` vaut
650
650
  `null` (`routerDecorators.ts:1151`) : `callController()` fait **une comparaison** et repart en flux
651
- normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:473`).
651
+ normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:613`).
652
652
  La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
653
653
 
654
654
  Sur le chemin décoré :
@@ -691,7 +691,7 @@ Trois surfaces existent aujourd'hui :
691
691
 
692
692
  | Symptôme | Cause (dans le code) | Correction |
693
693
  | ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
694
- | Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:628`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
694
+ | Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:773`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
695
695
  | `409` en boucle sur un endpoint | Action qui lève avant `complete`/`abort` → in-flight bloqué jusqu'au bail (60 s) | Le seam le gère ; en usage manuel du store, `try/finally` obligatoire |
696
696
  | `422 Idempotency-Key is already used` | Même clé, **payload différent** (empreinte ≠, `idempotency.ts:189`) | Une clé = une intention ; nouvelle clé par requête distincte |
697
697
  | Rien n'est dédupliqué **malgré** la clé | Pas d'identité fiable → verdict `execute` (`idempotency.ts:176`) | S'assurer que le firewall a résolu l'utilisateur **avant** la mutation |
package/docs/index.md CHANGED
@@ -74,7 +74,7 @@ Le tableau pour choisir vite ; les cards en dessous pour savoir ce qu'on y trouv
74
74
  "desc": "Une table ordonnée où le premier motif qui correspond gagne : l'arbitrage sans score de spécificité, la partition littéral/dynamique qui accélère sans changer la sémantique, le 405 et son en-tête Allow, les vhosts, le duplex HTTP+WebSocket sur un même chemin.",
75
75
  "meta": "deux routes se disputent, ou un 404/405 surprend" },
76
76
  { "icon": "🔁", "title": "idempotence", "href": "idempotence.md",
77
- "desc": "@Idempotent, la clé d'idempotence, les trois stores et leurs capacités réelles, le GC des entrées expirées, et ce que le client observe quand il rejoue la même clé.",
77
+ "desc": "@Idempotent, la clé d'idempotence, les quatre stores (mémoire, Redis, SQL, MongoDB) et leurs capacités réelles, le GC des entrées expirées, et ce que le client observe quand il rejoue la même clé.",
78
78
  "meta": "paiement, commande, tout effet non rejouable" },
79
79
  { "icon": "🛡️", "title": "admin", "href": "admin.md",
80
80
  "desc": "Le data plane d'administration : comment un module déclare son API d'admin via AdminBroker, la convention de route /nodefony/<ns>/api/*, le RBAC fail-closed (ROLE_NODEFONY_ADMIN), le duplex HTTP/WebSocket, le catalogue et le Playground.",