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