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