@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
@@ -56,8 +56,11 @@ import {
56
56
  CORE_CACHE_TOKEN,
57
57
  CORE_CONFIG_TOKEN,
58
58
  CORE_EVENT_BUS_TOKEN,
59
+ CORE_HTTP_CLEANUP_TOKEN,
59
60
  CORE_POLICY_GATE_TOKEN,
60
61
  CORE_QUEUE_TOKEN,
62
+ CORE_TENANT_RESOLVER_TOKEN,
63
+ CORE_THROTTLE_QUOTA_POLICY_TOKEN,
61
64
  CORE_TOKEN_SERVICE_TOKEN
62
65
  } from "@getstrata/core/contracts/serviceTokens";
63
66
  var APP_PORT_CONFIG_KEY = "app.port";
@@ -123,8 +126,39 @@ function resolveRegisterRateLimit() {
123
126
  // ../../src/bootstrap/httpKernel.ts
124
127
  class HttpKernel {
125
128
  dependencies;
129
+ ownedThrottles = new Set;
130
+ disposed = false;
126
131
  constructor(dependencies) {
127
132
  this.dependencies = dependencies;
133
+ if (dependencies.container.has(CORE_HTTP_CLEANUP_TOKEN)) {
134
+ dependencies.container.resolve(CORE_HTTP_CLEANUP_TOKEN)(() => this.dispose());
135
+ }
136
+ }
137
+ dispose() {
138
+ this.disposed = true;
139
+ const owned = [...this.ownedThrottles];
140
+ this.ownedThrottles.clear();
141
+ const errors = [];
142
+ for (const throttle of owned.reverse()) {
143
+ try {
144
+ throttle.dispose();
145
+ } catch (error) {
146
+ errors.push(error);
147
+ }
148
+ }
149
+ if (errors.length)
150
+ throw new AggregateError(errors, "HTTP throttle cleanup failed.");
151
+ }
152
+ ownThrottle(throttle) {
153
+ if (this.disposed) {
154
+ if ("dispose" in throttle && typeof throttle.dispose === "function")
155
+ throttle.dispose();
156
+ throw new Error("HTTP kernel is disposed.");
157
+ }
158
+ if ("dispose" in throttle && typeof throttle.dispose === "function") {
159
+ this.ownedThrottles.add(throttle);
160
+ }
161
+ return throttle;
128
162
  }
129
163
  globalMiddleware(errorFormat = "json") {
130
164
  const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
@@ -139,7 +173,9 @@ class HttpKernel {
139
173
  errorFormat === "json" ? createJsonErrorMiddleware() : createWebErrorMiddleware(),
140
174
  createAuthMiddleware(auth),
141
175
  createMembershipMiddleware(),
142
- createTenantMiddleware()
176
+ createTenantMiddleware({
177
+ resolveTenant: this.dependencies.container.has(CORE_TENANT_RESOLVER_TOKEN) ? this.dependencies.container.resolve(CORE_TENANT_RESOLVER_TOKEN) : undefined
178
+ })
143
179
  ];
144
180
  }
145
181
  group(name) {
@@ -154,25 +190,28 @@ class HttpKernel {
154
190
  if (!this.dependencies.container.has(CORE_CONFIG_TOKEN)) {
155
191
  return [csrf];
156
192
  }
193
+ const quotaPolicy = this.dependencies.container.has(CORE_THROTTLE_QUOTA_POLICY_TOKEN) ? this.dependencies.container.resolve(CORE_THROTTLE_QUOTA_POLICY_TOKEN) : undefined;
157
194
  const config = this.dependencies.container.resolve(CORE_CONFIG_TOKEN);
158
195
  const redisUrl = config.get(REDIS_URL_CONFIG_KEY)?.trim() ?? "";
159
196
  if (!redisUrl) {
160
197
  const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
161
198
  return [
162
- createMemoryThrottleMiddleware({
199
+ this.ownThrottle(createMemoryThrottleMiddleware({
163
200
  maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
164
- decaySeconds: 60
165
- }),
201
+ decaySeconds: 60,
202
+ quotaPolicy
203
+ })),
166
204
  csrf
167
205
  ];
168
206
  }
169
207
  const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
170
208
  return [
171
- createThrottleMiddleware({
209
+ this.ownThrottle(createThrottleMiddleware({
172
210
  redisUrl,
173
211
  maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
174
- decaySeconds: 60
175
- }),
212
+ decaySeconds: 60,
213
+ quotaPolicy
214
+ })),
176
215
  csrf
177
216
  ];
178
217
  }
@@ -302,25 +341,25 @@ class HttpKernel {
302
341
  const memoryKeyPrefix = scope === "login" ? "login-throttle:" : "register-throttle:";
303
342
  const redisUrl = this.dependencies.container.has(CORE_CONFIG_TOKEN) ? this.dependencies.container.resolve(CORE_CONFIG_TOKEN).get(REDIS_URL_CONFIG_KEY)?.trim() ?? "" : "";
304
343
  if (scope === "login") {
305
- return withMiddleware(createLoginThrottleMiddleware({
344
+ return withMiddleware(this.ownThrottle(createLoginThrottleMiddleware({
306
345
  ...redisUrl ? { redisUrl } : {},
307
346
  maxAttempts: rateLimit.maxAttempts,
308
347
  decaySeconds: rateLimit.decaySeconds
309
- }))(handler);
348
+ })))(handler);
310
349
  }
311
350
  if (redisUrl) {
312
- return withMiddleware(createThrottleMiddleware({
351
+ return withMiddleware(this.ownThrottle(createThrottleMiddleware({
313
352
  redisUrl,
314
353
  maxAttempts: rateLimit.maxAttempts,
315
354
  decaySeconds: rateLimit.decaySeconds,
316
355
  keyPrefix: memoryKeyPrefix
317
- }))(handler);
356
+ })))(handler);
318
357
  }
319
- return withMiddleware(createMemoryThrottleMiddleware({
358
+ return withMiddleware(this.ownThrottle(createMemoryThrottleMiddleware({
320
359
  maxAttempts: rateLimit.maxAttempts,
321
360
  decaySeconds: rateLimit.decaySeconds,
322
361
  keyPrefix: memoryKeyPrefix
323
- }))(handler);
362
+ })))(handler);
324
363
  }
325
364
  }
326
365
  function createHttpKernel(dependencies) {
@@ -1,5 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/bootstrap/web/server.ts
3
+ import { resolveMaxBodyBytes } from "@getstrata/core/http/bodySizeLimitMiddleware";
3
4
  import { createCorsMiddleware } from "@getstrata/core/http/corsMiddleware";
4
5
  import { currentRequestMeta, runWithRequestMeta } from "@getstrata/core/http/requestMetaContext";
5
6
  import { assertUrlPathUnderRoot } from "@getstrata/core/security/safePath";
@@ -11,29 +12,32 @@ function socketAddress(server, request) {
11
12
  async function missingHtmlResponse() {
12
13
  return notFoundHtmlResponse();
13
14
  }
14
- function wrapRouteHandler(handler, path) {
15
+ function wrapRouteHandler(handler, path, onRequest, track = (work) => work()) {
15
16
  return async (request, server) => {
16
- return await runWithRequestMeta({
17
+ return await track(async () => await runWithRequestMeta({
17
18
  ...currentRequestMeta(),
18
19
  request,
19
20
  routeTemplate: path,
20
21
  ipAddress: socketAddress(server, request),
21
22
  userAgent: request.headers.get("user-agent")
22
23
  }, async () => {
24
+ const intercepted = await onRequest?.(request, server);
25
+ if (intercepted instanceof Response)
26
+ return intercepted;
23
27
  const response = await handler(request);
24
28
  return response ?? await missingHtmlResponse();
25
- });
29
+ }));
26
30
  };
27
31
  }
28
- function convertAppRoutesToBunRoutes(routes, cors = {}) {
32
+ function convertAppRoutesToBunRoutes(routes, cors = {}, onRequest, track) {
29
33
  const bunRoutes = {};
30
34
  const middleware = createCorsMiddleware(cors);
31
35
  const preflight = (request) => middleware(request, async () => new Response(null, { status: 405 }));
32
36
  for (const [path, handler] of Object.entries(routes)) {
33
37
  if (typeof handler === "function") {
34
38
  bunRoutes[path] = {
35
- GET: wrapRouteHandler(handler, path),
36
- OPTIONS: wrapRouteHandler(preflight, path)
39
+ GET: wrapRouteHandler(handler, path, onRequest, track),
40
+ OPTIONS: wrapRouteHandler(preflight, path, onRequest, track)
37
41
  };
38
42
  continue;
39
43
  }
@@ -43,10 +47,10 @@ function convertAppRoutesToBunRoutes(routes, cors = {}) {
43
47
  if (typeof methodHandler !== "function") {
44
48
  continue;
45
49
  }
46
- methods[method.toUpperCase()] = wrapRouteHandler(methodHandler, path);
50
+ methods[method.toUpperCase()] = wrapRouteHandler(methodHandler, path, onRequest, track);
47
51
  }
48
52
  if (Object.keys(methods).length > 0) {
49
- methods.OPTIONS ??= wrapRouteHandler(preflight, path);
53
+ methods.OPTIONS ??= wrapRouteHandler(preflight, path, onRequest, track);
50
54
  bunRoutes[path] = methods;
51
55
  }
52
56
  }
@@ -54,19 +58,47 @@ function convertAppRoutesToBunRoutes(routes, cors = {}) {
54
58
  return bunRoutes;
55
59
  }
56
60
  function createWebServer(options) {
61
+ if (options.lifecycle?.isShuttingDown)
62
+ throw new Error("Cannot start HTTP during shutdown.");
63
+ const onRequest = (request, server) => {
64
+ if (options.lifecycle?.isShuttingDown)
65
+ return new Response("Service Unavailable", { status: 503 });
66
+ return options.onRequest?.(request, server);
67
+ };
68
+ for (const field of ["fetch", "routes", "port", "unix"])
69
+ if (options.native && field in options.native)
70
+ throw new TypeError(`native.${field} is owned by createWebServer.`);
71
+ const maxRequestBodySize = options.native?.maxRequestBodySize ?? resolveMaxBodyBytes();
72
+ if (!Number.isSafeInteger(maxRequestBodySize) || maxRequestBodySize <= 0)
73
+ throw new RangeError("maxRequestBodySize must be a positive safe integer.");
74
+ const active = new Set;
75
+ const track = (work) => {
76
+ if (!options.lifecycle)
77
+ return work();
78
+ const running = work();
79
+ active.add(running);
80
+ return running.finally(() => {
81
+ active.delete(running);
82
+ });
83
+ };
57
84
  const publicDir = options.publicDir ?? "./public";
58
- const bunRoutes = options.routes ? convertAppRoutesToBunRoutes(options.routes, options.cors) : undefined;
59
- return Bun.serve({
85
+ const bunRoutes = options.routes ? convertAppRoutesToBunRoutes(options.routes, options.cors, onRequest, track) : undefined;
86
+ const { websocket, ...native } = options.native ?? {};
87
+ const configuration = {
88
+ ...native,
89
+ maxRequestBodySize,
60
90
  port: options.port,
61
91
  ...bunRoutes ? { routes: bunRoutes } : {},
62
92
  async fetch(request, server) {
63
- return await runWithRequestMeta({
93
+ return await track(async () => await runWithRequestMeta({
64
94
  ...currentRequestMeta(),
65
95
  request,
66
96
  ipAddress: socketAddress(server, request),
67
97
  userAgent: request.headers.get("user-agent")
68
98
  }, async () => {
69
- await options.onRequest?.(request);
99
+ const intercepted = await onRequest(request, server);
100
+ if (intercepted instanceof Response)
101
+ return intercepted;
70
102
  const url = new URL(request.url);
71
103
  if (url.pathname.startsWith("/assets/")) {
72
104
  try {
@@ -80,13 +112,36 @@ function createWebServer(options) {
80
112
  }
81
113
  }
82
114
  if (options.handle) {
83
- const response = await options.handle(request);
115
+ const response = await options.handle(request, server);
116
+ if (response === undefined && websocket)
117
+ return;
84
118
  return response ?? await missingHtmlResponse();
85
119
  }
86
120
  return await missingHtmlResponse();
87
- });
121
+ }));
122
+ }
123
+ };
124
+ const server = websocket ? Bun.serve({ ...configuration, websocket }) : Bun.serve({
125
+ ...configuration,
126
+ async fetch(request, server) {
127
+ return await configuration.fetch(request, server) ?? await missingHtmlResponse();
88
128
  }
89
129
  });
130
+ if (options.lifecycle) {
131
+ const lifecycle = options.lifecycle;
132
+ const name = `http:${crypto.randomUUID()}`;
133
+ let draining;
134
+ lifecycle.register(`${name}:stop`, () => {
135
+ draining ??= server.stop(false);
136
+ draining.catch(() => {});
137
+ }, "stop");
138
+ lifecycle.register(`${name}:drain`, async () => {
139
+ await draining;
140
+ await Promise.allSettled([...active]);
141
+ }, "drain");
142
+ lifecycle.register(`${name}:force`, () => server.stop(true), "force");
143
+ }
144
+ return server;
90
145
  }
91
146
  export {
92
147
  convertAppRoutesToBunRoutes,