@sayknow-cli/stats 0.4.7 → 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.
@@ -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 {};
@@ -0,0 +1,3 @@
1
+ export declare function createCompiledClientAssetHandler(loadArchiveBytes: () => Uint8Array | null | Promise<Uint8Array | null>): {
2
+ response(requestPath: string): Promise<Response>;
3
+ };
@@ -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.7",
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.4.7",
41
- "@sayknow-cli/utils": "0.4.7",
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<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> {
@@ -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
- const COMPILED_CLIENT_DIR_ROOT = path.join(os.tmpdir(), "skc-stats-client");
34
- let compiledClientDirPromise: Promise<string> | null = null;
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
- async function extractEmbeddedClientArchive(archiveBytes: Buffer, outputDir: string): Promise<void> {
44
- const archive = new Bun.Archive(archiveBytes);
45
- const files = await archive.files();
46
- const extractRoot = path.resolve(outputDir);
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
- async function getCompiledClientDir(): Promise<string> {
60
- if (!IS_BUN_COMPILED) return STATIC_DIR;
61
- if (compiledClientDirPromise) return compiledClientDirPromise;
62
-
63
- const archiveBytes = getEmbeddedClientArchive?.();
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
- const result = await syncAllSessions();
239
- const count = await getTotalMessageCount();
240
- 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
+ }
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
- const staticDir = IS_BUN_COMPILED ? await getCompiledClientDir() : STATIC_DIR;
265
+ if (IS_BUN_COMPILED) return await compiledClientAssets.response(requestPath);
266
+
251
267
  const filePath = requestPath === "/" ? "/index.html" : requestPath;
252
- const fullPath = path.join(staticDir, filePath);
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(staticDir, "index.html"));
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(port = 3847): Promise<{ port: number; stop: () => void }> {
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
- const url = new URL(req.url);
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
- // CORS headers for local development
282
- const corsHeaders = {
283
- "Access-Control-Allow-Origin": "*",
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
- response = await handleApi(req);
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
  });