bunsane 0.3.1 → 0.3.2
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/.claude/scheduled_tasks.lock +1 -0
- package/CHANGELOG.md +52 -0
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1064
- package/core/ArcheType.ts +78 -2110
- package/core/Entity.ts +10 -33
- package/core/RequestContext.ts +85 -36
- package/core/RequestLoaders.ts +89 -31
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +94 -0
- package/core/app/graphqlSetup.ts +56 -0
- package/core/app/healthEndpoints.ts +31 -0
- package/core/app/metricsCollector.ts +27 -0
- package/core/app/preparedStatementWarmup.ts +55 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +309 -0
- package/core/app/restRegistry.ts +72 -0
- package/core/app/shutdown.ts +97 -0
- package/core/app/studioRouter.ts +83 -0
- package/core/archetype/customTypes.ts +100 -0
- package/core/archetype/decorators.ts +171 -0
- package/core/archetype/fieldResolvers.ts +621 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +118 -0
- package/core/archetype/schemaBuilder.ts +141 -0
- package/core/archetype/weaver.ts +218 -0
- package/core/archetype/zodSchemaBuilder.ts +527 -0
- package/core/cache/CacheManager.ts +126 -9
- package/core/middleware/AccessLog.ts +8 -1
- package/database/PreparedStatementCache.ts +12 -3
- package/database/cancellable.ts +22 -0
- package/database/instrumentedDb.ts +141 -0
- package/docs/RFC_APP_REFACTOR.md +248 -0
- package/docs/RFC_REFACTOR_TARGETS.md +251 -0
- package/package.json +1 -1
- package/query/Query.ts +53 -20
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
- package/tests/integration/query/Query.abort.test.ts +66 -0
- package/tests/unit/cache/CacheManager.test.ts +132 -1
- package/tests/unit/database/cancellable.test.ts +81 -0
- package/tests/unit/database/instrumentedDb.test.ts +160 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import ServiceRegistry from "../../service/ServiceRegistry";
|
|
2
|
+
import { type Plugin } from "graphql-yoga";
|
|
3
|
+
import { createYogaInstance } from "../../gql";
|
|
4
|
+
import { createRequestContextPlugin } from "../RequestContext";
|
|
5
|
+
|
|
6
|
+
export function setupGraphQL(app: any): void {
|
|
7
|
+
const schema = ServiceRegistry.getSchema();
|
|
8
|
+
|
|
9
|
+
const wrappedContextFactory = app.contextFactory
|
|
10
|
+
? async (yogaContext: any) => {
|
|
11
|
+
const userContext = await app.contextFactory(yogaContext);
|
|
12
|
+
return {
|
|
13
|
+
...yogaContext,
|
|
14
|
+
...userContext,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
: undefined;
|
|
18
|
+
|
|
19
|
+
const envDepth = process.env.GRAPHQL_MAX_DEPTH;
|
|
20
|
+
if (envDepth) {
|
|
21
|
+
app.graphqlMaxDepth = parseInt(envDepth, 10);
|
|
22
|
+
}
|
|
23
|
+
const envComplexity = process.env.GRAPHQL_MAX_COMPLEXITY;
|
|
24
|
+
if (envComplexity) {
|
|
25
|
+
const parsed = parseInt(envComplexity, 10);
|
|
26
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
27
|
+
app.graphqlMaxComplexity = parsed;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const yogaOptions = {
|
|
32
|
+
cors: app.config.cors,
|
|
33
|
+
maxDepth: app.graphqlMaxDepth || undefined,
|
|
34
|
+
maxComplexity: app.graphqlMaxComplexity,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const effectivePlugins: Plugin[] = app.requestContextPluginEnabled
|
|
38
|
+
? [createRequestContextPlugin(), ...app.yogaPlugins]
|
|
39
|
+
: [...app.yogaPlugins];
|
|
40
|
+
|
|
41
|
+
if (schema) {
|
|
42
|
+
app.yoga = createYogaInstance(
|
|
43
|
+
schema,
|
|
44
|
+
effectivePlugins,
|
|
45
|
+
wrappedContextFactory,
|
|
46
|
+
yogaOptions,
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
app.yoga = createYogaInstance(
|
|
50
|
+
undefined,
|
|
51
|
+
effectivePlugins,
|
|
52
|
+
wrappedContextFactory,
|
|
53
|
+
yogaOptions,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { deepHealthCheck, readinessCheck } from "../health";
|
|
2
|
+
|
|
3
|
+
export async function handleHealth(_app: any): Promise<Response> {
|
|
4
|
+
const health = await deepHealthCheck();
|
|
5
|
+
return new Response(JSON.stringify(health.result), {
|
|
6
|
+
status: health.httpStatus,
|
|
7
|
+
headers: { "Content-Type": "application/json" },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function handleReady(app: any): Promise<Response> {
|
|
12
|
+
const ready = await readinessCheck(app.isReady, app.isShuttingDown);
|
|
13
|
+
return new Response(JSON.stringify(ready.result), {
|
|
14
|
+
status: ready.httpStatus,
|
|
15
|
+
headers: { "Content-Type": "application/json" },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function handleRemoteHealth(app: any): Promise<Response> {
|
|
20
|
+
if (!app.remote) {
|
|
21
|
+
return new Response(
|
|
22
|
+
JSON.stringify({ healthy: false, error: "Remote subsystem not enabled" }),
|
|
23
|
+
{ status: 503, headers: { "Content-Type": "application/json" } },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const health = await app.remote.health();
|
|
27
|
+
return new Response(JSON.stringify(health), {
|
|
28
|
+
status: health.healthy ? 200 : 503,
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { logger as MainLogger } from "../Logger";
|
|
2
|
+
import { SchedulerManager } from "../SchedulerManager";
|
|
3
|
+
import { preparedStatementCache } from "../../database/PreparedStatementCache";
|
|
4
|
+
import { getDbStats } from "../../database/instrumentedDb";
|
|
5
|
+
|
|
6
|
+
const logger = MainLogger.child({ scope: "App" });
|
|
7
|
+
|
|
8
|
+
export async function collectMetrics(app: any) {
|
|
9
|
+
let cacheStats = null;
|
|
10
|
+
try {
|
|
11
|
+
const { CacheManager } = await import('../cache/CacheManager');
|
|
12
|
+
cacheStats = await CacheManager.getInstance().getStats();
|
|
13
|
+
} catch (err) {
|
|
14
|
+
logger.warn({ err }, 'metrics: cache stats unavailable');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
timestamp: new Date().toISOString(),
|
|
19
|
+
uptime: process.uptime(),
|
|
20
|
+
process: process.memoryUsage(),
|
|
21
|
+
cache: cacheStats,
|
|
22
|
+
scheduler: SchedulerManager.getInstance().getMetrics(),
|
|
23
|
+
preparedStatements: preparedStatementCache.getStats(),
|
|
24
|
+
db: getDbStats(),
|
|
25
|
+
remote: app.remote ? app.remote.getMetrics() : null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ComponentRegistry } from "../components";
|
|
2
|
+
import { logger as MainLogger } from "../Logger";
|
|
3
|
+
import { preparedStatementCache } from "../../database/PreparedStatementCache";
|
|
4
|
+
import db from "../../database";
|
|
5
|
+
|
|
6
|
+
const logger = MainLogger.child({ scope: "App" });
|
|
7
|
+
|
|
8
|
+
export async function warmUpPreparedStatementCache(_app: any): Promise<void> {
|
|
9
|
+
const components = ComponentRegistry.getComponents();
|
|
10
|
+
|
|
11
|
+
if (components.length === 0) {
|
|
12
|
+
logger.trace("No components registered yet, skipping cache warm-up");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const commonQueries: Array<{ sql: string; key: string }> = [];
|
|
17
|
+
|
|
18
|
+
commonQueries.push({
|
|
19
|
+
sql: "SELECT COUNT(*) as count FROM (SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.deleted_at IS NULL) AS subquery",
|
|
20
|
+
key: "count_all_entities",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
for (let i = 0; i < Math.min(5, components.length); i++) {
|
|
24
|
+
const component = components[i];
|
|
25
|
+
if (component) {
|
|
26
|
+
const { name } = component;
|
|
27
|
+
const typeId = ComponentRegistry.getComponentId(name);
|
|
28
|
+
if (typeId) {
|
|
29
|
+
commonQueries.push({
|
|
30
|
+
sql: `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id = '${typeId}' AND ec.deleted_at IS NULL LIMIT 10`,
|
|
31
|
+
key: `find_${name.toLowerCase()}_sample`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (components.length >= 2) {
|
|
38
|
+
const typeIds = components
|
|
39
|
+
.slice(0, 3)
|
|
40
|
+
.map((component: { name: string; ctor: any }) =>
|
|
41
|
+
ComponentRegistry.getComponentId(component.name)
|
|
42
|
+
)
|
|
43
|
+
.filter((id: string | undefined) => id)
|
|
44
|
+
.join("','");
|
|
45
|
+
|
|
46
|
+
if (typeIds) {
|
|
47
|
+
commonQueries.push({
|
|
48
|
+
sql: `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id IN ('${typeIds}') AND ec.deleted_at IS NULL LIMIT 10`,
|
|
49
|
+
key: "find_multi_component_sample",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await preparedStatementCache.warmUp(commonQueries, db);
|
|
55
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { logger as MainLogger } from "../Logger";
|
|
2
|
+
|
|
3
|
+
const logger = MainLogger.child({ scope: "App" });
|
|
4
|
+
|
|
5
|
+
export function registerProcessHandlers(app: any): void {
|
|
6
|
+
if (app.processHandlersRegistered) return;
|
|
7
|
+
|
|
8
|
+
app.sigTermHandler = () => {
|
|
9
|
+
logger.info({ scope: 'app', component: 'App', msg: 'Received SIGTERM' });
|
|
10
|
+
app.shutdown().finally(() => process.exit(0));
|
|
11
|
+
};
|
|
12
|
+
app.sigIntHandler = () => {
|
|
13
|
+
logger.info({ scope: 'app', component: 'App', msg: 'Received SIGINT' });
|
|
14
|
+
app.shutdown().finally(() => process.exit(0));
|
|
15
|
+
};
|
|
16
|
+
process.once('SIGTERM', app.sigTermHandler);
|
|
17
|
+
process.once('SIGINT', app.sigIntHandler);
|
|
18
|
+
|
|
19
|
+
app.unhandledRejectionHandler = (reason: unknown, _promise: Promise<unknown>) => {
|
|
20
|
+
logger.error({ scope: 'app', component: 'App', reason, msg: 'Unhandled promise rejection' });
|
|
21
|
+
};
|
|
22
|
+
app.uncaughtExceptionHandler = (error: Error) => {
|
|
23
|
+
logger.fatal({ scope: 'app', component: 'App', err: error, msg: 'Uncaught exception — shutting down' });
|
|
24
|
+
app.shutdown().finally(() => process.exit(1));
|
|
25
|
+
};
|
|
26
|
+
process.on('unhandledRejection', app.unhandledRejectionHandler);
|
|
27
|
+
process.on('uncaughtException', app.uncaughtExceptionHandler);
|
|
28
|
+
|
|
29
|
+
app.processHandlersRegistered = true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function unregisterProcessHandlers(app: any): void {
|
|
33
|
+
if (!app.processHandlersRegistered) return;
|
|
34
|
+
if (app.sigTermHandler) process.removeListener('SIGTERM', app.sigTermHandler);
|
|
35
|
+
if (app.sigIntHandler) process.removeListener('SIGINT', app.sigIntHandler);
|
|
36
|
+
if (app.unhandledRejectionHandler) process.removeListener('unhandledRejection', app.unhandledRejectionHandler);
|
|
37
|
+
if (app.uncaughtExceptionHandler) process.removeListener('uncaughtException', app.uncaughtExceptionHandler);
|
|
38
|
+
app.sigTermHandler = null;
|
|
39
|
+
app.sigIntHandler = null;
|
|
40
|
+
app.unhandledRejectionHandler = null;
|
|
41
|
+
app.uncaughtExceptionHandler = null;
|
|
42
|
+
app.processHandlersRegistered = false;
|
|
43
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import { logger as MainLogger } from "../Logger";
|
|
3
|
+
import { getSerializedMetadataStorage } from "../metadata";
|
|
4
|
+
import { addCorsHeaders, getCorsHeaders } from "./cors";
|
|
5
|
+
import {
|
|
6
|
+
handleHealth,
|
|
7
|
+
handleReady,
|
|
8
|
+
handleRemoteHealth,
|
|
9
|
+
} from "./healthEndpoints";
|
|
10
|
+
import { routeStudio } from "./studioRouter";
|
|
11
|
+
import { getDbStats } from "../../database/instrumentedDb";
|
|
12
|
+
import type { RequestStats } from "../RequestContext";
|
|
13
|
+
|
|
14
|
+
const logger = MainLogger.child({ scope: "App" });
|
|
15
|
+
|
|
16
|
+
function combineSignals(signals: AbortSignal[]): AbortSignal {
|
|
17
|
+
const anyFn = (AbortSignal as any).any;
|
|
18
|
+
if (typeof anyFn === 'function') {
|
|
19
|
+
return anyFn.call(AbortSignal, signals);
|
|
20
|
+
}
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
for (const s of signals) {
|
|
23
|
+
if (s.aborted) {
|
|
24
|
+
controller.abort((s as any).reason);
|
|
25
|
+
return controller.signal;
|
|
26
|
+
}
|
|
27
|
+
s.addEventListener('abort', () => controller.abort((s as any).reason), { once: true });
|
|
28
|
+
}
|
|
29
|
+
return controller.signal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function handleRequest(app: any, req: Request): Promise<Response> {
|
|
33
|
+
const url = new URL(req.url);
|
|
34
|
+
const method = req.method;
|
|
35
|
+
const startTime = Date.now();
|
|
36
|
+
|
|
37
|
+
if (method === 'OPTIONS') {
|
|
38
|
+
return new Response(null, {
|
|
39
|
+
status: 204,
|
|
40
|
+
headers: getCorsHeaders(app.config.cors, req),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Request timeout — combine framework wall-clock with client abort signal
|
|
45
|
+
// and rebind onto the request so downstream handlers (Yoga, REST) see
|
|
46
|
+
// cancellation propagation (C05).
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const timeoutId = setTimeout(() => {
|
|
49
|
+
controller.abort(new Error(`Request timeout after 30000ms: ${method} ${url.pathname}`));
|
|
50
|
+
const stats = (req as any).__bunsaneStats as RequestStats | undefined;
|
|
51
|
+
logger.warn({
|
|
52
|
+
scope: 'App',
|
|
53
|
+
method,
|
|
54
|
+
path: url.pathname,
|
|
55
|
+
operationName: stats?.operationName,
|
|
56
|
+
dataLoaderCalls: stats?.dataLoaderCalls,
|
|
57
|
+
dbQueryCount: stats?.dbQueryCount,
|
|
58
|
+
msg: 'Request timeout',
|
|
59
|
+
}, `Request timeout: ${method} ${url.pathname}`);
|
|
60
|
+
}, 30000);
|
|
61
|
+
const combinedSignal = combineSignals([req.signal, controller.signal]);
|
|
62
|
+
req = new Request(req, { signal: combinedSignal });
|
|
63
|
+
|
|
64
|
+
const cors = app.config.cors;
|
|
65
|
+
const wrap = (response: Response) => addCorsHeaders(response, cors, req);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
if (url.pathname === "/health") {
|
|
69
|
+
const response = await handleHealth(app);
|
|
70
|
+
clearTimeout(timeoutId);
|
|
71
|
+
return wrap(response);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (url.pathname === "/metrics") {
|
|
75
|
+
const metrics = await app.collectMetrics();
|
|
76
|
+
clearTimeout(timeoutId);
|
|
77
|
+
return wrap(new Response(JSON.stringify(metrics), {
|
|
78
|
+
status: 200,
|
|
79
|
+
headers: { "Content-Type": "application/json" },
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (url.pathname === "/health/remote") {
|
|
84
|
+
const response = await handleRemoteHealth(app);
|
|
85
|
+
clearTimeout(timeoutId);
|
|
86
|
+
return wrap(response);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (url.pathname === "/health/ready") {
|
|
90
|
+
const response = await handleReady(app);
|
|
91
|
+
clearTimeout(timeoutId);
|
|
92
|
+
return wrap(response);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (url.pathname === "/openapi.json") {
|
|
96
|
+
clearTimeout(timeoutId);
|
|
97
|
+
return wrap(new Response(app.openAPISpecGenerator!.toJSON(), {
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (url.pathname === "/docs") {
|
|
103
|
+
clearTimeout(timeoutId);
|
|
104
|
+
const swaggerUIHTML = `
|
|
105
|
+
<!DOCTYPE html>
|
|
106
|
+
<html>
|
|
107
|
+
<head>
|
|
108
|
+
<title>${app.name} Documentation</title>
|
|
109
|
+
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui.css" />
|
|
110
|
+
<style>
|
|
111
|
+
html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
|
|
112
|
+
*, *:before, *:after { box-sizing: inherit; }
|
|
113
|
+
body { margin: 0; background: #fafafa; }
|
|
114
|
+
</style>
|
|
115
|
+
</head>
|
|
116
|
+
<body>
|
|
117
|
+
<div id="swagger-ui"></div>
|
|
118
|
+
<script src="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui-bundle.js"></script>
|
|
119
|
+
<script>
|
|
120
|
+
window.onload = function() {
|
|
121
|
+
const ui = SwaggerUIBundle({
|
|
122
|
+
url: '/openapi.json',
|
|
123
|
+
dom_id: '#swagger-ui',
|
|
124
|
+
deepLinking: true,
|
|
125
|
+
presets: [
|
|
126
|
+
SwaggerUIBundle.presets.apis,
|
|
127
|
+
SwaggerUIBundle.presets.standalone
|
|
128
|
+
],
|
|
129
|
+
plugins: [
|
|
130
|
+
SwaggerUIBundle.plugins.DownloadUrl
|
|
131
|
+
],
|
|
132
|
+
layout: "BaseLayout"
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
</script>
|
|
136
|
+
</body>
|
|
137
|
+
</html>`;
|
|
138
|
+
return wrap(new Response(swaggerUIHTML, {
|
|
139
|
+
headers: { "Content-Type": "text/html" },
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const studioApiResponse = await routeStudio(app, url, req, method);
|
|
144
|
+
if (studioApiResponse) {
|
|
145
|
+
clearTimeout(timeoutId);
|
|
146
|
+
return wrap(studioApiResponse);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
app.studioEnabled &&
|
|
151
|
+
(url.pathname === "/studio" || url.pathname.startsWith("/studio/"))
|
|
152
|
+
) {
|
|
153
|
+
clearTimeout(timeoutId);
|
|
154
|
+
|
|
155
|
+
if (url.pathname.startsWith("/studio/api/")) {
|
|
156
|
+
return wrap(new Response(
|
|
157
|
+
JSON.stringify({ error: "Studio API endpoint not found" }),
|
|
158
|
+
{ status: 404, headers: { "Content-Type": "application/json" } },
|
|
159
|
+
));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!url.pathname.startsWith("/studio/assets/")) {
|
|
163
|
+
const studioIndexPath = path.join(
|
|
164
|
+
import.meta.dirname,
|
|
165
|
+
"..",
|
|
166
|
+
"..",
|
|
167
|
+
"studio",
|
|
168
|
+
"dist",
|
|
169
|
+
"index.html",
|
|
170
|
+
);
|
|
171
|
+
try {
|
|
172
|
+
const studioFile = Bun.file(studioIndexPath);
|
|
173
|
+
if (await studioFile.exists()) {
|
|
174
|
+
let html = await studioFile.text();
|
|
175
|
+
const metadata = getSerializedMetadataStorage();
|
|
176
|
+
const metadataScript = `<script>window.bunsaneMetadata = ${JSON.stringify(metadata)};</script>`;
|
|
177
|
+
html = html.replace("</head>", `${metadataScript}</head>`);
|
|
178
|
+
return wrap(new Response(html, {
|
|
179
|
+
headers: { "Content-Type": "text/html" },
|
|
180
|
+
}));
|
|
181
|
+
} else {
|
|
182
|
+
return wrap(new Response(
|
|
183
|
+
"Studio not built. Run `bun run build:studio` to build the studio.",
|
|
184
|
+
{ status: 404, headers: { "Content-Type": "text/plain" } },
|
|
185
|
+
));
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.log("Error loading studio index.html:", error);
|
|
189
|
+
return wrap(new Response("Studio not available", {
|
|
190
|
+
status: 404,
|
|
191
|
+
headers: { "Content-Type": "text/plain" },
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const [route, folder] of app.staticAssets) {
|
|
198
|
+
if (url.pathname.startsWith(route)) {
|
|
199
|
+
const relativePath = url.pathname.slice(route.length);
|
|
200
|
+
const filePath = path.join(folder, relativePath);
|
|
201
|
+
try {
|
|
202
|
+
const file = Bun.file(filePath);
|
|
203
|
+
if (await file.exists()) {
|
|
204
|
+
clearTimeout(timeoutId);
|
|
205
|
+
return wrap(new Response(file));
|
|
206
|
+
}
|
|
207
|
+
} catch (error) {
|
|
208
|
+
logger.error(`Error serving static file ${filePath}:`, error as any);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const endpointKey = `${method}:${url.pathname}`;
|
|
214
|
+
let endpoint = app.restEndpointMap.get(endpointKey);
|
|
215
|
+
|
|
216
|
+
if (!endpoint) {
|
|
217
|
+
for (const ep of app.restEndpoints) {
|
|
218
|
+
if (ep.method !== method) continue;
|
|
219
|
+
const pattern = ep.path.replace(/:[^/]+/g, '[^/]+');
|
|
220
|
+
const regex = new RegExp(`^${pattern}$`);
|
|
221
|
+
if (regex.test(url.pathname)) {
|
|
222
|
+
endpoint = ep;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (endpoint) {
|
|
229
|
+
try {
|
|
230
|
+
const result = await endpoint.handler(req);
|
|
231
|
+
const duration = Date.now() - startTime;
|
|
232
|
+
logger.trace(`REST ${method} ${url.pathname} completed in ${duration}ms`);
|
|
233
|
+
|
|
234
|
+
clearTimeout(timeoutId);
|
|
235
|
+
if (result instanceof Response) {
|
|
236
|
+
return wrap(result);
|
|
237
|
+
} else {
|
|
238
|
+
return wrap(new Response(JSON.stringify(result), {
|
|
239
|
+
headers: { "Content-Type": "application/json" },
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const duration = Date.now() - startTime;
|
|
244
|
+
logger.error(
|
|
245
|
+
`Error in REST endpoint ${method} ${endpoint.path} after ${duration}ms`,
|
|
246
|
+
error as any,
|
|
247
|
+
);
|
|
248
|
+
clearTimeout(timeoutId);
|
|
249
|
+
return wrap(new Response(
|
|
250
|
+
JSON.stringify({
|
|
251
|
+
error: "Internal server error",
|
|
252
|
+
code: "INTERNAL_ERROR",
|
|
253
|
+
...(process.env.NODE_ENV === 'development' && {
|
|
254
|
+
message: (error as Error)?.message,
|
|
255
|
+
}),
|
|
256
|
+
}),
|
|
257
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
258
|
+
));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (app.yoga) {
|
|
263
|
+
const response = await app.yoga(req);
|
|
264
|
+
const duration = Date.now() - startTime;
|
|
265
|
+
logger.trace(`GraphQL request completed in ${duration}ms`);
|
|
266
|
+
clearTimeout(timeoutId);
|
|
267
|
+
return response;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
clearTimeout(timeoutId);
|
|
271
|
+
return wrap(new Response("Not Found", { status: 404 }));
|
|
272
|
+
} catch (error) {
|
|
273
|
+
const duration = Date.now() - startTime;
|
|
274
|
+
const stats = (req as any).__bunsaneStats as RequestStats | undefined;
|
|
275
|
+
logger.error(
|
|
276
|
+
{
|
|
277
|
+
scope: 'App',
|
|
278
|
+
method,
|
|
279
|
+
path: url.pathname,
|
|
280
|
+
duration,
|
|
281
|
+
operationName: stats?.operationName,
|
|
282
|
+
dataLoaderCalls: stats?.dataLoaderCalls,
|
|
283
|
+
dbQueryCount: stats?.dbQueryCount,
|
|
284
|
+
dbStats: getDbStats(),
|
|
285
|
+
err: error,
|
|
286
|
+
},
|
|
287
|
+
`Request failed after ${duration}ms: ${method} ${url.pathname}`,
|
|
288
|
+
);
|
|
289
|
+
clearTimeout(timeoutId);
|
|
290
|
+
|
|
291
|
+
if ((error as Error).name === "AbortError") {
|
|
292
|
+
return wrap(new Response(
|
|
293
|
+
JSON.stringify({ error: "Request timeout", code: "TIMEOUT_ERROR" }),
|
|
294
|
+
{ status: 408, headers: { "Content-Type": "application/json" } },
|
|
295
|
+
));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return wrap(new Response(
|
|
299
|
+
JSON.stringify({
|
|
300
|
+
error: "Internal server error",
|
|
301
|
+
code: "INTERNAL_ERROR",
|
|
302
|
+
...(process.env.NODE_ENV === 'development' && {
|
|
303
|
+
message: (error as Error)?.message,
|
|
304
|
+
}),
|
|
305
|
+
}),
|
|
306
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
307
|
+
));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { logger as MainLogger } from "../Logger";
|
|
2
|
+
|
|
3
|
+
const logger = MainLogger.child({ scope: "App" });
|
|
4
|
+
|
|
5
|
+
export function collectRestEndpoints(app: any, services: any[]): void {
|
|
6
|
+
for (const service of services) {
|
|
7
|
+
const endpoints = (service.constructor as any).httpEndpoints;
|
|
8
|
+
if (!endpoints) continue;
|
|
9
|
+
|
|
10
|
+
for (const endpoint of endpoints) {
|
|
11
|
+
const endpointInfo = {
|
|
12
|
+
method: endpoint.method,
|
|
13
|
+
path: endpoint.path,
|
|
14
|
+
handler: endpoint.handler.bind(service),
|
|
15
|
+
service: service,
|
|
16
|
+
};
|
|
17
|
+
logger.trace(
|
|
18
|
+
`Registered REST endpoint: [${endpoint.method}] ${endpoint.path} for service ${service.constructor.name}`,
|
|
19
|
+
);
|
|
20
|
+
app.restEndpoints.push(endpointInfo);
|
|
21
|
+
app.restEndpointMap.set(`${endpoint.method}:${endpoint.path}`, endpointInfo);
|
|
22
|
+
|
|
23
|
+
if ((endpoint.handler as any).swaggerOperation) {
|
|
24
|
+
const classTags = (service.constructor as any).swaggerClassTags || [];
|
|
25
|
+
const methodTags =
|
|
26
|
+
(service.constructor as any).swaggerMethodTags?.[endpoint.handler.name] || [];
|
|
27
|
+
const allTags = [...classTags, ...methodTags];
|
|
28
|
+
|
|
29
|
+
logger.trace(
|
|
30
|
+
`Generating OpenAPI spec for endpoint: [${endpoint.method}] ${endpoint.path} with tags: ${allTags.join(", ")}`,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const operation = { ...(endpoint.handler as any).swaggerOperation };
|
|
34
|
+
if (allTags.length > 0) {
|
|
35
|
+
operation.tags = [...(operation.tags || []), ...allTags];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
app.openAPISpecGenerator!.addEndpoint({
|
|
39
|
+
method: endpoint.method,
|
|
40
|
+
path: endpoint.path,
|
|
41
|
+
operation,
|
|
42
|
+
});
|
|
43
|
+
logger.trace(
|
|
44
|
+
`Registered OpenAPI spec for endpoint: [${endpoint.method}] ${endpoint.path}`,
|
|
45
|
+
);
|
|
46
|
+
} else if (app.enforceDocs) {
|
|
47
|
+
logger.warn(
|
|
48
|
+
`No swagger operation found for endpoint: [${endpoint.method}] ${endpoint.path} in service ${service.constructor.name}`,
|
|
49
|
+
);
|
|
50
|
+
app.openAPISpecGenerator!.addEndpoint({
|
|
51
|
+
method: endpoint.method,
|
|
52
|
+
path: endpoint.path,
|
|
53
|
+
operation: {
|
|
54
|
+
summary: `No description for ${endpoint.path}. Don't use this endpoint until it's properly documented!`,
|
|
55
|
+
requestBody: {
|
|
56
|
+
content: {
|
|
57
|
+
"application/json": {
|
|
58
|
+
schema: {},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
responses: {
|
|
63
|
+
"200": {
|
|
64
|
+
description: "Success",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|