6b76463941
- Fixed npm CLI bundling by invoking `generate-client-bundle.ts --generate` before build and `--reset` after. - Added `PI_BUNDLED` handling in `packages/stats/src/server.ts` and switched bundled distributions to embedded assets. - Decoded embedded archives via `decodeEmbeddedClientArchive` and treated legacy placeholder content as missing. - Handled missing dashboard source/build directories as ENOENT during mtime scans to avoid startup crashes. - Extended `--smoke-test` to start the stats server and validate dashboard HTML contains expected asset markers.
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
import type { Dirent } from "node:fs";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
import { $ } from "bun";
|
|
import {
|
|
getBehaviorDashboardStats,
|
|
getCostDashboardStats,
|
|
getDashboardStats,
|
|
getModelDashboardStats,
|
|
getOverviewStats,
|
|
getRecentErrors,
|
|
getRecentRequests,
|
|
getRequestDetails,
|
|
getTotalMessageCount,
|
|
syncAllSessions,
|
|
} from "./aggregator";
|
|
import { decodeEmbeddedClientArchive } from "./embedded-client";
|
|
import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
|
|
|
|
const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
|
|
|
|
const CLIENT_DIR = path.join(import.meta.dir, "client");
|
|
const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client");
|
|
const IS_BUN_COMPILED =
|
|
Boolean(process.env.PI_COMPILED || Bun.env.PI_COMPILED) ||
|
|
import.meta.url.includes("$bunfs") ||
|
|
import.meta.url.includes("~BUN") ||
|
|
import.meta.url.includes("%7EBUN");
|
|
// The prepacked npm bundle (coding-agent dist/cli.js) constant-folds
|
|
// process.env.PI_BUNDLED at build time. Like compiled binaries, it ships no
|
|
// dashboard sources or prebuilt dist/client next to the bundle, so the
|
|
// embedded archive is the only viable asset source.
|
|
const IS_PREBUILT = IS_BUN_COMPILED || Boolean(process.env.PI_BUNDLED || Bun.env.PI_BUNDLED);
|
|
const USE_EMBEDDED_CLIENT = EMBEDDED_CLIENT_ARCHIVE !== null || IS_PREBUILT;
|
|
|
|
const EMBEDDED_CLIENT_DIR_ROOT = path.join(os.tmpdir(), "omp-stats-client");
|
|
let embeddedClientDirPromise: Promise<string> | null = null;
|
|
|
|
function sanitizeArchivePath(archivePath: string): string | null {
|
|
const normalized = archivePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
if (!normalized || normalized === ".") return null;
|
|
if (normalized.includes("..") || path.isAbsolute(normalized)) return null;
|
|
return normalized;
|
|
}
|
|
|
|
async function extractEmbeddedClientArchive(archiveBytes: Buffer, outputDir: string): Promise<void> {
|
|
const archive = new Bun.Archive(archiveBytes);
|
|
const files = await archive.files();
|
|
const extractRoot = path.resolve(outputDir);
|
|
|
|
for (const [archivePath, file] of files) {
|
|
const sanitizedPath = sanitizeArchivePath(archivePath);
|
|
if (!sanitizedPath) continue;
|
|
const destinationPath = path.resolve(extractRoot, sanitizedPath);
|
|
if (!destinationPath.startsWith(extractRoot + path.sep)) {
|
|
throw new Error(`Archive entry escapes extraction directory: ${archivePath}`);
|
|
}
|
|
await Bun.write(destinationPath, file);
|
|
}
|
|
}
|
|
|
|
async function getEmbeddedClientDir(): Promise<string> {
|
|
if (!USE_EMBEDDED_CLIENT) return STATIC_DIR;
|
|
if (embeddedClientDirPromise) return embeddedClientDirPromise;
|
|
|
|
if (!EMBEDDED_CLIENT_ARCHIVE) {
|
|
throw new Error(
|
|
"Embedded stats client bundle missing. Rebuild the omp binary or npm bundle with embedded stats assets.",
|
|
);
|
|
}
|
|
|
|
embeddedClientDirPromise = (async () => {
|
|
const bundleHash = Bun.hash(EMBEDDED_CLIENT_ARCHIVE).toString(16);
|
|
const outputDir = path.join(EMBEDDED_CLIENT_DIR_ROOT, bundleHash);
|
|
const markerPath = path.join(outputDir, "index.html");
|
|
try {
|
|
const marker = await fs.stat(markerPath);
|
|
if (marker.isFile()) return outputDir;
|
|
} catch {}
|
|
|
|
await fs.rm(outputDir, { recursive: true, force: true });
|
|
await fs.mkdir(outputDir, { recursive: true });
|
|
await extractEmbeddedClientArchive(EMBEDDED_CLIENT_ARCHIVE, outputDir);
|
|
return outputDir;
|
|
})();
|
|
|
|
return embeddedClientDirPromise;
|
|
}
|
|
|
|
async function getLatestMtime(dir: string): Promise<number> {
|
|
let entries: Dirent[];
|
|
try {
|
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
} catch (err) {
|
|
// Tolerate missing source trees (e.g. installs without the dashboard
|
|
// sources); the caller falls back to prebuilt assets or a clear build
|
|
// failure instead of crashing on the scan.
|
|
if (isEnoent(err)) return 0;
|
|
throw err;
|
|
}
|
|
|
|
const promises = [];
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
promises.push(getLatestMtime(fullPath));
|
|
} else if (entry.isFile()) {
|
|
promises.push(fs.stat(fullPath).then(stats => stats.mtimeMs));
|
|
}
|
|
}
|
|
|
|
let latest = 0;
|
|
await Promise.allSettled(promises).then(results => {
|
|
for (const result of results) {
|
|
if (result.status === "fulfilled") {
|
|
latest = Math.max(latest, result.value);
|
|
}
|
|
}
|
|
});
|
|
return latest;
|
|
}
|
|
|
|
const ensureClientBuild = async () => {
|
|
if (USE_EMBEDDED_CLIENT) return;
|
|
const indexPath = path.join(STATIC_DIR, "index.html");
|
|
const cssPath = path.join(STATIC_DIR, "styles.css");
|
|
const clientSourceMtime = await getLatestMtime(CLIENT_DIR);
|
|
const tailwindConfigPath = path.join(import.meta.dir, "..", "tailwind.config.js");
|
|
let tailwindConfigMtime = 0;
|
|
try {
|
|
const tailwindConfigStats = await fs.stat(tailwindConfigPath);
|
|
tailwindConfigMtime = tailwindConfigStats.mtimeMs;
|
|
} catch {}
|
|
const sourceMtime = Math.max(clientSourceMtime, tailwindConfigMtime);
|
|
let shouldBuild = true;
|
|
try {
|
|
const [indexStats, cssStats] = await Promise.all([fs.stat(indexPath), fs.stat(cssPath)]);
|
|
if (
|
|
indexStats.isFile() &&
|
|
cssStats.isFile() &&
|
|
indexStats.mtimeMs >= sourceMtime &&
|
|
cssStats.mtimeMs >= sourceMtime
|
|
) {
|
|
shouldBuild = false;
|
|
}
|
|
} catch {
|
|
shouldBuild = true;
|
|
}
|
|
|
|
if (!shouldBuild) return;
|
|
|
|
await fs.rm(STATIC_DIR, { recursive: true, force: true });
|
|
|
|
console.log("Building stats client...");
|
|
const packageRoot = path.join(import.meta.dir, "..");
|
|
const buildResult = await $`bun run build.ts`.cwd(packageRoot).quiet().nothrow();
|
|
if (buildResult.exitCode !== 0) {
|
|
const output = buildResult.text().trim();
|
|
const details = output ? `\n${output}` : "";
|
|
throw new Error(`Failed to build stats client (exit ${buildResult.exitCode})${details}`);
|
|
}
|
|
|
|
const indexHtml = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>AI Usage Statistics</title>
|
|
<link rel="stylesheet" href="styles.css">
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script src="index.js" type="module"></script>
|
|
</body>
|
|
</html>`;
|
|
|
|
await Bun.write(path.join(STATIC_DIR, "index.html"), indexHtml);
|
|
};
|
|
|
|
/**
|
|
* Handle API requests.
|
|
*/
|
|
async function handleApi(req: Request): Promise<Response> {
|
|
const url = new URL(req.url);
|
|
const path = url.pathname;
|
|
|
|
// Stats reads are DB-only; explicit /api/sync does the expensive session scan.
|
|
const range = url.searchParams.get("range");
|
|
|
|
if (path === "/api/stats") {
|
|
const stats = await getDashboardStats(range);
|
|
return Response.json(stats);
|
|
}
|
|
|
|
if (path === "/api/stats/overview") {
|
|
const stats = await getOverviewStats(range);
|
|
return Response.json(stats);
|
|
}
|
|
|
|
if (path === "/api/stats/model-dashboard") {
|
|
const stats = await getModelDashboardStats(range);
|
|
return Response.json(stats);
|
|
}
|
|
|
|
if (path === "/api/stats/costs") {
|
|
const stats = await getCostDashboardStats(range);
|
|
return Response.json(stats);
|
|
}
|
|
|
|
if (path === "/api/stats/behavior") {
|
|
const stats = await getBehaviorDashboardStats(range);
|
|
return Response.json(stats);
|
|
}
|
|
|
|
if (path === "/api/stats/recent") {
|
|
const limit = url.searchParams.get("limit");
|
|
const stats = await getRecentRequests(limit ? parseInt(limit, 10) : undefined);
|
|
return Response.json(stats);
|
|
}
|
|
|
|
if (path === "/api/stats/errors") {
|
|
const limit = url.searchParams.get("limit");
|
|
const stats = await getRecentErrors(limit ? parseInt(limit, 10) : undefined);
|
|
return Response.json(stats);
|
|
}
|
|
|
|
if (path === "/api/stats/models") {
|
|
const stats = await getDashboardStats(range);
|
|
return Response.json(stats.byModel);
|
|
}
|
|
|
|
if (path === "/api/stats/folders") {
|
|
const stats = await getDashboardStats(range);
|
|
return Response.json(stats.byFolder);
|
|
}
|
|
|
|
if (path === "/api/stats/timeseries") {
|
|
const stats = await getDashboardStats(range);
|
|
return Response.json(stats.timeSeries);
|
|
}
|
|
|
|
if (path.startsWith("/api/request/")) {
|
|
const id = path.split("/").pop();
|
|
if (!id) return new Response("Bad Request", { status: 400 });
|
|
const details = await getRequestDetails(parseInt(id, 10));
|
|
if (!details) return new Response("Not Found", { status: 404 });
|
|
return Response.json(details);
|
|
}
|
|
|
|
if (path === "/api/sync") {
|
|
const result = await syncAllSessions();
|
|
const count = await getTotalMessageCount();
|
|
return Response.json({ ...result, totalMessages: count });
|
|
}
|
|
|
|
return new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
/**
|
|
* Handle static file requests.
|
|
*/
|
|
async function handleStatic(requestPath: string): Promise<Response> {
|
|
const staticDir = await getEmbeddedClientDir();
|
|
const filePath = requestPath === "/" ? "/index.html" : requestPath;
|
|
const fullPath = path.join(staticDir, filePath);
|
|
|
|
const file = Bun.file(fullPath);
|
|
if (await file.exists()) {
|
|
return new Response(file);
|
|
}
|
|
|
|
// SPA fallback
|
|
const index = Bun.file(path.join(staticDir, "index.html"));
|
|
if (await index.exists()) {
|
|
return new Response(index);
|
|
}
|
|
|
|
return new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
/**
|
|
* Start the HTTP server.
|
|
*/
|
|
export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> {
|
|
await ensureClientBuild();
|
|
|
|
const server = Bun.serve({
|
|
port,
|
|
async fetch(req) {
|
|
const url = new URL(req.url);
|
|
const path = url.pathname;
|
|
|
|
// CORS headers for local development
|
|
const corsHeaders = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
"Access-Control-Allow-Headers": "Content-Type",
|
|
};
|
|
|
|
if (req.method === "OPTIONS") {
|
|
return new Response(null, { headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
let response: Response;
|
|
|
|
if (path.startsWith("/api/")) {
|
|
response = await handleApi(req);
|
|
} else {
|
|
response = await handleStatic(path);
|
|
}
|
|
|
|
// Add CORS headers to all responses
|
|
const headers = new Headers(response.headers);
|
|
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
headers.set(key, value);
|
|
}
|
|
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
headers,
|
|
});
|
|
} catch (error) {
|
|
console.error("Server error:", error);
|
|
return Response.json(
|
|
{ error: error instanceof Error ? error.message : "Unknown error" },
|
|
{ status: 500, headers: corsHeaders },
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
return {
|
|
port: server.port ?? port,
|
|
stop: () => server.stop(),
|
|
};
|
|
}
|