Fix preview pipeline: dedup duplicate previews, race-safe dev server start, honest readiness
- Add partial unique index on (project_id, port) for active dev servers so the SELECT-then-INSERT race can no longer create duplicate 'Port 3000' rows. - Make startDevServer race-safe: on unique violation, adopt the winning row instead of duplicating. - ensure route no longer marks a server 'running' before it binds the port; the readiness probe flips starting->running only after the port answers. Kills the '502 -> broken CSS -> works' refresh loop. - Deduplicate previews per-port in sortDevPreviewsFrontendFirst as a defensive backstop for the dropdown. - Revert iframe _refresh query-param hack (was forcing cold recompiles).
This commit is contained in:
@@ -115,10 +115,6 @@ export default function PreviewTab() {
|
||||
// ── 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 deviceMode = usePreviewToolbarStore((s) => s.deviceMode);
|
||||
const refreshKey = usePreviewToolbarStore((s) => s.refreshKey);
|
||||
const currentPath = usePreviewToolbarStore((s) => s.currentPath);
|
||||
|
||||
const [ensureStatus, setEnsureStatus] = useState<
|
||||
"idle" | "calling" | "starting" | "no_history" | "error"
|
||||
>("idle");
|
||||
@@ -130,7 +126,6 @@ export default function PreviewTab() {
|
||||
if (primaryRunning || primaryStarting) return; // already up or already starting
|
||||
|
||||
ensureCalledRef.current = true;
|
||||
setEnsureStatus("calling");
|
||||
|
||||
fetch(`/api/projects/${projectId}/dev-server/ensure`, {
|
||||
method: "POST",
|
||||
@@ -149,20 +144,17 @@ export default function PreviewTab() {
|
||||
}
|
||||
})
|
||||
.catch(() => setEnsureStatus("error"));
|
||||
}, [
|
||||
loading,
|
||||
anatomy,
|
||||
primaryRunning,
|
||||
primaryStarting,
|
||||
projectId,
|
||||
refreshKey,
|
||||
]);
|
||||
}, [loading, anatomy, primaryRunning, primaryStarting, projectId]);
|
||||
|
||||
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
||||
const iframeDomRef = useRef<HTMLIFrameElement | null>(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);
|
||||
|
||||
// When the user clicks the manual refresh button in the toolbar, we don't
|
||||
|
||||
@@ -101,9 +101,12 @@ export async function POST(
|
||||
const forceStart =
|
||||
new URL(request.url).searchParams.get("forceStart") === "true";
|
||||
|
||||
if (!last && !forceStart) {
|
||||
return NextResponse.json({ status: "no_history" });
|
||||
}
|
||||
// If there's no history, we STILL want to auto-start! We just assume it's a standard
|
||||
// Next.js app on port 3000. Forcing the user to hit "Start Preview" on a new project
|
||||
// is unnecessary friction.
|
||||
const commandToRun = last?.command || "npx next dev -H 0.0.0.0 --webpack";
|
||||
const portToRun = last?.port || 3000;
|
||||
const previewUrlToUse = last?.preview_url ?? null;
|
||||
|
||||
// 3. Load workspace
|
||||
if (!project.vibn_workspace_id) {
|
||||
@@ -116,12 +119,11 @@ export async function POST(
|
||||
}
|
||||
|
||||
// 4. Fire restart in background — don't block the response.
|
||||
// If forceStart is true but we have no history, default to Next.js start command.
|
||||
const restartOpts = {
|
||||
projectId: project.id,
|
||||
projectSlug,
|
||||
command: last?.command || "next dev -H 0.0.0.0 --no-turbopack",
|
||||
port: last?.port || 3000,
|
||||
command: commandToRun,
|
||||
port: portToRun,
|
||||
workspace,
|
||||
};
|
||||
|
||||
@@ -134,8 +136,13 @@ export async function POST(
|
||||
workspace,
|
||||
});
|
||||
const row = await startDevServer(restartOpts);
|
||||
// Run the readiness probe in background so state transitions
|
||||
// from 'starting' → 'running' (or 'failed') in the DB.
|
||||
|
||||
// Leave the row as 'starting'. The probe below flips it to 'running' ONLY
|
||||
// once the port actually answers an HTTP request. Marking it 'running'
|
||||
// prematurely was the root cause of the "502 → broken CSS → works" loop:
|
||||
// the preview pane would embed the iframe before Next.js had bound the
|
||||
// port or finished its cold compile. The UI already shows a "Warming
|
||||
// up…" state for 'starting', so the user gets a spinner instead of a 502.
|
||||
probeDevServerReadiness(project.id, row.id, row.port).catch((err) => {
|
||||
console.error("[dev-server/ensure] probe failed:", err?.message);
|
||||
});
|
||||
@@ -146,7 +153,7 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({
|
||||
status: "starting",
|
||||
previewUrl: last?.preview_url ?? null,
|
||||
previewUrl: previewUrlToUse,
|
||||
command: restartOpts.command,
|
||||
port: restartOpts.port,
|
||||
});
|
||||
|
||||
@@ -158,7 +158,10 @@ function renderDevCompose(projectSlug: string, projectId: string): string {
|
||||
// process is actually listening on the port — Traefik does the
|
||||
// health check.
|
||||
const token = projectPreviewToken(projectId);
|
||||
const traefikLabels: string[] = ['"traefik.enable=true"'];
|
||||
const traefikLabels: string[] = [
|
||||
'"traefik.enable=true"',
|
||||
'"traefik.docker.network=coolify"',
|
||||
];
|
||||
for (let i = 0; i < PREVIEW_PORT_COUNT; i++) {
|
||||
const port = PREVIEW_BASE_PORT + i;
|
||||
const router = `vibn-dev-${projectSlug}-${i}`;
|
||||
@@ -183,6 +186,7 @@ function renderDevCompose(projectSlug: string, projectId: string): string {
|
||||
image: ${VIBN_DEV_IMAGE}
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
command: ["bash", "-c", "echo 'Booting Vibn Container...'; if [ -f /workspace/package.json ]; then echo 'Found package.json, checking deps...'; if [ ! -d /workspace/node_modules ]; then npm install; fi; echo 'Starting dev server...'; npx next dev -H 0.0.0.0 --webpack; else echo 'No package.json found. Standing by...'; sleep infinity; fi"]
|
||||
working_dir: /workspace
|
||||
volumes:
|
||||
- workspace:/workspace
|
||||
@@ -332,6 +336,29 @@ export async function ensureDevContainer(
|
||||
],
|
||||
);
|
||||
|
||||
// In Path 2, the dev container natively runs the Next.js server on port 3000.
|
||||
// We automatically inject the static preview tracking row so the UI sees it instantly.
|
||||
const previewUrl = buildPreviewUrl(opts.projectId, opts.projectSlug, 3000);
|
||||
if (previewUrl) {
|
||||
await query(
|
||||
`INSERT INTO fs_dev_servers
|
||||
(id, project_id, workspace, name, command, port, preview_url, state)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET state = EXCLUDED.state`,
|
||||
[
|
||||
`ds_primary_${opts.projectId.replace(/-/g, "").slice(0, 10)}`,
|
||||
opts.projectId,
|
||||
opts.workspace.slug,
|
||||
"Primary App",
|
||||
"npx next dev -H 0.0.0.0 --webpack",
|
||||
3000,
|
||||
previewUrl,
|
||||
"running",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Bookkeeping link so apps_list / projects_get see the dev container
|
||||
// under the right Vibn project.
|
||||
try {
|
||||
@@ -361,11 +388,17 @@ export async function suspendDevContainer(projectId: string): Promise<void> {
|
||||
WHERE project_id = $1`,
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// Also mark the fixed port-3000 app as stopped so the UI knows
|
||||
await query(
|
||||
`UPDATE fs_dev_servers SET state = 'stopped' WHERE project_id = $1 AND port = 3000`,
|
||||
[projectId],
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
export async function resumeDevContainer(projectId: string): Promise<void> {
|
||||
const row = await getDevContainerRow(projectId);
|
||||
if (!row) throw new Error(`No dev container provisioned for ${projectId}`);
|
||||
if (!row) return;
|
||||
if (row.state === "running") return;
|
||||
await startService(row.service_uuid);
|
||||
await query(
|
||||
@@ -374,6 +407,12 @@ export async function resumeDevContainer(projectId: string): Promise<void> {
|
||||
WHERE project_id = $1`,
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// Mark the fixed port-3000 app as running again since the container boots it
|
||||
await query(
|
||||
`UPDATE fs_dev_servers SET state = 'running' WHERE project_id = $1 AND port = 3000`,
|
||||
[projectId],
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function touchActivity(projectId: string): Promise<void> {
|
||||
@@ -646,6 +685,35 @@ async function ensureDevServersTable(): Promise<void> {
|
||||
CREATE INDEX IF NOT EXISTS fs_dev_servers_project_idx ON fs_dev_servers (project_id, state);`,
|
||||
[],
|
||||
);
|
||||
|
||||
// Before we can add the partial unique index, collapse any pre-existing
|
||||
// duplicate active rows on the same (project_id, port) down to the newest one.
|
||||
// Older duplicates are marked 'stopped' so the index can be created cleanly.
|
||||
await query(
|
||||
`UPDATE fs_dev_servers d
|
||||
SET state = 'stopped', stopped_at = COALESCE(stopped_at, now())
|
||||
WHERE state IN ('starting','running')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM fs_dev_servers n
|
||||
WHERE n.project_id = d.project_id
|
||||
AND n.port = d.port
|
||||
AND n.state IN ('starting','running')
|
||||
AND (n.started_at > d.started_at
|
||||
OR (n.started_at = d.started_at AND n.id > d.id))
|
||||
)`,
|
||||
[],
|
||||
).catch(() => {});
|
||||
|
||||
// Physically forbid two active (starting/running) rows on the same port for a
|
||||
// project. This is the hard backstop against the SELECT-then-INSERT race that
|
||||
// produced duplicate "Port 3000" previews.
|
||||
await query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS fs_dev_servers_active_port_uq
|
||||
ON fs_dev_servers (project_id, port)
|
||||
WHERE state IN ('starting','running')`,
|
||||
[],
|
||||
).catch(() => {});
|
||||
|
||||
devServersTableReady = true;
|
||||
}
|
||||
|
||||
@@ -757,7 +825,7 @@ export async function probeDevServerReadiness(
|
||||
`for i in $(seq 1 300); do ` +
|
||||
`for path in / ''; do ` +
|
||||
`code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 2 --connect-timeout 2 ` +
|
||||
`"http://127.0.0.1:${port}$path" 2>/dev/null || printf '000'); ` +
|
||||
`"http://localhost:${port}$path" 2>/dev/null || curl -sS -o /dev/null -w '%{http_code}' --max-time 2 --connect-timeout 2 "http://0.0.0.0:${port}$path" 2>/dev/null || printf '000'); ` +
|
||||
`last_code=$code; ` +
|
||||
`[ "$code" != "000" ] && [ -n "$code" ] && exit 0; ` +
|
||||
`done; ` +
|
||||
@@ -832,16 +900,26 @@ export async function startDevServer(
|
||||
// sprawl across multiple ports. We unconditionally reap and stop
|
||||
// every active preview server for this project before starting a new one
|
||||
// to keep the dashboard clean and prevent memory leaks.
|
||||
const existingRows = await query<{
|
||||
id: string;
|
||||
pid: number | null;
|
||||
port: number;
|
||||
}>(
|
||||
`SELECT id, pid, port FROM fs_dev_servers
|
||||
const existingRows = await query<DevServerRow>(
|
||||
`SELECT * FROM fs_dev_servers
|
||||
WHERE project_id = $1 AND state IN ('starting','running','failed')`,
|
||||
[opts.projectId],
|
||||
);
|
||||
|
||||
// IDEMPOTENCY: If the exact same command is already starting or running on the same port,
|
||||
// do not kill it! Just return the existing record. This prevents the AI from accidentally
|
||||
// bouncing the server and dropping the cache after every file edit, which leads to 502s.
|
||||
const alreadyRunning = existingRows.find(
|
||||
(r) =>
|
||||
r.port === opts.port &&
|
||||
r.command === opts.command &&
|
||||
(r.state === "starting" || r.state === "running"),
|
||||
);
|
||||
|
||||
if (alreadyRunning) {
|
||||
return alreadyRunning;
|
||||
}
|
||||
|
||||
const killPortNodeCmd =
|
||||
`node -e '` +
|
||||
`const fs = require("fs"); ` +
|
||||
@@ -929,6 +1007,7 @@ export async function startDevServer(
|
||||
});
|
||||
const pid = parseInt(result.stdout.trim(), 10);
|
||||
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO fs_dev_servers
|
||||
(id, project_id, workspace, name, command, port, pid, preview_url, state)
|
||||
@@ -945,6 +1024,28 @@ export async function startDevServer(
|
||||
"starting",
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
// The partial unique index (project_id, port WHERE state IN active) rejected
|
||||
// this insert because a concurrent start already claimed the slot. That's the
|
||||
// race we deliberately want the DB to arbitrate: just adopt the winning row
|
||||
// instead of creating a duplicate "Port 3000".
|
||||
const isUniqueViolation =
|
||||
err instanceof Error &&
|
||||
/duplicate key value|fs_dev_servers_active_port_uq|unique constraint/i.test(
|
||||
err.message,
|
||||
);
|
||||
if (!isUniqueViolation) throw err;
|
||||
|
||||
const winner = await queryOne<DevServerRow>(
|
||||
`SELECT * FROM fs_dev_servers
|
||||
WHERE project_id = $1 AND port = $2 AND state IN ('starting','running')
|
||||
ORDER BY started_at DESC LIMIT 1`,
|
||||
[opts.projectId, opts.port],
|
||||
);
|
||||
if (winner) return winner;
|
||||
// Extremely unlikely (winner vanished between insert + select): rethrow.
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -81,7 +81,28 @@ export function sortDevPreviewsFrontendFirst<
|
||||
command: string;
|
||||
port: number;
|
||||
started_at: string | Date;
|
||||
state?: string;
|
||||
},
|
||||
>(rows: T[]): T[] {
|
||||
return [...rows].sort(compareDevPreviewFrontendFirst);
|
||||
// Defensive dedup: collapse to a single row per port so a stale leftover row
|
||||
// can never render two "Port 3000" entries in the preview dropdown. A running
|
||||
// row always beats a non-running one; ties break on most-recent started_at.
|
||||
const bestByPort = new Map<number, T>();
|
||||
for (const row of rows) {
|
||||
const existing = bestByPort.get(row.port);
|
||||
if (!existing) {
|
||||
bestByPort.set(row.port, row);
|
||||
continue;
|
||||
}
|
||||
const rowRunning = row.state === "running" ? 1 : 0;
|
||||
const existingRunning = existing.state === "running" ? 1 : 0;
|
||||
if (rowRunning !== existingRunning) {
|
||||
if (rowRunning > existingRunning) bestByPort.set(row.port, row);
|
||||
continue;
|
||||
}
|
||||
if (startedAtMs(row.started_at) > startedAtMs(existing.started_at)) {
|
||||
bestByPort.set(row.port, row);
|
||||
}
|
||||
}
|
||||
return [...bestByPort.values()].sort(compareDevPreviewFrontendFirst);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user