@nodefony/framework 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +50 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +211 -0
  6. package/dist/nodefony/config/config.js +61 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  8. package/dist/nodefony/controller/AdminApiController.js +163 -0
  9. package/dist/nodefony/controller/ApiKeyController.js +151 -0
  10. package/dist/nodefony/controller/BenchController.js +132 -0
  11. package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
  12. package/dist/nodefony/controller/OAuth2Controller.js +133 -0
  13. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
  14. package/dist/nodefony/controller/SessionAuthController.js +141 -0
  15. package/dist/nodefony/controller/TokenAuthController.js +113 -0
  16. package/dist/nodefony/controller/TotpController.js +129 -0
  17. package/dist/nodefony/controller/WebAuthnController.js +242 -0
  18. package/dist/nodefony/controller/oauthAuthority.js +74 -0
  19. package/dist/nodefony/decorators/routerDecorators.js +967 -0
  20. package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
  21. package/dist/nodefony/interfaces/IController.js +1 -0
  22. package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
  23. package/dist/nodefony/interfaces/IResolver.js +1 -0
  24. package/dist/nodefony/interfaces/IRoute.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/service/AdminBroker.js +106 -0
  27. package/dist/nodefony/service/Eta.js +68 -0
  28. package/dist/nodefony/service/IdempotencyStore.js +136 -0
  29. package/dist/nodefony/service/router.js +243 -0
  30. package/dist/nodefony/src/Controller.js +515 -0
  31. package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
  32. package/dist/nodefony/src/KernelAdminApi.js +1243 -0
  33. package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
  34. package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
  35. package/dist/nodefony/src/Resolver.js +416 -0
  36. package/dist/nodefony/src/ResourceController.js +148 -0
  37. package/dist/nodefony/src/Route.js +476 -0
  38. package/dist/nodefony/src/SyslogAdminApi.js +466 -0
  39. package/dist/nodefony/src/Template.js +15 -0
  40. package/dist/nodefony/src/configMutation.js +186 -0
  41. package/dist/nodefony/src/docsReader.js +929 -0
  42. package/dist/nodefony/src/idempotency.js +137 -0
  43. package/dist/nodefony/src/idempotencyGc.js +32 -0
  44. package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
  45. package/dist/nodefony/src/scopeCatalog.js +40 -0
  46. package/dist/nodefony/src/syslogFilters.js +51 -0
  47. package/dist/types/index.d.ts +96 -0
  48. package/dist/types/nodefony/config/config.d.ts +41 -0
  49. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  50. package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
  51. package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
  52. package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
  53. package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
  54. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
  55. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
  56. package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
  57. package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
  58. package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
  59. package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
  60. package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
  61. package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
  62. package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
  63. package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
  64. package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
  65. package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
  66. package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
  67. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  68. package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
  69. package/dist/types/nodefony/service/Eta.d.ts +25 -0
  70. package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
  71. package/dist/types/nodefony/service/router.d.ts +53 -0
  72. package/dist/types/nodefony/src/Controller.d.ts +193 -0
  73. package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
  74. package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
  75. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
  76. package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
  77. package/dist/types/nodefony/src/Resolver.d.ts +165 -0
  78. package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
  79. package/dist/types/nodefony/src/Route.d.ts +192 -0
  80. package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
  81. package/dist/types/nodefony/src/Template.d.ts +8 -0
  82. package/dist/types/nodefony/src/configMutation.d.ts +109 -0
  83. package/dist/types/nodefony/src/docsReader.d.ts +369 -0
  84. package/dist/types/nodefony/src/idempotency.d.ts +96 -0
  85. package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
  86. package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
  87. package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
  88. package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
  89. package/docs/admin.md +451 -0
  90. package/docs/controller.md +645 -0
  91. package/docs/decorateurs.md +845 -0
  92. package/docs/idempotence.md +741 -0
  93. package/docs/index.md +151 -0
  94. package/docs/routing.md +648 -0
  95. package/docs/templates.md +380 -0
  96. package/package.json +83 -0
