@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.
- package/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/dist/bootstrap/app.d.ts +7 -1
- package/dist/bootstrap/config.d.ts +1 -1
- package/dist/bootstrap/context.d.ts +8 -2
- package/dist/bootstrap/dependencies.d.ts +1 -1
- package/dist/bootstrap/discoverJobs.d.ts +11 -3
- package/dist/bootstrap/discoverListeners.d.ts +7 -3
- package/dist/bootstrap/discoverModels.d.ts +8 -0
- package/dist/bootstrap/discoverModules.d.ts +4 -1
- package/dist/bootstrap/httpKernel.d.ts +11 -2
- package/dist/bootstrap/inProcessCron.d.ts +2 -1
- package/dist/bootstrap/infrastructureDiscovery.d.ts +9 -0
- package/dist/bootstrap/modules.d.ts +1 -1
- package/dist/bootstrap/providers/tracing.d.ts +3 -0
- package/dist/bootstrap/public-api.d.ts +9 -4
- package/dist/bootstrap/web/index.d.ts +1 -1
- package/dist/bootstrap/web/server.d.ts +15 -7
- package/dist/entries/buildModuleRoutes.js +218 -71
- package/dist/entries/buildWebModuleRoutes.js +218 -71
- package/dist/entries/cache/modelCacheTags.js +157 -51
- package/dist/entries/config.js +8 -0
- package/dist/entries/context.js +427 -188
- package/dist/entries/createWebRoutes.js +220 -73
- package/dist/entries/dependencies.js +429 -190
- package/dist/entries/discoverJobs.js +132 -50
- package/dist/entries/discoverListeners.js +85 -37
- package/dist/entries/discoverModels.js +39 -0
- package/dist/entries/discoverModules.js +158 -51
- package/dist/entries/health.js +4 -0
- package/dist/entries/httpKernel.js +58 -17
- package/dist/entries/listeners/invalidateCacheOnModelWrite.js +157 -51
- package/dist/entries/providers.js +308 -164
- package/dist/entries/secretsGuard.js +2 -2
- package/dist/entries/web/routing.js +58 -17
- package/dist/entries/web/server.js +71 -14
- package/dist/index.js +600 -222
- 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
|
-
|
|
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
|
|
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,
|