Skip to content

feat(cms): Fast Preview — render the draft via the sandbox daemon - #5232

Closed
tlgimenes wants to merge 7 commits into
mainfrom
feat/cms-fast-preview-daemon
Closed

feat(cms): Fast Preview — render the draft via the sandbox daemon#5232
tlgimenes wants to merge 7 commits into
mainfrom
feat/cms-fast-preview-daemon

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Fast Preview renders the current working-tree draft against the linked site's production /live/previews without waiting for or using the dev server — so content editors see their unpublished edits fast.

Settings

  • New CMS section in agent settings: Preview (production/preview URL + Fast Preview switch, gated on the URL) and Publishing (moved in; extracted PublishPolicyField). Copy in en + pt-br. metadata.fastPreview added to the schema.

Rendering (daemon proxy — no URL cap, no production change)

  • New public daemon route GET /_deco/fast-preview (packages/sandbox/daemon): merges .deco/blocks/* (dep-free, no dev server), POSTs it server-side to ${productionUrl}/live/previews/<component> (which already accepts a JSON __decofile body), and returns the HTML with an injected <base href> so assets resolve against production.
  • productionUrl threaded to the daemon config: Workload → SANDBOX_START → buildConfigPayload / desktop link-daemon → daemon Application.productionUrl.
  • Web: the frame loads the daemon route (previewUrl origin) via buildFastPreviewDaemonUrl. Fast Preview never renders the published site — daemon draft or a booting overlay while the sandbox provisions.

Save → refresh

On a .deco/* write the daemon emits file-changed; the events gate is relaxed for Fast Preview and the handler bumps a nonce (new daemon URL → the frame re-navigates → the daemon re-reads the saved blocks). No decofile refetch (daemon reads disk; a refetch could revert an optimistic edit).

Also: URL bar follows the iframe domain (buildPreviewLabel); open-in-new-tab hidden off the sandbox surface.

Testing

  • Unit: preview-display.test.ts, section-preview-url.test.ts, preview-label.test.ts.
  • Typecheck (@decocms/sandbox, @decocms/shared, apps/api, apps/web) + oxlint clean.

Deploy note

Needs the sandbox daemon rebuilt with the /_deco/fast-preview route, and the linked site must run the deco runtime whose /live/previews accepts the POST __decofile body. Desktop (user-desktop) sandboxes: field is wired but productionUrl isn't threaded into the desktop start flow yet (cloud is).

Notes

🤖 Generated with Claude Code


Summary by cubic

Fast Preview now renders the working‑tree draft on the site’s real pages using a ?__draft=<handle>@<version> pointer, so editors see changes immediately without waiting for the dev server. The preview shows the published site while the sandbox boots, then swaps to the draft as soon as the daemon reports a version.

  • New Features

    • Daemon: public GET /_sandbox/decofile merges .deco/blocks/*, returns JSON with a weak ETag, and emits decofile {version} on block writes. Black‑box e2e added.
    • Web preview:
      • Builds the draft URL via buildDraftPreviewUrl(productionUrl, sandboxHandle, version) and re-navigates on new versions; never hands the canvas to the sandbox while Fast Preview is on.
      • Seeds previewUrl from SANDBOX_START (scoped to virtualMcpId::branch) and re-drives decofile/entity when the working tree first exists (installing onward).
      • Display: published‑site fallback + waking pill until the draft is renderable; URL label follows the iframe’s actual origin; “open in new tab” is shown only in sandbox mode.
    • Settings: new CMS section with Preview (Preview server URL + Fast Preview switch) and Publishing (extracted PublishPolicyField). Copy updated in en and pt‑br. Tests added/updated for display, label, URL building, lifecycle, and the daemon route.
  • Migration

    • Rebuild the sandbox daemon to serve /_sandbox/decofile.
    • The linked site must resolve ?__draft=<handle>@<version> by fetching the sandbox’s /_sandbox/decofile and rendering its own routes against the merged draft.

Written for commit 60b22e6. Summary will update on new commits.

Review in cubic

@tlgimenes
tlgimenes force-pushed the feat/cms-fast-preview-daemon branch 2 times, most recently from 6bfddc4 to 5e6811c Compare July 28, 2026 16:04
Deco Studio and others added 7 commits July 28, 2026 16:50
Adds Fast Preview: render the current working-tree draft against the linked
site's production `/live/previews` WITHOUT waiting for (or using) the dev server.

- New agent-settings CMS section: Preview (production/preview URL + Fast Preview
  switch) + Publishing (extracted PublishPolicyField). Copy in en + pt-br.
  `metadata.fastPreview` added to the schema.
- Rendering goes through a new PUBLIC daemon route `GET /_deco/fast-preview`
  (packages/sandbox/daemon): merges `.deco/blocks/*` (dep-free, no dev server),
  POSTs it server-side to `${productionUrl}/live/previews/<component>` (which
  already accepts a JSON `__decofile` body — no production change, no CORS, no
  URL cap), and returns the HTML with an injected `<base href>` so assets resolve
  against production.
- `productionUrl` threaded to the daemon config (Workload → SANDBOX_START →
  buildConfigPayload / link-daemon → daemon `Application.productionUrl`).
- Web: the Fast Preview frame loads the daemon route (`previewUrl` origin) via
  `buildFastPreviewDaemonUrl`. Fast Preview NEVER renders the published site — it
  shows the daemon draft or a booting overlay while the sandbox provisions.
- Save → refresh: on a `.deco/*` write the daemon emits `file-changed`; the
  events gate is relaxed for Fast Preview and the handler bumps a nonce (new
  daemon URL → the frame re-navigates → the daemon re-reads the saved blocks). No
  decofile refetch (the daemon reads disk; refetching could revert an optimistic
  edit).
- URL bar follows the iframe domain (buildPreviewLabel); open-in-new-tab hidden
  off the sandbox surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fast Preview renders the draft from the sandbox daemon, which is up shortly
after the clone — but the preview still sat behind a blocking overlay until
the dev server reported `running`, i.e. through the whole install. That is
precisely the wait the feature exists to skip.

Three causes, all upstream of the display decision:

- `previewUrl` (the daemon origin) was read only from the entity's
  `sandboxMap`, a collection query with a 60s staleTime whose only
  lifecycle-driven invalidation fires at `running` — even though
  SANDBOX_START already returns it at claim time. Seed it from the claim
  response, key-scoped to `virtualMcpId::branch` so a stale claim can never
  paint another branch's sandbox, and dropped on a terminal claim failure so
  the errored card still wins.

- The decofile (which supplies the page key) 502s while the repo is still
  cloning and is deliberately never retried, so nothing re-drove it until
  `running`. Re-invalidate when the daemon lifecycle first reaches a phase
  that guarantees a working tree (`installing` onward) — the read regenerates
  `.deco/blocks.gen.json` from `.deco/blocks/*.json` with no dev server.

- With neither input, the canvas fell to a blocking overlay. Fast Preview now
  shares main's published-site fallback: the published site plus the waking
  pill hold the canvas until the draft render is renderable, then it swaps
  in. Fast Preview no longer changes which surface is shown, only when it is
  ready — the draft after the clone, versus the dev server at `running`.

`iframeBase` stays the published URL in every production-mode case so the
base is always navigable and the URL-bar label does not jump origins
mid-boot; the draft URL is layered over it.

Inverts the test that pinned the old blocking-overlay rule, and corrects the
`metadata.fastPreview` doc, which claimed the sandbox surface takes over for
interactivity once it is up — it never did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ecofile

Two changes to the daemon's public surface, both in service of pull-based
draft rendering.

`/_deco/fast-preview` moves to `/_sandbox/fast-preview`. The `/_deco` prefix
was a one-route namespace with no stated contract, and it shadowed the proxied
dev server — a storefront serving its own `/_deco/*` would have been silently
intercepted. `/_sandbox` already has a public, unauthenticated tier
(`/events`, `/scripts`, `/idle`) that the browser talks to directly, which is
exactly where a browser-loaded iframe route belongs; `entry.ts` matches it
ahead of the bearer-token gate for the same reason `/events` is.

`GET /_sandbox/decofile` is new: it serves the merged working-tree
`.deco/blocks/*` so a production site can PULL the draft and render its own
real pages against it, instead of the daemon POSTing a multi-MB decofile into
`/live/previews`. That push model only works on deco's own runtime — Next.js
and most frameworks render on GET only — and it re-sent the whole payload on
every navigation. The response is content-addressed (weak ETag over the merged
bytes, 304 on match) so callers cache per version and re-fetch only when the
draft actually changes.

Returns the merged object as raw text, no JSON.parse round-trip: this payload
is routinely >3MB and the daemon runs a single-threaded event loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Match `foreignSandbox`: a caller that knows nothing about Fast Preview gets
exactly the pre-existing behaviour, so tests and call sites added on main
don't have to thread flags they don't care about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Studio needs the draft's content version to build a pull-based preview pointer
(`<handle>@<version>`), and polling for it would mean re-fetching a multi-MB
payload just to read its hash.

The daemon now emits `decofile: { version }` when a `.deco/blocks/*.json` write
lands, so Studio can rebuild the pointer on save instead. The version comes
from `readDecofile`, the same function the route serves as its `ETag` — one
definition, so a consumer's pointer and the framework's cache key cannot
disagree.

Kept off the hot path deliberately: only `.deco/blocks/*.json` writes trigger
it, it runs after the existing 300ms file-changed debounce (a burst of saves
costs one merge), the merge is async and deduped, and an unchanged hash
re-emits nothing. A failed probe warns and is swallowed rather than taking the
watcher down.

Adds a black-box e2e for the route: 404 with no blocks, merged body + content
ETag + no-store, unauthenticated access, ETag stability vs change, and the
304/200 revalidation pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Studio now sends the iframe to the REAL page on `productionUrl` carrying a
`?__draft=<handle>@<version>` pointer, which the site's framework resolves by
pulling the merged working-tree decofile from `/_sandbox/decofile`. The daemon
no longer renders anything.

Why the push model had to go: it POSTed the decofile to `/live/previews`, which
only deco's own runtime honours — Next.js and most frameworks render on GET
only — and it could only ever produce a single statically-rendered component,
with no hydration and no navigation. Going through the site's own route fixes
all three.

The `currentPageKey` dependency disappears with it, and that is the interesting
part: the preview no longer waits on the decofile query at all. That query is
what used to strand the whole surface behind a cold-start 502 while the sandbox
installed, since it needed a page key just to build a URL. The site does its own
routing now.

`fastPreviewReady` is fed off the draft version instead. The version also
replaces the cache-busting nonce: a save emits a new version, which changes the
URL, which re-navigates the frame — so the reload handler yields in draft mode
rather than racing that with a stale URL.

Deletes the daemon's fast-preview route, and with it the `productionUrl` that
was threaded from Studio through the config payload to the daemon purely to
feed it. Nothing daemon-side reads it any more; Studio builds the URL itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six black-box cases against the spawned daemon: 404 with no blocks, merged
body + content ETag + no-store, unauthenticated access, ETag stability vs
change, the 304/200 revalidation pair, and the `decofile` version event.

The event test asserts the value it announces is the SAME one the route serves
as its ETag — that equality is the whole contract, since Studio puts it in a
draft pointer and the framework caches on it.

It writes through the daemon's own `/_sandbox/write` rather than straight to
disk. That is how the CMS actually saves a block, and it signals
`onWorkingTreeWrite` directly. A bare fs write depends on
BranchStatusMonitor's watcher, which `ensureWatch()` only starts after
`compute()` succeeds — i.e. once the repo is a real git checkout — so it never
fires in a bare workspace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tlgimenes
tlgimenes force-pushed the feat/cms-fast-preview-daemon branch from 5e6811c to 60b22e6 Compare July 28, 2026 21:13
@tlgimenes tlgimenes closed this Jul 28, 2026
@tlgimenes
tlgimenes deleted the feat/cms-fast-preview-daemon branch July 28, 2026 21:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant