@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.
- package/LICENSE +544 -0
- package/README.md +50 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +211 -0
- package/dist/nodefony/config/config.js +61 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controller/AdminApiController.js +163 -0
- package/dist/nodefony/controller/ApiKeyController.js +151 -0
- package/dist/nodefony/controller/BenchController.js +132 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -0
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
- package/dist/nodefony/controller/SessionAuthController.js +141 -0
- package/dist/nodefony/controller/TokenAuthController.js +113 -0
- package/dist/nodefony/controller/TotpController.js +129 -0
- package/dist/nodefony/controller/WebAuthnController.js +242 -0
- package/dist/nodefony/controller/oauthAuthority.js +74 -0
- package/dist/nodefony/decorators/routerDecorators.js +967 -0
- package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
- package/dist/nodefony/interfaces/IController.js +1 -0
- package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
- package/dist/nodefony/interfaces/IResolver.js +1 -0
- package/dist/nodefony/interfaces/IRoute.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/AdminBroker.js +106 -0
- package/dist/nodefony/service/Eta.js +68 -0
- package/dist/nodefony/service/IdempotencyStore.js +136 -0
- package/dist/nodefony/service/router.js +243 -0
- package/dist/nodefony/src/Controller.js +515 -0
- package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
- package/dist/nodefony/src/KernelAdminApi.js +1243 -0
- package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
- package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
- package/dist/nodefony/src/Resolver.js +416 -0
- package/dist/nodefony/src/ResourceController.js +148 -0
- package/dist/nodefony/src/Route.js +476 -0
- package/dist/nodefony/src/SyslogAdminApi.js +466 -0
- package/dist/nodefony/src/Template.js +15 -0
- package/dist/nodefony/src/configMutation.js +186 -0
- package/dist/nodefony/src/docsReader.js +929 -0
- package/dist/nodefony/src/idempotency.js +137 -0
- package/dist/nodefony/src/idempotencyGc.js +32 -0
- package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
- package/dist/nodefony/src/scopeCatalog.js +40 -0
- package/dist/nodefony/src/syslogFilters.js +51 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/nodefony/config/config.d.ts +41 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
- package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
- package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
- package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
- package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
- package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
- package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
- package/dist/types/nodefony/service/Eta.d.ts +25 -0
- package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
- package/dist/types/nodefony/service/router.d.ts +53 -0
- package/dist/types/nodefony/src/Controller.d.ts +193 -0
- package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
- package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
- package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
- package/dist/types/nodefony/src/Resolver.d.ts +165 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
- package/dist/types/nodefony/src/Route.d.ts +192 -0
- package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
- package/dist/types/nodefony/src/Template.d.ts +8 -0
- package/dist/types/nodefony/src/configMutation.d.ts +109 -0
- package/dist/types/nodefony/src/docsReader.d.ts +369 -0
- package/dist/types/nodefony/src/idempotency.d.ts +96 -0
- package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
- package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
- package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
- package/docs/admin.md +451 -0
- package/docs/controller.md +645 -0
- package/docs/decorateurs.md +845 -0
- package/docs/idempotence.md +741 -0
- package/docs/index.md +151 -0
- package/docs/routing.md +648 -0
- package/docs/templates.md +380 -0
- package/package.json +83 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import __decorate from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
|
|
3
|
+
import router_default from "./router.js";
|
|
4
|
+
import AdminApiController from "../controller/AdminApiController.js";
|
|
5
|
+
import { ADMIN_DEFAULT_ROLE, Module, Service, injectable, resolveAdminRole } from "nodefony";
|
|
6
|
+
//#region nodefony/service/AdminBroker.ts
|
|
7
|
+
const serviceName = "adminBroker";
|
|
8
|
+
/**
|
|
9
|
+
* Implémentation du data plane admin (Studio) — collecte les {@link IAdminApi}
|
|
10
|
+
* et monte `/nodefony/<namespace>/api/*` via le Router.
|
|
11
|
+
*
|
|
12
|
+
* Vit dans `@nodefony/framework` : seul niveau qui possède le Router. Le
|
|
13
|
+
* contrat producteur (`IAdminApi`) vit dans le core (inversion de dépendance).
|
|
14
|
+
*
|
|
15
|
+
* @see IAdminBroker pour le contrat public + la convention de routage.
|
|
16
|
+
*/
|
|
17
|
+
let AdminBroker = class AdminBroker extends Service {
|
|
18
|
+
rootPrefix = "/nodefony";
|
|
19
|
+
apiSegment = "api";
|
|
20
|
+
defaultRole = ADMIN_DEFAULT_ROLE;
|
|
21
|
+
/** Producteurs enregistrés, indexés par namespace. */
|
|
22
|
+
producers = /* @__PURE__ */ new Map();
|
|
23
|
+
/** Routes résolues, indexées par nom de route (dispatch O(1)). */
|
|
24
|
+
byRouteName = /* @__PURE__ */ new Map();
|
|
25
|
+
/** Module framework propriétaire — requis pour `Router.setController`. */
|
|
26
|
+
frameworkModule;
|
|
27
|
+
/** Vrai une fois `mountAll()` exécuté (idempotence + verrou de register). */
|
|
28
|
+
mounted = false;
|
|
29
|
+
constructor(module) {
|
|
30
|
+
super(serviceName, module.container, module.notificationsCenter, module.options.adminBroker);
|
|
31
|
+
this.frameworkModule = module;
|
|
32
|
+
}
|
|
33
|
+
register(api) {
|
|
34
|
+
if (this.mounted) throw new Error(`AdminBroker: register("${api.adminNamespace}") après mountAll — routes figées`);
|
|
35
|
+
if (this.producers.has(api.adminNamespace)) throw new Error(`AdminBroker: namespace déjà enregistré "${api.adminNamespace}"`);
|
|
36
|
+
this.producers.set(api.adminNamespace, api);
|
|
37
|
+
this.log(`Admin API registered: ${api.adminNamespace}`, "DEBUG");
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
unregister(namespace) {
|
|
41
|
+
const existed = this.producers.delete(namespace);
|
|
42
|
+
if (existed && this.mounted) {
|
|
43
|
+
const router = this.getRouter();
|
|
44
|
+
for (const [name, r] of this.byRouteName) if (r.namespace === namespace) {
|
|
45
|
+
try {
|
|
46
|
+
router.removeRoutes(name);
|
|
47
|
+
} catch {}
|
|
48
|
+
this.byRouteName.delete(name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return existed;
|
|
52
|
+
}
|
|
53
|
+
has(namespace) {
|
|
54
|
+
return this.producers.has(namespace);
|
|
55
|
+
}
|
|
56
|
+
getApi(namespace) {
|
|
57
|
+
return this.producers.get(namespace);
|
|
58
|
+
}
|
|
59
|
+
list() {
|
|
60
|
+
return Array.from(this.producers.values());
|
|
61
|
+
}
|
|
62
|
+
resolvePath(namespace, endpointPath) {
|
|
63
|
+
const tail = endpointPath.replace(/^\/+/, "");
|
|
64
|
+
return `${this.rootPrefix}/${namespace}/${this.apiSegment}/${tail}`;
|
|
65
|
+
}
|
|
66
|
+
resolve(routeName) {
|
|
67
|
+
return this.byRouteName.get(routeName);
|
|
68
|
+
}
|
|
69
|
+
routes() {
|
|
70
|
+
return Array.from(this.byRouteName.values());
|
|
71
|
+
}
|
|
72
|
+
mountAll() {
|
|
73
|
+
if (this.mounted) return;
|
|
74
|
+
for (const api of this.producers.values()) for (const endpoint of api.adminEndpoints()) {
|
|
75
|
+
const method = endpoint.method ?? "GET";
|
|
76
|
+
const role = resolveAdminRole(endpoint);
|
|
77
|
+
const path = this.resolvePath(api.adminNamespace, endpoint.path);
|
|
78
|
+
const name = `admin.${api.adminNamespace}.${method}.${endpoint.path}`;
|
|
79
|
+
const methods = [method, "WEBSOCKET"];
|
|
80
|
+
router_default.createRoute(name, {
|
|
81
|
+
path,
|
|
82
|
+
constructor: AdminApiController,
|
|
83
|
+
classMethod: "dispatch",
|
|
84
|
+
requirements: { methods }
|
|
85
|
+
});
|
|
86
|
+
this.byRouteName.set(name, {
|
|
87
|
+
name,
|
|
88
|
+
namespace: api.adminNamespace,
|
|
89
|
+
path,
|
|
90
|
+
method,
|
|
91
|
+
role,
|
|
92
|
+
endpoint
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (this.byRouteName.size > 0 && !Object.prototype.hasOwnProperty.call(AdminApiController.prototype, "module")) router_default.setController(AdminApiController, this.frameworkModule);
|
|
96
|
+
this.mounted = true;
|
|
97
|
+
this.log(`Admin data plane mounted: ${this.byRouteName.size} route(s)`, "DEBUG");
|
|
98
|
+
}
|
|
99
|
+
getRouter() {
|
|
100
|
+
return this.get("router");
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
AdminBroker = __decorate([injectable(), __decorateMetadata("design:paramtypes", [typeof Module === "undefined" ? Object : Module])], AdminBroker);
|
|
104
|
+
var AdminBroker_default = AdminBroker;
|
|
105
|
+
//#endregion
|
|
106
|
+
export { AdminBroker_default as default };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import Template from "../src/Template.js";
|
|
2
|
+
import { extend, injectable } from "nodefony";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { Eta as Eta$1 } from "eta";
|
|
5
|
+
//#region nodefony/service/Eta.ts
|
|
6
|
+
/**
|
|
7
|
+
* Options par défaut du moteur Eta.
|
|
8
|
+
*
|
|
9
|
+
* `autoEscape: true` = échappement HTML par défaut sur `<%= %>` (protection XSS,
|
|
10
|
+
* cf exigence sécurité Nodefony). Sortie brute volontaire via `<%~ %>`.
|
|
11
|
+
* `useWith: true` = variables exposées nues (`<%= name %>`) comme EJS — DX
|
|
12
|
+
* familière, migration des vues `.ejs` → `.eta` verbatim.
|
|
13
|
+
* `cache: false` par défaut — réactivé en prod par {@link Template} (env).
|
|
14
|
+
*/
|
|
15
|
+
const defaultOption = {
|
|
16
|
+
autoEscape: true,
|
|
17
|
+
useWith: true,
|
|
18
|
+
cache: false
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Moteur de template **Eta** — unique moteur de Nodefony (remplace EJS + Twig).
|
|
22
|
+
*
|
|
23
|
+
* Choisi pour : écrit en TypeScript (types fournis, pas de `@types/*`), ESM
|
|
24
|
+
* natif, autoescape, et délimiteurs `<% %>` / `<%= %>` qui ne collisionnent pas
|
|
25
|
+
* avec la syntaxe TS/JSON/JSX — décisif pour la génération de code (scaffold
|
|
26
|
+
* `module:create`) autant que pour le rendu HTML serveur.
|
|
27
|
+
*
|
|
28
|
+
* Le `Controller` lit le fichier de vue (via `FileClass`) puis appelle
|
|
29
|
+
* {@link Eta.render} avec la chaîne ; `renderFile` est fourni pour les usages
|
|
30
|
+
* directs (CLI/Builder) qui partent d'un chemin.
|
|
31
|
+
*/
|
|
32
|
+
injectable();
|
|
33
|
+
var Eta = class extends Template {
|
|
34
|
+
constructor(module) {
|
|
35
|
+
const engine = new Eta$1(extend(true, {}, defaultOption));
|
|
36
|
+
super("template", engine, module, extend(true, {}, defaultOption));
|
|
37
|
+
this.engine.configure({ cache: this.cache });
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Rend un template depuis une chaîne source (chemin chaud du Controller).
|
|
41
|
+
*
|
|
42
|
+
* @param str - source du template Eta
|
|
43
|
+
* @param data - locals injectés dans le template
|
|
44
|
+
* @returns le rendu HTML/texte
|
|
45
|
+
*/
|
|
46
|
+
async render(str, data = {}) {
|
|
47
|
+
return this.engine.renderStringAsync(str, data);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Rend un template depuis un chemin de fichier (lecture async non bloquante).
|
|
51
|
+
*
|
|
52
|
+
* @param path - chemin absolu du fichier `.eta`
|
|
53
|
+
* @param data - locals injectés dans le template
|
|
54
|
+
* @returns le rendu HTML/texte
|
|
55
|
+
* @throws Si la lecture ou le parsing échoue (loggé en ERROR).
|
|
56
|
+
*/
|
|
57
|
+
async renderFile(path, data = {}) {
|
|
58
|
+
try {
|
|
59
|
+
const src = await readFile(path, { encoding: "utf8" });
|
|
60
|
+
return await this.engine.renderStringAsync(src, data);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
this.log(e, "ERROR");
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
//#endregion
|
|
68
|
+
export { Eta as default };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import __decorate from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
|
|
3
|
+
import { Module, Service, assertPageQuery, injectable } from "nodefony";
|
|
4
|
+
//#region nodefony/service/IdempotencyStore.ts
|
|
5
|
+
const serviceName = "idempotencyStore";
|
|
6
|
+
/** Réponses mémorisées : 10 min (un rejeu plausible reste dans cette fenêtre). */
|
|
7
|
+
const DEFAULT_TTL_MS = 6e5;
|
|
8
|
+
/** Bail d'une entrée *in-flight* : 60 s (au-delà = exécution réputée abandonnée). */
|
|
9
|
+
const DEFAULT_LEASE_MS = 6e4;
|
|
10
|
+
/** Borne mémoire : nombre max d'entrées (éviction FIFO de la plus ancienne). */
|
|
11
|
+
const DEFAULT_CAP = 1e3;
|
|
12
|
+
/**
|
|
13
|
+
* Implémentation **mémoire** (per-pod) de {@link IIdempotencyStore} — cache borné
|
|
14
|
+
* de dédup des mutations admin rejouées.
|
|
15
|
+
*
|
|
16
|
+
* Vit dans `@nodefony/framework` (niveau qui possède le data plane admin) et
|
|
17
|
+
* s'enregistre comme service DI `idempotencyStore` (manifeste `@services`).
|
|
18
|
+
*
|
|
19
|
+
* **Perf/mémoire** : `Map` allouée **lazy** au 1ᵉʳ `begin` (le store ne sert que
|
|
20
|
+
* les mutations admin = cold path) ; aucun timer/listener (purge passive +
|
|
21
|
+
* éviction FIFO au cap, payées seulement quand on écrit). Aucun coût tant
|
|
22
|
+
* qu'aucune mutation idempotente n'est invoquée.
|
|
23
|
+
*
|
|
24
|
+
* @see IIdempotencyStore pour le contrat + l'invariant de scope de la clé.
|
|
25
|
+
*/
|
|
26
|
+
let MemoryIdempotencyStore = class MemoryIdempotencyStore extends Service {
|
|
27
|
+
/** Entrées vivantes — `null` tant qu'aucun `begin` n'a eu lieu (lazy alloc). */
|
|
28
|
+
entries = null;
|
|
29
|
+
ttlMs = DEFAULT_TTL_MS;
|
|
30
|
+
leaseMs = DEFAULT_LEASE_MS;
|
|
31
|
+
cap = DEFAULT_CAP;
|
|
32
|
+
constructor(module) {
|
|
33
|
+
super(serviceName, module.container, module.notificationsCenter, void 0);
|
|
34
|
+
}
|
|
35
|
+
get size() {
|
|
36
|
+
return this.entries === null ? 0 : this.entries.size;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* {@inheritDoc IIdempotencyStore.listPage}
|
|
40
|
+
*
|
|
41
|
+
* La collection est déjà en RAM et **bornée par le cap** : le tri porte sur
|
|
42
|
+
* des références, seule la page devient des vues. Les entrées expirées sont
|
|
43
|
+
* exclues à la LECTURE (la purge d'ici est passive : elle n'a lieu qu'à
|
|
44
|
+
* l'écriture) — sinon on montrerait comme vivante une clé déjà rejouable.
|
|
45
|
+
*/
|
|
46
|
+
listPage(query) {
|
|
47
|
+
assertPageQuery(query, "offset");
|
|
48
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
49
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const prefix = query.q !== void 0 && query.q.length > 0 ? query.q : null;
|
|
52
|
+
const matched = [];
|
|
53
|
+
for (const pair of this.entries ?? []) {
|
|
54
|
+
const [key, entry] = pair;
|
|
55
|
+
if (now > entry.expiresAt) continue;
|
|
56
|
+
if (prefix !== null && !key.startsWith(prefix)) continue;
|
|
57
|
+
if (query.state !== void 0 && entry.kind !== query.state) continue;
|
|
58
|
+
matched.push(pair);
|
|
59
|
+
}
|
|
60
|
+
matched.sort((a, b) => a[1].expiresAt - b[1].expiresAt || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
61
|
+
const items = matched.slice(offset, offset + limit).map(([key, entry]) => ({
|
|
62
|
+
key,
|
|
63
|
+
state: entry.kind,
|
|
64
|
+
expiresAtMs: entry.expiresAt,
|
|
65
|
+
hasResponse: entry.kind === "done"
|
|
66
|
+
}));
|
|
67
|
+
return Promise.resolve({
|
|
68
|
+
items,
|
|
69
|
+
total: query.withTotal === false ? void 0 : matched.length,
|
|
70
|
+
limit,
|
|
71
|
+
offset,
|
|
72
|
+
hasNext: offset + items.length < matched.length
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
begin(key, fingerprint) {
|
|
76
|
+
const entries = this.entries ?? (this.entries = /* @__PURE__ */ new Map());
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const existing = entries.get(key);
|
|
79
|
+
if (existing !== void 0 && now <= existing.expiresAt) {
|
|
80
|
+
if (existing.fingerprint !== fingerprint) return { state: "mismatch" };
|
|
81
|
+
if (existing.kind === "done") return {
|
|
82
|
+
state: "replayed",
|
|
83
|
+
response: existing.response
|
|
84
|
+
};
|
|
85
|
+
return { state: "in-flight" };
|
|
86
|
+
}
|
|
87
|
+
if (existing !== void 0) entries.delete(key);
|
|
88
|
+
entries.set(key, {
|
|
89
|
+
kind: "in-flight",
|
|
90
|
+
fingerprint,
|
|
91
|
+
expiresAt: now + this.leaseMs
|
|
92
|
+
});
|
|
93
|
+
return { state: "fresh" };
|
|
94
|
+
}
|
|
95
|
+
complete(key, response) {
|
|
96
|
+
const entries = this.entries;
|
|
97
|
+
if (entries === null) return;
|
|
98
|
+
const existing = entries.get(key);
|
|
99
|
+
if (existing === void 0 || existing.kind !== "in-flight") return;
|
|
100
|
+
entries.set(key, {
|
|
101
|
+
kind: "done",
|
|
102
|
+
fingerprint: existing.fingerprint,
|
|
103
|
+
response,
|
|
104
|
+
expiresAt: Date.now() + this.ttlMs
|
|
105
|
+
});
|
|
106
|
+
this.evictIfNeeded();
|
|
107
|
+
}
|
|
108
|
+
abort(key) {
|
|
109
|
+
const entries = this.entries;
|
|
110
|
+
if (entries === null) return;
|
|
111
|
+
const existing = entries.get(key);
|
|
112
|
+
if (existing !== void 0 && existing.kind === "in-flight") entries.delete(key);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Borne la taille : purge passive des expirées rencontrées, puis éviction FIFO
|
|
116
|
+
* (la plus ancienne insérée) jusqu'à repasser sous le cap. Appelé au `complete`
|
|
117
|
+
* (seul point qui fait croître durablement la Map).
|
|
118
|
+
*/
|
|
119
|
+
evictIfNeeded() {
|
|
120
|
+
const entries = this.entries;
|
|
121
|
+
if (entries === null || entries.size <= this.cap) return;
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
for (const [k, e] of entries) {
|
|
124
|
+
if (now > e.expiresAt) entries.delete(k);
|
|
125
|
+
if (entries.size <= this.cap) return;
|
|
126
|
+
}
|
|
127
|
+
for (const k of entries.keys()) {
|
|
128
|
+
entries.delete(k);
|
|
129
|
+
if (entries.size <= this.cap) return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
MemoryIdempotencyStore = __decorate([injectable(), __decorateMetadata("design:paramtypes", [typeof Module === "undefined" ? Object : Module])], MemoryIdempotencyStore);
|
|
134
|
+
var IdempotencyStore_default = MemoryIdempotencyStore;
|
|
135
|
+
//#endregion
|
|
136
|
+
export { IdempotencyStore_default as default };
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import Route from "../src/Route.js";
|
|
2
|
+
import { routeExpectsBodyStream } from "../decorators/routerDecorators.js";
|
|
3
|
+
import Resolver from "../src/Resolver.js";
|
|
4
|
+
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
|
|
5
|
+
import __decorate from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
|
|
6
|
+
import { Module, Service, injectable } from "nodefony";
|
|
7
|
+
import { HttpError, isDomainAllowed } from "@nodefony/http";
|
|
8
|
+
//#region nodefony/service/router.ts
|
|
9
|
+
var _Router;
|
|
10
|
+
let routeNoticePromoted = null;
|
|
11
|
+
function collectSupportedMethods(route) {
|
|
12
|
+
const set = /* @__PURE__ */ new Set();
|
|
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;
|
|
17
|
+
}
|
|
18
|
+
const routes = [];
|
|
19
|
+
const serviceName = "router";
|
|
20
|
+
const REG_NON_LITERAL = /[{}*+?()[\]^$|\\]/;
|
|
21
|
+
const NO_LITERALS = [];
|
|
22
|
+
/**
|
|
23
|
+
* Préfixe LITTÉRAL d'un chemin de route : tout ce qui précède son premier
|
|
24
|
+
* caractère non littéral, en minuscules. Le motif compilé étant ancré (`^…$`),
|
|
25
|
+
* un chemin que cette route peut servir commence FORCÉMENT par ce préfixe —
|
|
26
|
+
* c'est ce qui autorise à écarter la route sans exécuter son motif.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ Garde ASCII. Le pré-filtre compare `cleanPath.toLowerCase()` à ce préfixe,
|
|
29
|
+
* or `toLowerCase()` (repli Unicode complet) et le drapeau `i` du motif (repli
|
|
30
|
+
* simple) ne traitent PAS la casse de la même façon hors ASCII : comparer les
|
|
31
|
+
* deux pourrait écarter une route qui matche — un 404 à la place d'une réponse.
|
|
32
|
+
* On tronque donc le préfixe au premier caractère non ASCII : filtrer moins est
|
|
33
|
+
* toujours correct, filtrer à tort ne l'est jamais.
|
|
34
|
+
*
|
|
35
|
+
* @param path - chemin déclaré de la route (`undefined` pour une route sans chemin).
|
|
36
|
+
* @returns le préfixe exploitable, ou `""` quand il n'y en a pas (route jamais filtrée).
|
|
37
|
+
*/
|
|
38
|
+
function literalPrefix(path) {
|
|
39
|
+
if (path === void 0) return "";
|
|
40
|
+
const cut = path.search(REG_NON_LITERAL);
|
|
41
|
+
const head = cut === -1 ? path : path.slice(0, cut);
|
|
42
|
+
for (let i = 0; i < head.length; i++) if (head.charCodeAt(i) > 127) return head.slice(0, i).toLowerCase();
|
|
43
|
+
return head.toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
let routeIndex = null;
|
|
46
|
+
function invalidateRouteIndex() {
|
|
47
|
+
routeIndex = null;
|
|
48
|
+
}
|
|
49
|
+
function buildRouteIndex() {
|
|
50
|
+
const statics = /* @__PURE__ */ new Map();
|
|
51
|
+
const dynamics = [];
|
|
52
|
+
for (let i = 0; i < routes.length; i++) {
|
|
53
|
+
const route = routes[i];
|
|
54
|
+
const path = route.path;
|
|
55
|
+
if (path !== void 0 && route.variables.length === 0 && !REG_NON_LITERAL.test(path)) {
|
|
56
|
+
const key = path.toLowerCase();
|
|
57
|
+
let list = statics.get(key);
|
|
58
|
+
if (list === void 0) {
|
|
59
|
+
list = [];
|
|
60
|
+
statics.set(key, list);
|
|
61
|
+
}
|
|
62
|
+
list.push({
|
|
63
|
+
route,
|
|
64
|
+
pos: i,
|
|
65
|
+
prefix: ""
|
|
66
|
+
});
|
|
67
|
+
} else dynamics.push({
|
|
68
|
+
route,
|
|
69
|
+
pos: i,
|
|
70
|
+
prefix: literalPrefix(path)
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return routeIndex = {
|
|
74
|
+
statics,
|
|
75
|
+
dynamics,
|
|
76
|
+
length: routes.length,
|
|
77
|
+
first: routes[0],
|
|
78
|
+
last: routes[routes.length - 1]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
let Router = class Router extends Service {
|
|
82
|
+
static {
|
|
83
|
+
_Router = this;
|
|
84
|
+
}
|
|
85
|
+
static routes = routes;
|
|
86
|
+
routes = _Router.routes;
|
|
87
|
+
singletonControllers = null;
|
|
88
|
+
constructor(module) {
|
|
89
|
+
super(serviceName, module.container, module.notificationsCenter, module.options.router);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Retourne l'instance singleton d'une classe controller `@Scope("singleton")`,
|
|
93
|
+
* en la créant au premier appel via `create`. On cache la **promesse** (pas
|
|
94
|
+
* l'instance) : N requêtes concurrentes pendant la création (`initialize()`
|
|
95
|
+
* async) attendent le MÊME travail — jamais deux instances (race de création
|
|
96
|
+
* éliminée structurellement).
|
|
97
|
+
*
|
|
98
|
+
* @param ctor - la classe controller (clé du cache).
|
|
99
|
+
* @param create - fabrique exécutée une seule fois (instantiate + initialize).
|
|
100
|
+
* @returns la promesse de l'instance partagée.
|
|
101
|
+
*/
|
|
102
|
+
getSingletonController(ctor, create) {
|
|
103
|
+
if (this.singletonControllers == null) this.singletonControllers = /* @__PURE__ */ new Map();
|
|
104
|
+
let instance = this.singletonControllers.get(ctor);
|
|
105
|
+
if (!instance) {
|
|
106
|
+
instance = create();
|
|
107
|
+
this.singletonControllers.set(ctor, instance);
|
|
108
|
+
}
|
|
109
|
+
return instance;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Résout une route pour un contexte donné.
|
|
113
|
+
*
|
|
114
|
+
* @param context - le contexte HTTP/WS courant (porte container, méthode, URL…).
|
|
115
|
+
* @param cleanPathOverride - quand fourni, le matching se fait sur CE pathname au
|
|
116
|
+
* lieu de `context.request.url` — permet de router un path **porté par un message**
|
|
117
|
+
* (WS-RPC `invoke`) vers une action, sans muter l'URL de la connexion (état partagé).
|
|
118
|
+
* `undefined` (cas hot path normal) → comportement inchangé.
|
|
119
|
+
* @param methodOverride - méthode HTTP **logique** à exiger en plus du transport
|
|
120
|
+
* WEBSOCKET (pont WS-RPC `api.request` d'une MUTATION) : lève l'ambiguïté
|
|
121
|
+
* GET-via-WS / POST-via-WS sur un même chemin (`context.method` = "WEBSOCKET").
|
|
122
|
+
* `undefined` (GET/HTTP) → match historique sur `context.method`.
|
|
123
|
+
* @returns un `Resolver` (`.resolve === true` si une route a matché).
|
|
124
|
+
*/
|
|
125
|
+
resolve(context, cleanPathOverride, methodOverride) {
|
|
126
|
+
const resolver = new Resolver(context);
|
|
127
|
+
resolver.methodOverride = methodOverride ?? null;
|
|
128
|
+
const cleanPath = cleanPathOverride ?? Route.cleanPathname(context);
|
|
129
|
+
let index = routeIndex;
|
|
130
|
+
if (index === null || index.length !== routes.length || index.first !== routes[0] || index.last !== routes[routes.length - 1]) index = buildRouteIndex();
|
|
131
|
+
const cleanPathLower = cleanPath !== void 0 ? cleanPath.toLowerCase() : void 0;
|
|
132
|
+
const literals = cleanPathLower !== void 0 ? index.statics.get(cleanPathLower) ?? NO_LITERALS : NO_LITERALS;
|
|
133
|
+
const dynamics = index.dynamics;
|
|
134
|
+
const litCount = literals.length;
|
|
135
|
+
const dynCount = dynamics.length;
|
|
136
|
+
let li = 0;
|
|
137
|
+
let di = 0;
|
|
138
|
+
while (li < litCount || di < dynCount) {
|
|
139
|
+
let route;
|
|
140
|
+
if (li < litCount && (di >= dynCount || literals[li].pos < dynamics[di].pos)) route = literals[li++].route;
|
|
141
|
+
else {
|
|
142
|
+
const candidate = dynamics[di++];
|
|
143
|
+
if (candidate.prefix !== "" && cleanPathLower !== void 0 && !cleanPathLower.startsWith(candidate.prefix)) continue;
|
|
144
|
+
route = candidate.route;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
if (resolver.match(route, context, cleanPath)) {
|
|
148
|
+
if (routeNoticePromoted === null) routeNoticePromoted = this.kernel?.environment !== "production";
|
|
149
|
+
if (routeNoticePromoted) this.log(`Match route : ${route.name}`, "NOTICE");
|
|
150
|
+
resolver.exception = void 0;
|
|
151
|
+
routeExpectsBodyStream(route);
|
|
152
|
+
return resolver;
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
this.log(`Match route exception : ${route.name} ${e}`, "DEBUG");
|
|
156
|
+
resolver.exception = e;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (context.method !== "WEBSOCKET" && cleanPath !== void 0) {
|
|
161
|
+
const path = cleanPath || "/";
|
|
162
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
163
|
+
for (const route of routes) {
|
|
164
|
+
const servesDomain = !route.hostRegexp || isDomainAllowed(route.hostRegexp, context.domain);
|
|
165
|
+
if (route.pattern && route.pattern.test(path) && servesDomain) collectSupportedMethods(route).forEach((x) => allowed.add(x));
|
|
166
|
+
}
|
|
167
|
+
if (allowed.size > 0) {
|
|
168
|
+
const allowHeader = Array.from(allowed).join(", ");
|
|
169
|
+
const err = new HttpError(`Method ${context.method} Not Allowed`, 405, context);
|
|
170
|
+
const methodErr = err;
|
|
171
|
+
methodErr.allow = allowHeader;
|
|
172
|
+
methodErr.type = "method";
|
|
173
|
+
context.response?.setHeaders({ Allow: allowHeader });
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (resolver.exception) {
|
|
178
|
+
switch (resolver.exception.code) {
|
|
179
|
+
case 405: context.response?.setHeaders({ Allow: resolver.exception.allow });
|
|
180
|
+
}
|
|
181
|
+
throw resolver.exception;
|
|
182
|
+
}
|
|
183
|
+
return resolver;
|
|
184
|
+
}
|
|
185
|
+
resolveController(contex, name) {
|
|
186
|
+
const resolver = new Resolver(contex);
|
|
187
|
+
resolver.parsePathernController(name);
|
|
188
|
+
return resolver;
|
|
189
|
+
}
|
|
190
|
+
matchRoutes(path) {
|
|
191
|
+
let result = [];
|
|
192
|
+
for (const route of routes) {
|
|
193
|
+
let res = route.pattern?.exec(path);
|
|
194
|
+
if (res) result.push(res);
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
getRoutes(name) {
|
|
199
|
+
if (name) return routes.find((route) => route.name === name);
|
|
200
|
+
return routes;
|
|
201
|
+
}
|
|
202
|
+
setRoute() {}
|
|
203
|
+
removeRoutes(name) {
|
|
204
|
+
if (name) {
|
|
205
|
+
const index = routes.findIndex((route) => route.name === name);
|
|
206
|
+
if (index !== -1) {
|
|
207
|
+
routes.splice(index, 1);
|
|
208
|
+
invalidateRouteIndex();
|
|
209
|
+
} else throw new Error(`Route ${name} not found.`);
|
|
210
|
+
} else {
|
|
211
|
+
routes.length = 0;
|
|
212
|
+
invalidateRouteIndex();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
static createRoute(name, obj) {
|
|
216
|
+
const routenew = new Route(name, obj);
|
|
217
|
+
routes.push(routenew);
|
|
218
|
+
invalidateRouteIndex();
|
|
219
|
+
return routenew;
|
|
220
|
+
}
|
|
221
|
+
static setController(myconstructor, module) {
|
|
222
|
+
Object.defineProperty(myconstructor.prototype, "module", {
|
|
223
|
+
value: module,
|
|
224
|
+
writable: false
|
|
225
|
+
});
|
|
226
|
+
const key = `${module.name}:${myconstructor.name}`;
|
|
227
|
+
if (Module.controllers[key]) module.log(/* @__PURE__ */ new Error(`Controller already exist ${key}`), "WARNING");
|
|
228
|
+
for (const r of routes) if (r.controller === myconstructor) r.module = { name: module.name };
|
|
229
|
+
return Module.controllers[key] = myconstructor;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Retourne les routes enregistrées pour un controller donné — utilisé par
|
|
233
|
+
* le décorateur `@controllers` pour logger chaque route depuis le module
|
|
234
|
+
* propriétaire (msgid `MODULE <name>` au lieu de `KERNEL`).
|
|
235
|
+
*/
|
|
236
|
+
static getRoutesForController(myconstructor) {
|
|
237
|
+
return routes.filter((r) => r.controller === myconstructor);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
Router = _Router = __decorate([injectable(), __decorateMetadata("design:paramtypes", [typeof Module === "undefined" ? Object : Module])], Router);
|
|
241
|
+
var router_default = Router;
|
|
242
|
+
//#endregion
|
|
243
|
+
export { router_default as default };
|