feat: pull-based draft preview - #409
Open
tlgimenes wants to merge 7 commits into
Open
Conversation
A site can now render its OWN pages against a Studio sandbox's working-tree draft, by fetching the merged decofile itself over a plain GET. This replaces pushing the decofile into a POST body at `/live/previews`. That only worked because deco's own runtime honours a POST render — Next.js and most frameworks render on GET only — and it could produce just one statically rendered component: no hydration, no navigation, and client components invoked server-side. Going through the site's real route fixes all three, and the payload is fetched once per version instead of re-sent on every navigation. Shape: `?__draft=<handle>@<version>` on any page URL. The site resolves the handle against a CONFIGURED origin suffix, so it never fetches a caller-supplied URL and there is no SSRF surface to defend. - `@decocms/blocks` — `draftSource`: pointer parsing, origin construction, fetch, and a bounded version cache. Framework-agnostic; binding a draft to a request is injected via `setDraftOverrideGetter`, the same shape as `setFastDeployKVGetter`, so the runtime keeps its zero-dependency direction. - `@decocms/nextjs` — `ensureDraft` (page-level) plus `prepareDraft`/`applyDraft` (middleware, on a `./middleware` subpath so the edge graph never imports the root barrel), wired into `createDecoPage`. Two findings worth stating, both measured rather than assumed: A layout's `await` does NOT gate its children — App Router renders them concurrently, so a layout-level resolve lands after sections have already read `loadBlocks()` and silently rendered published content. The resolve therefore belongs on the PAGE. Request scoping uses React `cache()`, not AsyncLocalStorage: `RequestContext.run` is never entered on Next, and ALS cannot wrap a component's children anyway. Verified with concurrent requests. Next reserves `Cache-Control` and `Vary` on dynamic responses and overwrites them from middleware AND from `next.config` `headers()`; the wire keeps `no-cache, must-revalidate`. Isolated with a marker header on the same rules, so this is Next owning those two names, not our rules failing to match. `no-cache` still forbids reuse without revalidating with the origin, so a shared cache cannot serve a draft to another visitor — but closing the gap fully needs a CDN-level rule at deploy time. `X-Robots-Tag` does get through. Inert unless BOTH `DECO_DRAFT_PREVIEW=1` and `DECO_SANDBOX_ORIGIN_SUFFIXES` are set, mirroring Fast Deploy's opt-in: upgrading the package must never be enough to start fetching from the network and rendering unpublished content. A test asserts `fetch` is never called when disabled, and that an unconfigured site never touches a dynamic API — otherwise every page would lose static/ISR rendering on upgrade.
Adds `rewriteToDraftRoute`, so a site can keep its real routes statically rendered while still serving drafts. `dynamic` and `revalidate` are STATIC route exports — a page cannot become dynamic for a single request. Without this, supporting drafts means making the real route dynamic, which costs every visitor their cached page and, worse, lets a statically rendered draft be cached and served to them. Rewriting sends only drafted requests to a route that is dynamic by construction; ordinary traffic gets null back and falls straight through, untouched. The prefix is `/_draft` at the URL level, but a site's directory for it must be URL-encoded (`app/%5Fdraft/...`). Next treats a literal `_` prefix as a private folder and excludes it from routing, so a rewrite to `/_draft/...` silently falls through to whatever catch-all follows and 404s. Verified the hard way. Documented on the constant so the next person doesn't repeat it. Guards against nesting the prefix if middleware re-runs on the rewritten URL, and does not rewrite when leaving draft mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… first A deployment can legitimately serve sandboxes from more than one origin (cluster and desktop-link), and a handle alone does not say which suffix it lives under. `resolveDraftDecofile` now tries the configured suffixes in order; a miss on one origin — unreachable, non-2xx, unparseable — falls through to the next instead of aborting the resolve. Previously only the first entry was consulted, silently, while the option read as a list. Also corrects the DRAFT_ROUTE_PREFIX doc, which claimed a literal `_draft` folder "cannot be exposed as a real page": true, but for the wrong reason — Next excludes `_`-prefixed folders from routing entirely, so the rewrite target must be mounted URL-encoded (`app/%5Fdraft/...`) or every drafted request 404s through the site's catch-all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`DECO_DRAFT_PREVIEW=1` becomes `DECO_DRAFT_PREVIEW_HOST=<host[,host…]>`. A boolean cannot express the common deployment shape: one build serving several domains, of which only the preview domain may render drafts. Host scoping makes the production domain structurally inert — `fila.com.br?__draft=` does nothing on the same build that previews on `fila.vtex.app` — while keeping the upgrade invariant: no host configured, feature completely inert, no dynamic API touched, static/ISR rendering untouched. The gate is enforced twice: middleware returns an inert decision on a non-allowed host (no cookie, no rewrite, no headers), and ensureDraft re-checks before binding — the header is read only once a pointer exists, so the check itself costs no dynamic rendering. Hosts match verbatim (port included) and case-insensitively against `x-forwarded-host ?? host ?? nextUrl.host`; the header is spoofable by direct-to-origin calls, but the sandbox handle remains the capability — host scoping bounds blast radius, it is not a secret. `DECO_SANDBOX_ORIGIN_SUFFIXES` now defaults to the deco-operated origins (.preview-studio.decocms.com, .local.studio.decocms.com, .localhost) and the env becomes an override. Shipping defaults adds no SSRF surface — the origin is still configuration, never caller input. BREAKING CHANGE: DECO_DRAFT_PREVIEW is removed; sites must set DECO_DRAFT_PREVIEW_HOST to enable draft preview. Beta-only surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… env pair
Token v2: `?__draft=<host[:port]>@<version>` — the token now names WHERE the
draft lives, not just a bare label. Probing across configured suffixes is
gone: the authority is validated against `DECO_PREVIEW_API_DOMAINS`
(dot-prefixed suffix match, label-boundary safe) and the fetch goes to exactly
one origin. The token still never carries a scheme or path — a full URL would
be an SSRF vector — so `javascript:`/`file:`/URL-shaped tokens fail at parse
time; the scheme is derived from the matched domain, and an explicit port is
admitted only under localhost-ish domains, so a public-domain token cannot aim
at odd ports. This also dissolves the desktop-link dynamic-port problem:
Studio embeds the daemon origin's authority verbatim, no suffix override
needed. The version charset is now validated too (it is a cache key).
Env renames, detaching the sandbox vocabulary from the site-facing surface:
DECO_DRAFT_PREVIEW_HOST → DECO_ALLOWED_PREVIEW_HOSTS
DECO_SANDBOX_ORIGIN_SUFFIXES → DECO_PREVIEW_API_DOMAINS
Semantics unchanged: the host allowlist is the single opt-in (unset = fully
inert), the domains default to the deco-operated origins and an override
replaces the list. `handle` is gone from the public surface; DraftPointer is
`{ host, version }`.
BREAKING CHANGE: token format and both env names change. Beta-only surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The opt-in moves into the repo: the global `site` block may declare `previewHosts` (e.g. ["fila.vtex.app", "localhost:3100"]), read once at setup from the BASE blocks and installed via setDraftPreviewHosts. Reviewed in a PR, versioned with branches, and shared with Studio — which reads the same block through the decofile it already fetches — so the value exists exactly once. Read at setup time on purpose, never via loadBlocks() at request time: the request path merges the draft override, and an allowlist readable through the override could be rewritten by the very draft it gates. DECO_ALLOWED_PREVIEW_HOSTS demotes to an operational escape hatch: when set it REPLACES the block hosts (kill a bad value without a deploy, add a machine-specific port); unset, the block is the source; neither → inert, so upgrading still enables nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first cut installed the site-block hosts lazily (inside ensureSetup) into plain module state — and the wire test failed both ways: pages call ensureDraft BEFORE they resolve CMS content, so the gate ran ahead of the install; and the middleware bundle is a separate module graph/runtime that never runs setup at all, so its copy of the allowlist stayed empty forever. Three-part fix, one lesson (module state does not travel): - hosts live on globalThis, the same pattern the block loader already uses against bundler-duplicated module instances; - createNextSetup installs them synchronously at module evaluation (the blocksDir mode still re-installs after its fs read); - prepareDraft hard-gates only when DECO_ALLOWED_PREVIEW_HOSTS is set — the env override keeps full kill-switch power — and otherwise passes through, leaving the page-side ensureDraft as the authoritative gate. Worst case on a non-preview host is a cookie the page gate then ignores. Verified on the wire with zero env: draft renders on the block-listed host, fila.com.br stays published on the same build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Pull-based draft preview: a site renders its own pages against a Studio sandbox's working-tree draft, fetching the merged decofile itself over a plain GET.
Paired with
decocms/studio#5363(daemon servesGET /_sandbox/decofile, Studio builds the pointer) anddeco-sites/faststore-fila#271(first adopter). Published as7.28.0-beta.3under thebetadist-tag.Why
The old flow POSTed the decofile to
/live/previews. Three structural problems: only deco's own runtime honours a POST render (Next renders on GET); it produced one statically-rendered component — no hydration, no navigation, and client components invoked server-side (renderToStringcannot call Next's client-reference proxies); and it re-sent a multi-MB payload on every navigation. Pull fixes all three — the payload is fetched once per version.Shape
The token carries an authority, never a URL — no scheme, no path, so
javascript:/file:/URL-shaped tokens fail at parse time and the domain allowlist decides what may be fetched. Scheme is derived (http only for localhost-ish domains, where an explicit port is also admitted — which is how the desktop link's per-run port travels in the token).<version>is the daemon's content ETag; a save mints a new version, which is what refreshes the frame.Gating: host allowlist, not a boolean
DECO_ALLOWED_PREVIEW_HOSTS=fila.vtex.app,localhost:3100— only listed request hosts render drafts. One build commonly serves several domains; host scoping makes the production domain structurally inert:fila.com.br?__draft=does nothing on the same build that previews onfila.vtex.app. Verified on the wire — identical URL, same server: draft under an allowedHost, published (and no cookie) otherwise.ensureDraftre-checks before binding. The host header is spoofable direct-to-origin, but the sandbox handle is the capability — this bounds blast radius, it is not a secret.DECO_PREVIEW_API_DOMAINSnow defaults to the deco-operated origins (.preview-studio.decocms.com,.local.studio.decocms.com,.localhost); the env is an override (e.g. a ported local link.localhost:60534). Defaults add no SSRF surface — origins remain configuration, never caller input.Integration surface
ensureDraft(searchParams)is the primary API — await it in every entry point that resolves CMS content (page andgenerateMetadata; they race). It lives on the page, not a layout: a layout'sawaitdoes not gate its children (measured — App Router renders them concurrently). Request scoping is Reactcache();RequestContext.runis never entered on Next and ALS can't wrap a component's children.Middleware (
@decocms/nextjs/middleware, own subpath so the edge graph never pulls the root barrel):prepareDraft/applyDraft(cookie lifecycle —?__draft=enters, cookie carries navigation,offexits — plus no-store/noindex headers; cookie isSameSite=None; Secure; HttpOnly; Partitionedsince the preview iframe is cross-site), andrewriteToDraftRoutefor statically rendered routes only (a static draft render would be cached for real visitors). The rewrite target must be mounted URL-encoded (app/%5Fdraft/…) — a literal_draftfolder is a Next private folder, excluded from routing.createDecoPageis also wired, but a usage survey found its only consumer is this repo's smoke example — real sites hand-roll routes, which is whyensureDraftis the documented path.Safety & failure modes
Every failure path (malformed pointer incl.
a@b@c, disallowed host, unreachable origin, non-2xx, bad JSON) degrades to published. Version cache is bounded (3 entries; multi-MB payloads). Measured limit: Next reservesCache-Control/Varyon dynamic responses (overwrites middleware andnext.configheaders()); the wire keepsno-cache, must-revalidate, which still forbids reuse without revalidation — a CDN-level rule is recommended before production.X-Robots-Taggets through.Testing
673 tests across the two packages: host allow/deny, suffix defaults + fallback order, cookie, rewrite, gate-never-touches-dynamic-APIs. Verified end-to-end against a real sandbox daemon serving a real store's 440 blocks: entry, cookie navigation, stale-cookie override,
?__draft=off, concurrent distinct drafts, and the host gate above.Known follow-ups (not in this PR)
/deco/invoke, which does not bind drafts — client-fetched sections render published content..releaserc.jsonhas no prerelease channel; betas were published manually.bun run lintalready fails on cleanmain(unrelated files); this branch doesn't add to it.