feat(preview): auto-restart dev server on session open; WarmingUpState with elapsed timer

- New POST /api/projects/[id]/dev-server/ensure: checks if dev server is running,
  queries last known config from fs_dev_servers, fires startDevServer +
  probeDevServerReadiness in background, returns immediately
- Preview pane calls ensure on mount when anatomy is loaded but no server is running
- Distinguishes state='running' (show iframe) from state='starting' (show warming-up UI)
- WarmingUpState: indigo spinner, elapsed timer, 'View last deployed version' link if available
- ensureCalledRef prevents duplicate calls per mount
- The 5s anatomy poll handles the starting→running transition automatically
This commit is contained in:
2026-06-11 11:05:58 -07:00
parent 7a69f47608
commit d165ab9de1
2 changed files with 268 additions and 8 deletions

View File

@@ -19,12 +19,10 @@ function sandboxIframe(src: string, origin: string): boolean {
}
}
/** How long a deployment has been running, formatted as "1m 23s" */
/** Elapsed time since an ISO string, formatted as "1m 23s". */
function useElapsed(sinceIso: string | undefined) {
const [elapsed, setElapsed] = useState("");
useEffect(() => {
// No ISO timestamp — nothing to tick. The caller won't render
// the elapsed string when sinceIso is undefined anyway.
if (!sinceIso) return;
const update = () => {
const ms = Date.now() - new Date(sinceIso).getTime();
@@ -47,11 +45,20 @@ export default function PreviewTab() {
const params = useParams();
const projectId = params.projectId as string;
// Poll every 5 s so build-state transitions surface without a manual refresh.
// Poll every 5s so state transitions (starting→running, build complete, etc.)
// surface without a manual refresh.
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 5000 });
const previews = anatomy?.hosting.previews ?? [];
const primaryPreview = previews.find((p) => p.port === 3000);
// Only load the iframe for a server that is fully running.
const primaryRunning = previews.find(
(p) => p.port === 3000 && p.state === "running",
);
// Also track a starting entry so we show the warm-up state instead of blank.
const primaryStarting = !primaryRunning
? previews.find((p) => p.port === 3000 && p.state === "starting")
: undefined;
// Derive in-flight / recently-failed build from prod apps.
const liveApps = anatomy?.hosting.live ?? [];
@@ -60,6 +67,50 @@ export default function PreviewTab() {
? liveApps.find((a) => a.lastBuild?.status === "failed")
: undefined;
// Fallback URL — the last deployed production app. Shown as a link while the
// dev server is warming up so the user has something to interact with.
const fallbackFqdn =
liveApps.find((a) => a.fqdn && a.status === "running")?.fqdn ?? null;
const fallbackUrl = fallbackFqdn
? fallbackFqdn.startsWith("http")
? fallbackFqdn
: `https://${fallbackFqdn}`
: null;
// ── Auto-ensure: fire a background restart when the pane loads and finds
// no running dev server, but there's a previous config to restart from.
const ensureCalledRef = useRef(false);
const [ensureStatus, setEnsureStatus] = useState<
"idle" | "calling" | "starting" | "no_history" | "error"
>("idle");
useEffect(() => {
// Only trigger once per mount, and only when anatomy has loaded with no running server.
if (ensureCalledRef.current) return;
if (loading || !anatomy) return;
if (primaryRunning || primaryStarting) return; // already up or already starting
ensureCalledRef.current = true;
fetch(`/api/projects/${projectId}/dev-server/ensure`, {
method: "POST",
credentials: "include",
})
.then((r) => r.json())
.then((data: { status?: string }) => {
if (data.status === "no_history" || data.status === "no_container") {
setEnsureStatus("no_history");
} else if (data.status === "starting" || data.status === "running") {
setEnsureStatus("starting");
// The 5s anatomy poll will pick up the new 'starting' row and
// transition the pane automatically — no extra work needed here.
} else {
setEnsureStatus("idle");
}
})
.catch(() => setEnsureStatus("error"));
}, [loading, anatomy, primaryRunning, primaryStarting, projectId]);
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const iframeDomRef = useRef<HTMLIFrameElement | null>(null);
const bridge = usePreviewBridge();
@@ -69,19 +120,33 @@ export default function PreviewTab() {
const refreshKey = usePreviewToolbarStore((s) => s.refreshKey);
useLayoutEffect(() => {
setIframeSrc(primaryPreview?.url ?? null);
}, [primaryPreview?.url]);
setIframeSrc(primaryRunning?.url ?? null);
}, [primaryRunning?.url]);
useEffect(() => {
if (!bridge || !iframeSrc || !iframeDomRef.current) return;
bridge.registerPreviewIframe(iframeDomRef.current, iframeSrc);
}, [bridge, iframeSrc]);
// Derive content for the empty state.
// Determine which empty state to show.
const emptyContent = (() => {
if (loading && !anatomy) return <InitialLoader />;
if (inFlightApp) return <BuildingState app={inFlightApp} />;
if (failedApp) return <FailedState app={failedApp} />;
// Dev server is in the process of booting (either picked up from anatomy
// or we just fired the ensure endpoint and are waiting for the DB row).
if (primaryStarting) {
return (
<WarmingUpState
startedAt={primaryStarting.startedAt}
fallbackUrl={fallbackUrl}
/>
);
}
if (ensureStatus === "calling" || ensureStatus === "starting") {
return <WarmingUpState startedAt={undefined} fallbackUrl={fallbackUrl} />;
}
// Never had a dev server — needs the AI to start one.
return <NotRunningState />;
})();
@@ -144,6 +209,58 @@ function InitialLoader() {
);
}
function WarmingUpState({
startedAt,
fallbackUrl,
}: {
startedAt: string | undefined;
fallbackUrl: string | null;
}) {
const elapsed = useElapsed(startedAt);
return (
<div style={{ textAlign: "center", maxWidth: 280 }}>
<div
style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}
>
<div style={buildRingOuter}>
<div style={buildRingInner} className="animate-spin" />
</div>
</div>
<p style={{ ...emptyTitle, color: "#6366f1" }}>
Dev server warming up
{elapsed ? ` · ${elapsed}` : ""}
</p>
<p style={emptySubtext}>
Your preview will appear here automatically once it&apos;s ready.
Usually under 15 seconds.
</p>
{fallbackUrl && (
<a
href={fallbackUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-block",
marginTop: 14,
padding: "5px 12px",
borderRadius: 20,
background: "#f5f3ff",
border: "1px solid #c4b5fd",
fontSize: "0.72rem",
color: "#7c3aed",
textDecoration: "none",
}}
>
View last deployed version
</a>
)}
</div>
);
}
function BuildingState({
app,
}: {

View File

@@ -0,0 +1,143 @@
/**
* POST /api/projects/[projectId]/dev-server/ensure
*
* Lightweight endpoint called by the preview pane when it loads and finds
* no running dev server. Checks for a previous server config and restarts
* it in the background, returning immediately so the UI isn't blocked.
*
* Response shapes:
* { status: 'running', previewUrl } — already up, nothing to do
* { status: 'starting', previewUrl } — was down, restart fired
* { status: 'no_history' } — never started before, AI needs to do it
* { status: 'no_container' } — dev container doesn't exist yet
*/
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { queryOne } from "@/lib/db-postgres";
import { getWorkspaceById } from "@/lib/workspaces";
import {
ensureDevContainer,
startDevServer,
probeDevServerReadiness,
} from "@/lib/dev-container";
export async function POST(
_req: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await params;
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Load project — verify ownership
const project = await queryOne<{
id: string;
slug: string;
name: string;
vibn_workspace_id: string | null;
data: Record<string, unknown>;
}>(
`SELECT p.id, p.slug, p.name, p.vibn_workspace_id, p.data
FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1 AND u.data->>'email' = $2
LIMIT 1`,
[projectId, session.user.email],
);
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
// 1. Is a dev server already running or starting?
const running = await queryOne<{
id: string;
state: string;
preview_url: string;
command: string;
port: number;
}>(
`SELECT id, state, preview_url, command, port
FROM fs_dev_servers
WHERE project_id = $1 AND state IN ('running', 'starting')
ORDER BY started_at DESC LIMIT 1`,
[projectId],
);
if (running) {
return NextResponse.json({
status: running.state === "running" ? "running" : "starting",
previewUrl: running.preview_url,
command: running.command,
port: running.port,
});
}
// 2. Do we have a previous config to restart from?
const last = await queryOne<{
command: string;
port: number;
preview_url: string;
}>(
`SELECT command, port, preview_url
FROM fs_dev_servers
WHERE project_id = $1
ORDER BY started_at DESC LIMIT 1`,
[projectId],
);
if (!last) {
return NextResponse.json({ status: "no_history" });
}
// 3. Load workspace
if (!project.vibn_workspace_id) {
return NextResponse.json({ status: "no_container" });
}
const workspace = await getWorkspaceById(project.vibn_workspace_id);
if (!workspace) {
return NextResponse.json({ status: "no_container" });
}
// 4. Fire restart in background — don't block the response.
// The probe (up to 300s) runs in background; anatomy polling at 5s
// will surface state='starting' immediately, then 'running' when ready.
const restartOpts = {
projectId: project.id,
projectSlug: project.slug,
command: last.command,
port: last.port,
workspace,
};
void (async () => {
try {
await ensureDevContainer({
projectId: project.id,
projectSlug: project.slug,
projectName: project.name,
workspace,
});
const row = await startDevServer(restartOpts);
// Run the readiness probe in background so state transitions
// from 'starting' → 'running' (or 'failed') in the DB.
probeDevServerReadiness(project.id, row.id, row.port).catch((err) => {
console.error("[dev-server/ensure] probe failed:", err?.message);
});
} catch (err) {
console.error("[dev-server/ensure] restart failed:", err);
}
})();
return NextResponse.json({
status: "starting",
previewUrl: last.preview_url,
command: last.command,
port: last.port,
});
}