@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.
- package/LICENSE +544 -0
- package/README.md +50 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +211 -0
- package/dist/nodefony/config/config.js +61 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controller/AdminApiController.js +163 -0
- package/dist/nodefony/controller/ApiKeyController.js +151 -0
- package/dist/nodefony/controller/BenchController.js +132 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -0
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
- package/dist/nodefony/controller/SessionAuthController.js +141 -0
- package/dist/nodefony/controller/TokenAuthController.js +113 -0
- package/dist/nodefony/controller/TotpController.js +129 -0
- package/dist/nodefony/controller/WebAuthnController.js +242 -0
- package/dist/nodefony/controller/oauthAuthority.js +74 -0
- package/dist/nodefony/decorators/routerDecorators.js +967 -0
- package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
- package/dist/nodefony/interfaces/IController.js +1 -0
- package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
- package/dist/nodefony/interfaces/IResolver.js +1 -0
- package/dist/nodefony/interfaces/IRoute.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/AdminBroker.js +106 -0
- package/dist/nodefony/service/Eta.js +68 -0
- package/dist/nodefony/service/IdempotencyStore.js +136 -0
- package/dist/nodefony/service/router.js +243 -0
- package/dist/nodefony/src/Controller.js +515 -0
- package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
- package/dist/nodefony/src/KernelAdminApi.js +1243 -0
- package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
- package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
- package/dist/nodefony/src/Resolver.js +416 -0
- package/dist/nodefony/src/ResourceController.js +148 -0
- package/dist/nodefony/src/Route.js +476 -0
- package/dist/nodefony/src/SyslogAdminApi.js +466 -0
- package/dist/nodefony/src/Template.js +15 -0
- package/dist/nodefony/src/configMutation.js +186 -0
- package/dist/nodefony/src/docsReader.js +929 -0
- package/dist/nodefony/src/idempotency.js +137 -0
- package/dist/nodefony/src/idempotencyGc.js +32 -0
- package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
- package/dist/nodefony/src/scopeCatalog.js +40 -0
- package/dist/nodefony/src/syslogFilters.js +51 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/nodefony/config/config.d.ts +41 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
- package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
- package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
- package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
- package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
- package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
- package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
- package/dist/types/nodefony/service/Eta.d.ts +25 -0
- package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
- package/dist/types/nodefony/service/router.d.ts +53 -0
- package/dist/types/nodefony/src/Controller.d.ts +193 -0
- package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
- package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
- package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
- package/dist/types/nodefony/src/Resolver.d.ts +165 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
- package/dist/types/nodefony/src/Route.d.ts +192 -0
- package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
- package/dist/types/nodefony/src/Template.d.ts +8 -0
- package/dist/types/nodefony/src/configMutation.d.ts +109 -0
- package/dist/types/nodefony/src/docsReader.d.ts +369 -0
- package/dist/types/nodefony/src/idempotency.d.ts +96 -0
- package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
- package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
- package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
- package/docs/admin.md +451 -0
- package/docs/controller.md +645 -0
- package/docs/decorateurs.md +845 -0
- package/docs/idempotence.md +741 -0
- package/docs/index.md +151 -0
- package/docs/routing.md +648 -0
- package/docs/templates.md +380 -0
- 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`
|