@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
|
@@ -50,8 +50,12 @@ import {
|
|
|
50
50
|
CORE_CACHE_TOKEN,
|
|
51
51
|
CORE_CONFIG_TOKEN,
|
|
52
52
|
CORE_EVENT_BUS_TOKEN,
|
|
53
|
+
CORE_HTTP_CLEANUP_TOKEN,
|
|
53
54
|
CORE_POLICY_GATE_TOKEN,
|
|
55
|
+
CORE_PUBLIC_TENANCY_TOKEN,
|
|
54
56
|
CORE_QUEUE_TOKEN,
|
|
57
|
+
CORE_TENANT_RESOLVER_TOKEN,
|
|
58
|
+
CORE_THROTTLE_QUOTA_POLICY_TOKEN,
|
|
55
59
|
CORE_TOKEN_SERVICE_TOKEN
|
|
56
60
|
} from "@getstrata/core/contracts/serviceTokens";
|
|
57
61
|
var APP_PORT_CONFIG_KEY = "app.port";
|
|
@@ -117,8 +121,39 @@ function resolveRegisterRateLimit() {
|
|
|
117
121
|
// ../../src/bootstrap/httpKernel.ts
|
|
118
122
|
class HttpKernel {
|
|
119
123
|
dependencies;
|
|
124
|
+
ownedThrottles = new Set;
|
|
125
|
+
disposed = false;
|
|
120
126
|
constructor(dependencies) {
|
|
121
127
|
this.dependencies = dependencies;
|
|
128
|
+
if (dependencies.container.has(CORE_HTTP_CLEANUP_TOKEN)) {
|
|
129
|
+
dependencies.container.resolve(CORE_HTTP_CLEANUP_TOKEN)(() => this.dispose());
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
dispose() {
|
|
133
|
+
this.disposed = true;
|
|
134
|
+
const owned = [...this.ownedThrottles];
|
|
135
|
+
this.ownedThrottles.clear();
|
|
136
|
+
const errors = [];
|
|
137
|
+
for (const throttle of owned.reverse()) {
|
|
138
|
+
try {
|
|
139
|
+
throttle.dispose();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
errors.push(error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (errors.length)
|
|
145
|
+
throw new AggregateError(errors, "HTTP throttle cleanup failed.");
|
|
146
|
+
}
|
|
147
|
+
ownThrottle(throttle) {
|
|
148
|
+
if (this.disposed) {
|
|
149
|
+
if ("dispose" in throttle && typeof throttle.dispose === "function")
|
|
150
|
+
throttle.dispose();
|
|
151
|
+
throw new Error("HTTP kernel is disposed.");
|
|
152
|
+
}
|
|
153
|
+
if ("dispose" in throttle && typeof throttle.dispose === "function") {
|
|
154
|
+
this.ownedThrottles.add(throttle);
|
|
155
|
+
}
|
|
156
|
+
return throttle;
|
|
122
157
|
}
|
|
123
158
|
globalMiddleware(errorFormat = "json") {
|
|
124
159
|
const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
|
|
@@ -133,7 +168,10 @@ class HttpKernel {
|
|
|
133
168
|
errorFormat === "json" ? createJsonErrorMiddleware() : createWebErrorMiddleware(),
|
|
134
169
|
createAuthMiddleware(auth),
|
|
135
170
|
createMembershipMiddleware(),
|
|
136
|
-
createTenantMiddleware(
|
|
171
|
+
createTenantMiddleware({
|
|
172
|
+
publicTenancy: this.dependencies.container.has(CORE_PUBLIC_TENANCY_TOKEN) ? this.dependencies.container.resolve(CORE_PUBLIC_TENANCY_TOKEN) : undefined,
|
|
173
|
+
resolveTenant: this.dependencies.container.has(CORE_TENANT_RESOLVER_TOKEN) ? this.dependencies.container.resolve(CORE_TENANT_RESOLVER_TOKEN) : undefined
|
|
174
|
+
})
|
|
137
175
|
];
|
|
138
176
|
}
|
|
139
177
|
group(name) {
|
|
@@ -148,25 +186,28 @@ class HttpKernel {
|
|
|
148
186
|
if (!this.dependencies.container.has(CORE_CONFIG_TOKEN)) {
|
|
149
187
|
return [csrf];
|
|
150
188
|
}
|
|
189
|
+
const quotaPolicy = this.dependencies.container.has(CORE_THROTTLE_QUOTA_POLICY_TOKEN) ? this.dependencies.container.resolve(CORE_THROTTLE_QUOTA_POLICY_TOKEN) : undefined;
|
|
151
190
|
const config = this.dependencies.container.resolve(CORE_CONFIG_TOKEN);
|
|
152
191
|
const redisUrl = config.get(REDIS_URL_CONFIG_KEY)?.trim() ?? "";
|
|
153
192
|
if (!redisUrl) {
|
|
154
193
|
const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
|
|
155
194
|
return [
|
|
156
|
-
createMemoryThrottleMiddleware({
|
|
195
|
+
this.ownThrottle(createMemoryThrottleMiddleware({
|
|
157
196
|
maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
|
|
158
|
-
decaySeconds: 60
|
|
159
|
-
|
|
197
|
+
decaySeconds: 60,
|
|
198
|
+
quotaPolicy
|
|
199
|
+
})),
|
|
160
200
|
csrf
|
|
161
201
|
];
|
|
162
202
|
}
|
|
163
203
|
const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
|
|
164
204
|
return [
|
|
165
|
-
createThrottleMiddleware({
|
|
205
|
+
this.ownThrottle(createThrottleMiddleware({
|
|
166
206
|
redisUrl,
|
|
167
207
|
maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
|
|
168
|
-
decaySeconds: 60
|
|
169
|
-
|
|
208
|
+
decaySeconds: 60,
|
|
209
|
+
quotaPolicy
|
|
210
|
+
})),
|
|
170
211
|
csrf
|
|
171
212
|
];
|
|
172
213
|
}
|
|
@@ -203,8 +244,8 @@ class HttpKernel {
|
|
|
203
244
|
return handler(request);
|
|
204
245
|
});
|
|
205
246
|
}
|
|
206
|
-
wrapWebPublicRead(handler) {
|
|
207
|
-
if (isPublicReadsEnabled()) {
|
|
247
|
+
wrapWebPublicRead(handler, options = {}) {
|
|
248
|
+
if (options.allowAnonymous ?? isPublicReadsEnabled()) {
|
|
208
249
|
return this.wrapWeb(handler);
|
|
209
250
|
}
|
|
210
251
|
return this.wrapWebAuthenticated(handler);
|
|
@@ -250,8 +291,8 @@ class HttpKernel {
|
|
|
250
291
|
const middleware = [...this.group("authenticated"), ...this.verifiedMiddleware()];
|
|
251
292
|
return withMiddleware(...middleware)(handler);
|
|
252
293
|
}
|
|
253
|
-
wrapPublicRead(handler) {
|
|
254
|
-
if (isPublicReadsEnabled()) {
|
|
294
|
+
wrapPublicRead(handler, options = {}) {
|
|
295
|
+
if (options.allowAnonymous ?? isPublicReadsEnabled()) {
|
|
255
296
|
return handler;
|
|
256
297
|
}
|
|
257
298
|
return this.wrapAuthenticated(handler);
|
|
@@ -296,25 +337,25 @@ class HttpKernel {
|
|
|
296
337
|
const memoryKeyPrefix = scope === "login" ? "login-throttle:" : "register-throttle:";
|
|
297
338
|
const redisUrl = this.dependencies.container.has(CORE_CONFIG_TOKEN) ? this.dependencies.container.resolve(CORE_CONFIG_TOKEN).get(REDIS_URL_CONFIG_KEY)?.trim() ?? "" : "";
|
|
298
339
|
if (scope === "login") {
|
|
299
|
-
return withMiddleware(createLoginThrottleMiddleware({
|
|
340
|
+
return withMiddleware(this.ownThrottle(createLoginThrottleMiddleware({
|
|
300
341
|
...redisUrl ? { redisUrl } : {},
|
|
301
342
|
maxAttempts: rateLimit.maxAttempts,
|
|
302
343
|
decaySeconds: rateLimit.decaySeconds
|
|
303
|
-
}))(handler);
|
|
344
|
+
})))(handler);
|
|
304
345
|
}
|
|
305
346
|
if (redisUrl) {
|
|
306
|
-
return withMiddleware(createThrottleMiddleware({
|
|
347
|
+
return withMiddleware(this.ownThrottle(createThrottleMiddleware({
|
|
307
348
|
redisUrl,
|
|
308
349
|
maxAttempts: rateLimit.maxAttempts,
|
|
309
350
|
decaySeconds: rateLimit.decaySeconds,
|
|
310
351
|
keyPrefix: memoryKeyPrefix
|
|
311
|
-
}))(handler);
|
|
352
|
+
})))(handler);
|
|
312
353
|
}
|
|
313
|
-
return withMiddleware(createMemoryThrottleMiddleware({
|
|
354
|
+
return withMiddleware(this.ownThrottle(createMemoryThrottleMiddleware({
|
|
314
355
|
maxAttempts: rateLimit.maxAttempts,
|
|
315
356
|
decaySeconds: rateLimit.decaySeconds,
|
|
316
357
|
keyPrefix: memoryKeyPrefix
|
|
317
|
-
}))(handler);
|
|
358
|
+
})))(handler);
|
|
318
359
|
}
|
|
319
360
|
}
|
|
320
361
|
function createHttpKernel(dependencies) {
|
|
@@ -322,74 +363,180 @@ function createHttpKernel(dependencies) {
|
|
|
322
363
|
}
|
|
323
364
|
|
|
324
365
|
// ../../src/bootstrap/discoverModules.ts
|
|
325
|
-
import {
|
|
326
|
-
import { join } from "path";
|
|
366
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
367
|
+
import { join as join2, resolve } from "path";
|
|
368
|
+
|
|
369
|
+
// ../../src/bootstrap/infrastructureDiscovery.ts
|
|
370
|
+
import { readdir } from "fs/promises";
|
|
371
|
+
import { join, relative } from "path";
|
|
327
372
|
import { pathToFileURL } from "url";
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
373
|
+
async function infrastructureFiles(directory, exclude = []) {
|
|
374
|
+
const excluded = new Set([
|
|
375
|
+
"index.ts",
|
|
376
|
+
"index.js",
|
|
377
|
+
"index.mts",
|
|
378
|
+
"index.mjs",
|
|
379
|
+
"register.ts",
|
|
380
|
+
"register.js",
|
|
381
|
+
"register.mts",
|
|
382
|
+
"register.mjs",
|
|
383
|
+
...exclude
|
|
384
|
+
]);
|
|
385
|
+
const files = [];
|
|
386
|
+
async function visit(path) {
|
|
387
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
388
|
+
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
389
|
+
for (const entry of entries) {
|
|
390
|
+
const file = join(path, entry.name);
|
|
391
|
+
const key = relative(directory, file).replaceAll("\\", "/");
|
|
392
|
+
if (excluded.has(entry.name) || excluded.has(key))
|
|
393
|
+
continue;
|
|
394
|
+
if (entry.isDirectory())
|
|
395
|
+
await visit(file);
|
|
396
|
+
else if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name) && !/\.(d|test|spec)\.[cm]?[jt]s$/.test(entry.name))
|
|
397
|
+
files.push(file);
|
|
398
|
+
}
|
|
350
399
|
}
|
|
351
|
-
|
|
352
|
-
|
|
400
|
+
try {
|
|
401
|
+
await visit(directory);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error.code !== "ENOENT")
|
|
404
|
+
throw error;
|
|
405
|
+
if (error.path !== directory)
|
|
406
|
+
throw error;
|
|
353
407
|
}
|
|
354
|
-
|
|
408
|
+
return files;
|
|
355
409
|
}
|
|
356
|
-
async function
|
|
357
|
-
const modulesDirectory = resolveModulesDirectory(options);
|
|
358
|
-
let moduleNames;
|
|
410
|
+
async function importInfrastructure(file) {
|
|
359
411
|
try {
|
|
360
|
-
|
|
412
|
+
return await import(pathToFileURL(file).href);
|
|
361
413
|
} catch (error) {
|
|
362
|
-
|
|
363
|
-
|
|
414
|
+
throw new Error(`Cannot load infrastructure module ${file}`, { cause: error });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ../../src/bootstrap/discoverModules.ts
|
|
419
|
+
var KEY = Symbol.for("@getstrata/discoverModulesState");
|
|
420
|
+
function state() {
|
|
421
|
+
const globals = globalThis;
|
|
422
|
+
globals[KEY] ??= { appModules: [] };
|
|
423
|
+
return globals[KEY];
|
|
424
|
+
}
|
|
425
|
+
function configureModulesDirectory(modulesDir) {
|
|
426
|
+
const current = state();
|
|
427
|
+
const directory = resolve(modulesDir);
|
|
428
|
+
if (current.configuredModulesDir !== directory || current.configuredManifest) {
|
|
429
|
+
current.appModules.length = 0;
|
|
430
|
+
current.modulesReady = undefined;
|
|
431
|
+
current.source = undefined;
|
|
432
|
+
}
|
|
433
|
+
current.configuredManifest = undefined;
|
|
434
|
+
current.configuredModulesDir = directory;
|
|
435
|
+
}
|
|
436
|
+
function configureModulesManifest(manifest) {
|
|
437
|
+
const current = state();
|
|
438
|
+
if (current.configuredManifest !== manifest) {
|
|
439
|
+
current.appModules.length = 0;
|
|
440
|
+
current.modulesReady = undefined;
|
|
441
|
+
current.source = undefined;
|
|
442
|
+
}
|
|
443
|
+
current.configuredManifest = manifest;
|
|
444
|
+
}
|
|
445
|
+
function validateModule(value, location) {
|
|
446
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
447
|
+
throw new TypeError(`Invalid application module at ${location}.`);
|
|
448
|
+
const module = value;
|
|
449
|
+
if (typeof module.name !== "string" || !module.name.trim())
|
|
450
|
+
throw new TypeError(`Invalid module name at ${location}.`);
|
|
451
|
+
if (module.order !== undefined && (typeof module.order !== "number" || !Number.isFinite(module.order)))
|
|
452
|
+
throw new TypeError(`Invalid module order at ${location}.`);
|
|
453
|
+
for (const hook of ["routes", "webRoutes"])
|
|
454
|
+
if (module[hook] !== undefined && typeof module[hook] !== "function")
|
|
455
|
+
throw new TypeError(`Invalid ${hook} at ${location}.`);
|
|
456
|
+
if (module.providers !== undefined) {
|
|
457
|
+
if (!Array.isArray(module.providers))
|
|
458
|
+
throw new TypeError(`Invalid providers at ${location}.`);
|
|
459
|
+
for (const provider of module.providers) {
|
|
460
|
+
if (!provider || typeof provider.name !== "string" || !provider.name.trim() || provider.register !== undefined && typeof provider.register !== "function" || provider.boot !== undefined && typeof provider.boot !== "function")
|
|
461
|
+
throw new TypeError(`Invalid provider at ${location}.`);
|
|
364
462
|
}
|
|
365
|
-
throw error;
|
|
366
463
|
}
|
|
367
|
-
const modules = await Promise.all(moduleNames.map(async (moduleName) => {
|
|
368
|
-
const moduleUrl = pathToFileURL(join(modulesDirectory, moduleName, "index.ts")).href;
|
|
369
|
-
const loaded = await import(moduleUrl);
|
|
370
|
-
return loaded.default;
|
|
371
|
-
}));
|
|
372
|
-
return modules.filter((module) => module?.name !== undefined).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
|
373
464
|
}
|
|
374
|
-
async function
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
465
|
+
async function load(options) {
|
|
466
|
+
const manifest = options.manifest ?? (options.modulesDir === undefined ? state().configuredManifest : undefined);
|
|
467
|
+
const entries = [];
|
|
468
|
+
if (manifest)
|
|
469
|
+
for (const [index, value] of manifest.entries())
|
|
470
|
+
entries.push({ value, location: `module manifest[${index}]` });
|
|
471
|
+
else {
|
|
472
|
+
const directory = options.modulesDir ?? state().configuredModulesDir;
|
|
473
|
+
if (!directory)
|
|
474
|
+
throw new Error("configureModulesDirectory() or configureModulesManifest() must be called before discovering modules.");
|
|
475
|
+
let folders;
|
|
476
|
+
try {
|
|
477
|
+
folders = await readdir2(directory, { withFileTypes: true });
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if (error.code === "ENOENT")
|
|
480
|
+
return [];
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
const excluded = new Set(options.exclude ?? []);
|
|
484
|
+
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)) {
|
|
485
|
+
const path = join2(directory, folder.name);
|
|
486
|
+
const files = await readdir2(path);
|
|
487
|
+
const candidates = files.filter((file) => /^index\.(ts|js|mts|mjs)$/.test(file));
|
|
488
|
+
if (candidates.length !== 1)
|
|
489
|
+
throw new Error(`Expected one module entrypoint at ${path}; found ${candidates.length}.`);
|
|
490
|
+
const file = join2(path, candidates[0]);
|
|
491
|
+
const exports = await importInfrastructure(file);
|
|
492
|
+
entries.push({ value: exports.default, location: file });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const names = new Map;
|
|
496
|
+
const modules = [];
|
|
497
|
+
for (const { value, location } of entries) {
|
|
498
|
+
validateModule(value, location);
|
|
499
|
+
const previous = names.get(value.name);
|
|
500
|
+
if (previous)
|
|
501
|
+
throw new Error(`Duplicate module "${value.name}" at ${location}; first declared at ${previous}.`);
|
|
502
|
+
names.set(value.name, location);
|
|
503
|
+
modules.push(value);
|
|
504
|
+
}
|
|
505
|
+
return modules.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
|
506
|
+
}
|
|
507
|
+
function ensureModulesLoaded(options = {}) {
|
|
508
|
+
const current = state();
|
|
509
|
+
const key = options.manifest ?? (options.modulesDir === undefined ? current.configuredManifest : undefined) ?? JSON.stringify([
|
|
510
|
+
options.modulesDir ? resolve(options.modulesDir) : current.configuredModulesDir,
|
|
511
|
+
[...options.exclude ?? []].sort()
|
|
512
|
+
]);
|
|
513
|
+
if (current.modulesReady && current.source === key)
|
|
514
|
+
return current.modulesReady;
|
|
515
|
+
current.appModules.length = 0;
|
|
516
|
+
current.source = key;
|
|
517
|
+
const pending = load(options).then((modules) => {
|
|
518
|
+
if (current.modulesReady !== pending)
|
|
519
|
+
return modules;
|
|
520
|
+
current.appModules.splice(0, current.appModules.length, ...modules);
|
|
521
|
+
return current.appModules;
|
|
522
|
+
}).catch((error) => {
|
|
523
|
+
if (current.modulesReady === pending)
|
|
524
|
+
current.modulesReady = undefined;
|
|
525
|
+
throw error;
|
|
382
526
|
});
|
|
383
|
-
|
|
527
|
+
current.modulesReady = pending;
|
|
528
|
+
return pending;
|
|
384
529
|
}
|
|
385
530
|
function discoverModules() {
|
|
386
|
-
return
|
|
531
|
+
return state().appModules;
|
|
387
532
|
}
|
|
388
533
|
function resetDiscoverModulesForTests() {
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
534
|
+
const current = state();
|
|
535
|
+
current.configuredModulesDir = undefined;
|
|
536
|
+
current.configuredManifest = undefined;
|
|
537
|
+
current.appModules.length = 0;
|
|
538
|
+
current.modulesReady = undefined;
|
|
539
|
+
current.source = undefined;
|
|
393
540
|
}
|
|
394
541
|
// ../../src/bootstrap/prefixRouteMap.ts
|
|
395
542
|
function prefixRouteMap(prefix, routes) {
|