@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +32 -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/dependencies.d.ts +1 -1
  7. package/dist/bootstrap/discoverJobs.d.ts +11 -3
  8. package/dist/bootstrap/discoverListeners.d.ts +7 -3
  9. package/dist/bootstrap/discoverModels.d.ts +8 -0
  10. package/dist/bootstrap/discoverModules.d.ts +4 -1
  11. package/dist/bootstrap/httpKernel.d.ts +11 -2
  12. package/dist/bootstrap/inProcessCron.d.ts +2 -1
  13. package/dist/bootstrap/infrastructureDiscovery.d.ts +9 -0
  14. package/dist/bootstrap/modules.d.ts +1 -1
  15. package/dist/bootstrap/providers/tracing.d.ts +3 -0
  16. package/dist/bootstrap/public-api.d.ts +9 -4
  17. package/dist/bootstrap/web/index.d.ts +1 -1
  18. package/dist/bootstrap/web/server.d.ts +15 -7
  19. package/dist/entries/buildModuleRoutes.js +218 -71
  20. package/dist/entries/buildWebModuleRoutes.js +218 -71
  21. package/dist/entries/cache/modelCacheTags.js +157 -51
  22. package/dist/entries/config.js +8 -0
  23. package/dist/entries/context.js +427 -188
  24. package/dist/entries/createWebRoutes.js +220 -73
  25. package/dist/entries/dependencies.js +429 -190
  26. package/dist/entries/discoverJobs.js +132 -50
  27. package/dist/entries/discoverListeners.js +85 -37
  28. package/dist/entries/discoverModels.js +39 -0
  29. package/dist/entries/discoverModules.js +158 -51
  30. package/dist/entries/health.js +4 -0
  31. package/dist/entries/httpKernel.js +58 -17
  32. package/dist/entries/listeners/invalidateCacheOnModelWrite.js +157 -51
  33. package/dist/entries/providers.js +308 -164
  34. package/dist/entries/secretsGuard.js +2 -2
  35. package/dist/entries/web/routing.js +58 -17
  36. package/dist/entries/web/server.js +71 -14
  37. package/dist/index.js +600 -222
  38. 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 { readdirSync } from "fs";
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
- 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;
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
- if (state.configuredModulesDir) {
352
- return state.configuredModulesDir;
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
- throw new Error("configureModulesDirectory() must be called before discovering modules. The app preload (or starter) should call it.");
408
+ return files;
355
409
  }
356
- async function loadDiscoveredModules(options) {
357
- const modulesDirectory = resolveModulesDirectory(options);
358
- let moduleNames;
410
+ async function importInfrastructure(file) {
359
411
  try {
360
- moduleNames = readdirSync(modulesDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
412
+ return await import(pathToFileURL(file).href);
361
413
  } catch (error) {
362
- if (error.code === "ENOENT") {
363
- return [];
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 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;
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
- return state.modulesReady;
527
+ current.modulesReady = pending;
528
+ return pending;
384
529
  }
385
530
  function discoverModules() {
386
- return readDiscoverModulesState().appModules;
531
+ return state().appModules;
387
532
  }
388
533
  function resetDiscoverModulesForTests() {
389
- const state = readDiscoverModulesState();
390
- state.configuredModulesDir = undefined;
391
- state.appModules.length = 0;
392
- state.modulesReady = undefined;
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) {