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.
96 lines • 5.17 kB
JavaScript
import { resolveRunCheckout } from './store/index.js';
import { startPreview, detectServeTargets } from './preview.js';
import { scopedKey } from './runtime-keys.js';
import { errorMessage } from './error-message.js';
/**
* On-demand app preview (#475), one long-lived preview process per project (plus one per session that
* asks, #797), kept alive across the request that opens it and the Stop that closes it. Split out of
* the project runtime because it shares nothing with the run half but the project resolver and the
* key scheme: previews know nothing about runs, and the run half reaches in only to stop a finished
* run's preview.
*
* Track a preview under its key and evict it the moment it stops serving (stop, or a self-exit: crash
* / build error / the user killing it), so status never reports a dead URL and the idempotent open
* below never hands back a corpse instead of restarting.
*
* Since #797 the key carries the session too, because a session serves its OWN worktree: one preview
* per project as before, plus one per session that asks for it, each pointing at the tree it belongs
* to. Keyed by project alone, a session's Serve booted the project's checkout and showed you code
* that session never wrote.
*/
export function createPreviewRuntime({ homeId, resolveProject }) {
const activePreviews = new Map();
const trackPreview = (key, handle) => {
activePreviews.set(key, handle);
void handle.exited.then(() => {
if (activePreviews.get(key) === handle)
activePreviews.delete(key);
});
};
// The app the user last served per project (#651), so re-serving a monorepo picks it again
// without re-choosing. In-memory: a live preview already rehydrates via onPreviewStatus, and
// the pick resets on daemon restart (the picker still lists everything).
const lastServeTarget = new Map();
const onServeTargets = async (targetProjectId, runId) => {
const projectCwd = await resolveProject(targetProjectId);
if (!projectCwd)
return [];
// Detected in the checkout that will actually be served: a session's branch may have added or
// removed a servable package, and offering the project's list would offer apps it cannot serve.
const serveCwd = await resolveRunCheckout(projectCwd, runId);
return detectServeTargets(serveCwd).catch(() => []);
};
const onPreview = async (targetProjectId, targetId, runId) => {
const projectKey = targetProjectId ?? homeId;
const key = scopedKey(projectKey, runId);
const existing = activePreviews.get(key);
if (existing)
return { ok: true, url: existing.url, command: existing.command };
const projectCwd = await resolveProject(targetProjectId);
if (!projectCwd)
return { ok: false, error: `unknown project: ${targetProjectId}` };
const serveCwd = await resolveRunCheckout(projectCwd, runId);
try {
// Resolve the pick: an explicit choice, else the one remembered from last time. Both are
// matched against the live target list so a stale/unknown id falls back to the root default.
// The memory is per project, not per session: which app you serve is a property of the repo.
const wantId = targetId ?? lastServeTarget.get(projectKey);
const target = wantId ? (await detectServeTargets(serveCwd).catch(() => [])).find(t => t.id === wantId) : undefined;
const handle = await startPreview(target ? { cwd: serveCwd, target } : { cwd: serveCwd });
// A racing second open won the slot while we were booting: keep theirs, drop ours.
const raced = activePreviews.get(key);
if (raced) {
await handle.stop().catch(() => { });
return { ok: true, url: raced.url, command: raced.command };
}
if (target)
lastServeTarget.set(projectKey, target.id);
trackPreview(key, handle);
return { ok: true, url: handle.url, command: handle.command };
}
catch (err) {
return { ok: false, error: errorMessage(err) };
}
};
const onStopPreview = async (targetProjectId, runId) => {
const key = scopedKey(targetProjectId ?? homeId, runId);
const handle = activePreviews.get(key);
if (!handle)
return;
activePreviews.delete(key);
await handle.stop().catch(() => { });
};
const onPreviewStatus = (targetProjectId, runId) => {
const handle = activePreviews.get(scopedKey(targetProjectId ?? homeId, runId));
return handle ? { running: true, url: handle.url, command: handle.command } : { running: false };
};
const dispose = async () => {
await Promise.all([...activePreviews.values()].map(p => p.stop().catch(() => { })));
activePreviews.clear();
};
return {
preview: { start: onPreview, targets: onServeTargets, stop: onStopPreview, status: onPreviewStatus },
dispose,
};
}
//# sourceMappingURL=preview-runtime.js.map