feat: @decocms/tanstack for v8 (createDecoWorker, DecoDocument, router, decoTanstack)

TL
2h ago
+5195-15

Brings @decocms/tanstack back for v8 as packages/tanstack. It is for TanStack Start on Cloudflare Workers, deployed the Deco way, and it is opinionated: there is one createDecoWorker({...}), an option only where a current site needs a different value, and routes as the one escape hatch. It depends only on @decocms/blocks. Stacked on #695 (P3).

As you asked, the draft host allowlist lives here in the binding, not in the framework. @decocms/blocks keeps its host behaviour unchanged.

Per your review of #693, this package also owns what #693 had put in @decocms/blocks. That means the draft names, the draft response headers and Studio's origins. Their values are unchanged: this is a move. @decocms/blocks exports none of them now.

What's in it

SubpathWhat it does
. (root)DRAFT_PARAM (__draft), DRAFT_COOKIE (__deco_draft), DRAFT_HEADER (x-deco-draft), draftResponseHeaders(pointer) (no-store, private, Vary: Cookie, noindex), STUDIO_ORIGINS, STUDIO_FRAME_ANCESTORS, isStudioOrigin. No imports, browser-safe.
/workercreateDecoWorker. Server only.
/servercontentClient(cms, request?), requestDraft, markPrivate, isServerFnCall. Server only.
/startdecoServerFnFetch, safe in the client bundle.
/routercreateDecoRouter, decoParseSearch / decoStringifySearch, PAGE_STATUS_HEADER.
/reactDecoDocument, NavigationProgress, StableOutlet.
/vitedecoTanstack({ buildHash }) and gitBuildHash().
/scopeThe request scope behind AsyncLocalStorage. It is a conditional export with workerd and node before browser.

