@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.
- package/README.md +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/controller/ApiKeyController.js +2 -1
- package/dist/nodefony/controller/TokenAuthController.js +2 -2
- package/dist/nodefony/controller/TotpController.js +2 -1
- package/dist/nodefony/service/AdminBroker.js +2 -2
- package/dist/nodefony/service/IdempotencyStore.js +2 -2
- package/dist/nodefony/service/router.js +5 -3
- package/dist/nodefony/src/Resolver.js +2 -2
- package/dist/nodefony/src/Route.js +24 -1
- package/dist/nodefony/src/docsReader.js +27 -21
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -1
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +2 -2
- package/dist/types/nodefony/controller/TotpController.d.ts +2 -1
- package/dist/types/nodefony/src/Resolver.d.ts +1 -1
- package/dist/types/nodefony/src/Route.d.ts +15 -1
- package/dist/types/nodefony/src/docsReader.d.ts +16 -6
- package/docs/controller.md +55 -55
- package/docs/decorateurs.md +18 -17
- package/docs/idempotence.md +11 -11
- package/docs/index.md +1 -1
- package/docs/routing.md +44 -43
- package/docs/templates.md +7 -7
- package/package.json +9 -10
package/docs/controller.md
CHANGED
|
@@ -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:
|
|
53
|
-
3. **Ton `return` EST la réponse** — `Resolver.returnController()` (`Resolver.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 : `
|
|
367
|
-
|
|
368
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
622
|
-
| Réponse vide alors qu'on retourne une entité ORM | Instance de classe **non** sérialisée → `waitAsync` (`Resolver.ts:
|
|
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:
|
|
625
|
-
| Effet de bord exécuté pour une requête finalement 401 | `initialize()` tourne avant `firewall.handleSecurity()` (`http-kernel.ts:
|
|
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:
|
|
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
|
|
package/docs/decorateurs.md
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
546
|
-
|
|
|
547
|
-
| `@UseSession({ readOnly
|
|
548
|
-
| `@Idempotent({ required? })`
|
|
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.
|
|
553
|
-
zéro écriture)
|
|
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:
|
|
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:
|
|
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:
|
|
715
|
-
(`_applyResponseMeta()`, `Resolver.ts:
|
|
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:
|
|
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:
|
|
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 `()`) |
|
package/docs/idempotence.md
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
331
|
-
| Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:
|
|
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 `
|
|
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:
|
|
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:
|
|
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
|
|
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.",
|