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
This commit is contained in:
weishu
2025-12-21 08:42:00 +08:00
parent cf04937355
commit 1028547a3a
12 changed files with 278 additions and 10 deletions
+53 -3
View File
@@ -1,5 +1,5 @@
import { dirname, isAbsolute, join } from 'node:path';
import { existsSync, mkdirSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const DEFAULT_TARGETS = [
@@ -129,6 +129,52 @@ function resolveOutdir(projectRoot: string, outdir: string): string {
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);
@@ -169,10 +215,11 @@ async function main(): Promise<void> {
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]');
console.error(' or: bun run scripts/build-executable.ts --all [--outdir dist-exe] [--name hapi]');
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);
}
@@ -182,10 +229,13 @@ async function main(): Promise<void> {
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);
}