@nodefony/framework 10.0.0-beta.4 → 10.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js +7 -0
- package/dist/index.js +14 -10
- package/dist/nodefony/config/config.js +16 -2
- package/dist/nodefony/controller/AdminApiController.js +19 -6
- package/dist/nodefony/controller/BenchController.js +1 -1
- package/dist/nodefony/controller/CspReportController.js +70 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +1 -1
- package/dist/nodefony/controller/LoginPageController.js +5 -7
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -1
- package/dist/nodefony/controller/SessionAuthController.js +6 -7
- package/dist/nodefony/controller/TokenAuthController.js +3 -3
- package/dist/nodefony/decorators/routerDecorators.js +2 -2
- package/dist/nodefony/service/AdminBroker.js +1 -1
- package/dist/nodefony/service/IdempotencyStore.js +1 -1
- package/dist/nodefony/service/router.js +5 -4
- package/dist/nodefony/src/Controller.js +321 -114
- package/dist/nodefony/src/PlaygroundAdminApi.js +1 -1
- package/dist/nodefony/src/Resolver.js +7 -10
- package/dist/nodefony/src/ResourceController.js +11 -3
- package/dist/nodefony/src/SecurityApiController.js +30 -7
- package/dist/nodefony/src/conditionalRequest.js +103 -0
- package/dist/nodefony/src/docsReader.js +1 -1
- package/dist/nodefony/src/idempotency.js +1 -1
- package/dist/nodefony/src/loginPage.js +2 -1
- package/dist/nodefony/src/scopeCatalog.js +1 -1
- package/dist/nodefony/src/syslogFilters.js +6 -0
- package/dist/nodefony/src/viewNonce.js +58 -0
- package/dist/types/index.d.ts +3 -2
- package/dist/types/nodefony/config/config.d.ts +20 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +5 -3
- package/dist/types/nodefony/controller/CspReportController.d.ts +35 -0
- package/dist/types/nodefony/controller/LoginPageController.d.ts +3 -2
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +5 -6
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +3 -3
- package/dist/types/nodefony/interfaces/IController.d.ts +2 -2
- package/dist/types/nodefony/service/router.d.ts +3 -2
- package/dist/types/nodefony/src/Controller.d.ts +119 -13
- package/dist/types/nodefony/src/Resolver.d.ts +9 -1
- package/dist/types/nodefony/src/ResourceController.d.ts +11 -3
- package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -4
- package/dist/types/nodefony/src/conditionalRequest.d.ts +82 -0
- package/dist/types/nodefony/src/loginPage.d.ts +1 -1
- package/dist/types/nodefony/src/syslogFilters.d.ts +6 -0
- package/dist/types/nodefony/src/viewNonce.d.ts +33 -0
- package/docs/admin.md +51 -2
- package/docs/controller.md +146 -27
- package/docs/decorateurs.md +8 -2
- package/docs/idempotence.md +22 -4
- package/docs/index.md +4 -2
- package/docs/routing.md +15 -1
- package/docs/templates.md +15 -0
- package/package.json +10 -9
package/docs/controller.md
CHANGED
|
@@ -306,7 +306,7 @@ class ChatController extends Controller {
|
|
|
306
306
|
| Nombre d'appels d'action | 1 | 1 au handshake (`WebsocketContext.handle()`, `WebsocketContext.ts#WebsocketContext.handle`) + 1 par frame (`handleMessage()`, `WebsocketContext.ts#WebsocketContext.handleMessage`) |
|
|
307
307
|
| Argument de l'action | Variables de route (ou paramètres décorés) | Idem + **le message** en dernier argument (`WebsocketContext.ts#WebsocketContext.handleMessage`) |
|
|
308
308
|
| Rendu d'un `return` | Corps de la réponse | Frame envoyée sur la socket |
|
|
309
|
-
| Échec | Statut HTTP + corps d'erreur | **
|
|
309
|
+
| Échec | Statut HTTP + corps d'erreur | **Au handshake** : une garde refusée répond par un **statut HTTP** (401/403) **avant l'ouverture** (`Resolver.authorizeAction()`, `Resolver.ts#Resolver.authorizeAction`), chemin sans route WebSocket → **404** ; **après l'ouverture** : un **code de fermeture** RFC 6455 (401/403 → 1008, 5xx → 1011, autre → 4004) |
|
|
310
310
|
| `initialize()` | Singleton : **une fois**, à la création · `@Scope("request")` : à chaque requête | Singleton : une fois, à la création · `@Scope("request")` : **une fois par connexion**, au handshake |
|
|
311
311
|
|
|
312
312
|
La réutilisation de l'instance vient du cache posé sur le container du contexte
|
|
@@ -415,14 +415,14 @@ de ce que tu as retourné :
|
|
|
415
415
|
| Un `number` / un `boolean` | Auto-JSON scalaire (RFC 8259 §2 : `42`, `true` sont des documents valides) | `Resolver.ts#Resolver._callWithIdempotency` |
|
|
416
416
|
| Un `Buffer` | Envoyé brut | `Resolver.ts#Resolver.returnController` |
|
|
417
417
|
| Une `Response` (via un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts#Resolver._invokeAction` |
|
|
418
|
-
| `void`/`null` **et** statut 204/205/304 | Réponse **vide envoyée** (RFC 9110 : ces statuts n'ont pas de corps) | `
|
|
418
|
+
| `void`/`null` **et** statut 204/205/304 | Réponse **vide envoyée** (RFC 9110 : ces statuts n'ont pas de corps) | `isContentlessStatus` (`Response.ts#isContentlessStatus`, `@nodefony/http`) |
|
|
419
419
|
| `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts#Resolver.returnController` |
|
|
420
420
|
| Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts#Resolver.returnController` |
|
|
421
421
|
|
|
422
422
|
> [!WARNING]
|
|
423
423
|
> **Le piège n° 1 : `return null` sur un statut à corps.** Le framework l'interprète comme « je
|
|
424
424
|
> répondrai moi-même » et attend — jusqu'au timeout. La distinction se fait sur le **statut** :
|
|
425
|
-
> `
|
|
425
|
+
> `isContentlessStatus` (`Response.ts#isContentlessStatus`, liste unique de `@nodefony/http`) rend vrai pour 204, 205 et 304. Donc un `@Delete` qui fait
|
|
426
426
|
> `@HttpCode(204)` puis `return null` répond bien 204 vide ; le même `return null` sans `@HttpCode`
|
|
427
427
|
> laisse la requête pendue.
|
|
428
428
|
|
|
@@ -463,8 +463,10 @@ Deux besoins distincts, deux helpers.
|
|
|
463
463
|
### Téléchargement — `renderFileDownload()`
|
|
464
464
|
|
|
465
465
|
`renderFileDownload(file, options?, headers?)` (`Controller.ts#Controller.renderFileDownload`) pose
|
|
466
|
-
`Content-Disposition: attachment`, `Content-Length`, le type MIME du fichier
|
|
467
|
-
de flux.
|
|
466
|
+
`Content-Disposition: attachment`, `Content-Length`, le type MIME du fichier et un `Expires` déjà
|
|
467
|
+
passé, puis délègue au moteur de flux. Un nom de fichier accentué ou porteur de `"` part en
|
|
468
|
+
`filename*` UTF-8, précédé d'un repli ASCII (RFC 6266 §4.3, RFC 8187) — un nom quelconque ne fait
|
|
469
|
+
jamais lever la réponse. Le fichier est résolu **sans bloquer l'event loop** (`getFileAsync()`, `Controller.ts#Controller.getFileAsync`) ;
|
|
468
470
|
la variante synchrone `getFile()` existe encore mais est marquée obsolète — elle appelle `lstatSync`
|
|
469
471
|
et gèle le process le temps du stat.
|
|
470
472
|
|
|
@@ -473,25 +475,136 @@ et gèle le process le temps du stat.
|
|
|
473
475
|
`renderMediaStream(file, headers?, options?)` (`Controller.ts#Controller.renderMediaStream`) implémente les **requêtes par
|
|
474
476
|
plage** (RFC 9110 §14), ce qui permet à un lecteur vidéo de sauter dans le flux :
|
|
475
477
|
|
|
476
|
-
| Le client envoie…
|
|
477
|
-
|
|
|
478
|
-
| Pas de `Range`
|
|
479
|
-
| `Range: bytes=0-499`
|
|
480
|
-
| `Range: bytes=-500` (suffixe)
|
|
481
|
-
| Plage hors fichier
|
|
482
|
-
| Syntaxe invalide, multi-plage, unité inconnue
|
|
478
|
+
| Le client envoie… | Réponse |
|
|
479
|
+
| ------------------------------------------------ | --------------------------------------------------------------- |
|
|
480
|
+
| Pas de `Range` | 200 + fichier complet, `Accept-Ranges: bytes` |
|
|
481
|
+
| `Range: bytes=0-499` | **206** + `Content-Range`, bornes clampées à la taille réelle |
|
|
482
|
+
| `Range: bytes=-500` (suffixe) | 206 sur les 500 derniers octets |
|
|
483
|
+
| Plage hors fichier | **416** + `Content-Range: bytes */<taille>` (RFC 9110 §15.5.17) |
|
|
484
|
+
| Syntaxe invalide, multi-plage, unité inconnue | En-tête **ignoré** → 200 complet (jamais un 500) |
|
|
485
|
+
| `HEAD` avec `Range` | `Range` **ignoré** : en-têtes du GET complet (RFC 9110 §14.2) |
|
|
486
|
+
| `If-Range` égal à l'`ETag` ou au `Last-Modified` | 206 : la reprise est sûre, le fichier n'a pas changé |
|
|
487
|
+
| `If-Range` différent, ou étiquette faible | `Range` **ignoré** → 200 complet (RFC 9110 §13.1.5) |
|
|
488
|
+
|
|
489
|
+
Une condition `If-Range` fausse sert le fichier entier — plutôt qu'une plage d'un fichier qui a
|
|
490
|
+
changé, que le client recollerait à l'ancienne. Seuls un `ETag` FORTEMENT égal, ou une date égale
|
|
491
|
+
à un `Last-Modified` vieux d'au moins une seconde, autorisent la reprise.
|
|
483
492
|
|
|
484
493
|
La logique est isolée dans une fonction pure exportée, `parseByteRange()` (`Controller.ts#parseByteRange`) —
|
|
485
494
|
donc testable sans serveur.
|
|
486
495
|
|
|
487
496
|
### Ce que `streamFile()` garantit
|
|
488
497
|
|
|
489
|
-
`streamFile()` (`Controller.ts#Controller.streamFile`) est le moteur commun
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
498
|
+
`streamFile()` (`Controller.ts#Controller.streamFile`) est le moteur commun, et il repose sur le
|
|
499
|
+
même cycle de vie que `renderStream()` ci-dessous (`HttpContext.openStream`) :
|
|
500
|
+
|
|
501
|
+
- **la réponse est prise à l'appel**, les en-têtes partent au premier morceau ;
|
|
502
|
+
- **un client qui raccroche détruit le flux**, donc ferme le descripteur : un téléchargement
|
|
503
|
+
interrompu ne retient rien ;
|
|
504
|
+
- **un fichier illisible à l'ouverture** (`EACCES`) rend la page d'erreur ordinaire, 500 ; une
|
|
505
|
+
lecture qui échoue en route coupe la connexion, jamais une fin propre ;
|
|
506
|
+
- **la longueur annoncée est tenue** : la lecture est bornée à la taille relevée (un fichier qui
|
|
507
|
+
grossit pendant l'envoi n'envoie pas plus que `Content-Length`), et un extrait `start`/`end`
|
|
508
|
+
annonce sa propre longueur ;
|
|
509
|
+
- **un fichier n'est pas un flux vivant** : ni `X-Accel-Buffering: no`, ni délai d'inactivité.
|
|
510
|
+
|
|
511
|
+
### Requêtes conditionnelles — 304, 412, reprise
|
|
512
|
+
|
|
513
|
+
Les trois helpers posent sur le fichier ENTIER une `ETag` forte et un `Last-Modified`, tirés de sa
|
|
514
|
+
taille et de sa date de modification, sans le lire. Les préconditions sont évaluées dans l'ordre de
|
|
515
|
+
RFC 9110 §13.2.2, AVANT d'ouvrir le fichier (`conditionalRequest.ts#evaluatePreconditions`) :
|
|
516
|
+
|
|
517
|
+
| Le client envoie… | Réponse |
|
|
518
|
+
| ------------------------------------------------------------ | ------------------------------------------- |
|
|
519
|
+
| `If-None-Match` égal (comparaison faible), ou `*` | **304** en GET/HEAD, sans corps ; 412 sinon |
|
|
520
|
+
| `If-Modified-Since` ≥ date du fichier (sans `If-None-Match`) | **304** en GET/HEAD |
|
|
521
|
+
| `If-Match` différent (comparaison forte) | **412** `Precondition Failed` |
|
|
522
|
+
| `If-Unmodified-Since` < date du fichier (sans `If-Match`) | **412** |
|
|
523
|
+
|
|
524
|
+
Le 304 porte ce qu'aurait porté le 200 : `ETag`, `Last-Modified`, `Cache-Control`, `Expires`,
|
|
525
|
+
`Vary` (RFC 9110 §15.4.5). Le helper rend alors `null` : aucun fichier n'a été ouvert.
|
|
526
|
+
|
|
527
|
+
**`Cache-Control: private, no-cache` par défaut.** Sans lui, `Last-Modified` rendrait la réponse
|
|
528
|
+
« heuristiquement cacheable » (RFC 9111 §4.2.2) : un cache partagé pourrait garder le fichier et le
|
|
529
|
+
resservir sans redemander — or un fichier servi par une action est le plus souvent protégé.
|
|
530
|
+
`private` ferme les caches partagés, `no-cache` fait revalider avant chaque réutilisation, ce qui
|
|
531
|
+
ne coûte qu'un 304. Un fichier public fixe le sien :
|
|
532
|
+
|
|
533
|
+
```ts
|
|
534
|
+
return this.renderMediaStream(file, {
|
|
535
|
+
"Cache-Control": "public, max-age=3600",
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Une `ETag` ou un `Last-Modified` passés dans les en-têtes (un hachage du contenu, une version)
|
|
540
|
+
remplacent ceux du fichier ET servent à la comparaison. Un extrait `streamFile(f, h, { start, end })`
|
|
541
|
+
n'est pas la représentation entière : il ne reçoit ni validateur ni évaluation.
|
|
542
|
+
|
|
543
|
+
**Ce que l'application peut changer** :
|
|
544
|
+
|
|
545
|
+
| Besoin | Geste |
|
|
546
|
+
| ----------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
547
|
+
| Un autre `Cache-Control` (fichier public, `no-store`) | le passer dans les en-têtes, ou `@Header("Cache-Control", …)` sur l'action |
|
|
548
|
+
| Sa propre `ETag` (hachage du contenu, version) | la passer dans les en-têtes, ou `@Header("ETag", …)` — elle sert au 304 |
|
|
549
|
+
| Aucun validateur, aucun 304, aucun `Cache-Control` | `{ validators: false }` dans les options |
|
|
550
|
+
| Le défaut de toute l'application | la section `fileServing` de la config du module (ci-dessous) |
|
|
551
|
+
|
|
552
|
+
Un en-tête posé AVANT l'appel (`@Header`, `this.response.setHeader`) est respecté comme s'il était
|
|
553
|
+
passé en argument.
|
|
554
|
+
|
|
555
|
+
**Pour toute l'application**, la section `fileServing` de la config du module fixe le défaut
|
|
556
|
+
(`config.ts#fileServingSchema`) ; un appel le remplace toujours :
|
|
557
|
+
|
|
558
|
+
```ts
|
|
559
|
+
use("@nodefony/framework", {
|
|
560
|
+
fileServing: {
|
|
561
|
+
validators: true, // false : aucun validateur, aucun 304, aucun Cache-Control par défaut
|
|
562
|
+
cacheControl: "private, no-cache", // null : n'en poser aucun
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
`cacheControl` est validé au démarrage : une valeur porteuse d'un retour à la ligne (qui forgerait
|
|
568
|
+
un en-tête de plus) l'interrompt en nommant la clé. `{ validators: true }` dans les options d'un
|
|
569
|
+
appel rétablit les validateurs quand la config les a coupés.
|
|
570
|
+
|
|
571
|
+
⚠️ **La date d'un fichier n'est pas toujours fiable.** Une archive décompressée ou un build
|
|
572
|
+
reproductible donne la même date à tous les fichiers : deux contenus de même taille portent alors
|
|
573
|
+
la même `ETag`, et le client garde l'ancien sur un 304 sans que rien ne le signale. Pour ces
|
|
574
|
+
fichiers, passer une `ETag` tirée du contenu — le 304 reste juste — ou couper les validateurs :
|
|
575
|
+
|
|
576
|
+
```ts
|
|
577
|
+
// le 304 garde son sens : l'étiquette suit le contenu
|
|
578
|
+
return this.renderMediaStream(file, { ETag: `"${hash}"` });
|
|
579
|
+
// ou : aucune requête conditionnelle, le fichier part toujours entier
|
|
580
|
+
return this.renderMediaStream(file, {}, { validators: false });
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### Une source qui n'est pas un fichier — `renderStream()`
|
|
584
|
+
|
|
585
|
+
La sortie d'un processus (ffmpeg, `tar`), le corps d'un `fetch()` relayé, une archive construite à
|
|
586
|
+
la volée : `renderStream(source, options?)` (`Controller.ts#Controller.renderStream`) répond par
|
|
587
|
+
ce flux d'octets, en HTTP/1.1 comme en HTTP/2. La réponse est prise à l'appel, ses en-têtes partent
|
|
588
|
+
au premier morceau, et la source est **détruite** à la fin quelle qu'en soit la raison. La promesse
|
|
589
|
+
ne rejette jamais : elle rend `{ reason, bytes }`.
|
|
590
|
+
|
|
591
|
+
```typescript
|
|
592
|
+
const ff = spawn("ffmpeg", args, {
|
|
593
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
594
|
+
windowsHide: true,
|
|
595
|
+
});
|
|
596
|
+
try {
|
|
597
|
+
await this.renderStream(ff.stdout, {
|
|
598
|
+
headers: { "content-type": "video/mp4" },
|
|
599
|
+
});
|
|
600
|
+
} finally {
|
|
601
|
+
ff.kill("SIGKILL"); // le flux est fini, quelle qu'en soit la raison
|
|
602
|
+
}
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Page complète — options, raisons de fin, erreurs avant et après le premier octet, relais `fetch`,
|
|
606
|
+
pièges : [Flux d'octets](../../http/docs/byte-stream.md). Pour des **événements texte** lus par
|
|
607
|
+
`EventSource`, voir [Flux SSE](../../http/docs/sse.md).
|
|
495
608
|
|
|
496
609
|
## ⚠️ Erreurs — lever, rendre, observer
|
|
497
610
|
|
|
@@ -594,15 +707,21 @@ code du framework applique — et attend de toi — les règles suivantes :
|
|
|
594
707
|
|
|
595
708
|
## 📜 Normes appliquées
|
|
596
709
|
|
|
597
|
-
| Domaine
|
|
598
|
-
|
|
|
599
|
-
| Statuts sans corps (204/205/304)
|
|
600
|
-
| Requêtes par plage
|
|
601
|
-
| Plage insatisfiable → 416
|
|
602
|
-
|
|
|
603
|
-
|
|
|
604
|
-
|
|
|
605
|
-
|
|
|
710
|
+
| Domaine | Norme | Comment le code s'y conforme |
|
|
711
|
+
| --------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------- |
|
|
712
|
+
| Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.3.6/§15.4.5 | `isContentlessStatus` (`Response.ts#isContentlessStatus`) — aucun corps ne part |
|
|
713
|
+
| Requêtes par plage | RFC 9110 §14.1.2, §14.2 | `parseByteRange()` (`Controller.ts#parseByteRange`) |
|
|
714
|
+
| Plage insatisfiable → 416 | RFC 9110 §15.5.17 | `renderResponse()` avec 416 (`Controller.ts#Controller.renderResponse`) |
|
|
715
|
+
| `Range` hors GET, sous `If-Range` | RFC 9110 §14.2, §13.1.5 | `renderMediaStream()` (`Controller.ts#Controller.renderMediaStream`) |
|
|
716
|
+
| Validateurs d'un fichier | RFC 9110 §8.8.2, §8.8.3 | `fileValidators()` (`conditionalRequest.ts#fileValidators`) |
|
|
717
|
+
| Ordre des préconditions → 304/412 | RFC 9110 §13.1, §13.2.2 | `evaluatePreconditions()` (`conditionalRequest.ts#evaluatePreconditions`) |
|
|
718
|
+
| Cache d'un fichier protégé | RFC 9111 §4.2.2, §5.2.2 | `Cache-Control: private, no-cache` par défaut (`Controller.ts#Controller.streamFile`) |
|
|
719
|
+
| Nom de fichier téléchargé | RFC 6266 §4.3, RFC 8187 | `contentDisposition()` (`Controller.ts#contentDisposition`) |
|
|
720
|
+
| Longueur du corps tenue | RFC 9112 §6.3, RFC 9113 §8.1.1 | `streamFile()` (`Controller.ts#Controller.streamFile`) + `ByteStream.ts#ByteStream` |
|
|
721
|
+
| Redirections | RFC 9110 §15.4 | Liste blanche + repli 302 (`Response.ts#HttpResponse.send`) |
|
|
722
|
+
| Média JSON sans `charset` | RFC 8259 §11 | Auto-JSON (`Resolver.ts#Resolver.returnController`), vérifié par le banc `auto-json` |
|
|
723
|
+
| Scalaire JSON de premier niveau | RFC 8259 §2 | `number`/`boolean` rendus (`Resolver.ts#Resolver._callWithIdempotency`) |
|
|
724
|
+
| Codes de fermeture WebSocket | RFC 6455 §7.4 | `renderWebsocket()` (`error-renderer.ts#DefaultErrorRenderer.renderWebsocket`) |
|
|
606
725
|
|
|
607
726
|
## 📡 Observabilité — Studio
|
|
608
727
|
|
package/docs/decorateurs.md
CHANGED
|
@@ -334,7 +334,7 @@ Deux points qu'un dev découvre sinon à ses dépens :
|
|
|
334
334
|
méthodes, ne serait-ce que pour l'en-tête `Allow`.
|
|
335
335
|
|
|
336
336
|
Le second argument accepte les options de route non redondantes — `Omit<RouteOptions, "path" | "method">`
|
|
337
|
-
(`routerDecorators.ts#route`),
|
|
337
|
+
(`routerDecorators.ts#route`), notamment `defaults`, `requirements`, `host`, `bypassFirewall` et `fallback`
|
|
338
338
|
(`RouteOptions`, `Route.ts#RouteOptions`) :
|
|
339
339
|
|
|
340
340
|
```typescript
|
|
@@ -342,6 +342,12 @@ Le second argument accepte les options de route non redondantes — `Omit<RouteO
|
|
|
342
342
|
async index(@Param("page") page: string) { /* … */ }
|
|
343
343
|
```
|
|
344
344
|
|
|
345
|
+
`requirements.scheme` se compare au scheme **effectif** de la requête — celui qu'annonce un proxy de
|
|
346
|
+
confiance compris : hors exigence, la route répond **403**, jamais une redirection
|
|
347
|
+
(`Route.ts#Route.matchRequirements`). Rediriger `http` vers `https` relève de la bordure (HSTS, proxy
|
|
348
|
+
inverse), pas du routage. `fallback: true` fait de la route un **repli**, servi seulement si aucune
|
|
349
|
+
autre ne correspond — voir [le routage](routing.md#-ordre-de-résolution--trois-situations).
|
|
350
|
+
|
|
345
351
|
### Paramètres — ce que l'action reçoit
|
|
346
352
|
|
|
347
353
|
Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts#paramDecoratorFactory`) sauf
|
|
@@ -425,7 +431,7 @@ update(input: RouteInput<{ params: typeof postParams; body: typeof postBody }>)
|
|
|
425
431
|
**Ce que fait le framework, dans l'ordre :**
|
|
426
432
|
|
|
427
433
|
- chaque partie déclarée est un [Standard Schema](https://standardschema.dev) — Zod, Valibot,
|
|
428
|
-
ArkType… ; l'interface est recopiée au cœur (`runtime/
|
|
434
|
+
ArkType… ; l'interface est recopiée au cœur (`runtime/standardSchemaTypes.ts#IStandardSchemaV1`), aucune
|
|
429
435
|
dépendance n'est imposée ;
|
|
430
436
|
- la validation a lieu **après** les gardes (`@IsGranted` : un 403 ne révèle pas le contrat) et
|
|
431
437
|
**avant** l'instanciation du contrôleur : une entrée invalide ne construit rien
|
package/docs/idempotence.md
CHANGED
|
@@ -263,7 +263,7 @@ Deux mécanismes rendent ça possible côté socket :
|
|
|
263
263
|
`resolveIdempotencyKey()` (`idempotency.ts#resolveIdempotencyKey`) donne la **priorité à l'ALS** sur l'en-tête HTTP.
|
|
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
|
-
frame `socket.
|
|
266
|
+
frame `socket.post` créerait un doublon. Le Resolver teste donc
|
|
267
267
|
`isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts#Resolver.action`).
|
|
268
268
|
|
|
269
269
|
Le pont utilise `executeActionGuarded()` (`Resolver.ts#Resolver.executeActionGuarded`) : porte d'idempotence **sans** rendu HTTP
|
|
@@ -619,14 +619,32 @@ registerIdempotencyStore("mon-backend", (ctx) => {
|
|
|
619
619
|
});
|
|
620
620
|
```
|
|
621
621
|
|
|
622
|
-
|
|
622
|
+
Quatre règles héritées du code existant, à respecter sous peine de double-effet :
|
|
623
623
|
|
|
624
624
|
1. **`begin` doit être atomique côté backend.** Un `GET` puis `SET` séparés laissent deux retries
|
|
625
625
|
concurrents obtenir `fresh` — précisément ce que l'idempotence doit empêcher.
|
|
626
626
|
2. **Ne jamais rendre `fresh` hors réservation gagnée.** En cas de doute (course, état illisible),
|
|
627
|
-
rendre `in-flight` : le client réessaiera, c'est sans danger.
|
|
627
|
+
rendre `in-flight` : le client réessaiera, c'est sans danger. La seule exception est la coupure
|
|
628
|
+
du backend, et elle se décide (règle 4).
|
|
628
629
|
3. **`complete` doit préserver l'empreinte** de l'entrée in-flight, sinon un rejeu avec un autre
|
|
629
630
|
payload après complétion ne produit plus de 422.
|
|
631
|
+
4. **Une fabrique qui ne peut pas construire son store LÈVE — elle ne rend jamais un store qui se
|
|
632
|
+
tait.** Module manquant, client du mauvais type, configuration incohérente : tout ce qui est
|
|
633
|
+
certain dès le démarrage se refuse dans la fabrique. Le framework en fait un arrêt de boot en
|
|
634
|
+
production, et un `WARNING` suivi d'un repli sur la mémoire en développement
|
|
635
|
+
([contrat de dégradation](#le-contrat-de-dégradation--fail-loud-jamais-silencieux)). Un store
|
|
636
|
+
construit malgré tout, qui garde chaque appel par `if (!connected) return`, échappe à ce
|
|
637
|
+
contrat : le registre affiche ton backend pendant que plus rien n'est dédoublonné. C'est un
|
|
638
|
+
échec **ouvert** — la garde a disparu, et rien ne le dit.
|
|
639
|
+
|
|
640
|
+
La connexion, elle, ne peut pas être exigée là : la fabrique est appelée à `onKernelBoot`
|
|
641
|
+
(`index.ts#Framework.onKernelBoot`), avant que l'ORM ne soit connecté. Elle se résout donc à
|
|
642
|
+
chaque appel, comme le fait `DrizzleIdempotencyStore.from()`
|
|
643
|
+
(`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.from`), et la conduite pendant une coupure
|
|
644
|
+
est un **choix à écrire**. Les stores livrés rendent `fresh` — exécuter sans dédoublonnage
|
|
645
|
+
plutôt que bloquer — et l'annoncent comme un compromis
|
|
646
|
+
([`redis`](#redis--le-choix-cluster)). Si ton domaine ne tolère aucun double effet, rends
|
|
647
|
+
`in-flight` (le client réessaiera) ou lève : la requête échoue, rien n'est exécuté deux fois.
|
|
630
648
|
|
|
631
649
|
Fonctions du registre : `registerIdempotencyStore()` (`idempotencyStoreRegistry.ts#registerIdempotencyStore`),
|
|
632
650
|
`getIdempotencyStoreFactory()` (`idempotencyStoreRegistry.ts#getIdempotencyStoreFactory`), `listIdempotencyStores()`
|
|
@@ -638,7 +656,7 @@ pour l'affichage Studio, `idempotencyStoreRegistry.ts#listIdempotencyBackends`).
|
|
|
638
656
|
<!-- prettier-ignore -->
|
|
639
657
|
| Sujet | Norme | Ancrage |
|
|
640
658
|
| --- | --- | --- |
|
|
641
|
-
| En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-
|
|
659
|
+
| En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-08` | `evaluateIdempotency()` (`idempotency.ts#evaluateIdempotency`) |
|
|
642
660
|
| Clé réutilisée avec un autre payload → 422 | draft §2.2 / §2.7 | `idempotency.ts#evaluateIdempotency` |
|
|
643
661
|
| Exécution concurrente identique → 409 | draft §2.6 | `idempotency.ts#evaluateIdempotency` |
|
|
644
662
|
| Clé requise absente → 400 | draft §2.7 | `idempotency.ts#evaluateIdempotency` |
|
package/docs/index.md
CHANGED
|
@@ -108,8 +108,10 @@ dans `.ai/symbols.json` et les types générés — jamais recopiées ici, où e
|
|
|
108
108
|
## ⚙️ Configuration
|
|
109
109
|
|
|
110
110
|
Bloc Zod (`nodefony/config/config.ts`), déclaré depuis l'application via
|
|
111
|
-
`use("@nodefony/framework", { … })` : `router`, `adminBroker`,
|
|
112
|
-
réglages du GC — détaillé dans la page [Idempotence](idempotence.md))
|
|
111
|
+
`use("@nodefony/framework", { … })` : `router`, `adminBroker`, `idempotency` (choix du store et
|
|
112
|
+
réglages du GC — détaillé dans la page [Idempotence](idempotence.md)), et `fileServing` (validateurs
|
|
113
|
+
et `Cache-Control` des fichiers servis par une action — détaillé dans la page
|
|
114
|
+
[Contrôleur](controller.md#requêtes-conditionnelles--304-412-reprise)).
|
|
113
115
|
|
|
114
116
|
## 📜 Normes appliquées
|
|
115
117
|
|
package/docs/routing.md
CHANGED
|
@@ -172,7 +172,7 @@ class CatalogController extends Controller {
|
|
|
172
172
|
path: "/files/*",
|
|
173
173
|
requirements: { methods: ["GET"] },
|
|
174
174
|
})
|
|
175
|
-
async
|
|
175
|
+
async download(rest: string) {
|
|
176
176
|
return this.renderJson({ rest });
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -363,6 +363,20 @@ le suit — un `@All("*")` déclaré tôt masque le reste du contrôleur.
|
|
|
363
363
|
> ses propres routes, quel que soit l'ordre d'écriture (`hasMagic`, `routerDecorators.ts#controller`). Ça ne
|
|
364
364
|
> vaut **que** pour `"*"` seul : `/files/*` reste ordonné comme les autres.
|
|
365
365
|
|
|
366
|
+
Pour un repli qui doit céder **entre modules** — la page d'une application monopage, servie sur
|
|
367
|
+
`/app/{page}` ou `/app/*` —, l'option `fallback: true` range la route après toutes les autres, dans
|
|
368
|
+
leur ordre relatif, au moment de construire l'index (`Route.ts#RouteOptions.fallback`) : une route
|
|
369
|
+
littérale ou dynamique de même forme, déclarée par un module chargé après, garde la main. Aucun coût
|
|
370
|
+
par requête. Sans ce drapeau, un repli déclaré tôt capture en silence les routes des modules
|
|
371
|
+
suivants.
|
|
372
|
+
|
|
373
|
+
```ts ignore
|
|
374
|
+
@Get("/app/{page}", { fallback: true })
|
|
375
|
+
renderSpa(): unknown {
|
|
376
|
+
return this.renderApp();
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
366
380
|
### Situation 2 — le même chemin, deux méthodes
|
|
367
381
|
|
|
368
382
|
Deux routes peuvent partager un chemin et se distinguer par la méthode. La passe 1 essaie la première,
|
package/docs/templates.md
CHANGED
|
@@ -249,6 +249,21 @@ inerte `<script>alert(1)</script>`.
|
|
|
249
249
|
> par un utilisateur (pseudo, commentaire, champ de formulaire, paramètre d'URL). Réserve-le à des
|
|
250
250
|
> fragments HTML que ton propre code a construits.
|
|
251
251
|
|
|
252
|
+
**La politique de sécurité du contenu (CSP) est signée pour toi.** La politique par défaut n'exécute
|
|
253
|
+
un `<script>` et n'applique un `<style>` que s'ils portent le nonce de la requête. `renderView` le
|
|
254
|
+
pose seul sur chaque balise ÉCRITE dans le gabarit (`viewNonce.ts#signTemplateTags`) — rien à
|
|
255
|
+
ajouter. Il ne signe jamais :
|
|
256
|
+
|
|
257
|
+
- une balise que les données apportent (`<%~ %>`) : c'est ce qui laisse au nonce sa valeur contre
|
|
258
|
+
l'injection ;
|
|
259
|
+
- un `<script>` dont un attribut vient des données (`src="<%= it.url %>"`) : le signer exécuterait
|
|
260
|
+
l'URL qu'elles apportent. Si tu la tiens pour sûre, écris le nonce toi-même :
|
|
261
|
+
`<script nonce="<%= it.cspNonce %>" src="<%= it.url %>">` ;
|
|
262
|
+
- un gestionnaire en attribut (`onclick="…"`, `onload="…"`) : aucun nonce ne s'y pose, la politique
|
|
263
|
+
le refuse. Attache l'écouteur depuis un `<script>` du gabarit (`addEventListener`).
|
|
264
|
+
|
|
265
|
+
Le détail de la politique et ses réglages : [En-têtes de sécurité](../../security/docs/headers.md).
|
|
266
|
+
|
|
252
267
|
## 🧰 API publique
|
|
253
268
|
|
|
254
269
|
Deux niveaux : ce que le **service Eta** expose, et ce que le **contrôleur** t'offre au-dessus.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodefony/framework",
|
|
3
|
-
"version": "10.0.0-beta.
|
|
3
|
+
"version": "10.0.0-beta.6",
|
|
4
4
|
"description": "Le modèle de programmation Nodefony : routeur, contrôleurs, décorateurs et vues — HTTP et WebSocket dans le même contexte",
|
|
5
5
|
"author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -46,23 +46,24 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"eta": "4.6.0",
|
|
48
48
|
"reflect-metadata": "0.2.2",
|
|
49
|
-
"tslib": "2.
|
|
49
|
+
"tslib": "2.9.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
52
|
+
"@nodefony/http": "^10.0.0-beta.6",
|
|
53
53
|
"@types/chai": "5.2.3",
|
|
54
|
-
"@types/node": "26.6.
|
|
55
|
-
"@vitest/coverage-v8": "5.0
|
|
56
|
-
"nodefony": "^10.0.0-beta.
|
|
54
|
+
"@types/node": "26.6.6",
|
|
55
|
+
"@vitest/coverage-v8": "5.1.0",
|
|
56
|
+
"nodefony": "^10.0.0-beta.6",
|
|
57
57
|
"tsx": "4.23.15",
|
|
58
|
-
"vitest": "5.0
|
|
58
|
+
"vitest": "5.1.0",
|
|
59
|
+
"ws": "8.22.1"
|
|
59
60
|
},
|
|
60
61
|
"license": "Apache-2.0",
|
|
61
62
|
"readmeFilename": "README.md",
|
|
62
63
|
"contributors": [],
|
|
63
64
|
"peerDependencies": {
|
|
64
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
65
|
-
"nodefony": "^10.0.0-beta.
|
|
65
|
+
"@nodefony/http": "^10.0.0-beta.6",
|
|
66
|
+
"nodefony": "^10.0.0-beta.6",
|
|
66
67
|
"zod": "^4.6.7"
|
|
67
68
|
},
|
|
68
69
|
"files": [
|