@nodefony/framework 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +50 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +211 -0
  6. package/dist/nodefony/config/config.js +61 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  8. package/dist/nodefony/controller/AdminApiController.js +163 -0
  9. package/dist/nodefony/controller/ApiKeyController.js +151 -0
  10. package/dist/nodefony/controller/BenchController.js +132 -0
  11. package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
  12. package/dist/nodefony/controller/OAuth2Controller.js +133 -0
  13. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
  14. package/dist/nodefony/controller/SessionAuthController.js +141 -0
  15. package/dist/nodefony/controller/TokenAuthController.js +113 -0
  16. package/dist/nodefony/controller/TotpController.js +129 -0
  17. package/dist/nodefony/controller/WebAuthnController.js +242 -0
  18. package/dist/nodefony/controller/oauthAuthority.js +74 -0
  19. package/dist/nodefony/decorators/routerDecorators.js +967 -0
  20. package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
  21. package/dist/nodefony/interfaces/IController.js +1 -0
  22. package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
  23. package/dist/nodefony/interfaces/IResolver.js +1 -0
  24. package/dist/nodefony/interfaces/IRoute.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/service/AdminBroker.js +106 -0
  27. package/dist/nodefony/service/Eta.js +68 -0
  28. package/dist/nodefony/service/IdempotencyStore.js +136 -0
  29. package/dist/nodefony/service/router.js +243 -0
  30. package/dist/nodefony/src/Controller.js +515 -0
  31. package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
  32. package/dist/nodefony/src/KernelAdminApi.js +1243 -0
  33. package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
  34. package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
  35. package/dist/nodefony/src/Resolver.js +416 -0
  36. package/dist/nodefony/src/ResourceController.js +148 -0
  37. package/dist/nodefony/src/Route.js +476 -0
  38. package/dist/nodefony/src/SyslogAdminApi.js +466 -0
  39. package/dist/nodefony/src/Template.js +15 -0
  40. package/dist/nodefony/src/configMutation.js +186 -0
  41. package/dist/nodefony/src/docsReader.js +929 -0
  42. package/dist/nodefony/src/idempotency.js +137 -0
  43. package/dist/nodefony/src/idempotencyGc.js +32 -0
  44. package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
  45. package/dist/nodefony/src/scopeCatalog.js +40 -0
  46. package/dist/nodefony/src/syslogFilters.js +51 -0
  47. package/dist/types/index.d.ts +96 -0
  48. package/dist/types/nodefony/config/config.d.ts +41 -0
  49. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  50. package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
  51. package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
  52. package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
  53. package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
  54. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
  55. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
  56. package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
  57. package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
  58. package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
  59. package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
  60. package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
  61. package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
  62. package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
  63. package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
  64. package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
  65. package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
  66. package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
  67. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  68. package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
  69. package/dist/types/nodefony/service/Eta.d.ts +25 -0
  70. package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
  71. package/dist/types/nodefony/service/router.d.ts +53 -0
  72. package/dist/types/nodefony/src/Controller.d.ts +193 -0
  73. package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
  74. package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
  75. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
  76. package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
  77. package/dist/types/nodefony/src/Resolver.d.ts +165 -0
  78. package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
  79. package/dist/types/nodefony/src/Route.d.ts +192 -0
  80. package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
  81. package/dist/types/nodefony/src/Template.d.ts +8 -0
  82. package/dist/types/nodefony/src/configMutation.d.ts +109 -0
  83. package/dist/types/nodefony/src/docsReader.d.ts +369 -0
  84. package/dist/types/nodefony/src/idempotency.d.ts +96 -0
  85. package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
  86. package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
  87. package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
  88. package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
  89. package/docs/admin.md +451 -0
  90. package/docs/controller.md +645 -0
  91. package/docs/decorateurs.md +845 -0
  92. package/docs/idempotence.md +741 -0
  93. package/docs/index.md +151 -0
  94. package/docs/routing.md +648 -0
  95. package/docs/templates.md +380 -0
  96. package/package.json +83 -0
