@nodefony/framework 10.0.0-alpha.9 → 10.0.0-beta.2
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 +5 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +4 -4
- package/dist/nodefony/controller/AdminApiController.js +4 -3
- package/dist/nodefony/controller/ApiKeyController.js +3 -2
- package/dist/nodefony/controller/OAuth2Controller.js +11 -6
- package/dist/nodefony/controller/SessionAuthController.js +3 -3
- package/dist/nodefony/controller/TokenAuthController.js +4 -4
- package/dist/nodefony/controller/TotpController.js +7 -5
- package/dist/nodefony/controller/WebAuthnController.js +4 -4
- package/dist/nodefony/decorators/routerDecorators.js +41 -35
- package/dist/nodefony/service/AdminBroker.js +2 -2
- package/dist/nodefony/service/IdempotencyStore.js +4 -5
- package/dist/nodefony/service/router.js +78 -26
- 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 +221 -65
- package/dist/nodefony/src/Route.js +62 -39
- package/dist/nodefony/src/SyslogAdminApi.js +4 -4
- package/dist/nodefony/src/docsReader.js +49 -39
- package/dist/nodefony/src/scopeCatalog.js +1 -1
- package/dist/nodefony/src/singletonGuard.js +76 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -1
- 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/controller/TokenAuthController.d.ts +2 -2
- package/dist/types/nodefony/controller/TotpController.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 +155 -16
- package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
- package/dist/types/nodefony/src/Route.d.ts +39 -23
- package/dist/types/nodefony/src/docsReader.d.ts +19 -9
- package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
- package/docs/admin.md +1 -1
- package/docs/controller.md +139 -121
- package/docs/decorateurs.md +102 -98
- package/docs/idempotence.md +23 -21
- package/docs/index.md +1 -1
- package/docs/routing.md +60 -53
- package/docs/templates.md +14 -14
- package/package.json +10 -11
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import Route from "../src/Route.js";
|
|
2
2
|
import { routeExpectsBodyStream } from "../decorators/routerDecorators.js";
|
|
3
3
|
import Resolver from "../src/Resolver.js";
|
|
4
|
-
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.
|
|
5
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
6
|
-
import { Module, Service, injectable } from "nodefony";
|
|
4
|
+
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
|
|
5
|
+
import __decorate from "../../_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
|
|
6
|
+
import { Injector, Module, Service, injectable, isPromise } from "nodefony";
|
|
7
7
|
import { HttpError, isDomainAllowed } from "@nodefony/http";
|
|
8
8
|
//#region nodefony/service/router.ts
|
|
9
9
|
var _Router;
|
|
10
10
|
let routeNoticePromoted = null;
|
|
11
|
+
/**
|
|
12
|
+
* Méthodes que sert une route — lues sur le `methodsSet` COMPILÉ par
|
|
13
|
+
* `Route.compileRequirements`, jamais recalculées depuis la config brute.
|
|
14
|
+
* Deux lectures de la même règle avaient divergé : la route acceptait HEAD
|
|
15
|
+
* (implicite sur GET, RFC 9110 §9.1) quand le `Allow` du 405 l'omettait.
|
|
16
|
+
*/
|
|
11
17
|
function collectSupportedMethods(route) {
|
|
12
|
-
|
|
13
|
-
const m = route.requirements?.methods;
|
|
14
|
-
if (typeof m === "string") m.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean).forEach((x) => set.add(x));
|
|
15
|
-
else if (Array.isArray(m)) m.map((s) => String(s).toUpperCase()).forEach((x) => set.add(x));
|
|
16
|
-
return set;
|
|
18
|
+
return route.methodsSet ?? NO_METHODS;
|
|
17
19
|
}
|
|
20
|
+
const NO_METHODS = /* @__PURE__ */ new Set();
|
|
18
21
|
const routes = [];
|
|
19
22
|
const serviceName = "router";
|
|
20
23
|
const REG_NON_LITERAL = /[{}*+?()[\]^$|\\]/;
|
|
@@ -49,8 +52,7 @@ function invalidateRouteIndex() {
|
|
|
49
52
|
function buildRouteIndex() {
|
|
50
53
|
const statics = /* @__PURE__ */ new Map();
|
|
51
54
|
const dynamics = [];
|
|
52
|
-
for (
|
|
53
|
-
const route = routes[i];
|
|
55
|
+
for (const [i, route] of routes.entries()) {
|
|
54
56
|
const path = route.path;
|
|
55
57
|
if (path !== void 0 && route.variables.length === 0 && !REG_NON_LITERAL.test(path)) {
|
|
56
58
|
const key = path.toLowerCase();
|
|
@@ -90,23 +92,39 @@ let Router = class Router extends Service {
|
|
|
90
92
|
}
|
|
91
93
|
/**
|
|
92
94
|
* Retourne l'instance singleton d'une classe controller `@Scope("singleton")`,
|
|
93
|
-
* en la créant au premier appel via `create`.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
95
|
+
* en la créant au premier appel via `create`.
|
|
96
|
+
*
|
|
97
|
+
* Tant que la création est en cours (`initialize()` async), c'est la
|
|
98
|
+
* **promesse** qui est en cache : N requêtes concurrentes attendent le MÊME
|
|
99
|
+
* travail — jamais deux instances (race de création éliminée
|
|
100
|
+
* structurellement). Dès que l'instance est prête, elle REMPLACE la promesse :
|
|
101
|
+
* les requêtes suivantes la reçoivent directement, au lieu d'attendre à
|
|
102
|
+
* chaque fois une promesse déjà tenue (#505).
|
|
97
103
|
*
|
|
98
104
|
* @param ctor - la classe controller (clé du cache).
|
|
99
105
|
* @param create - fabrique exécutée une seule fois (instantiate + initialize).
|
|
100
|
-
* @returns
|
|
106
|
+
* @returns l'instance partagée quand elle existe — ou que `create` la rend
|
|
107
|
+
* sans rien attendre —, sinon la promesse de sa création.
|
|
108
|
+
* @throws ce que `create` lève de façon synchrone ; rien n'est alors mis en
|
|
109
|
+
* cache, la requête suivante recrée.
|
|
101
110
|
*/
|
|
102
111
|
getSingletonController(ctor, create) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
this.singletonControllers ??= /* @__PURE__ */ new Map();
|
|
113
|
+
const cached = this.singletonControllers.get(ctor);
|
|
114
|
+
if (cached !== void 0) return cached;
|
|
115
|
+
const created = create();
|
|
116
|
+
if (!isPromise(created)) {
|
|
117
|
+
this.singletonControllers.set(ctor, created);
|
|
118
|
+
return created;
|
|
108
119
|
}
|
|
109
|
-
|
|
120
|
+
const pending = Promise.resolve(created);
|
|
121
|
+
this.singletonControllers.set(ctor, pending);
|
|
122
|
+
pending.then((instance) => {
|
|
123
|
+
if (this.singletonControllers?.get(ctor) === pending) this.singletonControllers.set(ctor, instance);
|
|
124
|
+
}, () => {
|
|
125
|
+
if (this.singletonControllers?.get(ctor) === pending) this.singletonControllers.delete(ctor);
|
|
126
|
+
});
|
|
127
|
+
return pending;
|
|
110
128
|
}
|
|
111
129
|
/**
|
|
112
130
|
* Résout une route pour un contexte donné.
|
|
@@ -138,22 +156,29 @@ let Router = class Router extends Service {
|
|
|
138
156
|
let di = 0;
|
|
139
157
|
while (li < litCount || di < dynCount) {
|
|
140
158
|
let route;
|
|
141
|
-
|
|
159
|
+
let fromLiterals = false;
|
|
160
|
+
const literal = literals[li];
|
|
161
|
+
const candidate = dynamics[di];
|
|
162
|
+
if (literal !== void 0 && (candidate === void 0 || literal.pos < candidate.pos)) {
|
|
163
|
+
li++;
|
|
164
|
+
route = literal.route;
|
|
165
|
+
fromLiterals = true;
|
|
166
|
+
} else if (candidate === void 0) break;
|
|
142
167
|
else {
|
|
143
|
-
|
|
168
|
+
di++;
|
|
144
169
|
if (candidate.prefix !== "" && cleanPathLower !== void 0 && !cleanPathLower.startsWith(candidate.prefix)) continue;
|
|
145
170
|
route = candidate.route;
|
|
146
171
|
}
|
|
147
172
|
try {
|
|
148
|
-
if (resolver.match(route, context, cleanPath)) {
|
|
149
|
-
|
|
173
|
+
if (resolver.match(route, context, cleanPath, fromLiterals)) {
|
|
174
|
+
routeNoticePromoted ??= this.kernel?.environment !== "production";
|
|
150
175
|
if (routeNoticePromoted) this.log(`Match route : ${route.name}`, "NOTICE");
|
|
151
176
|
resolver.exception = void 0;
|
|
152
177
|
routeExpectsBodyStream(route);
|
|
153
178
|
return resolver;
|
|
154
179
|
}
|
|
155
180
|
} catch (e) {
|
|
156
|
-
this.log(`Match route exception : ${route.name} ${e}`, "DEBUG");
|
|
181
|
+
this.log(`Match route exception : ${route.name} ${String(e)}`, "DEBUG");
|
|
157
182
|
resolver.exception = e;
|
|
158
183
|
continue;
|
|
159
184
|
}
|
|
@@ -165,6 +190,14 @@ let Router = class Router extends Service {
|
|
|
165
190
|
const servesDomain = !route.hostRegexp || isDomainAllowed(route.hostRegexp, context.domain);
|
|
166
191
|
if (route.pattern && route.pattern.test(path) && servesDomain) collectSupportedMethods(route).forEach((x) => allowed.add(x));
|
|
167
192
|
}
|
|
193
|
+
const servesWebsocket = allowed.delete("WEBSOCKET");
|
|
194
|
+
if (allowed.size === 0 && servesWebsocket) {
|
|
195
|
+
context.response?.setHeaders({
|
|
196
|
+
Upgrade: "websocket",
|
|
197
|
+
Connection: "Upgrade"
|
|
198
|
+
});
|
|
199
|
+
throw new HttpError("Upgrade Required: websocket", 426, context);
|
|
200
|
+
}
|
|
168
201
|
if (allowed.size > 0) {
|
|
169
202
|
const allowHeader = Array.from(allowed).join(", ");
|
|
170
203
|
const err = new HttpError(`Method ${context.method} Not Allowed`, 405, context);
|
|
@@ -219,7 +252,26 @@ let Router = class Router extends Service {
|
|
|
219
252
|
invalidateRouteIndex();
|
|
220
253
|
return routenew;
|
|
221
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Enregistre une classe contrôleur pour un module — l'entonnoir UNIQUE : le
|
|
257
|
+
* décorateur `@controllers` y passe, comme les contrôleurs internes que le
|
|
258
|
+
* framework enregistre directement (plan d'administration, OAuth,
|
|
259
|
+
* WebAuthn…).
|
|
260
|
+
*
|
|
261
|
+
* C'est donc ICI que se refusent, au démarrage, les fautes de déclaration
|
|
262
|
+
* qu'un contrôleur ne révélerait qu'à sa première requête : une dépendance
|
|
263
|
+
* captive (un singleton qui détient un service de portée `request`), et une
|
|
264
|
+
* portée que sa classe de base n'admet pas (`Controller.assertScope`).
|
|
265
|
+
*
|
|
266
|
+
* @param myconstructor - la classe contrôleur.
|
|
267
|
+
* @param module - le module qui la porte.
|
|
268
|
+
* @returns la classe, rangée sous `module:Classe`.
|
|
269
|
+
* @throws BootConfigurationError sur une dépendance captive ou une portée
|
|
270
|
+
* refusée — fatale dans tous les environnements.
|
|
271
|
+
*/
|
|
222
272
|
static setController(myconstructor, module) {
|
|
273
|
+
Injector.assertNoCaptiveDependency(myconstructor);
|
|
274
|
+
myconstructor.assertScope?.();
|
|
223
275
|
Object.defineProperty(myconstructor.prototype, "module", {
|
|
224
276
|
value: module,
|
|
225
277
|
writable: false
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDevelopment, singletonWriteMessage } from "./singletonGuard.js";
|
|
1
2
|
import { FileClass, RequestContext, Service } from "nodefony";
|
|
2
3
|
import { HttpError } from "@nodefony/http";
|
|
3
4
|
import fs, { createReadStream } from "node:fs";
|
|
@@ -48,12 +49,12 @@ function cspExigeUnNonce(csp) {
|
|
|
48
49
|
function parseByteRange(range, length) {
|
|
49
50
|
const unit = /^\s*bytes\s*=\s*(.+)$/i.exec(range);
|
|
50
51
|
if (!unit) return null;
|
|
51
|
-
const
|
|
52
|
+
const [, rawSpec = ""] = unit;
|
|
53
|
+
const spec = rawSpec.trim();
|
|
52
54
|
if (spec.includes(",")) return null;
|
|
53
55
|
const parts = /^(\d*)-(\d*)$/.exec(spec);
|
|
54
56
|
if (!parts) return null;
|
|
55
|
-
const first =
|
|
56
|
-
const last = parts[2] ?? "";
|
|
57
|
+
const [, first = "", last = ""] = parts;
|
|
57
58
|
if (first === "" && last === "") return null;
|
|
58
59
|
if (first === "") {
|
|
59
60
|
const suffix = parseInt(last, 10);
|
|
@@ -71,14 +72,37 @@ function parseByteRange(range, length) {
|
|
|
71
72
|
end: last === "" ? length - 1 : Math.min(parseInt(last, 10), length - 1)
|
|
72
73
|
};
|
|
73
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Garantit une `Error` comme motif de rejet : une valeur levée par un tiers
|
|
77
|
+
* (`fs`, flux) peut être n'importe quoi.
|
|
78
|
+
*
|
|
79
|
+
* @param e - valeur interceptée.
|
|
80
|
+
* @returns `e` si c'est déjà une `Error`, sinon une `Error` qui en porte le texte.
|
|
81
|
+
*/
|
|
82
|
+
function asError(e) {
|
|
83
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
84
|
+
}
|
|
74
85
|
var Controller = class extends Service {
|
|
75
86
|
static prefix = "/";
|
|
76
87
|
/**
|
|
77
|
-
*
|
|
78
|
-
* posé par le décorateur `@Scope` (statique hérité, lu via
|
|
79
|
-
*
|
|
88
|
+
* Portée d'instanciation de la classe — `"singleton"` par défaut,
|
|
89
|
+
* `"request"` posé par le décorateur `@Scope` (statique hérité, lu via
|
|
90
|
+
* `new.target` au constructeur et par le Resolver : 0 Reflect).
|
|
91
|
+
* Cf {@link ControllerScope}.
|
|
80
92
|
*/
|
|
81
|
-
static scope = "
|
|
93
|
+
static scope = "singleton";
|
|
94
|
+
/**
|
|
95
|
+
* Vérifie que la portée de la classe est admise par sa lignée. Appelée à
|
|
96
|
+
* l'enregistrement du contrôleur (`Router.setController`), donc au
|
|
97
|
+
* démarrage.
|
|
98
|
+
*
|
|
99
|
+
* N'impose rien par défaut. Une classe de base qui n'admet qu'une portée la
|
|
100
|
+
* fait respecter ici — `RealtimeController`, lié à SA connexion par
|
|
101
|
+
* construction, refuse le singleton.
|
|
102
|
+
*
|
|
103
|
+
* @throws BootConfigurationError quand la portée déclarée est refusée.
|
|
104
|
+
*/
|
|
105
|
+
static assertScope() {}
|
|
82
106
|
#context = null;
|
|
83
107
|
#route = null;
|
|
84
108
|
#request = null;
|
|
@@ -91,66 +115,103 @@ var Controller = class extends Service {
|
|
|
91
115
|
module;
|
|
92
116
|
template;
|
|
93
117
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
118
|
+
* Refuse d'écrire un état de requête sur un contrôleur singleton : la valeur
|
|
119
|
+
* serait lue par la requête suivante. Coût nul sur le chemin chaud — ces
|
|
120
|
+
* accesseurs n'y sont jamais écrits.
|
|
121
|
+
*
|
|
122
|
+
* @param member - le membre écrit, pour le message.
|
|
123
|
+
* @throws TypeError qui nomme le contrôleur, le membre et le remède.
|
|
124
|
+
*/
|
|
125
|
+
#assertPerRequest(member) {
|
|
126
|
+
const ctor = this.constructor;
|
|
127
|
+
if (ctor.scope === "singleton") throw new TypeError(singletonWriteMessage(ctor.name, member));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Contexte transport courant. Par requête : champ posé par `setContext`
|
|
131
|
+
* (constructeur). Singleton : champ jamais posé → lecture de l'ALS
|
|
132
|
+
* `RequestContext`, où le `HttpKernel` place le contexte à l'entrée de la
|
|
133
|
+
* requête — chaque helper retrouve LA requête en cours, jamais une requête
|
|
134
|
+
* concurrente.
|
|
99
135
|
*/
|
|
100
136
|
get context() {
|
|
101
137
|
return this.#context ?? RequestContext.getContext();
|
|
102
138
|
}
|
|
103
139
|
set context(context) {
|
|
140
|
+
this.#assertPerRequest("context");
|
|
104
141
|
this.#context = context ?? null;
|
|
105
142
|
}
|
|
106
143
|
/**
|
|
107
|
-
* Route
|
|
108
|
-
*
|
|
109
|
-
*
|
|
144
|
+
* Route de l'appel en cours. Par requête : posée par le Resolver
|
|
145
|
+
* (`setRoute`). Singleton : celle du résolveur de l'APPEL quand le pont
|
|
146
|
+
* `api.request` en a posé un dans l'ALS (clé `resolver` — un résolveur par
|
|
147
|
+
* appel, jamais rangé sur le contexte de la connexion), sinon celle du
|
|
148
|
+
* contexte.
|
|
110
149
|
*/
|
|
111
150
|
get route() {
|
|
112
|
-
|
|
151
|
+
if (this.#route !== null) return this.#route;
|
|
152
|
+
if (this.#context !== null) return this.#context.resolver?.route ?? null;
|
|
153
|
+
const store = RequestContext.get();
|
|
154
|
+
return (store?.resolver)?.route ?? (store?.context)?.resolver?.route ?? null;
|
|
113
155
|
}
|
|
114
156
|
get request() {
|
|
115
157
|
return this.#request ?? this.context?.request ?? null;
|
|
116
158
|
}
|
|
117
159
|
set request(request) {
|
|
160
|
+
this.#assertPerRequest("request");
|
|
118
161
|
this.#request = request;
|
|
119
162
|
}
|
|
120
163
|
get response() {
|
|
121
164
|
return this.#response ?? this.context?.response ?? null;
|
|
122
165
|
}
|
|
123
166
|
set response(response) {
|
|
167
|
+
this.#assertPerRequest("response");
|
|
124
168
|
this.#response = response;
|
|
125
169
|
}
|
|
126
170
|
get method() {
|
|
127
171
|
return this.#method ?? this.context?.method ?? void 0;
|
|
128
172
|
}
|
|
129
173
|
set method(method) {
|
|
174
|
+
this.#assertPerRequest("method");
|
|
130
175
|
this.#method = method ?? null;
|
|
131
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Query string de l'appel en cours — pour un singleton invoqué par le pont
|
|
179
|
+
* `api.request`, celle du chemin INVOQUÉ (résolveur d'appel dans l'ALS), pas
|
|
180
|
+
* celle du handshake de la connexion. Une seule lecture de l'ALS.
|
|
181
|
+
*/
|
|
132
182
|
get queryGet() {
|
|
133
|
-
|
|
183
|
+
if (this.#queryGet !== null) return this.#queryGet;
|
|
184
|
+
if (this.#context !== null) return this.#context.request?.queryGet;
|
|
185
|
+
const store = RequestContext.get();
|
|
186
|
+
return (store?.resolver)?.queryOverride ?? ((store?.context)?.request)?.queryGet;
|
|
134
187
|
}
|
|
135
188
|
set queryGet(value) {
|
|
189
|
+
this.#assertPerRequest("queryGet");
|
|
136
190
|
this.#queryGet = value;
|
|
137
191
|
}
|
|
192
|
+
/** Comme {@link queryGet}, pour la query fusionnée (`query`). */
|
|
138
193
|
get query() {
|
|
139
|
-
|
|
194
|
+
if (this.#query !== null) return this.#query;
|
|
195
|
+
if (this.#context !== null) return this.#context.request?.query;
|
|
196
|
+
const store = RequestContext.get();
|
|
197
|
+
return (store?.resolver)?.queryOverride ?? ((store?.context)?.request)?.query;
|
|
140
198
|
}
|
|
141
199
|
set query(value) {
|
|
200
|
+
this.#assertPerRequest("query");
|
|
142
201
|
this.#query = value;
|
|
143
202
|
}
|
|
144
203
|
get queryFile() {
|
|
145
204
|
return this.#queryFile ?? (this.context?.request)?.queryFile;
|
|
146
205
|
}
|
|
147
206
|
set queryFile(value) {
|
|
207
|
+
this.#assertPerRequest("queryFile");
|
|
148
208
|
this.#queryFile = value;
|
|
149
209
|
}
|
|
150
210
|
get queryPost() {
|
|
151
211
|
return this.#queryPost ?? (this.context?.request)?.queryPost;
|
|
152
212
|
}
|
|
153
213
|
set queryPost(value) {
|
|
214
|
+
this.#assertPerRequest("queryPost");
|
|
154
215
|
this.#queryPost = value;
|
|
155
216
|
}
|
|
156
217
|
/**
|
|
@@ -166,6 +227,7 @@ var Controller = class extends Service {
|
|
|
166
227
|
return this.queryPost;
|
|
167
228
|
}
|
|
168
229
|
set body(value) {
|
|
230
|
+
this.#assertPerRequest("body");
|
|
169
231
|
this.queryPost = value;
|
|
170
232
|
}
|
|
171
233
|
/**
|
|
@@ -184,7 +246,16 @@ var Controller = class extends Service {
|
|
|
184
246
|
this.template = this.get("template");
|
|
185
247
|
if (!singleton) this.setContext(context);
|
|
186
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Lie l'instance au contexte de SA requête — contrôleur par requête
|
|
251
|
+
* seulement : tout l'état de la requête (`request`, `response`, `method`,
|
|
252
|
+
* `query*`) en dérive ensuite, toujours frais.
|
|
253
|
+
*
|
|
254
|
+
* @param context - le contexte de la requête servie par cette instance.
|
|
255
|
+
* @throws TypeError sur un singleton, qui sert toutes les requêtes.
|
|
256
|
+
*/
|
|
187
257
|
setContext(context) {
|
|
258
|
+
this.#assertPerRequest("context");
|
|
188
259
|
this.#context = context;
|
|
189
260
|
}
|
|
190
261
|
setContextJson(encoding = "utf-8") {
|
|
@@ -211,18 +282,36 @@ var Controller = class extends Service {
|
|
|
211
282
|
* @returns rien — cette sonde n'échoue jamais et ne change aucune réponse.
|
|
212
283
|
*/
|
|
213
284
|
#warnScriptWithoutNonce(data) {
|
|
214
|
-
|
|
215
|
-
if (kernel?.environment !== "development" && kernel?.environment !== "dev") return;
|
|
285
|
+
if (!isDevelopment(this.context?.kernel?.environment)) return;
|
|
216
286
|
if (typeof data !== "string" || data.length === 0) return;
|
|
217
287
|
const response = this.response;
|
|
218
288
|
if (typeof response?.getHeader !== "function") return;
|
|
219
289
|
if (!cspExigeUnNonce(response.getHeader("Content-Security-Policy")) || !INLINE_SCRIPT_SANS_NONCE.test(data)) return;
|
|
220
290
|
this.log("Cette réponse porte un <script> EN LIGNE sans attribut `nonce`, et la politique de contenu de cette application exige un nonce sur les scripts : le navigateur REFUSERA de l'exécuter (« Refused to execute inline script »). Deux gestes, au choix :\n • signer le script — `<script nonce=\"${this.context?.cspNonce}\">` (dans une vue Eta : `<script nonce=\"<%= it.nonce %>\">`, le nonce étant passé en donnée depuis `this.context?.cspNonce`) ;\n • sortir le script dans un FICHIER servi par l'application — un `<script src=…>` de même origine satisfait `script-src 'self'` sans nonce.\n Ne desserre PAS la politique (`'unsafe-inline'`) : le détail vit dans `@nodefony/security/docs/headers.md`.", "WARNING", "CSP");
|
|
221
291
|
}
|
|
222
|
-
|
|
292
|
+
/**
|
|
293
|
+
* Rend une donnée sur la réponse de la requête courante (JSON, HTML ou
|
|
294
|
+
* texte selon son type).
|
|
295
|
+
*
|
|
296
|
+
* Synchrone quand l'envoi l'est — réponse unique, sans session à sauver ni
|
|
297
|
+
* hook d'envoi (#505). Un `await` reste valable dans tous les cas ; un
|
|
298
|
+
* `.then` chaîné ne l'est plus : la valeur n'est une promesse que quand
|
|
299
|
+
* l'envoi attend.
|
|
300
|
+
*
|
|
301
|
+
* @returns la réponse, ou sa promesse.
|
|
302
|
+
*/
|
|
303
|
+
render(data, encoding, status, headers) {
|
|
223
304
|
this.#warnScriptWithoutNonce(data);
|
|
224
305
|
return this.context?.render(data, encoding, status, headers);
|
|
225
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Envoie une donnée déjà sérialisée sur le transport de la requête (HTTP ou
|
|
309
|
+
* WebSocket), après statut et en-têtes éventuels.
|
|
310
|
+
*
|
|
311
|
+
* Synchrone quand l'envoi l'est (#505) — cf {@link render}.
|
|
312
|
+
*
|
|
313
|
+
* @returns la réponse, ou sa promesse.
|
|
314
|
+
*/
|
|
226
315
|
renderResponse(data, encoding, status, headers) {
|
|
227
316
|
if (headers) this.response?.setHeaders(headers);
|
|
228
317
|
if (status) this.response?.setStatusCode(status);
|
|
@@ -240,7 +329,7 @@ var Controller = class extends Service {
|
|
|
240
329
|
}
|
|
241
330
|
this.setContextHtml();
|
|
242
331
|
this.#warnScriptWithoutNonce(data);
|
|
243
|
-
return this.renderResponse(data, "utf8", status, headers);
|
|
332
|
+
return await this.renderResponse(data, "utf8", status, headers);
|
|
244
333
|
} catch (e) {
|
|
245
334
|
this.log(e, "ERROR");
|
|
246
335
|
throw e;
|
|
@@ -257,24 +346,38 @@ var Controller = class extends Service {
|
|
|
257
346
|
if (!fe?.renderTags) return param;
|
|
258
347
|
const nonce = this.context?.cspNonce;
|
|
259
348
|
const host = this.context?.domain;
|
|
349
|
+
const renderTags = fe.renderTags;
|
|
350
|
+
const renderDocument = fe.renderDocument;
|
|
260
351
|
return {
|
|
261
|
-
frontendTags: (entry) =>
|
|
262
|
-
frontendDocument: (entry) =>
|
|
352
|
+
frontendTags: (entry) => renderTags(entry, nonce, host),
|
|
353
|
+
frontendDocument: (entry) => {
|
|
354
|
+
if (!renderDocument) throw new Error("frontend service does not expose renderDocument");
|
|
355
|
+
return renderDocument(entry, nonce, host);
|
|
356
|
+
},
|
|
263
357
|
asset: (p) => fe.assetUrl ? fe.assetUrl(p) : p,
|
|
264
358
|
...param
|
|
265
359
|
};
|
|
266
360
|
}
|
|
267
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Sérialise en JSON puis envoie ({@link renderResponse}). Synchrone quand
|
|
363
|
+
* l'envoi l'est (#505).
|
|
364
|
+
*
|
|
365
|
+
* @returns la réponse, ou sa promesse.
|
|
366
|
+
* @throws de façon SYNCHRONE ce que lève `JSON.stringify` (cycle, BigInt).
|
|
367
|
+
*/
|
|
368
|
+
renderJson(obj, status, headers) {
|
|
268
369
|
const data = JSON.stringify(obj);
|
|
269
370
|
this.setContextJson();
|
|
270
371
|
return this.renderResponse(data, "utf8", status, headers);
|
|
271
372
|
}
|
|
272
373
|
setRoute(route) {
|
|
374
|
+
this.#assertPerRequest("route");
|
|
273
375
|
this.#route = route;
|
|
274
376
|
return route;
|
|
275
377
|
}
|
|
276
378
|
getSession() {
|
|
277
|
-
|
|
379
|
+
const session = this.context?.session;
|
|
380
|
+
if (session) return session;
|
|
278
381
|
}
|
|
279
382
|
redirect(url, status, headers) {
|
|
280
383
|
if (!url) throw new Error("Redirect error no url !!!");
|
|
@@ -306,7 +409,7 @@ var Controller = class extends Service {
|
|
|
306
409
|
if (file instanceof FileClass) File = file;
|
|
307
410
|
else if (typeof file === "string") File = new FileClass(file);
|
|
308
411
|
else throw new Error(`File argument bad type for getFile :${typeof file}`);
|
|
309
|
-
if (File.type !== "File") throw new Error(`getFile bad type for :${file}`);
|
|
412
|
+
if (File.type !== "File") throw new Error(`getFile bad type for :${String(file)}`);
|
|
310
413
|
return File;
|
|
311
414
|
}
|
|
312
415
|
/**
|
|
@@ -347,7 +450,7 @@ var Controller = class extends Service {
|
|
|
347
450
|
}
|
|
348
451
|
else throw new Error(`File argument bad type for getFileAsync :${typeof file}`);
|
|
349
452
|
if (File.type !== "File") {
|
|
350
|
-
this.log(`ce chemin n'est pas un fichier : ${file}`, "DEBUG");
|
|
453
|
+
this.log(`ce chemin n'est pas un fichier : ${String(file)}`, "DEBUG");
|
|
351
454
|
throw new HttpError("Not Found", 404, this.context);
|
|
352
455
|
}
|
|
353
456
|
return File;
|
|
@@ -375,12 +478,7 @@ var Controller = class extends Service {
|
|
|
375
478
|
"Content-Type": File.mimeType || "application/octet-stream",
|
|
376
479
|
...headers
|
|
377
480
|
};
|
|
378
|
-
|
|
379
|
-
return this.streamFile(File, head, options);
|
|
380
|
-
} catch (e) {
|
|
381
|
-
this.log(e, "ERROR");
|
|
382
|
-
throw e;
|
|
383
|
-
}
|
|
481
|
+
return this.streamFile(File, head, options);
|
|
384
482
|
}
|
|
385
483
|
/**
|
|
386
484
|
* Envoie un fichier en FLUX, du disque vers la réponse, sans jamais le charger
|
|
@@ -404,73 +502,81 @@ var Controller = class extends Service {
|
|
|
404
502
|
* @throws Si la réponse n'est pas disponible, ou si le fichier est illisible.
|
|
405
503
|
*/
|
|
406
504
|
async streamFile(file, headers, options = {}) {
|
|
505
|
+
const context = this.context;
|
|
407
506
|
if (!this.response) throw new Error(`response not found`);
|
|
408
507
|
const contextResponse = this.response;
|
|
409
508
|
const response = contextResponse.response;
|
|
410
509
|
if (!response) throw new Error(`response not found`);
|
|
411
510
|
options.autoClose = false;
|
|
511
|
+
let streamFile;
|
|
412
512
|
try {
|
|
413
513
|
const fileDetails = await this.getFileAsync(file);
|
|
414
514
|
this.response.response?.removeHeader("Content-Type");
|
|
415
515
|
this.response.response?.removeHeader("content-type");
|
|
416
516
|
if (!(headers && (headers["Content-Type"] || headers["content-type"]))) this.response.setFileMimeType(fileDetails.name);
|
|
417
517
|
if (!(headers && (headers["Content-Length"] || headers["content-length"]))) {
|
|
418
|
-
|
|
518
|
+
headers ??= {};
|
|
419
519
|
this.response.response?.removeHeader("Content-Length");
|
|
420
520
|
this.response.response?.removeHeader("content-length");
|
|
421
521
|
headers["Content-Length"] = fileDetails.stats.size;
|
|
422
522
|
}
|
|
423
|
-
|
|
424
|
-
return new Promise((resolve, reject) => {
|
|
425
|
-
let handled = false;
|
|
426
|
-
const onResponseClose = () => {
|
|
427
|
-
if (!handled) streamFile.destroy();
|
|
428
|
-
};
|
|
429
|
-
response.once("close", onResponseClose);
|
|
430
|
-
streamFile.on("open", () => {
|
|
431
|
-
try {
|
|
432
|
-
this.context?.writeHead(contextResponse?.statusCode, headers);
|
|
433
|
-
streamFile.pipe(response, { end: false });
|
|
434
|
-
} catch (e) {
|
|
435
|
-
this.log(e, "ERROR");
|
|
436
|
-
return reject(e);
|
|
437
|
-
}
|
|
438
|
-
});
|
|
439
|
-
const handleStreamEnd = async () => {
|
|
440
|
-
try {
|
|
441
|
-
if (handled) return;
|
|
442
|
-
handled = true;
|
|
443
|
-
response.removeListener("close", onResponseClose);
|
|
444
|
-
if (streamFile) {
|
|
445
|
-
streamFile.unpipe(response);
|
|
446
|
-
if (streamFile.fd) await fsClose(streamFile.fd).catch((e) => {
|
|
447
|
-
return reject(e);
|
|
448
|
-
});
|
|
449
|
-
if (!this.context?.finished) this.context?.end();
|
|
450
|
-
return resolve(streamFile);
|
|
451
|
-
}
|
|
452
|
-
} catch (e) {
|
|
453
|
-
this.log(e, "ERROR");
|
|
454
|
-
return reject(e);
|
|
455
|
-
}
|
|
456
|
-
};
|
|
457
|
-
streamFile.on("end", handleStreamEnd);
|
|
458
|
-
streamFile.on("close", handleStreamEnd);
|
|
459
|
-
streamFile.on("error", (error) => {
|
|
460
|
-
this.log(error, "ERROR");
|
|
461
|
-
if (!this.context?.finished) this.context?.end();
|
|
462
|
-
return reject(error);
|
|
463
|
-
});
|
|
464
|
-
});
|
|
523
|
+
streamFile = createReadStream(fileDetails.path, options);
|
|
465
524
|
} catch (e) {
|
|
466
525
|
this.log(e, "ERROR");
|
|
467
526
|
throw e;
|
|
468
527
|
}
|
|
528
|
+
return new Promise((resolve, reject) => {
|
|
529
|
+
let handled = false;
|
|
530
|
+
const onResponseClose = () => {
|
|
531
|
+
if (!handled) streamFile.destroy();
|
|
532
|
+
};
|
|
533
|
+
response.once("close", onResponseClose);
|
|
534
|
+
streamFile.on("open", () => {
|
|
535
|
+
try {
|
|
536
|
+
context?.writeHead(contextResponse.statusCode, headers);
|
|
537
|
+
streamFile.pipe(response, { end: false });
|
|
538
|
+
} catch (e) {
|
|
539
|
+
this.log(e, "ERROR");
|
|
540
|
+
reject(asError(e));
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
const endContext = () => {
|
|
544
|
+
if (context && !context.finished) Promise.resolve(context.end()).catch((e) => {
|
|
545
|
+
this.log(e, "ERROR");
|
|
546
|
+
});
|
|
547
|
+
};
|
|
548
|
+
const handleStreamEnd = async () => {
|
|
549
|
+
try {
|
|
550
|
+
if (handled) return;
|
|
551
|
+
handled = true;
|
|
552
|
+
response.removeListener("close", onResponseClose);
|
|
553
|
+
streamFile.unpipe(response);
|
|
554
|
+
if (streamFile.fd) await fsClose(streamFile.fd).catch((e) => {
|
|
555
|
+
reject(asError(e));
|
|
556
|
+
});
|
|
557
|
+
endContext();
|
|
558
|
+
resolve(streamFile);
|
|
559
|
+
} catch (e) {
|
|
560
|
+
this.log(e, "ERROR");
|
|
561
|
+
reject(asError(e));
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
const onStreamEnd = () => {
|
|
565
|
+
handleStreamEnd();
|
|
566
|
+
};
|
|
567
|
+
streamFile.on("end", onStreamEnd);
|
|
568
|
+
streamFile.on("close", onStreamEnd);
|
|
569
|
+
streamFile.on("error", (error) => {
|
|
570
|
+
this.log(error, "ERROR");
|
|
571
|
+
endContext();
|
|
572
|
+
reject(error);
|
|
573
|
+
});
|
|
574
|
+
});
|
|
469
575
|
}
|
|
470
576
|
async renderMediaStream(file, headers = {}, options = {}) {
|
|
471
577
|
const File = await this.getFileAsync(file);
|
|
472
578
|
this.response?.setEncoding("binary");
|
|
473
|
-
const range = this.request?.headers
|
|
579
|
+
const range = this.request?.headers.range;
|
|
474
580
|
const length = File.stats.size;
|
|
475
581
|
let head;
|
|
476
582
|
let value;
|
|
@@ -54,8 +54,8 @@ function readPageCapabilities(caps) {
|
|
|
54
54
|
function createFrameworkAdminApi(broker, opts) {
|
|
55
55
|
/** Normalise `requirements.methods` (string | string[]) → tableau majuscule. */
|
|
56
56
|
const methodsOf = (route) => {
|
|
57
|
-
const m = route.requirements
|
|
58
|
-
if (Array.isArray(m)) return m.map((x) =>
|
|
57
|
+
const m = route.requirements.methods ?? route.method;
|
|
58
|
+
if (Array.isArray(m)) return m.map((x) => x.toUpperCase());
|
|
59
59
|
if (typeof m === "string") return m.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
|
|
60
60
|
return ["ANY"];
|
|
61
61
|
};
|
|
@@ -65,6 +65,7 @@ function createFrameworkAdminApi(broker, opts) {
|
|
|
65
65
|
methods: methodsOf(route),
|
|
66
66
|
controller: route.controller?.name ?? null,
|
|
67
67
|
action: route.classMethod ?? null,
|
|
68
|
+
scope: route.controller?.scope ?? null,
|
|
68
69
|
module: route.module?.name ?? null,
|
|
69
70
|
host: route.host ?? null,
|
|
70
71
|
bypassFirewall: route.bypassFirewall
|
|
@@ -129,7 +130,7 @@ function createFrameworkAdminApi(broker, opts) {
|
|
|
129
130
|
switch (key) {
|
|
130
131
|
case "methods": return r.methods.join(",");
|
|
131
132
|
case "path": return r.path ?? "";
|
|
132
|
-
case "name": return r.name
|
|
133
|
+
case "name": return r.name;
|
|
133
134
|
case "controller": return [r.controller, r.action].filter(Boolean).join(".");
|
|
134
135
|
case "module": return r.module ?? "";
|
|
135
136
|
case "firewall": return r.bypassFirewall ? "bypass" : "protected";
|
|
@@ -146,9 +147,21 @@ function createFrameworkAdminApi(broker, opts) {
|
|
|
146
147
|
* adossé à une ressource persistée déclare un `IFilterSpec` et laisse le cœur
|
|
147
148
|
* valider — il ne recopie pas ce `matchOp`.
|
|
148
149
|
*/
|
|
150
|
+
const filterValueText = (value) => {
|
|
151
|
+
switch (typeof value) {
|
|
152
|
+
case "string": return value;
|
|
153
|
+
case "number":
|
|
154
|
+
case "boolean":
|
|
155
|
+
case "bigint": return String(value);
|
|
156
|
+
case "object": return Array.isArray(value) ? value.map(filterValueText).join(",") : "";
|
|
157
|
+
case "undefined":
|
|
158
|
+
case "symbol":
|
|
159
|
+
case "function": return "";
|
|
160
|
+
}
|
|
161
|
+
};
|
|
149
162
|
const matchOp = (raw, op, value) => {
|
|
150
|
-
const s =
|
|
151
|
-
const v =
|
|
163
|
+
const s = raw;
|
|
164
|
+
const v = filterValueText(value);
|
|
152
165
|
if (op !== "isEmpty" && op !== "notEmpty" && v === "") return true;
|
|
153
166
|
switch (op) {
|
|
154
167
|
case "contains": return s.toLowerCase().includes(v.toLowerCase());
|
|
@@ -270,7 +283,7 @@ function createFrameworkAdminApi(broker, opts) {
|
|
|
270
283
|
icon: d?.icon ?? null,
|
|
271
284
|
order: d?.order ?? 99,
|
|
272
285
|
role: d?.role ?? null,
|
|
273
|
-
endpoints: byNs.get(ns)
|
|
286
|
+
endpoints: byNs.get(ns) ?? []
|
|
274
287
|
};
|
|
275
288
|
}).sort((a, b) => a.order - b.order) };
|
|
276
289
|
}
|