@nodefony/framework 10.0.0-alpha.9 → 10.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/controller/AdminApiController.js +4 -3
- package/dist/nodefony/controller/ApiKeyController.js +1 -1
- package/dist/nodefony/controller/OAuth2Controller.js +11 -6
- package/dist/nodefony/controller/SessionAuthController.js +3 -3
- package/dist/nodefony/controller/TokenAuthController.js +2 -2
- package/dist/nodefony/controller/TotpController.js +5 -4
- package/dist/nodefony/controller/WebAuthnController.js +4 -4
- package/dist/nodefony/decorators/routerDecorators.js +41 -35
- package/dist/nodefony/service/IdempotencyStore.js +2 -3
- package/dist/nodefony/service/router.js +73 -23
- package/dist/nodefony/src/Controller.js +184 -78
- package/dist/nodefony/src/FrameworkAdminApi.js +19 -6
- package/dist/nodefony/src/KernelAdminApi.js +49 -49
- package/dist/nodefony/src/PlaygroundAdminApi.js +3 -3
- package/dist/nodefony/src/RedisIdempotencyStore.js +2 -2
- package/dist/nodefony/src/Resolver.js +219 -63
- package/dist/nodefony/src/Route.js +38 -38
- package/dist/nodefony/src/SyslogAdminApi.js +4 -4
- package/dist/nodefony/src/docsReader.js +22 -18
- package/dist/nodefony/src/scopeCatalog.js +1 -1
- package/dist/nodefony/src/singletonGuard.js +76 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +2 -2
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +3 -3
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +1 -1
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +2 -1
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +49 -43
- package/dist/types/nodefony/interfaces/IController.d.ts +9 -7
- package/dist/types/nodefony/interfaces/IResolver.d.ts +10 -10
- package/dist/types/nodefony/interfaces/IRoute.d.ts +13 -13
- package/dist/types/nodefony/service/AdminBroker.d.ts +3 -3
- package/dist/types/nodefony/service/router.d.ts +33 -7
- package/dist/types/nodefony/src/Controller.d.ts +97 -24
- package/dist/types/nodefony/src/Resolver.d.ts +154 -15
- package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
- package/dist/types/nodefony/src/Route.d.ts +24 -22
- package/dist/types/nodefony/src/docsReader.d.ts +3 -3
- package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
- package/docs/admin.md +1 -1
- package/docs/controller.md +133 -115
- package/docs/decorateurs.md +95 -92
- package/docs/idempotence.md +21 -19
- package/docs/routing.md +46 -40
- package/docs/templates.md +14 -14
- package/package.json +8 -8
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { guardSingletonState, isDevelopment } from "./singletonGuard.js";
|
|
1
2
|
import { buildParamArgs, computeActionMeta, resolveActionMeta, resolveSessionIntent } from "../decorators/routerDecorators.js";
|
|
2
3
|
import { computeFingerprint, evaluateIdempotency, isMutationMethod, resolveIdempotencyKey, resolveIdentity } from "./idempotency.js";
|
|
3
|
-
import { RequestContext, identityHint, isArray, isPlainObject, isPromise, nodefonyError, typeOf } from "nodefony";
|
|
4
|
+
import { RequestContext, finallyMaybe, identityHint, isArray, isPlainObject, isPromise, nodefonyError, thenMaybe, typeOf } from "nodefony";
|
|
4
5
|
import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
|
|
5
6
|
//#region nodefony/src/Resolver.ts
|
|
6
7
|
/**
|
|
@@ -21,6 +22,15 @@ import { Http2Response, HttpResponse, WebsocketResponse } from "@nodefony/http";
|
|
|
21
22
|
* cohabitent dans une même suite de tests.
|
|
22
23
|
*/
|
|
23
24
|
const areaRequirements = /* @__PURE__ */ new WeakMap();
|
|
25
|
+
/**
|
|
26
|
+
* Le controller expose-t-il un hook `initialize()` (appelé une fois à sa création) ?
|
|
27
|
+
*
|
|
28
|
+
* @param controller - instance fraîchement créée par l'injecteur.
|
|
29
|
+
* @returns `true` si `initialize` est une méthode.
|
|
30
|
+
*/
|
|
31
|
+
function hasInitialize(controller) {
|
|
32
|
+
return typeof Reflect.get(controller, "initialize") === "function";
|
|
33
|
+
}
|
|
24
34
|
var Resolver = class {
|
|
25
35
|
injector;
|
|
26
36
|
controller = null;
|
|
@@ -96,45 +106,84 @@ var Resolver = class {
|
|
|
96
106
|
* `Context.setMetaData()` pour exposer `msg.nodefony.route.variablesMap`.
|
|
97
107
|
*/
|
|
98
108
|
getMatchedParams() {
|
|
99
|
-
const
|
|
100
|
-
const params = {};
|
|
101
|
-
for (let i = 0; i < names.length; i++) params[names[i]] = this.variables[i];
|
|
109
|
+
const params = this._zipRouteParams();
|
|
102
110
|
const wildcard = this.variables["*"];
|
|
103
111
|
if (wildcard !== void 0) params["*"] = wildcard;
|
|
104
112
|
return params;
|
|
105
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Associe chaque nom de variable de la route à la valeur capturée au match.
|
|
116
|
+
*
|
|
117
|
+
* @returns la table nom → valeur (vide sans route)
|
|
118
|
+
*/
|
|
119
|
+
_zipRouteParams() {
|
|
120
|
+
const names = this.route?.variables ?? [];
|
|
121
|
+
const params = {};
|
|
122
|
+
for (let i = 0; i < names.length; i++) {
|
|
123
|
+
const name = names[i];
|
|
124
|
+
if (name !== void 0) params[name] = this.variables[i];
|
|
125
|
+
}
|
|
126
|
+
return params;
|
|
127
|
+
}
|
|
106
128
|
parsePathernController(name) {
|
|
107
|
-
let module;
|
|
108
|
-
let tab = [];
|
|
109
129
|
if (typeof name !== "string") throw new Error(`Invalid name parameter: expected a string`);
|
|
110
|
-
tab = name.split(":");
|
|
130
|
+
const tab = name.split(":");
|
|
131
|
+
const [moduleName = "", controllerName = "", actionName = ""] = tab;
|
|
111
132
|
if (tab.length !== 3) throw new Error(`Invalid name format: expected "module:controller:action"`);
|
|
112
|
-
module = this.context.kernel?.getModule(
|
|
113
|
-
if (!module) throw new Error(`Module not found: ${
|
|
114
|
-
this.controller = module.getController(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
this.actionName =
|
|
133
|
+
const module = this.context.kernel?.getModule(moduleName);
|
|
134
|
+
if (!module) throw new Error(`Module not found: ${moduleName}`);
|
|
135
|
+
this.controller = module.getController(controllerName);
|
|
136
|
+
const action = this.getAction(actionName);
|
|
137
|
+
if (!action) throw new Error(`Action not found in controller ${controllerName}: ${actionName}`);
|
|
138
|
+
this.action = action;
|
|
139
|
+
this.actionName = actionName;
|
|
119
140
|
this.resolve = true;
|
|
120
|
-
|
|
141
|
+
this.context.sessionIntent = resolveSessionIntent(this.controller, this.actionName);
|
|
121
142
|
}
|
|
122
143
|
getAction(name) {
|
|
123
144
|
if (!this.controller) throw new Error(`Controller not set`);
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
145
|
+
const proto = this.controller.prototype;
|
|
146
|
+
if (!Object.hasOwn(proto, name)) return null;
|
|
147
|
+
const member = Reflect.get(proto, name);
|
|
148
|
+
return typeof member === "function" ? member : null;
|
|
127
149
|
}
|
|
128
|
-
|
|
150
|
+
/**
|
|
151
|
+
* L'instance du controller de la route, posée sur le container du contexte.
|
|
152
|
+
*
|
|
153
|
+
* Singleton (le défaut) : l'instance partagée du cache du Router — rendue
|
|
154
|
+
* DIRECTEMENT une fois créée, sans promesse à attendre (#505). Portée
|
|
155
|
+
* `request` : une instance neuve, synchrone tant que son `initialize()` ne
|
|
156
|
+
* rend pas de promesse.
|
|
157
|
+
*
|
|
158
|
+
* @param context - le contexte à injecter (celui du résolveur par défaut).
|
|
159
|
+
* @returns l'instance, ou la promesse de l'instance quand sa création attend
|
|
160
|
+
* (`initialize()` asynchrone).
|
|
161
|
+
* @throws de façon SYNCHRONE si la route n'a pas de controller, ou si sa
|
|
162
|
+
* construction lève ; un `initialize()` qui échoue rompt la promesse rendue.
|
|
163
|
+
*/
|
|
164
|
+
newController(context) {
|
|
129
165
|
if (!this.controller) throw new Error(`Route Controller not found`);
|
|
166
|
+
let created;
|
|
130
167
|
if (this.controller.scope === "singleton") {
|
|
131
168
|
const router = this.context.router;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
169
|
+
created = router ? router.getSingletonController(this.controller, () => this._createController(context)) : this._createController(context);
|
|
170
|
+
} else created = this._createController(context);
|
|
171
|
+
return thenMaybe(created, (controller) => this._pinController(controller));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Pose le pointeur `"controller"` sur le container du contexte : un message
|
|
175
|
+
* WS suivant ou un forward (`reload`) retrouve l'instance par ce chemin.
|
|
176
|
+
*
|
|
177
|
+
* SAUF pour une invocation par message (pont `api.request`) : le container
|
|
178
|
+
* est alors celui de la CONNEXION, et son pointeur désigne le contrôleur qui
|
|
179
|
+
* la sert (le hub). L'écraser par la cible de l'appel faisait échouer le test
|
|
180
|
+
* `instanceof` au message suivant — le hub était RÉINSTANCIÉ (DI +
|
|
181
|
+
* `initialize()`) à chaque frame qui suivait un `api.request`.
|
|
182
|
+
*
|
|
183
|
+
* @returns le controller reçu, pour enchaîner.
|
|
184
|
+
*/
|
|
185
|
+
_pinController(controller) {
|
|
186
|
+
if (!this.messageInvocation) this.context.container?.set("controller", controller);
|
|
138
187
|
return controller;
|
|
139
188
|
}
|
|
140
189
|
/**
|
|
@@ -143,19 +192,36 @@ var Resolver = class {
|
|
|
143
192
|
* requête dans `executeAction`) puis `initialize()`. Pour un singleton,
|
|
144
193
|
* `initialize()` n'est donc appelé qu'UNE fois, à la création (sémantique
|
|
145
194
|
* boot) — le per-request y lit l'ALS s'il a besoin de la requête.
|
|
195
|
+
*
|
|
196
|
+
* @returns le controller prêt ; sa promesse seulement quand `initialize()`
|
|
197
|
+
* en rend une.
|
|
198
|
+
* @throws de façon SYNCHRONE ce que lèvent la DI, le constructeur ou un
|
|
199
|
+
* `initialize()` synchrone ; un `initialize()` asynchrone qui échoue rompt
|
|
200
|
+
* la promesse rendue.
|
|
146
201
|
*/
|
|
147
|
-
|
|
148
|
-
const ctx = context
|
|
202
|
+
_createController(context) {
|
|
203
|
+
const ctx = context ?? this.context;
|
|
149
204
|
ctx.phaseStart("initialize");
|
|
150
|
-
|
|
205
|
+
return finallyMaybe(() => {
|
|
151
206
|
const controller = this.injector?.instantiate(this.controller, ctx);
|
|
152
207
|
if (!controller) throw new Error(`Route Controller not found`);
|
|
153
208
|
if (this.controller?.prototype.module) controller.module = this.controller.prototype.module;
|
|
154
|
-
|
|
155
|
-
return controller;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
209
|
+
const initialized = hasInitialize(controller) ? controller.initialize() : void 0;
|
|
210
|
+
return thenMaybe(initialized, () => this._guardSingleton(controller, ctx));
|
|
211
|
+
}, () => ctx.phaseEnd("initialize"));
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Filet de DÉVELOPPEMENT : un singleton sert toutes les requêtes, donc une
|
|
215
|
+
* écriture sur l'un de ses champs, à partir d'ici, fuirait vers la suivante.
|
|
216
|
+
* Posé APRÈS `initialize()`, qui prépare légitimement un état de démarrage.
|
|
217
|
+
* Jamais hors développement : les champs y deviennent des accesseurs, un coût
|
|
218
|
+
* que la production ne paie pas.
|
|
219
|
+
*
|
|
220
|
+
* @returns le controller reçu, pour enchaîner.
|
|
221
|
+
*/
|
|
222
|
+
_guardSingleton(controller, ctx) {
|
|
223
|
+
if (this.controller.scope === "singleton" && isDevelopment(ctx.kernel?.environment)) guardSingletonState(controller, controller.constructor.name);
|
|
224
|
+
return controller;
|
|
159
225
|
}
|
|
160
226
|
/**
|
|
161
227
|
* Exécute l'action résolue et retourne sa **valeur brute**, SANS la rendre sur
|
|
@@ -165,19 +231,72 @@ var Resolver = class {
|
|
|
165
231
|
* (`{ id, result }`, champ GraphQL…). Le pipeline HTTP/WS normal passe par
|
|
166
232
|
* {@link callController} (= `executeAction` + rendu).
|
|
167
233
|
*
|
|
234
|
+
* Synchrone tant que rien n'est à attendre (#505) : la garde d'autorisation
|
|
235
|
+
* n'est attendue que sur une route gardée, l'instance que si elle est à
|
|
236
|
+
* créer. La valeur de l'action est rendue BRUTE — une promesse si l'action
|
|
237
|
+
* est asynchrone ; c'est le rendu qui la démêle.
|
|
238
|
+
*
|
|
168
239
|
* @param data - args supplémentaires (message WS brut legacy) concaténés aux variables de route.
|
|
169
240
|
* @param reload - force `newController()` (le container peut déjà porter un AUTRE controller).
|
|
170
|
-
* @returns la valeur retournée par l'action + son `RedirectMeta` éventuel
|
|
241
|
+
* @returns la valeur retournée par l'action + son `RedirectMeta` éventuel —
|
|
242
|
+
* ou la promesse de ce couple quand la garde ou la création attendent.
|
|
243
|
+
* @throws de façon SYNCHRONE ce que lève une action synchrone (ou l'absence
|
|
244
|
+
* d'action) ; un refus d'autorisation rompt la promesse rendue.
|
|
171
245
|
*/
|
|
172
|
-
|
|
246
|
+
executeAction(data, reload = false, metaArg) {
|
|
173
247
|
const meta = metaArg ?? (this.route ? resolveActionMeta(this.route) : computeActionMeta(this.controller, this.actionName));
|
|
174
|
-
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
248
|
+
const guarded = this._enforceActionSecurity(meta);
|
|
249
|
+
if (guarded !== void 0) return guarded.then(() => this._acquireAndInvoke(meta, data, reload));
|
|
250
|
+
return this._acquireAndInvoke(meta, data, reload);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Les gardes d'autorisation d'une action : `@IsGranted`/`@RequireScope`, puis
|
|
254
|
+
* le filet de la ZONE quand aucune garde n'a décidé du RÔLE.
|
|
255
|
+
*
|
|
256
|
+
* ⚠️ Vrai du trajet HTTP SEULEMENT. Le kernel y arme la route sans instancier
|
|
257
|
+
* (`http-kernel.ts` `prepareFrontController`), donc ce court-circuit est celui
|
|
258
|
+
* de bout en bout. En **WebSocket**, le controller est instancié au HANDSHAKE
|
|
259
|
+
* — avant qu'une frame ne soit gardée : sur ce transport, un refus n'évite
|
|
260
|
+
* ni la DI ni `initialize()`, qui ont déjà tourné.
|
|
261
|
+
*
|
|
262
|
+
* @returns `undefined` quand la route n'est pas gardée (le cas nominal : ni
|
|
263
|
+
* lookup, ni attente) ; sinon la promesse du verdict, rompue par un refus.
|
|
264
|
+
*/
|
|
265
|
+
_enforceActionSecurity(meta) {
|
|
266
|
+
if (meta.security === null) return this._enforceAreaSecurity();
|
|
267
|
+
const granted = this._enforceSecurity(meta.security);
|
|
268
|
+
return meta.security.hasRoleClause ? granted : granted.then(() => this._enforceAreaSecurity());
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Filet de la ZONE : une route dont personne n'a décidé le RÔLE hérite de
|
|
272
|
+
* celui de sa zone du firewall, au lieu d'être ouverte à tout compte
|
|
273
|
+
* authentifié.
|
|
274
|
+
*
|
|
275
|
+
* @returns `undefined` sur l'immense majorité des routes (aucune zone, ou
|
|
276
|
+
* zone sans rôle) ; sinon la promesse du verdict.
|
|
277
|
+
*/
|
|
278
|
+
_enforceAreaSecurity() {
|
|
279
|
+
const area = this._areaSecurity();
|
|
280
|
+
return area !== null ? this._enforceSecurity(area) : void 0;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* L'instance qui sert l'action — celle déjà posée sur le container quand
|
|
284
|
+
* c'est la bonne classe, sinon une création —, puis l'action elle-même.
|
|
285
|
+
* N'attend que la création, et seulement quand elle attend.
|
|
286
|
+
*/
|
|
287
|
+
_acquireAndInvoke(meta, data, reload) {
|
|
288
|
+
const pinned = this.context.container?.get("controller");
|
|
289
|
+
if (!pinned || reload || this.controller && !(pinned instanceof this.controller)) return thenMaybe(this.newController(), (controller) => this._invokeAction(controller, meta, data));
|
|
290
|
+
return this._invokeAction(pinned, meta, data);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Appelle l'action sur l'instance : état de requête (portée `request`
|
|
294
|
+
* seulement), arguments, méta de réponse (`@HttpCode`, `@Header`), puis
|
|
295
|
+
* l'appel — synchrone, sa valeur rendue brute.
|
|
296
|
+
*
|
|
297
|
+
* @throws de façon SYNCHRONE ce que lève l'action, ou l'absence d'action.
|
|
298
|
+
*/
|
|
299
|
+
_invokeAction(controller, meta, data) {
|
|
181
300
|
if (this.controller?.scope !== "singleton") {
|
|
182
301
|
controller.setRoute(this.route);
|
|
183
302
|
if (this.queryOverride !== null) {
|
|
@@ -202,10 +321,22 @@ var Resolver = class {
|
|
|
202
321
|
};
|
|
203
322
|
throw new Error(`Route Action not found`);
|
|
204
323
|
}
|
|
205
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Exécute l'action PUIS rend sa valeur sur le transport.
|
|
326
|
+
*
|
|
327
|
+
* Synchrone de bout en bout quand rien n'attend — route non gardée,
|
|
328
|
+
* controller déjà créé, action synchrone, rendu synchrone (#505).
|
|
329
|
+
*
|
|
330
|
+
* @param data - args supplémentaires (message WS brut legacy).
|
|
331
|
+
* @param reload - force une nouvelle instance (forward).
|
|
332
|
+
* @returns le résultat du rendu, ou sa promesse dès qu'une étape attend.
|
|
333
|
+
* @throws de façon SYNCHRONE ce que lève une étape synchrone (action,
|
|
334
|
+
* construction) ; les échecs asynchrones rompent la promesse rendue.
|
|
335
|
+
*/
|
|
336
|
+
callController(data, reload = false) {
|
|
206
337
|
const meta = this.route ? resolveActionMeta(this.route) : computeActionMeta(this.controller, this.actionName);
|
|
207
|
-
const
|
|
208
|
-
return this._handleRedirect(result, redirectMeta);
|
|
338
|
+
const outcome = meta.idempotent !== null ? this._callWithIdempotency(meta, data, reload) : this.executeAction(data, reload, meta);
|
|
339
|
+
return thenMaybe(outcome, (done) => this._handleRedirect(done.result, done.redirectMeta));
|
|
209
340
|
}
|
|
210
341
|
/**
|
|
211
342
|
* Exécute l'action AVEC la porte d'idempotence mais SANS rendu
|
|
@@ -249,9 +380,7 @@ var Resolver = class {
|
|
|
249
380
|
if (!isMutationMethod(this.methodOverride ?? context.method)) return this.executeAction(data, reload, meta);
|
|
250
381
|
const als = RequestContext.get();
|
|
251
382
|
const httpReq = context.request;
|
|
252
|
-
const
|
|
253
|
-
const params = {};
|
|
254
|
-
for (let i = 0; i < names.length; i++) params[names[i]] = this.variables[i];
|
|
383
|
+
const params = this._zipRouteParams();
|
|
255
384
|
const body = als?.body !== void 0 ? als.body : httpReq?.queryPost ?? null;
|
|
256
385
|
const store = context.container?.get("idempotencyStore");
|
|
257
386
|
const verdict = await evaluateIdempotency({
|
|
@@ -271,7 +400,11 @@ var Resolver = class {
|
|
|
271
400
|
const { status, headers, body: memo } = verdict.response;
|
|
272
401
|
const response = context.response;
|
|
273
402
|
response?.setStatusCode(status);
|
|
274
|
-
if (headers) for (const k in headers)
|
|
403
|
+
if (headers) for (const k in headers) {
|
|
404
|
+
const value = headers[k];
|
|
405
|
+
if (value === void 0) continue;
|
|
406
|
+
response?.setHeader(k, value);
|
|
407
|
+
}
|
|
275
408
|
return {
|
|
276
409
|
result: memo,
|
|
277
410
|
redirectMeta: void 0
|
|
@@ -355,12 +488,11 @@ var Resolver = class {
|
|
|
355
488
|
const token = RequestContext.get()?.token;
|
|
356
489
|
if (!authz || token === void 0) throw new nodefonyError(this._accessDenied(!authz ? `aucun moteur d'autorisation n'est posé sur cette requête` : "aucune identité n'est résolue sur cette requête — la route est-elle couverte par une zone du firewall ?"), 403);
|
|
357
490
|
const clauses = req.clauses;
|
|
358
|
-
for (
|
|
359
|
-
const clause = clauses[i];
|
|
491
|
+
for (const clause of clauses) {
|
|
360
492
|
const subject = clause.subjectParam !== void 0 ? this._resolveSubject(clause.subjectParam) : void 0;
|
|
361
493
|
let ok = false;
|
|
362
494
|
const anyOf = clause.anyOf;
|
|
363
|
-
for (
|
|
495
|
+
for (const attribute of anyOf) if (await authz.decide(token, attribute, subject)) {
|
|
364
496
|
ok = true;
|
|
365
497
|
break;
|
|
366
498
|
}
|
|
@@ -395,17 +527,15 @@ var Resolver = class {
|
|
|
395
527
|
}
|
|
396
528
|
_buildParamArgs(metas) {
|
|
397
529
|
const httpCtx = this.context;
|
|
398
|
-
const
|
|
399
|
-
const paramsMap = {};
|
|
400
|
-
for (let i = 0; i < varNames.length; i++) paramsMap[varNames[i]] = this.variables[i];
|
|
530
|
+
const paramsMap = this._zipRouteParams();
|
|
401
531
|
const ctx = this.context;
|
|
402
532
|
return buildParamArgs(metas, {
|
|
403
533
|
paramsMap,
|
|
404
|
-
request: httpCtx
|
|
405
|
-
response: httpCtx
|
|
406
|
-
session: ctx
|
|
534
|
+
request: httpCtx.request,
|
|
535
|
+
response: httpCtx.response,
|
|
536
|
+
session: ctx.session,
|
|
407
537
|
queryOverride: this.queryOverride ?? void 0,
|
|
408
|
-
getRequestCookies: (name) => ctx
|
|
538
|
+
getRequestCookies: (name) => ctx.getRequestCookies(name)
|
|
409
539
|
});
|
|
410
540
|
}
|
|
411
541
|
/**
|
|
@@ -419,11 +549,23 @@ var Resolver = class {
|
|
|
419
549
|
const response = this.context.response;
|
|
420
550
|
if (meta.httpCode !== null) response?.setStatusCode(meta.httpCode);
|
|
421
551
|
const entries = meta.headerEntries;
|
|
422
|
-
if (entries) for (
|
|
552
|
+
if (entries) for (const [name, value] of entries) response?.setHeader(name, value);
|
|
423
553
|
}
|
|
424
|
-
|
|
554
|
+
/**
|
|
555
|
+
* Applique `@Redirect` à la valeur de l'action, puis la rend.
|
|
556
|
+
*
|
|
557
|
+
* Sans `@Redirect` (le cas nominal), rend directement. Avec, la valeur est
|
|
558
|
+
* attendue seulement si c'est une promesse — une action synchrone qui rend
|
|
559
|
+
* `undefined` redirige sans suspension.
|
|
560
|
+
*
|
|
561
|
+
* @returns le résultat du rendu, ou sa promesse.
|
|
562
|
+
*/
|
|
563
|
+
_handleRedirect(actionResult, redirectMeta) {
|
|
425
564
|
if (!redirectMeta) return this.returnController(actionResult);
|
|
426
|
-
|
|
565
|
+
return thenMaybe(actionResult, (resolved) => this._redirectOrReturn(resolved, redirectMeta));
|
|
566
|
+
}
|
|
567
|
+
/** Suite de {@link _handleRedirect}, la valeur de l'action connue. */
|
|
568
|
+
_redirectOrReturn(resolved, redirectMeta) {
|
|
427
569
|
if (resolved !== null && resolved !== void 0 && typeof resolved === "object" && "url" in resolved) {
|
|
428
570
|
const override = resolved;
|
|
429
571
|
this.context.redirect(override.url, override.statusCode ?? redirectMeta.statusCode);
|
|
@@ -435,7 +577,21 @@ var Resolver = class {
|
|
|
435
577
|
}
|
|
436
578
|
return this.returnController(resolved);
|
|
437
579
|
}
|
|
438
|
-
|
|
580
|
+
/**
|
|
581
|
+
* Rend la valeur d'une action sur le transport, selon sa nature : promesse
|
|
582
|
+
* (démêlée puis re-rendue), texte, Buffer, réponse déjà construite, scalaire
|
|
583
|
+
* ou objet (auto-JSON), rien (l'action envoie elle-même).
|
|
584
|
+
*
|
|
585
|
+
* Pas `async` : l'envelopper dans une promesse de plus ne changeait rien au
|
|
586
|
+
* rendu, et coûtait une suspension à chaque requête (#505). La valeur n'est
|
|
587
|
+
* attendue que si c'en est une.
|
|
588
|
+
*
|
|
589
|
+
* @param result - la valeur rendue par l'action.
|
|
590
|
+
* @returns ce que rend l'envoi (souvent sa promesse), ou `undefined` quand il
|
|
591
|
+
* n'y a rien à envoyer.
|
|
592
|
+
* @throws de façon SYNCHRONE ce que lève un envoi synchrone.
|
|
593
|
+
*/
|
|
594
|
+
returnController(result) {
|
|
439
595
|
const type = typeOf(result);
|
|
440
596
|
switch (true) {
|
|
441
597
|
case result instanceof Promise:
|
|
@@ -177,7 +177,7 @@ var Route = class Route {
|
|
|
177
177
|
this.method = obj.method;
|
|
178
178
|
this.setHostname(obj.host);
|
|
179
179
|
this.setDefaults(obj.defaults);
|
|
180
|
-
this.requirements = obj.requirements
|
|
180
|
+
this.requirements = obj.requirements ?? {};
|
|
181
181
|
this.bypassFirewall = obj.bypassFirewall ?? false;
|
|
182
182
|
this.areaRoleExempt = obj.areaRoleExempt ?? false;
|
|
183
183
|
this.compile();
|
|
@@ -206,7 +206,7 @@ var Route = class Route {
|
|
|
206
206
|
match(context, cleanPath, methodOverride) {
|
|
207
207
|
let res;
|
|
208
208
|
if (context.request && this.pattern) {
|
|
209
|
-
const url = cleanPath
|
|
209
|
+
const url = cleanPath ?? Route.cleanPathname(context);
|
|
210
210
|
if (url !== void 0) res = url.match(this.pattern);
|
|
211
211
|
}
|
|
212
212
|
if (!res) return res;
|
|
@@ -228,7 +228,7 @@ var Route = class Route {
|
|
|
228
228
|
else throw { BreakException: `Requirement Routing config Exception variable : ${k} must be RegExp or string : ${typeOf(req)}` };
|
|
229
229
|
}
|
|
230
230
|
result = compiled.test(param ?? "");
|
|
231
|
-
if (!result) throw { BreakException: `Requirement Exception variable : ${k} ==> ${param} doesn't match with ${String(
|
|
231
|
+
if (!result) throw { BreakException: `Requirement Exception variable : ${k} ==> ${param} doesn't match with ${typeof req === "string" ? req : String(compiled)}` };
|
|
232
232
|
}
|
|
233
233
|
const index = map.push(param);
|
|
234
234
|
map[k] = map[index - 1];
|
|
@@ -237,7 +237,7 @@ var Route = class Route {
|
|
|
237
237
|
if (e && typeof e === "object" && "BreakException" in e) throw e.BreakException;
|
|
238
238
|
throw e;
|
|
239
239
|
}
|
|
240
|
-
if (map
|
|
240
|
+
if (map.wildcard) map["*"] = map.wildcard;
|
|
241
241
|
return map;
|
|
242
242
|
}
|
|
243
243
|
/**
|
|
@@ -264,11 +264,12 @@ var Route = class Route {
|
|
|
264
264
|
REG_ROUTE.lastIndex = 0;
|
|
265
265
|
let found;
|
|
266
266
|
while ((found = REG_ROUTE.exec(this.path)) !== null) {
|
|
267
|
+
const [match, slash, dot, key = "", capture, opt] = found;
|
|
267
268
|
const literal = this.path.slice(from, found.index);
|
|
268
269
|
this.#collectUnreachable(literal);
|
|
269
270
|
pattern += compileLiteral(literal, wildcard);
|
|
270
|
-
pattern += replaceCallback.call(this,
|
|
271
|
-
from = found.index +
|
|
271
|
+
pattern += replaceCallback.call(this, match, slash, dot, key, capture, opt, found.index);
|
|
272
|
+
from = found.index + match.length;
|
|
272
273
|
}
|
|
273
274
|
const tail = this.path.slice(from);
|
|
274
275
|
this.#collectUnreachable(tail);
|
|
@@ -290,13 +291,12 @@ var Route = class Route {
|
|
|
290
291
|
this.methodsSet = void 0;
|
|
291
292
|
this.methodsAllow = void 0;
|
|
292
293
|
this.varRegexp = void 0;
|
|
293
|
-
const methods = this.requirements
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const list = methods.map((m) => String(m).toUpperCase());
|
|
294
|
+
const methods = this.requirements.methods;
|
|
295
|
+
let list;
|
|
296
|
+
if (typeof methods === "string") list = methods.replace(/\s/g, "").toUpperCase().split(",");
|
|
297
|
+
else if (Array.isArray(methods)) list = methods.map((m) => m.toUpperCase());
|
|
298
|
+
if (list) {
|
|
299
|
+
if (list.includes("GET") && !list.includes("HEAD")) list.push("HEAD");
|
|
300
300
|
this.methodsSet = new Set(list);
|
|
301
301
|
this.methodsAllow = list.join(",");
|
|
302
302
|
}
|
|
@@ -308,11 +308,13 @@ var Route = class Route {
|
|
|
308
308
|
}
|
|
309
309
|
}
|
|
310
310
|
hydrateDefaultParameters(res) {
|
|
311
|
-
if (this.variables.length) {
|
|
312
|
-
|
|
313
|
-
|
|
311
|
+
if (this.variables.length) for (let i = 0; i < this.variables.length; i++) {
|
|
312
|
+
const name = this.variables[i];
|
|
313
|
+
if (name !== void 0 && this.defaults[name]) {
|
|
314
|
+
if (res[i + 1] === "") res[i + 1] = this.defaults[name];
|
|
314
315
|
}
|
|
315
|
-
}
|
|
316
|
+
}
|
|
317
|
+
else for (const def in this.defaults) switch (def) {
|
|
316
318
|
case "controller": continue;
|
|
317
319
|
default: res.push(this.defaults[def]);
|
|
318
320
|
}
|
|
@@ -336,8 +338,7 @@ var Route = class Route {
|
|
|
336
338
|
* `this.module` à `onBoot` — appel via le décorateur `@controllers` du module.
|
|
337
339
|
*/
|
|
338
340
|
toLogLine() {
|
|
339
|
-
const
|
|
340
|
-
const method = `[${String(m)}]`.padEnd(10);
|
|
341
|
+
const method = `[${Array.isArray(this.requirements.methods) ? this.requirements.methods.join("|") : this.requirements.methods || this.method || "ANY"}]`.padEnd(10);
|
|
341
342
|
const ctrl = this.controller?.name || "?";
|
|
342
343
|
const action = this.classMethod || this.name;
|
|
343
344
|
const mod = this.module?.name ? `@${this.module.name}/` : "";
|
|
@@ -388,14 +389,14 @@ var Route = class Route {
|
|
|
388
389
|
compileHost() {
|
|
389
390
|
const patterns = [];
|
|
390
391
|
if (this.host) patterns.push(this.host);
|
|
391
|
-
const reqDomain = this.requirements
|
|
392
|
+
const reqDomain = this.requirements.domain;
|
|
392
393
|
if (reqDomain) patterns.push(reqDomain);
|
|
393
394
|
this.hostRegexp = patterns.length ? compileDomainPatterns(patterns.flat()) : void 0;
|
|
394
395
|
}
|
|
395
396
|
matchHostname(context) {
|
|
396
397
|
if (this.hostRegexp) {
|
|
397
398
|
if (isDomainAllowed(this.hostRegexp, context.domain)) return true;
|
|
398
|
-
const error = new HttpError(`Domain ${context.domain}
|
|
399
|
+
const error = new HttpError(`Domain ${context.domain} Forbidden for this route`);
|
|
399
400
|
error.code = 403;
|
|
400
401
|
error.type = "domain";
|
|
401
402
|
throw error;
|
|
@@ -422,7 +423,7 @@ var Route = class Route {
|
|
|
422
423
|
return this.hash;
|
|
423
424
|
}
|
|
424
425
|
addRequirement(key, value) {
|
|
425
|
-
if (
|
|
426
|
+
if (value) {
|
|
426
427
|
this.requirements[key] = value;
|
|
427
428
|
this.compileRequirements();
|
|
428
429
|
return value;
|
|
@@ -437,10 +438,10 @@ var Route = class Route {
|
|
|
437
438
|
matchRequirements(context, methodOverride) {
|
|
438
439
|
if (this.hasRequirements()) for (const i in this.requirements) switch (i) {
|
|
439
440
|
case "methods":
|
|
440
|
-
if (!this.methodsSet) throw new Error(`Bad config route method : ${this.requirements
|
|
441
|
+
if (!this.methodsSet) throw new Error(`Bad config route method : ${String(this.requirements.methods)}`);
|
|
441
442
|
if (methodOverride !== void 0) {
|
|
442
443
|
if (!this.methodsSet.has("WEBSOCKET") || !this.methodsSet.has(methodOverride)) {
|
|
443
|
-
const error = new HttpError(`Method ${methodOverride}
|
|
444
|
+
const error = new HttpError(`Method ${methodOverride} Not Allowed`);
|
|
444
445
|
error.code = 405;
|
|
445
446
|
error.type = "method";
|
|
446
447
|
error.allow = this.methodsAllow ?? "";
|
|
@@ -449,7 +450,7 @@ var Route = class Route {
|
|
|
449
450
|
break;
|
|
450
451
|
}
|
|
451
452
|
if (!this.methodsSet.has(context.method)) {
|
|
452
|
-
const error = new HttpError(`Method ${context.method}
|
|
453
|
+
const error = new HttpError(`Method ${context.method} Not Allowed`);
|
|
453
454
|
error.code = 405;
|
|
454
455
|
error.type = "method";
|
|
455
456
|
error.allow = this.methodsAllow ?? "";
|
|
@@ -457,23 +458,22 @@ var Route = class Route {
|
|
|
457
458
|
}
|
|
458
459
|
break;
|
|
459
460
|
case "domain": break;
|
|
460
|
-
case "protocol":
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
const error = new HttpError(`Protocol ${context.acceptedProtocol} Unauthorized`);
|
|
467
|
-
error.code = 1002;
|
|
468
|
-
error.type = "protocol";
|
|
469
|
-
throw error;
|
|
470
|
-
}
|
|
471
|
-
} else {
|
|
472
|
-
const error = new HttpError(`Protocol ${context.acceptedProtocol} Unauthorized`);
|
|
461
|
+
case "protocol": if (context.method === "WEBSOCKET") {
|
|
462
|
+
const requirement = this.requirements[i];
|
|
463
|
+
if (!requirement) return true;
|
|
464
|
+
if (typeof requirement === "string") {
|
|
465
|
+
if (context.acceptedProtocol !== requirement) {
|
|
466
|
+
const error = new HttpError(`Protocol ${context.acceptedProtocol} Not Supported`);
|
|
473
467
|
error.code = 1002;
|
|
474
468
|
error.type = "protocol";
|
|
475
469
|
throw error;
|
|
476
470
|
}
|
|
471
|
+
} else {
|
|
472
|
+
const error = new HttpError(`Protocol ${context.acceptedProtocol} Not Supported`);
|
|
473
|
+
error.code = 1002;
|
|
474
|
+
error.type = "protocol";
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
477
|
}
|
|
478
478
|
}
|
|
479
479
|
return true;
|
|
@@ -36,7 +36,7 @@ function createSyslogAdminApi(syslog, options = {}) {
|
|
|
36
36
|
/** Lit un entier de query (`?limit=50`), borné, avec défaut. */
|
|
37
37
|
const intParam = (req, key, def, max) => {
|
|
38
38
|
const raw = req.query[key];
|
|
39
|
-
const v = Array.isArray(raw) ? raw
|
|
39
|
+
const v = Array.isArray(raw) ? raw.at(0) : raw;
|
|
40
40
|
const n = v !== void 0 ? Number.parseInt(v, 10) : NaN;
|
|
41
41
|
if (Number.isNaN(n) || n <= 0) return def;
|
|
42
42
|
return Math.min(n, max);
|
|
@@ -176,7 +176,7 @@ function createSyslogAdminApi(syslog, options = {}) {
|
|
|
176
176
|
driver: driver?.name ?? wanted ?? null
|
|
177
177
|
}
|
|
178
178
|
};
|
|
179
|
-
return
|
|
179
|
+
return driver.query(buildCriteria(request, ["driver", "scope"]));
|
|
180
180
|
}
|
|
181
181
|
},
|
|
182
182
|
{
|
|
@@ -233,7 +233,7 @@ function createSyslogAdminApi(syslog, options = {}) {
|
|
|
233
233
|
local: "destination locale (pas de réseau)"
|
|
234
234
|
}
|
|
235
235
|
};
|
|
236
|
-
return
|
|
236
|
+
return driver.probe();
|
|
237
237
|
}
|
|
238
238
|
},
|
|
239
239
|
{
|
|
@@ -307,7 +307,7 @@ function createSyslogAdminApi(syslog, options = {}) {
|
|
|
307
307
|
status: 400,
|
|
308
308
|
body: { error: "missing enabled (boolean)" }
|
|
309
309
|
};
|
|
310
|
-
if (body.enabled
|
|
310
|
+
if (!body.enabled) syslog.log(`sink texte « ${Syslog.logSinkName} » coupé (console/fichier)`, "NOTICE", "LOG-BACKPLANE");
|
|
311
311
|
const changed = Syslog.setSinkEnabled(body.enabled);
|
|
312
312
|
if (changed && body.enabled) syslog.log("sink texte rétabli", "NOTICE", "LOG-BACKPLANE");
|
|
313
313
|
return {
|