Skip to content

feat: pull-based draft preview - #409

Open
tlgimenes wants to merge 7 commits into
mainfrom
feat/draft-preview
Open

feat: pull-based draft preview#409
tlgimenes wants to merge 7 commits into
mainfrom
feat/draft-preview

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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 serves GET /_sandbox/decofile, Studio builds the pointer) and deco-sites/faststore-fila#271 (first adopter). Published as 7.28.0-beta.3 under the beta dist-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 (renderToString cannot 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

GET https://site.com/any/page?__draft=<host[:port]>@<version>
  → authority validated against DECO_PREVIEW_API_DOMAINS (dot-prefixed
    suffix match, label-boundary safe; defaults to the deco-operated domains)
  → GET {derived-scheme}://<host[:port]>/_sandbox/decofile  (cached by version)
  → renders its own route with the draft merged over published blocks

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 on fila.vtex.app. Verified on the wire — identical URL, same server: draft under an allowed Host, published (and no cookie) otherwise.

  • Unset ⇒ feature completely inert; no dynamic API touched, static/ISR untouched. Upgrading the package can never enable it.
  • Enforced twice: middleware returns an inert decision on other hosts (no cookie/rewrite/headers), and ensureDraft re-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_DOMAINS now 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 and generateMetadata; they race). It lives on the page, not a layout: a layout's await does not gate its children (measured — App Router renders them concurrently). Request scoping is React cache(); RequestContext.run is 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, off exits — plus no-store/noindex headers; cookie is SameSite=None; Secure; HttpOnly; Partitioned since the preview iframe is cross-site), and rewriteToDraftRoute for 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 _draft folder is a Next private folder, excluded from routing.

createDecoPage is also wired, but a usage survey found its only consumer is this repo's smoke example — real sites hand-roll routes, which is why ensureDraft is 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 reserves Cache-Control/Vary on dynamic responses (overwrites middleware and next.config headers()); the wire keeps no-cache, must-revalidate, which still forbids reuse without revalidation — a CDN-level rule is recommended before production. X-Robots-Tag gets 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)

  • Deferred/lazy sections resolve via /deco/invoke, which does not bind drafts — client-fetched sections render published content.
  • .releaserc.json has no prerelease channel; betas were published manually.
  • bun run lint already fails on clean main (unrelated files); this branch doesn't add to it.

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.
tlgimenes and others added 2 commits July 28, 2026 19:59
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>
@tlgimenes
tlgimenes requested a review from guitavano July 29, 2026 00:41
tlgimenes and others added 4 commits July 28, 2026 22:05
`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>
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