Files
hapi/cli/scripts/build-executable.ts
T
weishu 1028547a3a feat(cli): add single executable with embedded web assets support
Implement support for bundling web assets into CLI single executable binaries.
When built with --with-web-assets, the executable includes the compiled web
application and serves it directly without file system access. A stub generator
creates empty manifests for normal builds to maintain compatibility.

Key changes:
- Add --with-web-assets flag to build-executable.ts with manifest validation
- Generate embeddedAssets.ts manifest from web/dist during build
- Serve embedded assets in web server with fallback to file system
- Add hapi server subcommand to start API + web server
- Include server sources in CLI tsconfig for compilation scope
- Add workspace-level build:single-exe scripts for production builds
2025-12-21 08:42:00 +08:00

248 lines
8.1 KiB
TypeScript

import { dirname, isAbsolute, join } from 'node:path';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const DEFAULT_TARGETS = [
'bun-darwin-x64',
'bun-darwin-arm64',
'bun-linux-x64',
'bun-linux-arm64',
'bun-windows-x64',
'bun-windows-arm64'
];
const SUPPORTED_PLATFORMS = new Set(['darwin', 'linux', 'windows']);
const SUPPORTED_ARCHES = new Set(['x64', 'arm64']);
function getArg(args: string[], name: string): string | undefined {
const idx = args.indexOf(name);
if (idx === -1 || idx + 1 >= args.length) {
return undefined;
}
return args[idx + 1];
}
function resolveHostPlatform(): string {
if (process.platform === 'win32') {
return 'windows';
}
if (process.platform === 'darwin' || process.platform === 'linux') {
return process.platform;
}
throw new Error(`Unsupported host platform: ${process.platform}`);
}
function resolveHostArch(): string {
if (SUPPORTED_ARCHES.has(process.arch)) {
return process.arch;
}
throw new Error(`Unsupported host arch: ${process.arch}`);
}
function resolveTarget(target?: string): string {
if (!target) {
return `bun-${resolveHostPlatform()}-${resolveHostArch()}`;
}
const parts = target.split('-');
if (parts.length < 2 || parts.length > 3 || parts[0] !== 'bun') {
throw new Error(`Invalid target: ${target}`);
}
const platformPart = parts[1];
if (!SUPPORTED_PLATFORMS.has(platformPart)) {
throw new Error(`Unsupported platform in target: ${target}`);
}
const archPart = parts[2] ?? resolveHostArch();
if (!SUPPORTED_ARCHES.has(archPart)) {
throw new Error(`Unsupported arch in target: ${target}`);
}
return `bun-${platformPart}-${archPart}`;
}
function parseTarget(target: string): { platform: string; arch: string } {
const parts = target.split('-');
if (parts.length !== 3 || parts[0] !== 'bun') {
throw new Error(`Invalid target: ${target}`);
}
const platformPart = parts[1];
const archPart = parts[2];
if (!SUPPORTED_PLATFORMS.has(platformPart)) {
throw new Error(`Unsupported platform in target: ${target}`);
}
if (!SUPPORTED_ARCHES.has(archPart)) {
throw new Error(`Unsupported arch in target: ${target}`);
}
return {
platform: platformPart === 'windows' ? 'win32' : platformPart,
arch: archPart
};
}
function getFeatureFlag(platform: string, arch: string): string {
const platformToken = platform === 'win32' ? 'WIN32' : platform.toUpperCase();
return `HAPI_TARGET_${platformToken}_${arch.toUpperCase()}`;
}
function getPlatformDir(platform: string, arch: string): string {
if (platform === 'darwin') {
return arch === 'arm64' ? 'arm64-darwin' : 'x64-darwin';
}
if (platform === 'linux') {
return arch === 'arm64' ? 'arm64-linux' : 'x64-linux';
}
if (platform === 'win32') {
if (arch !== 'x64') {
throw new Error('Windows arm64 archives are missing; add arm64 tool archives before building.');
}
return 'x64-win32';
}
throw new Error(`Unsupported platform: ${platform}`);
}
function assertArchivesExist(projectRoot: string, platform: string, arch: string): void {
const platformDir = getPlatformDir(platform, arch);
const archives = [
join(projectRoot, 'tools', 'archives', `difftastic-${platformDir}.tar.gz`),
join(projectRoot, 'tools', 'archives', `ripgrep-${platformDir}.tar.gz`)
];
for (const archive of archives) {
if (!existsSync(archive)) {
throw new Error(`Missing archive: ${archive}`);
}
}
}
function resolveOutdir(projectRoot: string, outdir: string): string {
if (isAbsolute(outdir)) {
return outdir;
}
return join(projectRoot, outdir);
}
function writeStubEmbeddedAssets(workspaceRoot: string): void {
const outputPath = join(workspaceRoot, 'server', 'src', 'web', 'embeddedAssets.generated.ts');
const contents = [
'// This file is generated by cli/scripts/build-executable.ts when --with-web-assets is not used.',
'// It intentionally contains no embedded assets.',
'',
'export interface EmbeddedWebAsset {',
' path: string;',
' sourcePath: string;',
' mimeType: string;',
'}',
'',
'export const embeddedAssets: EmbeddedWebAsset[] = [];',
''
].join('\n');
writeFileSync(outputPath, contents, 'utf-8');
}
function isStubEmbeddedAssets(manifestPath: string): boolean {
try {
const contents = readFileSync(manifestPath, 'utf-8');
return contents.includes('intentionally contains no embedded assets');
} catch {
return false;
}
}
function ensureEmbeddedAssetsManifest(workspaceRoot: string, includeWebAssets: boolean): void {
const manifestPath = join(workspaceRoot, 'server', 'src', 'web', 'embeddedAssets.generated.ts');
if (includeWebAssets) {
if (!existsSync(manifestPath)) {
throw new Error(
'Missing embedded web asset manifest. Run `bun run build:web` and `cd server && bun run generate:embedded-web-assets`, or `bun run build:single-exe` from the repo root.'
);
}
if (isStubEmbeddedAssets(manifestPath)) {
throw new Error(
'Embedded web asset manifest is a stub. Run `bun run build:web` and `cd server && bun run generate:embedded-web-assets`, or `bun run build:single-exe` from the repo root.'
);
}
return;
}
writeStubEmbeddedAssets(workspaceRoot);
}
async function buildTarget(projectRoot: string, target: string, outdir: string, name: string): Promise<void> {
const { platform, arch } = parseTarget(target);
assertArchivesExist(projectRoot, platform, arch);
const outputName = platform === 'win32' ? `${name}.exe` : name;
const outfile = join(outdir, target, outputName);
mkdirSync(dirname(outfile), { recursive: true });
const featureFlag = getFeatureFlag(platform, arch);
const cmd = [
process.execPath,
'build',
'--compile',
`--feature=${featureFlag}`,
`--target=${target}`,
`--outfile=${outfile}`,
join(projectRoot, 'src', 'bootstrap.ts')
];
console.log(`[build:exe] ${cmd.join(' ')}`);
const proc = Bun.spawn({
cmd,
env: process.env,
stdout: 'inherit',
stderr: 'inherit',
cwd: projectRoot
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`Build failed for target ${target} (exit ${exitCode})`);
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const target = getArg(args, '--target');
const outdirArg = getArg(args, '--outdir') ?? 'dist-exe';
const name = getArg(args, '--name') ?? 'hapi';
const buildAll = args.includes('--all');
const includeWebAssets = args.includes('--with-web-assets');
if (args.includes('--target') && !target) {
console.error('Usage: bun run scripts/build-executable.ts [--target <bun-platform[-arch]>] [--outdir dist-exe] [--name hapi] [--with-web-assets]');
console.error(' or: bun run scripts/build-executable.ts --all [--outdir dist-exe] [--name hapi] [--with-web-assets]');
process.exit(1);
}
if (!buildAll && !target) {
console.log('[build:exe] No --target provided; defaulting to host platform + arch.');
}
const scriptDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(scriptDir, '..');
const workspaceRoot = join(projectRoot, '..');
const outdir = resolveOutdir(projectRoot, outdirArg);
const resolvedTarget = buildAll ? undefined : resolveTarget(target);
const targets = buildAll ? DEFAULT_TARGETS : [resolvedTarget!];
ensureEmbeddedAssetsManifest(workspaceRoot, includeWebAssets);
for (const targetName of targets) {
await buildTarget(projectRoot, targetName, outdir, name);
}
}
main().catch((error) => {
console.error(`[build:exe] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});