@@ -0,0 +1,416 @@
1
+ import { buildParamArgs, computeActionMeta, resolveActionMeta, resolveSessionIntent } from "../decorators/routerDecorators.js";
2
+ import { computeFingerprint, evaluateIdempotency, isMutationMethod, resolveIdempotencyKey, resolveIdentity } from "./idempotency.js";
3
+ import { RequestContext, isArray, isPlainObject, isPromise, nodefonyError, typeOf } from "nodefony";
4
+ import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
5
+ //#region nodefony/src/Resolver.ts
6
+ /**
7
+ * Résout une route vers son couple controller/action et exécute l'action —
8
+ * UN Resolver est alloué par requête HTTP (et par connexion WS, réutilisé
9
+ * par message). **POJO volontaire** (V3.1) : n'étend PAS `Service` — le
10
+ * plumbing Service (Map de listeners trackés, spread d'options, lookups
11
+ * kernel/syslog) coûtait par requête sans aucun consommateur (jamais écouté,
12
+ * jamais loggé, jamais dans le container). Le DI per-request passe par
13
+ * `context.container` (le cache `"controller"` y survit au Resolver : un
14
+ * forward ou un 2ᵉ Resolver WS sur la MÊME connexion retrouve l'instance).
15
+ */
16
+ var Resolver = class {
17
+ injector;
18
+ controller = null;
19
+ actionName;
20
+ action;
21
+ context;
22
+ route = null;
23
+ resolve = false;
24
+ variables = [];
25
+ exception;
26
+ acceptedProtocol = null;
27
+ bypassFirewall = false;
28
+ /**
29
+ * Query d'une invocation **par message** (pont WS-RPC `api.request`) — pendant
30
+ * de `cleanPathOverride` pour le `?…` du path invoqué. Le contexte WS étant
31
+ * PARTAGÉ par la connexion (sa `queryGet` = celle du handshake), la query
32
+ * per-invocation vit ici (le Resolver est per-invocation → zéro bleed entre
33
+ * requêtes concurrentes d'une même socket). `null` (hot path HTTP) = ignoré.
34
+ * Consommé par `@Query` (`_buildParamArgs`) et copié sur le controller
35
+ * per-request (`executeAction`).
36
+ */
37
+ queryOverride = null;
38
+ /**
39
+ * Méthode HTTP **logique** d'une invocation par le pont WS-RPC `api.request`
40
+ * quand c'est une MUTATION (POST/PUT/PATCH/DELETE). Posée par
41
+ * `Router.resolve(ctx, cleanPath, methodOverride)` → consommée par `match()`
42
+ * pour désambiguïser, sur le transport WEBSOCKET unique, la route logique
43
+ * visée (cf `Route.matchRequirements`). `null` (GET/HTTP) = match historique
44
+ * sur `context.method`.
45
+ */
46
+ methodOverride = null;
47
+ constructor(context) {
48
+ this.context = context;
49
+ this.injector = context.container?.get("injector") ?? null;
50
+ }
51
+ match(route, context, cleanPath) {
52
+ const match = route.match(context, cleanPath, this.methodOverride ?? void 0);
53
+ if (match) {
54
+ this.variables = match;
55
+ this.route = route;
56
+ this.controller = route.controller;
57
+ this.actionName = route.classMethod;
58
+ this.resolve = true;
59
+ this.bypassFirewall = this.route.bypassFirewall;
60
+ if (route.requirements.protocol) this.acceptedProtocol = route.requirements.protocol.toLowerCase();
61
+ const actionMeta = resolveActionMeta(route);
62
+ this.context.sessionIntent = actionMeta.sessionIntent;
63
+ if (actionMeta.cspDirectives !== null) this.context.cspDirectives = actionMeta.cspDirectives;
64
+ if (actionMeta.csrfProtect) this.context.csrfProtect = true;
65
+ if (actionMeta.csrfExempt) this.context.csrfExempt = true;
66
+ }
67
+ return match;
68
+ }
69
+ /**
70
+ * Snapshot per-requête des variables de route matchées (`{name}` → valeur,
71
+ * + wildcard `*` éventuel). Construit depuis les valeurs de CETTE requête
72
+ * (`this.variables`, posées par `match()`) zippées avec les noms de
73
+ * `route.variables`. Remplace l'ancien `Route.variablesMap` qui vivait sur
74
+ * l'instance `Route` partagée (statique) → écrasé par toute requête/connexion
75
+ * concurrente sur la même route (bleed inter-requêtes). Lu par
76
+ * `Context.setMetaData()` pour exposer `msg.nodefony.route.variablesMap`.
77
+ */
78
+ getMatchedParams() {
79
+ const names = this.route?.variables ?? [];
80
+ const params = {};
81
+ for (let i = 0; i < names.length; i++) params[names[i]] = this.variables[i];
82
+ const wildcard = this.variables["*"];
83
+ if (wildcard !== void 0) params["*"] = wildcard;
84
+ return params;
85
+ }
86
+ parsePathernController(name) {
87
+ let module;
88
+ let tab = [];
89
+ if (typeof name !== "string") throw new Error(`Invalid name parameter: expected a string`);
90
+ tab = name.split(":");
91
+ if (tab.length !== 3) throw new Error(`Invalid name format: expected "module:controller:action"`);
92
+ module = this.context.kernel?.getModule(tab[0]);
93
+ if (!module) throw new Error(`Module not found: ${tab[0]}`);
94
+ this.controller = module.getController(tab[1]);
95
+ if (!this.controller) throw new Error(`Controller not found in module: ${tab[1]}`);
96
+ this.action = this.getAction(tab[2]);
97
+ if (!this.action) throw new Error(`Action not found in controller ${tab[1]}: ${tab[2]}`);
98
+ this.actionName = tab[2];
99
+ this.resolve = true;
100
+ if (this.controller) this.context.sessionIntent = resolveSessionIntent(this.controller, this.actionName);
101
+ }
102
+ getAction(name) {
103
+ if (!this.controller) throw new Error(`Controller not set`);
104
+ const methodNames = Object.getOwnPropertyNames(this.controller.prototype);
105
+ for (const methodName of methodNames) if (typeof this.controller.prototype[methodName] === "function" && methodName === name) return this.controller.prototype[methodName];
106
+ return null;
107
+ }
108
+ async newController(context) {
109
+ if (!this.controller) throw new Error(`Route Controller not found`);
110
+ if (this.controller.scope === "singleton") {
111
+ const router = this.context.router;
112
+ const controller = router ? await router.getSingletonController(this.controller, () => this._createController(context)) : await this._createController(context);
113
+ this.context.container?.set("controller", controller);
114
+ return controller;
115
+ }
116
+ const controller = await this._createController(context);
117
+ this.context.container?.set("controller", controller);
118
+ return controller;
119
+ }
120
+ /**
121
+ * Instancie la classe controller résolue (DI) + hooks de création : `module`
122
+ * (constante de classe, shadow d'instance posé 1× ici — plus de write par
123
+ * requête dans `executeAction`) puis `initialize()`. Pour un singleton,
124
+ * `initialize()` n'est donc appelé qu'UNE fois, à la création (sémantique
125
+ * boot) — le per-request y lit l'ALS s'il a besoin de la requête.
126
+ */
127
+ async _createController(context) {
128
+ const ctx = context || this.context;
129
+ ctx.phaseStart("initialize");
130
+ try {
131
+ const controller = this.injector?.instantiate(this.controller, ctx);
132
+ if (!controller) throw new Error(`Route Controller not found`);
133
+ if (this.controller?.prototype.module) controller.module = this.controller.prototype.module;
134
+ if ("initialize" in controller && typeof controller.initialize === "function") await controller.initialize();
135
+ return controller;
136
+ } finally {
137
+ ctx.phaseEnd("initialize");
138
+ }
139
+ }
140
+ /**
141
+ * Exécute l'action résolue et retourne sa **valeur brute**, SANS la rendre sur
142
+ * le transport (pas de `returnController`/`send`). Découple « exécuter → valeur »
143
+ * de « rendre la valeur » : un appelant multi-transport (WS-RPC `invoke`, futur
144
+ * GraphQL) réutilise la MÊME action puis emballe le résultat à sa façon
145
+ * (`{ id, result }`, champ GraphQL…). Le pipeline HTTP/WS normal passe par
146
+ * {@link callController} (= `executeAction` + rendu).
147
+ *
148
+ * @param data - args supplémentaires (message WS brut legacy) concaténés aux variables de route.
149
+ * @param reload - force `newController()` (le container peut déjà porter un AUTRE controller).
150
+ * @returns la valeur retournée par l'action + son `RedirectMeta` éventuel.
151
+ */
152
+ async executeAction(data, reload = false, metaArg) {
153
+ const meta = metaArg ?? (this.route ? resolveActionMeta(this.route) : computeActionMeta(this.controller, this.actionName));
154
+ if (meta.security !== null) await this._enforceSecurity(meta.security);
155
+ let controller = this.context.container?.get("controller");
156
+ if (!controller || reload || this.controller && !(controller instanceof this.controller)) controller = await this.newController();
157
+ if (this.controller?.scope !== "singleton") {
158
+ controller.setRoute(this.route);
159
+ if (this.queryOverride !== null) {
160
+ controller.queryGet = this.queryOverride;
161
+ controller.query = this.queryOverride;
162
+ }
163
+ }
164
+ const methodKey = this.actionName;
165
+ let args;
166
+ if (meta.paramsMeta) args = this._buildParamArgs(meta.paramsMeta);
167
+ else if (data) args = [...this.variables, ...data];
168
+ else args = [...this.variables];
169
+ this._applyResponseMeta(meta);
170
+ const redirectMeta = meta.redirectMeta ?? void 0;
171
+ if (typeof controller[methodKey] === "function") return {
172
+ result: controller[methodKey](...args),
173
+ redirectMeta
174
+ };
175
+ if (this.action) return {
176
+ result: this.action(...args),
177
+ redirectMeta
178
+ };
179
+ throw new Error(`Route Action not found`);
180
+ }
181
+ async callController(data, reload = false) {
182
+ const meta = this.route ? resolveActionMeta(this.route) : computeActionMeta(this.controller, this.actionName);
183
+ const { result, redirectMeta } = meta.idempotent !== null ? await this._callWithIdempotency(meta, data, reload) : await this.executeAction(data, reload, meta);
184
+ return this._handleRedirect(result, redirectMeta);
185
+ }
186
+ /**
187
+ * Exécute l'action AVEC la porte d'idempotence mais SANS rendu
188
+ * (`returnController`) — le chemin du **pont `api.request`** (WS) : la valeur
189
+ * nue est enveloppée `{id, result}` par le peer, jamais rendue sur le
190
+ * transport. La méthode HTTP logique d'une mutation du pont voyage dans
191
+ * {@link methodOverride} ; sans porte ici, un rejeu `socket.mutate` (socket
192
+ * qui reconnecte) ré-exécuterait la mutation (doublon — vécu au banc duplex).
193
+ *
194
+ * @returns `{ result }` — la valeur retournée par l'action (ou la réponse
195
+ * mémorisée rejouée pour une clé d'idempotence déjà servie).
196
+ */
197
+ async executeActionGuarded(data, reload = false) {
198
+ const meta = this.route ? resolveActionMeta(this.route) : computeActionMeta(this.controller, this.actionName);
199
+ const { result } = meta.idempotent !== null ? await this._callWithIdempotency(meta, data, reload) : await this.executeAction(data, reload, meta);
200
+ return { result };
201
+ }
202
+ /**
203
+ * Applique la porte d'idempotence d'une action `@Idempotent` (mutations), via le
204
+ * helper partagé `idempotency.ts` (la MÊME sémantique que le data plane admin) et
205
+ * le service `idempotencyStore`. Conforme `draft-ietf-httpapi-idempotency-key-header`.
206
+ *
207
+ * Cycle (anti double-effet) : `evaluateIdempotency` rend un verdict neutre →
208
+ * - `reject` → `nodefonyError` (400 clé requise / 409 concurrent / 422 mismatch) ;
209
+ * - `replay` → réponse mémorisée rejouée SANS ré-exécuter l'action ;
210
+ * - `execute` → exécution directe (mode souple sans clé / store absent) ;
211
+ * - `guarded` → exécuter, puis `complete()` (succès, réponse rejouable) ou
212
+ * `abort()` (échec/403 — la clé reste réessayable, un échec ne se mémorise pas).
213
+ *
214
+ * No-op sur les méthodes sûres (GET…). La réponse mémorisée est
215
+ * le **résultat retourné** par l'action (`return data`) + son statut : une action
216
+ * qui pilote la response manuellement (`this.render`/stream) n'est pas rejouée
217
+ * fidèlement (le double-effet reste évité, mais le corps rejoué est vide).
218
+ *
219
+ * Retourne la forme BRUTE `{ result, redirectMeta }` (comme `executeAction`) :
220
+ * le rendu appartient à l'appelant — `callController` rend (`_handleRedirect`),
221
+ * le pont (`executeActionGuarded`) enveloppe la valeur nue.
222
+ */
223
+ async _callWithIdempotency(meta, data, reload = false) {
224
+ const context = this.context;
225
+ if (!isMutationMethod(this.methodOverride ?? context.method)) return this.executeAction(data, reload, meta);
226
+ const als = RequestContext.get();
227
+ const httpReq = context.request;
228
+ const names = this.route?.variables ?? [];
229
+ const params = {};
230
+ for (let i = 0; i < names.length; i++) params[names[i]] = this.variables[i];
231
+ const body = als?.body !== void 0 ? als.body : httpReq?.queryPost ?? null;
232
+ const store = context.container?.get("idempotencyStore");
233
+ const verdict = await evaluateIdempotency({
234
+ store,
235
+ identity: resolveIdentity(als?.user),
236
+ clientKey: resolveIdempotencyKey(als?.idempotencyKey, httpReq?.headers?.["idempotency-key"]),
237
+ fingerprint: computeFingerprint([
238
+ this.route?.name ?? this.actionName,
239
+ params,
240
+ body
241
+ ]),
242
+ isWs: Boolean(context.type?.startsWith("websocket")),
243
+ required: meta.idempotent?.required ?? true
244
+ });
245
+ if (verdict.kind === "reject") throw new nodefonyError(verdict.message, verdict.status);
246
+ if (verdict.kind === "replay") {
247
+ const { status, headers, body: memo } = verdict.response;
248
+ const response = context.response;
249
+ response?.setStatusCode(status);
250
+ if (headers) for (const k in headers) response?.setHeader(k, headers[k]);
251
+ return {
252
+ result: memo,
253
+ redirectMeta: void 0
254
+ };
255
+ }
256
+ if (verdict.kind === "execute") return this.executeAction(data, reload, meta);
257
+ try {
258
+ const { result, redirectMeta } = await this.executeAction(data, reload, meta);
259
+ const resolved = await result;
260
+ const status = context.response?.statusCode ?? 200;
261
+ try {
262
+ await store?.complete(verdict.key, {
263
+ status,
264
+ body: resolved
265
+ });
266
+ } catch {
267
+ try {
268
+ context.log(`@Idempotent ${this.route?.name ?? this.actionName}: réponse non mémorisable (corps non sérialisable — retourne le payload brut, pas renderJson) ; dédup conservée avec un corps de rejeu vide`, "WARNING");
269
+ } catch {}
270
+ await store?.complete(verdict.key, {
271
+ status,
272
+ body: null
273
+ });
274
+ }
275
+ return {
276
+ result: resolved,
277
+ redirectMeta
278
+ };
279
+ } catch (e) {
280
+ await store?.abort(verdict.key);
281
+ throw e;
282
+ }
283
+ }
284
+ /**
285
+ * Évalue l'exigence d'autorisation (`@IsGranted`) d'une action via le service
286
+ * `authorization` (par nom, 0 import security). Clauses en **AND**, attributs
287
+ * d'une clause en **OR**. Refus (ou moteur/identité absents) → 403 (Zero Trust,
288
+ * fail-closed). Cold path : n'est appelé que sur une route gardée.
289
+ *
290
+ * @throws nodefonyError 403 si l'accès est refusé.
291
+ */
292
+ async _enforceSecurity(req) {
293
+ const authz = this.context.container?.get("authorization");
294
+ const token = RequestContext.get()?.token;
295
+ if (!authz || token === void 0) throw new nodefonyError("Access denied", 403);
296
+ const clauses = req.clauses;
297
+ for (let i = 0; i < clauses.length; i++) {
298
+ const clause = clauses[i];
299
+ const subject = clause.subjectParam !== void 0 ? this._resolveSubject(clause.subjectParam) : void 0;
300
+ let ok = false;
301
+ const anyOf = clause.anyOf;
302
+ for (let j = 0; j < anyOf.length; j++) if (await authz.decide(token, anyOf[j], subject)) {
303
+ ok = true;
304
+ break;
305
+ }
306
+ if (!ok) throw new nodefonyError("Access denied", 403);
307
+ }
308
+ }
309
+ /**
310
+ * Résout un paramètre de route NOMMÉ (`@IsGranted(..., { subject: "id" })`) vers
311
+ * sa valeur, depuis `route.variables` (noms) + `this.variables` (valeurs déjà
312
+ * parsées). 0 alloc (indexOf + accès tableau).
313
+ */
314
+ _resolveSubject(name) {
315
+ const idx = (this.route?.variables ?? []).indexOf(name);
316
+ return idx === -1 ? void 0 : this.variables[idx];
317
+ }
318
+ _buildParamArgs(metas) {
319
+ const httpCtx = this.context;
320
+ const varNames = this.route?.variables ?? [];
321
+ const paramsMap = {};
322
+ for (let i = 0; i < varNames.length; i++) paramsMap[varNames[i]] = this.variables[i];
323
+ const ctx = this.context;
324
+ return buildParamArgs(metas, {
325
+ paramsMap,
326
+ request: httpCtx?.request,
327
+ response: httpCtx?.response,
328
+ session: ctx?.session,
329
+ queryOverride: this.queryOverride ?? void 0,
330
+ getRequestCookies: (name) => ctx?.getRequestCookies ? ctx.getRequestCookies(name) : void 0
331
+ });
332
+ }
333
+ /**
334
+ * Applique `@HttpCode` + `@Header` depuis le snapshot figé de la route
335
+ * (P5) — plus aucune lecture `Reflect` ni `Object.entries` par requête.
336
+ * V4.3 : cible la response du CONTEXT (per-request : identique à
337
+ * `controller.response` ; singleton : la seule source correcte — l'instance
338
+ * partagée ne porte aucune response).
339
+ */
340
+ _applyResponseMeta(meta) {
341
+ const response = this.context.response;
342
+ if (meta.httpCode !== null) response?.setStatusCode(meta.httpCode);
343
+ const entries = meta.headerEntries;
344
+ if (entries) for (let i = 0; i < entries.length; i++) response?.setHeader(entries[i][0], entries[i][1]);
345
+ }
346
+ async _handleRedirect(actionResult, redirectMeta) {
347
+ if (!redirectMeta) return this.returnController(actionResult);
348
+ const resolved = await Promise.resolve(actionResult);
349
+ if (resolved !== null && resolved !== void 0 && typeof resolved === "object" && "url" in resolved) {
350
+ const override = resolved;
351
+ this.context.redirect(override.url, override.statusCode ?? redirectMeta.statusCode);
352
+ return this.returnController(void 0);
353
+ }
354
+ if (resolved === void 0 || resolved === null) {
355
+ this.context.redirect(redirectMeta.url, redirectMeta.statusCode);
356
+ return this.returnController(void 0);
357
+ }
358
+ return this.returnController(resolved);
359
+ }
360
+ async returnController(result) {
361
+ const type = typeOf(result);
362
+ switch (true) {
363
+ case result instanceof Promise:
364
+ case isPromise(result): return result.then((myresult) => this.returnController(myresult));
365
+ case type === "string":
366
+ case result instanceof String: return this.context.send(result);
367
+ case result instanceof Http2Response:
368
+ case result instanceof HttpResponse:
369
+ case result instanceof WebsocketResponse: return result;
370
+ case type === "buffer":
371
+ if (this.context.sended) return;
372
+ return this.context.send(result);
373
+ case type === "number":
374
+ case type === "boolean":
375
+ if (this.context.sended) return;
376
+ this.context.setContextJson();
377
+ return this.context.render(result);
378
+ case type === "array":
379
+ case type === "object":
380
+ if (this.context.sended) return;
381
+ if (isPlainObject(result) || isArray(result)) {
382
+ this.context.setContextJson();
383
+ return this.context.render(result);
384
+ }
385
+ switch (this.context.type) {
386
+ case "http":
387
+ case "http2":
388
+ case "http3":
389
+ case "https": this.context.waitAsync = true;
390
+ }
391
+ return;
392
+ default: switch (this.context.type) {
393
+ case "http":
394
+ case "http2":
395
+ case "http3":
396
+ case "https":
397
+ if (this.context.sended) return;
398
+ if (this.context.isRedirect) return this.context.send();
399
+ if (NO_BODY_STATUS.has(this.getResponseStatus())) return this.context.send();
400
+ this.context.waitAsync = true;
401
+ }
402
+ }
403
+ }
404
+ /** Statut courant de la réponse (0 si le transport n'en porte pas). */
405
+ getResponseStatus() {
406
+ return this.context.response?.statusCode ?? 0;
407
+ }
408
+ };
409
+ /** Statuts dont la réponse n'a, par définition, pas de corps (RFC 9110). */
410
+ const NO_BODY_STATUS = /* @__PURE__ */ new Set([
411
+ 204,
412
+ 205,
413
+ 304
414
+ ]);
415
+ //#endregion
416
+ export { Resolver as default };
@@ -0,0 +1,148 @@
1
+ import Controller from "./Controller.js";
2
+ import { assertPageQuery } from "nodefony";
3
+ import { HttpError } from "@nodefony/http";
4
+ //#region nodefony/src/ResourceController.ts
5
+ /**
6
+ * Controller de ressource **souverain** (V4.2 — POC API souveraine, Phase 2) :
7
+ * la logique métier est écrite UNE fois dans le service de ressource, les
8
+ * actions de la sous-classe ne sont que des portes (REST, WS-RPC `invoke`,
9
+ * GraphQL à venir) qui retournent la **valeur brute** — `returnController`
10
+ * l'auto-JSON en REST, le pont WS l'enveloppe (`{id,result}`), sans réécrire
11
+ * l'action par transport.
12
+ *
13
+ * **Stateless par construction** :
14
+ * - `static scope = "singleton"` : UNE instance partagée (cache Router, V4.3).
15
+ * L'état per-request n'existe PAS sur `this` — il arrive par les arguments
16
+ * décorés (`@Param`/`@Body`/`@Query`) et par les helpers hérités qui
17
+ * retrouvent la requête courante via l'ALS (V4.1).
18
+ * - le seul champ est `resource`, posé 1× au constructor (état de BOOT,
19
+ * immuable ensuite — sûr en concurrence).
20
+ * - règle absolue pour les sous-classes : **jamais `this.x = …` par requête**
21
+ * (data race silencieuse). Une sous-classe qui a besoin d'état per-request
22
+ * sur `this` doit rétrograder : `static scope = "request"`.
23
+ *
24
+ * Sécurité : aucun critère de requête n'est passé AUTOMATIQUEMENT au service
25
+ * (pas de `find(this.queryGet)` implicite) — exposer un filtrage est une
26
+ * décision EXPLICITE de la sous-classe (deny-by-default ; le scope de
27
+ * sécurité des données se branche au niveau service/criteria, P6).
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * \@controller("/api/books")
32
+ * class BookController extends ResourceController<Book> {
33
+ * constructor(context: Context) {
34
+ * super("BookController", context, bookService);
35
+ * }
36
+ * \@route("books-list", { path: "", requirements: { methods: ["GET", "WEBSOCKET"] } })
37
+ * list() {
38
+ * return this.listResource();
39
+ * }
40
+ * \@route("books-get", { path: "/{id}", requirements: { methods: ["GET", "WEBSOCKET"] } })
41
+ * detail(\@Param("id") id: string) {
42
+ * return this.getResource(id);
43
+ * }
44
+ * }
45
+ * ```
46
+ */
47
+ var ResourceController = class extends Controller {
48
+ /**
49
+ * Singleton PAR DÉFAUT : la classe est conçue stateless — premier client
50
+ * du scope V4.3. Une sous-classe peut rétrograder (`static scope =
51
+ * "request"`) si elle doit porter de l'état per-request sur `this`.
52
+ */
53
+ static scope = "singleton";
54
+ /**
55
+ * Service de ressource — état de BOOT (posé 1× ici, jamais réassigné).
56
+ * `protected` : les actions de la sous-classe y accèdent, les portes non.
57
+ */
58
+ resource = null;
59
+ constructor(name, context, resource) {
60
+ super(name, context);
61
+ if (resource) this.resource = resource;
62
+ }
63
+ /**
64
+ * Service de ressource garanti — 500 explicite si la sous-classe ne l'a
65
+ * pas fourni (erreur de câblage, pas une erreur client).
66
+ */
67
+ requireResource() {
68
+ if (!this.resource) throw new HttpError(`${this.name}: no resource service wired (pass it to super(name, context, resource))`, 500, this.context);
69
+ return this.resource;
70
+ }
71
+ /**
72
+ * Liste la ressource. `criteria` est EXPLICITE (jamais dérivé de la query
73
+ * string automatiquement — deny-by-default).
74
+ */
75
+ listResource(criteria, options) {
76
+ return Promise.resolve(this.requireResource().find(criteria, options));
77
+ }
78
+ /**
79
+ * Liste la ressource en rendant une **page** (`{ items, hasNext, total? }`)
80
+ * plutôt qu'un tableau nu.
81
+ *
82
+ * Pourquoi une page et pas un tableau : un tableau ne dit pas s'il en reste.
83
+ * Le client qui reçoit 25 lignes ne peut pas distinguer « c'est tout » de
84
+ * « demande la suite » — il redemande indéfiniment, ou s'arrête trop tôt.
85
+ *
86
+ * **Mode offset imposé** (`assertPageQuery`) : un client qui enverrait un
87
+ * `cursor` recevrait sinon la page 1 à chaque appel, en boucle et sans erreur.
88
+ *
89
+ * Si le service n'expose pas `findPage`, la page est reconstituée à partir de
90
+ * `find` en chargeant `limit + 1` lignes (même technique que `paginate`) : le
91
+ * `hasNext` reste exact, seul `total` manque — et son absence est lisible dans
92
+ * la réponse, le contrat `IPage` le donnant pour optionnel.
93
+ *
94
+ * @param page - bornes, tri et critères de la page demandée.
95
+ * @returns la page (`items` borné à `limit`).
96
+ * @throws PaginationModeError si la requête mélange offset et curseur.
97
+ */
98
+ async listPageResource(page) {
99
+ assertPageQuery(page, "offset");
100
+ const resource = this.requireResource();
101
+ if (typeof resource.findPage === "function") return resource.findPage(page);
102
+ const limit = Math.max(1, Math.floor(page.limit));
103
+ const offset = Math.max(0, Math.floor(page.offset ?? 0));
104
+ const rows = await Promise.resolve(resource.find(page.criteria, {
105
+ limit: limit + 1,
106
+ offset,
107
+ order: page.order
108
+ }));
109
+ const hasNext = rows.length > limit;
110
+ return {
111
+ items: hasNext ? rows.slice(0, limit) : rows,
112
+ limit,
113
+ offset,
114
+ hasNext
115
+ };
116
+ }
117
+ /**
118
+ * Lit une entité par id — `null` si absente (la porte décide du 404).
119
+ *
120
+ * `options.relations` charge les associations dans la foulée. La porte doit
121
+ * n'y laisser passer que des relations qu'elle a DÉCLARÉES : un `include`
122
+ * libre laisse le client nommer n'importe quelle association, donc lire des
123
+ * données qu'aucune route ne lui ouvre.
124
+ */
125
+ getResource(id, options) {
126
+ return Promise.resolve(this.requireResource().findById(id, options));
127
+ }
128
+ /** Crée une entité — 501 si la ressource est read-only. */
129
+ createResource(data) {
130
+ const resource = this.requireResource();
131
+ if (typeof resource.create !== "function") throw new HttpError(`${this.name}: resource is read-only (no create)`, 501, this.context);
132
+ return Promise.resolve(resource.create(data));
133
+ }
134
+ /** Met à jour une entité ciblée par critères — 501 si non supporté. */
135
+ updateResource(criteria, data) {
136
+ const resource = this.requireResource();
137
+ if (typeof resource.updateOne !== "function") throw new HttpError(`${this.name}: resource is read-only (no updateOne)`, 501, this.context);
138
+ return Promise.resolve(resource.updateOne(criteria, data));
139
+ }
140
+ /** Supprime par critères (nombre d'entités touchées) — 501 si non supporté. */
141
+ removeResource(criteria) {
142
+ const resource = this.requireResource();
143
+ if (typeof resource.delete !== "function") throw new HttpError(`${this.name}: resource is read-only (no delete)`, 501, this.context);
144
+ return Promise.resolve(resource.delete(criteria));
145
+ }
146
+ };
147
+ //#endregion
148
+ export { ResourceController as default };