"use client"; import { useParams } from "next/navigation"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Loader2 } from "lucide-react"; import { useAnatomy } from "@/components/project/use-anatomy"; import { usePreviewBridge } from "@/components/project/preview-bridge-context"; import { usePreviewToolbarStore } from "@/components/project/preview-toolbar/preview-toolbar-state"; const SAME_ORIGIN_SANDBOX = "allow-scripts allow-forms allow-same-origin allow-popups allow-modals allow-downloads" as const; function sandboxIframe(src: string, origin: string): boolean { if (!src.startsWith("http://") && !src.startsWith("https://")) return true; try { return origin.length > 0 && new URL(src).origin === origin; } catch { return true; } } /** Elapsed time since an ISO string, formatted as "1m 23s". Capped at 59m 59s. */ function useElapsed(sinceIso: string | undefined) { const [elapsed, setElapsed] = useState(""); useEffect(() => { if (!sinceIso) return; const update = () => { const ms = Date.now() - new Date(sinceIso).getTime(); if (ms < 0) return; const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); if (m > 59) { setElapsed("> 1h"); } else { setElapsed(m > 0 ? `${m}m ${s % 60}s` : `${s}s`); } }; update(); const id = setInterval(update, 1000); return () => { clearInterval(id); setElapsed(""); }; }, [sinceIso]); return elapsed; } export default function PreviewTab() { const params = useParams(); const projectId = params.projectId as string; // Poll every 5s so state transitions (starting→running, build complete, etc.) // surface without a manual refresh. const { anatomy, loading, reload } = useAnatomy(projectId, { pollMs: 5000 }); const previews = anatomy?.hosting.previews ?? []; const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 10000); return () => clearInterval(id); }, []); // Identify all valid running/starting previews (ignoring stale starting ones) const validPreviews = previews .filter( (p) => p.state === "running" || (p.state === "starting" && now - new Date(p.startedAt).getTime() < 15 * 60 * 1000), ) .sort((a, b) => a.port - b.port); // sort ports ascending const selectedPort = usePreviewToolbarStore((s) => s.selectedPort); const setSelectedPort = usePreviewToolbarStore((s) => s.setSelectedPort); // Auto-select logic if selectedPort is not in validPreviews useEffect(() => { if (validPreviews.length === 0) return; const hasSelected = validPreviews.some((p) => p.port === selectedPort); if (!hasSelected) { // Prefer 3000 if available, else pick the first one const fallback = validPreviews.find((p) => p.port === 3000) ?? validPreviews[0]; if (fallback) setSelectedPort(fallback.port); } }, [validPreviews, selectedPort, setSelectedPort]); // Derive the currently selected preview const activePreview = validPreviews.find((p) => p.port === selectedPort); // Split it into running and starting like before const primaryRunning = activePreview?.state === "running" ? activePreview : undefined; const primaryStarting = activePreview?.state === "starting" ? activePreview : undefined; // Derive in-flight / recently-failed build from prod apps. const liveApps = anatomy?.hosting.live ?? []; const inFlightApp = liveApps.find((a) => a.inFlightBuild); const failedApp = !inFlightApp ? 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: the single entry point that guarantees the preview is live. // We call it on every mount — even when anatomy already says "running" — // because a `running` row is only intent; the process may have died // (idle-stop / OOM / crash / host restart) leaving a dead port behind a // stale flag. The `ensure` endpoint verifies the port is ACTUALLY answering // and resurrects it if not, but never bounces a healthy server. That makes // "open the preview → it loads cleanly" reliable, and keeps the container // warm (the liveness probe touches activity). const ensureCalledRef = useRef(false); const [ensureStatus, setEnsureStatus] = useState< "idle" | "calling" | "starting" | "running" | "no_history" | "error" >("idle"); const [iframeSrc, setIframeSrc] = useState(null); const iframeDomRef = useRef(null); const bridge = usePreviewBridge(); const origin = typeof window !== "undefined" ? window.location.origin : ""; const deviceMode = usePreviewToolbarStore((s) => s.deviceMode); const refreshKey = usePreviewToolbarStore((s) => s.refreshKey); const currentPath = usePreviewToolbarStore((s) => s.currentPath); const [isForceStarting, setIsForceStarting] = useState(false); // Auto-ensure + refresh-heal, in one effect. // // On mount (and whenever the refresh button bumps `refreshKey`) we hit the // `ensure` endpoint, which is the single entry point that guarantees the // preview is live. We call it even when anatomy already says "running", // because a `running` row is only intent — the process may have died // (idle-stop / OOM / crash / host restart) leaving a dead port behind a stale // flag. `ensure` verifies the port is ACTUALLY answering and resurrects it if // not, but never bounces a healthy server (and the unique index + // `startDevServer` idempotency mean it can't duplicate one). So "open the // preview" and "click refresh" both reliably land on a clean, loaded app. // // The re-arm is a ref write (not setState), so the effect body stays free of // synchronous state updates; the only setState calls live in async callbacks. const lastEnsuredRefreshKeyRef = useRef(refreshKey); useEffect(() => { if (refreshKey !== lastEnsuredRefreshKeyRef.current && !isForceStarting) { lastEnsuredRefreshKeyRef.current = refreshKey; ensureCalledRef.current = false; } if (ensureCalledRef.current) return; if (loading || !anatomy) return; 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 === "running") { // Verified live — keep showing the iframe. setEnsureStatus("running"); } else if (data.status === "starting") { // Fresh start or resurrection of a dead server. Flip to warming-up and // force an immediate anatomy refetch: `ensure` has already marked any // stale/dead `running` row as stopped, so the refetch drops the // possibly-502 iframe and shows warming-up without waiting for the 5s // poll. The readiness probe then carries it to a clean load. setEnsureStatus("starting"); reload(); } else { setEnsureStatus("idle"); } }) .catch(() => setEnsureStatus("error")); }, [loading, anatomy, projectId, refreshKey, isForceStarting, reload]); useLayoutEffect(() => { if (!primaryRunning?.url) { setIframeSrc(null); } else { const base = primaryRunning.url.replace(/\/$/, ""); const path = currentPath.startsWith("/") ? currentPath : `/${currentPath}`; setIframeSrc(`${base}${path}`); } }, [primaryRunning?.url, currentPath]); useEffect(() => { if (!bridge || !iframeSrc || !iframeDomRef.current) return; bridge.registerPreviewIframe(iframeDomRef.current, iframeSrc); }, [bridge, iframeSrc]); // Determine which empty state to show. const emptyContent = (() => { if (loading && !anatomy) return ; if (inFlightApp) return ; if (failedApp) return ; // 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 isForceStarting is true, we know we clicked the manual start button // and are waiting for the DB to reflect 'starting'. if (primaryStarting || ensureStatus === "starting" || isForceStarting) { return ( ); } if (ensureStatus === "calling") { return ( ); } // Never had a dev server — needs the AI to start one. return ( { setIsForceStarting(true); fetch( `/api/projects/${projectId}/dev-server/ensure?forceStart=true`, { method: "POST", }, ) .then((res) => { if (!res.ok) throw new Error("Failed to start"); }) .catch(() => setIsForceStarting(false)); }} /> ); })(); return (
{deviceMode !== "desktop" && } {iframeSrc ? (