@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
@@ -46,8 +46,11 @@ import {
46
46
  CORE_CACHE_TOKEN,
47
47
  CORE_CONFIG_TOKEN,
48
48
  CORE_EVENT_BUS_TOKEN,
49
+ CORE_HTTP_CLEANUP_TOKEN,
49
50
  CORE_POLICY_GATE_TOKEN,
50
51
  CORE_QUEUE_TOKEN,
52
+ CORE_TENANT_RESOLVER_TOKEN,
53
+ CORE_THROTTLE_QUOTA_POLICY_TOKEN,
51
54
  CORE_TOKEN_SERVICE_TOKEN
52
55
  } from "@getstrata/core/contracts/serviceTokens";
53
56
  var APP_PORT_CONFIG_KEY = "app.port";
@@ -113,8 +116,39 @@ function resolveRegisterRateLimit() {
113
116
  // ../../src/bootstrap/httpKernel.ts
114
117
  class HttpKernel {
115
118
  dependencies;
119
+ ownedThrottles = new Set;
120
+ disposed = false;
116
121
  constructor(dependencies) {
117
122
  this.dependencies = dependencies;
123
+ if (dependencies.container.has(CORE_HTTP_CLEANUP_TOKEN)) {
124
+ dependencies.container.resolve(CORE_HTTP_CLEANUP_TOKEN)(() => this.dispose());
125
+ }
126
+ }
127
+ dispose() {
128
+ this.disposed = true;
129
+ const owned = [...this.ownedThrottles];
130
+ this.ownedThrottles.clear();
131
+ const errors = [];
132
+ for (const throttle of owned.reverse()) {
133
+ try {
134
+ throttle.dispose();
135
+ } catch (error) {
136
+ errors.push(error);
137
+ }
138
+ }
139
+ if (errors.length)
140
+ throw new AggregateError(errors, "HTTP throttle cleanup failed.");
141
+ }
142
+ ownThrottle(throttle) {
143
+ if (this.disposed) {
144
+ if ("dispose" in throttle && typeof throttle.dispose === "function")
145
+ throttle.dispose();
146
+ throw new Error("HTTP kernel is disposed.");
147
+ }
148
+ if ("dispose" in throttle && typeof throttle.dispose === "function") {
149
+ this.ownedThrottles.add(throttle);
150
+ }
151
+ return throttle;
118
152
  }
119
153
  globalMiddleware(errorFormat = "json") {
120
154
  const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
@@ -129,7 +163,9 @@ class HttpKernel {
129
163
  errorFormat === "json" ? createJsonErrorMiddleware() : createWebErrorMiddleware(),
130
164
  createAuthMiddleware(auth),
131
165
  createMembershipMiddleware(),
132
- createTenantMiddleware()
166
+ createTenantMiddleware({
167
+ resolveTenant: this.dependencies.container.has(CORE_TENANT_RESOLVER_TOKEN) ? this.dependencies.container.resolve(CORE_TENANT_RESOLVER_TOKEN) : undefined
168
+ })
133
169
  ];
134
170
  }
135
171
  group(name) {
@@ -144,25 +180,28 @@ class HttpKernel {
144
180
  if (!this.dependencies.container.has(CORE_CONFIG_TOKEN)) {
145
181
  return [csrf];
146
182
  }
183
+ const quotaPolicy = this.dependencies.container.has(CORE_THROTTLE_QUOTA_POLICY_TOKEN) ? this.dependencies.container.resolve(CORE_THROTTLE_QUOTA_POLICY_TOKEN) : undefined;
147
184
  const config = this.dependencies.container.resolve(CORE_CONFIG_TOKEN);
148
185
  const redisUrl = config.get(REDIS_URL_CONFIG_KEY)?.trim() ?? "";
149
186
  if (!redisUrl) {
150
187
  const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
151
188
  return [
152
- createMemoryThrottleMiddleware({
189
+ this.ownThrottle(createMemoryThrottleMiddleware({
153
190
  maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
154
- decaySeconds: 60
155
- }),
191
+ decaySeconds: 60,
192
+ quotaPolicy
193
+ })),
156
194
  csrf
157
195
  ];
158
196
  }
159
197
  const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
160
198
  return [
161
- createThrottleMiddleware({
199
+ this.ownThrottle(createThrottleMiddleware({
162
200
  redisUrl,
163
201
  maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
164
- decaySeconds: 60
165
- }),
202
+ decaySeconds: 60,
203
+ quotaPolicy
204
+ })),
166
205
  csrf
167
206
  ];
168
207
  }
@@ -292,25 +331,25 @@ class HttpKernel {
292
331
  const memoryKeyPrefix = scope === "login" ? "login-throttle:" : "register-throttle:";
293
332
  const redisUrl = this.dependencies.container.has(CORE_CONFIG_TOKEN) ? this.dependencies.container.resolve(CORE_CONFIG_TOKEN).get(REDIS_URL_CONFIG_KEY)?.trim() ?? "" : "";
294
333
  if (scope === "login") {
295
- return withMiddleware(createLoginThrottleMiddleware({
334
+ return withMiddleware(this.ownThrottle(createLoginThrottleMiddleware({
296
335
  ...redisUrl ? { redisUrl } : {},
297
336
  maxAttempts: rateLimit.maxAttempts,
298
337
  decaySeconds: rateLimit.decaySeconds
299
- }))(handler);
338
+ })))(handler);
300
339
  }
301
340
  if (redisUrl) {
302
- return withMiddleware(createThrottleMiddleware({
341
+ return withMiddleware(this.ownThrottle(createThrottleMiddleware({
303
342
  redisUrl,
304
343
  maxAttempts: rateLimit.maxAttempts,
305
344
  decaySeconds: rateLimit.decaySeconds,
306
345
  keyPrefix: memoryKeyPrefix
307
- }))(handler);
346
+ })))(handler);
308
347
  }
309
- return withMiddleware(createMemoryThrottleMiddleware({
348
+ return withMiddleware(this.ownThrottle(createMemoryThrottleMiddleware({
310
349
  maxAttempts: rateLimit.maxAttempts,
311
350
  decaySeconds: rateLimit.decaySeconds,
312
351
  keyPrefix: memoryKeyPrefix
313
- }))(handler);
352
+ })))(handler);
314
353
  }
315
354
  }
316
355
  function createHttpKernel(dependencies) {
@@ -18,74 +18,180 @@ import {
18
18
  } from "@getstrata/core/runtime/applicationRegistry";
19
19
 
20
20
  // ../../src/bootstrap/discoverModules.ts
21
- import { readdirSync } from "fs";
22
- import { join } from "path";
21
+ import { readdir as readdir2 } from "fs/promises";
22
+ import { join as join2, resolve } from "path";
23
+
24
+ // ../../src/bootstrap/infrastructureDiscovery.ts
25
+ import { readdir } from "fs/promises";
26
+ import { join, relative } from "path";
23
27
  import { pathToFileURL } from "url";
24
- var DISCOVER_MODULES_STATE_KEY = Symbol.for("@getstrata/discoverModulesState");
25
- function readDiscoverModulesState() {
26
- const existing = globalThis[DISCOVER_MODULES_STATE_KEY];
27
- if (existing) {
28
- return existing;
28
+ async function infrastructureFiles(directory, exclude = []) {
29
+ const excluded = new Set([
30
+ "index.ts",
31
+ "index.js",
32
+ "index.mts",
33
+ "index.mjs",
34
+ "register.ts",
35
+ "register.js",
36
+ "register.mts",
37
+ "register.mjs",
38
+ ...exclude
39
+ ]);
40
+ const files = [];
41
+ async function visit(path) {
42
+ const entries = await readdir(path, { withFileTypes: true });
43
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
44
+ for (const entry of entries) {
45
+ const file = join(path, entry.name);
46
+ const key = relative(directory, file).replaceAll("\\", "/");
47
+ if (excluded.has(entry.name) || excluded.has(key))
48
+ continue;
49
+ if (entry.isDirectory())
50
+ await visit(file);
51
+ else if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name) && !/\.(d|test|spec)\.[cm]?[jt]s$/.test(entry.name))
52
+ files.push(file);
53
+ }
54
+ }
55
+ try {
56
+ await visit(directory);
57
+ } catch (error) {
58
+ if (error.code !== "ENOENT")
59
+ throw error;
60
+ if (error.path !== directory)
61
+ throw error;
29
62
  }
30
- const state = { appModules: [] };
31
- globalThis[DISCOVER_MODULES_STATE_KEY] = state;
32
- return state;
63
+ return files;
64
+ }
65
+ async function importInfrastructure(file) {
66
+ try {
67
+ return await import(pathToFileURL(file).href);
68
+ } catch (error) {
69
+ throw new Error(`Cannot load infrastructure module ${file}`, { cause: error });
70
+ }
71
+ }
72
+
73
+ // ../../src/bootstrap/discoverModules.ts
74
+ var KEY = Symbol.for("@getstrata/discoverModulesState");
75
+ function state() {
76
+ const globals = globalThis;
77
+ globals[KEY] ??= { appModules: [] };
78
+ return globals[KEY];
33
79
  }
34
80
  function configureModulesDirectory(modulesDir) {
35
- const state = readDiscoverModulesState();
36
- if (state.configuredModulesDir !== modulesDir) {
37
- state.appModules.length = 0;
38
- state.modulesReady = undefined;
81
+ const current = state();
82
+ const directory = resolve(modulesDir);
83
+ if (current.configuredModulesDir !== directory || current.configuredManifest) {
84
+ current.appModules.length = 0;
85
+ current.modulesReady = undefined;
86
+ current.source = undefined;
39
87
  }
40
- state.configuredModulesDir = modulesDir;
88
+ current.configuredManifest = undefined;
89
+ current.configuredModulesDir = directory;
41
90
  }
42
- function resolveModulesDirectory(options) {
43
- const state = readDiscoverModulesState();
44
- if (options?.modulesDir) {
45
- return options.modulesDir;
91
+ function configureModulesManifest(manifest) {
92
+ const current = state();
93
+ if (current.configuredManifest !== manifest) {
94
+ current.appModules.length = 0;
95
+ current.modulesReady = undefined;
96
+ current.source = undefined;
46
97
  }
47
- if (state.configuredModulesDir) {
48
- return state.configuredModulesDir;
98
+ current.configuredManifest = manifest;
99
+ }
100
+ function validateModule(value, location) {
101
+ if (!value || typeof value !== "object" || Array.isArray(value))
102
+ throw new TypeError(`Invalid application module at ${location}.`);
103
+ const module = value;
104
+ if (typeof module.name !== "string" || !module.name.trim())
105
+ throw new TypeError(`Invalid module name at ${location}.`);
106
+ if (module.order !== undefined && (typeof module.order !== "number" || !Number.isFinite(module.order)))
107
+ throw new TypeError(`Invalid module order at ${location}.`);
108
+ for (const hook of ["routes", "webRoutes"])
109
+ if (module[hook] !== undefined && typeof module[hook] !== "function")
110
+ throw new TypeError(`Invalid ${hook} at ${location}.`);
111
+ if (module.providers !== undefined) {
112
+ if (!Array.isArray(module.providers))
113
+ throw new TypeError(`Invalid providers at ${location}.`);
114
+ for (const provider of module.providers) {
115
+ if (!provider || typeof provider.name !== "string" || !provider.name.trim() || provider.register !== undefined && typeof provider.register !== "function" || provider.boot !== undefined && typeof provider.boot !== "function")
116
+ throw new TypeError(`Invalid provider at ${location}.`);
117
+ }
49
118
  }
50
- throw new Error("configureModulesDirectory() must be called before discovering modules. The app preload (or starter) should call it.");
51
119
  }
52
- async function loadDiscoveredModules(options) {
53
- const modulesDirectory = resolveModulesDirectory(options);
54
- let moduleNames;
55
- try {
56
- moduleNames = readdirSync(modulesDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
57
- } catch (error) {
58
- if (error.code === "ENOENT") {
59
- return [];
120
+ async function load(options) {
121
+ const manifest = options.manifest ?? (options.modulesDir === undefined ? state().configuredManifest : undefined);
122
+ const entries = [];
123
+ if (manifest)
124
+ for (const [index, value] of manifest.entries())
125
+ entries.push({ value, location: `module manifest[${index}]` });
126
+ else {
127
+ const directory = options.modulesDir ?? state().configuredModulesDir;
128
+ if (!directory)
129
+ throw new Error("configureModulesDirectory() or configureModulesManifest() must be called before discovering modules.");
130
+ let folders;
131
+ try {
132
+ folders = await readdir2(directory, { withFileTypes: true });
133
+ } catch (error) {
134
+ if (error.code === "ENOENT")
135
+ return [];
136
+ throw error;
137
+ }
138
+ const excluded = new Set(options.exclude ?? []);
139
+ 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)) {
140
+ const path = join2(directory, folder.name);
141
+ const files = await readdir2(path);
142
+ const candidates = files.filter((file) => /^index\.(ts|js|mts|mjs)$/.test(file));
143
+ if (candidates.length !== 1)
144
+ throw new Error(`Expected one module entrypoint at ${path}; found ${candidates.length}.`);
145
+ const file = join2(path, candidates[0]);
146
+ const exports = await importInfrastructure(file);
147
+ entries.push({ value: exports.default, location: file });
60
148
  }
61
- throw error;
62
149
  }
63
- const modules = await Promise.all(moduleNames.map(async (moduleName) => {
64
- const moduleUrl = pathToFileURL(join(modulesDirectory, moduleName, "index.ts")).href;
65
- const loaded = await import(moduleUrl);
66
- return loaded.default;
67
- }));
68
- return modules.filter((module) => module?.name !== undefined).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
69
- }
70
- async function ensureModulesLoaded(options) {
71
- const state = readDiscoverModulesState();
72
- if (state.appModules.length > 0) {
73
- return state.appModules;
150
+ const names = new Map;
151
+ const modules = [];
152
+ for (const { value, location } of entries) {
153
+ validateModule(value, location);
154
+ const previous = names.get(value.name);
155
+ if (previous)
156
+ throw new Error(`Duplicate module "${value.name}" at ${location}; first declared at ${previous}.`);
157
+ names.set(value.name, location);
158
+ modules.push(value);
74
159
  }
75
- state.modulesReady ??= loadDiscoveredModules(options).then((modules) => {
76
- state.appModules.splice(0, state.appModules.length, ...modules);
77
- return state.appModules;
160
+ return modules.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
161
+ }
162
+ function ensureModulesLoaded(options = {}) {
163
+ const current = state();
164
+ const key = options.manifest ?? (options.modulesDir === undefined ? current.configuredManifest : undefined) ?? JSON.stringify([
165
+ options.modulesDir ? resolve(options.modulesDir) : current.configuredModulesDir,
166
+ [...options.exclude ?? []].sort()
167
+ ]);
168
+ if (current.modulesReady && current.source === key)
169
+ return current.modulesReady;
170
+ current.appModules.length = 0;
171
+ current.source = key;
172
+ const pending = load(options).then((modules) => {
173
+ if (current.modulesReady !== pending)
174
+ return modules;
175
+ current.appModules.splice(0, current.appModules.length, ...modules);
176
+ return current.appModules;
177
+ }).catch((error) => {
178
+ if (current.modulesReady === pending)
179
+ current.modulesReady = undefined;
180
+ throw error;
78
181
  });
79
- return state.modulesReady;
182
+ current.modulesReady = pending;
183
+ return pending;
80
184
  }
81
185
  function discoverModules() {
82
- return readDiscoverModulesState().appModules;
186
+ return state().appModules;
83
187
  }
84
188
  function resetDiscoverModulesForTests() {
85
- const state = readDiscoverModulesState();
86
- state.configuredModulesDir = undefined;
87
- state.appModules.length = 0;
88
- state.modulesReady = undefined;
189
+ const current = state();
190
+ current.configuredModulesDir = undefined;
191
+ current.configuredManifest = undefined;
192
+ current.appModules.length = 0;
193
+ current.modulesReady = undefined;
194
+ current.source = undefined;
89
195
  }
90
196
 
91
197
  // ../../src/bootstrap/cache/modelCacheTags.ts