diff --git a/apps/api/src/tools/sandbox/start.ts b/apps/api/src/tools/sandbox/start.ts index 2a5a7e5be0..5a220c2052 100644 --- a/apps/api/src/tools/sandbox/start.ts +++ b/apps/api/src/tools/sandbox/start.ts @@ -460,6 +460,14 @@ async function provisionSandbox( // runners free to assign a unique dynamic port (user-desktop needs this; // multiple sandboxes on the user's machine share the host network and // can't all bind 3000). + // Live production URL (already normalized to an https URL by the settings + // form). Forwarded so the daemon's Fast Preview route can render the draft + // against production. Harmless when Fast Preview is off — the daemon only + // reads it when that route is hit. + const productionUrl = + typeof metadata.productionUrl === "string" && metadata.productionUrl.trim() + ? metadata.productionUrl.trim() + : undefined; const workload: Workload | undefined = runtime && packageManager ? { @@ -467,6 +475,7 @@ async function provisionSandbox( packageManager, ...(port !== null ? { devPort: Number(port) } : {}), ...(packageManagerPath ? { packageManagerPath } : {}), + ...(productionUrl ? { productionUrl } : {}), } : undefined; diff --git a/apps/web/src/components/sandbox/hooks/sandbox-events-context.test.ts b/apps/web/src/components/sandbox/hooks/sandbox-events-context.test.ts index aac70b5363..f58349f3ee 100644 --- a/apps/web/src/components/sandbox/hooks/sandbox-events-context.test.ts +++ b/apps/web/src/components/sandbox/hooks/sandbox-events-context.test.ts @@ -3,6 +3,7 @@ import { buildDirectDaemonEventsUrl, isDirectDaemonEventsGoneStatus, isLiveMetaKeyForScope, + isWorkingTreeReadyPhase, } from "./sandbox-events-context"; describe("buildDirectDaemonEventsUrl", () => { @@ -67,3 +68,33 @@ describe("isLiveMetaKeyForScope", () => { ); }); }); + +describe("isWorkingTreeReadyPhase", () => { + test("false before the repo is on disk", () => { + // The cold-start window that strands Fast Preview: the daemon answers, but + // `.deco/blocks/` doesn't exist, so /read 400s and useDecofile 502s. + expect(isWorkingTreeReadyPhase("idle")).toBe(false); + expect(isWorkingTreeReadyPhase("cloning")).toBe(false); + expect(isWorkingTreeReadyPhase("clone-failed")).toBe(false); + }); + + test("checking-out is excluded — the tree is mid-write", () => { + expect(isWorkingTreeReadyPhase("checking-out")).toBe(false); + }); + + test("installing is the trigger — clone done, deps pending", () => { + // Precisely the window Fast Preview exists to render in. + expect(isWorkingTreeReadyPhase("installing")).toBe(true); + }); + + test("later phases also count — a warm sandbox can skip past installing", () => { + expect(isWorkingTreeReadyPhase("starting")).toBe(true); + expect(isWorkingTreeReadyPhase("running")).toBe(true); + }); + + test("failure phases count — a failed install still leaves a readable tree", () => { + expect(isWorkingTreeReadyPhase("install-failed")).toBe(true); + expect(isWorkingTreeReadyPhase("start-failed")).toBe(true); + expect(isWorkingTreeReadyPhase("crashed")).toBe(true); + }); +}); diff --git a/apps/web/src/components/sandbox/hooks/sandbox-events-context.tsx b/apps/web/src/components/sandbox/hooks/sandbox-events-context.tsx index b533476ae7..05e2521cab 100644 --- a/apps/web/src/components/sandbox/hooks/sandbox-events-context.tsx +++ b/apps/web/src/components/sandbox/hooks/sandbox-events-context.tsx @@ -30,7 +30,8 @@ import { type ReactNode, } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { useProjectContext } from "@/sdk"; +import { useProjectContext, useVirtualMCP } from "@/sdk"; +import { sanitizeProductionUrl } from "@decocms/shared/deco-site-production-url"; import { KEYS, invalidateVirtualMcpQueries } from "@/lib/query-keys"; import { exponentialBackoffWithJitter } from "@decocms/shared/std"; @@ -71,6 +72,14 @@ export interface SandboxEventsValue { branch: BranchMeta; /** True after a `gone` event — handle gone, reprovision via SANDBOX_START. */ notFound: boolean; + /** + * Content version of the working-tree draft decofile, or null until the + * daemon first announces one. Same value `/_sandbox/decofile` serves as its + * ETag, so it can be used verbatim in a draft pointer. Changes on every + * `.deco/blocks/*` save, which is what lets the preview refresh without + * polling. + */ + decofileVersion: string | null; scripts: string[]; activeProcesses: string[]; getBuffer: (source: string) => string; @@ -92,6 +101,7 @@ const DEFAULT_VALUE: SandboxEventsValue = { status: { state: "running" }, branch: { kind: "unknown" }, notFound: false, + decofileVersion: null, scripts: [], activeProcesses: [], getBuffer: () => "", @@ -134,6 +144,7 @@ const DAEMON_EVENT_TYPES: readonly DaemonEventName[] = [ "branch", "reload", "file-changed", + "decofile", ] as const; // `log` is broadcast separately — same SSE stream, different shape. const LOG_EVENT = "log" as const; @@ -168,6 +179,37 @@ export function isDirectDaemonEventsGoneStatus(status: number): boolean { return status === 404; } +/** + * Phases that guarantee the repo is checked out on disk. + * + * The daemon serves the CMS decofile without a dev server: `/read` of + * `.deco/blocks.gen.json` falls back to merging `.deco/blocks/*.json` on the + * fly. That fallback returns 400 while `.deco/blocks/` is missing — i.e. during + * `cloning`/`checking-out` — and `useDecofile` turns that into a 502 it + * deliberately never retries. So the read has exactly one good moment to be + * re-driven: the first phase that implies a populated working tree. + * + * `installing` is that moment (clone + checkout done, only deps pending) and is + * precisely the window Fast Preview renders in. The later phases are included + * because a warm sandbox with cached deps can skip past `installing` between + * two SSE frames — and the failure phases because a repo that failed to install + * still has a tree the CMS can read. + * + * `checking-out` is deliberately excluded: the tree is mid-write there. + */ +export function isWorkingTreeReadyPhase( + phase: LifecycleState["phase"], +): boolean { + return ( + phase === "installing" || + phase === "install-failed" || + phase === "starting" || + phase === "start-failed" || + phase === "running" || + phase === "crashed" + ); +} + async function probeDirectDaemonEventsGone(url: string): Promise { try { const response = await fetch(url, { @@ -205,8 +247,24 @@ export function SandboxEventsProvider({ }) { const { org } = useProjectContext(); const queryClient = useQueryClient(); + + // Fast Preview renders via the daemon with NO dev server, so the + // dev-server-gated `.deco/*` reload path below would never fire. Track it (as + // a ref, so the SSE closure reads the latest without reconnecting) to still + // fire a reload on a Blocks save. We deliberately do NOT invalidate the + // decofile query in that mode — the daemon reads `.deco/blocks/*` straight + // from disk, and a refetch could revert an optimistic edit against a committed + // `blocks.gen.json`. + const vmcp = useVirtualMCP(virtualMcpId ?? undefined); + const fastPreviewActive = + !!sanitizeProductionUrl(vmcp?.metadata?.productionUrl) && + vmcp?.metadata?.fastPreview === true; + const fastPreviewActiveRef = useRef(fastPreviewActive); + // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- keep the SSE closure reading the latest value without reconnecting + fastPreviewActiveRef.current = fastPreviewActive; const [phase, setPhase] = useState(null); const [lifecycle, setLifecycle] = useState({ phase: "idle" }); + const [decofileVersion, setDecofileVersion] = useState(null); const [status, setStatus] = useState({ state: "running" }); const [branchMeta, setBranchMeta] = useState({ kind: "unknown" }); const [notFound, setNotFound] = useState(false); @@ -241,6 +299,7 @@ export function SandboxEventsProvider({ setBranchMeta({ kind: "unknown" }); prevPortRef.current = null; setNotFound(false); + setDecofileVersion(null); setScripts([]); setActiveProcesses([]); buffers.current.clear(); @@ -259,7 +318,7 @@ export function SandboxEventsProvider({ // Tracks the dev-server lifecycle so we can invalidate the CMS queries once // it comes up (switching them from the committed `.deco/*.gen.json` snapshot // to the live `/.decofile` + `/live/_meta` routes). - let prevLifecyclePhase: string | null = null; + let prevLifecyclePhase: LifecycleState["phase"] | null = null; const handleClaimPhase = (e: MessageEvent) => { try { @@ -327,6 +386,30 @@ export function SandboxEventsProvider({ case "lifecycle": { const lp = payload as DaemonEventPayload<"lifecycle">; setLifecycle(lp.state); + const cacheKeyForBranch = `${org.slug}/${virtualMcpId}/${branch}`; + // Working tree just landed (see isWorkingTreeReadyPhase). Re-drive + // the two things Fast Preview needs but that nothing else retries: + // the decofile — whose cold-start read 502s while the repo is still + // cloning and is never retried — and the entity, which carries + // `previewUrl` in `sandboxMap` behind a 60s staleTime. Without this + // both stay stale until `running` below, stranding Fast Preview + // behind the very install it exists to skip. + if ( + isWorkingTreeReadyPhase(lp.state.phase) && + !( + prevLifecyclePhase && + isWorkingTreeReadyPhase(prevLifecyclePhase) + ) + ) { + invalidateVirtualMcpQueries(queryClient, org.id); + // Only when the decofile never loaded (the 502 case) — a + // populated cache can hold an optimistic block edit a refetch + // would revert, the same hazard the post-write path guards. + const decofileKey = KEYS.decofile(cacheKeyForBranch); + if (queryClient.getQueryData(decofileKey) === undefined) { + void queryClient.invalidateQueries({ queryKey: decofileKey }); + } + } // Dev server just came up: swap the CMS queries off the committed // `.deco/*.gen.json` snapshot and onto the live routes. This is the // trigger they rely on (they're enabled as soon as the daemon is up, @@ -343,15 +426,14 @@ export function SandboxEventsProvider({ // panels never mount on a cold-start open — only a hard refresh // re-fetches the entity and picks it up. invalidateVirtualMcpQueries(queryClient, org.id); - const cacheKey = `${org.slug}/${virtualMcpId}/${branch}`; void queryClient.invalidateQueries({ - queryKey: KEYS.decofile(cacheKey), + queryKey: KEYS.decofile(cacheKeyForBranch), }); void queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === "live-meta" && typeof query.queryKey[1] === "string" && - query.queryKey[1].startsWith(`${cacheKey}/`), + query.queryKey[1].startsWith(`${cacheKeyForBranch}/`), }); } prevLifecyclePhase = lp.state.phase; @@ -371,6 +453,11 @@ export function SandboxEventsProvider({ } return; } + case "decofile": + setDecofileVersion( + (payload as DaemonEventPayload<"decofile">).version, + ); + return; case "status": setStatus(payload as DaemonEventPayload<"status">); return; @@ -421,15 +508,16 @@ export function SandboxEventsProvider({ const isDecoFile = filePath.startsWith(".deco/") || filePath.includes("/.deco/"); if (isDecoFile) { - // Only act while the dev server is running. When it's down - // (crashed/paused — the state the committed-snapshot fallback - // supports editing in), `blocks.gen.json` is a stale build - // artifact the dev server can't regenerate, so invalidating + - // refetching it would overwrite the optimistic cache update from - // useSaveBlock/useDeleteBlock and the edit would visibly revert. - // Leave the optimistic cache as the source of truth; the - // lifecycle→running transition re-invalidates onto the live routes. - if (prevLifecyclePhase !== "running") return; + // Act when the dev server is running OR Fast Preview is on (which + // renders via the daemon with no dev server). Otherwise + // (crashed/paused — committed-snapshot editing) `blocks.gen.json` + // is a stale build artifact the dev server can't regenerate, so + // invalidating + refetching it would overwrite the optimistic + // cache update from useSaveBlock/useDeleteBlock and the edit would + // visibly revert; leave the optimistic cache as the source of + // truth until the lifecycle→running transition re-invalidates. + const devServerRunning = prevLifecyclePhase === "running"; + if (!devServerRunning && !fastPreviewActiveRef.current) return; // Turn on the preview's loading overlay immediately — before the // debounce below — so the pending refresh feels instant instead of // only appearing once the reload finally fires. @@ -451,15 +539,20 @@ export function SandboxEventsProvider({ if (decofileDebounceTimer) clearTimeout(decofileDebounceTimer); decofileDebounceTimer = setTimeout(() => { decofileDebounceTimer = null; - void queryClient.invalidateQueries({ - queryKey: KEYS.decofile(cacheKey), - }); - // Reload the preview iframe once the rebuild has landed — HMR + // Only refetch the decofile when the dev server is running (the + // live `/.decofile` route reflects the write). In Fast Preview + // the daemon serves the render straight from disk, so skip the + // refetch (it would revert an optimistic edit) and just reload. + if (devServerRunning) { + void queryClient.invalidateQueries({ + queryKey: KEYS.decofile(cacheKey), + }); + } + // Reload the preview iframe once the write has landed — HMR // won't reflect a decofile edit, so this is the only thing that // refreshes the rendered page after a Blocks save (or an - // external/agent `.deco/` write). Debounced with the - // invalidation above so it fires after the dev server rebuilds, - // not against the stale pre-rebuild page. + // external/agent `.deco/` write). Debounced so it fires after + // the write lands ("save → refresh"). for (const fn of reloadHandlers.current) { try { fn(); @@ -627,6 +720,7 @@ export function SandboxEventsProvider({ status, branch: branchMeta, notFound, + decofileVersion, scripts, activeProcesses, getBuffer: (source: string) => buffers.current.get(source)?.get() ?? "", diff --git a/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.test.ts b/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.test.ts index 36743d8aa3..d567b32e8c 100644 --- a/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.test.ts +++ b/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.test.ts @@ -13,6 +13,8 @@ import { shouldAutoRetryClaim, reconcileClaimRetryEpisode, MAX_CLAIM_AUTO_RETRIES, + resolvePreviewUrl, + sandboxPreviewKey, type BranchMapEntryLike, } from "./sandbox-lifecycle-context"; @@ -524,3 +526,105 @@ describe("computeOthersThreadGate", () => { expect(acked.autoStartBlocked).toBe(false); }); }); + +describe("sandboxPreviewKey", () => { + test("distinguishes branches of the same vmcp", () => { + expect(sandboxPreviewKey("vm1", "main")).not.toBe( + sandboxPreviewKey("vm1", "feat"), + ); + }); + + test("distinguishes the same branch across vmcps", () => { + expect(sandboxPreviewKey("vm1", "main")).not.toBe( + sandboxPreviewKey("vm2", "main"), + ); + }); + + test("a null branch is its own key, not a wildcard", () => { + expect(sandboxPreviewKey("vm1", null)).toBe("vm1::"); + expect(sandboxPreviewKey("vm1", null)).not.toBe( + sandboxPreviewKey("vm1", "main"), + ); + }); +}); + +describe("resolvePreviewUrl", () => { + const KEY = sandboxPreviewKey("vm1", "main"); + const SEED = { key: KEY, url: "https://claimed.example" }; + + test("the recorded entry wins over a seed", () => { + expect( + resolvePreviewUrl({ + vmEntry: entry("agent-sandbox", "h1", "https://recorded.example"), + seeded: SEED, + key: KEY, + }), + ).toBe("https://recorded.example"); + }); + + test("falls back to the claim seed while the entity query is stale", () => { + // The window this exists for: the daemon is up and SANDBOX_START already + // returned its URL, but sandboxMap hasn't been refetched yet. + expect(resolvePreviewUrl({ vmEntry: null, seeded: SEED, key: KEY })).toBe( + "https://claimed.example", + ); + }); + + test("falls back when an entry exists but carries no previewUrl", () => { + expect( + resolvePreviewUrl({ + vmEntry: entry("agent-sandbox", "h1", null), + seeded: SEED, + key: KEY, + }), + ).toBe("https://claimed.example"); + }); + + test("ignores a seed claimed for another branch", () => { + // Guards the real hazard: painting the previous branch's sandbox after a + // switch. Stale seeds are scoped out, never reset. + expect( + resolvePreviewUrl({ + vmEntry: null, + seeded: SEED, + key: sandboxPreviewKey("vm1", "other"), + }), + ).toBeNull(); + }); + + test("null with neither source", () => { + expect( + resolvePreviewUrl({ vmEntry: null, seeded: null, key: KEY }), + ).toBeNull(); + }); +}); + +describe("resolvePreviewUrl + claim failure (integration of the two rules)", () => { + const KEY = sandboxPreviewKey("vm1", "main"); + const SEED = { key: KEY, url: "https://claimed.example" }; + + test("a dropped seed leaves nothing to paint, so the errored card wins", () => { + // The provider passes `seeded: null` on a terminal claim failure. Encoded + // here because computePreviewState reads any previewUrl as "sandbox live" + // and would paint a dead iframe over the error. + expect( + resolvePreviewUrl({ vmEntry: null, seeded: null, key: KEY }), + ).toBeNull(); + }); + + test("a recorded entry still wins on failure — a failed restart can't blank a working preview", () => { + expect( + resolvePreviewUrl({ + vmEntry: entry("agent-sandbox", "h1", "https://recorded.example"), + seeded: null, + key: KEY, + }), + ).toBe("https://recorded.example"); + }); + + test("the seed applies while no failure is present", () => { + expect(resolvePreviewUrl({ vmEntry: null, seeded: SEED, key: KEY })).toBe( + "https://claimed.example", + ); + }); +}); diff --git a/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx b/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx index 15eb143c32..9dabb35193 100644 --- a/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx +++ b/apps/web/src/components/sandbox/hooks/sandbox-lifecycle-context.tsx @@ -250,6 +250,48 @@ export function computeDrawerStatus(state: PreviewState): DrawerStatus { } } +/** A `previewUrl` captured from a SANDBOX_START response, scoped to its claim. */ +export interface SeededPreviewUrl { + /** `virtualMcpId::branch` this URL was claimed for (see `sandboxPreviewKey`). */ + key: string; + url: string; +} + +/** + * Identity of a claim. `branch` is null on a fresh thread — the server names it + * in the SANDBOX_START response — so callers seeding from a response pass the + * response's branch, not the (still null) prop. + */ +export function sandboxPreviewKey( + virtualMcpId: string | null, + branch: string | null, +): string { + return `${virtualMcpId ?? ""}::${branch ?? ""}`; +} + +/** + * The daemon origin to render against. + * + * SANDBOX_START returns `previewUrl` at claim time, but `vmEntry` reads it from + * the entity's `sandboxMap` — a collection query with a 60s staleTime whose only + * lifecycle-driven invalidation fires when the dev server reports `running`. So + * the recorded value can lag the live daemon by the entire install/boot, which + * is exactly the window Fast Preview exists to render in. + * + * The recorded entry wins when present (authoritative, survives remounts); the + * claim response is the fallback. The seed is key-scoped rather than reset on + * change, so a stale claim can never paint another branch's sandbox. + */ +export function resolvePreviewUrl(args: { + vmEntry: BranchMapEntryLike | null; + seeded: SeededPreviewUrl | null; + key: string; +}): string | null { + const recorded = args.vmEntry?.previewUrl ?? null; + if (recorded) return recorded; + return args.seeded?.key === args.key ? args.seeded.url : null; +} + // --------------------------------------------------------------------------- // Provider + hook // --------------------------------------------------------------------------- @@ -276,11 +318,37 @@ import { sandboxUserStop, useSandboxStart, type SandboxStartArgs, + type SandboxStartResult, } from "./use-sandbox-start"; import { useSandboxEvents } from "./use-sandbox-events"; import { computePreviewState } from "@/components/sandbox/preview/preview-state"; import { decodeSandboxStartError } from "@decocms/shared/sandbox-start-errors"; +/** + * Shared SANDBOX_START `onSuccess`: adopt the branch the server named and record + * the claim's `previewUrl` (see `resolvePreviewUrl`). + * + * Module-level on purpose — every call site passes its dependencies explicitly, + * so the identity stays stable and the effects that reference it don't re-fire + * each render (React 19 bans useCallback here). + */ +function recordSandboxStart(args: { + data: SandboxStartResult | undefined; + virtualMcpId: string | null; + branch: string | null; + setCurrentTaskBranch: (branch: string) => void; + setSeededPreviewUrl: (seed: SeededPreviewUrl) => void; +}): void { + const { data, virtualMcpId, branch } = args; + if (data?.branch && !branch) args.setCurrentTaskBranch(data.branch); + if (data?.previewUrl) { + args.setSeededPreviewUrl({ + key: sandboxPreviewKey(virtualMcpId, data.branch ?? branch), + url: data.previewUrl, + }); + } +} + export interface SandboxLifecycleValue { branch: string | null; previewState: PreviewState; @@ -384,6 +452,12 @@ export function SandboxLifecycleProvider({ // Destructure mutate/reset so they're stable references for effect deps. const { mutate: startVmMutate, reset: startVmReset } = startVm; + // `previewUrl` straight off the claim, until the entity query catches up. + // Lets Fast Preview render while the dev server is still installing — see + // resolvePreviewUrl. + const [seededPreviewUrl, setSeededPreviewUrl] = + useState(null); + // Branch-keyed dedup (replaces both legacy taskId-keyed (preview.tsx) and // branch-keyed (VmEventsBridge) dedup refs). const autoStartAttemptedForBranchRef = useRef>(new Set()); @@ -421,13 +495,24 @@ export function SandboxLifecycleProvider({ // matching entry wins; with no entry for that kind (or no kind at all) fall // back to whatever is serving the branch. See resolveVmEntry. const vmEntry = resolveVmEntry(branchMap, sandboxProviderKind); - const previewUrl = vmEntry?.previewUrl ?? null; + const failedPhase = events.phase?.kind === "failed" ? events.phase : null; + const previewUrl = resolvePreviewUrl({ + vmEntry, + // SANDBOX_START returns a previewUrl at claim time, before the pod is + // known-healthy. On a terminal claim failure that handle never came up, so + // drop the seed: computePreviewState reads any previewUrl as "the sandbox + // is live" and would paint a dead iframe over the errored card. A recorded + // vmEntry is different — it means the sandbox did serve at some point, and + // the existing rule (a failed restart shouldn't blank a working preview) + // still applies. + seeded: failedPhase ? null : seededPreviewUrl, + key: sandboxPreviewKey(virtualMcpId, branch), + }); const userStopped = !!virtualMcpId && !!branch && sandboxUserStop.isStopped(virtualMcpId, branch); const appPaused = events.status.state === "paused"; - const failedPhase = events.phase?.kind === "failed" ? events.phase : null; // A retryable claim failure keeps the booting overlay (deriveStartError // returns null) while bounded auto-retry still has budget; once exhausted (or // for a non-retryable reason) it surfaces the errored card. @@ -487,7 +572,13 @@ export function SandboxLifecycleProvider({ ); startVmMutate(args, { onSuccess: (data) => { - if (data?.branch && !branch) setCurrentTaskBranch(data.branch); + recordSandboxStart({ + data, + virtualMcpId, + branch, + setCurrentTaskBranch, + setSeededPreviewUrl, + }); }, onError: (err) => { console.error("[sandbox-lifecycle] auto-start failed:", err); @@ -501,6 +592,7 @@ export function SandboxLifecycleProvider({ sandboxProviderKind, startVmMutate, setCurrentTaskBranch, + setSeededPreviewUrl, ]); // Self-heal: SSE emits `gone` → reprovision via SANDBOX_START. Dedup by @@ -527,7 +619,13 @@ export function SandboxLifecycleProvider({ ); startVmMutate(args, { onSuccess: (data) => { - if (data?.branch && !branch) setCurrentTaskBranch(data.branch); + recordSandboxStart({ + data, + virtualMcpId, + branch, + setCurrentTaskBranch, + setSeededPreviewUrl, + }); }, onError: (err) => { console.error("[sandbox-lifecycle] self-heal failed:", err); @@ -541,6 +639,7 @@ export function SandboxLifecycleProvider({ sandboxProviderKind, startVmMutate, setCurrentTaskBranch, + setSeededPreviewUrl, ]); // Auto-retry: a *retryable* terminal claim failure (capacity/scheduling/ @@ -581,7 +680,13 @@ export function SandboxLifecycleProvider({ ); startVmMutate(args, { onSuccess: (data) => { - if (data?.branch && !branch) setCurrentTaskBranch(data.branch); + recordSandboxStart({ + data, + virtualMcpId, + branch, + setCurrentTaskBranch, + setSeededPreviewUrl, + }); }, onError: (err) => { console.error("[sandbox-lifecycle] claim auto-retry failed:", err); @@ -595,6 +700,7 @@ export function SandboxLifecycleProvider({ sandboxProviderKind, startVmMutate, setCurrentTaskBranch, + setSeededPreviewUrl, ]); // User-driven actions. @@ -607,7 +713,13 @@ export function SandboxLifecycleProvider({ ); startVmMutate(args, { onSuccess: (data) => { - if (data?.branch && !branch) setCurrentTaskBranch(data.branch); + recordSandboxStart({ + data, + virtualMcpId, + branch, + setCurrentTaskBranch, + setSeededPreviewUrl, + }); }, onError: (err) => { console.error("[sandbox-lifecycle] user start failed:", err); diff --git a/apps/web/src/components/sandbox/preview/preview-display.test.ts b/apps/web/src/components/sandbox/preview/preview-display.test.ts index 8f377c7b98..cc4fe30840 100644 --- a/apps/web/src/components/sandbox/preview/preview-display.test.ts +++ b/apps/web/src/components/sandbox/preview/preview-display.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { resolvePreviewDisplay } from "./preview-display"; +import { + type PreviewDisplayInput, + resolvePreviewDisplay, +} from "./preview-display"; import type { PreviewState } from "./preview-state"; const IFRAME: PreviewState = { @@ -16,15 +19,22 @@ const OTHERS_THREAD: PreviewState = { kind: "othersThread", label: "Alice" }; const PROD = "https://acme.com"; +// Fast Preview off is the default for the pre-existing behavior cases; each test +// overrides what it exercises. +function run(overrides: Partial) { + return resolvePreviewDisplay({ + previewState: STARTING, + progressStatus: "doing", + productionUrl: PROD, + fastPreviewActive: false, + fastPreviewReady: false, + ...overrides, + }); +} + describe("resolvePreviewDisplay", () => { it("shows the sandbox iframe once boot is done (running)", () => { - expect( - resolvePreviewDisplay({ - previewState: IFRAME, - progressStatus: "done", - productionUrl: PROD, - }), - ).toEqual({ + expect(run({ previewState: IFRAME, progressStatus: "done" })).toEqual({ mode: "sandbox", iframeBase: IFRAME.kind === "iframe" ? IFRAME.previewUrl : null, showBlockingOverlay: false, @@ -33,23 +43,13 @@ describe("resolvePreviewDisplay", () => { }); it("shows the sandbox iframe (crash page) on a failed boot, not production", () => { - const result = resolvePreviewDisplay({ - previewState: IFRAME, - progressStatus: "failed", - productionUrl: PROD, - }); + const result = run({ previewState: IFRAME, progressStatus: "failed" }); expect(result.mode).toBe("sandbox"); expect(result.showWakingPill).toBe(false); }); it("falls back to production + pill while a fresh boot is in progress", () => { - expect( - resolvePreviewDisplay({ - previewState: STARTING, - progressStatus: "doing", - productionUrl: PROD, - }), - ).toEqual({ + expect(run({ previewState: STARTING, progressStatus: "doing" })).toEqual({ mode: "production", iframeBase: PROD, showBlockingOverlay: false, @@ -57,13 +57,11 @@ describe("resolvePreviewDisplay", () => { }); }); - it("falls back to production + pill while the sandbox iframe is still warming", () => { + it("falls back to production + pill while the sandbox iframe is still warming (Fast Preview off)", () => { // previewUrl exists (kind === "iframe") but the dev server isn't up yet. - const result = resolvePreviewDisplay({ - previewState: IFRAME, - progressStatus: "doing", - productionUrl: PROD, - }); + // With Fast Preview off the production surface is the published stopgap, so + // the pill stays up until the dev server is routable. + const result = run({ previewState: IFRAME, progressStatus: "doing" }); expect(result.mode).toBe("production"); expect(result.iframeBase).toBe(PROD); expect(result.showWakingPill).toBe(true); @@ -71,7 +69,7 @@ describe("resolvePreviewDisplay", () => { it("keeps the blocking overlay while booting when there is no production URL", () => { expect( - resolvePreviewDisplay({ + run({ previewState: STARTING, progressStatus: "doing", productionUrl: null, @@ -85,7 +83,7 @@ describe("resolvePreviewDisplay", () => { }); it("keeps the blocking overlay for a warming iframe with no production URL", () => { - const result = resolvePreviewDisplay({ + const result = run({ previewState: IFRAME, progressStatus: "doing", productionUrl: null, @@ -95,13 +93,7 @@ describe("resolvePreviewDisplay", () => { }); it("yields the canvas to the suspended card (no overlay, no pill)", () => { - expect( - resolvePreviewDisplay({ - previewState: SUSPENDED, - progressStatus: "doing", - productionUrl: PROD, - }), - ).toEqual({ + expect(run({ previewState: SUSPENDED, progressStatus: "doing" })).toEqual({ mode: "none", iframeBase: null, showBlockingOverlay: false, @@ -110,13 +102,7 @@ describe("resolvePreviewDisplay", () => { }); it("yields the canvas to the errored card (no overlay, no pill)", () => { - expect( - resolvePreviewDisplay({ - previewState: ERRORED, - progressStatus: "failed", - productionUrl: PROD, - }), - ).toEqual({ + expect(run({ previewState: ERRORED, progressStatus: "failed" })).toEqual({ mode: "none", iframeBase: null, showBlockingOverlay: false, @@ -178,11 +164,7 @@ describe("resolvePreviewDisplay", () => { // A teammate's branch: even with a productionUrl set, don't paint a toolbar // or load the production iframe behind the confirmation card. expect( - resolvePreviewDisplay({ - previewState: OTHERS_THREAD, - progressStatus: "doing", - productionUrl: PROD, - }), + run({ previewState: OTHERS_THREAD, progressStatus: "doing" }), ).toEqual({ mode: "none", iframeBase: null, @@ -190,4 +172,84 @@ describe("resolvePreviewDisplay", () => { showWakingPill: false, }); }); + + describe("Fast Preview", () => { + it("drops the pill once the draft render is ready, still based on production", () => { + // `iframeBase` stays the PUBLISHED url — the caller layers the daemon + // draft URL over it, so the base is always navigable and the URL-bar + // label doesn't jump origins when the draft swaps in. + expect( + run({ + previewState: IFRAME, + progressStatus: "doing", + fastPreviewActive: true, + fastPreviewReady: true, + }), + ).toEqual({ + mode: "production", + iframeBase: PROD, + showBlockingOverlay: false, + showWakingPill: false, + }); + }); + + it("shows the published site + pill while the sandbox provisions", () => { + // Inverted from the original rule (blocking overlay, never the published + // site). Boot should be invisible: the published site holds the canvas + // until the draft render is renderable. + expect( + run({ + previewState: STARTING, + progressStatus: "doing", + fastPreviewActive: true, + fastPreviewReady: false, + }), + ).toEqual({ + mode: "production", + iframeBase: PROD, + showBlockingOverlay: false, + showWakingPill: true, + }); + }); + + it("holds the published site when the daemon is up but no page matched yet", () => { + // previewUrl exists, but the decofile hasn't yielded a page key, so the + // draft URL isn't buildable. Keep the pill rather than blanking. + const result = run({ + previewState: IFRAME, + progressStatus: "doing", + fastPreviewActive: true, + fastPreviewReady: false, + }); + expect(result.mode).toBe("production"); + expect(result.iframeBase).toBe(PROD); + expect(result.showWakingPill).toBe(true); + }); + + it("never swaps to the sandbox — the draft render owns the canvas regardless of dev-server state", () => { + for (const progressStatus of ["done", "failed"] as const) { + const result = run({ + previewState: IFRAME, + progressStatus, + fastPreviewActive: true, + fastPreviewReady: true, + }); + expect(result.mode).toBe("production"); + expect(result.iframeBase).toBe(PROD); + } + }); + + it("does not fall through to the sandbox while waiting for the draft", () => { + // Even with the dev server up, Fast Preview must not hand the canvas to + // the sandbox iframe — that's the surface it deliberately replaces. + const result = run({ + previewState: IFRAME, + progressStatus: "done", + fastPreviewActive: true, + fastPreviewReady: false, + }); + expect(result.mode).toBe("production"); + expect(result.showWakingPill).toBe(true); + }); + }); }); diff --git a/apps/web/src/components/sandbox/preview/preview-display.ts b/apps/web/src/components/sandbox/preview/preview-display.ts index 357f9cf74d..6ca33a08e2 100644 --- a/apps/web/src/components/sandbox/preview/preview-display.ts +++ b/apps/web/src/components/sandbox/preview/preview-display.ts @@ -11,6 +11,13 @@ * as the sandbox handle exists — well before the dev server is routable — so we * gate the sandbox surface on `progressStatus` (boot no longer in progress), * NOT on the mere existence of `previewUrl`. + * + * Both modes share one shape: the published site holds the canvas until + * something better is ready, then that swaps in. Fast Preview does not change + * the surface, only *when* it's ready — its draft render needs the daemon alone + * (up shortly after the clone), where the normal path waits out install + dev + * server. So the blocking overlay is only for projects with no production URL + * at all; Fast Preview requires one, so it never reaches that branch. */ import type { PreviewState } from "./preview-state"; @@ -51,6 +58,21 @@ export interface PreviewDisplayInput { * to watch. */ foreignSandbox?: boolean; + /** + * Fast Preview is on (its switch is enabled AND a production URL is set). + * Fast Preview does not change the *surface* — it changes when the surface is + * ready. Both modes paint the published site while the sandbox boots; Fast + * Preview swaps in the daemon's draft render (ready after the clone) where the + * normal path waits for the dev server (ready at `running`). + */ + fastPreviewActive?: boolean; + /** + * The caller could actually build the draft URL — it has the sandbox handle + * and a draft version. Only meaningful with `fastPreviewActive`. False means + * the draft isn't addressable yet, so the published site keeps the canvas + * (with the waking pill) instead. + */ + fastPreviewReady?: boolean; } const NONE: PreviewDisplay = { @@ -63,7 +85,16 @@ const NONE: PreviewDisplay = { export function resolvePreviewDisplay( input: PreviewDisplayInput, ): PreviewDisplay { - const { previewState, progressStatus, productionUrl, foreignSandbox } = input; + const { + previewState, + progressStatus, + productionUrl, + foreignSandbox, + // Optional like `foreignSandbox`: a caller that knows nothing about Fast + // Preview gets exactly the pre-existing behaviour. + fastPreviewActive = false, + fastPreviewReady = false, + } = input; // Suspended / errored / othersThread all render their own dedicated card // (the last one is a confirmation gate on a teammate's branch) — hand the @@ -77,13 +108,31 @@ export function resolvePreviewDisplay( return NONE; } + // Fast Preview's draft render, the moment it's renderable. It needs only the + // daemon (up shortly after the clone), so this deliberately ignores + // `progressStatus` — waiting for the dev server is the whole cost it exists to + // skip. `iframeBase` stays the PUBLISHED url: the caller layers the draft URL + // over it, so every production-mode base is a page we can actually navigate to + // (and the URL-bar label doesn't jump between origins mid-boot). + if (fastPreviewActive && fastPreviewReady && productionUrl) { + return { + mode: "production", + iframeBase: productionUrl, + showBlockingOverlay: false, + showWakingPill: false, + }; + } + // The sandbox surface is showable once a previewUrl exists AND boot is no // longer in progress: `done` (running) serves the live app, `failed`/`crashed` // serves the daemon's auto-reloading status page — both belong in the iframe, - // not behind a "waking" pill. A foreign sandbox skips the progress gate (its - // progress is unobservable — see `foreignSandbox`); whatever the owner's - // daemon serves is the displayed state. + // not behind a "waking" pill. Two independent bypasses meet here: Fast + // Preview skips this branch entirely (its draft render above owns the canvas + // instead of the dev server), and a foreign sandbox skips the progress gate + // (its progress is unobservable — see `foreignSandbox`), so whatever the + // owner's daemon serves is the displayed state. if ( + !fastPreviewActive && previewState.kind === "iframe" && (foreignSandbox || progressStatus !== "doing") ) { @@ -95,8 +144,11 @@ export function resolvePreviewDisplay( }; } - // Still booting (fresh boot with no previewUrl yet, or a warming iframe). - // Prefer the live production site so the user sees their site immediately. + // Nothing better is ready yet — paint the published site + a "waking" pill. + // Shared by both modes: Fast Preview holds here until the daemon can render + // the draft, the normal path until the dev server is routable. Fast Preview + // guarantees a `productionUrl` (its gate requires one), so it always lands + // here rather than on the blocking overlay below. if (productionUrl) { return { mode: "production", diff --git a/apps/web/src/components/sandbox/preview/preview-label.test.ts b/apps/web/src/components/sandbox/preview/preview-label.test.ts new file mode 100644 index 0000000000..9260748a68 --- /dev/null +++ b/apps/web/src/components/sandbox/preview/preview-label.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test"; +import { buildPreviewLabel } from "./preview-label"; + +const NO_SERVER = "No server running"; + +describe("buildPreviewLabel", () => { + it("shows the production host under Fast Preview / production surface", () => { + // iframeBase is production — the label must follow it, not the sandbox. + expect( + buildPreviewLabel({ + iframeBase: "https://acme.com", + resolvedPath: "/", + activeGlobalSectionName: null, + noServerLabel: NO_SERVER, + }), + ).toBe("acme.com"); + }); + + it("shows the sandbox host when the sandbox surface is live", () => { + expect( + buildPreviewLabel({ + iframeBase: "https://abc.deco.host", + resolvedPath: "/", + activeGlobalSectionName: null, + noServerLabel: NO_SERVER, + }), + ).toBe("abc.deco.host"); + }); + + it("appends a non-root path to the host", () => { + expect( + buildPreviewLabel({ + iframeBase: "https://acme.com", + resolvedPath: "/products/shoes", + activeGlobalSectionName: null, + noServerLabel: NO_SERVER, + }), + ).toBe("acme.com/products/shoes"); + }); + + it("omits the path for the root route", () => { + expect( + buildPreviewLabel({ + iframeBase: "https://acme.com:8443", + resolvedPath: "/", + activeGlobalSectionName: null, + noServerLabel: NO_SERVER, + }), + ).toBe("acme.com:8443"); + }); + + it("prefers the pinned global section name over any host", () => { + expect( + buildPreviewLabel({ + iframeBase: "https://acme.com", + resolvedPath: "/", + activeGlobalSectionName: "Header", + noServerLabel: NO_SERVER, + }), + ).toBe("Header"); + }); + + it("falls back to the no-server label when nothing is loaded", () => { + expect( + buildPreviewLabel({ + iframeBase: null, + resolvedPath: "/", + activeGlobalSectionName: null, + noServerLabel: NO_SERVER, + }), + ).toBe(NO_SERVER); + }); + + it("returns the raw base when it isn't a parseable URL", () => { + expect( + buildPreviewLabel({ + iframeBase: "not a url", + resolvedPath: "/", + activeGlobalSectionName: null, + noServerLabel: NO_SERVER, + }), + ).toBe("not a url"); + }); +}); diff --git a/apps/web/src/components/sandbox/preview/preview-label.ts b/apps/web/src/components/sandbox/preview/preview-label.ts new file mode 100644 index 0000000000..db018081de --- /dev/null +++ b/apps/web/src/components/sandbox/preview/preview-label.ts @@ -0,0 +1,32 @@ +/** + * The URL-bar label for the preview toolbar — the domain (plus path) the user + * sees for the currently rendered page. + * + * It derives the host from `iframeBase` — the URL actually loaded in the iframe + * — NOT from the sandbox `previewUrl`. Under Fast Preview / the waking fallback + * the iframe shows the production origin, so the label must follow it or it + * drifts (showing the sandbox `.localhost` while production is on screen). + */ +export function buildPreviewLabel(input: { + /** The URL loaded in the iframe (`display.iframeBase`): production or sandbox. */ + iframeBase: string | null; + /** Current resolved path (template filled in). `"/"` renders as bare host. */ + resolvedPath: string; + /** Name of the pinned global section, if one is being previewed. */ + activeGlobalSectionName: string | null; + /** Fallback shown when there's no surface loaded (`mode === "none"`). */ + noServerLabel: string; +}): string { + // A pinned global section is previewed on a synthetic page — show its name, + // not a host, exactly as before. + if (input.activeGlobalSectionName) return input.activeGlobalSectionName; + const base = input.iframeBase; + if (!base) return input.noServerLabel; + try { + const url = new URL(base); + const path = input.resolvedPath === "/" ? "" : input.resolvedPath; + return `${url.host}${path}`; + } catch { + return base; + } +} diff --git a/apps/web/src/components/sandbox/preview/preview.tsx b/apps/web/src/components/sandbox/preview/preview.tsx index 770f158666..45700a0be4 100644 --- a/apps/web/src/components/sandbox/preview/preview.tsx +++ b/apps/web/src/components/sandbox/preview/preview.tsx @@ -10,6 +10,7 @@ import { TOUR_ANCHORS } from "@/components/cms-tour/anchors"; import { useInsetContext } from "@/layouts/agent-shell-layout"; import { resolvePreviewDisplay } from "./preview-display"; import { useIframeLoadRecovery } from "./preview-iframe-recovery"; +import { buildPreviewLabel } from "./preview-label"; import { sanitizeProductionUrl } from "@decocms/shared/deco-site-production-url"; import { useIsMobile } from "@deco/ui/hooks/use-mobile.ts"; import { useT } from "@/i18n/use-t.ts"; @@ -81,7 +82,10 @@ import { } from "@/components/sections-editor/page-path-utils"; import { decoBlockFileViewPath } from "@/components/sections-editor/deco-block-key"; import { findLivePageResolveType } from "@/components/sections-editor/section-catalog"; -import { buildGlobalSectionPreviewUrl } from "@/components/sections-editor/section-preview-url"; +import { + buildDraftPreviewUrl, + buildGlobalSectionPreviewUrl, +} from "@/components/sections-editor/section-preview-url"; import { useCreatePage } from "@/components/sections-editor/use-create-page"; import { CreatePageModal } from "@/components/sections-editor/create-page-modal"; import { toast } from "sonner"; @@ -260,6 +264,8 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { const pagesContainerRef = useRef(null); const [createPageDialogOpen, setCreatePageDialogOpen] = useState(false); const [createPageError, setCreatePageError] = useState(); + // Bumped after a Blocks save so the Fast Preview daemon frame re-navigates and + // the daemon re-reads the fresh `.deco/blocks/*` (the "save → refresh" step). const [activeGlobalSection, setActiveGlobalSection] = useState(null); /** Overrides iframe src for global-section live previews (stable URL, not recomputed). */ @@ -303,6 +309,7 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { const vmEntry = lifecycle.vmEntry; const previewUrl = lifecycle.previewUrl; const lifecyclePhase = vmEvents.lifecycle.phase; + const decofileVersion = vmEvents.decofileVersion; const devServerReady = lifecyclePhase === "running"; const isDesktopSandbox = vmEntry?.sandboxProviderKind === "user-desktop"; @@ -457,10 +464,10 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { // Live production URL of the linked site, persisted on the agent's // `metadata.productionUrl` at import time (deco.cx reports the real domain, // which can be a custom one — so we store it rather than guess it). Used as a - // Lovable-style fallback: while the sandbox dev server is still waking, paint - // the published site in the iframe (non-blocking) instead of a blank overlay, - // then swap to the sandbox preview once it's up. `null` (no field, or a site - // imported before this was persisted) → the original blocking overlay is kept. + // published-site fallback while the sandbox provisions (non-blocking) instead + // of a blank overlay; once the daemon is up, Fast Preview swaps to the daemon + // render (`draftPreviewUrl` below). `null` (no field, or a site imported + // before this was persisted) → the original blocking overlay is kept. const inset = useInsetContext(); const productionUrl = inset?.entity?.id === virtualMcpId @@ -475,20 +482,60 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { ? inset.entity.metadata?.ui?.layout?.cmsDefaultOpen : null) ?? false; + // Fast Preview (opt-in switch in CMS settings): render the draft against + // `productionUrl` instead of the published site while the sandbox boots. + // Requires BOTH the switch and a production URL — a bare flag is inert + // (nothing to render against), and `productionUrl` is non-null only for this + // agent's entity, so reading `metadata.fastPreview` off the same object is + // safe. + const fastPreviewEnabled = + !!productionUrl && inset?.entity?.metadata?.fastPreview === true; + + // Fast Preview (gated by the CMS switch): render the site's REAL page on + // `productionUrl`, carrying a `?__draft=@` pointer that the + // site's framework resolves by pulling the merged working-tree decofile from + // the sandbox daemon. Replaces POSTing the decofile at `/live/previews`, + // which only deco's own runtime honoured and could only render one component + // statically. + // + // Needs the sandbox handle and a draft version — NOT the dev server, and + // notably not `currentPageKey` any more: the site does its own routing, so + // the preview no longer waits on the decofile query that used to stall the + // whole surface behind a cold-start 502. + // + // `version` changes on every `.deco/blocks/*` save (the daemon's `decofile` + // event), which re-navigates the frame — no cache-busting nonce needed. + // + // Computed BEFORE `display`: it is an input to that decision, so it must not + // depend on `display.mode` in turn. + const draftPreviewUrl = + fastPreviewEnabled && + productionUrl && + vmEntry?.sandboxHandle && + decofileVersion + ? buildDraftPreviewUrl({ + productionUrl, + sandboxHandle: vmEntry.sandboxHandle, + version: decofileVersion, + path: resolvedPath, + }) + : null; + // The recorded previewUrl flips previewState to "iframe" as soon as the // sandbox handle exists — well before the public preview proxy is routable — // so the sandbox surface is gated on `progress.status` (boot no longer in // progress), not on `previewUrl` alone. `resolvePreviewDisplay` decides what - // to paint: the sandbox iframe, the production fallback + a waking pill, or - // (no production URL) today's blocking booting overlay. + // to paint: the sandbox iframe, the published site + a waking pill, or + // (no production URL) the blocking booting overlay. const display = resolvePreviewDisplay({ previewState, progressStatus: progress.status, productionUrl, foreignSandbox: lifecycle.foreignSandbox, + fastPreviewActive: fastPreviewEnabled, + fastPreviewReady: !!draftPreviewUrl, }); const previewSurfaceActive = display.mode !== "none"; - const showBootingOverlay = display.showBlockingOverlay; const iframeSrc = display.mode === "sandbox" @@ -500,11 +547,13 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { workspace.state.variantOverride ?? [], ) : display.mode === "production" - ? // Production is a different origin: no sandbox-only overrides - // (directPreviewUrl / variant matcher) apply. Load the current path - // best-effort; the published site serves its own committed pages. + ? // The published site is the base for both modes while the sandbox + // provisions; Fast Preview layers its daemon draft render over it the + // moment that's renderable. Same origin either way (the draft carries + // ``), so the swap changes content, not + // surface. withDeviceHint( - new URL(resolvedPath, display.iframeBase!).href, + draftPreviewUrl ?? new URL(resolvedPath, display.iframeBase!).href, previewDeviceSize, ) : null; @@ -616,13 +665,32 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { // lands on the browser's connection-refused page and fires no load/error // event, so nothing reloads it once the server is back. This watchdog retries // (backoff) until a load fires. Sandbox surface only — never thrash the - // best-effort production fallback frame. + // best-effort production fallback frame, nor the Fast Preview daemon frame, + // which has its own cross-origin navigation path below. const iframeRecovery = useIframeLoadRecovery({ iframeRef: previewIframeRef, src: display.mode === "sandbox" ? iframeSrc : null, active: display.mode === "sandbox", }); + // Fast Preview refresh backstop: the daemon frame is cross-origin (the daemon + // host), so the shared reload path can't `.reload()` it. When the draft URL + // changes — a page switch, or a new version after a save — force-navigate the + // frame. The version makes the URL distinct, and the ref guard makes the + // first paint (React already set `src`) and unrelated re-renders no-ops. + const lastDraftUrlRef = useRef(null); + // oxlint-disable-next-line ban-use-effect/ban-use-effect -- imperative cross-origin iframe navigation on draft change; .reload() throws cross-origin + useEffect(() => { + if (!draftPreviewUrl || !iframeSrc) return; + if (lastDraftUrlRef.current === draftPreviewUrl) return; + lastDraftUrlRef.current = draftPreviewUrl; + const iframe = previewIframeRef.current; + if (iframe && iframe.getAttribute("src") !== iframeSrc) { + beginNavigation(); + iframe.src = iframeSrc; + } + }, [draftPreviewUrl, iframeSrc]); + // Daemon is reachable independent of the dev script: ready claim, handle // still present, not user-stopped. Gates surfaces (FileExplorer, // terminal) that talk to the daemon's HTTP API and don't need a dev server. @@ -815,8 +883,13 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { // Fires on the daemon "reload" event and on debounced `.deco/` file changes // (Blocks saves, external/agent decofile writes) — the only refresh path for - // decofile edits, which the framework's HMR doesn't cover. + // decofile edits, which the framework's HMR doesn't cover. In Fast Preview + // the refresh is already driven by the draft VERSION: the same write emits a + // `decofile` event, the new version changes `draftPreviewUrl`, and the effect + // above re-navigates the frame. Reloading here as well would race that with a + // stale URL, so this path yields; the sandbox path uses the normal reload. useSandboxReloadHandler(() => { + if (draftPreviewUrl) return; reloadPreviewPreservingScroll(); }); // Show the loading overlay the instant a `.deco/` change is detected, ahead of @@ -850,17 +923,15 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { } }; - const previewLabel = (() => { - if (!previewUrl) return t("sandbox.preview.noServerRunning"); - if (activeGlobalSection) return activeGlobalSection.name; - try { - const url = new URL(previewUrl); - const path = resolvedPath === "/" ? "" : resolvedPath; - return `${url.host}${path}`; - } catch { - return previewUrl; - } - })(); + // Domain shown in the URL bar follows what's actually in the iframe + // (`display.iframeBase`) — production under Fast Preview, the sandbox + // otherwise — so the label never drifts from the rendered page. + const previewLabel = buildPreviewLabel({ + iframeBase: display.iframeBase, + resolvedPath, + activeGlobalSectionName: activeGlobalSection?.name ?? null, + noServerLabel: t("sandbox.preview.noServerRunning"), + }); const navigatePreviewToPage = (page: PageEntry) => { // The iframe loads the template with any stored param values filled in. @@ -1256,22 +1327,28 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { )} - - - { - const url = iframeSrc ?? display.iframeBase; - if (url) window.open(url, "_blank", "noopener"); - }} - > - - - - - {t("sandbox.preview.openInNewTab")} - - + {/* Open-in-new-tab only makes sense for a real sandbox iframe — the + production / Fast Preview surface is a cross-origin published site or + an ephemeral `/live/previews` draft URL, neither of which is a stable + link to hand out. */} + {display.mode === "sandbox" && ( + + + { + const url = iframeSrc ?? display.iframeBase; + if (url) window.open(url, "_blank", "noopener"); + }} + > + + + + + {t("sandbox.preview.openInNewTab")} + + + )} ) : null; @@ -1567,7 +1644,7 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) { )} - {showBootingOverlay && ( + {display.showBlockingOverlay && (
{ + control: Control; + /** Current `metadata.productionUrl` value (watched by the parent). */ + productionUrl: string | null | undefined; +} + +export function FastPreviewField({ + control, + productionUrl, +}: FastPreviewFieldProps) { + const t = useT(); + const hasProductionUrl = !!sanitizeProductionUrl(productionUrl); + return ( + } + render={({ field }) => ( +
+
+ +

+ {hasProductionUrl + ? t("sandbox.cmsSettings.fastPreview.description") + : t("sandbox.cmsSettings.fastPreview.needsProductionUrl")} +

+
+ field.onChange(checked)} + /> +
+ )} + /> + ); +} diff --git a/apps/web/src/components/sections-editor/section-preview-url.test.ts b/apps/web/src/components/sections-editor/section-preview-url.test.ts new file mode 100644 index 0000000000..4b7fe52f36 --- /dev/null +++ b/apps/web/src/components/sections-editor/section-preview-url.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { buildDraftPreviewUrl } from "./section-preview-url"; + +const PROD = "https://www.acme.com"; + +describe("buildDraftPreviewUrl", () => { + it("targets the real page on the production origin", () => { + // Not the daemon and not /live/previews: the site renders its OWN route, so + // hydration and in-preview navigation work. + const url = new URL( + buildDraftPreviewUrl({ + productionUrl: PROD, + sandboxHandle: "gimenes-abc-1234", + version: "ff00", + path: "/blog/hello", + }), + ); + expect(url.origin).toBe(PROD); + expect(url.pathname).toBe("/blog/hello"); + }); + + it("carries the pointer as @, never a URL", () => { + // A URL here would make the site fetch caller-supplied origins — the SSRF + // surface the suffix-based design exists to avoid. + const url = new URL( + buildDraftPreviewUrl({ + productionUrl: PROD, + sandboxHandle: "gimenes-abc-1234", + version: "ff00", + path: "/", + }), + ); + expect(url.searchParams.get("__draft")).toBe("gimenes-abc-1234@ff00"); + }); + + it("changes with the version, so a save re-navigates the frame", () => { + const at = (version: string) => + buildDraftPreviewUrl({ + productionUrl: PROD, + sandboxHandle: "h", + version, + path: "/", + }); + expect(at("v1")).not.toBe(at("v2")); + }); + + it("preserves a production origin that carries a trailing slash", () => { + const url = new URL( + buildDraftPreviewUrl({ + productionUrl: "https://fila.vtex.app/", + sandboxHandle: "h", + version: "v1", + path: "/institucional/historia", + }), + ); + expect(url.origin).toBe("https://fila.vtex.app"); + expect(url.pathname).toBe("/institucional/historia"); + }); + + it("keeps path params already filled in", () => { + const url = new URL( + buildDraftPreviewUrl({ + productionUrl: PROD, + sandboxHandle: "h", + version: "v1", + path: "/produto/tenis-123/p", + }), + ); + expect(url.pathname).toBe("/produto/tenis-123/p"); + }); +}); diff --git a/apps/web/src/components/sections-editor/section-preview-url.ts b/apps/web/src/components/sections-editor/section-preview-url.ts index 3b6f11dc72..b789e0d4e0 100644 --- a/apps/web/src/components/sections-editor/section-preview-url.ts +++ b/apps/web/src/components/sections-editor/section-preview-url.ts @@ -29,6 +29,41 @@ export function buildGlobalSectionPreviewUrl( return url.toString(); } +/** + * Fast Preview URL — the site's own page, rendered against the working-tree + * draft. + * + * Points at the REAL page on `productionUrl` and carries a `?__draft=` pointer. + * The site's framework resolves that pointer by fetching the merged decofile + * from the sandbox daemon (`./_sandbox/decofile`) and rendering + * its own routes against it. + * + * This replaces pushing the decofile into a POST body: only deco's own runtime + * honours a POST render, while Next.js and most frameworks render on GET. Going + * through the site's normal route also means hydration and in-preview + * navigation work, instead of a single statically-rendered component. + * + * The pointer is `@`, never a URL — the site builds the origin + * from a configured suffix, so there is no SSRF surface. `version` is the + * daemon's content ETag, so the site caches per version and re-fetches only + * when the draft actually changes; a new version after a save is what refreshes + * the frame, which is why no cache-busting nonce is needed here. + */ +export function buildDraftPreviewUrl(input: { + /** Published site origin — the draft renders against this deployment. */ + productionUrl: string; + /** Sandbox handle; the site resolves it under its configured origin suffix. */ + sandboxHandle: string; + /** Draft content version (the daemon's ETag), from the `decofile` event. */ + version: string; + /** Path to render, with any `:param` values already filled in. */ + path: string; +}): string { + const url = new URL(input.path, input.productionUrl); + url.searchParams.set("__draft", `${input.sandboxHandle}@${input.version}`); + return url.toString(); +} + export function buildSectionPreviewUrl( previewBaseUrl: string, livePageResolveType: string, diff --git a/apps/web/src/components/sections-editor/use-decofile.ts b/apps/web/src/components/sections-editor/use-decofile.ts index 5582be8e12..c6e1726d08 100644 --- a/apps/web/src/components/sections-editor/use-decofile.ts +++ b/apps/web/src/components/sections-editor/use-decofile.ts @@ -74,9 +74,12 @@ export function useDecofile( }, enabled: !!params, staleTime: 30_000, - // 502 = preview unreachable / nothing available yet. The sandbox lifecycle - // re-invalidates this query when the dev server comes up (see - // sandbox-events-context), so retrying just hammers a known-down endpoint. + // 502 = preview unreachable / nothing available yet. Retrying just hammers + // a known-down endpoint; sandbox-events-context re-invalidates this query + // on first daemon contact (and again when the dev server reports + // `running`), so the recovery is event-driven. The first-contact trigger is + // load-bearing for Fast Preview: it renders off the daemon alone, so + // waiting for `running` would strand it behind the install it skips. retry: (failureCount, error) => (error as { status?: number }).status !== 502 && failureCount < 2, retryDelay: (attempt) => diff --git a/apps/web/src/i18n/en/sandbox.ts b/apps/web/src/i18n/en/sandbox.ts index 455a12233a..3f2481a42c 100644 --- a/apps/web/src/i18n/en/sandbox.ts +++ b/apps/web/src/i18n/en/sandbox.ts @@ -415,7 +415,7 @@ export const sandbox = { "sandbox.preview.searchPagesAndComponents": "Search pages and components...", "sandbox.preview.startingPreview": "Starting your preview", "sandbox.preview.startingPreviewHint": - "You can make changes now — they'll appear once the preview is ready.", + "Showing your published site. You can make changes now — they'll appear once the preview is ready.", "sandbox.preview.templateNoLongerExists": "Selected template no longer exists.", "sandbox.preview.urlCopiedToClipboard": "URL copied to clipboard", @@ -506,9 +506,18 @@ export const sandbox = { "sandbox.redirectEditor.typePermanent": "Permanent ({status})", "sandbox.redirectEditor.typePlaceholder": "Type", "sandbox.redirectEditor.typeTemporary": "Temporary ({status})", + "sandbox.cmsSettings.title": "CMS", + "sandbox.cmsSettings.preview.title": "Preview", + "sandbox.cmsSettings.preview.description": + "See your changes before they go live.", + "sandbox.cmsSettings.fastPreview.label": "Fast Preview", + "sandbox.cmsSettings.fastPreview.description": + "Preview changes on your preview server instead of the sandbox.", + "sandbox.cmsSettings.fastPreview.needsProductionUrl": + "Set a preview server above to enable Fast Preview.", "sandbox.productionUrlField.description": - "Shown in the preview while the dev server is starting, so you can view and edit right away.", - "sandbox.productionUrlField.label": "Production URL", + "Your live server's address, used to preview content. Required for Fast Preview.", + "sandbox.productionUrlField.label": "Preview server", "sandbox.productionUrlField.placeholder": "https://example.com", "sandbox.repoRow.label": "Repository", "sandbox.repoRow.noRepositoryConnected": "No repository connected", diff --git a/apps/web/src/i18n/pt-br/sandbox.ts b/apps/web/src/i18n/pt-br/sandbox.ts index 9b1d9edd38..e51c357cfe 100644 --- a/apps/web/src/i18n/pt-br/sandbox.ts +++ b/apps/web/src/i18n/pt-br/sandbox.ts @@ -434,7 +434,7 @@ export const sandbox = { "Procurar páginas e componentes...", "sandbox.preview.startingPreview": "Iniciando seu preview", "sandbox.preview.startingPreviewHint": - "Você já pode fazer alterações — elas aparecem quando o preview estiver pronto.", + "Mostrando seu site publicado. Você já pode fazer alterações — elas aparecem quando o preview estiver pronto.", "sandbox.preview.templateNoLongerExists": "O modelo selecionado não existe mais.", "sandbox.preview.urlCopiedToClipboard": @@ -528,9 +528,18 @@ export const sandbox = { "sandbox.redirectEditor.typePermanent": "Permanente ({status})", "sandbox.redirectEditor.typePlaceholder": "Tipo", "sandbox.redirectEditor.typeTemporary": "Temporário ({status})", + "sandbox.cmsSettings.title": "CMS", + "sandbox.cmsSettings.preview.title": "Preview", + "sandbox.cmsSettings.preview.description": + "Veja suas alterações antes de publicá-las.", + "sandbox.cmsSettings.fastPreview.label": "Preview Rápido", + "sandbox.cmsSettings.fastPreview.description": + "Pré-visualize alterações no seu servidor de preview em vez do sandbox.", + "sandbox.cmsSettings.fastPreview.needsProductionUrl": + "Defina um servidor de preview acima para ativar o Preview Rápido.", "sandbox.productionUrlField.description": - "Exibida no preview enquanto o servidor de desenvolvimento inicia, para você já visualizar e editar.", - "sandbox.productionUrlField.label": "URL de produção", + "O endereço do seu servidor ativo, usado para pré-visualizar conteúdo. Necessário para o Preview Rápido.", + "sandbox.productionUrlField.label": "Servidor de preview", "sandbox.productionUrlField.placeholder": "https://exemplo.com", "sandbox.repoRow.label": "Repositório", "sandbox.repoRow.noRepositoryConnected": "Nenhum repositório conectado", diff --git a/apps/web/src/views/virtual-mcp/index.tsx b/apps/web/src/views/virtual-mcp/index.tsx index 47ebce8674..41f1603f48 100644 --- a/apps/web/src/views/virtual-mcp/index.tsx +++ b/apps/web/src/views/virtual-mcp/index.tsx @@ -32,10 +32,6 @@ import { } from "@deco/ui/components/dialog.tsx"; import { Button } from "@deco/ui/components/button.tsx"; import { Card, CardContent } from "@deco/ui/components/card.tsx"; -import { - RadioGroup, - RadioGroupItem, -} from "@deco/ui/components/radio-group.tsx"; import { Textarea } from "@deco/ui/components/textarea.tsx"; import { Tooltip, @@ -80,6 +76,8 @@ import { SubmoduleCredentialsField } from "@/components/sandbox/runtime-card/sub import { RepoRow } from "@/components/sandbox/runtime-card/repo-row"; import { RuntimeFields } from "@/components/sandbox/runtime-card/runtime-fields"; import { ProductionUrlField } from "@/components/sandbox/runtime-card/production-url-field"; +import { FastPreviewField } from "@/components/sandbox/runtime-card/fast-preview-field"; +import { PublishPolicyField } from "./publish-policy-field"; type DialogState = { shareDialogOpen: boolean; @@ -1052,89 +1050,52 @@ function VirtualMcpDetailViewWithData({ {/* Development agent section (link a dev counterpart) */} - {/* Publishing section (code agents only) */} - {hasGithubRepo && ( -
-
-

- {t("virtualMcp.virtualMcp.publishing")} -

-

- {t("virtualMcp.virtualMcp.publishingDescription")} -

-
- - - +
+

+ {t("sandbox.cmsSettings.title")} +

+
+ + + {/* Preview — preview URL + the Fast Preview switch it gates + (a URL is required for Fast Preview to take effect). */} +
+
+

+ {t("sandbox.cmsSettings.preview.title")} +

+

+ {t("sandbox.cmsSettings.preview.description")} +

+
+ + ( - { - field.onChange(value); - flushAndSave(); - }} - className="gap-4" - > - {( - [ - { - value: "smart", - label: t( - "virtualMcp.virtualMcp.publishPolicySmart", - ), - description: t( - "virtualMcp.virtualMcp.publishPolicySmartDescription", - ), - }, - { - value: "code-review", - label: t( - "virtualMcp.virtualMcp.publishPolicyCodeReview", - ), - description: t( - "virtualMcp.virtualMcp.publishPolicyCodeReviewDescription", - ), - }, - { - value: "open", - label: t( - "virtualMcp.virtualMcp.publishPolicyOpen", - ), - description: t( - "virtualMcp.virtualMcp.publishPolicyOpenDescription", - ), - }, - ] as const - ).map((option) => ( - - ))} - - )} + productionUrl={form.watch("metadata.productionUrl")} /> - - -
- )} +
+ {hasGithubRepo && ( +
+
+

+ {t("virtualMcp.virtualMcp.publishing")} +

+

+ {t("virtualMcp.virtualMcp.publishingDescription")} +

+
+ +
+ )} + + +
{/* Sandbox section */}
@@ -1146,7 +1107,6 @@ function VirtualMcpDetailViewWithData({ - { + control: Control; + /** Settings auto-save on change — persist immediately (blur-equivalent). */ + onCommit: () => void; +} + +export function PublishPolicyField({ + control, + onCommit, +}: PublishPolicyFieldProps) { + const t = useT(); + const options = [ + { + value: "smart", + label: t("virtualMcp.virtualMcp.publishPolicySmart"), + description: t("virtualMcp.virtualMcp.publishPolicySmartDescription"), + }, + { + value: "code-review", + label: t("virtualMcp.virtualMcp.publishPolicyCodeReview"), + description: t( + "virtualMcp.virtualMcp.publishPolicyCodeReviewDescription", + ), + }, + { + value: "open", + label: t("virtualMcp.virtualMcp.publishPolicyOpen"), + description: t("virtualMcp.virtualMcp.publishPolicyOpenDescription"), + }, + ] as const; + return ( + } + control={control} + render={({ field }) => ( + { + field.onChange(value); + onCommit(); + }} + className="gap-4" + > + {options.map((option) => ( + + ))} + + )} + /> + ); +} diff --git a/packages/sandbox/daemon/daemon.decofile.e2e.test.ts b/packages/sandbox/daemon/daemon.decofile.e2e.test.ts new file mode 100644 index 0000000000..8bdbc2e99c --- /dev/null +++ b/packages/sandbox/daemon/daemon.decofile.e2e.test.ts @@ -0,0 +1,163 @@ +/** + * Black-box e2e for GET /_sandbox/decofile. + * + * The route a production site pulls its draft from (pull-based Fast Preview). + * Same contract as the rest of the daemon suite: spawn the built daemon, talk + * to it over HTTP, assert only on responses and the workspace filesystem. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + type Daemon, + HOOK_TIMEOUT_MS, + jsonAuthHeaders, + readSseUntil, + startDaemon, + stopDaemon, + url, +} from "./daemon.e2e.helpers"; + +let d: Daemon | null = null; + +/** `/repo/.deco/blocks` — the sources the decofile is merged from. */ +function blocksDir(daemon: Daemon): string { + return join(daemon.appDir, "repo", ".deco", "blocks"); +} + +async function writeBlock( + daemon: Daemon, + name: string, + body: unknown, +): Promise { + await mkdir(blocksDir(daemon), { recursive: true }); + await writeFile( + join(blocksDir(daemon), `${name}.json`), + JSON.stringify(body), + "utf-8", + ); +} + +beforeEach(async () => { + d = await startDaemon(); +}, HOOK_TIMEOUT_MS); + +afterEach(async () => { + await stopDaemon(d); + d = null; +}, HOOK_TIMEOUT_MS); + +describe("GET /_sandbox/decofile", () => { + test("404s when there are no blocks to merge", async () => { + const res = await fetch(url(d!, "/_sandbox/decofile")); + expect(res.status).toBe(404); + }); + + test("serves the merged blocks with a content ETag", async () => { + await writeBlock(d!, "pages-home", { path: "/", sections: [] }); + await writeBlock(d!, "Header", { + __resolveType: "site/sections/Header.tsx", + }); + + const res = await fetch(url(d!, "/_sandbox/decofile")); + expect(res.status).toBe(200); + expect(res.headers.get("etag")).toMatch(/^W\/"[0-9a-f]+"$/); + // Never let a shared cache hold an unpublished draft. + expect(res.headers.get("cache-control")).toBe("no-store"); + + const body = (await res.json()) as Record; + // Filename stem is the block key — the shape the deco runtime emits. + expect(Object.keys(body).sort()).toEqual(["Header", "pages-home"]); + expect(body["pages-home"]).toEqual({ path: "/", sections: [] }); + }); + + test("is unauthenticated — the fetcher is a site, not the cluster", async () => { + // Deliberately no Authorization header: the production server pulling this + // has no daemon token. Matched ahead of the bearer gate in entry.ts. + await writeBlock(d!, "pages-home", { path: "/", sections: [] }); + const res = await fetch(url(d!, "/_sandbox/decofile")); + expect(res.status).toBe(200); + }); + + test("ETag is stable across reads and changes with content", async () => { + await writeBlock(d!, "pages-home", { path: "/", sections: [] }); + + const first = await fetch(url(d!, "/_sandbox/decofile")); + const etag1 = first.headers.get("etag"); + await first.text(); + + const again = await fetch(url(d!, "/_sandbox/decofile")); + const etag2 = again.headers.get("etag"); + await again.text(); + expect(etag2).toBe(etag1); + + await writeBlock(d!, "pages-home", { path: "/", sections: [{ x: 1 }] }); + const changed = await fetch(url(d!, "/_sandbox/decofile")); + await changed.text(); + expect(changed.headers.get("etag")).not.toBe(etag1); + }); + + test("revalidates: 304 on a matching ETag, full body on a stale one", async () => { + await writeBlock(d!, "pages-home", { path: "/", sections: [] }); + + const first = await fetch(url(d!, "/_sandbox/decofile")); + const etag = first.headers.get("etag") ?? ""; + await first.text(); + + const fresh = await fetch(url(d!, "/_sandbox/decofile"), { + headers: { "if-none-match": etag }, + }); + expect(fresh.status).toBe(304); + expect((await fresh.text()).length).toBe(0); + + const stale = await fetch(url(d!, "/_sandbox/decofile"), { + headers: { "if-none-match": 'W/"deadbeef"' }, + }); + expect(stale.status).toBe(200); + expect((await stale.text()).length).toBeGreaterThan(0); + }); + + test("announces a new version on the events stream after a block write", async () => { + // The signal Studio rebuilds its draft pointer from. Without it a save + // would not refresh the preview — Studio would have to poll a multi-MB + // payload just to read its hash. + await writeBlock(d!, "pages-home", { path: "/", sections: [] }); + + // Land the write while the stream is open, so the event is observed live + // rather than replayed from connect. + const pending = readSseUntil(url(d!, "/_sandbox/events"), { + predicate: (acc) => acc.includes("event: decofile"), + deadlineMs: 15_000, + }); + await new Promise((r) => setTimeout(r, 500)); + + // Write through the daemon's own route rather than straight to disk: that + // is how the CMS actually saves a block, and it signals `onWorkingTreeWrite` + // directly. A bare fs write would rely on BranchStatusMonitor's watcher, + // which only starts once the repo is a real git checkout. + const saved = await fetch(url(d!, "/_sandbox/write"), { + method: "POST", + headers: jsonAuthHeaders(), + body: JSON.stringify({ + path: ".deco/blocks/pages-home.json", + content: JSON.stringify({ path: "/", sections: [{ n: 1 }] }), + }), + }); + expect(saved.status).toBe(200); + + const { text } = await pending; + const line = text + .split("\n") + .find((l) => l.startsWith("data:") && l.includes("version")); + expect(line).toBeDefined(); + const { version } = JSON.parse(line!.slice("data:".length).trim()) as { + version: string; + }; + + // Must be the SAME value the route serves as its ETag — one definition, or + // Studio's pointer and the framework's cache key drift apart. + const res = await fetch(url(d!, "/_sandbox/decofile")); + await res.text(); + expect(res.headers.get("etag")).toBe(`W/"${version}"`); + }, 30_000); +}); diff --git a/packages/sandbox/daemon/entry.ts b/packages/sandbox/daemon/entry.ts index 9e673b15fb..1055b20013 100644 --- a/packages/sandbox/daemon/entry.ts +++ b/packages/sandbox/daemon/entry.ts @@ -25,6 +25,7 @@ import { TaskManager } from "./process/task-manager"; import { PhaseManager } from "./process/phase-manager"; import { startUpstreamProbe } from "./probe"; import { makeProxyHandler } from "./proxy"; +import { makeDecofileHandler, readDecofile } from "./routes/decofile"; import { jsonResponse } from "./routes/body-parser"; import { makeBashHandler } from "./routes/bash"; import { @@ -333,6 +334,37 @@ let pendingPaths = new Set(); const FILE_CHANGED_DEBOUNCE_MS = 300; +/** `.deco/blocks/*.json` — the sources the draft decofile is merged from. */ +function isDecoBlockPath(filePath: string): boolean { + return ( + filePath.includes(".deco/blocks/") && + filePath.toLowerCase().endsWith(".json") + ); +} + +/** Version announced by the last `decofile` event; suppresses no-op re-emits. */ +let lastDecofileVersion: string | null = null; + +/** + * Re-merge the draft and announce its version, if it actually changed. + * + * Only called for `.deco/blocks/*.json` writes, and only after the debounce, so + * a burst of block saves costs one merge. The merge is async and deduped + * (`readDecofile`), which matters: this payload is routinely multi-MB and the + * daemon runs a single-threaded event loop. + */ +async function announceDecofileVersion(): Promise { + try { + const merged = await readDecofile({ repoDir, store }); + if (!merged || merged.version === lastDecofileVersion) return; + lastDecofileVersion = merged.version; + broadcaster.emit("decofile", { version: merged.version }); + } catch (err) { + // Never let a draft-version probe take down the watcher. + console.warn("[daemon] decofile version probe failed:", err); + } +} + function emitFileChanged(filePath: string) { pendingPaths.add(filePath); if (fileChangedDebounce) clearTimeout(fileChangedDebounce); @@ -340,9 +372,12 @@ function emitFileChanged(filePath: string) { fileChangedDebounce = null; const paths = pendingPaths; pendingPaths = new Set(); + let touchedBlocks = false; for (const p of paths) { broadcaster.emit("file-changed", { path: p }); + touchedBlocks ||= isDecoBlockPath(p); } + if (touchedBlocks) void announceDecofileVersion(); }, FILE_CHANGED_DEBOUNCE_MS); } @@ -450,6 +485,7 @@ const eventsH = makeEventsHandler({ const idleH = makeIdleHandler(); const proxyH = makeProxyHandler({ broadcaster, getDevPort }); +const decofileH = makeDecofileHandler({ repoDir, store }); // ─── Harness dispatch ────────────────────────────────────────────────── // Authenticated by the bearer `bootConfig.daemonToken` (see `./auth`). The @@ -673,6 +709,11 @@ async function vmRouteH( if (method === "GET" && vmPath === "/idle") return idleH(); if (method === "GET" && vmPath === "/events") return eventsH(req); if (method === "GET" && vmPath === "/scripts") return scriptsHandler(); + // Draft decofile for a production site to pull (pull-based Fast Preview). + // Browser/server-reachable like `/events` above: the fetcher is an arbitrary + // production server, not the cluster, so it carries no daemon token — + // matched ahead of the bearer gate for the same reason. + if (method === "GET" && vmPath === "/decofile") return decofileH(req); if (method === "OPTIONS") return new Response(null, { status: 204, headers: CORS_HEADERS }); diff --git a/packages/sandbox/daemon/events/types.ts b/packages/sandbox/daemon/events/types.ts index 5aa4fa8e07..c2fd030a98 100644 --- a/packages/sandbox/daemon/events/types.ts +++ b/packages/sandbox/daemon/events/types.ts @@ -66,6 +66,15 @@ export interface DaemonEventMap { branch: { meta: BranchMeta }; reload: Record; "file-changed": { path: string }; + /** + * The working-tree draft decofile changed, and its new content version. + * + * Same value the `/_sandbox/decofile` route serves as its `ETag` (see + * `readDecofile`) — one definition, so a consumer's pointer and the + * framework's cache key cannot disagree. Lets Studio rebuild a draft pointer + * on save instead of polling for it. + */ + decofile: { version: string }; } export type DaemonEventName = keyof DaemonEventMap; diff --git a/packages/sandbox/daemon/routes/decofile.ts b/packages/sandbox/daemon/routes/decofile.ts new file mode 100644 index 0000000000..870fef9a91 --- /dev/null +++ b/packages/sandbox/daemon/routes/decofile.ts @@ -0,0 +1,91 @@ +import { join } from "node:path"; +import type { TenantConfigStore } from "../config-store"; +import { generateDecofileFromBlocksDeduped } from "./fs"; + +interface DecofileDeps { + repoDir: string; + store: TenantConfigStore; +} + +/** + * `GET /_sandbox/decofile` + * + * The working-tree DRAFT decofile — every `.deco/blocks/*.json` merged — served + * for a production site to pull and render against (see the pull-based Fast + * Preview design). Replaces pushing the same payload into a POST body, which + * only deco's own runtime honours; Next.js and friends render on GET only. + * + * Unauthenticated, like its neighbours `/_sandbox/{events,scripts,idle}`: the + * fetcher is an arbitrary production server, not the cluster, so it cannot + * carry the daemon bearer token. `entry.ts` matches it ahead of the token gate. + * Readable by anyone holding the sandbox handle — the same capability boundary + * `/_sandbox/events` already relies on. + * + * Content-addressed: the response carries an `ETag` over the merged bytes, so + * callers cache by version and re-fetch only when the draft actually changes. + * `?v=` is accepted and ignored — it exists so the caller can cache-bust by URL. + * + * Returns the merged object as raw text (no `JSON.parse` round-trip) to respect + * the daemon's single-threaded budget: this payload is routinely >10MB. + */ +/** + * Merge the working-tree blocks and derive their version. + * + * The single definition of "what version is the draft" — the route serves it as + * an `ETag` and the `decofile` SSE event announces it, so Studio's pointer and + * the framework's cache key can never disagree. Returns null when there is no + * `.deco/blocks` directory to merge. + * + * `generateDecofileFromBlocksDeduped` collapses concurrent callers onto one + * merge, so a save that both emits an event and serves a fetch pays for one. + */ +export async function readDecofile( + deps: DecofileDeps, +): Promise<{ text: string; version: string } | null> { + const app = deps.store.read()?.application; + // `.deco/blocks` sits under the package path (the dev-script cwd) when the + // project isn't at the repo root; daemon reads resolve against repoDir. + const packagePath = app?.packageManager?.path ?? ""; + const blocksDir = join(deps.repoDir, packagePath, ".deco", "blocks"); + + const text = await generateDecofileFromBlocksDeduped(blocksDir); + if (text === null) return null; + + // Wyhash over the merged bytes — native speed, so even a multi-MB payload + // stays well inside the health probe's budget. Not a security boundary, + // only a change detector. + return { text, version: Bun.hash(text).toString(16) }; +} + +export function makeDecofileHandler(deps: DecofileDeps) { + return async (req: Request): Promise => { + const merged = await readDecofile(deps); + if (merged === null) { + return new Response( + JSON.stringify({ error: "No .deco/blocks to serve." }), + { + status: 404, + headers: { "content-type": "application/json; charset=utf-8" }, + }, + ); + } + + const etag = `W/"${merged.version}"`; + if (req.headers.get("if-none-match") === etag) { + return new Response(null, { status: 304, headers: { etag } }); + } + + return new Response(merged.text, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + etag, + // The draft changes on every save; never let a shared cache hold it. + // Callers key their own cache on the ETag instead. + "cache-control": "no-store", + // Server-to-server fetch, but the browser may probe it during dev. + "access-control-allow-origin": "*", + }, + }); + }; +} diff --git a/packages/sandbox/server/provider/shared/build-config-payload.ts b/packages/sandbox/server/provider/shared/build-config-payload.ts index face11c351..2a6944669e 100644 --- a/packages/sandbox/server/provider/shared/build-config-payload.ts +++ b/packages/sandbox/server/provider/shared/build-config-payload.ts @@ -13,6 +13,7 @@ export function buildConfigPayload(args: { port?: number; repo: NonNullable | null; tenant?: EnsureOptions["tenant"]; + /** Live production URL for the Fast Preview daemon route (see Application). */ }): Partial | null { const repo = args.repo; const git = repo diff --git a/packages/shared/src/sdk/types/virtual-mcp.ts b/packages/shared/src/sdk/types/virtual-mcp.ts index 23c2baf34c..fbaab16d8b 100644 --- a/packages/shared/src/sdk/types/virtual-mcp.ts +++ b/packages/shared/src/sdk/types/virtual-mcp.ts @@ -605,6 +605,29 @@ const publishPolicyMetadataField = PublishPolicySchema.nullable() "Controls when this code agent's changes may be published directly vs. requiring PR review. 'smart' (default) uses AI to judge per change; 'code-review' requires review for any code; 'open' allows direct publish always.", ); +/** + * Reusable `metadata.fastPreview` field. When true — and `productionUrl` is set — + * the CMS preview renders the current working-tree draft against the production + * deployment's `/live/previews` route (pushing the draft decofile as a + * per-request override) instead of waiting for the sandbox dev server to boot. + * The gate requires BOTH the flag and a production URL, so a bare flag with no + * URL is inert. + * + * It changes *when* the preview is ready, not which surface is shown: both modes + * paint the published site while the sandbox provisions, but Fast Preview swaps + * in the draft render as soon as the daemon can serve it (shortly after the + * clone) rather than at dev-server `running`. The draft render is static — no + * hydration or navigation — and it keeps the canvas for as long as Fast Preview + * is on; the sandbox dev-server surface is not swapped in behind it. + */ +const fastPreviewMetadataField = z + .boolean() + .nullable() + .optional() + .describe( + "Enable Fast Preview: render the draft instantly against productionUrl's /live/previews route while the sandbox boots. Requires productionUrl to be set to take effect.", + ); + /** * Virtual MCP entity schema - single source of truth * Compliant with collections binding pattern @@ -682,6 +705,7 @@ export const VirtualMCPEntitySchema = z.object({ .describe( "Live production URL of the linked site (e.g. https://acme.com). Painted in the preview iframe while the sandbox dev server is waking.", ), + fastPreview: fastPreviewMetadataField, }) .loose() .describe("Metadata"), @@ -788,6 +812,7 @@ export const VirtualMCPCreateDataSchema = z.object({ .describe( "Live production URL of the linked site (e.g. https://acme.com). Painted in the preview iframe while the sandbox dev server is waking.", ), + fastPreview: fastPreviewMetadataField, }) .loose() .nullable() @@ -875,6 +900,7 @@ export const VirtualMCPUpdateDataSchema = z.object({ .describe( "Live production URL of the linked site (e.g. https://acme.com). Painted in the preview iframe while the sandbox dev server is waking.", ), + fastPreview: fastPreviewMetadataField, }) .loose() .nullable()