feat(cms): Fast Preview — render the draft via the sandbox daemon - #5232
Closed
tlgimenes wants to merge 7 commits into
Closed
feat(cms): Fast Preview — render the draft via the sandbox daemon#5232tlgimenes wants to merge 7 commits into
tlgimenes wants to merge 7 commits into
Conversation
tlgimenes
force-pushed
the
feat/cms-fast-preview-daemon
branch
2 times, most recently
from
July 28, 2026 16:04
6bfddc4 to
5e6811c
Compare
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
force-pushed
the
feat/cms-fast-preview-daemon
branch
from
July 28, 2026 21:13
5e6811c to
60b22e6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fast Preview renders the current working-tree draft against the linked site's production
/live/previewswithout waiting for or using the dev server — so content editors see their unpublished edits fast.Settings
PublishPolicyField). Copy in en + pt-br.metadata.fastPreviewadded to the schema.Rendering (daemon proxy — no URL cap, no production change)
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__decofilebody), and returns the HTML with an injected<base href>so assets resolve against production.productionUrlthreaded to the daemon config:Workload→ SANDBOX_START →buildConfigPayload/ desktoplink-daemon→ daemonApplication.productionUrl.previewUrlorigin) viabuildFastPreviewDaemonUrl. 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 emitsfile-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
preview-display.test.ts,section-preview-url.test.ts,preview-label.test.ts.@decocms/sandbox,@decocms/shared, apps/api, apps/web) + oxlint clean.Deploy note
Needs the sandbox daemon rebuilt with the
/_deco/fast-previewroute, and the linked site must run the deco runtime whose/live/previewsaccepts the POST__decofilebody. Desktop (user-desktop) sandboxes: field is wired butproductionUrlisn't threaded into the desktop start flow yet (cloud is).Notes
<base href>covers relative + root-relative assets for the static (renderToString) render; no hydration/client fetches in that mode.🤖 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
GET /_sandbox/decofilemerges.deco/blocks/*, returns JSON with a weak ETag, and emitsdecofile {version}on block writes. Black‑box e2e added.buildDraftPreviewUrl(productionUrl, sandboxHandle, version)and re-navigates on new versions; never hands the canvas to the sandbox while Fast Preview is on.previewUrlfrom SANDBOX_START (scoped tovirtualMcpId::branch) and re-drives decofile/entity when the working tree first exists (installingonward).PublishPolicyField). Copy updated in en and pt‑br. Tests added/updated for display, label, URL building, lifecycle, and the daemon route.Migration
/_sandbox/decofile.?__draft=<handle>@<version>by fetching the sandbox’s/_sandbox/decofileand rendering its own routes against the merged draft.Written for commit 60b22e6. Summary will update on new commits.