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:
@@ -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
|
||||
|
||||
@@ -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 = "";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user