UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

172 lines 7.21 kB
import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { delimiter, join } from 'node:path'; /** Where Chrome usually lives, per platform. First hit wins. */ const CHROME_PATHS = { darwin: [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', ], linux: ['/opt/google/chrome/chrome', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser'], win32: [ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', ], }; /** The binaries to look for on `PATH` when no well-known path exists. */ const CHROME_BINARIES = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']; /** First existing match for `name` on `PATH`, or undefined. */ function onPath(name, env, platform, exists) { const exts = platform === 'win32' ? ['.exe', '.cmd', ''] : ['']; for (const dir of (env.PATH ?? '').split(delimiter).filter(Boolean)) { for (const ext of exts) { const full = join(dir, name + ext); if (exists(full)) return full; } } return undefined; } /** * The Chrome binary to launch, or undefined when the machine has none. `CHROME_PATH` (and * Puppeteer's variable, since a repo that has one usually means it) wins so a user on a * non-standard install is not stuck. * * `exists` is a parameter rather than a direct `existsSync` call so the lookup can be tested * against a known filesystem: CI runners have Chrome installed, so a test that assumes the * well-known paths are absent passes on a laptop and fails there. */ export function resolveChromePath(env = process.env, platform = process.platform, exists = existsSync) { for (const override of [env.CHROME_PATH, env.PUPPETEER_EXECUTABLE_PATH]) { if (override && exists(override)) return override; } for (const candidate of CHROME_PATHS[platform] ?? []) { if (exists(candidate)) return candidate; } for (const name of CHROME_BINARIES) { const found = onPath(name, env, platform, exists); if (found) return found; } return undefined; } /** * The launch flags. Headless by default — the agent has no screen, and a screencast reads a * headless page fine. The profile is throwaway so an agent never inherits (or dirties) the * user's real Chrome session. */ export function chromeLaunchArgs(port, userDataDir, headless = true) { return [ ...(headless ? ['--headless=new'] : []), `--remote-debugging-port=${port}`, `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check', '--window-size=1280,720', 'about:blank', ]; } /** A free localhost port, asked of the OS rather than guessed. */ export async function freePort() { return new Promise((resolve, reject) => { const server = createServer(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; server.close(() => (port ? resolve(port) : reject(new Error('no port')))); }); }); } /** * Poll `/json/version` until Chrome answers. Chrome opens the port a beat after the process * starts, so handing the MCP server a URL that is not listening yet is the obvious race. */ export async function waitForDebugEndpoint(browserUrl, opts = {}) { const { timeoutMs = 15_000, intervalMs = 100, fetchImpl = fetch } = opts; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const res = await fetchImpl(`${browserUrl}/json/version`); if (res.ok) return true; } catch { // Not listening yet. } await new Promise(r => setTimeout(r, intervalMs)); } return false; } /** * Launch the agent's Chrome, or return undefined when this machine has none — in which case the * caller leaves `--browser` exactly as it was (chrome-devtools-mcp launches its own). A * missing browser should cost the agent its preview, never its browser tools. */ export async function launchSharedBrowser(opts = {}) { const chromePath = opts.chromePath ?? resolveChromePath(); if (!chromePath) return undefined; const port = await freePort(); const userDataDir = await mkdtemp(join(tmpdir(), 'framework-chrome-')); const browserUrl = `http://127.0.0.1:${port}`; let child; try { child = spawn(chromePath, chromeLaunchArgs(port, userDataDir, opts.headless ?? true), { stdio: 'ignore' }); } catch { await rm(userDataDir, { recursive: true, force: true }); return undefined; } let closed = false; const close = async () => { if (closed) return; closed = true; child.kill(); await rm(userDataDir, { recursive: true, force: true }).catch(() => { }); }; // A Chrome that dies on its own must not leave the agent pointing at a dead port. `error` // needs its own handler or a failed spawn (bad path, no exec bit) throws unhandled and // takes the agent with it — a missing browser must only cost the preview. child.on('exit', () => void close()); child.on('error', () => void close()); const timeoutOpt = opts.timeoutMs === undefined ? {} : { timeoutMs: opts.timeoutMs }; if (!(await waitForDebugEndpoint(browserUrl, timeoutOpt))) { await close(); return undefined; } return { browserUrl, close }; } /** * The `--browser` MCP wiring (#452): chrome-devtools-mcp is a maintained stdio * server that launches its own Chromium and exposes DevTools tools (navigate, * console, network, DOM, screenshot). `npx -y` resolves it on demand so there is * nothing to pre-install. Merged into the build driver only, not the short * preset-router turn. */ export const BROWSER_MCP_SERVERS = { 'chrome-devtools': { command: 'npx', args: ['-y', 'chrome-devtools-mcp@latest'] }, }; /** * The same server, pointed at a Chrome the agent already launched (#793). `--browserUrl` makes * it attach instead of launching, which is what lets a second client (the screencast (#609)) * watch the very page the agent is on. Without a URL this is the old spec unchanged. */ export function browserMcpServers(browserUrl) { if (!browserUrl) return BROWSER_MCP_SERVERS; return { 'chrome-devtools': { command: 'npx', args: ['-y', 'chrome-devtools-mcp@latest', '--browserUrl', browserUrl] } }; } /** Fold the `--browser` MCP server into driver options when the flag is set. */ export function withBrowser(base, browser, browserUrl) { if (!browser) return base; return { ...base, mcpServers: { ...base.mcpServers, ...browserMcpServers(browserUrl) } }; } //# sourceMappingURL=browser.js.map