@getstrata/bootstrap 2.0.9 → 2.2.0

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