fix(stats): generated tar bundle for compiled dashboard assets

Generate a tar.gz bundle from stats dist/client during binary builds, embed it into the compiled binary, and extract/serve it at runtime in compiled mode instead of reading //root/client directly.

Updated CI and build:binary flow to generate/reset the embedded bundle around compilation.

Fixes #61
This commit is contained in:
can1357
2026-02-14 03:42:18 +01:00
parent 0a27cbcc9d
commit 35aa15ae94
5 changed files with 157 additions and 3 deletions
+2
View File
@@ -172,6 +172,7 @@ jobs:
- name: Build binaries
run: |
bun --cwd=packages/stats scripts/generate-client-bundle.ts
mkdir -p packages/coding-agent/binaries
ENTRYPOINT="./packages/coding-agent/src/cli.ts"
DEFINES="--define PI_COMPILED=true --root ."
@@ -189,6 +190,7 @@ jobs:
build_binary linux arm64 bun-linux-arm64 packages/coding-agent/binaries/omp-linux-arm64
build_binary win32 x64 bun-windows-x64-modern packages/coding-agent/binaries/omp-windows-x64.exe
bun --cwd=packages/natives run embed:native --reset
bun --cwd=packages/stats scripts/generate-client-bundle.ts --reset
- name: Stage native addons for release
run: cp packages/natives/native/*.node packages/coding-agent/binaries/
- name: Create GitHub Release
+1 -1
View File
@@ -78,7 +78,7 @@
"scripts": {
"check": "tsgo -p tsconfig.json",
"format-prompts": "bun scripts/format-prompts.ts",
"build:binary": "cd ../.. && bun --cwd=packages/natives run embed:native && bun build --compile --define PI_COMPILED=true --root . ./packages/coding-agent/src/cli.ts --outfile packages/coding-agent/dist/omp && bun --cwd=packages/natives run embed:native --reset",
"build:binary": "cd ../.. && bun --cwd=packages/stats scripts/generate-client-bundle.ts && bun --cwd=packages/natives run embed:native && bun build --compile --define PI_COMPILED=true --root . ./packages/coding-agent/src/cli.ts --outfile packages/coding-agent/dist/omp && bun --cwd=packages/natives run embed:native --reset && bun --cwd=packages/stats scripts/generate-client-bundle.ts --reset",
"generate-template": "bun scripts/generate-template.ts",
"test": "bun test"
},
@@ -0,0 +1,82 @@
#!/usr/bin/env bun
import { $ } from "bun";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
const GENERATED_FILE = path.join("src", "embedded-client.generated.ts");
const DIST_CLIENT_DIR = path.join("dist", "client");
function placeholderContent(): string {
return `/**
* Embedded stats dashboard bundle for compiled binaries.
*
* This file is generated by \`bun --cwd=packages/stats scripts/generate-client-bundle.ts\` during
* binary builds. The checked-in value is intentionally empty.
*/
export const EMBEDDED_CLIENT_ARCHIVE_TAR_GZ_BASE64 = "";
`;
}
async function collectFiles(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectFiles(fullPath)));
} else if (entry.isFile()) {
files.push(fullPath);
}
}
files.sort((a, b) => a.localeCompare(b));
return files;
}
async function buildArchiveBase64(dir: string): Promise<string> {
const files = await collectFiles(dir);
const entries: Record<string, Uint8Array> = {};
for (const filePath of files) {
const relativePath = path.relative(dir, filePath).split(path.sep).join("/");
entries[relativePath] = await fs.readFile(filePath);
}
const tempArchivePath = path.join(
os.tmpdir(),
`omp-stats-client-${Bun.hash(Date.now().toString() + Math.random().toString(16)).toString(16)}.tar.gz`,
);
try {
await Bun.Archive.write(tempArchivePath, entries, { compress: "gzip" });
const archiveBytes = await Bun.file(tempArchivePath).bytes();
return Buffer.from(archiveBytes).toString("base64");
} finally {
await fs.rm(tempArchivePath, { force: true });
}
}
async function writeGeneratedBundle(base64Bundle: string): Promise<void> {
const content = `/**
* Embedded stats dashboard bundle for compiled binaries.
*
* Generated by \`bun --cwd=packages/stats scripts/generate-client-bundle.ts\`.
*/
export const EMBEDDED_CLIENT_ARCHIVE_TAR_GZ_BASE64 = ${JSON.stringify(base64Bundle)};
`;
await Bun.write(GENERATED_FILE, content);
}
async function main(): Promise<void> {
if (process.argv.includes("--reset")) {
await Bun.write(GENERATED_FILE, placeholderContent());
console.log(`Reset ${GENERATED_FILE}`);
return;
}
await $`bun run build`;
const archiveBase64 = await buildArchiveBase64(DIST_CLIENT_DIR);
await writeGeneratedBundle(archiveBase64);
console.log(`Generated ${GENERATED_FILE}`);
}
await main();
@@ -0,0 +1,7 @@
/**
* Embedded stats dashboard bundle for compiled binaries.
*
* This file is generated by `bun --cwd=packages/stats scripts/generate-client-bundle.ts` during
* binary builds. The checked-in value is intentionally empty.
*/
export const EMBEDDED_CLIENT_ARCHIVE_TAR_GZ_BASE64 = "";
+65 -2
View File
@@ -1,7 +1,9 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
import postcss from "postcss";
import tailwindcss from "tailwindcss";
import { EMBEDDED_CLIENT_ARCHIVE_TAR_GZ_BASE64 } from "./embedded-client.generated";
import {
getDashboardStats,
getRecentErrors,
@@ -13,6 +15,65 @@ import {
const CLIENT_DIR = path.join(import.meta.dir, "client");
const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client");
const IS_BUN_COMPILED =
Bun.env.PI_COMPILED ||
import.meta.url.includes("$bunfs") ||
import.meta.url.includes("~BUN") ||
import.meta.url.includes("%7EBUN");
const COMPILED_CLIENT_DIR_ROOT = path.join(os.tmpdir(), "omp-stats-client");
let compiledClientDirPromise: 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(outputDir: string): Promise<void> {
const archiveBytes = Buffer.from(EMBEDDED_CLIENT_ARCHIVE_TAR_GZ_BASE64, "base64");
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 getCompiledClientDir(): Promise<string> {
if (!IS_BUN_COMPILED) return STATIC_DIR;
if (!EMBEDDED_CLIENT_ARCHIVE_TAR_GZ_BASE64) {
throw new Error("Compiled stats client bundle missing. Rebuild binary with embedded stats assets.");
}
if (compiledClientDirPromise) return compiledClientDirPromise;
compiledClientDirPromise = (async () => {
const bundleHash = Bun.hash(EMBEDDED_CLIENT_ARCHIVE_TAR_GZ_BASE64).toString(16);
const outputDir = path.join(COMPILED_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(outputDir);
return outputDir;
})();
return compiledClientDirPromise;
}
async function buildTailwindCss(inputPath: string, outputPath: string): Promise<void> {
const sourceCss = await Bun.file(inputPath).text();
@@ -43,6 +104,7 @@ async function getLatestMtime(dir: string): Promise<number> {
}
const ensureClientBuild = async () => {
if (IS_BUN_COMPILED) return;
const indexPath = path.join(STATIC_DIR, "index.html");
const cssPath = path.join(STATIC_DIR, "styles.css");
const clientSourceMtime = await getLatestMtime(CLIENT_DIR);
@@ -173,8 +235,9 @@ async function handleApi(req: Request): Promise<Response> {
* Handle static file requests.
*/
async function handleStatic(requestPath: string): Promise<Response> {
const staticDir = IS_BUN_COMPILED ? await getCompiledClientDir() : STATIC_DIR;
const filePath = requestPath === "/" ? "/index.html" : requestPath;
const fullPath = path.join(STATIC_DIR, filePath);
const fullPath = path.join(staticDir, filePath);
const file = Bun.file(fullPath);
if (await file.exists()) {
@@ -182,7 +245,7 @@ async function handleStatic(requestPath: string): Promise<Response> {
}
// SPA fallback
const index = Bun.file(path.join(STATIC_DIR, "index.html"));
const index = Bun.file(path.join(staticDir, "index.html"));
if (await index.exists()) {
return new Response(index);
}