/** * Getting a running app in front of the browser. * * Three modes, mirroring how the fleet ships frontends: * staticDir — app shells and websites, which build to a servable `dist/` * command — microfrontends, which build to a library and need their Vite * harness (`bun run dev`) to render anything * baseUrl — an already-running target (a preview deploy, alpha, prod) */ import { spawn, type ChildProcess } from 'node:child_process'; import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'; import { createServer, type Server } from 'node:http'; import { extname, join, normalize, resolve, sep } from 'node:path'; import type { ResolvedConfig } from './types'; const MIME_TYPES: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.map': 'application/json; charset=utf-8', '.wasm': 'application/wasm', '.txt': 'text/plain; charset=utf-8', '.webmanifest': 'application/manifest+json', }; /** A file resolved out of the built `dist/` directory. */ export interface ResolvedAsset { body: Buffer; contentType: string; } export interface RunningApp { baseUrl: string; close: () => Promise; /** * Present in `staticDir` mode: resolves a URL path straight out of the build * directory. The audit fulfils the page's own requests through this instead * of letting Chromium open a socket per chunk, which keeps a code-split shell * from losing a hundred parallel chunk loads to one spurious * ERR_NETWORK_CHANGED and rendering blank. */ resolveAsset?: (urlPath: string) => ResolvedAsset | null; } function resolveWithinRoot(root: string, urlPath: string): string | null { let decoded: string; try { decoded = decodeURIComponent(urlPath); } catch { return null; } const candidate = resolve(root, `.${normalize(decoded)}`); if (candidate !== root && !candidate.startsWith(root + sep)) return null; return candidate; } /** * Map a URL path onto a file inside the build directory. * * `null` means "outside the root" (403) and `undefined` means "no such file" * (404). Both the HTTP server and the browser-side interception use this, so * the two can never disagree about what the build contains. */ function resolveBuildFile( rootDir: string, indexPath: string, spa: boolean, urlPath: string ): string | null | undefined { let filePath = resolveWithinRoot(rootDir, urlPath); if (filePath === null) return null; if (existsSync(filePath) && statSync(filePath).isDirectory()) { filePath = join(filePath, 'index.html'); } if (existsSync(filePath) && statSync(filePath).isFile()) return filePath; // Client-routed URLs get the shell. A missing *asset* stays a 404 — a broken // bundle must not be laundered into a successful page load. if (spa && extname(urlPath) === '') return indexPath; return undefined; } /** Serve a built `dist/` over loopback, with optional SPA fallback. */ export function startStaticServer(root: string, spa: boolean): Promise { const rootDir = resolve(root); if (!existsSync(rootDir)) { return Promise.reject( new Error( `staticDir "${root}" does not exist — build the app before running the audit ` + '(e.g. `bun run build`)' ) ); } const indexPath = join(rootDir, 'index.html'); if (!existsSync(indexPath)) { return Promise.reject(new Error(`staticDir "${root}" has no index.html`)); } const contentTypeFor = (filePath: string): string => MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream'; const resolveAsset = (urlPath: string): ResolvedAsset | null => { const filePath = resolveBuildFile(rootDir, indexPath, spa, urlPath); if (filePath === null || filePath === undefined) return null; return { body: readFileSync(filePath), contentType: contentTypeFor(filePath) }; }; const server: Server = createServer((req, res) => { const urlPath = (req.url ?? '/').split('?')[0].split('#')[0]; const filePath = resolveBuildFile(rootDir, indexPath, spa, urlPath); if (filePath === null) { res.writeHead(403).end('Forbidden'); return; } if (filePath === undefined) { res.writeHead(404, { 'content-type': 'text/plain' }).end('Not found'); return; } res.writeHead(200, { 'content-type': contentTypeFor(filePath), 'cache-control': 'no-store', }); createReadStream(filePath).pipe(res); }); return new Promise((resolvePromise, rejectPromise) => { server.on('error', rejectPromise); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (address === null || typeof address === 'string') { rejectPromise(new Error('static server did not bind to a TCP port')); return; } resolvePromise({ baseUrl: `http://127.0.0.1:${address.port}`, resolveAsset, close: () => new Promise((done) => { server.close(() => done()); server.closeAllConnections?.(); }), }); }); }); } async function waitForUrl(url: string, timeoutMs: number, child?: ChildProcess): Promise { const deadline = Date.now() + timeoutMs; let lastError = 'no response'; while (Date.now() < deadline) { if (child && child.exitCode !== null) { throw new Error(`server command exited with code ${child.exitCode} before serving ${url}`); } try { const response = await fetch(url, { redirect: 'manual' }); if (response.status < 500) return; lastError = `HTTP ${response.status}`; } catch (error) { lastError = (error as Error).message; } await new Promise((done) => setTimeout(done, 500)); } throw new Error(`timed out after ${timeoutMs}ms waiting for ${url} (last error: ${lastError})`); } /** Start a server via a shell command and wait until its port answers. */ export async function startCommandServer( command: string, port: number, readyTimeoutMs: number ): Promise { const baseUrl = `http://127.0.0.1:${port}`; const child = spawn(command, { shell: true, stdio: ['ignore', 'inherit', 'inherit'], detached: true, env: { ...process.env, PORT: String(port) }, }); const close = async (): Promise => { if (child.pid === undefined || child.exitCode !== null) return; try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); } await new Promise((done) => setTimeout(done, 500)); }; try { await waitForUrl(baseUrl, readyTimeoutMs, child); } catch (error) { await close(); throw error; } return { baseUrl, close }; } /** Resolve the configured serve mode into a running app. */ export async function startApp( config: ResolvedConfig, baseUrlOverride?: string ): Promise { if (baseUrlOverride) { await waitForUrl(baseUrlOverride, config.serve.readyTimeoutMs); return { baseUrl: baseUrlOverride.replace(/\/$/, ''), close: async () => {} }; } if (config.serve.baseUrl) { await waitForUrl(config.serve.baseUrl, config.serve.readyTimeoutMs); return { baseUrl: config.serve.baseUrl.replace(/\/$/, ''), close: async () => {} }; } if (config.serve.staticDir) { return startStaticServer(config.serve.staticDir, config.serve.spa); } return startCommandServer( config.serve.command as string, config.serve.port as number, config.serve.readyTimeoutMs ); }