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.

301 lines 13.2 kB
import { spawn } from 'node:child_process'; import { createServer } from 'node:http'; import { createReadStream } from 'node:fs'; import { readFile, readdir, stat } from 'node:fs/promises'; import { basename, join, normalize, sep } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { contentTypeFor } from './dashboard/content-type.js'; /** The npm scripts we try, best-first, when serving a project's dev preview. */ export const PREVIEW_SCRIPTS = ['dev', 'start', 'preview', 'serve']; /** The first {@link PREVIEW_SCRIPTS} entry a `package.json`'s `scripts` defines, else undefined. */ function pickScript(scripts) { const s = scripts ?? {}; return PREVIEW_SCRIPTS.find(name => typeof s[name] === 'string' && s[name].trim() !== ''); } /** The first {@link PREVIEW_SCRIPTS} entry the project's `package.json` defines, else undefined. */ export async function detectDevScript(cwd) { return pickScript((await readPkg(cwd))?.scripts); } /** How many workspace packages we scan / offer, so a pathological monorepo can't hang or flood the picker. */ const MAX_SERVE_TARGETS = 50; /** * Enumerate the repo's servable apps (#651), best-first: the root package (when it has a serve * script) followed by each workspace package that has one, in path order. A plain single-package * repo yields at most one target (the root); a monorepo yields one per servable workspace so the * Serve button can offer a pick. Workspaces come from `pnpm-workspace.yaml` or the package.json * `workspaces` field; unreadable/absent config just yields the root (or nothing). */ export async function detectServeTargets(cwd) { const targets = []; const seen = new Set(); const add = async (dir) => { const abs = dir === '' ? cwd : join(cwd, dir); if (seen.has(abs)) return; seen.add(abs); const pkg = await readPkg(abs); const script = pickScript(pkg?.scripts); if (!script) return; targets.push({ id: dir === '' ? '.' : dir, label: serveLabel(pkg?.name, dir), dir, script }); }; await add(''); const rootPkg = await readPkg(cwd); for (const dir of await workspaceDirs(cwd, rootPkg)) { if (targets.length >= MAX_SERVE_TARGETS) break; await add(dir); } return targets; } /** A target's display label: its package name, else the dir basename (`.`/root → `root`). */ function serveLabel(name, dir) { if (typeof name === 'string' && name.trim() !== '') return name.trim(); const base = basename(dir); return base === '' || base === '.' ? 'root' : base; } /** Read and parse a directory's `package.json`, or undefined when absent/unreadable/malformed. */ async function readPkg(dir) { try { return JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')); } catch { return undefined; } } /** * The workspace package directories (relative to `cwd`), from `pnpm-workspace.yaml` `packages:` * or the package.json `workspaces` field, expanded from their globs. Order is stable (sorted). */ async function workspaceDirs(cwd, rootPkg) { const globs = await workspaceGlobs(cwd, rootPkg); const dirs = new Set(); for (const glob of globs) { for (const dir of await expandWorkspaceGlob(cwd, glob)) dirs.add(dir); } return [...dirs].sort(); } /** The raw workspace globs: pnpm's `pnpm-workspace.yaml` wins, else npm/yarn's `workspaces`. */ async function workspaceGlobs(cwd, rootPkg) { try { const doc = parseYaml(await readFile(join(cwd, 'pnpm-workspace.yaml'), 'utf8')); const pkgs = doc?.packages; if (Array.isArray(pkgs)) return pkgs.filter((g) => typeof g === 'string'); } catch { // no pnpm-workspace.yaml — fall through to the package.json workspaces field } const ws = rootPkg?.workspaces; const list = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : []; return list.filter((g) => typeof g === 'string'); } /** * Expand one workspace glob to the package dirs it matches (relative to `cwd`). Handles the shapes * real workspaces use — a literal dir, a single `*` segment (`packages/*`), and a trailing `**` * (`packages/**`) — by walking the directory tree rather than pulling in a glob dependency. Negations * (`!`) and dirs without a `package.json` are skipped; the walk is depth-bounded for `**`. */ async function expandWorkspaceGlob(cwd, glob) { if (glob.startsWith('!')) return []; // exclusion patterns: skip, we only add positives const parts = glob.replace(/\/+$/, '').split('/'); const out = []; let visited = 0; const walk = async (relDir, i) => { if (++visited > 2000) return; // bound the tree walk so a pathological repo can't hang the picker if (i === parts.length) { if (await hasPkg(join(cwd, relDir))) out.push(relDir); return; } const seg = parts[i]; if (seg === '**') { // Match this dir and any descendant (bounded), then continue past the `**`. await walk(relDir, i + 1); for (const child of await subdirs(join(cwd, relDir))) await walk(join(relDir, child), i); return; } if (seg === '*') { for (const child of await subdirs(join(cwd, relDir))) await walk(join(relDir, child), i + 1); return; } await walk(relDir === '' ? seg : join(relDir, seg), i + 1); }; await walk('', 0); return out; } /** The immediate child directory names of `dir` (excluding dotfiles and `node_modules`), or []. */ async function subdirs(dir) { try { const entries = await readdir(dir, { withFileTypes: true }); return entries.filter(e => e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.')).map(e => e.name); } catch { return []; } } /** Whether `dir` holds a `package.json` (marking a workspace package). */ async function hasPkg(dir) { return stat(join(dir, 'package.json')).then(s => s.isFile()).catch(() => false); } // Strip ANSI color codes (dev servers print their URL in color) before matching. const ANSI = /\[[0-9;]*m/g; const LOCAL_URL = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?(?:\/[^\s'"]*)?/i; /** * Parse the first browsable localhost URL a dev server prints (Vite, Next, CRA, and * friends all announce one), normalizing `0.0.0.0` to `localhost` and trimming any * trailing punctuation. Returns undefined when the output carries no such URL yet. */ export function parsePreviewUrl(output) { const match = output.replace(ANSI, '').match(LOCAL_URL); if (!match) return undefined; return match[0].replace(/^(https?:\/\/)0\.0\.0\.0/, '$1localhost').replace(/[.,;:)\]]+$/, ''); } /** * Start a live preview of the project. Prefers its dev script (spawned as its own * process group so {@link PreviewHandle.stop} kills the whole tree), and reads the * localhost URL the server announces on stdout/stderr. With no dev script, falls back * to a built-in static server when the project has an `index.html`. Throws when there * is nothing to serve, or the dev script never announces a URL. */ export async function startPreview(opts) { // A picked target (#651) serves that workspace package; otherwise fall back to the root package. if (opts.target) { const dir = opts.target.dir === '' ? opts.cwd : join(opts.cwd, opts.target.dir); return startDevServer(dir, opts.target.script, opts.waitMs ?? 20_000); } const script = await detectDevScript(opts.cwd); if (script) return startDevServer(opts.cwd, script, opts.waitMs ?? 20_000); const hasIndex = await stat(join(opts.cwd, 'index.html')).then(s => s.isFile()).catch(() => false); if (hasIndex) return startStaticServer(opts.cwd); throw new Error('nothing to preview: the project has no dev script and no index.html'); } /** Run the project's dev script and resolve once it announces a localhost URL. */ async function startDevServer(cwd, script, waitMs) { const command = `npm run ${script}`; // Own process group (detached) so stop() can SIGTERM the whole tree, not just npm. const child = spawn('npm', ['run', script], { cwd, env: process.env, detached: true }); try { const url = await new Promise((resolvePromise, reject) => { let output = ''; const timer = setTimeout(() => reject(new Error(`the "${script}" script did not print a localhost URL within ${Math.round(waitMs / 1000)}s`)), waitMs); const settle = (fn) => { clearTimeout(timer); child.stdout?.off('data', onData); child.stderr?.off('data', onData); child.off('exit', onExit); child.off('error', onError); fn(); }; const onData = (d) => { output += d.toString(); const found = parsePreviewUrl(output); if (found) settle(() => resolvePromise(found)); }; const onExit = () => settle(() => reject(new Error(`the "${script}" script exited before printing a URL`))); const onError = (err) => settle(() => reject(err)); child.stdout?.on('data', onData); child.stderr?.on('data', onData); child.once('exit', onExit); child.once('error', onError); }); // Resolves whether the child is killed by stop() or exits on its own (crash/build error). const exited = new Promise(resolvePromise => child.once('exit', () => resolvePromise())); return { url, command, exited, stop: () => stopChild(child) }; } catch (err) { await stopChild(child); throw err; } } /** SIGTERM the child's process group, escalating to SIGKILL, and resolve once it exits. */ function stopChild(child) { return new Promise(resolvePromise => { if (child.exitCode !== null || child.signalCode) return resolvePromise(); let done = false; const finish = () => { if (done) return; done = true; resolvePromise(); }; child.once('exit', finish); const kill = (sig) => { try { if (child.pid) process.kill(-child.pid, sig); } catch { // group already gone } }; kill('SIGTERM'); setTimeout(() => { kill('SIGKILL'); finish(); }, 3000).unref(); }); } /** A built-in static file server for a project with a plain `index.html` and no dev script. */ async function startStaticServer(cwd) { const server = createServer((req, res) => void serveStaticFile(cwd, req.url ?? '/', res)); await new Promise((resolvePromise, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => resolvePromise()); }); const port = server.address().port; const exited = new Promise(resolvePromise => server.once('close', () => resolvePromise())); return { url: `http://localhost:${port}`, command: 'static', exited, stop: () => new Promise(resolvePromise => { // Destroy any lingering keep-alive sockets so close() cannot hang on an open tab. server.closeAllConnections?.(); server.close(() => resolvePromise()); }), }; } /** Serve one file from `root`, defaulting `/` to `index.html`, refusing path traversal. */ async function serveStaticFile(root, urlPath, res) { // A malformed escape (`/%zz`) must not throw: this runs void-dispatched, so an // exception here would be an unhandled rejection that takes the process down (#938). let decoded; try { decoded = decodeURIComponent(urlPath.split('?')[0]); } catch { res.writeHead(400, { 'content-type': 'text/plain' }).end('bad request'); return; } const rel = normalize(decoded).replace(/^(\.\.[/\\])+/, ''); // `join(root, '.')` drops a trailing separator, so a `dir/`-shaped cwd cannot fail the prefix check. const base = join(root, '.'); const target = join(base, rel === '/' || rel === '.' || rel === '' ? 'index.html' : rel); // Refuse anything that escaped the root after normalization. if (target !== base && !target.startsWith(base + sep)) { res.writeHead(403, { 'content-type': 'text/plain' }).end('forbidden'); return; } const info = await stat(target).catch(() => undefined); const file = info?.isDirectory() ? join(target, 'index.html') : target; const fileInfo = info?.isDirectory() ? await stat(file).catch(() => undefined) : info; if (!fileInfo?.isFile()) { res.writeHead(404, { 'content-type': 'text/plain' }).end('not found'); return; } res.writeHead(200, { 'content-type': contentTypeFor(file) }); createReadStream(file).pipe(res); } //# sourceMappingURL=preview.js.map