feat(tanstack): opt-in CDN caching for /_serverFn via a URL segment marker - #513
Merged
Conversation
…r in the URL
Anything in front of the Worker — Workers Cache, a CDN rule — keys on the raw
URL. The Worker keys on a synthetic Request carrying `__seg`/`__cf_device`/
`__cf_geo`/`__bot`/`__fetch`/`__abf` (`buildCacheKey`). That mismatch is why
every response ships `CDN-Cache-Control: no-store`, and why 100% of traffic
comes back uncached.
This inverts the problem for `/_serverFn` (SPA navigation and prefetch data):
the client appends the segment to the URL itself, so the two keys become
equivalent and releasing the cache is safe.
// worker-entry.ts
createDecoWorkerEntry(serverEntry, {
cdnCacheControl: "serverfn-segment",
buildSegment: (request) => ({ /* ... */ }), // required
});
// src/start.ts
import { decoServerFnFetch } from "@decocms/tanstack/sdk/serverFnFetch";
export const startInstance = createStart(() => ({
serverFns: { fetch: decoServerFnFetch },
}));
The default is unchanged, and the marker is a HINT, not a source of truth: the
Worker recomputes the segment from the request and only relaxes `no-store` on
an exact match. Absent, diverging, forged or stale-build markers, logged-in /
region / sales-channel requests, an unknown custom segment field, a bot UA, an
A/B cohort cookie, or geo-varying keys all keep today's behaviour. The worst
case is not caching, never a wrong response.
The build hash is part of the token because deploying does not purge whatever
caches the response, so the URL has to change on its own when the bundle does.
`isBot` and the A/B cookie are checked with the same helpers `buildCacheKey`
uses — keying and releasing off different predicates is exactly how the two
silently diverge.
HTML documents stay `no-store`: the initial navigation is a browser request
with no client hook to attach a marker to.
Also refuses `cdnCacheControl: "match-profile"` when the cache key is
segmented. Its JSDoc promised safety "without buildSegment and
deviceSpecificKeys: false", but `deviceSpecificKeys` defaults to true, so the
condition is false on every site — following the docs would have silently
cross-served segments.
Ported from a site-level implementation running in production, with two gaps
closed: that version verified only the device, leaving bots (which execute JS —
Lighthouse, PageSpeed) and A/B cohorts able to poison a shared entry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JonasJesus42
changed the base branch from
main
to
fix/cache-private-routes-hardening
August 28, 2026 14:35
JonasJesus42
changed the base branch from
fix/cache-private-routes-hardening
to
main
September 1, 2026 13:57
|
🎉 This PR is included in version 7.56.0 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
This was referenced Sep 1, 2026
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.
Split out of #510 at review request. Stacked on #512 (private-route hotfix), which is this branch's base —
merge that one first.
The problem
Anything sitting in front of the Worker keys on the raw URL. The Worker keys on a synthetic Request
carrying
__seg/__cf_device/__cf_geo/__bot/__fetch/__abf(buildCacheKey). That mismatch is whatforces
CDN-Cache-Control: no-storeon every response, and why 100% of traffic invokes the Worker.The fix, for
/_serverFnInvert the problem: instead of expecting the layer in front to reproduce the key, the client puts the
segment in the URL itself (
?__cseg=<device>.<buildHash>), which makes the two keys equivalent.Covers SPA navigation and prefetch — the volume Speculation Rules generates. Default unchanged.
Why it's safe
The marker is a hint, not a source of truth. The Worker recomputes the segment from the request itself and
only relaxes
no-storeon an exact match. These all keep today's behaviour:marker absent · diverging · forged · stale build · logged-in · region · sales channel ≠ 1 · unknown custom
SegmentKeyfield · bot UA · A/B cohort cookie · geo-varying keyThe worst case is not caching, never a wrong response.
isBotand the A/B cookie are checked with the same helpersbuildCacheKeyuses — keying and releasingoff different predicates is exactly how the two silently diverge.
The build hash is part of the token because deploying does not purge whatever is caching the response, so the
URL has to change on its own when the bundle does.
HTML stays
no-store: the initial navigation is a browser request, with no client hook to attach a markerto.
Also here:
match-profilewas a silent trapIts JSDoc promised safety "without
buildSegmentanddeviceSpecificKeys: false", butdeviceSpecificKeysdefaults to
true— the condition is false on every site. Following the docs would have cross-served segmentswith no warning. Now refused with a warning.
Provenance
Ported from a site-level implementation running in production, with two gaps closed: that version verified
only the device, leaving bots (which execute JS — Lighthouse, PageSpeed) and A/B cohorts able to poison a
shared entry. On the site, this replaced ~230 lines of shim with 6 lines plus a 9-line
start.ts.Validation
Tested end-to-end against a real built site, not just unit tests: linked packages,
bun run build, workerunder
wrangler dev, SPA navigation driven by Chrome.Cdn-Cache-Control/_serverFnwith a correct marker (desktop and mobile)public, max-age=1800✅no-storeno-storeno-storeno-storeno-storeno-storeno-storeThe released value matches exactly what the site-level implementation produces in production.
2573tests passing. Typecheck clean.What was dropped
The Cache Rules generator that was in #510 is gone. Review pointed out — correctly — that sites served by
Workers have no cache in front of them, and that the path is Workers Cache (
cache: { enabled: true }inwrangler), not zone-level Cache Rules. That changes the design: per-worker instead of the shared zone,
config-as-code, the worker version already in the key (so a deploy invalidates on its own), and
Varyrespected.
I'll redo the HTML side on top of that, in its own PR. No point merging a generator I already know is the
wrong approach.
🤖 Generated with Claude Code