@gajae-code/stats 0.11.5 → 0.11.7
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/stats",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.7",
|
|
5
5
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@gajae-code/ai": "0.11.
|
|
41
|
-
"@gajae-code/utils": "0.11.
|
|
40
|
+
"@gajae-code/ai": "0.11.7",
|
|
41
|
+
"@gajae-code/utils": "0.11.7",
|
|
42
42
|
"@tailwindcss/node": "^4.2.4",
|
|
43
43
|
"chart.js": "^4.5.1",
|
|
44
44
|
"date-fns": "^4.1.0",
|
|
@@ -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,6 +13,7 @@ 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
18
|
|
|
19
19
|
const getEmbeddedClientArchive = (() => {
|
|
@@ -29,59 +29,7 @@ const IS_BUN_COMPILED =
|
|
|
29
29
|
import.meta.url.includes("$bunfs") ||
|
|
30
30
|
import.meta.url.includes("~BUN") ||
|
|
31
31
|
import.meta.url.includes("%7EBUN");
|
|
32
|
-
|
|
33
|
-
const COMPILED_CLIENT_DIR_ROOT = path.join(os.tmpdir(), "gjc-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;
|
|
41
|
-
}
|
|
42
|
-
|
|
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
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
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;
|
|
84
|
-
}
|
|
32
|
+
const compiledClientAssets = createCompiledClientAssetHandler(() => getEmbeddedClientArchive?.() ?? null);
|
|
85
33
|
|
|
86
34
|
async function getLatestMtime(dir: string): Promise<number> {
|
|
87
35
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
@@ -247,9 +195,10 @@ async function handleApi(req: Request): Promise<Response> {
|
|
|
247
195
|
* Handle static file requests.
|
|
248
196
|
*/
|
|
249
197
|
async function handleStatic(requestPath: string): Promise<Response> {
|
|
250
|
-
|
|
198
|
+
if (IS_BUN_COMPILED) return await compiledClientAssets.response(requestPath);
|
|
199
|
+
|
|
251
200
|
const filePath = requestPath === "/" ? "/index.html" : requestPath;
|
|
252
|
-
const fullPath = path.join(
|
|
201
|
+
const fullPath = path.join(STATIC_DIR, filePath);
|
|
253
202
|
|
|
254
203
|
const file = Bun.file(fullPath);
|
|
255
204
|
if (await file.exists()) {
|
|
@@ -257,7 +206,7 @@ async function handleStatic(requestPath: string): Promise<Response> {
|
|
|
257
206
|
}
|
|
258
207
|
|
|
259
208
|
// SPA fallback
|
|
260
|
-
const index = Bun.file(path.join(
|
|
209
|
+
const index = Bun.file(path.join(STATIC_DIR, "index.html"));
|
|
261
210
|
if (await index.exists()) {
|
|
262
211
|
return new Response(index);
|
|
263
212
|
}
|