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