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
+1
View File
@@ -5,6 +5,7 @@
**/dist/ **/dist/
**/dist-exe/ **/dist-exe/
**/*.tsbuildinfo **/*.tsbuildinfo
server/src/web/embeddedAssets.generated.ts
# Env files (can contain secrets) # Env files (can contain secrets)
**/.env **/.env
+2
View File
@@ -70,6 +70,8 @@
"build": "shx rm -rf dist && tsc --noEmit && pkgroll", "build": "shx rm -rf dist && tsc --noEmit && pkgroll",
"build:exe": "bun run scripts/build-executable.ts", "build:exe": "bun run scripts/build-executable.ts",
"build:exe:all": "bun run scripts/build-executable.ts --all", "build:exe:all": "bun run scripts/build-executable.ts --all",
"build:exe:allinone": "bun run scripts/build-executable.ts --with-web-assets",
"build:exe:allinone:all": "bun run scripts/build-executable.ts --with-web-assets --all",
"test": "bun run build && tsx --env-file .env.integration-test node_modules/.bin/vitest run", "test": "bun run build && tsx --env-file .env.integration-test node_modules/.bin/vitest run",
"test:win": "bun run build && vitest run", "test:win": "bun run build && vitest run",
"start": "bun run build && ./bin/happy.mjs", "start": "bun run build && ./bin/happy.mjs",
+53 -3
View File
@@ -1,5 +1,5 @@
import { dirname, isAbsolute, join } from 'node:path'; 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'; import { fileURLToPath } from 'node:url';
const DEFAULT_TARGETS = [ const DEFAULT_TARGETS = [
@@ -129,6 +129,52 @@ function resolveOutdir(projectRoot: string, outdir: string): string {
return join(projectRoot, 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> { async function buildTarget(projectRoot: string, target: string, outdir: string, name: string): Promise<void> {
const { platform, arch } = parseTarget(target); const { platform, arch } = parseTarget(target);
assertArchivesExist(projectRoot, platform, arch); assertArchivesExist(projectRoot, platform, arch);
@@ -169,10 +215,11 @@ async function main(): Promise<void> {
const outdirArg = getArg(args, '--outdir') ?? 'dist-exe'; const outdirArg = getArg(args, '--outdir') ?? 'dist-exe';
const name = getArg(args, '--name') ?? 'hapi'; const name = getArg(args, '--name') ?? 'hapi';
const buildAll = args.includes('--all'); const buildAll = args.includes('--all');
const includeWebAssets = args.includes('--with-web-assets');
if (args.includes('--target') && !target) { 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('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]'); console.error(' or: bun run scripts/build-executable.ts --all [--outdir dist-exe] [--name hapi] [--with-web-assets]');
process.exit(1); process.exit(1);
} }
@@ -182,10 +229,13 @@ async function main(): Promise<void> {
const scriptDir = dirname(fileURLToPath(import.meta.url)); const scriptDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(scriptDir, '..'); const projectRoot = join(scriptDir, '..');
const workspaceRoot = join(projectRoot, '..');
const outdir = resolveOutdir(projectRoot, outdirArg); const outdir = resolveOutdir(projectRoot, outdirArg);
const resolvedTarget = buildAll ? undefined : resolveTarget(target); const resolvedTarget = buildAll ? undefined : resolveTarget(target);
const targets = buildAll ? DEFAULT_TARGETS : [resolvedTarget!]; const targets = buildAll ? DEFAULT_TARGETS : [resolvedTarget!];
ensureEmbeddedAssetsManifest(workspaceRoot, includeWebAssets);
for (const targetName of targets) { for (const targetName of targets) {
await buildTarget(projectRoot, targetName, outdir, name); await buildTarget(projectRoot, targetName, outdir, name);
} }
+14
View File
@@ -65,6 +65,19 @@ import { withBunRuntimeEnv } from './utils/bunRuntime'
return return
} }
if (subcommand === 'server') {
try {
await import('../../server/src/index')
} catch (error) {
console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error')
if (process.env.DEBUG) {
console.error(error)
}
process.exit(1)
}
return
}
await ensureRuntimeAssets() await ensureRuntimeAssets()
// If --version is passed - do not log, its likely daemon inquiring about our version // If --version is passed - do not log, its likely daemon inquiring about our version
@@ -323,6 +336,7 @@ ${chalk.bold('Usage:')}
hapi mcp Start MCP stdio bridge hapi mcp Start MCP stdio bridge
hapi connect (not available in direct-connect mode) hapi connect (not available in direct-connect mode)
hapi notify (not available in direct-connect mode) hapi notify (not available in direct-connect mode)
hapi server Start the API + web server
hapi daemon Manage background service that allows hapi daemon Manage background service that allows
to spawn new sessions away from your computer to spawn new sessions away from your computer
hapi doctor System diagnostics & troubleshooting hapi doctor System diagnostics & troubleshooting
+7 -4
View File
@@ -6,12 +6,13 @@
"es2022" "es2022"
], ],
"jsx": "react", "jsx": "react",
"rootDir": "src", "rootDir": "..",
"experimentalDecorators": true, "experimentalDecorators": true,
"outDir": "dist", "outDir": "dist",
"noEmit": true, "noEmit": true,
"typeRoots": [ "types": [
"./node_modules/@types" "node",
"bun-types"
], ],
"paths": { "paths": {
"@/*": [ "@/*": [
@@ -25,6 +26,8 @@
"include": [ "include": [
"src/**/*.ts", "src/**/*.ts",
"src/**/*.tsx", "src/**/*.tsx",
"src/**/*.d.ts" "src/**/*.d.ts",
"../server/src/**/*.ts",
"../server/src/**/*.d.ts"
] ]
} }
+2
View File
@@ -8,6 +8,8 @@
"build:cli": "cd cli && bun run build", "build:cli": "cd cli && bun run build",
"build:cli:exe": "cd cli && bun run build:exe", "build:cli:exe": "cd cli && bun run build:exe",
"build:cli:exe:all": "cd cli && bun run build:exe:all", "build:cli:exe:all": "cd cli && bun run build:exe:all",
"build:single-exe": "bun run build:web && (cd server && bun run generate:embedded-web-assets) && (cd cli && bun run build:exe:allinone)",
"build:single-exe:all": "bun run build:web && (cd server && bun run generate:embedded-web-assets) && (cd cli && bun run build:exe:allinone:all)",
"build:server": "cd server && bun run build", "build:server": "cd server && bun run build",
"build:web": "cd web && bun run build", "build:web": "cd web && bun run build",
"dev:server": "cd server && bun run dev", "dev:server": "cd server && bun run dev",
+2 -1
View File
@@ -10,7 +10,8 @@
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
"dev": "bun --watch run src/index.ts", "dev": "bun --watch run src/index.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "bun build src/index.ts --outdir dist --target bun" "build": "bun build src/index.ts --outdir dist --target bun",
"generate:embedded-web-assets": "bun run scripts/generate-embedded-web-assets.ts"
}, },
"dependencies": { "dependencies": {
"@socket.io/bun-engine": "^0.1.0", "@socket.io/bun-engine": "^0.1.0",
@@ -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();
+10
View File
@@ -0,0 +1,10 @@
// Stub types for embeddedAssets.generated.ts when the manifest is not generated.
declare module './embeddedAssets.generated' {
export interface EmbeddedWebAsset {
path: string;
sourcePath: string;
mimeType: string;
}
export const embeddedAssets: EmbeddedWebAsset[];
}
+15
View File
@@ -0,0 +1,15 @@
import type { EmbeddedWebAsset } from './embeddedAssets.generated';
let embeddedAssetMap: Map<string, EmbeddedWebAsset> | null = null;
export type { EmbeddedWebAsset };
export async function loadEmbeddedAssetMap(): Promise<Map<string, EmbeddedWebAsset>> {
if (embeddedAssetMap) {
return embeddedAssetMap;
}
const { embeddedAssets } = await import('./embeddedAssets.generated');
embeddedAssetMap = new Map(embeddedAssets.map((asset) => [asset.path, asset]));
return embeddedAssetMap;
}
+58 -1
View File
@@ -19,6 +19,7 @@ import type { SSEManager } from '../sse/sseManager'
import type { Server as BunServer } from 'bun' import type { Server as BunServer } from 'bun'
import type { Server as SocketEngine } from '@socket.io/bun-engine' import type { Server as SocketEngine } from '@socket.io/bun-engine'
import type { WebSocketData } from '@socket.io/bun-engine' import type { WebSocketData } from '@socket.io/bun-engine'
import { loadEmbeddedAssetMap, type EmbeddedWebAsset } from './embeddedAssets'
function findWebappDistDir(): { distDir: string; indexHtmlPath: string } { function findWebappDistDir(): { distDir: string; indexHtmlPath: string } {
const candidates = [ const candidates = [
@@ -38,10 +39,19 @@ function findWebappDistDir(): { distDir: string; indexHtmlPath: string } {
return { distDir, indexHtmlPath: join(distDir, 'index.html') } return { distDir, indexHtmlPath: join(distDir, 'index.html') }
} }
function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response {
return new Response(Bun.file(asset.sourcePath), {
headers: {
'Content-Type': asset.mimeType
}
})
}
function createWebApp(options: { function createWebApp(options: {
getSyncEngine: () => SyncEngine | null getSyncEngine: () => SyncEngine | null
getSseManager: () => SSEManager | null getSseManager: () => SSEManager | null
jwtSecret: Uint8Array jwtSecret: Uint8Array
embeddedAssetMap: Map<string, EmbeddedWebAsset> | null
}): Hono<WebAppEnv> { }): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>() const app = new Hono<WebAppEnv>()
@@ -69,6 +79,51 @@ function createWebApp(options: {
app.route('/api', createMachinesRoutes(options.getSyncEngine)) app.route('/api', createMachinesRoutes(options.getSyncEngine))
app.route('/api', createGitRoutes(options.getSyncEngine)) app.route('/api', createGitRoutes(options.getSyncEngine))
if (options.embeddedAssetMap) {
const embeddedAssetMap = options.embeddedAssetMap
const indexHtmlAsset = embeddedAssetMap.get('/index.html')
if (!indexHtmlAsset) {
app.get('*', (c) => {
return c.text(
'Embedded Mini App is missing index.html. Rebuild the executable after running bun run build:web.',
503
)
})
return app
}
app.use('*', async (c, next) => {
if (c.req.path.startsWith('/api')) {
await next()
return
}
if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
await next()
return
}
const asset = embeddedAssetMap.get(c.req.path)
if (asset) {
return serveEmbeddedAsset(asset)
}
await next()
})
app.get('*', async (c, next) => {
if (c.req.path.startsWith('/api')) {
await next()
return
}
return serveEmbeddedAsset(indexHtmlAsset)
})
return app
}
const { distDir, indexHtmlPath } = findWebappDistDir() const { distDir, indexHtmlPath } = findWebappDistDir()
if (!existsSync(indexHtmlPath)) { if (!existsSync(indexHtmlPath)) {
@@ -110,10 +165,12 @@ export async function startWebServer(options: {
jwtSecret: Uint8Array jwtSecret: Uint8Array
socketEngine: SocketEngine socketEngine: SocketEngine
}): Promise<BunServer<WebSocketData>> { }): Promise<BunServer<WebSocketData>> {
const embeddedAssetMap = Bun.isCompiled ? await loadEmbeddedAssetMap() : null
const app = createWebApp({ const app = createWebApp({
getSyncEngine: options.getSyncEngine, getSyncEngine: options.getSyncEngine,
getSseManager: options.getSseManager, getSseManager: options.getSseManager,
jwtSecret: options.jwtSecret jwtSecret: options.jwtSecret,
embeddedAssetMap
}) })
const socketHandler = options.socketEngine.handler() const socketHandler = options.socketEngine.handler()
+1 -1
View File
@@ -10,5 +10,5 @@
} }
}, },
"exclude": ["node_modules"], "exclude": ["node_modules"],
"include": ["src/**/*.ts"] "include": ["src/**/*.ts", "src/**/*.d.ts"]
} }