@gajae-code/stats 0.11.7 → 0.11.9

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.
@@ -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<any>;
14
+ export declare function sync(): Promise<SyncResponse>;
10
15
  export declare function getBehaviorDashboardStats(range?: string): Promise<BehaviorDashboardStats>;
16
+ export {};
@@ -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": "@gajae-code/stats",
4
- "version": "0.11.7",
4
+ "version": "0.11.9",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -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
- "@gajae-code/ai": "0.11.7",
41
- "@gajae-code/utils": "0.11.7",
41
+ "@gajae-code/ai": "0.11.9",
42
+ "@gajae-code/utils": "0.11.9",
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<any> {
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> {
package/src/server.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from "./aggregator";
16
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();
@@ -31,6 +32,24 @@ const IS_BUN_COMPILED =
31
32
  import.meta.url.includes("%7EBUN");
32
33
  const compiledClientAssets = createCompiledClientAssetHandler(() => getEmbeddedClientArchive?.() ?? null);
33
34
 
35
+ interface SyncResult {
36
+ processed: number;
37
+ files: number;
38
+ }
39
+
40
+ export interface StatsServerOptions {
41
+ getDashboardStats?: (range?: string | null) => Promise<DashboardStats>;
42
+ syncAllSessions?: () => Promise<SyncResult>;
43
+ getTotalMessageCount?: () => Promise<number>;
44
+ }
45
+
46
+ interface ApiContext {
47
+ getDashboardStats: (range?: string | null) => Promise<DashboardStats>;
48
+ syncAllSessions: () => Promise<SyncResult>;
49
+ getTotalMessageCount: () => Promise<number>;
50
+ syncInProgress: boolean;
51
+ }
52
+
34
53
  async function getLatestMtime(dir: string): Promise<number> {
35
54
  const entries = await fs.readdir(dir, { withFileTypes: true });
36
55
 
@@ -115,7 +134,7 @@ const ensureClientBuild = async () => {
115
134
  /**
116
135
  * Handle API requests.
117
136
  */
118
- async function handleApi(req: Request): Promise<Response> {
137
+ async function handleApi(req: Request, context: ApiContext): Promise<Response> {
119
138
  const url = new URL(req.url);
120
139
  const path = url.pathname;
121
140
 
@@ -123,7 +142,7 @@ async function handleApi(req: Request): Promise<Response> {
123
142
  const range = url.searchParams.get("range");
124
143
 
125
144
  if (path === "/api/stats") {
126
- const stats = await getDashboardStats(range);
145
+ const stats = await context.getDashboardStats(range);
127
146
  return Response.json(stats);
128
147
  }
129
148
 
@@ -160,17 +179,17 @@ async function handleApi(req: Request): Promise<Response> {
160
179
  }
161
180
 
162
181
  if (path === "/api/stats/models") {
163
- const stats = await getDashboardStats(range);
182
+ const stats = await context.getDashboardStats(range);
164
183
  return Response.json(stats.byModel);
165
184
  }
166
185
 
167
186
  if (path === "/api/stats/folders") {
168
- const stats = await getDashboardStats(range);
187
+ const stats = await context.getDashboardStats(range);
169
188
  return Response.json(stats.byFolder);
170
189
  }
171
190
 
172
191
  if (path === "/api/stats/timeseries") {
173
- const stats = await getDashboardStats(range);
192
+ const stats = await context.getDashboardStats(range);
174
193
  return Response.json(stats.timeSeries);
175
194
  }
176
195
 
@@ -183,14 +202,62 @@ async function handleApi(req: Request): Promise<Response> {
183
202
  }
184
203
 
185
204
  if (path === "/api/sync") {
186
- const result = await syncAllSessions();
187
- const count = await getTotalMessageCount();
188
- return Response.json({ ...result, totalMessages: count });
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
+ }
189
216
  }
190
217
 
191
218
  return new Response("Not Found", { status: 404 });
192
219
  }
193
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
+
194
261
  /**
195
262
  * Handle static file requests.
196
263
  */
@@ -217,52 +284,43 @@ async function handleStatic(requestPath: string): Promise<Response> {
217
284
  /**
218
285
  * Start the HTTP server.
219
286
  */
220
- export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> {
287
+ export async function startServer(
288
+ port = 3847,
289
+ options: StatsServerOptions = {},
290
+ ): Promise<{ port: number; stop: () => void }> {
221
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
+ };
222
298
 
223
299
  const server = Bun.serve({
224
300
  hostname: "127.0.0.1",
225
301
  port,
226
302
  async fetch(req) {
227
- const url = new URL(req.url);
303
+ let url: URL;
304
+ try {
305
+ url = new URL(req.url);
306
+ } catch {
307
+ return forbidden();
308
+ }
228
309
  const path = url.pathname;
229
310
 
230
- // CORS headers for local development
231
- const corsHeaders = {
232
- "Access-Control-Allow-Origin": "*",
233
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
234
- "Access-Control-Allow-Headers": "Content-Type",
235
- };
236
-
237
- if (req.method === "OPTIONS") {
238
- 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;
239
314
  }
240
315
 
241
316
  try {
242
- let response: Response;
243
-
244
317
  if (path.startsWith("/api/")) {
245
- response = await handleApi(req);
246
- } else {
247
- response = await handleStatic(path);
318
+ return await handleApi(req, apiContext);
248
319
  }
249
-
250
- // Add CORS headers to all responses
251
- const headers = new Headers(response.headers);
252
- for (const [key, value] of Object.entries(corsHeaders)) {
253
- headers.set(key, value);
254
- }
255
-
256
- return new Response(response.body, {
257
- status: response.status,
258
- headers,
259
- });
320
+ return await handleStatic(path);
260
321
  } catch (error) {
261
322
  console.error("Server error:", error);
262
- return Response.json(
263
- { error: error instanceof Error ? error.message : "Unknown error" },
264
- { status: 500, headers: corsHeaders },
265
- );
323
+ return Response.json({ error: error instanceof Error ? error.message : "Unknown error" }, { status: 500 });
266
324
  }
267
325
  },
268
326
  });