@getstrata/bootstrap 2.1.0 → 2.2.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/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/dist/bootstrap/app.d.ts +7 -1
- package/dist/bootstrap/config.d.ts +1 -1
- package/dist/bootstrap/context.d.ts +8 -2
- package/dist/bootstrap/dependencies.d.ts +1 -1
- package/dist/bootstrap/discoverJobs.d.ts +11 -3
- package/dist/bootstrap/discoverListeners.d.ts +7 -3
- package/dist/bootstrap/discoverModels.d.ts +8 -0
- package/dist/bootstrap/discoverModules.d.ts +4 -1
- package/dist/bootstrap/httpKernel.d.ts +11 -2
- package/dist/bootstrap/inProcessCron.d.ts +2 -1
- package/dist/bootstrap/infrastructureDiscovery.d.ts +9 -0
- package/dist/bootstrap/modules.d.ts +1 -1
- package/dist/bootstrap/providers/tracing.d.ts +3 -0
- package/dist/bootstrap/public-api.d.ts +9 -4
- package/dist/bootstrap/web/index.d.ts +1 -1
- package/dist/bootstrap/web/server.d.ts +15 -7
- package/dist/entries/buildModuleRoutes.js +218 -71
- package/dist/entries/buildWebModuleRoutes.js +218 -71
- package/dist/entries/cache/modelCacheTags.js +157 -51
- package/dist/entries/config.js +8 -0
- package/dist/entries/context.js +427 -188
- package/dist/entries/createWebRoutes.js +220 -73
- package/dist/entries/dependencies.js +429 -190
- package/dist/entries/discoverJobs.js +132 -50
- package/dist/entries/discoverListeners.js +85 -37
- package/dist/entries/discoverModels.js +39 -0
- package/dist/entries/discoverModules.js +158 -51
- package/dist/entries/health.js +4 -0
- package/dist/entries/httpKernel.js +58 -17
- package/dist/entries/listeners/invalidateCacheOnModelWrite.js +157 -51
- package/dist/entries/providers.js +308 -164
- package/dist/entries/secretsGuard.js +2 -2
- package/dist/entries/web/routing.js +58 -17
- package/dist/entries/web/server.js +71 -14
- package/dist/index.js +600 -222
- package/package.json +8 -3
|
@@ -46,8 +46,12 @@ import {
|
|
|
46
46
|
CORE_CACHE_TOKEN,
|
|
47
47
|
CORE_CONFIG_TOKEN,
|
|
48
48
|
CORE_EVENT_BUS_TOKEN,
|
|
49
|
+
CORE_HTTP_CLEANUP_TOKEN,
|
|
49
50
|
CORE_POLICY_GATE_TOKEN,
|
|
51
|
+
CORE_PUBLIC_TENANCY_TOKEN,
|
|
50
52
|
CORE_QUEUE_TOKEN,
|
|
53
|
+
CORE_TENANT_RESOLVER_TOKEN,
|
|
54
|
+
CORE_THROTTLE_QUOTA_POLICY_TOKEN,
|
|
51
55
|
CORE_TOKEN_SERVICE_TOKEN
|
|
52
56
|
} from "@getstrata/core/contracts/serviceTokens";
|
|
53
57
|
var APP_PORT_CONFIG_KEY = "app.port";
|
|
@@ -113,8 +117,39 @@ function resolveRegisterRateLimit() {
|
|
|
113
117
|
// ../../src/bootstrap/httpKernel.ts
|
|
114
118
|
class HttpKernel {
|
|
115
119
|
dependencies;
|
|
120
|
+
ownedThrottles = new Set;
|
|
121
|
+
disposed = false;
|
|
116
122
|
constructor(dependencies) {
|
|
117
123
|
this.dependencies = dependencies;
|
|
124
|
+
if (dependencies.container.has(CORE_HTTP_CLEANUP_TOKEN)) {
|
|
125
|
+
dependencies.container.resolve(CORE_HTTP_CLEANUP_TOKEN)(() => this.dispose());
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
dispose() {
|
|
129
|
+
this.disposed = true;
|
|
130
|
+
const owned = [...this.ownedThrottles];
|
|
131
|
+
this.ownedThrottles.clear();
|
|
132
|
+
const errors = [];
|
|
133
|
+
for (const throttle of owned.reverse()) {
|
|
134
|
+
try {
|
|
135
|
+
throttle.dispose();
|
|
136
|
+
} catch (error) {
|
|
137
|
+
errors.push(error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (errors.length)
|
|
141
|
+
throw new AggregateError(errors, "HTTP throttle cleanup failed.");
|
|
142
|
+
}
|
|
143
|
+
ownThrottle(throttle) {
|
|
144
|
+
if (this.disposed) {
|
|
145
|
+
if ("dispose" in throttle && typeof throttle.dispose === "function")
|
|
146
|
+
throttle.dispose();
|
|
147
|
+
throw new Error("HTTP kernel is disposed.");
|
|
148
|
+
}
|
|
149
|
+
if ("dispose" in throttle && typeof throttle.dispose === "function") {
|
|
150
|
+
this.ownedThrottles.add(throttle);
|
|
151
|
+
}
|
|
152
|
+
return throttle;
|
|
118
153
|
}
|
|
119
154
|
globalMiddleware(errorFormat = "json") {
|
|
120
155
|
const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
|
|
@@ -129,7 +164,10 @@ class HttpKernel {
|
|
|
129
164
|
errorFormat === "json" ? createJsonErrorMiddleware() : createWebErrorMiddleware(),
|
|
130
165
|
createAuthMiddleware(auth),
|
|
131
166
|
createMembershipMiddleware(),
|
|
132
|
-
createTenantMiddleware(
|
|
167
|
+
createTenantMiddleware({
|
|
168
|
+
publicTenancy: this.dependencies.container.has(CORE_PUBLIC_TENANCY_TOKEN) ? this.dependencies.container.resolve(CORE_PUBLIC_TENANCY_TOKEN) : undefined,
|
|
169
|
+
resolveTenant: this.dependencies.container.has(CORE_TENANT_RESOLVER_TOKEN) ? this.dependencies.container.resolve(CORE_TENANT_RESOLVER_TOKEN) : undefined
|
|
170
|
+
})
|
|
133
171
|
];
|
|
134
172
|
}
|
|
135
173
|
group(name) {
|
|
@@ -144,25 +182,28 @@ class HttpKernel {
|
|
|
144
182
|
if (!this.dependencies.container.has(CORE_CONFIG_TOKEN)) {
|
|
145
183
|
return [csrf];
|
|
146
184
|
}
|
|
185
|
+
const quotaPolicy = this.dependencies.container.has(CORE_THROTTLE_QUOTA_POLICY_TOKEN) ? this.dependencies.container.resolve(CORE_THROTTLE_QUOTA_POLICY_TOKEN) : undefined;
|
|
147
186
|
const config = this.dependencies.container.resolve(CORE_CONFIG_TOKEN);
|
|
148
187
|
const redisUrl = config.get(REDIS_URL_CONFIG_KEY)?.trim() ?? "";
|
|
149
188
|
if (!redisUrl) {
|
|
150
189
|
const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
|
|
151
190
|
return [
|
|
152
|
-
createMemoryThrottleMiddleware({
|
|
191
|
+
this.ownThrottle(createMemoryThrottleMiddleware({
|
|
153
192
|
maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
|
|
154
|
-
decaySeconds: 60
|
|
155
|
-
|
|
193
|
+
decaySeconds: 60,
|
|
194
|
+
quotaPolicy
|
|
195
|
+
})),
|
|
156
196
|
csrf
|
|
157
197
|
];
|
|
158
198
|
}
|
|
159
199
|
const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
|
|
160
200
|
return [
|
|
161
|
-
createThrottleMiddleware({
|
|
201
|
+
this.ownThrottle(createThrottleMiddleware({
|
|
162
202
|
redisUrl,
|
|
163
203
|
maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
|
|
164
|
-
decaySeconds: 60
|
|
165
|
-
|
|
204
|
+
decaySeconds: 60,
|
|
205
|
+
quotaPolicy
|
|
206
|
+
})),
|
|
166
207
|
csrf
|
|
167
208
|
];
|
|
168
209
|
}
|
|
@@ -199,8 +240,8 @@ class HttpKernel {
|
|
|
199
240
|
return handler(request);
|
|
200
241
|
});
|
|
201
242
|
}
|
|
202
|
-
wrapWebPublicRead(handler) {
|
|
203
|
-
if (isPublicReadsEnabled()) {
|
|
243
|
+
wrapWebPublicRead(handler, options = {}) {
|
|
244
|
+
if (options.allowAnonymous ?? isPublicReadsEnabled()) {
|
|
204
245
|
return this.wrapWeb(handler);
|
|
205
246
|
}
|
|
206
247
|
return this.wrapWebAuthenticated(handler);
|
|
@@ -246,8 +287,8 @@ class HttpKernel {
|
|
|
246
287
|
const middleware = [...this.group("authenticated"), ...this.verifiedMiddleware()];
|
|
247
288
|
return withMiddleware(...middleware)(handler);
|
|
248
289
|
}
|
|
249
|
-
wrapPublicRead(handler) {
|
|
250
|
-
if (isPublicReadsEnabled()) {
|
|
290
|
+
wrapPublicRead(handler, options = {}) {
|
|
291
|
+
if (options.allowAnonymous ?? isPublicReadsEnabled()) {
|
|
251
292
|
return handler;
|
|
252
293
|
}
|
|
253
294
|
return this.wrapAuthenticated(handler);
|
|
@@ -292,25 +333,25 @@ class HttpKernel {
|
|
|
292
333
|
const memoryKeyPrefix = scope === "login" ? "login-throttle:" : "register-throttle:";
|
|
293
334
|
const redisUrl = this.dependencies.container.has(CORE_CONFIG_TOKEN) ? this.dependencies.container.resolve(CORE_CONFIG_TOKEN).get(REDIS_URL_CONFIG_KEY)?.trim() ?? "" : "";
|
|
294
335
|
if (scope === "login") {
|
|
295
|
-
return withMiddleware(createLoginThrottleMiddleware({
|
|
336
|
+
return withMiddleware(this.ownThrottle(createLoginThrottleMiddleware({
|
|
296
337
|
...redisUrl ? { redisUrl } : {},
|
|
297
338
|
maxAttempts: rateLimit.maxAttempts,
|
|
298
339
|
decaySeconds: rateLimit.decaySeconds
|
|
299
|
-
}))(handler);
|
|
340
|
+
})))(handler);
|
|
300
341
|
}
|
|
301
342
|
if (redisUrl) {
|
|
302
|
-
return withMiddleware(createThrottleMiddleware({
|
|
343
|
+
return withMiddleware(this.ownThrottle(createThrottleMiddleware({
|
|
303
344
|
redisUrl,
|
|
304
345
|
maxAttempts: rateLimit.maxAttempts,
|
|
305
346
|
decaySeconds: rateLimit.decaySeconds,
|
|
306
347
|
keyPrefix: memoryKeyPrefix
|
|
307
|
-
}))(handler);
|
|
348
|
+
})))(handler);
|
|
308
349
|
}
|
|
309
|
-
return withMiddleware(createMemoryThrottleMiddleware({
|
|
350
|
+
return withMiddleware(this.ownThrottle(createMemoryThrottleMiddleware({
|
|
310
351
|
maxAttempts: rateLimit.maxAttempts,
|
|
311
352
|
decaySeconds: rateLimit.decaySeconds,
|
|
312
353
|
keyPrefix: memoryKeyPrefix
|
|
313
|
-
}))(handler);
|
|
354
|
+
})))(handler);
|
|
314
355
|
}
|
|
315
356
|
}
|
|
316
357
|
function createHttpKernel(dependencies) {
|
|
@@ -18,74 +18,180 @@ import {
|
|
|
18
18
|
} from "@getstrata/core/runtime/applicationRegistry";
|
|
19
19
|
|
|
20
20
|
// ../../src/bootstrap/discoverModules.ts
|
|
21
|
-
import {
|
|
22
|
-
import { join } from "path";
|
|
21
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
22
|
+
import { join as join2, resolve } from "path";
|
|
23
|
+
|
|
24
|
+
// ../../src/bootstrap/infrastructureDiscovery.ts
|
|
25
|
+
import { readdir } from "fs/promises";
|
|
26
|
+
import { join, relative } from "path";
|
|
23
27
|
import { pathToFileURL } from "url";
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
async function infrastructureFiles(directory, exclude = []) {
|
|
29
|
+
const excluded = new Set([
|
|
30
|
+
"index.ts",
|
|
31
|
+
"index.js",
|
|
32
|
+
"index.mts",
|
|
33
|
+
"index.mjs",
|
|
34
|
+
"register.ts",
|
|
35
|
+
"register.js",
|
|
36
|
+
"register.mts",
|
|
37
|
+
"register.mjs",
|
|
38
|
+
...exclude
|
|
39
|
+
]);
|
|
40
|
+
const files = [];
|
|
41
|
+
async function visit(path) {
|
|
42
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
43
|
+
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
const file = join(path, entry.name);
|
|
46
|
+
const key = relative(directory, file).replaceAll("\\", "/");
|
|
47
|
+
if (excluded.has(entry.name) || excluded.has(key))
|
|
48
|
+
continue;
|
|
49
|
+
if (entry.isDirectory())
|
|
50
|
+
await visit(file);
|
|
51
|
+
else if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name) && !/\.(d|test|spec)\.[cm]?[jt]s$/.test(entry.name))
|
|
52
|
+
files.push(file);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
await visit(directory);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error.code !== "ENOENT")
|
|
59
|
+
throw error;
|
|
60
|
+
if (error.path !== directory)
|
|
61
|
+
throw error;
|
|
29
62
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
63
|
+
return files;
|
|
64
|
+
}
|
|
65
|
+
async function importInfrastructure(file) {
|
|
66
|
+
try {
|
|
67
|
+
return await import(pathToFileURL(file).href);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(`Cannot load infrastructure module ${file}`, { cause: error });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ../../src/bootstrap/discoverModules.ts
|
|
74
|
+
var KEY = Symbol.for("@getstrata/discoverModulesState");
|
|
75
|
+
function state() {
|
|
76
|
+
const globals = globalThis;
|
|
77
|
+
globals[KEY] ??= { appModules: [] };
|
|
78
|
+
return globals[KEY];
|
|
33
79
|
}
|
|
34
80
|
function configureModulesDirectory(modulesDir) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
81
|
+
const current = state();
|
|
82
|
+
const directory = resolve(modulesDir);
|
|
83
|
+
if (current.configuredModulesDir !== directory || current.configuredManifest) {
|
|
84
|
+
current.appModules.length = 0;
|
|
85
|
+
current.modulesReady = undefined;
|
|
86
|
+
current.source = undefined;
|
|
39
87
|
}
|
|
40
|
-
|
|
88
|
+
current.configuredManifest = undefined;
|
|
89
|
+
current.configuredModulesDir = directory;
|
|
41
90
|
}
|
|
42
|
-
function
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
|
|
91
|
+
function configureModulesManifest(manifest) {
|
|
92
|
+
const current = state();
|
|
93
|
+
if (current.configuredManifest !== manifest) {
|
|
94
|
+
current.appModules.length = 0;
|
|
95
|
+
current.modulesReady = undefined;
|
|
96
|
+
current.source = undefined;
|
|
46
97
|
}
|
|
47
|
-
|
|
48
|
-
|
|
98
|
+
current.configuredManifest = manifest;
|
|
99
|
+
}
|
|
100
|
+
function validateModule(value, location) {
|
|
101
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
102
|
+
throw new TypeError(`Invalid application module at ${location}.`);
|
|
103
|
+
const module = value;
|
|
104
|
+
if (typeof module.name !== "string" || !module.name.trim())
|
|
105
|
+
throw new TypeError(`Invalid module name at ${location}.`);
|
|
106
|
+
if (module.order !== undefined && (typeof module.order !== "number" || !Number.isFinite(module.order)))
|
|
107
|
+
throw new TypeError(`Invalid module order at ${location}.`);
|
|
108
|
+
for (const hook of ["routes", "webRoutes"])
|
|
109
|
+
if (module[hook] !== undefined && typeof module[hook] !== "function")
|
|
110
|
+
throw new TypeError(`Invalid ${hook} at ${location}.`);
|
|
111
|
+
if (module.providers !== undefined) {
|
|
112
|
+
if (!Array.isArray(module.providers))
|
|
113
|
+
throw new TypeError(`Invalid providers at ${location}.`);
|
|
114
|
+
for (const provider of module.providers) {
|
|
115
|
+
if (!provider || typeof provider.name !== "string" || !provider.name.trim() || provider.register !== undefined && typeof provider.register !== "function" || provider.boot !== undefined && typeof provider.boot !== "function")
|
|
116
|
+
throw new TypeError(`Invalid provider at ${location}.`);
|
|
117
|
+
}
|
|
49
118
|
}
|
|
50
|
-
throw new Error("configureModulesDirectory() must be called before discovering modules. The app preload (or starter) should call it.");
|
|
51
119
|
}
|
|
52
|
-
async function
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
120
|
+
async function load(options) {
|
|
121
|
+
const manifest = options.manifest ?? (options.modulesDir === undefined ? state().configuredManifest : undefined);
|
|
122
|
+
const entries = [];
|
|
123
|
+
if (manifest)
|
|
124
|
+
for (const [index, value] of manifest.entries())
|
|
125
|
+
entries.push({ value, location: `module manifest[${index}]` });
|
|
126
|
+
else {
|
|
127
|
+
const directory = options.modulesDir ?? state().configuredModulesDir;
|
|
128
|
+
if (!directory)
|
|
129
|
+
throw new Error("configureModulesDirectory() or configureModulesManifest() must be called before discovering modules.");
|
|
130
|
+
let folders;
|
|
131
|
+
try {
|
|
132
|
+
folders = await readdir2(directory, { withFileTypes: true });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error.code === "ENOENT")
|
|
135
|
+
return [];
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
const excluded = new Set(options.exclude ?? []);
|
|
139
|
+
for (const folder of folders.filter((entry) => entry.isDirectory() && !excluded.has(entry.name)).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
|
140
|
+
const path = join2(directory, folder.name);
|
|
141
|
+
const files = await readdir2(path);
|
|
142
|
+
const candidates = files.filter((file) => /^index\.(ts|js|mts|mjs)$/.test(file));
|
|
143
|
+
if (candidates.length !== 1)
|
|
144
|
+
throw new Error(`Expected one module entrypoint at ${path}; found ${candidates.length}.`);
|
|
145
|
+
const file = join2(path, candidates[0]);
|
|
146
|
+
const exports = await importInfrastructure(file);
|
|
147
|
+
entries.push({ value: exports.default, location: file });
|
|
60
148
|
}
|
|
61
|
-
throw error;
|
|
62
149
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (state.appModules.length > 0) {
|
|
73
|
-
return state.appModules;
|
|
150
|
+
const names = new Map;
|
|
151
|
+
const modules = [];
|
|
152
|
+
for (const { value, location } of entries) {
|
|
153
|
+
validateModule(value, location);
|
|
154
|
+
const previous = names.get(value.name);
|
|
155
|
+
if (previous)
|
|
156
|
+
throw new Error(`Duplicate module "${value.name}" at ${location}; first declared at ${previous}.`);
|
|
157
|
+
names.set(value.name, location);
|
|
158
|
+
modules.push(value);
|
|
74
159
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
160
|
+
return modules.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
|
161
|
+
}
|
|
162
|
+
function ensureModulesLoaded(options = {}) {
|
|
163
|
+
const current = state();
|
|
164
|
+
const key = options.manifest ?? (options.modulesDir === undefined ? current.configuredManifest : undefined) ?? JSON.stringify([
|
|
165
|
+
options.modulesDir ? resolve(options.modulesDir) : current.configuredModulesDir,
|
|
166
|
+
[...options.exclude ?? []].sort()
|
|
167
|
+
]);
|
|
168
|
+
if (current.modulesReady && current.source === key)
|
|
169
|
+
return current.modulesReady;
|
|
170
|
+
current.appModules.length = 0;
|
|
171
|
+
current.source = key;
|
|
172
|
+
const pending = load(options).then((modules) => {
|
|
173
|
+
if (current.modulesReady !== pending)
|
|
174
|
+
return modules;
|
|
175
|
+
current.appModules.splice(0, current.appModules.length, ...modules);
|
|
176
|
+
return current.appModules;
|
|
177
|
+
}).catch((error) => {
|
|
178
|
+
if (current.modulesReady === pending)
|
|
179
|
+
current.modulesReady = undefined;
|
|
180
|
+
throw error;
|
|
78
181
|
});
|
|
79
|
-
|
|
182
|
+
current.modulesReady = pending;
|
|
183
|
+
return pending;
|
|
80
184
|
}
|
|
81
185
|
function discoverModules() {
|
|
82
|
-
return
|
|
186
|
+
return state().appModules;
|
|
83
187
|
}
|
|
84
188
|
function resetDiscoverModulesForTests() {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
189
|
+
const current = state();
|
|
190
|
+
current.configuredModulesDir = undefined;
|
|
191
|
+
current.configuredManifest = undefined;
|
|
192
|
+
current.appModules.length = 0;
|
|
193
|
+
current.modulesReady = undefined;
|
|
194
|
+
current.source = undefined;
|
|
89
195
|
}
|
|
90
196
|
|
|
91
197
|
// ../../src/bootstrap/cache/modelCacheTags.ts
|