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
@@ -0,0 +1,113 @@
import { dirname, extname, join, relative, sep } from 'node:path';
import { existsSync, readdirSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const MIME_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.otf': 'font/otf',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
'.wasm': 'application/wasm',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.xml': 'application/xml; charset=utf-8'
};
function toPosixPath(filePath: string): string {
return filePath.split(sep).join('/');
}
function listFiles(rootDir: string, currentDir = rootDir): string[] {
const entries = readdirSync(currentDir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const entryPath = join(currentDir, entry.name);
if (entry.isDirectory()) {
files.push(...listFiles(rootDir, entryPath));
continue;
}
if (entry.isFile()) {
files.push(entryPath);
}
}
return files;
}
function resolveMimeType(filePath: string): string {
const ext = extname(filePath).toLowerCase();
return MIME_TYPES[ext] ?? 'application/octet-stream';
}
function main(): void {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = join(scriptDir, '..', '..');
const webDistDir = join(workspaceRoot, 'web', 'dist');
const outputPath = join(workspaceRoot, 'server', 'src', 'web', 'embeddedAssets.generated.ts');
const outputDir = dirname(outputPath);
if (!existsSync(webDistDir)) {
throw new Error(`Missing web/dist directory: ${webDistDir}. Run bun run build:web first.`);
}
const indexHtmlPath = join(webDistDir, 'index.html');
if (!existsSync(indexHtmlPath)) {
throw new Error(`Missing web/dist/index.html. Run bun run build:web first.`);
}
const files = listFiles(webDistDir).sort((a, b) => a.localeCompare(b));
if (files.length === 0) {
throw new Error(`No files found in web/dist: ${webDistDir}.`);
}
const imports: string[] = [];
const manifestLines: string[] = [];
files.forEach((filePath, index) => {
const relativeToDist = toPosixPath(relative(webDistDir, filePath));
const requestPath = `/${relativeToDist}`;
const importPath = toPosixPath(relative(outputDir, filePath));
const importName = `asset${index}`;
const mimeType = resolveMimeType(filePath);
imports.push(`import ${importName} from '${importPath}' assert { type: 'file' };`);
manifestLines.push(` { path: '${requestPath}', sourcePath: ${importName}, mimeType: '${mimeType}' },`);
});
const output = [
'// This file is generated by server/scripts/generate-embedded-web-assets.ts.',
'// Do not edit by hand.',
'',
...imports,
'',
'export interface EmbeddedWebAsset {',
' path: string;',
' sourcePath: string;',
' mimeType: string;',
'}',
'',
'export const embeddedAssets: EmbeddedWebAsset[] = [',
...manifestLines,
'];',
''
].join('\n');
writeFileSync(outputPath, output, 'utf-8');
console.log(`[embedded-assets] Wrote ${files.length} assets to ${outputPath}`);
}
main();