@sayknow-cli/stats 0.4.6 → 0.5.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.
- package/README.md +7 -1
- package/dist/client/index.js +39 -39
- package/dist/types/client/api.d.ts +7 -1
- package/dist/types/compiled-client-assets.d.ts +3 -0
- package/dist/types/server.d.ts +12 -1
- package/package.json +4 -3
- package/src/client/api.ts +9 -3
- package/src/compiled-client-assets.ts +96 -0
- package/src/server.ts +97 -90
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { BehaviorDashboardStats, CostDashboardStats, DashboardStats, MessageStats, ModelDashboardStats, OverviewStats, RequestDetails } from "./types";
|
|
2
|
+
interface SyncResponse {
|
|
3
|
+
processed: number;
|
|
4
|
+
files: number;
|
|
5
|
+
totalMessages: number;
|
|
6
|
+
}
|
|
2
7
|
export declare function getStats(range?: string): Promise<DashboardStats>;
|
|
3
8
|
export declare function getOverviewStats(range?: string): Promise<OverviewStats>;
|
|
4
9
|
export declare function getModelDashboardStats(range?: string): Promise<ModelDashboardStats>;
|
|
@@ -6,5 +11,6 @@ export declare function getCostDashboardStats(range?: string): Promise<CostDashb
|
|
|
6
11
|
export declare function getRecentRequests(limit?: number): Promise<MessageStats[]>;
|
|
7
12
|
export declare function getRecentErrors(limit?: number): Promise<MessageStats[]>;
|
|
8
13
|
export declare function getRequestDetails(id: number): Promise<RequestDetails>;
|
|
9
|
-
export declare function sync(): Promise<
|
|
14
|
+
export declare function sync(): Promise<SyncResponse>;
|
|
10
15
|
export declare function getBehaviorDashboardStats(range?: string): Promise<BehaviorDashboardStats>;
|
|
16
|
+
export {};
|
package/dist/types/server.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
import type { DashboardStats } from "./types";
|
|
2
|
+
interface SyncResult {
|
|
3
|
+
processed: number;
|
|
4
|
+
files: number;
|
|
5
|
+
}
|
|
6
|
+
export interface StatsServerOptions {
|
|
7
|
+
getDashboardStats?: (range?: string | null) => Promise<DashboardStats>;
|
|
8
|
+
syncAllSessions?: () => Promise<SyncResult>;
|
|
9
|
+
getTotalMessageCount?: () => Promise<number>;
|
|
10
|
+
}
|
|
1
11
|
/**
|
|
2
12
|
* Start the HTTP server.
|
|
3
13
|
*/
|
|
4
|
-
export declare function startServer(port?: number): Promise<{
|
|
14
|
+
export declare function startServer(port?: number, options?: StatsServerOptions): Promise<{
|
|
5
15
|
port: number;
|
|
6
16
|
stop: () => void;
|
|
7
17
|
}>;
|
|
18
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/stats",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
6
6
|
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "bun run build.ts",
|
|
32
32
|
"dev": "bun run src/index.ts",
|
|
33
|
+
"test": "bun test",
|
|
33
34
|
"check": "biome check . && bun run check:types",
|
|
34
35
|
"check:types": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
35
36
|
"lint": "biome lint .",
|
|
@@ -37,8 +38,8 @@
|
|
|
37
38
|
"fmt": "biome format --write ."
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
|
-
"@sayknow-cli/ai": "0.
|
|
41
|
-
"@sayknow-cli/utils": "0.
|
|
41
|
+
"@sayknow-cli/ai": "0.5.0",
|
|
42
|
+
"@sayknow-cli/utils": "0.5.0",
|
|
42
43
|
"@tailwindcss/node": "^4.2.4",
|
|
43
44
|
"chart.js": "^4.5.1",
|
|
44
45
|
"date-fns": "^4.1.0",
|
package/src/client/api.ts
CHANGED
|
@@ -10,6 +10,12 @@ import type {
|
|
|
10
10
|
|
|
11
11
|
const API_BASE = "/api";
|
|
12
12
|
|
|
13
|
+
interface SyncResponse {
|
|
14
|
+
processed: number;
|
|
15
|
+
files: number;
|
|
16
|
+
totalMessages: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
export async function getStats(range = "24h"): Promise<DashboardStats> {
|
|
14
20
|
const res = await fetch(`${API_BASE}/stats?range=${encodeURIComponent(range)}`);
|
|
15
21
|
if (!res.ok) throw new Error("Failed to fetch stats");
|
|
@@ -52,10 +58,10 @@ export async function getRequestDetails(id: number): Promise<RequestDetails> {
|
|
|
52
58
|
return res.json() as Promise<RequestDetails>;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
export async function sync(): Promise<
|
|
56
|
-
const res = await fetch(`${API_BASE}/sync
|
|
61
|
+
export async function sync(): Promise<SyncResponse> {
|
|
62
|
+
const res = await fetch(`${API_BASE}/sync`, { method: "POST" });
|
|
57
63
|
if (!res.ok) throw new Error("Failed to sync");
|
|
58
|
-
return res.json()
|
|
64
|
+
return res.json() as Promise<SyncResponse>;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
export async function getBehaviorDashboardStats(range = "24h"): Promise<BehaviorDashboardStats> {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
|
|
3
|
+
const INDEX_ASSET = "index.html";
|
|
4
|
+
const UNSAFE_ARCHIVE_NAME = /[\u0000-\u001f\u007f%?#]/u;
|
|
5
|
+
const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:\//;
|
|
6
|
+
|
|
7
|
+
const CONTENT_TYPES: Readonly<Record<string, string>> = Object.freeze({
|
|
8
|
+
".css": "text/css; charset=utf-8",
|
|
9
|
+
".gif": "image/gif",
|
|
10
|
+
".html": "text/html; charset=utf-8",
|
|
11
|
+
".ico": "image/x-icon",
|
|
12
|
+
".jpeg": "image/jpeg",
|
|
13
|
+
".jpg": "image/jpeg",
|
|
14
|
+
".js": "text/javascript; charset=utf-8",
|
|
15
|
+
".json": "application/json; charset=utf-8",
|
|
16
|
+
".map": "application/json; charset=utf-8",
|
|
17
|
+
".png": "image/png",
|
|
18
|
+
".svg": "image/svg+xml",
|
|
19
|
+
".ttf": "font/ttf",
|
|
20
|
+
".webp": "image/webp",
|
|
21
|
+
".woff": "font/woff",
|
|
22
|
+
".woff2": "font/woff2",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function normalizeArchiveName(archiveName: string): string {
|
|
26
|
+
const normalized = archiveName.normalize("NFC");
|
|
27
|
+
const segments = normalized.split("/");
|
|
28
|
+
if (
|
|
29
|
+
!normalized ||
|
|
30
|
+
normalized.startsWith("/") ||
|
|
31
|
+
WINDOWS_ABSOLUTE_PATH.test(normalized) ||
|
|
32
|
+
normalized.includes("\\") ||
|
|
33
|
+
UNSAFE_ARCHIVE_NAME.test(normalized) ||
|
|
34
|
+
segments.some(segment => !segment || segment === "." || segment === "..")
|
|
35
|
+
) {
|
|
36
|
+
throw new Error(`Unsafe compiled stats client archive entry: ${JSON.stringify(archiveName)}`);
|
|
37
|
+
}
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function contentType(assetName: string): string {
|
|
42
|
+
return CONTENT_TYPES[path.posix.extname(assetName).toLowerCase()] ?? "application/octet-stream";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function parseArchive(archiveBytes: Uint8Array): Promise<ReadonlyMap<string, Blob>> {
|
|
46
|
+
const files = await new Bun.Archive(archiveBytes).files();
|
|
47
|
+
const assets = new Map<string, Blob>();
|
|
48
|
+
for (const [archiveName, file] of files) {
|
|
49
|
+
const assetName = normalizeArchiveName(archiveName);
|
|
50
|
+
if (assets.has(assetName)) {
|
|
51
|
+
throw new Error(`Duplicate compiled stats client archive entry: ${JSON.stringify(assetName)}`);
|
|
52
|
+
}
|
|
53
|
+
assets.set(assetName, new Blob([file], { type: contentType(assetName) }));
|
|
54
|
+
}
|
|
55
|
+
if (!assets.has(INDEX_ASSET)) {
|
|
56
|
+
throw new Error("Compiled stats client archive is missing index.html");
|
|
57
|
+
}
|
|
58
|
+
return assets;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function responseForPath(assets: ReadonlyMap<string, Blob>, requestPath: string): Response {
|
|
62
|
+
const assetName = requestPath === "/" ? INDEX_ASSET : requestPath.replace(/^\//, "");
|
|
63
|
+
const asset = assets.get(assetName);
|
|
64
|
+
if (asset) return new Response(asset);
|
|
65
|
+
return new Response(assets.get(INDEX_ASSET));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createCompiledClientAssetHandler(
|
|
69
|
+
loadArchiveBytes: () => Uint8Array | null | Promise<Uint8Array | null>,
|
|
70
|
+
): { response(requestPath: string): Promise<Response> } {
|
|
71
|
+
let initialization: Promise<ReadonlyMap<string, Blob>> | null = null;
|
|
72
|
+
|
|
73
|
+
async function getAssets(): Promise<ReadonlyMap<string, Blob>> {
|
|
74
|
+
if (initialization) return await initialization;
|
|
75
|
+
const attempt = (async () => {
|
|
76
|
+
const archiveBytes = await loadArchiveBytes();
|
|
77
|
+
if (!archiveBytes) {
|
|
78
|
+
throw new Error("Compiled stats client bundle missing. Rebuild binary with embedded stats assets.");
|
|
79
|
+
}
|
|
80
|
+
return await parseArchive(archiveBytes);
|
|
81
|
+
})();
|
|
82
|
+
initialization = attempt;
|
|
83
|
+
try {
|
|
84
|
+
return await attempt;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (initialization === attempt) initialization = null;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
async response(requestPath) {
|
|
93
|
+
return responseForPath(await getAssets(), requestPath);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
-
import * as os from "node:os";
|
|
3
2
|
import * as path from "node:path";
|
|
4
3
|
import { $ } from "bun";
|
|
5
4
|
import {
|
|
@@ -14,7 +13,9 @@ import {
|
|
|
14
13
|
getTotalMessageCount,
|
|
15
14
|
syncAllSessions,
|
|
16
15
|
} from "./aggregator";
|
|
16
|
+
import { createCompiledClientAssetHandler } from "./compiled-client-assets";
|
|
17
17
|
import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
|
|
18
|
+
import type { DashboardStats } from "./types";
|
|
18
19
|
|
|
19
20
|
const getEmbeddedClientArchive = (() => {
|
|
20
21
|
const txt = embeddedClientArchiveTxt.replaceAll(/[\s\r\n]/g, "").trim();
|
|
@@ -29,58 +30,24 @@ const IS_BUN_COMPILED =
|
|
|
29
30
|
import.meta.url.includes("$bunfs") ||
|
|
30
31
|
import.meta.url.includes("~BUN") ||
|
|
31
32
|
import.meta.url.includes("%7EBUN");
|
|
33
|
+
const compiledClientAssets = createCompiledClientAssetHandler(() => getEmbeddedClientArchive?.() ?? null);
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
function sanitizeArchivePath(archivePath: string): string | null {
|
|
37
|
-
const normalized = archivePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
38
|
-
if (!normalized || normalized === ".") return null;
|
|
39
|
-
if (normalized.includes("..") || path.isAbsolute(normalized)) return null;
|
|
40
|
-
return normalized;
|
|
35
|
+
interface SyncResult {
|
|
36
|
+
processed: number;
|
|
37
|
+
files: number;
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
for (const [archivePath, file] of files) {
|
|
49
|
-
const sanitizedPath = sanitizeArchivePath(archivePath);
|
|
50
|
-
if (!sanitizedPath) continue;
|
|
51
|
-
const destinationPath = path.resolve(extractRoot, sanitizedPath);
|
|
52
|
-
if (!destinationPath.startsWith(extractRoot + path.sep)) {
|
|
53
|
-
throw new Error(`Archive entry escapes extraction directory: ${archivePath}`);
|
|
54
|
-
}
|
|
55
|
-
await Bun.write(destinationPath, file);
|
|
56
|
-
}
|
|
40
|
+
export interface StatsServerOptions {
|
|
41
|
+
getDashboardStats?: (range?: string | null) => Promise<DashboardStats>;
|
|
42
|
+
syncAllSessions?: () => Promise<SyncResult>;
|
|
43
|
+
getTotalMessageCount?: () => Promise<number>;
|
|
57
44
|
}
|
|
58
45
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!archiveBytes) {
|
|
65
|
-
throw new Error("Compiled stats client bundle missing. Rebuild binary with embedded stats assets.");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
compiledClientDirPromise = (async () => {
|
|
69
|
-
const bundleHash = Bun.hash(archiveBytes).toString(16);
|
|
70
|
-
const outputDir = path.join(COMPILED_CLIENT_DIR_ROOT, bundleHash);
|
|
71
|
-
const markerPath = path.join(outputDir, "index.html");
|
|
72
|
-
try {
|
|
73
|
-
const marker = await fs.stat(markerPath);
|
|
74
|
-
if (marker.isFile()) return outputDir;
|
|
75
|
-
} catch {}
|
|
76
|
-
|
|
77
|
-
await fs.rm(outputDir, { recursive: true, force: true });
|
|
78
|
-
await fs.mkdir(outputDir, { recursive: true });
|
|
79
|
-
await extractEmbeddedClientArchive(archiveBytes, outputDir);
|
|
80
|
-
return outputDir;
|
|
81
|
-
})();
|
|
82
|
-
|
|
83
|
-
return compiledClientDirPromise;
|
|
46
|
+
interface ApiContext {
|
|
47
|
+
getDashboardStats: (range?: string | null) => Promise<DashboardStats>;
|
|
48
|
+
syncAllSessions: () => Promise<SyncResult>;
|
|
49
|
+
getTotalMessageCount: () => Promise<number>;
|
|
50
|
+
syncInProgress: boolean;
|
|
84
51
|
}
|
|
85
52
|
|
|
86
53
|
async function getLatestMtime(dir: string): Promise<number> {
|
|
@@ -167,7 +134,7 @@ const ensureClientBuild = async () => {
|
|
|
167
134
|
/**
|
|
168
135
|
* Handle API requests.
|
|
169
136
|
*/
|
|
170
|
-
async function handleApi(req: Request): Promise<Response> {
|
|
137
|
+
async function handleApi(req: Request, context: ApiContext): Promise<Response> {
|
|
171
138
|
const url = new URL(req.url);
|
|
172
139
|
const path = url.pathname;
|
|
173
140
|
|
|
@@ -175,7 +142,7 @@ async function handleApi(req: Request): Promise<Response> {
|
|
|
175
142
|
const range = url.searchParams.get("range");
|
|
176
143
|
|
|
177
144
|
if (path === "/api/stats") {
|
|
178
|
-
const stats = await getDashboardStats(range);
|
|
145
|
+
const stats = await context.getDashboardStats(range);
|
|
179
146
|
return Response.json(stats);
|
|
180
147
|
}
|
|
181
148
|
|
|
@@ -212,17 +179,17 @@ async function handleApi(req: Request): Promise<Response> {
|
|
|
212
179
|
}
|
|
213
180
|
|
|
214
181
|
if (path === "/api/stats/models") {
|
|
215
|
-
const stats = await getDashboardStats(range);
|
|
182
|
+
const stats = await context.getDashboardStats(range);
|
|
216
183
|
return Response.json(stats.byModel);
|
|
217
184
|
}
|
|
218
185
|
|
|
219
186
|
if (path === "/api/stats/folders") {
|
|
220
|
-
const stats = await getDashboardStats(range);
|
|
187
|
+
const stats = await context.getDashboardStats(range);
|
|
221
188
|
return Response.json(stats.byFolder);
|
|
222
189
|
}
|
|
223
190
|
|
|
224
191
|
if (path === "/api/stats/timeseries") {
|
|
225
|
-
const stats = await getDashboardStats(range);
|
|
192
|
+
const stats = await context.getDashboardStats(range);
|
|
226
193
|
return Response.json(stats.timeSeries);
|
|
227
194
|
}
|
|
228
195
|
|
|
@@ -235,21 +202,70 @@ async function handleApi(req: Request): Promise<Response> {
|
|
|
235
202
|
}
|
|
236
203
|
|
|
237
204
|
if (path === "/api/sync") {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
205
|
+
if (context.syncInProgress) {
|
|
206
|
+
return Response.json({ error: "Sync already in progress" }, { status: 409 });
|
|
207
|
+
}
|
|
208
|
+
context.syncInProgress = true;
|
|
209
|
+
try {
|
|
210
|
+
const result = await context.syncAllSessions();
|
|
211
|
+
const count = await context.getTotalMessageCount();
|
|
212
|
+
return Response.json({ ...result, totalMessages: count });
|
|
213
|
+
} finally {
|
|
214
|
+
context.syncInProgress = false;
|
|
215
|
+
}
|
|
241
216
|
}
|
|
242
217
|
|
|
243
218
|
return new Response("Not Found", { status: 404 });
|
|
244
219
|
}
|
|
245
220
|
|
|
221
|
+
function forbidden(): Response {
|
|
222
|
+
return new Response("Forbidden", { status: 403 });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function methodNotAllowed(allowedMethod: "GET" | "POST"): Response {
|
|
226
|
+
return new Response("Method Not Allowed", { status: 405, headers: { Allow: allowedMethod } });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function validateApiRequest(req: Request, url: URL, boundPort: number): Response | null {
|
|
230
|
+
const authority = req.headers.get("Host");
|
|
231
|
+
const allowedAuthorities =
|
|
232
|
+
boundPort === 80
|
|
233
|
+
? new Set(["localhost", "localhost:80", "127.0.0.1", "127.0.0.1:80"])
|
|
234
|
+
: new Set([`localhost:${boundPort}`, `127.0.0.1:${boundPort}`]);
|
|
235
|
+
if (!authority || !allowedAuthorities.has(authority)) return forbidden();
|
|
236
|
+
|
|
237
|
+
if (url.protocol !== "http:" || (url.hostname !== "localhost" && url.hostname !== "127.0.0.1")) {
|
|
238
|
+
return forbidden();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const requestPort = url.port ? Number.parseInt(url.port, 10) : 80;
|
|
242
|
+
if (requestPort !== boundPort) return forbidden();
|
|
243
|
+
|
|
244
|
+
const origin = req.headers.get("Origin");
|
|
245
|
+
if (origin !== null) {
|
|
246
|
+
try {
|
|
247
|
+
const parsedOrigin = new URL(origin);
|
|
248
|
+
if (parsedOrigin.origin !== origin || origin !== url.origin) return forbidden();
|
|
249
|
+
} catch {
|
|
250
|
+
return forbidden();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const allowedMethod = url.pathname === "/api/sync" ? "POST" : "GET";
|
|
255
|
+
if (req.method !== allowedMethod) return methodNotAllowed(allowedMethod);
|
|
256
|
+
if (url.pathname === "/api/sync" && origin === null) return forbidden();
|
|
257
|
+
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
246
261
|
/**
|
|
247
262
|
* Handle static file requests.
|
|
248
263
|
*/
|
|
249
264
|
async function handleStatic(requestPath: string): Promise<Response> {
|
|
250
|
-
|
|
265
|
+
if (IS_BUN_COMPILED) return await compiledClientAssets.response(requestPath);
|
|
266
|
+
|
|
251
267
|
const filePath = requestPath === "/" ? "/index.html" : requestPath;
|
|
252
|
-
const fullPath = path.join(
|
|
268
|
+
const fullPath = path.join(STATIC_DIR, filePath);
|
|
253
269
|
|
|
254
270
|
const file = Bun.file(fullPath);
|
|
255
271
|
if (await file.exists()) {
|
|
@@ -257,7 +273,7 @@ async function handleStatic(requestPath: string): Promise<Response> {
|
|
|
257
273
|
}
|
|
258
274
|
|
|
259
275
|
// SPA fallback
|
|
260
|
-
const index = Bun.file(path.join(
|
|
276
|
+
const index = Bun.file(path.join(STATIC_DIR, "index.html"));
|
|
261
277
|
if (await index.exists()) {
|
|
262
278
|
return new Response(index);
|
|
263
279
|
}
|
|
@@ -268,52 +284,43 @@ async function handleStatic(requestPath: string): Promise<Response> {
|
|
|
268
284
|
/**
|
|
269
285
|
* Start the HTTP server.
|
|
270
286
|
*/
|
|
271
|
-
export async function startServer(
|
|
287
|
+
export async function startServer(
|
|
288
|
+
port = 3847,
|
|
289
|
+
options: StatsServerOptions = {},
|
|
290
|
+
): Promise<{ port: number; stop: () => void }> {
|
|
272
291
|
await ensureClientBuild();
|
|
292
|
+
const apiContext: ApiContext = {
|
|
293
|
+
getDashboardStats: options.getDashboardStats ?? getDashboardStats,
|
|
294
|
+
syncAllSessions: options.syncAllSessions ?? syncAllSessions,
|
|
295
|
+
getTotalMessageCount: options.getTotalMessageCount ?? getTotalMessageCount,
|
|
296
|
+
syncInProgress: false,
|
|
297
|
+
};
|
|
273
298
|
|
|
274
299
|
const server = Bun.serve({
|
|
275
300
|
hostname: "127.0.0.1",
|
|
276
301
|
port,
|
|
277
302
|
async fetch(req) {
|
|
278
|
-
|
|
303
|
+
let url: URL;
|
|
304
|
+
try {
|
|
305
|
+
url = new URL(req.url);
|
|
306
|
+
} catch {
|
|
307
|
+
return forbidden();
|
|
308
|
+
}
|
|
279
309
|
const path = url.pathname;
|
|
280
310
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
285
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
if (req.method === "OPTIONS") {
|
|
289
|
-
return new Response(null, { headers: corsHeaders });
|
|
311
|
+
if (path.startsWith("/api/")) {
|
|
312
|
+
const policyResponse = validateApiRequest(req, url, server.port ?? port);
|
|
313
|
+
if (policyResponse) return policyResponse;
|
|
290
314
|
}
|
|
291
315
|
|
|
292
316
|
try {
|
|
293
|
-
let response: Response;
|
|
294
|
-
|
|
295
317
|
if (path.startsWith("/api/")) {
|
|
296
|
-
|
|
297
|
-
} else {
|
|
298
|
-
response = await handleStatic(path);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Add CORS headers to all responses
|
|
302
|
-
const headers = new Headers(response.headers);
|
|
303
|
-
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
304
|
-
headers.set(key, value);
|
|
318
|
+
return await handleApi(req, apiContext);
|
|
305
319
|
}
|
|
306
|
-
|
|
307
|
-
return new Response(response.body, {
|
|
308
|
-
status: response.status,
|
|
309
|
-
headers,
|
|
310
|
-
});
|
|
320
|
+
return await handleStatic(path);
|
|
311
321
|
} catch (error) {
|
|
312
322
|
console.error("Server error:", error);
|
|
313
|
-
return Response.json(
|
|
314
|
-
{ error: error instanceof Error ? error.message : "Unknown error" },
|
|
315
|
-
{ status: 500, headers: corsHeaders },
|
|
316
|
-
);
|
|
323
|
+
return Response.json({ error: error instanceof Error ? error.message : "Unknown error" }, { status: 500 });
|
|
317
324
|
}
|
|
318
325
|
},
|
|
319
326
|
});
|