@nodefony/framework 10.0.0-beta.4 → 10.0.0-beta.5
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/nodefony/controller/AdminApiController.js +1 -1
- package/dist/nodefony/src/Controller.js +10 -0
- package/dist/nodefony/src/Resolver.js +4 -1
- package/dist/nodefony/src/ResourceController.js +11 -3
- package/dist/nodefony/src/syslogFilters.js +6 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +1 -1
- package/dist/types/nodefony/src/Controller.d.ts +9 -1
- package/dist/types/nodefony/src/Resolver.d.ts +8 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +11 -3
- package/dist/types/nodefony/src/syslogFilters.d.ts +6 -0
- package/docs/admin.md +51 -2
- package/docs/controller.md +1 -1
- package/docs/decorateurs.md +7 -1
- package/docs/idempotence.md +20 -2
- package/docs/routing.md +15 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -8,6 +8,21 @@ sert **HTTP et WebSocket dans le même contexte**, nativement.
|
|
|
8
8
|
> (internals concis) · [`docs/`](./docs) (concepts). `@nodefony/http` ne peut PAS importer
|
|
9
9
|
> ce module (dépendance circulaire) — l'accès se fait via `context.resolver`, typé par le contrat `IRouteResolver` (défini par http, implémenté par framework).
|
|
10
10
|
|
|
11
|
+
## ⚡ Le geste — un contrôleur ne s'écrit pas à la main
|
|
12
|
+
|
|
13
|
+
Dans une application, les générateurs posent le contrôleur, l'inscrivent dans `index.ts` et
|
|
14
|
+
écrivent son test. Depuis la racine de l'application :
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# une ressource REST adossée à une table : entité, service CRUD, contrôleur, tests, migration
|
|
18
|
+
npx nodefony create entity Post title:string content:text --route /api/posts
|
|
19
|
+
# un contrôleur seul : hello (GET + écho WebSocket, défaut), rest (CRUD) ou realtime (canal)
|
|
20
|
+
npx nodefony create controller Facture --kind rest --route /api/factures
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`--module <nom>` cible un module de l'application au lieu de sa racine. La suite de ce README
|
|
24
|
+
décrit ce que ces fichiers contiennent — de quoi les lire et les retoucher.
|
|
25
|
+
|
|
11
26
|
## Rôle
|
|
12
27
|
|
|
13
28
|
- **Router** : matching de routes (path + méthode + domaine), `Route.match()`.
|
|
@@ -59,7 +59,7 @@ var AdminApiController = class AdminApiController extends Controller {
|
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
61
|
* Action générique appelée par le Resolver pour toute route admin — DUPLEX
|
|
62
|
-
* (
|
|
62
|
+
* (une action, deux transports) : même exécution sur les deux transports, seul
|
|
63
63
|
* l'emballage diffère.
|
|
64
64
|
*
|
|
65
65
|
* - **HTTP** : rendu historique inchangé (`renderJson` + status + header
|
|
@@ -140,6 +140,16 @@ var Controller = class extends Service {
|
|
|
140
140
|
this.#context = context ?? null;
|
|
141
141
|
}
|
|
142
142
|
/**
|
|
143
|
+
* Journalise au nom du module du contrôleur, pas du noyau : `moduleName`
|
|
144
|
+
* explicite, sinon le module posé sur la requête (`context.logModule`),
|
|
145
|
+
* sinon le module propriétaire du contrôleur.
|
|
146
|
+
*
|
|
147
|
+
* @param moduleName - module qui signe CETTE ligne
|
|
148
|
+
*/
|
|
149
|
+
log(pci, severity, msgid, msg, moduleName) {
|
|
150
|
+
return super.log(pci, severity, msgid, msg, moduleName ?? this.context?.logModule ?? this.module?.name);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
143
153
|
* Route de l'appel en cours. Par requête : posée par le Resolver
|
|
144
154
|
* (`setRoute`). Singleton : celle du résolveur de l'APPEL quand le pont
|
|
145
155
|
* `api.request` en a posé un dans l'ALS (clé `resolver` — un résolveur par
|
|
@@ -668,7 +668,9 @@ var Resolver = class {
|
|
|
668
668
|
return this.context.render(result);
|
|
669
669
|
}
|
|
670
670
|
switch (this.context.type) {
|
|
671
|
-
case "http":
|
|
671
|
+
case "http":
|
|
672
|
+
this.context.waitAsync = true;
|
|
673
|
+
this.unrendered = `une instance de ${(typeof result === "object" && result !== null ? result.constructor.name : void 0) ?? "classe"}`;
|
|
672
674
|
}
|
|
673
675
|
return;
|
|
674
676
|
default: switch (this.context.type) {
|
|
@@ -677,6 +679,7 @@ var Resolver = class {
|
|
|
677
679
|
if (this.context.isRedirect) return this.context.send();
|
|
678
680
|
if (NO_BODY_STATUS.has(this.getResponseStatus())) return this.context.send();
|
|
679
681
|
this.context.waitAsync = true;
|
|
682
|
+
this.unrendered = result === null ? "null" : typeof result;
|
|
680
683
|
}
|
|
681
684
|
}
|
|
682
685
|
}
|
|
@@ -3,7 +3,7 @@ import { assertPageQuery } from "nodefony";
|
|
|
3
3
|
import { HttpError } from "@nodefony/http";
|
|
4
4
|
// nodefony/src/ResourceController.ts
|
|
5
5
|
/**
|
|
6
|
-
* Controller de ressource **
|
|
6
|
+
* Controller de ressource **duplex** (V4.2 — une action, deux transports) :
|
|
7
7
|
* la logique métier est écrite UNE fois dans le service de ressource, les
|
|
8
8
|
* actions de la sous-classe ne sont que des portes (REST, WS-RPC `invoke`,
|
|
9
9
|
* GraphQL à venir) qui retournent la **valeur brute** — `returnController`
|
|
@@ -38,8 +38,12 @@ import { HttpError } from "@nodefony/http";
|
|
|
38
38
|
* return this.listResource();
|
|
39
39
|
* }
|
|
40
40
|
* \@route("books-get", { path: "/{id}", requirements: { methods: ["GET", "WEBSOCKET"] } })
|
|
41
|
-
* detail(\@Param("id") id: string) {
|
|
42
|
-
*
|
|
41
|
+
* async detail(\@Param("id") id: string) {
|
|
42
|
+
* // `null` rendu tel quel = « l'action répond elle-même » : la requête
|
|
43
|
+
* // pendrait jusqu'au délai d'expiration. La porte décide du 404.
|
|
44
|
+
* const found = await this.getResource(id);
|
|
45
|
+
* if (!found) throw new HttpError(`Book ${id} not found`, 404);
|
|
46
|
+
* return found;
|
|
43
47
|
* }
|
|
44
48
|
* }
|
|
45
49
|
* ```
|
|
@@ -117,6 +121,10 @@ var ResourceController = class extends Controller {
|
|
|
117
121
|
/**
|
|
118
122
|
* Lit une entité par id — `null` si absente (la porte décide du 404).
|
|
119
123
|
*
|
|
124
|
+
* ⚠️ Ne pas rendre ce `null` tel quel depuis une action : pour le Resolver,
|
|
125
|
+
* `null` veut dire « l'action répond elle-même », et la requête attend
|
|
126
|
+
* alors son délai d'expiration. Lever un `HttpError` 404.
|
|
127
|
+
*
|
|
120
128
|
* `options.relations` charge les associations dans la foulée. La porte doit
|
|
121
129
|
* n'y laisser passer que des relations qu'elle a DÉCLARÉES : un `include`
|
|
122
130
|
* libre laisse le client nommer n'importe quelle association, donc lire des
|
|
@@ -29,6 +29,8 @@ const SYSLOG_FILTERS = {
|
|
|
29
29
|
module: "string",
|
|
30
30
|
/** Catégorie de message (msgid) — inclusion insensible à la casse. */
|
|
31
31
|
msgid: "string",
|
|
32
|
+
/** Module exclu — égalité insensible à la casse (`NODEFONY` = le framework). */
|
|
33
|
+
excludeModule: "string",
|
|
32
34
|
/** Protocole d'origine ; absent = les deux. */
|
|
33
35
|
protocol: ["ws", "http"],
|
|
34
36
|
/**
|
|
@@ -42,6 +44,10 @@ const SYSLOG_FILTERS = {
|
|
|
42
44
|
* seconde liste ne peut se périmer.
|
|
43
45
|
*/
|
|
44
46
|
flow: { each: Object.keys(FLOW_STEPS) },
|
|
47
|
+
/** Écarte les lignes d'accès de la console d'administration elle-même. */
|
|
48
|
+
hideConsole: "boolean",
|
|
49
|
+
/** Une ligne par requête (sa ligne d'accès), avec le nombre de lignes regroupées. */
|
|
50
|
+
groupByRequest: "boolean",
|
|
45
51
|
/** Borne basse d'horodatage (epoch ms, incluse). */
|
|
46
52
|
from: "int",
|
|
47
53
|
/** Borne haute d'horodatage (epoch ms, incluse). */
|
|
@@ -35,7 +35,7 @@ declare class AdminApiController extends Controller {
|
|
|
35
35
|
constructor(context: ContextType);
|
|
36
36
|
/**
|
|
37
37
|
* Action générique appelée par le Resolver pour toute route admin — DUPLEX
|
|
38
|
-
* (
|
|
38
|
+
* (une action, deux transports) : même exécution sur les deux transports, seul
|
|
39
39
|
* l'emballage diffère.
|
|
40
40
|
*
|
|
41
41
|
* - **HTTP** : rendu historique inchangé (`renderJson` + status + header
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Service, Module, FileClass } from "nodefony";
|
|
1
|
+
import { Service, Module, type Pci, type Severity, type Msgid, type Message, FileClass } from "nodefony";
|
|
2
2
|
import type { IRequestUser, MaybePromise } from "nodefony";
|
|
3
3
|
import type { IController } from "../interfaces/index.js";
|
|
4
4
|
import Route from "./Route.js";
|
|
@@ -83,6 +83,14 @@ declare class Controller extends Service implements IController {
|
|
|
83
83
|
*/
|
|
84
84
|
get context(): ContextType | undefined;
|
|
85
85
|
set context(context: ContextType | undefined);
|
|
86
|
+
/**
|
|
87
|
+
* Journalise au nom du module du contrôleur, pas du noyau : `moduleName`
|
|
88
|
+
* explicite, sinon le module posé sur la requête (`context.logModule`),
|
|
89
|
+
* sinon le module propriétaire du contrôleur.
|
|
90
|
+
*
|
|
91
|
+
* @param moduleName - module qui signe CETTE ligne
|
|
92
|
+
*/
|
|
93
|
+
log(pci: Pci, severity?: Severity, msgid?: Msgid, msg?: Message, moduleName?: string): import("nodefony").Pdu;
|
|
86
94
|
/**
|
|
87
95
|
* Route de l'appel en cours. Par requête : posée par le Resolver
|
|
88
96
|
* (`setRoute`). Singleton : celle du résolveur de l'APPEL quand le pont
|
|
@@ -37,6 +37,14 @@ declare class Resolver implements IResolver {
|
|
|
37
37
|
resolve: boolean;
|
|
38
38
|
variables: unknown[];
|
|
39
39
|
exception?: HttpError | Error | null | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Ce que l'action a rendu quand le Resolver n'a rien pu envoyer — absent
|
|
42
|
+
* sinon. Lu par l'avertissement de développement
|
|
43
|
+
* (`IRouteResolver.unrendered`). `declare` : AUCUN champ émis — un Resolver
|
|
44
|
+
* naît à chaque requête, et la propriété n'apparaît que sur la branche qui
|
|
45
|
+
* attend déjà son délai d'expiration (`micro-resolver-field.mjs`).
|
|
46
|
+
*/
|
|
47
|
+
unrendered?: string;
|
|
40
48
|
acceptedProtocol: string | null;
|
|
41
49
|
bypassFirewall: boolean;
|
|
42
50
|
/**
|
|
@@ -67,7 +67,7 @@ export interface IResourceService<T = unknown> {
|
|
|
67
67
|
delete?(criteria: Record<string, unknown>): Promise<number> | number;
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
* Controller de ressource **
|
|
70
|
+
* Controller de ressource **duplex** (V4.2 — une action, deux transports) :
|
|
71
71
|
* la logique métier est écrite UNE fois dans le service de ressource, les
|
|
72
72
|
* actions de la sous-classe ne sont que des portes (REST, WS-RPC `invoke`,
|
|
73
73
|
* GraphQL à venir) qui retournent la **valeur brute** — `returnController`
|
|
@@ -102,8 +102,12 @@ export interface IResourceService<T = unknown> {
|
|
|
102
102
|
* return this.listResource();
|
|
103
103
|
* }
|
|
104
104
|
* \@route("books-get", { path: "/{id}", requirements: { methods: ["GET", "WEBSOCKET"] } })
|
|
105
|
-
* detail(\@Param("id") id: string) {
|
|
106
|
-
*
|
|
105
|
+
* async detail(\@Param("id") id: string) {
|
|
106
|
+
* // `null` rendu tel quel = « l'action répond elle-même » : la requête
|
|
107
|
+
* // pendrait jusqu'au délai d'expiration. La porte décide du 404.
|
|
108
|
+
* const found = await this.getResource(id);
|
|
109
|
+
* if (!found) throw new HttpError(`Book ${id} not found`, 404);
|
|
110
|
+
* return found;
|
|
107
111
|
* }
|
|
108
112
|
* }
|
|
109
113
|
* ```
|
|
@@ -155,6 +159,10 @@ declare class ResourceController<T = unknown> extends Controller {
|
|
|
155
159
|
/**
|
|
156
160
|
* Lit une entité par id — `null` si absente (la porte décide du 404).
|
|
157
161
|
*
|
|
162
|
+
* ⚠️ Ne pas rendre ce `null` tel quel depuis une action : pour le Resolver,
|
|
163
|
+
* `null` veut dire « l'action répond elle-même », et la requête attend
|
|
164
|
+
* alors son délai d'expiration. Lever un `HttpError` 404.
|
|
165
|
+
*
|
|
158
166
|
* `options.relations` charge les associations dans la foulée. La porte doit
|
|
159
167
|
* n'y laisser passer que des relations qu'elle a DÉCLARÉES : un `include`
|
|
160
168
|
* libre laisse le client nommer n'importe quelle association, donc lire des
|
|
@@ -28,6 +28,8 @@ export declare const SYSLOG_FILTERS: {
|
|
|
28
28
|
readonly module: "string";
|
|
29
29
|
/** Catégorie de message (msgid) — inclusion insensible à la casse. */
|
|
30
30
|
readonly msgid: "string";
|
|
31
|
+
/** Module exclu — égalité insensible à la casse (`NODEFONY` = le framework). */
|
|
32
|
+
readonly excludeModule: "string";
|
|
31
33
|
/** Protocole d'origine ; absent = les deux. */
|
|
32
34
|
readonly protocol: readonly ["ws", "http"];
|
|
33
35
|
/**
|
|
@@ -45,6 +47,10 @@ export declare const SYSLOG_FILTERS: {
|
|
|
45
47
|
readonly flow: {
|
|
46
48
|
readonly each: FlowStepId[];
|
|
47
49
|
};
|
|
50
|
+
/** Écarte les lignes d'accès de la console d'administration elle-même. */
|
|
51
|
+
readonly hideConsole: "boolean";
|
|
52
|
+
/** Une ligne par requête (sa ligne d'accès), avec le nombre de lignes regroupées. */
|
|
53
|
+
readonly groupByRequest: "boolean";
|
|
48
54
|
/** Borne basse d'horodatage (epoch ms, incluse). */
|
|
49
55
|
readonly from: "int";
|
|
50
56
|
/** Borne haute d'horodatage (epoch ms, incluse). */
|
package/docs/admin.md
CHANGED
|
@@ -82,7 +82,7 @@ Trois idées à retenir :
|
|
|
82
82
|
| RBAC | _Role-Based Access Control_ : l'accès dépend des rôles de l'appelant. |
|
|
83
83
|
| `ROLE_NODEFONY_*` | Rôles de la **plateforme** (admin du framework), distincts des rôles applicatifs `ROLE_*` d'un tenant. |
|
|
84
84
|
| Pont / dispatch | Le controller unique qui, à chaque requête, retrouve l'endpoint et exécute son handler. |
|
|
85
|
-
|
|
|
85
|
+
| Deux transports | Toute action admin déclare AUSSI le transport WebSocket → invocable par le pont WS-RPC `api.request`. |
|
|
86
86
|
| Duplex | Une même action servie sur HTTP **et** WebSocket (le différenciateur Nodefony). |
|
|
87
87
|
| Catalogue / discovery | La liste des producteurs + endpoints, exposée en JSON pour que Studio bâtisse sa navigation. |
|
|
88
88
|
| Playground | La console dev qui joue n'importe quel controller depuis le navigateur (`/nodefony/playground`). |
|
|
@@ -327,7 +327,7 @@ laissez-passer.
|
|
|
327
327
|
> applicatifs `ROLE_*` d'un tenant. Un endpoint peut exiger un rôle plus fin (`role: "ROLE_…"`) ou
|
|
328
328
|
> graduer l'information **dans** son handler en lisant `request.roles`.
|
|
329
329
|
|
|
330
|
-
## 🔌 HTTP et WebSocket — la même action
|
|
330
|
+
## 🔌 HTTP et WebSocket — la même action
|
|
331
331
|
|
|
332
332
|
Toute action admin déclare **aussi** le transport `WEBSOCKET` (`AdminBroker.ts#AdminBroker.mountAll`) : la route est
|
|
333
333
|
montée avec `[method, "WEBSOCKET"]`. Elle devient donc invocable par le pont WS-RPC `api.request`
|
|
@@ -362,6 +362,55 @@ Trois pas, du point de vue d'un module :
|
|
|
362
362
|
**Handlers lazy** : résous tes services **dans** le handler (à la requête), jamais au montage — un
|
|
363
363
|
service désactivable renvoie alors `503` proprement au lieu de casser le boot.
|
|
364
364
|
|
|
365
|
+
### Ce que publie un endpoint — l'état réel, aucun secret, ses capacités déclarées
|
|
366
|
+
|
|
367
|
+
Tout ce que renvoie un handler part tel quel vers la console, le pont socket et les agents. Trois
|
|
368
|
+
règles en découlent.
|
|
369
|
+
|
|
370
|
+
**1. Un DTO ne porte aucun secret : il en dit la présence.** Un mot de passe, une clé ou un jeton
|
|
371
|
+
se décrivent par un booléen. Le résumé d'un utilisateur expose `hasPassword`, jamais le hash
|
|
372
|
+
(`UserAdminApi.ts#IUserSummary`). La description du pare-feu expose `synchronizerToken: boolean`
|
|
373
|
+
(`IFirewallDescription.ts#IFirewallDescription`). La redaction se fait **en construisant** le DTO,
|
|
374
|
+
champ par champ. Copier l'objet puis retirer les clés sensibles laisserait passer la prochaine qui
|
|
375
|
+
sera ajoutée. Un test le prouve : `JSON.stringify(dto)` ne contient pas la valeur sentinelle posée
|
|
376
|
+
dans la configuration.
|
|
377
|
+
|
|
378
|
+
**2. Une description montre ce qui TOURNE, pas la configuration relue.** Une méthode `describe()`
|
|
379
|
+
lit les structures que le service a bâties au démarrage
|
|
380
|
+
(`IFirewall.ts#IFirewall.describe`, `IBackplane.ts#IBackplane.describe`). Un authenticator mal
|
|
381
|
+
orthographié dans la configuration n'y figure pas, puisqu'il n'est pas monté. Relire la
|
|
382
|
+
configuration montrerait l'intention, et la console afficherait comme active une défense qui ne
|
|
383
|
+
tourne pas.
|
|
384
|
+
|
|
385
|
+
**3. Une capacité de liste non déclarée est refusée en `400`, jamais ignorée.** Tri, filtres,
|
|
386
|
+
recherche et facettes se déclarent dans `page` (`IAdminApi.ts#IAdminPageCapabilities`), et le
|
|
387
|
+
handler lit **la même donnée** : il passe ses champs triables à `parsePageQuery()`
|
|
388
|
+
(`pageQuery.ts#parsePageQuery`) et son vocabulaire de filtres à `parseFilters()`
|
|
389
|
+
(`pageFilters.ts#parseFilters`). Ces deux lecteurs refusent en `400` un tri hors liste, un `?q=`
|
|
390
|
+
sur un endpoint qui ne cherche pas, une valeur mal formée et un paramètre que personne ne
|
|
391
|
+
reconnaît. Lire `request.query.order` à la main renvoie une page non triée que le client prend pour
|
|
392
|
+
triée. Avec un filtre mal orthographié, c'est la collection entière qui revient.
|
|
393
|
+
|
|
394
|
+
```typescript ignore
|
|
395
|
+
import { parseFilters, parsePageQuery, type IAdminEndpoint } from "nodefony";
|
|
396
|
+
|
|
397
|
+
// Une constante, deux lectures : le catalogue et le handler ne peuvent pas diverger.
|
|
398
|
+
const SORTABLE = ["createdAt", "total"] as const;
|
|
399
|
+
const FILTERS = { status: ["paid", "pending", "refunded"] } as const;
|
|
400
|
+
|
|
401
|
+
const listOrders: IAdminEndpoint = {
|
|
402
|
+
path: "orders",
|
|
403
|
+
method: "GET",
|
|
404
|
+
summary: "Commandes, paginées.",
|
|
405
|
+
page: { sortable: () => SORTABLE, filters: FILTERS },
|
|
406
|
+
handler: async (request) => {
|
|
407
|
+
const query = parsePageQuery(request.query, { sortable: SORTABLE }); // ?order=x → 400
|
|
408
|
+
const filters = parseFilters(request.query, FILTERS); // ?status=zzz, ?stauts= → 400
|
|
409
|
+
return orders.listPage({ ...query, ...filters });
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
```
|
|
413
|
+
|
|
365
414
|
### Les producteurs réels (à imiter, sans les redocumenter)
|
|
366
415
|
|
|
367
416
|
Le broker lui-même est déclaré comme service du framework (`index.ts`). Les producteurs internes
|
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
|
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
|
package/docs/idempotence.md
CHANGED
|
@@ -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()`
|
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/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.5",
|
|
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",
|
|
@@ -49,11 +49,11 @@
|
|
|
49
49
|
"tslib": "2.8.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
52
|
+
"@nodefony/http": "^10.0.0-beta.5",
|
|
53
53
|
"@types/chai": "5.2.3",
|
|
54
54
|
"@types/node": "26.6.5",
|
|
55
55
|
"@vitest/coverage-v8": "5.0.5",
|
|
56
|
-
"nodefony": "^10.0.0-beta.
|
|
56
|
+
"nodefony": "^10.0.0-beta.5",
|
|
57
57
|
"tsx": "4.23.15",
|
|
58
58
|
"vitest": "5.0.5"
|
|
59
59
|
},
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"readmeFilename": "README.md",
|
|
62
62
|
"contributors": [],
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
65
|
-
"nodefony": "^10.0.0-beta.
|
|
64
|
+
"@nodefony/http": "^10.0.0-beta.5",
|
|
65
|
+
"nodefony": "^10.0.0-beta.5",
|
|
66
66
|
"zod": "^4.6.7"
|
|
67
67
|
},
|
|
68
68
|
"files": [
|