feat: @decocms/tanstack for v8 (createDecoWorker, DecoDocument, router, decoTanstack)
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
| Subpath | What 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. |
/worker | createDecoWorker. Server only. |
/server | contentClient(cms, request?), requestDraft, markPrivate, isServerFnCall. Server only. |
/start | decoServerFnFetch, safe in the client bundle. |
/router | createDecoRouter, decoParseSearch / decoStringifySearch, PAGE_STATUS_HEADER. |
/react | DecoDocument, NavigationProgress, StableOutlet. |
/vite | decoTanstack({ buildHash }) and gitBuildHash(). |
/scope | The request scope behind AsyncLocalStorage. It is a conditional export with workerd and node before browser. |
Always on in createDecoWorker
/deco/_livenessanswers OK./deco/*,/live/*and/.decofileanswer 404.- Studio can frame the site:
frame-ancestorsfrom this package'sSTUDIO_FRAME_ANCESTORS, and noX-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 getno-store, private,Vary: Cookieandnoindex, and only a real draft skips the cache. - CMS redirects run before any route. The query string is kept, as
matchRoutedoes. - Edge cache. The key is the build, the content revision, the device and the
segmentparts. 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 getsCDN-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-statusis dropped on a HIT.x-page-statussets 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
CMSblock's, orcreateCMS's). A site that declares none previews only on its Deco hosts, as in v7; blocks' own default (every host, forcms.draftPointer) is unchanged and isn't used by the worker. - The Deco hosts named after
siteare always added, exactly:<site>.deco.siteand<site>.deco-cx.workers.dev. previewHosts: [...]is v7'sDECO_ALLOWED_PREVIEW_HOSTS: it replaces theCMSblock'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=offends 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), thengit rev-parse --short=12, then the time, as v7 did.vite devgets"dev", and the package reads no env. decoContent()stays in@decocms/blocks/vite;decoTanstack()only calls it.DecoDocumentrendersdata-theme="light", theDECO.eventsbus and thedata-eventobserver, the progress bar andStableOutlet, the published__csegtoken and draft,StudioBridge, andDraftBadge. It passesSTUDIO_ORIGINSas the bridge's requiredtrustedOrigins, which is the same list the bridge answered before #695 dropped its own copy. Its options arelang,bodyClassName,account,readyDelay,outlet(mainordiv),editShortcutandnonce.editShortcutis left to the bridge's default, so the "." edit shortcut is on unless a site passeseditShortcut={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.
DecoDocumentSSR, including the origins it passes to the Studio bridge.- The moved draft names,
draftResponseHeadersand Studio's origins (values pinned, lists frozen,isStudioOrigincases). - 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,/startand/router: nonode:, noAsyncLocalStorage, no CMS.
examples/tanstack-worker-smokeis the existing smoke app moved onto the package.bun run smokechecks 25 headers and markers on workerd (vite preview, Miniflare). A new CI job,tanstack-worker-smoke, builds it and runs it.guides.cli.test.tsin-11 now allows the bindings. Both bindings and everyapps-*package depend only on@decocms/blocks.- Commands run:
bun run checkpasses. The knip hint aboutesbuildis already on P3.bun run buildpasses. Plain Node imports the built root export.bun run skills:checkandbun run skills:readme:checkpass.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.tsunder full-suite load. That file passes 58/58 when run alone.
- One is DS-2 in
- 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.
| Site | Lines | Built under 2 GB | vite preview check |
|---|---|---|---|
| Shopify storefront A | −1036 / +75 | yes | MISS then HIT; enforced CSP + frame-ancestors; the page loader's server function is CDN-cacheable with its token |
| Shopify storefront B | −832 / +70 | yes | MISS then HIT; report-only CSP; ?q= answers 200 |
| A blog | −670 / +99 | yes | MISS then HIT; a CMS 404 answers 404; its tests pass (21/21) |
| A VTEX storefront with an A/B split | −3519 / +286 | RAM capped at 2 GB, with swap | MISS 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.siteand<site>.deco-cx.workers.devpreview. Local development (Studio's local mode onlocalhost:5173) needspreviewHosts: ["localhost:5173"]in dev or the host in theCMSblock, as v7 neededDECO_ALLOWED_PREVIEW_HOSTS. - Drafts are decided once, by the worker. The app reads
x-deco-draftthroughcontentClient. The worker also sets the draft cookie, so the sites' draft-cookie middleware instart.tsgoes 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.segmentcan return"private", for a signed-in shopper.segmentcan overridedevice: 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
RequestContextand 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:
- The VTEX storefront's router needs TanStack's
hydrateoption, so it keeps its owncreateRouterwith the exported search functions. - The blog's layout has a skip link and
<main id>, so it keeps its ownRootLayout. It rendersDecoPreview(2026-10-16, below), so it has the badge and__cseg. - The blog falls back to the release when a draft can't be read. That stays in the site, and
contentClientcould adopt it. - v7's body-hash caching of POST server functions is not ported, and no v8 site depends on it.
- CSP nonce generation is not ported; no site enforces one.
- The VTEX storefront's router needs TanStack's
- 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. TheImagecomponents come in #700; there, a site wires in its platform's URL helper itself (@decocms/apps-vtex/imageor@decocms/apps-shopify/image). - Not in this PR:
createPageLoader/pageState, and theImage/Picturecomponents. 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)
preloadhints (Link: rel=preloadfor the site's CSS and fonts) now go only on HTML the app rendered. Aroutesresponse (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 andCDN-Cache-Controlstill apply at the single exit. The handler'sroutesresult is marked internally (routed), so the worker knows nothing about A/B or compat.- Test: a
routesHTML response keeps its ownLinkand 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)
DecoDocumenthands the bridge the page on screen:pathTemplate(and optionallypageId) from the deepest matched route's loader data, as v7's document read its CMS route'spath. A page route returnspathTemplate: match.entry.path.DecoPreview({ site, editShortcut?, nonce? })from/reactis whatDecoDocumentmounts for Studio and drafts, for a site that keeps its own document: the published draft and__cseg,StudioBridgewith the page, andDraftBadge. 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
DecoPreviewin 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
4e244e17into 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:checkpass.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