@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
@@ -46,8 +46,12 @@ 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,
51
+ CORE_PUBLIC_TENANCY_TOKEN,
50
52
  CORE_QUEUE_TOKEN,
53
+ CORE_TENANT_RESOLVER_TOKEN,
54
+ CORE_THROTTLE_QUOTA_POLICY_TOKEN,
51
55
  CORE_TOKEN_SERVICE_TOKEN
52
56
  } from "@getstrata/core/contracts/serviceTokens";
53
57
  var APP_PORT_CONFIG_KEY = "app.port";
@@ -113,8 +117,39 @@ function resolveRegisterRateLimit() {
113
117
  // ../../src/bootstrap/httpKernel.ts
114
118
  class HttpKernel {
115
119
  dependencies;
120
+ ownedThrottles = new Set;
121
+ disposed = false;
116
122
  constructor(dependencies) {
117
123
  this.dependencies = dependencies;
124
+ if (dependencies.container.has(CORE_HTTP_CLEANUP_TOKEN)) {
125
+ dependencies.container.resolve(CORE_HTTP_CLEANUP_TOKEN)(() => this.dispose());
126
+ }
127
+ }
128
+ dispose() {
129
+ this.disposed = true;
130
+ const owned = [...this.ownedThrottles];
131
+ this.ownedThrottles.clear();
132
+ const errors = [];
133
+ for (const throttle of owned.reverse()) {
134
+ try {
135
+ throttle.dispose();
136
+ } catch (error) {
137
+ errors.push(error);
138
+ }
139
+ }
140
+ if (errors.length)
141
+ throw new AggregateError(errors, "HTTP throttle cleanup failed.");
142
+ }
143
+ ownThrottle(throttle) {
144
+ if (this.disposed) {
145
+ if ("dispose" in throttle && typeof throttle.dispose === "function")
146
+ throttle.dispose();
147
+ throw new Error("HTTP kernel is disposed.");
148
+ }
149
+ if ("dispose" in throttle && typeof throttle.dispose === "function") {
150
+ this.ownedThrottles.add(throttle);
151
+ }
152
+ return throttle;
118
153
  }
119
154
  globalMiddleware(errorFormat = "json") {
120
155
  const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
@@ -129,7 +164,10 @@ class HttpKernel {
129
164
  errorFormat === "json" ? createJsonErrorMiddleware() : createWebErrorMiddleware(),
130
165
  createAuthMiddleware(auth),
131
166
  createMembershipMiddleware(),
132
- createTenantMiddleware()
167
+ createTenantMiddleware({
168
+ publicTenancy: this.dependencies.container.has(CORE_PUBLIC_TENANCY_TOKEN) ? this.dependencies.container.resolve(CORE_PUBLIC_TENANCY_TOKEN) : undefined,
169
+ resolveTenant: this.dependencies.container.has(CORE_TENANT_RESOLVER_TOKEN) ? this.dependencies.container.resolve(CORE_TENANT_RESOLVER_TOKEN) : undefined
170
+ })
133
171
  ];
134
172
  }
135
173
  group(name) {
@@ -144,25 +182,28 @@ class HttpKernel {
144
182
  if (!this.dependencies.container.has(CORE_CONFIG_TOKEN)) {
145
183
  return [csrf];
146
184
  }
185
+ const quotaPolicy = this.dependencies.container.has(CORE_THROTTLE_QUOTA_POLICY_TOKEN) ? this.dependencies.container.resolve(CORE_THROTTLE_QUOTA_POLICY_TOKEN) : undefined;
147
186
  const config = this.dependencies.container.resolve(CORE_CONFIG_TOKEN);
148
187
  const redisUrl = config.get(REDIS_URL_CONFIG_KEY)?.trim() ?? "";
149
188
  if (!redisUrl) {
150
189
  const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
151
190
  return [
152
- createMemoryThrottleMiddleware({
191
+ this.ownThrottle(createMemoryThrottleMiddleware({
153
192
  maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
154
- decaySeconds: 60
155
- }),
193
+ decaySeconds: 60,
194
+ quotaPolicy
195
+ })),
156
196
  csrf
157
197
  ];
158
198
  }
159
199
  const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
160
200
  return [
161
- createThrottleMiddleware({
201
+ this.ownThrottle(createThrottleMiddleware({
162
202
  redisUrl,
163
203
  maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
164
- decaySeconds: 60
165
- }),
204
+ decaySeconds: 60,
205
+ quotaPolicy
206
+ })),
166
207
  csrf
167
208
  ];
168
209
  }
@@ -199,8 +240,8 @@ class HttpKernel {
199
240
  return handler(request);
200
241
  });
201
242
  }
202
- wrapWebPublicRead(handler) {
203
- if (isPublicReadsEnabled()) {
243
+ wrapWebPublicRead(handler, options = {}) {
244
+ if (options.allowAnonymous ?? isPublicReadsEnabled()) {
204
245
  return this.wrapWeb(handler);
205
246
  }
206
247
  return this.wrapWebAuthenticated(handler);
@@ -246,8 +287,8 @@ class HttpKernel {
246
287
  const middleware = [...this.group("authenticated"), ...this.verifiedMiddleware()];
247
288
  return withMiddleware(...middleware)(handler);
248
289
  }
249
- wrapPublicRead(handler) {
250
- if (isPublicReadsEnabled()) {
290
+ wrapPublicRead(handler, options = {}) {
291
+ if (options.allowAnonymous ?? isPublicReadsEnabled()) {
251
292
  return handler;
252
293
  }
253
294
  return this.wrapAuthenticated(handler);
@@ -292,25 +333,25 @@ class HttpKernel {
292
333
  const memoryKeyPrefix = scope === "login" ? "login-throttle:" : "register-throttle:";
293
334
  const redisUrl = this.dependencies.container.has(CORE_CONFIG_TOKEN) ? this.dependencies.container.resolve(CORE_CONFIG_TOKEN).get(REDIS_URL_CONFIG_KEY)?.trim() ?? "" : "";
294
335
  if (scope === "login") {
295
- return withMiddleware(createLoginThrottleMiddleware({
336
+ return withMiddleware(this.ownThrottle(createLoginThrottleMiddleware({
296
337
  ...redisUrl ? { redisUrl } : {},
297
338
  maxAttempts: rateLimit.maxAttempts,
298
339
  decaySeconds: rateLimit.decaySeconds
299
- }))(handler);
340
+ })))(handler);
300
341
  }
301
342
  if (redisUrl) {
302
- return withMiddleware(createThrottleMiddleware({
343
+ return withMiddleware(this.ownThrottle(createThrottleMiddleware({
303
344
  redisUrl,
304
345
  maxAttempts: rateLimit.maxAttempts,
305
346
  decaySeconds: rateLimit.decaySeconds,
306
347
  keyPrefix: memoryKeyPrefix
307
- }))(handler);
348
+ })))(handler);
308
349
  }
309
- return withMiddleware(createMemoryThrottleMiddleware({
350
+ return withMiddleware(this.ownThrottle(createMemoryThrottleMiddleware({
310
351
  maxAttempts: rateLimit.maxAttempts,
311
352
  decaySeconds: rateLimit.decaySeconds,
312
353
  keyPrefix: memoryKeyPrefix
313
- }))(handler);
354
+ })))(handler);
314
355
  }
315
356
  }
316
357
  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