@nodefony/framework 10.0.0-alpha.7 → 10.0.0-alpha.9
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/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +5 -4
- package/dist/nodefony/config/config.js +2 -1
- package/dist/nodefony/controller/AdminApiController.js +24 -4
- package/dist/nodefony/controller/ApiKeyController.js +2 -1
- package/dist/nodefony/controller/TotpController.js +2 -1
- package/dist/nodefony/decorators/routerDecorators.js +4 -1
- package/dist/nodefony/service/AdminBroker.js +4 -3
- package/dist/nodefony/service/IdempotencyStore.js +2 -2
- package/dist/nodefony/service/router.js +3 -2
- package/dist/nodefony/src/KernelAdminApi.js +12 -7
- package/dist/nodefony/src/Resolver.js +61 -0
- package/dist/nodefony/src/Route.js +9 -2
- package/dist/types/nodefony/config/config.d.ts +1 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +17 -1
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +14 -0
- package/dist/types/nodefony/src/Resolver.d.ts +35 -10
- package/dist/types/nodefony/src/Route.d.ts +28 -0
- package/docs/admin.md +25 -25
- package/docs/controller.md +44 -44
- package/docs/decorateurs.md +79 -79
- package/docs/idempotence.md +70 -65
- package/docs/routing.md +40 -40
- package/docs/templates.md +7 -7
- package/package.json +10 -10
package/docs/idempotence.md
CHANGED
|
@@ -67,21 +67,21 @@ reste du pipeline ne fait que traduire son verdict.
|
|
|
67
67
|
|
|
68
68
|
## 📖 Lexique
|
|
69
69
|
|
|
70
|
-
| Terme | Sens (dans ce module)
|
|
71
|
-
| ----------------- |
|
|
72
|
-
| Mutation | Méthode non sûre : `POST`/`PUT`/`PATCH`/`DELETE` (RFC 9110 §9.2.1). GET/HEAD/OPTIONS = no-op.
|
|
73
|
-
| `Idempotency-Key` | En-tête client : un identifiant par **intention** d'écriture (convention Stripe, draft IETF).
|
|
74
|
-
| Empreinte | SHA-256 de `route + params + corps` — détecte une clé réutilisée pour autre chose.
|
|
75
|
-
| Bail (_lease_) | Durée pendant laquelle une réservation _in-flight_ tient sans `complete`/`abort` (60 s).
|
|
76
|
-
| Rétention (TTL) | Durée pendant laquelle une réponse mémorisée reste rejouable (10 min).
|
|
77
|
-
| in-flight | Réservation posée, pas encore complétée ni abandonnée.
|
|
78
|
-
| Verdict | Décision neutre du helper : `execute` / `guarded` / `replay` / `reject`.
|
|
79
|
-
| Scope de clé | La clé réellement stockée = `[identité, clé client]` → anti-IDOR.
|
|
80
|
-
| IDOR | _Insecure Direct Object Reference_ : lire la donnée d'autrui en devinant son identifiant.
|
|
81
|
-
| Store | Backend qui porte les clés (`memory`, `redis`, `drizzle`) derrière le contrat `IIdempotencyStore`. |
|
|
82
|
-
| GC | _Garbage Collection_ : purge périodique des clés expirées — nécessaire seulement sans TTL natif.
|
|
83
|
-
| Fail-soft | Le store indisponible n'échoue pas la mutation : elle s'exécute **sans** dédup.
|
|
84
|
-
| Fail-loud | Un store distribué demandé mais non câblé fait échouer le boot plutôt que dédupliquer en silence.
|
|
70
|
+
| Terme | Sens (dans ce module) |
|
|
71
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
72
|
+
| Mutation | Méthode non sûre : `POST`/`PUT`/`PATCH`/`DELETE` (RFC 9110 §9.2.1). GET/HEAD/OPTIONS = no-op. |
|
|
73
|
+
| `Idempotency-Key` | En-tête client : un identifiant par **intention** d'écriture (convention Stripe, draft IETF). |
|
|
74
|
+
| Empreinte | SHA-256 de `route + params + corps` — détecte une clé réutilisée pour autre chose. |
|
|
75
|
+
| Bail (_lease_) | Durée pendant laquelle une réservation _in-flight_ tient sans `complete`/`abort` (60 s). |
|
|
76
|
+
| Rétention (TTL) | Durée pendant laquelle une réponse mémorisée reste rejouable (10 min). |
|
|
77
|
+
| in-flight | Réservation posée, pas encore complétée ni abandonnée. |
|
|
78
|
+
| Verdict | Décision neutre du helper : `execute` / `guarded` / `replay` / `reject`. |
|
|
79
|
+
| Scope de clé | La clé réellement stockée = `[identité, clé client]` → anti-IDOR. |
|
|
80
|
+
| IDOR | _Insecure Direct Object Reference_ : lire la donnée d'autrui en devinant son identifiant. |
|
|
81
|
+
| Store | Backend qui porte les clés (`memory`, `redis`, `drizzle`, `mongoose`) derrière le contrat `IIdempotencyStore`. |
|
|
82
|
+
| GC | _Garbage Collection_ : purge périodique des clés expirées — nécessaire seulement sans TTL natif. |
|
|
83
|
+
| Fail-soft | Le store indisponible n'échoue pas la mutation : elle s'exécute **sans** dédup. |
|
|
84
|
+
| Fail-loud | Un store distribué demandé mais non câblé fait échouer le boot plutôt que dédupliquer en silence. |
|
|
85
85
|
|
|
86
86
|
## Qu'est-ce que c'est ?
|
|
87
87
|
|
|
@@ -104,7 +104,7 @@ Sans ce ticket, trois choses cassent :
|
|
|
104
104
|
3. **DoS du cache** — une clé arbitrairement longue, ou un flot de clés uniques, remplirait la
|
|
105
105
|
mémoire. Une clé > 255 octets est traitée comme **absente** plutôt que stockée
|
|
106
106
|
(`IDEMPOTENCY_KEY_MAX`, `idempotency.ts:36`) et le cache mémoire est **borné** (`DEFAULT_CAP`,
|
|
107
|
-
`IdempotencyStore.ts:
|
|
107
|
+
`IdempotencyStore.ts:25`).
|
|
108
108
|
|
|
109
109
|
> [!IMPORTANT]
|
|
110
110
|
> L'idempotence n'est pas qu'une commodité de résilience : c'est une brique de **sécurité**. Une
|
|
@@ -119,9 +119,9 @@ connaît **aucun transport**. Il rend un verdict neutre (`IdempotencyVerdict`, `
|
|
|
119
119
|
que **deux** appelants traduisent dans leur monde :
|
|
120
120
|
|
|
121
121
|
- le **data plane admin** — `AdminApiController.idempotencyGate()`
|
|
122
|
-
(`AdminApiController.ts:
|
|
122
|
+
(`AdminApiController.ts:131`) → réponse `{status, headers, body}` ;
|
|
123
123
|
- les **controllers userland** décorés `@Idempotent` — seam `Resolver._callWithIdempotency()`
|
|
124
|
-
(`Resolver.ts:
|
|
124
|
+
(`Resolver.ts:499`) → `nodefonyError` typée, ou réponse rejouée.
|
|
125
125
|
|
|
126
126
|
Conséquence pratique : il est **impossible** que l'idempotence HTTP et l'idempotence WebSocket
|
|
127
127
|
divergent — elles partagent la même fonction. C'est le différenciateur du framework (HTTP + WS
|
|
@@ -187,7 +187,7 @@ use("@nodefony/framework", {
|
|
|
187
187
|
// un nom distribué non câblé fait ÉCHOUER le boot en production (fail-loud)
|
|
188
188
|
// plutôt que dédupliquer per-pod en silence.
|
|
189
189
|
store: "redis",
|
|
190
|
-
// Purge des clés expirées — utile
|
|
190
|
+
// Purge des clés expirées — utile pour les stores SANS TTL natif (drizzle, mongoose).
|
|
191
191
|
gcIntervalS: 600,
|
|
192
192
|
},
|
|
193
193
|
});
|
|
@@ -238,7 +238,7 @@ une route que d'anciens clients appellent déjà sans clé, le temps de la migra
|
|
|
238
238
|
async subscribe(@Body() body: SubscribeInput) { /* … */ }
|
|
239
239
|
```
|
|
240
240
|
|
|
241
|
-
Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:
|
|
241
|
+
Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:1576`), comme
|
|
242
242
|
`@UseSession`. Poser `@Idempotent()` sur la **classe** couvre toutes les mutations du controller ;
|
|
243
243
|
une méthode peut resserrer ou relâcher le mode. Les méthodes sûres (GET…) restent des no-op même
|
|
244
244
|
sous une classe décorée.
|
|
@@ -264,9 +264,9 @@ Deux mécanismes rendent ça possible côté socket :
|
|
|
264
264
|
- **La méthode logique voyage par `methodOverride`** — sur une socket, `context.method` vaut
|
|
265
265
|
`WEBSOCKET`, qui n'est **pas** une mutation. Sans override, la porte serait sautée et un rejeu de
|
|
266
266
|
frame `socket.mutate` créerait un doublon. Le Resolver teste donc
|
|
267
|
-
`isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:
|
|
267
|
+
`isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:126`).
|
|
268
268
|
|
|
269
|
-
Le pont utilise `executeActionGuarded()` (`Resolver.ts:
|
|
269
|
+
Le pont utilise `executeActionGuarded()` (`Resolver.ts:464`) : porte d'idempotence **sans** rendu HTTP
|
|
270
270
|
— la valeur nue est enveloppée par le peer WS, jamais écrite sur un transport HTTP.
|
|
271
271
|
|
|
272
272
|
## 🏗️ Architecture interne
|
|
@@ -299,7 +299,7 @@ sequenceDiagram
|
|
|
299
299
|
|
|
300
300
|
### Le parcours d'une mutation, étape par étape
|
|
301
301
|
|
|
302
|
-
1. **Court-circuit hot path.** `callController()` (`Resolver.ts:
|
|
302
|
+
1. **Court-circuit hot path.** `callController()` (`Resolver.ts:435`) lit `meta.idempotent` sur les
|
|
303
303
|
métadonnées d'action **figées par route**. `null` sur la quasi-totalité des routes → une
|
|
304
304
|
comparaison, flux normal, **zéro** lookup de store et zéro allocation.
|
|
305
305
|
2. **No-op sur méthode sûre.** Une action `GET` sous une classe `@Idempotent` repart directement en
|
|
@@ -327,20 +327,20 @@ sequenceDiagram
|
|
|
327
327
|
|
|
328
328
|
| Appelant | Point d'entrée | Traduction du verdict |
|
|
329
329
|
| ---------------------------- | -------------------------------------------------------------------- | ---------------------------------- |
|
|
330
|
-
| Controller userland HTTP | `callController()` (`Resolver.ts:
|
|
331
|
-
| Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:
|
|
332
|
-
| Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:
|
|
330
|
+
| Controller userland HTTP | `callController()` (`Resolver.ts:435`) | `nodefonyError` + rendu normal |
|
|
331
|
+
| Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:464`) | valeur nue, enveloppée par le peer |
|
|
332
|
+
| Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:131`) | `{status, headers, body}` |
|
|
333
333
|
|
|
334
334
|
## ⚙️ Configuration
|
|
335
335
|
|
|
336
336
|
Source unique = schéma Zod `idempotencySchema`
|
|
337
337
|
(`src/packages/@nodefony/framework/nodefony/config/config.ts:42`).
|
|
338
338
|
|
|
339
|
-
| Option | Type | Défaut | Effet
|
|
340
|
-
| ------------- | --------- | -------- |
|
|
341
|
-
| `store` | `string` | `"auto"` | Backing du cache. `auto` suit l'infra déclarée ; `memory` / `redis` / `drizzle` sont explicites (`config.ts:43`).
|
|
342
|
-
| `gcIntervalS` | `int ≥ 0` | `600` | Intervalle de purge des clés expirées, **hors** hot-path. Sans effet pour `redis` (TTL natif) et `memory` (`config.ts:57`).
|
|
343
|
-
| `gcJitter` | `bool` | `true` | Étale le départ du GC par process — anti _thundering-herd_ sur un store SQL partagé (`config.ts:68`).
|
|
339
|
+
| Option | Type | Défaut | Effet |
|
|
340
|
+
| ------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
341
|
+
| `store` | `string` | `"auto"` | Backing du cache. `auto` suit l'infra déclarée ; `memory` / `redis` / `drizzle` / `mongoose` sont explicites (`config.ts:43`). |
|
|
342
|
+
| `gcIntervalS` | `int ≥ 0` | `600` | Intervalle de purge des clés expirées, **hors** hot-path. Sans effet pour `redis` (TTL natif) et `memory` (`config.ts:57`). |
|
|
343
|
+
| `gcJitter` | `bool` | `true` | Étale le départ du GC par process — anti _thundering-herd_ sur un store SQL partagé (`config.ts:68`). |
|
|
344
344
|
|
|
345
345
|
### Comment `store: "auto"` se résout VRAIMENT
|
|
346
346
|
|
|
@@ -351,7 +351,7 @@ réel est le suivant :
|
|
|
351
351
|
|
|
352
352
|
| Ordre | Condition | Résolution | Ancre |
|
|
353
353
|
| ----- | ------------------------------------------------------------------------ | -------------------------------------- | -------------- |
|
|
354
|
-
| 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts:
|
|
354
|
+
| 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts:343` |
|
|
355
355
|
| 2 | Infra **cache** déclarée (`NF_REDIS_URL`) et `redis` enregistré | `redis` | `infra.ts:258` |
|
|
356
356
|
| 3 | Infra **database** déclarée (`NF_DATABASE_URL`) et le backend enregistré | `drizzle` (SQL) / `mongoose` (Mongo) | `infra.ts:261` |
|
|
357
357
|
| 4 | Une préférence existait mais son backend n'est pas enregistré | `fallback` = `memory`, raison ANNONCÉE | `infra.ts:274` |
|
|
@@ -364,11 +364,11 @@ réel est le suivant :
|
|
|
364
364
|
> que les données survivent au redémarrage. La résolution effective est toujours **journalisée au
|
|
365
365
|
> boot** (`index.ts:207`) : lis cette ligne plutôt que de la déduire.
|
|
366
366
|
|
|
367
|
-
MongoDB mérite une mention : `mongoose`
|
|
368
|
-
`NF_DATABASE_URL=mongodb://…`
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
367
|
+
MongoDB mérite une mention : `mongoose` porte lui aussi un store d'idempotence. Déclarer
|
|
368
|
+
`NF_DATABASE_URL=mongodb://…` résout donc en étape **3** → `mongoose`, et la dédup cross-pod tient
|
|
369
|
+
sans qu'il faille ajouter Redis à l'infrastructure. Sa réservation atomique repose sur la
|
|
370
|
+
contrainte d'unicité de `_id` — un `findOneAndUpdate` filtré sur l'entrée morte, en `upsert`, dont
|
|
371
|
+
le perdant reçoit un `E11000` : l'équivalent Mongo du `SET … NX PX` de Redis.
|
|
372
372
|
|
|
373
373
|
### Le contrat de dégradation : fail-loud, jamais silencieux
|
|
374
374
|
|
|
@@ -392,28 +392,29 @@ Tous respectent le même contrat `IIdempotencyStore`
|
|
|
392
392
|
|
|
393
393
|
### Choisir en 5 secondes
|
|
394
394
|
|
|
395
|
-
| Store
|
|
396
|
-
|
|
|
397
|
-
| `memory`
|
|
398
|
-
| `redis`
|
|
399
|
-
| `drizzle`
|
|
395
|
+
| Store | Atomicité de `begin` | Expiration | `gc()` | Multi-pod | Pour… |
|
|
396
|
+
| ---------- | ----------------------------------------------- | ------------------ | :----: | :-------: | ---------------------------------------- |
|
|
397
|
+
| `memory` | mono-thread JS | passive + cap FIFO | ❌ | ❌ | dev, tests, mono-pod |
|
|
398
|
+
| `redis` | `SET … NX PX` | TTL natif (`PX`) | ❌ | ✅ | cluster — le choix par défaut recommandé |
|
|
399
|
+
| `drizzle` | `INSERT … ON CONFLICT DO UPDATE … WHERE expiré` | applicative | ✅ | ✅ | cluster qui a déjà du SQL, pas de Redis |
|
|
400
|
+
| `mongoose` | `findOneAndUpdate(expiré) upsert` → `E11000` | applicative | ✅ | ✅ | cluster qui a déjà Mongo, pas de Redis |
|
|
400
401
|
|
|
401
402
|
### `memory` — le défaut per-pod, gratuit
|
|
402
403
|
|
|
403
404
|
`MemoryIdempotencyStore` (`IdempotencyStore.ts:44`) est enregistré d'office comme service DI
|
|
404
405
|
`idempotencyStore` par le manifeste `@services` du module framework — zéro configuration.
|
|
405
406
|
|
|
406
|
-
- **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts:
|
|
407
|
+
- **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts:115`) lit et écrit la `Map`
|
|
407
408
|
sans point de suspension → deux `begin` concurrents ne peuvent pas se croiser.
|
|
408
|
-
- **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts:
|
|
409
|
-
`DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts:
|
|
410
|
-
(`IdempotencyStore.ts:
|
|
411
|
-
- **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts:
|
|
409
|
+
- **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts:21`), bail
|
|
410
|
+
`DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts:23`), plafond `DEFAULT_CAP` = 1000 entrées
|
|
411
|
+
(`IdempotencyStore.ts:25`). Elles ne sont **pas** configurables.
|
|
412
|
+
- **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts:115`) ; aucun timer,
|
|
412
413
|
aucun listener.
|
|
413
414
|
- **Purge passive** : les entrées expirées ne sont retirées qu'à l'écriture, dans `evictIfNeeded()`
|
|
414
|
-
(`IdempotencyStore.ts:
|
|
415
|
+
(`IdempotencyStore.ts:172`), qui purge d'abord les mortes puis évince en **FIFO** jusqu'à repasser
|
|
415
416
|
sous le cap. Coût nul tant qu'on n'écrit pas.
|
|
416
|
-
- **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts:
|
|
417
|
+
- **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts:142`) n'écrit **que** si la clé est
|
|
417
418
|
encore _notre_ in-flight — jamais de résurrection d'une clé déjà `abort`-ée ou évincée.
|
|
418
419
|
|
|
419
420
|
⚠️ Limite structurelle : la dédup est **affine au pod**. Un rejeu routé vers un autre pod n'est pas
|
|
@@ -452,7 +453,7 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
|
|
|
452
453
|
|
|
453
454
|
- **Réservation atomique en UNE instruction** — un `INSERT` avec
|
|
454
455
|
`onConflictDoUpdate` (`DrizzleIdempotencyStore.ts:234`) dont la garde `setWhere` ne réécrit que si
|
|
455
|
-
l'entrée est morte (`DrizzleIdempotencyStore.ts:
|
|
456
|
+
l'entrée est morte (`DrizzleIdempotencyStore.ts:264`). Le `returning` ne rend une ligne que si
|
|
456
457
|
l'INSERT a passé (clé neuve) ou si le `DO UPDATE` a **volé** une entrée expirée → `fresh`. Zéro
|
|
457
458
|
ligne = contention → on lit l'état réel.
|
|
458
459
|
- **Invariant capital** : le store ne renvoie **jamais** `fresh` hors réservation atomique gagnée.
|
|
@@ -460,14 +461,14 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
|
|
|
460
461
|
(`DrizzleIdempotencyStore.ts:264`), jamais `fresh`.
|
|
461
462
|
- **MySQL/MariaDB** : ni `RETURNING`, ni `WHERE` sur l'`ON DUPLICATE KEY UPDATE`, et un `affectedRows`
|
|
462
463
|
ambigu → la réservation passe par `reserveIdempotencyKeyMysql()`
|
|
463
|
-
(`DrizzleIdempotencyStore.ts:
|
|
464
|
+
(`DrizzleIdempotencyStore.ts:233`), qui la reconstruit en deux instructions chacune atomique.
|
|
464
465
|
- **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts:318`) = `DELETE WHERE expiresAt <= now`.
|
|
465
466
|
C'est le **seul** store qui expose `gc`, donc le seul que le framework planifie (voir plus bas).
|
|
466
|
-
- **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:
|
|
467
|
-
(`DrizzleIdempotencyStore.ts:
|
|
467
|
+
- **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:296`) et `abort()`
|
|
468
|
+
(`DrizzleIdempotencyStore.ts:314`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
|
|
468
469
|
déjà mémorisée, jamais de résurrection d'une clé libérée. `complete` ne touche pas `fingerprint`.
|
|
469
470
|
- **Résolution lazy + dégradation gracieuse** : le handle Drizzle est résolu à **chaque** appel
|
|
470
|
-
(`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:
|
|
471
|
+
(`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:191`). ORM non connecté → `begin`
|
|
471
472
|
renvoie `fresh` (sans dédup), le reste est no-op.
|
|
472
473
|
|
|
473
474
|
Le câblage est **automatique** : charger `@nodefony/drizzle` enregistre l'entité **et** la fabrique
|
|
@@ -522,10 +523,14 @@ Deux détails qui expliquent des surprises réelles :
|
|
|
522
523
|
| `drizzle` (SQL) | SQLite | idem, mais mono-machine → **test** | ❌ | ✅ |
|
|
523
524
|
| `memory` | RAM du pod | mono-thread JS ; cap FIFO 1000 ; TTL 10 min | ❌ | n/a |
|
|
524
525
|
|
|
525
|
-
> [!
|
|
526
|
-
> **MongoDB (`@nodefony/mongoose`)
|
|
527
|
-
>
|
|
528
|
-
>
|
|
526
|
+
> [!NOTE]
|
|
527
|
+
> **MongoDB (`@nodefony/mongoose`) porte un store d'idempotence.** `store: "mongoose"` est
|
|
528
|
+
> sélectionnable dès que le module est chargé, et `auto` avec une infra Mongo y résout tout seul.
|
|
529
|
+
> En cluster Mongo, la dédup cross-pod n'exige donc pas d'ajouter Redis.
|
|
530
|
+
>
|
|
531
|
+
> Comme la variante SQL, il n'a **pas de TTL natif** : le framework arme un `GcScheduler` parce
|
|
532
|
+
> que le store expose `gc()`. L'échéance d'une entrée bascule du bail à la rétention, ce qu'un
|
|
533
|
+
> index TTL ne saurait pas suivre.
|
|
529
534
|
|
|
530
535
|
## 🧰 API publique
|
|
531
536
|
|
|
@@ -533,8 +538,8 @@ Signatures complètes : `.ai/symbols.json`. Ce qui compte à l'usage :
|
|
|
533
538
|
|
|
534
539
|
### Le décorateur
|
|
535
540
|
|
|
536
|
-
`@Idempotent(options?)` (`routerDecorators.ts:
|
|
537
|
-
métadonnées (`IdempotentMeta`, `routerDecorators.ts:
|
|
541
|
+
`@Idempotent(options?)` (`routerDecorators.ts:1142`) — dual **classe + méthode**. N'écrit que des
|
|
542
|
+
métadonnées (`IdempotentMeta`, `routerDecorators.ts:457`), zéro import de `@nodefony/security`, zéro
|
|
538
543
|
cycle. La porte est appliquée par le Resolver.
|
|
539
544
|
|
|
540
545
|
### Le contrat de store
|
|
@@ -576,7 +581,7 @@ Le mode **curseur** de Redis mérite une explication, parce qu'il piège : `SCAN
|
|
|
576
581
|
plafond** mais un indice d'effort — Redis peut rendre plus de clés que demandé. Sans précaution, la
|
|
577
582
|
page dépasserait `limit` et violerait le contrat `IPage` (`src/nodefony/src/types/IPage.ts:108`). D'où
|
|
578
583
|
le **curseur composite** `"<consommé>:<curseurRedis>"` (`encodeCursor()`,
|
|
579
|
-
`RedisIdempotencyStore.ts:
|
|
584
|
+
`RedisIdempotencyStore.ts:63`) : on ne rend que `limit` éléments et on mémorise combien de clés du
|
|
580
585
|
lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et reprend là.
|
|
581
586
|
|
|
582
587
|
> [!IMPORTANT]
|
|
@@ -641,13 +646,13 @@ pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
|
|
|
641
646
|
|
|
642
647
|
Le coût est **nul hors mutations décorées**. Sans `@Idempotent`, `RouteActionMeta.idempotent` vaut
|
|
643
648
|
`null` (`routerDecorators.ts:1103`) : `callController()` fait **une comparaison** et repart en flux
|
|
644
|
-
normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:
|
|
649
|
+
normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:435`).
|
|
645
650
|
La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
|
|
646
651
|
|
|
647
652
|
Sur le chemin décoré :
|
|
648
653
|
|
|
649
654
|
- Le store mémoire n'alloue sa `Map` qu'au 1ᵉʳ `begin`, ne pose **aucun timer ni listener**, et purge
|
|
650
|
-
en passif (`IdempotencyStore.ts:
|
|
655
|
+
en passif (`IdempotencyStore.ts:115`).
|
|
651
656
|
- L'empreinte est un hash SHA-256 court → comparaison O(1), et le payload n'est jamais conservé en
|
|
652
657
|
clair.
|
|
653
658
|
- Le GC est **hors hot-path** et armé pour un seul store (SQL) ; le jitter évite que N pods purgent
|
|
@@ -669,7 +674,7 @@ Trois surfaces existent aujourd'hui :
|
|
|
669
674
|
façon la plus rapide de voir un rejeu, un `409` ou un `422` en vrai.
|
|
670
675
|
- **Stores** — la brique `idempotency` y apparaît avec sa nature **éphémère**, le backend configuré,
|
|
671
676
|
le backend résolu, la liste des backends disponibles et la raison de la résolution — brique
|
|
672
|
-
`idempotency` (`stores/storesModel.ts:
|
|
677
|
+
`idempotency` (`stores/storesModel.ts:145`). C'est là qu'on vérifie qu'`auto` a choisi ce qu'on
|
|
673
678
|
croyait.
|
|
674
679
|
- **ERD** — la table `idempotency_key` est regroupée sous `@nodefony/framework`
|
|
675
680
|
(`idempotencyEntity.ts:133`), pas sous l'ORM qui l'héberge.
|
|
@@ -684,7 +689,7 @@ Trois surfaces existent aujourd'hui :
|
|
|
684
689
|
|
|
685
690
|
| Symptôme | Cause (dans le code) | Correction |
|
|
686
691
|
| ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
|
687
|
-
| Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:
|
|
692
|
+
| Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:581`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
|
|
688
693
|
| `409` en boucle sur un endpoint | Action qui lève avant `complete`/`abort` → in-flight bloqué jusqu'au bail (60 s) | Le seam le gère ; en usage manuel du store, `try/finally` obligatoire |
|
|
689
694
|
| `422 Idempotency-Key is already used` | Même clé, **payload différent** (empreinte ≠, `idempotency.ts:189`) | Une clé = une intention ; nouvelle clé par requête distincte |
|
|
690
695
|
| Rien n'est dédupliqué **malgré** la clé | Pas d'identité fiable → verdict `execute` (`idempotency.ts:176`) | S'assurer que le firewall a résolu l'utilisateur **avant** la mutation |
|
package/docs/routing.md
CHANGED
|
@@ -55,7 +55,7 @@ Trois faits à retenir avant tout le reste :
|
|
|
55
55
|
(`HttpError("Not Found", 404)`, `http-kernel.ts:798`).
|
|
56
56
|
3. **Le chemin est vérifié avant la méthode, et le domaine entre les deux** — c'est ce qui produit un
|
|
57
57
|
`403` plutôt qu'un `405` bavard quand la route appartient à un autre vhost (`Route.match()`,
|
|
58
|
-
`Route.ts:
|
|
58
|
+
`Route.ts:327`).
|
|
59
59
|
|
|
60
60
|
## 📖 Lexique
|
|
61
61
|
|
|
@@ -101,7 +101,7 @@ contrôleur.
|
|
|
101
101
|
|
|
102
102
|
**La performance ne change pas la sémantique.** Sous le capot, la table est partitionnée : les chemins
|
|
103
103
|
**littéraux** (aucun `{var}`, aucun métacaractère) vivent dans une `Map path → candidates` en lookup
|
|
104
|
-
O(1) ; les chemins **dynamiques** restent un scan regex (`buildRouteIndex()`, `router.ts:
|
|
104
|
+
O(1) ; les chemins **dynamiques** restent un scan regex (`buildRouteIndex()`, `router.ts:130`). À la
|
|
105
105
|
résolution, les deux flux sont fusionnés **par position d'insertion** — la séquence de candidats est
|
|
106
106
|
exactement celle du scan linéaire complet, moins les littérales d'un autre chemin, qui ne pouvaient de
|
|
107
107
|
toute façon pas correspondre (`Router.resolve()`, `router.ts:221`). C'est cette équivalence que fige le
|
|
@@ -109,7 +109,7 @@ banc de non-régression : un refacto du routeur doit le repasser à l'identique.
|
|
|
109
109
|
|
|
110
110
|
**Une seule table pour HTTP et WebSocket.** Il n'y a pas de « routeur WS » séparé : une action WS est
|
|
111
111
|
une route dont les méthodes déclarées contiennent `WEBSOCKET` (`Route.matchRequirements()`,
|
|
112
|
-
`Route.ts:
|
|
112
|
+
`Route.ts:700`). C'est le différenciateur du framework — le même contrôleur, le même contexte, les
|
|
113
113
|
mêmes décorateurs.
|
|
114
114
|
|
|
115
115
|
**Le routeur passe avant les fichiers statiques.** Une requête qui correspond à une route ne paie
|
|
@@ -263,15 +263,15 @@ que chaque forme **produit dans la table**.
|
|
|
263
263
|
| `@route(nom, options)` | **le tien** (stable, réutilisable) | `requirements.methods` (libre) | WebSocket, multi-méthodes, contraintes fines |
|
|
264
264
|
|
|
265
265
|
- Les décorateurs de méthode HTTP délèguent tous à `@route` avec un nom auto `Classe::methode`, et
|
|
266
|
-
posent `requirements: { methods }` (`httpMethodDecorator()`, `routerDecorators.ts:
|
|
266
|
+
posent `requirements: { methods }` (`httpMethodDecorator()`, `routerDecorators.ts:469`).
|
|
267
267
|
- `@All` n'émet **aucun** requirement de méthode : la route sert alors GET, POST, DELETE… et ne peut
|
|
268
|
-
donc jamais produire un 405 sur la méthode (`All()`, `routerDecorators.ts:
|
|
268
|
+
donc jamais produire un 405 sur la méthode (`All()`, `routerDecorators.ts:528`).
|
|
269
269
|
- `@route` est la forme complète : elle seule permet `protocol` (sous-protocole WS), un nom lisible, et
|
|
270
270
|
des requirements par variable.
|
|
271
271
|
|
|
272
272
|
**Comment une déclaration devient une route.** Les décorateurs de méthode **accumulent** des métadonnées
|
|
273
273
|
sur le constructeur (clé `routes:definitions`, `routerDecorators.ts:16`) ; c'est `@controller(prefix)`
|
|
274
|
-
qui les lit et appelle `Router.createRoute()` pour chacune (`controller()`, `routerDecorators.ts:
|
|
274
|
+
qui les lit et appelle `Router.createRoute()` pour chacune (`controller()`, `routerDecorators.ts:189`).
|
|
275
275
|
|
|
276
276
|
> [!WARNING]
|
|
277
277
|
> **`@route`/`@Get` doivent être SOUS `@controller`** — les décorateurs de classe s'évaluent après ceux
|
|
@@ -281,7 +281,7 @@ qui les lit et appelle `Router.createRoute()` pour chacune (`controller()`, `rou
|
|
|
281
281
|
## Motifs de chemin et paramètres
|
|
282
282
|
|
|
283
283
|
Le chemin déclaré est compilé **une fois**, à la création de la route, en une expression régulière
|
|
284
|
-
ancrée et **insensible à la casse** (`Route.compile()`, `Route.ts:
|
|
284
|
+
ancrée et **insensible à la casse** (`Route.compile()`, `Route.ts:424`). La grammaire tient en cinq
|
|
285
285
|
briques (`REG_ROUTE`, `Route.ts:17`) :
|
|
286
286
|
|
|
287
287
|
| Écriture | Motif compilé | Capture | Exemple |
|
|
@@ -295,7 +295,7 @@ briques (`REG_ROUTE`, `Route.ts:17`) :
|
|
|
295
295
|
Et trois comportements qui surprennent la première fois :
|
|
296
296
|
|
|
297
297
|
- **Le slash final est retiré avant le matching** — `/books/` et `/books` désignent la même route
|
|
298
|
-
(`Route.cleanPathname()`, `Route.ts:
|
|
298
|
+
(`Route.cleanPathname()`, `Route.ts:303`). Corollaire : `/files/*` ne matche pas `/files/`, qui a été
|
|
299
299
|
normalisé en `/files`.
|
|
300
300
|
- **Les valeurs sont URL-décodées** — `%C3%A9t%C3%A9` arrive dans l'action comme `été`
|
|
301
301
|
(`decode()`, `Route.ts:79`).
|
|
@@ -307,7 +307,7 @@ Et trois comportements qui surprennent la première fois :
|
|
|
307
307
|
C'est le mécanisme le moins évident, et le plus utile. Déclarer un `defaults` pour une variable change
|
|
308
308
|
le motif compilé : le segment devient facultatif (`[^/]*`) **et son slash aussi** (`/?`), puis la valeur
|
|
309
309
|
par défaut est réinjectée quand la capture est vide (`checkDefaultParameters()`, `Route.ts:99` ·
|
|
310
|
-
`Route.hydrateDefaultParameters()`, `Route.ts:
|
|
310
|
+
`Route.hydrateDefaultParameters()`, `Route.ts:513`).
|
|
311
311
|
|
|
312
312
|
```ts ignore
|
|
313
313
|
@route("route-page", { path: "/page/{slug}", defaults: { slug: "home" } })
|
|
@@ -327,7 +327,7 @@ async page(slug: string) {
|
|
|
327
327
|
Les captures sont passées **positionnellement**, dans l'ordre des variables du chemin — c'est pourquoi
|
|
328
328
|
la signature `async method6(metier: string, format: string)` suit l'ordre de `/{metier}/{format}`. Un
|
|
329
329
|
wildcard est exposé sous les clés `wildcard` et `*`. Le `Resolver` en fabrique aussi un instantané
|
|
330
|
-
nom → valeur par requête (`Resolver.getMatchedParams()`, `Resolver.ts:
|
|
330
|
+
nom → valeur par requête (`Resolver.getMatchedParams()`, `Resolver.ts:188`), lu par le contexte pour
|
|
331
331
|
les métadonnées et par les décorateurs `@Param`.
|
|
332
332
|
|
|
333
333
|
> [!IMPORTANT]
|
|
@@ -389,11 +389,11 @@ accepte, pas seulement ce que la dernière route scannée acceptait.
|
|
|
389
389
|
### Situation 3 — une route réservée à un domaine
|
|
390
390
|
|
|
391
391
|
Une route restreinte par `@Domain` est **invisible** aux requêtes des autres vhosts : elle lève un 403
|
|
392
|
-
au lieu de participer au match (`Route.matchHostname()`, `Route.ts:
|
|
392
|
+
au lieu de participer au match (`Route.matchHostname()`, `Route.ts:641`). Le point de sécurité est
|
|
393
393
|
l'**ordre des vérifications** : le domaine est vérifié **avant** la méthode. Sans cela, une route d'un
|
|
394
394
|
autre vhost pourrait répondre 405 en révélant SES méthodes — une fuite d'information cross-domaine
|
|
395
|
-
(`Route.match()`, `Route.ts:
|
|
396
|
-
exclues du calcul de `Allow` (`isDomainAllowed`, `router.ts:
|
|
395
|
+
(`Route.match()`, `Route.ts:327`). La passe 2 applique la même règle : les routes d'un autre vhost sont
|
|
396
|
+
exclues du calcul de `Allow` (`isDomainAllowed`, `router.ts:341`).
|
|
397
397
|
|
|
398
398
|
Si une autre route du même chemin sert **tous** les vhosts, le scan continue jusqu'à elle : le 403
|
|
399
399
|
n'interrompt pas la recherche, il ne conclut que s'il ne reste aucune candidate.
|
|
@@ -418,12 +418,12 @@ Ce qui change par rapport au HTTP :
|
|
|
418
418
|
inconnu ou un sous-protocole non conforme ferme la connexion **sans jamais l'ouvrir**.
|
|
419
419
|
- **Le sous-protocole est un requirement de route.** Un `protocol` déclaré et non satisfait lève une
|
|
420
420
|
erreur de code **1002** (Protocol Error, RFC 6455 §7.4) au lieu d'un statut HTTP
|
|
421
|
-
(`acceptedProtocol`, `Route.ts:
|
|
421
|
+
(`acceptedProtocol`, `Route.ts:758`).
|
|
422
422
|
- **Le 405 ne s'applique pas au WebSocket.** La passe 2 est réservée au HTTP : sur un contexte WS,
|
|
423
423
|
l'exception d'origine est préservée (`Router.resolve()`, `router.ts:230`).
|
|
424
424
|
- **Un `Resolver` par connexion, réutilisé à chaque frame.** Il est créé au handshake, puis chaque
|
|
425
425
|
message rejoue `match()` sur la route déjà trouvée avant d'appeler l'action
|
|
426
|
-
(`WebsocketContext.handle()`, `WebsocketContext.ts:
|
|
426
|
+
(`WebsocketContext.handle()`, `WebsocketContext.ts:265` · boucle message,
|
|
427
427
|
`callController`, `WebsocketContext.ts:508`).
|
|
428
428
|
L'action est donc invoquée une fois au handshake (`message` vaut `null`), puis une fois par frame.
|
|
429
429
|
|
|
@@ -438,9 +438,9 @@ que fait le data plane d'administration pour toutes ses lectures (`AdminBroker.m
|
|
|
438
438
|
(`routing-nonregression.test.ts:164`).
|
|
439
439
|
- **Une invocation WS d'une mutation doit dire quelle méthode logique elle vise.** Sur une socket,
|
|
440
440
|
`context.method` vaut toujours `WEBSOCKET` : insuffisant pour distinguer un GET d'un POST sur le même
|
|
441
|
-
chemin. Le pont transporte donc une **méthode logique** (`methodOverride`, `Resolver.ts:
|
|
441
|
+
chemin. Le pont transporte donc une **méthode logique** (`methodOverride`, `Resolver.ts:126`) que la
|
|
442
442
|
route doit déclarer **en plus** du transport — une route `POST` qui n'annonce pas `WEBSOCKET` reste
|
|
443
|
-
**injoignable** par socket (zéro contournement, `Route.ts:
|
|
443
|
+
**injoignable** par socket (zéro contournement, `Route.ts:722`).
|
|
444
444
|
|
|
445
445
|
Le routage par **message** (invoquer un chemin porté par une frame, sans toucher l'URL de la connexion)
|
|
446
446
|
passe par le même `resolve()`, avec un chemin fourni en argument — l'état partagé de la socket n'est
|
|
@@ -451,7 +451,7 @@ jamais muté (`Router.resolve()`, `router.ts:230`). Détails côté socket :
|
|
|
451
451
|
|
|
452
452
|
`@Domain` restreint une méthode (ou tout un contrôleur) à un ou plusieurs noms d'hôte. Les motifs
|
|
453
453
|
acceptent l'exact (`"marseille.fr"`) et le joker d'un label (`"*.cdn.example.com"`), compilés une fois
|
|
454
|
-
au boot en expressions ancrées (`Route.compileHost()`, `Route.ts:
|
|
454
|
+
au boot en expressions ancrées (`Route.compileHost()`, `Route.ts:627`).
|
|
455
455
|
|
|
456
456
|
```ts ignore
|
|
457
457
|
@controller("/")
|
|
@@ -463,14 +463,14 @@ class MarseilleController extends Controller {
|
|
|
463
463
|
```
|
|
464
464
|
|
|
465
465
|
Précédence, du plus fort au plus faible : `@route({ host })` › `@Domain` sur la méthode › `@Domain` sur
|
|
466
|
-
la classe (`controller()`, `routerDecorators.ts:
|
|
466
|
+
la classe (`controller()`, `routerDecorators.ts:189`). Une route sans domaine est servie sur **tous** les
|
|
467
467
|
vhosts, et ne coûte rien au matching (`hostRegexp` absent → aucun test, `Route.matchHostname()`,
|
|
468
|
-
`Route.ts:
|
|
468
|
+
`Route.ts:641`).
|
|
469
469
|
|
|
470
470
|
> [!WARNING]
|
|
471
471
|
> `@Domain` déclare quels vhosts une route **sert** ; il ne remplace pas la barrière d'entrée. Un
|
|
472
472
|
> `Host` inconnu du serveur est rejeté en amont (421 Misdirected Request, `checkValidDomain()`,
|
|
473
|
-
> `http-kernel.ts:
|
|
473
|
+
> `http-kernel.ts:1749`) via la liste `trustedHosts` de `@nodefony/http`.
|
|
474
474
|
|
|
475
475
|
## Préfixes — contrôleur, module, data plane
|
|
476
476
|
|
|
@@ -478,14 +478,14 @@ Trois niveaux de préfixe coexistent, et un seul est à ta main.
|
|
|
478
478
|
|
|
479
479
|
1. **Le préfixe de contrôleur** — `@controller("/api/catalog")` est concaténé devant le chemin de
|
|
480
480
|
chaque route de la classe, puis le chemin est normalisé : les `//` sont réduits et le slash final
|
|
481
|
-
retiré (`Route.setPattern()`, `Route.ts:
|
|
481
|
+
retiré (`Route.setPattern()`, `Route.ts:602`). Un chemin vide (`@Get("")`) désigne donc le préfixe
|
|
482
482
|
lui-même.
|
|
483
483
|
2. **Le module propriétaire** — il n'ajoute **aucun** préfixe d'URL. `@controllers([…])` enregistre la
|
|
484
484
|
classe au boot et propage le nom du module sur les routes déjà créées, pour l'introspection et les
|
|
485
|
-
logs (`Router.setController()`, `router.ts:
|
|
485
|
+
logs (`Router.setController()`, `router.ts:421`). Un module tiers et ton app peuvent porter deux
|
|
486
486
|
contrôleurs homonymes sans collision : la clé du registre est `module:Classe` (`router.ts:164`).
|
|
487
487
|
3. **Le data plane d'administration** — réservé, non négociable : `/nodefony/<namespace>/api/<endpoint>`
|
|
488
|
-
(`AdminBroker.resolvePath()`, `AdminBroker.ts:
|
|
488
|
+
(`AdminBroker.resolvePath()`, `AdminBroker.ts:99`). Trois segments minimum, pour ne jamais entrer en
|
|
489
489
|
collision avec les routes de l'application ni avec la SPA de Studio.
|
|
490
490
|
|
|
491
491
|
> [!TIP]
|
|
@@ -502,10 +502,10 @@ d'une route — il survit à un changement de chemin.
|
|
|
502
502
|
| Besoin | Comment |
|
|
503
503
|
| ---------------------------------------- | ------------------------------------------------------------------------- |
|
|
504
504
|
| Retrouver une route par son nom | `router.getRoutes("ma-route")` → l'objet `Route` (`router.ts:326`) |
|
|
505
|
-
| Lister toutes les routes | `router.getRoutes("")` → la table complète (`router.ts:
|
|
506
|
-
| Savoir quelles routes couvrent un chemin | `router.matchRoutes("/api/x")` → les résultats de regex (`router.ts:
|
|
507
|
-
| Appeler une autre action, en interne | `this.forward("module:Controller:action")` (`Controller.ts:
|
|
508
|
-
| Retirer une route | `router.removeRoutes("ma-route")` (`router.ts:
|
|
505
|
+
| Lister toutes les routes | `router.getRoutes("")` → la table complète (`router.ts:391`) |
|
|
506
|
+
| Savoir quelles routes couvrent un chemin | `router.matchRoutes("/api/x")` → les résultats de regex (`router.ts:380`) |
|
|
507
|
+
| Appeler une autre action, en interne | `this.forward("module:Controller:action")` (`Controller.ts:534`) |
|
|
508
|
+
| Retirer une route | `router.removeRoutes("ma-route")` (`router.ts:400`) |
|
|
509
509
|
|
|
510
510
|
**Il n'existe pas de générateur d'URL inverse côté serveur** (pas de `path("ma-route", {id})` à la
|
|
511
511
|
Symfony). Le chemin déclaré est lisible sur l'objet `Route` (`route.path`), et la substitution des
|
|
@@ -515,7 +515,7 @@ chemin ; pour un appel interne, utilise `forward()`.
|
|
|
515
515
|
|
|
516
516
|
**`forward()` n'est pas une redirection** : il résout `module:Controller:action` et exécute l'action
|
|
517
517
|
dans le **même** contexte de requête, sans repasser par le réseau (`Resolver.parsePathernController()`,
|
|
518
|
-
`Resolver.ts:
|
|
518
|
+
`Resolver.ts:203`). Une vraie redirection HTTP passe par `this.redirect(url, 302)` ou `@Redirect`.
|
|
519
519
|
|
|
520
520
|
## 🧰 API publique
|
|
521
521
|
|
|
@@ -529,7 +529,7 @@ modules qui montent des routes dynamiquement). Signatures complètes : `.ai/symb
|
|
|
529
529
|
| `router.resolve(context)` | Le cœur : rend un `Resolver` (`resolve === true` si trouvé). |
|
|
530
530
|
| `router.getRoutes(nom)` · `removeRoutes()` | Introspection et démontage. |
|
|
531
531
|
| `Route#path` · `#variables` · `#pattern` | Ce que la route déclare, après compilation. |
|
|
532
|
-
| `Route#toObject()` · `#toLogLine()` | Sérialisation pour l'API admin · ligne de log lisible (`Route.ts:
|
|
532
|
+
| `Route#toObject()` · `#toLogLine()` | Sérialisation pour l'API admin · ligne de log lisible (`Route.ts:554`). |
|
|
533
533
|
| `Resolver#route` · `#variables` | Ce que la requête courante a matché. |
|
|
534
534
|
| `Resolver#getMatchedParams()` | Les variables en `nom → valeur` (`Resolver.ts:170`). |
|
|
535
535
|
|
|
@@ -546,31 +546,31 @@ alloué par requête.
|
|
|
546
546
|
|
|
547
547
|
- **Compilation unique au montage** : motif d'URL, motifs de domaine, `Set` de méthodes en majuscules,
|
|
548
548
|
chaîne `Allow`, et regex des requirements par variable sont figés à la création de la route
|
|
549
|
-
(`Route.compileRequirements()`, `Route.ts:
|
|
549
|
+
(`Route.compileRequirements()`, `Route.ts:476`). Le matching ne fait plus que des lookups.
|
|
550
550
|
- **Un seul calcul de chemin par requête** : le `pathname` normalisé est calculé une fois puis passé à
|
|
551
551
|
chaque route scannée — sinon le getter `URL.pathname`, la regex de normalisation et l'allocation de
|
|
552
|
-
chaîne seraient refaits pour **chaque** route de la table (`Route.cleanPathname()`, `Route.ts:
|
|
552
|
+
chaîne seraient refaits pour **chaque** route de la table (`Route.cleanPathname()`, `Route.ts:303`).
|
|
553
553
|
- **Lookup O(1) pour les chemins littéraux**, scan pour les seuls chemins à motif — sans changer la
|
|
554
554
|
séquence de candidats (`buildRouteIndex()`, `router.ts:130`). L'index est invalidé par toute mutation
|
|
555
555
|
de la table, avec un garde-fou sur une photo `longueur/première/dernière` qui rattrape même les
|
|
556
|
-
mutations directes de la liste (`routeIndex`, `router.ts:
|
|
556
|
+
mutations directes de la liste (`routeIndex`, `router.ts:127`).
|
|
557
557
|
- **Zéro journalisation en production** : le log « route trouvée » est promu au niveau NOTICE hors
|
|
558
558
|
production seulement, et le test est résolu une fois puis mémoïsé — en production, aucune chaîne
|
|
559
559
|
n'est même construite (`routeNoticePromoted`, `router.ts:299`).
|
|
560
560
|
- **Métadonnées d'action mémoïsées par route** au premier passage (`@HttpCode`, `@Header`, `@Redirect`,
|
|
561
561
|
paramètres, intention de session) : plus aucune lecture `Reflect` par requête
|
|
562
|
-
(`resolveActionMeta`, `Resolver.ts:
|
|
562
|
+
(`resolveActionMeta`, `Resolver.ts:469`).
|
|
563
563
|
|
|
564
564
|
## 📜 Normes appliquées
|
|
565
565
|
|
|
566
566
|
| Sujet | Norme | Où le code s'y conforme |
|
|
567
567
|
| ---------------------------------------- | ----------------- | --------------------------------------------------------------- |
|
|
568
568
|
| 405 + en-tête `Allow` agrégé | RFC 9110 §15.5.6 | passe 2 (`collectSupportedMethods()`, `router.ts:31`) |
|
|
569
|
-
| Cible identifiée par l'URI, hôte compris | RFC 9110 §7.2 | hôte vérifié avant la méthode (`Route.match()`, `Route.ts:
|
|
570
|
-
| 403 sur ressource d'un autre vhost | RFC 9110 §15.5.4 | `Route.matchHostname()` (`Route.ts:
|
|
569
|
+
| Cible identifiée par l'URI, hôte compris | RFC 9110 §7.2 | hôte vérifié avant la méthode (`Route.match()`, `Route.ts:327`) |
|
|
570
|
+
| 403 sur ressource d'un autre vhost | RFC 9110 §15.5.4 | `Route.matchHostname()` (`Route.ts:641`) |
|
|
571
571
|
| 404 quand rien ne correspond | RFC 9110 §15.5.5 | après repli statique (`http-kernel.ts:688`) |
|
|
572
|
-
| 421 sur `Host` non servi | RFC 9110 §15.5.20 | `checkValidDomain()` (`http-kernel.ts:
|
|
573
|
-
| Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts:
|
|
572
|
+
| 421 sur `Host` non servi | RFC 9110 §15.5.20 | `checkValidDomain()` (`http-kernel.ts:1749`) |
|
|
573
|
+
| Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts:700`) |
|
|
574
574
|
| Décodage pourcent des segments | RFC 3986 §2.1 | `decode()` (`Route.ts:79`) |
|
|
575
575
|
|
|
576
576
|
## 📡 Observabilité — Studio
|
|
@@ -581,14 +581,14 @@ La table de routes est introspectable en ligne, sans lire le code :
|
|
|
581
581
|
méthodes, contrôleur (`FrameworkAdminApi.ts:123`). Variante paginée/triée/filtrée côté serveur :
|
|
582
582
|
`routes/page`.
|
|
583
583
|
- **`GET /nodefony/framework/api/info`** — résumé : nombre de routes, méthodes servies, modules
|
|
584
|
-
propriétaires (`FrameworkAdminApi.ts:
|
|
584
|
+
propriétaires (`FrameworkAdminApi.ts:335`).
|
|
585
585
|
- **Écran Routes** de Studio (`/nodefony/routes`) — la même table, filtrable.
|
|
586
586
|
- **Playground** (`/nodefony/playground`, développement uniquement) — un formulaire par action, généré depuis la table :
|
|
587
587
|
transports (dont le duplex), paramètres décorés, gardes de sécurité. Il **exécute** de vraies actions,
|
|
588
588
|
donc il n'est monté qu'en développement (`PlaygroundAdminApi.ts`).
|
|
589
589
|
|
|
590
590
|
Au boot, avec le debug actif, chaque route est aussi journalisée en une ligne
|
|
591
|
-
`[MÉTHODES] chemin → @module/Controller.action` (`Route.toLogLine()`, `Route.ts:
|
|
591
|
+
`[MÉTHODES] chemin → @module/Controller.action` (`Route.toLogLine()`, `Route.ts:554`).
|
|
592
592
|
|
|
593
593
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
594
594
|
|
package/docs/templates.md
CHANGED
|
@@ -104,7 +104,7 @@ Le branchement est **délibérément minimal** :
|
|
|
104
104
|
|
|
105
105
|
> [!NOTE]
|
|
106
106
|
> Le rendu **d'erreurs** ne passe **pas** par les templates : une exception devient un corps **JSON
|
|
107
|
-
> structuré** (`ErrorRenderer.renderHttp()`, `error-renderer.ts:
|
|
107
|
+
> structuré** (`ErrorRenderer.renderHttp()`, `error-renderer.ts:473`), jamais une page Eta. Le moteur
|
|
108
108
|
> de vues ne sert que le HTML **que tu rends explicitement**.
|
|
109
109
|
|
|
110
110
|
## 🚀 Démarrage rapide
|
|
@@ -222,11 +222,11 @@ sequenceDiagram
|
|
|
222
222
|
| 2 | Ouverture de la phase mesurée `render` | `phaseStart("render")` (`Controller.ts:377`) |
|
|
223
223
|
| 3 | Injection des aides frontend dans les locals | `withFrontendLocals()` (`Controller.ts:448`) |
|
|
224
224
|
| 4 | Rendu de la source par le moteur | `Eta.render()` → `renderStringAsync` (`Eta.ts:51`) |
|
|
225
|
-
| 5 | `Content-Type: text/html` puis envoi | `setContextHtml()` + `renderResponse()` (`Controller.ts:
|
|
225
|
+
| 5 | `Content-Type: text/html` puis envoi | `setContextHtml()` + `renderResponse()` (`Controller.ts:392`) |
|
|
226
226
|
|
|
227
227
|
Le point notable de l'étape 3 : `withFrontendLocals()` ajoute automatiquement `frontendTags`,
|
|
228
228
|
`frontendDocument` et `asset` aux locals **si** le service `frontend` est présent — et **tes** valeurs
|
|
229
|
-
priment (spread `param` en dernier, `Controller.ts:
|
|
229
|
+
priment (spread `param` en dernier, `Controller.ts:412`). Si le module frontend n'est pas chargé, la
|
|
230
230
|
fonction retourne les locals inchangés : zéro couplage dur.
|
|
231
231
|
|
|
232
232
|
## 🔐 Sécurité — échappement HTML et XSS
|
|
@@ -270,7 +270,7 @@ scaffold). Les deux sont **asynchrones** (I/O non bloquante).
|
|
|
270
270
|
| ----------------------------------- | ------------------------------------------------------- | ------------------- |
|
|
271
271
|
| `renderView(path, locals, status?)` | Rendre une vue `.eta` (lit le fichier + aides frontend) | `Controller.ts:308` |
|
|
272
272
|
| `render(data, encoding?, status?)` | Envoyer un corps quelconque (ex. HTML déjà prêt) | `Controller.ts:377` |
|
|
273
|
-
| `renderJson(obj, status?)` | Réponse JSON explicite (pas un template) | `Controller.ts:
|
|
273
|
+
| `renderJson(obj, status?)` | Réponse JSON explicite (pas un template) | `Controller.ts:481` |
|
|
274
274
|
|
|
275
275
|
Les signatures exactes vivent dans le graphe symbolique `.ai/symbols.json` — jamais recopiées ici.
|
|
276
276
|
|
|
@@ -285,7 +285,7 @@ Le moteur est configuré **en dur**, pas via un bloc Zod exposé à `use()`. Tro
|
|
|
285
285
|
| `cache` | `true` en prod, `false` sinon | compile-once des templates en production ; recompile à chaud en dev | `Template.ts:20` |
|
|
286
286
|
|
|
287
287
|
Le cache n'est **pas** un booléen figé : `Template` le dérive de l'environnement du kernel
|
|
288
|
-
(`environment === "prod"`, `Template.ts:20`) puis `Eta` l'applique au moteur (`Eta.ts:
|
|
288
|
+
(`environment === "prod"`, `Template.ts:20`) puis `Eta` l'applique au moteur (`Eta.ts:34`). En
|
|
289
289
|
développement, un template modifié est donc pris en compte sans redémarrer.
|
|
290
290
|
|
|
291
291
|
## 🔌 HTTP et WebSocket — le même rendu
|
|
@@ -309,7 +309,7 @@ c'est le différenciateur Nodefony : une classe, deux transports, le même moteu
|
|
|
309
309
|
| Domaine | Norme / référence | Comment le code s'y conforme |
|
|
310
310
|
| ------------------ | ----------------------- | --------------------------------------------------------------------- |
|
|
311
311
|
| Neutralisation XSS | OWASP — Output Encoding | échappement HTML par défaut (`autoEscape`, `Eta.ts:16`) |
|
|
312
|
-
| Type de média HTML | `text/html` | posé par `setContextHtml()` dans `renderView()` (`Controller.ts:
|
|
312
|
+
| Type de média HTML | `text/html` | posé par `setContextHtml()` dans `renderView()` (`Controller.ts:410`) |
|
|
313
313
|
| I/O non bloquante | Node.js async fs | lecture async du fichier (`readFile`, `Eta.ts:71`) |
|
|
314
314
|
|
|
315
315
|
## ⚡ Performance et mémoire
|
|
@@ -323,7 +323,7 @@ template), et le framework l'isole pour ça :
|
|
|
323
323
|
- **Cache en prod** : les templates sont compilés une fois (`cache` vrai en production,
|
|
324
324
|
`Template.ts:20`) ; le coût de parsing n'est payé qu'au premier rendu.
|
|
325
325
|
- **Lecture non bloquante** : le fichier est lu en async (`FileClass.readAsync()` côté `renderView`,
|
|
326
|
-
`readFile` côté `renderFile`, `Eta.ts:
|
|
326
|
+
`readFile` côté `renderFile`, `Eta.ts:66`) — l'event loop n'est jamais gelé par un `readFileSync`.
|
|
327
327
|
- **Aides frontend paresseuses** : `withFrontendLocals()` (`Controller.ts:448`) ne construit les
|
|
328
328
|
fonctions `frontendTags`/`asset` que si le service `frontend` répond — sinon il rend les locals tels
|
|
329
329
|
quels, zéro allocation superflue.
|