Always on in createDecoWorker

  • /deco/_liveness answers OK. /deco/*, /live/* and /.decofile answer 404.
  • Studio can frame the site: frame-ancestors from this package's STUDIO_FRAME_ANCESTORS, and no X-Frame-Options. v7's security headers are sent too.
  • Drafts. v7's allowlist decides which hosts may preview. The worker sets the draft cookie and passes the pointer to the app in x-deco-draft, removing any copy that arrives from outside. Draft responses get no-store, private, Vary: Cookie and noindex, and only a real draft skips the cache.
  • CMS redirects run before any route. The query string is kept, as matchRoute does.
  • Edge cache. The key is the build, the content revision, the device and the segment parts. It does stale-while-revalidate and stale-if-error. Signed-in, private and degraded (x-deco-degraded) responses are never stored, and safe cookies are stripped from the stored copy.
  • __cseg. The page publishes a segment token. The CDN may cache a GET server function only when the token in its URL matches the one the worker recomputes; every other response gets CDN-Cache-Control: no-store, set at one exit.
  • Set-Cookie is deduplicated by name + Domain + Path (v7 #642).
  • Fingerprinted assets and /fonts/ are immutable. cf-cache-status is dropped on a HIT. x-page-status sets the document's status.
  • onCache({ profile, decision, ms, status }) is the only metrics hook (D3).

Draft host allowlist (D1)

This ports v7's readAllowedHosts / isDraftHostAllowed as is.

  • The site's own hosts are the preview hosts it declares (the CMS block's, or createCMS's). A site that declares none previews only on its Deco hosts, as in v7; blocks' own default (every host, for cms.draftPointer) is unchanged and isn't used by the worker.
  • The Deco hosts named after site are always added, exactly: <site>.deco.site and <site>.deco-cx.workers.dev.
  • previewHosts: [...] is v7's DECO_ALLOWED_PREVIEW_HOSTS: it replaces the CMS block's list, and the Deco hosts stay allowed.
  • previewHosts: "none" is the kill switch: no host previews, the Deco hosts and "*" included.
  • Hosts compare exactly, lowercased, with the port.
  • ?__draft=off ends a preview on any host.

Every one of these cases is tested in worker.test.ts.

The rest

  • Build hash (D4). decoTanstack({ buildHash: process.env.WORKERS_CI_COMMIT_SHA }) uses the CI's sha (12 characters), then git rev-parse --short=12, then the time, as v7 did. vite dev gets "dev", and the package reads no env.
  • decoContent() stays in @decocms/blocks/vite; decoTanstack() only calls it.
  • DecoDocument renders data-theme="light", the DECO.events bus and the data-event observer, the progress bar and StableOutlet, the published __cseg token and draft, StudioBridge, and DraftBadge. It passes STUDIO_ORIGINS as the bridge's required trustedOrigins, which is the same list the bridge answered before #695 dropped its own copy. Its options are lang, bodyClassName, account, readyDelay, outlet (main or div), editShortcut and nonce. editShortcut is left to the bridge's default, so the "." edit shortcut is on unless a site passes editShortcut={false} (PO decision 4, 2026-10-15).

Tests

  • packages/tanstack: 123 tests.
    • The worker's header matrix: 60 tests, including every allowlist case and the kill switch.
    • Router round-trips (#646).
    • decoServerFnFetch.
    • Build-hash order.
    • DecoDocument SSR, including the origins it passes to the Studio bridge.
    • The moved draft names, draftResponseHeaders and Studio's origins (values pinned, lists frozen, isStudioOrigin cases).
    • The package depends on no @decocms/apps-* package.
    • The conditional export, resolved with Node --conditions=workerd,worker,browser.
    • esbuild browser bundles of the root, /react, /start and /router: no node:, no AsyncLocalStorage, no CMS.
  • examples/tanstack-worker-smoke is the existing smoke app moved onto the package. bun run smoke checks 25 headers and markers on workerd (vite preview, Miniflare). A new CI job, tanstack-worker-smoke, builds it and runs it.
  • guides.cli.test.ts in-11 now allows the bindings. Both bindings and every apps-* package depend only on @decocms/blocks.
  • Commands run:
    • bun run check passes. The knip hint about esbuild is already on P3.
    • bun run build passes. Plain Node imports the built root export.
    • bun run skills:check and bun run skills:readme:check pass.
    • bun run test: 2277 passed, 3 failed.
      • One is DS-2 in delivery.cli.test.ts, the known failure.
      • The other two are timeouts in schema.test.ts under full-suite load. That file passes 58/58 when run alone.
    • The workerd smoke passes 25/25.

The four TanStack sites, rewritten on local branches

None of these are pushed. Each is on its own worktree, on branch feat/decocms-tanstack-worker from feat/next-major. Each site links tarballs of this branch's @decocms/blocks and @decocms/tanstack packed at 8.3.0, with an overrides entry so only one copy of blocks is installed.

SiteLinesBuilt under 2 GBvite preview check
Shopify storefront A−1036 / +75yesMISS then HIT; enforced CSP + frame-ancestors; the page loader's server function is CDN-cacheable with its token
Shopify storefront B−832 / +70yesMISS then HIT; report-only CSP; ?q= answers 200
A blog−670 / +99yesMISS then HIT; a CMS 404 answers 404; its tests pass (21/21)
A VTEX storefront with an A/B split−3519 / +286RAM capped at 2 GB, with swapMISS then HIT; segment is device + geo + sales channel; IS cookies; ?asJson

All of the VTEX storefront's vendored v7 worker (src/vendor/tanstack/**) goes away.

For the PO

  • The draft allowlist default (changed in review, 0ca42fef). It is v7's: with no declared preview hosts only <site>.deco.site and <site>.deco-cx.workers.dev preview. Local development (Studio's local mode on localhost:5173) needs previewHosts: ["localhost:5173"] in dev or the host in the CMS block, as v7 needed DECO_ALLOWED_PREVIEW_HOSTS.
  • Drafts are decided once, by the worker. The app reads x-deco-draft through contentClient. The worker also sets the draft cookie, so the sites' draft-cookie middleware in start.ts goes away.
  • What the sites needed that the plan's API lacked. I added each of these because a site needs it:
    • site, for the Deco preview hosts.
    • previewHosts.
    • preload.fonts, for the VTEX storefront.
    • csp.frameAncestors, for the VTEX storefront's marketing-tool editors.
    • segment can return "private", for a signed-in shopper.
    • segment can override device: both Shopify storefronts merge tablets into mobile.
  • Kept as site code, through wrapping, with no new option. In the VTEX storefront: the location-matcher geo cookies, its vendored RequestContext and loader-cache scope, the VTEX middleware, and the A/B split. These are what a full S4 migration would move next.
  • What the package does not cover yet:
    1. The VTEX storefront's router needs TanStack's hydrate option, so it keeps its own createRouter with the exported search functions.
    2. The blog's layout has a skip link and <main id>, so it keeps its own RootLayout. It renders DecoPreview (2026-10-16, below), so it has the badge and __cseg.
    3. The blog falls back to the release when a draft can't be read. That stays in the site, and contentClient could adopt it.
    4. v7's body-hash caching of POST server functions is not ported, and no v8 site depends on it.
    5. CSP nonce generation is not ported; no site enforces one.
  • The blog's cache values moved to the shared defaults, as the plan decided. The blog's GET server functions are now cached only when they load a page.
  • The VTEX storefront's build. Its peak at "rendering chunks" exceeds 2 GB of RAM; its own build script sets a 3 GB heap. I built it with RAM capped at 2 GB and swap allowed.
  • Images. This package has no image code and depends on no @decocms/apps-* package. The Image components come in #700; there, a site wires in its platform's URL helper itself (@decocms/apps-vtex/image or @decocms/apps-shopify/image).
  • Not in this PR: createPageLoader/pageState, and the Image/Picture components. They are not needed by the four worker entries and are left for the site PRs. Release config and npm commands are for P6.

Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig

Preload hints only on the app's pages (2026-10-15, 68167723)

  • preload hints (Link: rel=preload for the site's CSS and fonts) now go only on HTML the app rendered. A routes response (a proxied checkout, an A/B test's legacy page) is another origin's page, so it gets none; v7's worker didn't send them there either. Security headers and CDN-Cache-Control still apply at the single exit. The handler's routes result is marked internally (routed), so the worker knows nothing about A/B or compat.
  • Test: a routes HTML response keeps its own Link and the security headers but none of the worker's hints, while the app's page has them. Reverting the check fails it.

"." opens the page on screen, and DecoPreview (2026-10-16, 8b568030, 6a3abe98)

  • DecoDocument hands the bridge the page on screen: pathTemplate (and optionally pageId) from the deepest matched route's loader data, as v7's document read its CMS route's path. A page route returns pathTemplate: match.entry.path.
  • DecoPreview({ site, editShortcut?, nonce? }) from /react is what DecoDocument mounts for Studio and drafts, for a site that keeps its own document: the published draft and __cseg, StudioBridge with the page, and DraftBadge. It closes follow-up 2 above: the blog renders it and now shows the badge, keeping its skip link.
  • Tests: the bridge carries the route's template and id, nothing without one, and DecoPreview in a site's own document publishes the draft and mounts the bridge after the site's markup.

Main refresh (2026-10-17, c2ac0c96)

  • Merged P3 4e244e17 into this branch (normal merge, no conflicts), then merged up the stack to M2 #707.
  • Gates at the top of the stack (M2 5131a3c1): bun run check, bun run build, skills:check, skills:readme:check pass. bun run test (2 GB memory cap): 122 files pass, 1 skipped; 2,745 tests pass, 95 skipped, 0 fail. DS-2 now passes (fixed on main by #709); no timeouts this run.
  • CI on this PR's new head: all checks pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu