diff --git a/.gitignore b/.gitignore index 88ad0d48..65edfe50 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ **/dist/ **/dist-exe/ **/*.tsbuildinfo +server/src/web/embeddedAssets.generated.ts # Env files (can contain secrets) **/.env diff --git a/cli/package.json b/cli/package.json index 2226a311..c2d1839d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -70,6 +70,8 @@ "build": "shx rm -rf dist && tsc --noEmit && pkgroll", "build:exe": "bun run scripts/build-executable.ts", "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:win": "bun run build && vitest run", "start": "bun run build && ./bin/happy.mjs", diff --git a/cli/scripts/build-executable.ts b/cli/scripts/build-executable.ts index 7754afe5..6c67687e 100644 --- a/cli/scripts/build-executable.ts +++ b/cli/scripts/build-executable.ts @@ -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 { const { platform, arch } = parseTarget(target); assertArchivesExist(projectRoot, platform, arch); @@ -169,10 +215,11 @@ async function main(): Promise { 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 ] [--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 ] [--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 { 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); } diff --git a/cli/src/index.ts b/cli/src/index.ts index 7591ee69..9584eb2a 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -65,6 +65,19 @@ import { withBunRuntimeEnv } from './utils/bunRuntime' 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() // 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 connect (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 to spawn new sessions away from your computer hapi doctor System diagnostics & troubleshooting diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 86410401..4e447cde 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -6,12 +6,13 @@ "es2022" ], "jsx": "react", - "rootDir": "src", + "rootDir": "..", "experimentalDecorators": true, "outDir": "dist", "noEmit": true, - "typeRoots": [ - "./node_modules/@types" + "types": [ + "node", + "bun-types" ], "paths": { "@/*": [ @@ -25,6 +26,8 @@ "include": [ "src/**/*.ts", "src/**/*.tsx", - "src/**/*.d.ts" + "src/**/*.d.ts", + "../server/src/**/*.ts", + "../server/src/**/*.d.ts" ] } diff --git a/package.json b/package.json index a7a8d445..cef8ad85 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build:cli": "cd cli && bun run build", "build:cli:exe": "cd cli && bun run build:exe", "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:web": "cd web && bun run build", "dev:server": "cd server && bun run dev", diff --git a/server/package.json b/server/package.json index 284389ea..270f8c02 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,8 @@ "start": "bun run src/index.ts", "dev": "bun --watch run src/index.ts", "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": { "@socket.io/bun-engine": "^0.1.0", diff --git a/server/scripts/generate-embedded-web-assets.ts b/server/scripts/generate-embedded-web-assets.ts new file mode 100644 index 00000000..8578e397 --- /dev/null +++ b/server/scripts/generate-embedded-web-assets.ts @@ -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 = { + '.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(); diff --git a/server/src/web/embeddedAssets.generated.stub.d.ts b/server/src/web/embeddedAssets.generated.stub.d.ts new file mode 100644 index 00000000..ac63a5f4 --- /dev/null +++ b/server/src/web/embeddedAssets.generated.stub.d.ts @@ -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[]; +} diff --git a/server/src/web/embeddedAssets.ts b/server/src/web/embeddedAssets.ts new file mode 100644 index 00000000..b158544e --- /dev/null +++ b/server/src/web/embeddedAssets.ts @@ -0,0 +1,15 @@ +import type { EmbeddedWebAsset } from './embeddedAssets.generated'; + +let embeddedAssetMap: Map | null = null; + +export type { EmbeddedWebAsset }; + +export async function loadEmbeddedAssetMap(): Promise> { + if (embeddedAssetMap) { + return embeddedAssetMap; + } + + const { embeddedAssets } = await import('./embeddedAssets.generated'); + embeddedAssetMap = new Map(embeddedAssets.map((asset) => [asset.path, asset])); + return embeddedAssetMap; +} diff --git a/server/src/web/server.ts b/server/src/web/server.ts index b2ba2841..162847dd 100644 --- a/server/src/web/server.ts +++ b/server/src/web/server.ts @@ -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 | null }): Hono { const app = new Hono() @@ -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> { + 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() diff --git a/server/tsconfig.json b/server/tsconfig.json index 08d0f44a..770a1654 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -10,5 +10,5 @@ } }, "exclude": ["node_modules"], - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.d.ts"] }