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
JavaScript
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