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
+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 SocketEngine } 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 } {
const candidates = [
@@ -38,10 +39,19 @@ function findWebappDistDir(): { distDir: string; indexHtmlPath: string } {
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: {
getSyncEngine: () => SyncEngine | null
getSseManager: () => SSEManager | null
jwtSecret: Uint8Array
embeddedAssetMap: Map<string, EmbeddedWebAsset> | null
}): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>()
@@ -69,6 +79,51 @@ function createWebApp(options: {
app.route('/api', createMachinesRoutes(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()
if (!existsSync(indexHtmlPath)) {
@@ -110,10 +165,12 @@ export async function startWebServer(options: {
jwtSecret: Uint8Array
socketEngine: SocketEngine
}): Promise<BunServer<WebSocketData>> {
const embeddedAssetMap = Bun.isCompiled ? await loadEmbeddedAssetMap() : null
const app = createWebApp({
getSyncEngine: options.getSyncEngine,
getSseManager: options.getSseManager,
jwtSecret: options.jwtSecret
jwtSecret: options.jwtSecret,
embeddedAssetMap
})
const socketHandler = options.socketEngine.handler()