@@ -0,0 +1,645 @@
1
+ ---
2
+ title: "Controller — le code de ta route"
3
+ lang: fr
4
+ module: "@nodefony/framework"
5
+ topic: controller
6
+ section: "Cœur runtime"
7
+ audience: [developer]
8
+ tags: [controller, resolver, action, contexte, websocket, als, reponse, erreurs]
9
+ version: "doc"
10
+ status: stable
11
+ updated: 2026-07-19
12
+ source: "src/packages/@nodefony/framework/docs/controller.md"
13
+ coverageModule: framework
14
+ coverageFiles: Controller.ts,Resolver.ts
15
+ ---
16
+
17
+ # Controller — le code de ta route
18
+
19
+ > Une fois la route trouvée, quelqu'un doit **faire le travail** : c'est le contrôleur. Nodefony
20
+ > l'instancie par requête (DI compris), appelle ton action, puis traduit ce que tu **retournes** en
21
+ > réponse HTTP ou en frame WebSocket. Cette page décrit ce qui se passe **dans** le contrôleur : de
22
+ > quoi il hérite, son cycle de vie réel (dont `initialize()`), d'où viennent `request`/`response`/
23
+ > `session`, comment répondre, comment échouer proprement. Tout est ancré sur le code.
24
+
25
+ 📍 [Documentation](../../../../../docs/index.md) › [Framework](index.md) › **Controller**
26
+
27
+ ## 🧠 Le modèle mental — un objet jetable entre deux mondes
28
+
29
+ Un contrôleur n'est **pas** un serveur ni un service partagé : par défaut c'est un **objet jetable**,
30
+ construit pour UNE requête et abandonné à la fin. Il vit entre deux mondes qu'il ne connaît pas :
31
+ le **transport** (le contexte HTTP ou WebSocket, fourni par `@nodefony/http`) et le **container**
32
+ (tes services, fournis par le DI).
33
+
34
+ ```mermaid
35
+ flowchart LR
36
+ RT["Router<br/>route trouvée"] --> RS["Resolver<br/>par requête"]
37
+ RS -->|"instantiate + DI"| CT["TON Controller<br/>extends Controller"]
38
+ CT -->|"initialize()"| CT
39
+ RS -->|"action(...args)"| CT
40
+ CT -->|"return valeur"| RC["returnController<br/>traduit le retour"]
41
+ RC --> OUT["réponse HTTP<br/>ou frame WS"]
42
+ CTX["Context HTTP / WS"] -.->|"request · response · session"| CT
43
+ DI["Container DI<br/>tes services"] -.->|"this.get() · @inject"| CT
44
+ ```
45
+
46
+ Trois idées à retenir :
47
+
48
+ 1. **Tu hérites de `Service`** — `Controller` étend `Service` (`Controller.ts:112`). Tu récupères
49
+ donc gratuitement le container (`this.get()`), les logs (`this.log()`) et les événements.
50
+ 2. **Tu ne construis rien toi-même** — le `Resolver` instancie ta classe via l'injecteur
51
+ (`Resolver.newController()`, `Resolver.ts:236`), jamais un `new` direct.
52
+ 3. **Ton `return` EST la réponse** — `Resolver.returnController()` (`Resolver.ts:697`) traduit la
53
+ valeur retournée : objet → JSON, string → corps brut, `void` → « j'ai répondu moi-même ».
54
+
55
+ ## 📖 Lexique
56
+
57
+ | Terme | Sens (dans cette page) |
58
+ | ------------------ | --------------------------------------------------------------------------------------------------- |
59
+ | Action | La méthode de ton contrôleur associée à une route (`read()`, `create()`…). |
60
+ | Contexte | L'objet transport de la requête courante (`HttpContext` ou `WebsocketContext`). |
61
+ | Resolver | L'objet **par requête** qui trouve l'action, instancie le contrôleur et l'appelle. |
62
+ | DI | _Dependency Injection_ : le container qui fabrique et fournit tes services par leur nom. |
63
+ | ALS | _AsyncLocalStorage_ : le « porte-documents » Node qui suit une requête à travers tous ses `await`. |
64
+ | Hot path | Le chemin parcouru par **chaque** requête — ce qu'on y met est payé des millions de fois. |
65
+ | Auto-JSON | Le fait qu'un objet retourné par l'action devienne une réponse `application/json` sans le demander. |
66
+ | Handshake | La poignée de main d'ouverture d'une connexion WebSocket (avant tout message). |
67
+ | Frame | Un message WebSocket individuel, une fois la connexion ouverte. |
68
+ | Scope (contrôleur) | `"request"` (une instance par requête, défaut) ou `"singleton"` (une instance partagée). |
69
+ | `waitAsync` | Drapeau posé quand le framework conclut « l'action enverra la réponse elle-même, plus tard ». |
70
+
71
+ ## 🚀 Démarrage rapide
72
+
73
+ Dans une app générée par `nodefony create app`, un contrôleur est une **classe décorée**. Voici un
74
+ contrôleur complet — il répond en JSON, consomme un service injecté et gère une erreur métier.
75
+
76
+ ```typescript
77
+ // nodefony/controllers/CatalogController.ts
78
+ import {
79
+ Controller,
80
+ controller,
81
+ Get,
82
+ Post,
83
+ Param,
84
+ Body,
85
+ HttpCode,
86
+ } from "@nodefony/framework";
87
+ import type { ContextType } from "@nodefony/http";
88
+ import { nodefonyError } from "nodefony";
89
+
90
+ /** Ton service métier, enregistré dans le container sous le nom "catalog". */
91
+ interface CatalogService {
92
+ find(id: string): Promise<{ id: string; label: string } | null>;
93
+ create(input: { label: string }): Promise<{ id: string; label: string }>;
94
+ }
95
+
96
+ @controller("/api/catalog")
97
+ class CatalogController extends Controller {
98
+ // Champ per-requête : sûr ici, car le scope par défaut est UNE instance par requête.
99
+ private catalog: CatalogService | null = null;
100
+
101
+ // Le contexte de la requête est le SEUL argument obligatoire ; le nom passé à
102
+ // `super()` est celui du service (il apparaît dans les logs).
103
+ constructor(context: ContextType) {
104
+ super("catalog", context);
105
+ }
106
+
107
+ // Hook optionnel, appelé à CHAQUE requête juste après l'instanciation.
108
+ // Voir « Le cycle de vie » : ici, ni session ni utilisateur ne sont encore résolus.
109
+ async initialize(): Promise<this> {
110
+ this.catalog = this.get<CatalogService>("catalog");
111
+ return this;
112
+ }
113
+
114
+ // `return item` suffit : un objet devient une réponse JSON (auto-JSON).
115
+ @Get("/{id}")
116
+ async read(@Param("id") id: string) {
117
+ const item = await this.catalog?.find(id);
118
+ if (!item) {
119
+ // Une erreur levée est traduite en réponse : 404 JSON, jamais de fuite de stack en prod.
120
+ throw new nodefonyError(`Article ${id} introuvable`, 404);
121
+ }
122
+ return item;
123
+ }
124
+
125
+ @Post("/")
126
+ @HttpCode(201)
127
+ async create(@Body("label") label: string) {
128
+ if (!label) {
129
+ throw new nodefonyError("Le champ `label` est requis", 422);
130
+ }
131
+ return this.catalog!.create({ label });
132
+ }
133
+ }
134
+
135
+ export default CatalogController;
136
+ ```
137
+
138
+ **Le câblage** tient en une ligne dans le `index.ts` de l'app : `@controllers([CatalogController])`
139
+ sur ta classe `Module` — c'est ce décorateur qui enregistre les routes au boot du kernel
140
+ (`nodefony create controller` l'ajoute pour toi). Le service `catalog`, lui, se déclare avec
141
+ `@services([CatalogService])` sur ce même module.
142
+
143
+ ### Ce qu'on observe
144
+
145
+ ```bash
146
+ # 1) Lecture d'un article existant → auto-JSON, 200, application/json SANS charset (RFC 8259 §11)
147
+ curl -si http://localhost:5151/api/catalog/42
148
+ # HTTP/1.1 200 OK
149
+ # Content-Type: application/json
150
+ # {"id":"42","label":"Cordage 12mm"}
151
+
152
+ # 2) Article absent → l'erreur levée devient une réponse structurée
153
+ curl -s http://localhost:5151/api/catalog/999 | head -c 120
154
+ # {"code":404,"message":"Article 999 introuvable","result":null,"error":{…},"nodefony":{…}}
155
+
156
+ # 3) Création → le 201 vient de @HttpCode, le corps de ton `return`
157
+ curl -si -X POST -H 'Content-Type: application/json' \
158
+ -d '{"label":"Bosse d amarrage"}' http://localhost:5151/api/catalog/
159
+ # HTTP/1.1 201 Created
160
+ ```
161
+
162
+ > [!TIP]
163
+ > Tu n'as écrit **aucun** appel d'envoi : ni `res.json()`, ni `send()`. Le contrat de Nodefony est
164
+ > « retourne une valeur, le framework la rend ». Les helpers `render*` restent disponibles quand tu
165
+ > veux piloter l'envoi toi-même (fichiers, flux, vues) — voir plus bas.
166
+
167
+ ## 🏗️ Le cycle de vie d'une action — l'ordre RÉEL
168
+
169
+ C'est la section à lire en entier : elle dit **quand** ton contrôleur naît, donc ce que tu as le
170
+ droit d'écrire dans `initialize()`.
171
+
172
+ ```mermaid
173
+ sequenceDiagram
174
+ participant K as HttpKernel
175
+ participant R as Resolver
176
+ participant C as TON Controller
177
+ K->>R: router.resolve() — appariement URL → route
178
+ K->>K: applySecurityHeaders (CSP…)
179
+ K->>K: parse du corps
180
+ K->>R: prepareFrontController() — arme la route, N'INSTANCIE PAS
181
+ K->>K: enforceCsrf()
182
+ K->>K: startSession()
183
+ K->>K: firewall.handleSecurity() — authentification
184
+ K->>R: context.handle() → callController()
185
+ R->>R: @IsGranted — autorisation
186
+ rect rgb(214, 245, 224)
187
+ R->>C: constructor (DI) + initialize()
188
+ end
189
+ R->>C: action(...args)
190
+ C-->>R: valeur retournée
191
+ R->>K: returnController() → réponse
192
+ ```
193
+
194
+ Le tableau ci-dessous donne la séquence exacte, avec l'ancre qui la prouve :
195
+
196
+ | # | Étape | Où |
197
+ | --- | --------------------------------------- | --------------------------------------------------- |
198
+ | 1 | Appariement de la route | `router.resolve()` (`http-kernel.ts:1324`) |
199
+ | 2 | En-têtes de sécurité applicatifs | `applySecurityHeaders()` (`http-kernel.ts:1334`) |
200
+ | 3 | Parse du corps (sauf `@Body({stream})`) | `http-kernel.ts:1316` |
201
+ | 4 | Armement de la route (sans instance) | `prepareFrontController()` (`http-kernel.ts:767`) |
202
+ | 5 | CSRF | `firewall.enforceCsrf()` (`http-kernel.ts:1290`) |
203
+ | 6 | Session (reprise ou ouverture) | `HttpKernel.startSession()` (`http-kernel.ts:1131`) |
204
+ | 7 | Firewall — **authentification** | `firewall.handleSecurity()` (`http-kernel.ts:1301`) |
205
+ | 8 | Autorisation `@IsGranted` | `Resolver.executeAction()` (`Resolver.ts:334`) |
206
+ | 9 | **Instanciation DI + `initialize()`** | `Resolver.executeAction()` (`Resolver.ts:313`) |
207
+ | 10 | **Ton action** | `controller[methodKey]()` (`Resolver.ts:382`) |
208
+
209
+ > [!IMPORTANT]
210
+ > **Rien de ton contrôleur ne s'exécute pour une requête qui sera refusée.** L'appariement de route
211
+ > est précoce — il pose l'intention de session et l'exemption de firewall que les étapes 5 à 7
212
+ > lisent — mais l'**instanciation** attend l'étape 9 : après CSRF, session, authentification et
213
+ > autorisation. Un appelant qui repart en **401** ou en **403** ne fait donc payer ni la résolution
214
+ > DI ni ton `initialize()`. Verrouillé par `pipeline-order.test.ts` (`@nodefony/http`), qui frappe
215
+ > une zone protégée en anonyme puis avec un rôle insuffisant, et exige un mouchard resté à zéro.
216
+
217
+ ### `initialize()` — le constructeur asynchrone de ton contrôleur
218
+
219
+ C'est **sa raison d'être** : un `constructor` ne peut pas être `async`, et la résolution DI est
220
+ synchrone. Tout ce qui demande un `await` à la mise en place de l'instance n'a pas d'autre endroit
221
+ où aller. Le hook est **optionnel** — le Resolver ne l'appelle que s'il existe
222
+ (`Resolver._createController()`, `Resolver.ts:269`). Son contrat est décrit par
223
+ `ControllerWithInitialize` (`Resolver.ts:72`) : aucun argument, retour `Promise<this>`.
224
+
225
+ ```typescript
226
+ async initialize(): Promise<this> {
227
+ this.setContextJson(); // forme de la réponse
228
+ const user = RequestContext.getUser(); // identité déjà résolue
229
+ this.prefs = await this.get<Prefs>("prefs").load(user.identifier);
230
+ return this; // toujours rendre `this`
231
+ }
232
+ ```
233
+
234
+ | Dans `initialize()`, tu peux… | Ce qui n'a rien à y faire |
235
+ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
236
+ | Un `await` de mise en place : charger des préférences, ouvrir une ressource, précalculer | Une **décision d'autorisation** — c'est `@IsGranted`, évalué avant, et un 403 n'arrive jamais jusqu'ici |
237
+ | Résoudre des services (`this.get("catalog")`) | Un travail qu'**une seule action sur cinq** utilise : il serait payé par toutes → fais-le dans l'action |
238
+ | Lire l'identité (`RequestContext.getUser()`) — le firewall est passé | Un effet de bord **par requête** qu'un rechargement ferait deux fois (compteur, envoi) sans idempotence |
239
+ | Poser un cookie ou un en-tête (`this.context?.setCookie()`) — rien n'est encore écrit | Une écriture longue qui bloque : la phase `initialize` est chronométrée, elle apparaîtra dans la debug bar |
240
+ | Choisir un mode de rendu (`this.setContextHtml()`) | Lire `this.session` sans l'avoir demandée : elle reste **lazy** (`@UseSession`, cf plus bas) |
241
+
242
+ > [!NOTE]
243
+ > **La session ne s'ouvre pas ici.** Nodefony a un point d'activation **unique**
244
+ > (`HttpKernel.startSession()`, étape 6), piloté par l'intention posée au match depuis `@UseSession`
245
+ > ou un paramètre `@Session`. Pour « une session sur tout ce contrôleur », décore la **classe** —
246
+ > l'appeler à la main dans `initialize()` doublerait le mécanisme, et le ferait avant le CSRF.
247
+
248
+ ### Une erreur dans `initialize()` ne pend pas
249
+
250
+ Si ton `initialize()` lève, l'exception remonte le pipeline et sort en réponse d'erreur cohérente :
251
+ **500 JSON**, serveur toujours sain. C'est prouvé par une sonde dédiée du dépôt
252
+ (`LifecycleController.initialize()`, `LifecycleController.ts:21`, exercée par
253
+ `lifecycle-init-crash.test.ts`). Aucune requête pendue, aucun timeout muet.
254
+
255
+ ### Les phases mesurées
256
+
257
+ Chaque étape est chronométrée sous le nom d'une **phase**, lisible dans la debug bar et le profileur :
258
+ `resolve` · `initialize` (DI + ton hook) · `parse` · `firewall` · `action` · `render` · `send`.
259
+ La phase `initialize` existe précisément pour que le temps passé dans ton hook et dans la résolution
260
+ DI **soit imputé à quelqu'un** au lieu de disparaître dans le bloc `action` (`Resolver.ts:281`).
261
+
262
+ ## 🔌 HTTP et WebSocket — le même contrôleur
263
+
264
+ C'est le différenciateur de Nodefony : **une classe, deux transports, les mêmes décorateurs**. Une
265
+ route WS se déclare avec `requirements: { methods: ["WEBSOCKET"] }` ; l'action est une méthode
266
+ ordinaire du même contrôleur.
267
+
268
+ ```typescript
269
+ @controller("/chat")
270
+ class ChatController extends Controller {
271
+ constructor(context: ContextType) {
272
+ super("chat", context);
273
+ }
274
+
275
+ // Appelée UNE fois au handshake (message absent), puis à CHAQUE frame reçue.
276
+ @route("chat-room", {
277
+ path: "/room",
278
+ requirements: { methods: ["WEBSOCKET"] },
279
+ })
280
+ async room(message?: string | Buffer) {
281
+ if (!message) {
282
+ return { type: "welcome" }; // handshake : l'objet retourné part en frame JSON
283
+ }
284
+ return { type: "echo", payload: message.toString() };
285
+ }
286
+ }
287
+ ```
288
+
289
+ ### Ce qui change entre les deux transports
290
+
291
+ <!-- prettier-ignore -->
292
+ | Aspect | HTTP | WebSocket |
293
+ | --- | --- | --- |
294
+ | Durée de vie du contexte | Une requête | **Toute la connexion** |
295
+ | Instance du contrôleur | Une par requête | **Une par connexion**, réutilisée à chaque frame |
296
+ | Nombre d'appels d'action | 1 | 1 au handshake (`WebsocketContext.handle()`, `WebsocketContext.ts:265`) + 1 par frame (`handleMessage()`, `WebsocketContext.ts:479`) |
297
+ | Argument de l'action | Variables de route (ou paramètres décorés) | Idem + **le message** en dernier argument (`WebsocketContext.ts:508`) |
298
+ | Rendu d'un `return` | Corps de la réponse | Frame envoyée sur la socket |
299
+ | Échec | Statut HTTP + corps d'erreur | **Code de fermeture** RFC 6455 (401/403 → 1008, 5xx → 1011, autre → 4004) |
300
+ | `initialize()` | À chaque requête | **Une seule fois**, au handshake |
301
+
302
+ La réutilisation de l'instance vient du cache posé sur le container du contexte
303
+ (`Resolver.newController()`, `Resolver.ts:232`) : le contexte WS étant partagé par la connexion, le
304
+ contrôleur l'est aussi. Un garde-fou vérifie que l'instance cachée est bien de la classe de la route
305
+ courante et la reconstruit sinon (`Resolver.ts:344-347`) — sans quoi un message invoquant une autre
306
+ action se tromperait d'objet.
307
+
308
+ > [!WARNING]
309
+ > **Sur une connexion WS, `this` survit aux frames.** Un champ écrit à la frame 1 est encore là à la
310
+ > frame 2 — pratique pour un état de conversation, piège si tu comptais sur une instance neuve. En
311
+ > HTTP, l'inverse : chaque requête repart d'une instance vierge.
312
+
313
+ Côté WebSocket, l'ordre est encore plus marqué : `HttpKernel.onConnect()` (`http-kernel.ts:1659`)
314
+ appelle `handleFrontController()` (donc `initialize()`) **avant** `startSession()`
315
+ (`http-kernel.ts:1131`), avant l'acceptation de la socket, et avant le firewall
316
+ (`http-kernel.ts:1457`).
317
+
318
+ ## 🧠 D'où viennent `request`, `response`, `session`
319
+
320
+ Ton contrôleur expose des raccourcis vers le transport. Ils ne sont **pas** des copies figées : ce
321
+ sont des accesseurs qui dérivent du contexte **vivant**, selon le motif `champ ?? dérivation`.
322
+
323
+ | Raccourci | Ce que tu obtiens | Ancre |
324
+ | ---------------- | -------------------------------------------------- | ------------------- |
325
+ | `this.context` | Le contexte transport de la requête courante | `Controller.ts:146` |
326
+ | `this.route` | La route matchée | `Controller.ts:158` |
327
+ | `this.request` | La requête (HTTP, HTTP/2 ou WS) | `Controller.ts:162` |
328
+ | `this.response` | La réponse du transport | `Controller.ts:169` |
329
+ | `this.method` | La méthode HTTP (ou `WEBSOCKET`) | `Controller.ts:178` |
330
+ | `this.queryGet` | Les paramètres de la query string | `Controller.ts:187` |
331
+ | `this.queryPost` | Le corps parsé | `Controller.ts:214` |
332
+ | `this.body` | Le corps parsé — alias de `queryPost` | `Controller.ts:229` |
333
+ | `this.queryFile` | Les fichiers uploadés | `Controller.ts:205` |
334
+ | `this.session` | La session **ou `null`** si elle n'est pas activée | `Controller.ts:229` |
335
+
336
+ Pourquoi des accesseurs plutôt que des champs recopiés au constructeur : **la fraîcheur et le coût**.
337
+ Une valeur recopiée vieillit dès que le pipeline modifie le contexte, et recopier quatre structures
338
+ par requête, c'est quatre allocations payées sur le hot path. L'accesseur lit la source de vérité,
339
+ gratuitement.
340
+
341
+ ### La session est **lazy** — elle n'existe que si tu la demandes
342
+
343
+ `this.session` est un simple getter sur `context.session` (`Controller.ts:229`). Il n'y a **pas** de
344
+ `startSession()` à appeler depuis un contrôleur : l'activation se déclare sur la route, avec
345
+ `@UseSession()` (ou un paramètre `@Session()`, qui vaut déclaration implicite), et le pipeline
346
+ l'exécute à son point unique. Sans déclaration et sans cookie de session entrant, **aucune session
347
+ n'est créée** — donc aucun coût de stockage.
348
+
349
+ Deux corollaires :
350
+
351
+ - Dans `initialize()`, `this.session` vaut `null` (l'activation vient plus tard — étape 6 du cycle).
352
+ - `this.getSession()` (`Controller.ts:394`) ne « démarre » rien : il retourne la session existante,
353
+ ou `undefined`.
354
+
355
+ Les messages flash s'appuient dessus : `setFlashBag()`/`addFlash()` (`Controller.ts:420`) et
356
+ `getFlashBag()` (`Controller.ts:412`) journalisent une **erreur** et retournent `null` si aucune
357
+ session n'est active — pas de crash, mais rien n'est mémorisé.
358
+
359
+ ### Contrôleur `singleton` — quand `this` n'est plus à toi
360
+
361
+ Par défaut, `Controller.scope` vaut `"request"` (`Controller.ts:119`). Un contrôleur **sans état**
362
+ peut passer en instance unique partagée :
363
+
364
+ ```typescript
365
+ @Scope("singleton")
366
+ @controller("/api/health")
367
+ class HealthController extends Controller {
368
+ /* … */
369
+ }
370
+ ```
371
+
372
+ Ce que ça change, concrètement :
373
+
374
+ - **une seule instance** pour tout le process, bindée au container du **kernel**, pas à celui de la
375
+ requête (`Controller.ts:244-252`) — capturer le container de requête serait fatal, il est nettoyé
376
+ au teardown ;
377
+ - **`initialize()` n'est appelé qu'une fois**, à la création (sémantique « boot ») ;
378
+ - le contexte n'est plus posé sur l'objet : `this.context` **retombe sur l'ALS**
379
+ (`RequestContext.getContext()`, `Controller.ts:147`) et retrouve donc la requête réellement en
380
+ cours, jamais celle d'une requête concurrente.
381
+
382
+ > [!WARNING]
383
+ > Un champ mutable par requête sur un contrôleur `singleton` est une **fuite de données entre
384
+ > utilisateurs** silencieuse (requête A lit ce qu'a écrit requête B). N'utilise `@Scope("singleton")`
385
+ > que si toutes tes données passent par les arguments décorés et l'ALS. Le défaut per-requête reste
386
+ > le choix sûr — le gain mesuré du singleton est dans le bruit de mesure.
387
+
388
+ ## 🧰 Répondre — ce que ton `return` déclenche
389
+
390
+ Le traducteur unique est `Resolver.returnController()` (`Resolver.ts:697`). Il regarde le **type**
391
+ de ce que tu as retourné :
392
+
393
+ <!-- prettier-ignore -->
394
+ | Tu retournes… | Ce qui se passe | Ancre |
395
+ | --- | --- | --- |
396
+ | Une `Promise` / un thenable | Déballée puis re-traitée (récursif) | `Resolver.ts:700-710` |
397
+ | Une `string` | Envoyée telle quelle en corps | `Resolver.ts:711` |
398
+ | Un objet simple ou un tableau | **Auto-JSON** : `application/json` + sérialisation | `Resolver.ts:760` |
399
+ | Un `number` / un `boolean` | Auto-JSON scalaire (RFC 8259 §2 : `42`, `true` sont des documents valides) | `Resolver.ts:734` |
400
+ | Un `Buffer` | Envoyé brut | `Resolver.ts:723` |
401
+ | Une `Response` (via un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts:716` |
402
+ | `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:798`) |
403
+ | `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts:801` |
404
+ | Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts:770-777` |
405
+
406
+ > [!WARNING]
407
+ > **Le piège n° 1 : `return null` sur un statut à corps.** Le framework l'interprète comme « je
408
+ > répondrai moi-même » et attend — jusqu'au timeout. La distinction se fait sur le **statut** :
409
+ > `NO_BODY_STATUS` (`Resolver.ts:817`) contient 204, 205 et 304. Donc un `@Delete` qui fait
410
+ > `@HttpCode(204)` puis `return null` répond bien 204 vide ; le même `return null` sans `@HttpCode`
411
+ > laisse la requête pendue.
412
+
413
+ Même règle pour une **instance de classe** (une entité ORM renvoyée telle quelle) : elle n'est
414
+ volontairement pas passée à `JSON.stringify`. Retourne un objet simple — ou appelle `renderJson()`.
415
+
416
+ ### Les helpers de rendu
417
+
418
+ Quand tu veux piloter l'envoi plutôt que retourner une valeur :
419
+
420
+ | Helper | Pour… | Ancre |
421
+ | -------------------------------------------- | -------------------------------------------------------- | ------------------- |
422
+ | `renderJson(obj, status?, headers?)` | JSON explicite avec statut/en-têtes | `Controller.ts:379` |
423
+ | `render(data, encoding?, status?, headers?)` | Envoyer un corps quelconque via le contexte | `Controller.ts:273` |
424
+ | `renderView(path, params, status?)` | Rendre un template **Eta** (avec les helpers frontend) | `Controller.ts:308` |
425
+ | `renderResponse(data, encoding?, …)` | Poser statut + en-têtes, puis envoyer | `Controller.ts:290` |
426
+ | `redirect(url, status?, headers?)` | Rediriger | `Controller.ts:382` |
427
+ | `forward("module:controller:action")` | Déléguer à une autre action **sans** aller-retour réseau | `Controller.ts:432` |
428
+ | `setContextJson()` / `setContextHtml()` | Choisir le type de contenu avant d'envoyer | `Controller.ts:282` |
429
+
430
+ `renderView()` mesure sa propre phase `render` et injecte automatiquement les aides frontend
431
+ (`frontendTags`, `frontendDocument`, `asset`) dans les variables du template
432
+ (`withFrontendLocals()`, `Controller.ts:345`) — tes propres valeurs restent prioritaires.
433
+
434
+ `forward()` re-résout un contrôleur sur le **même** contexte et rappelle son action
435
+ (`Controller.ts:445`) : c'est une délégation interne, la requête cliente reste unique.
436
+
437
+ > [!TIP]
438
+ > **Redirection : le code par défaut est 302** (Found), pas 301. Un statut absent ou hors de la liste
439
+ > RFC 9110 §15.4 (301, 302, 303, 307, 308) retombe sur 302 avec un log d'avertissement
440
+ > (`Response.redirect()`, `Response.ts:595`). Un 301 par défaut piégeait : les navigateurs le mettent
441
+ > en cache de façon quasi irréversible.
442
+
443
+ ## 📁 Servir un fichier — téléchargement et flux média
444
+
445
+ Deux besoins distincts, deux helpers.
446
+
447
+ ### Téléchargement — `renderFileDownload()`
448
+
449
+ `renderFileDownload(file, options?, headers?)` (`Controller.ts:473`) pose
450
+ `Content-Disposition: attachment`, `Content-Length`, le type MIME du fichier, puis délègue au moteur
451
+ de flux. Le fichier est résolu **sans bloquer l'event loop** (`getFileAsync()`, `Controller.ts:497`) ;
452
+ la variante synchrone `getFile()` existe encore mais est marquée obsolète — elle appelle `lstatSync`
453
+ et gèle le process le temps du stat.
454
+
455
+ ### Lecture en continu — `renderMediaStream()`
456
+
457
+ `renderMediaStream(file, headers?, options?)` (`Controller.ts:609`) implémente les **requêtes par
458
+ plage** (RFC 9110 §14), ce qui permet à un lecteur vidéo de sauter dans le flux :
459
+
460
+ | Le client envoie… | Réponse |
461
+ | --------------------------------------------- | --------------------------------------------------------------- |
462
+ | Pas de `Range` | 200 + fichier complet, `Accept-Ranges: bytes` |
463
+ | `Range: bytes=0-499` | **206** + `Content-Range`, bornes clampées à la taille réelle |
464
+ | `Range: bytes=-500` (suffixe) | 206 sur les 500 derniers octets |
465
+ | Plage hors fichier | **416** + `Content-Range: bytes */<taille>` (RFC 9110 §15.5.17) |
466
+ | Syntaxe invalide, multi-plage, unité inconnue | En-tête **ignoré** → 200 complet (jamais un 500) |
467
+
468
+ La logique est isolée dans une fonction pure exportée, `parseByteRange()` (`Controller.ts:73`) —
469
+ donc testable sans serveur.
470
+
471
+ ### Ce que `streamFile()` garantit
472
+
473
+ `streamFile()` (`Controller.ts:580`) est le moteur commun. Sa subtilité n'est pas le pipe, c'est le
474
+ **nettoyage** : le flux est ouvert avec `autoClose: false`, et un client qui raccroche en plein
475
+ téléchargement laisserait sinon un descripteur de fichier ouvert et une promesse pendue à jamais. Un
476
+ écouteur `close` sur la réponse détruit le flux, ce qui déclenche la fermeture du descripteur et
477
+ résout la promesse (`Controller.ts:553-558`), puis se retire lui-même (`Controller.ts:574`). Un
478
+ téléchargement interrompu ne coûte donc **rien** en ressource retenue.
479
+
480
+ ## ⚠️ Erreurs — lever, rendre, observer
481
+
482
+ La règle est simple : **on lève, on ne rend pas d'erreur à la main.**
483
+
484
+ ```typescript
485
+ import { nodefonyError } from "nodefony";
486
+ import { HttpError } from "@nodefony/http";
487
+
488
+ throw new nodefonyError("Article introuvable", 404); // statut porté par l'erreur
489
+ throw new HttpError("Not Found", 404, this.context); // variante enrichie du contexte
490
+ ```
491
+
492
+ L'exception remonte jusqu'à `HttpKernel.onError()` (`http-kernel.ts:874`), qui délègue la mise en
493
+ forme au rendeur d'erreurs. Ce qui en sort :
494
+
495
+ - **statut normalisé** — un code absent (ou l'ancien quirk `200`) devient **500**
496
+ (`normalizeHttpStatus()`, `error-renderer.ts:355`) ;
497
+ - **corps structuré** : `{ code, message, result: null, error: {…}, nodefony: {…} }`, l'enveloppe
498
+ `nodefony` portant l'environnement, l'URL et l'**identifiant de requête** — de quoi retrouver la
499
+ trace complète dans les logs ;
500
+ - **course gérée** : si le client est déjà parti (contexte terminé ou réponse envoyée), le framework
501
+ ne tente pas de rendre — il journalise et s'arrête (`http-kernel.ts:770-775`).
502
+
503
+ En **WebSocket**, il n'y a pas de statut : l'erreur devient un **code de fermeture** RFC 6455
504
+ (`renderWebsocket()`, `error-renderer.ts:393`) — 401/403 → 1008 (violation de politique),
505
+ 5xx → 1011 (erreur interne), le reste → 4004 (plage privée). Si la socket n'est pas encore acceptée,
506
+ c'est un **rejet** de handshake.
507
+
508
+ > [!NOTE]
509
+ > Les erreurs de ton action remontent **seules** : le Resolver n'enveloppe pas l'appel dans un
510
+ > `try/catch` inutile (`Resolver.ts:405-406`). Inutile d'attraper pour re-lever — sauf si tu veux
511
+ > vraiment traduire l'erreur en un autre statut.
512
+
513
+ ## 🧩 Services injectés — trois façons
514
+
515
+ Un contrôleur étant un `Service`, il a accès au container. Trois styles, du plus simple au plus
516
+ explicite :
517
+
518
+ ### 1. Résolution par nom — `this.get()`
519
+
520
+ ```typescript
521
+ const catalog = this.get<CatalogService>("catalog"); // null si absent ou container nettoyé
522
+ ```
523
+
524
+ `Service.get()` (`Service.ts:472`) est une **façade sûre** : elle retourne `null` au lieu de lever si
525
+ le container a déjà été détaché. C'est le style à privilégier dans `initialize()`.
526
+
527
+ ### 2. Injection par le constructeur — `@inject`
528
+
529
+ ```typescript
530
+ import { inject, Fetch } from "nodefony";
531
+
532
+ @controller("/demo")
533
+ class DemoController extends Controller {
534
+ constructor(
535
+ context: ContextType,
536
+ @inject("Fetch") private fetchService: Fetch,
537
+ ) {
538
+ super("DemoController", context);
539
+ }
540
+ }
541
+ ```
542
+
543
+ L'injecteur lit les noms déclarés par `@inject` et résout chaque dépendance dans le container avant
544
+ de construire (`Injector._instantiateWithStack()`, `injector.ts:254`). Le **contexte n'est pas une
545
+ dépendance** : il est passé en argument par le Resolver, et les paramètres non annotés le reçoivent
546
+ dans l'ordre (`injector.ts:309`). Les **dépendances circulaires sont détectées** et signalées avec le
547
+ chemin complet (`injector.ts:262-266`), jamais silencieusement.
548
+
549
+ ### 3. Depuis le contexte — pour un service optionnel
550
+
551
+ ```typescript
552
+ const svc = this.context?.container?.get("frontend");
553
+ ```
554
+
555
+ Utile quand le service peut légitimement être absent (module non chargé) et que tu veux dégrader
556
+ proprement plutôt que d'échouer à la construction.
557
+
558
+ ## ⚡ Performance & mémoire
559
+
560
+ Un contrôleur est sur le **hot path** : ce qu'il alloue est multiplié par le nombre de requêtes. Le
561
+ code du framework applique — et attend de toi — les règles suivantes :
562
+
563
+ - **Zéro recopie au constructeur** : l'état per-requête vit en champs privés `null` par défaut, et
564
+ les accesseurs dérivent du contexte (`Controller.ts:126-134`). Quatre allocations par requête ont
565
+ disparu de cette façon.
566
+ - **Zéro écouteur résiduel** : plus aucun `once("onRequestEnd")` n'est posé pour ré-échantillonner
567
+ l'état. Le seul écouteur restant est celui du flux de fichiers, explicitement retiré
568
+ (`Controller.ts:574`).
569
+ - **Métadonnées d'action figées** : `@HttpCode`, `@Header`, les paramètres décorés et l'intention de
570
+ session sont calculés **une fois** par route puis mémorisés, au lieu d'être relus par `Reflect` à
571
+ chaque requête (`resolveActionMeta()` appelé en `Resolver.ts:402`).
572
+ - **Gardes payées seulement si présentes** : sans `@IsGranted`, la vérification d'autorisation est
573
+ un test de nullité (`Resolver.ts:334`) — 0 lookup, 0 `await`, 0 allocation.
574
+ - **Ta part du contrat** : pas de structure allouée « au cas où » dans le constructeur ni dans
575
+ `initialize()`. Une valeur utile à 5 % des requêtes s'alloue à la demande.
576
+
577
+ ## 📜 Normes appliquées
578
+
579
+ | Domaine | Norme | Comment le code s'y conforme |
580
+ | -------------------------------- | ------------------------ | -------------------------------------------------------------- |
581
+ | Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts:817`) |
582
+ | Requêtes par plage | RFC 9110 §14.1.2, §14.2 | `parseByteRange()` (`Controller.ts:73`) |
583
+ | Plage insatisfiable → 416 | RFC 9110 §15.5.17 | `renderResponse()` avec 416 (`Controller.ts:304`) |
584
+ | Redirections | RFC 9110 §15.4 | Liste blanche + repli 302 (`Response.ts:534`) |
585
+ | Média JSON sans `charset` | RFC 8259 §11 | Auto-JSON (`Resolver.ts:760`), vérifié par le banc `auto-json` |
586
+ | Scalaire JSON de premier niveau | RFC 8259 §2 | `number`/`boolean` rendus (`Resolver.ts:734`) |
587
+ | Codes de fermeture WebSocket | RFC 6455 §7.4 | `renderWebsocket()` (`error-renderer.ts:393`) |
588
+
589
+ ## 📡 Observabilité — Studio
590
+
591
+ - **Playground** (`/nodefony/playground`, développement uniquement) : la liste de tes contrôleurs et
592
+ de leurs actions, avec formulaire d'appel généré — transports acceptés, paramètres décorés, gardes
593
+ (`@IsGranted`, `@Idempotent`, CSRF, intention de session). Aucun code à écrire pour essayer une
594
+ route.
595
+ - **Routes** : le dump du routeur (data plane `GET /nodefony/framework/api/routes`).
596
+ - **Debug bar** : les phases d'une requête (`resolve`, `initialize`, `parse`, `firewall`, `action`,
597
+ `render`, `send`) — c'est là qu'on voit si le temps part dans ton `initialize()` ou dans le rendu.
598
+
599
+ ## ⚠️ Pièges (symptôme → cause → correction)
600
+
601
+ <!-- prettier-ignore -->
602
+ | Symptôme | Cause (dans le code) | Correction |
603
+ | --- | --- | --- |
604
+ | La requête pend puis expire, alors que l'action a bien tourné | `return null`/`undefined` avec un statut à corps → `waitAsync` (`Resolver.ts:801`) | Retourner une valeur, ou poser `@HttpCode(204)` |
605
+ | Réponse vide alors qu'on retourne une entité ORM | Instance de classe **non** sérialisée → `waitAsync` (`Resolver.ts:775`) | Retourner un objet simple, ou `renderJson(entity.toJSON())` |
606
+ | `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 |
607
+ | `this.session` est `null` dans `initialize()` | La session est activée **après** (`http-kernel.ts:1142`) | Lire la session dans l'action, pas dans le hook |
608
+ | Effet de bord exécuté pour une requête finalement 401 | `initialize()` tourne avant `firewall.handleSecurity()` (`http-kernel.ts:1294`) | Déplacer l'effet de bord dans l'action |
609
+ | Redirection permanente non voulue | Un statut invalide retombe sur 302, un `301` explicite reste 301 | Passer le code voulu : `this.redirect(url, 302)` |
610
+ | WS : l'état d'une frame « bave » sur la suivante | L'instance est partagée par toute la connexion (`Resolver.ts:262`) | Réinitialiser l'état en tête d'action, ou le porter par message |
611
+ | WS : l'action n'est jamais appelée | Route sans transport `WEBSOCKET` déclaré | `requirements: { methods: ["WEBSOCKET"] }` |
612
+ | Contrôleur `singleton` : données d'un autre utilisateur | Champ mutable per-requête sur une instance partagée | Retirer `@Scope("singleton")`, ou passer par les arguments décorés |
613
+ | Event loop figé sur une route de fichier | `getFile()` synchrone (`lstatSync`, `Controller.ts:457`) | Utiliser `getFileAsync()` (`Controller.ts:472`) |
614
+
615
+ ## 🧪 Tests & couverture
616
+
617
+ Trois familles couvrent la brique — les **chiffres exacts vivent dans la carte de tests** de la page
618
+ (régénérée depuis vitest, jamais figée dans le texte) :
619
+
620
+ - **unitaires** — `Controller.test.ts` : chaque helper isolé (`setContext`, `renderJson`, `render`,
621
+ `renderResponse`, `renderView`, `setRoute`/`getSession`, getter `session`, `redirect`, messages
622
+ flash, `forward`, `getFile`/`getFileAsync`, `renderFileDownload`, `renderMediaStream`) ;
623
+ `controller-als.test.ts` : le repli sur l'ALS quand le contexte n'est pas porté par l'instance ;
624
+ `Resolver.test.ts` : le hook `initialize()`, la traduction des retours (HTTP **et** WS), les
625
+ statuts sans corps, la résolution `module:controller:action`.
626
+ - **intégration** (serveur réel) — `auto-json.test.ts` (conformité du retour automatique : statut,
627
+ type de média sans `charset`, longueur en octets), `errors.test.ts` (forme du corps d'erreur),
628
+ `body-content-types.test.ts` (corps parsé selon le type de contenu), `fileStream.test.ts` (flux et
629
+ plages, dont 416 et le repli sur 200).
630
+ - **sondes de cycle de vie** — `lifecycle-init-crash.test.ts` : un `initialize()` qui lève donne un
631
+ 500 cohérent et laisse le serveur sain.
632
+
633
+ Ce qui **manque** aujourd'hui : aucun banc de charge ni de mémoire dédié au contrôleur seul (le coût
634
+ est mesuré au niveau du pipeline complet, via `memory.test.ts` de `@nodefony/http` et les suites de
635
+ charge). Pour ces axes, voir les skills `nodefony-load-test` et `nodefony-check-memory-health`.
636
+
637
+ Couverture : `npm run coverage` dans `@nodefony/framework`.
638
+
639
+ ## 🔗 Pour aller plus loin
640
+
641
+ - ⬆️ **Retour au hub** : [Framework — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
642
+ - 🧭 **Pages sœurs** : [Routage](routing.md) (comment l'URL trouve ta route) · [Décorateurs](decorateurs.md) (`@Get`, `@Body`, `@IsGranted`…) · [Idempotence](idempotence.md) (mutations rejouées)
643
+ - Où le contrôleur s'insère dans le pipeline → [pipeline de requête](../../../../../docs/architecture/pipeline-requete.md)
644
+ - Qui authentifie avant ton action → [Firewall](../../security/docs/firewall.md)
645
+ - Signatures exactes des membres publics → graphe symbolique `.ai/symbols.json`