You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A reduced (X-Webjs-Have) fragment and a <webjs-frame> subtree are served with the PAGE's Cache-Control, so on a page that opted into public caching they are marked shared-cacheable.
Their safety then rests entirely on the CDN honoring Vary. Cloudflare ignores Vary
except Accept-Encoding, and so do several other shared caches, so a CDN can store a
chrome-less fragment under the page URL and serve it to a fresh full-page navigation.
Measured on the live site (webjs.dev origin, /docs/ai-first):
71,759 bytes full page
47,375 bytes same URL + x-webjs-have <- chrome-less fragment
cache-control: public, max-age=60, s-maxage=600, stale-while-revalidate=86400
vary: X-Webjs-Have, Accept-Encoding
Both responses carry the identical public Cache-Control. The only thing standing between a
visitor and a 47KB fragment rendered as a whole page is a Vary header that Cloudflare
documents as ignored.
This is latent rather than live today only because Cloudflare does not cache HTML by default,
so webjs.dev sits at cf-cache-status: DYNAMIC. It becomes live the moment an app does the
ordinary thing: put a WebJs app behind Cloudflare and turn on HTML caching, which is a normal
performance step and exactly what metadata.cacheControl invites. The failure is silent (no
error, a page that renders without its chrome) and cache-resident, so it persists for the
whole s-maxage window and can be served to many visitors.
The framework already reasons correctly about this for its OWN cache: the #241 server HTML
cache excludes any request carrying x-webjs-have, so a reduced body is never stored under
the URL-only key (ssr.js, the cacheEligible guard). The gap is that the same reasoning was
not applied to what SHARED caches are told.
Design / approach
A reduced fragment is per-client-state by construction, so it should never be shared-cacheable
at all. Rather than depending on Vary being honored, override Cache-Control on the reduced
and frame-subtree paths to a value no shared cache can store, while leaving the full-document
response's public caching exactly as it is.
Keep the Vary headers as belt-and-braces for caches that do honor them; the point is that
correctness must not DEPEND on them.
The value to send needs a small decision. private alone permits a browser-local store, which
is fine and arguably desirable (the client router's own snapshot cache already dedupes these,
and #1131 made router fetches revalidate). no-store is the maximally safe option. Prefer private plus the existing freshness directives unless a reason emerges to forbid the local
store too, since these responses are exactly the ones a returning client can reuse.
Do NOT solve this by dropping the public cacheControl from pages: full-document edge caching
is the win the header exists for, and it is safe (the SSR response is cookieless, since action
CSRF is an Origin / Sec-Fetch-Site check).
Implementation notes (for the implementing agent)
Where to edit
packages/server/src/ssr.js, the reduced-response site where Vary: X-Webjs-Have is
appended (search for if (reduced) res.headers.append('vary', 'X-Webjs-Have')). The Cache-Control override belongs beside it, gated on the same reduced flag.
The same file's frame path, where Vary: X-Webjs-Frame is appended to frameRes and Vary: X-Webjs-Have is added on top when the subtree came from a reduced render. A frame
subtree is sliced by a request header too, so it needs the identical treatment.
The reduced flag itself originates in the layout-chain walk (the return { html: ..., reduced: true }
short-circuit). No change needed there; it is context for why the flag exists.
A full-document response must keep its public Cache-Control byte-for-byte. The whole
point of dogfood: webjs.dev pages are never cached at any layer #1127 was to make those edge-cacheable; a blanket downgrade would silently undo it.
Assert the unchanged case, not just the changed one.
Conditional GET interacts here: packages/server/src/conditional-get.jsisCacheable()
treats a private response as NOT eligible for an ETag, so switching to private removes
the validator from fragment responses. That is acceptable (the client router revalidates its
own fetches per fix: a browser-cached page hides a deploy from the client router #1131 and dedupes via its snapshot cache), but it IS a behaviour change to
verify rather than discover.
The client router sends x-webjs-router: 1 on its fetches; that header is not what triggers
the reduced path (x-webjs-have is), so do not key the fix off it.
Invariants to respect
A shared cache must never be able to serve a partial fragment to a full-page navigation.
After this change that must hold even if Vary is ignored entirely.
packages/ is plain .js with JSDoc.
Tests + docs surfaces
Integration through createRequestHandler: assert a request WITH x-webjs-have that
produces a reduced body comes back non-shared-cacheable, and that the same URL WITHOUT the
header keeps its public Cache-Control unchanged. Same pair for a <webjs-frame> request.
Counterfactual: the test must fail when the override is removed.
references/client-router-and-streaming.md documents the Vary: X-Webjs-Have contract and
should state that fragments are additionally non-shared-cacheable, so the guarantee does not
read as depending on Vary.
website/app/docs/cache/page.ts is the user-facing caching page; a sentence that metadata.cacheControl is safe to use behind a CDN belongs there, since that is the
question this bug would otherwise raise.
Acceptance criteria
A reduced X-Webjs-Have response cannot be stored by a shared cache, independent of Vary
A <webjs-frame> subtree response gets the same treatment
A full-document response's Cache-Control is byte-identical to before
The existing Vary headers are still sent
A counterfactual proves the new tests fail when the override is removed
The client-router reference and the docs-site caching page state the guarantee
Problem
A reduced (
X-Webjs-Have) fragment and a<webjs-frame>subtree are served with the PAGE'sCache-Control, so on a page that opted into public caching they are marked shared-cacheable.Their safety then rests entirely on the CDN honoring
Vary. Cloudflare ignoresVaryexcept
Accept-Encoding, and so do several other shared caches, so a CDN can store achrome-less fragment under the page URL and serve it to a fresh full-page navigation.
Measured on the live site (webjs.dev origin,
/docs/ai-first):Both responses carry the identical public
Cache-Control. The only thing standing between avisitor and a 47KB fragment rendered as a whole page is a
Varyheader that Cloudflaredocuments as ignored.
This is latent rather than live today only because Cloudflare does not cache HTML by default,
so webjs.dev sits at
cf-cache-status: DYNAMIC. It becomes live the moment an app does theordinary thing: put a WebJs app behind Cloudflare and turn on HTML caching, which is a normal
performance step and exactly what
metadata.cacheControlinvites. The failure is silent (noerror, a page that renders without its chrome) and cache-resident, so it persists for the
whole
s-maxagewindow and can be served to many visitors.The framework already reasons correctly about this for its OWN cache: the #241 server HTML
cache excludes any request carrying
x-webjs-have, so a reduced body is never stored underthe URL-only key (
ssr.js, thecacheEligibleguard). The gap is that the same reasoning wasnot applied to what SHARED caches are told.
Design / approach
A reduced fragment is per-client-state by construction, so it should never be shared-cacheable
at all. Rather than depending on
Varybeing honored, overrideCache-Controlon the reducedand frame-subtree paths to a value no shared cache can store, while leaving the full-document
response's public caching exactly as it is.
Keep the
Varyheaders as belt-and-braces for caches that do honor them; the point is thatcorrectness must not DEPEND on them.
The value to send needs a small decision.
privatealone permits a browser-local store, whichis fine and arguably desirable (the client router's own snapshot cache already dedupes these,
and #1131 made router fetches revalidate).
no-storeis the maximally safe option. Preferprivateplus the existing freshness directives unless a reason emerges to forbid the localstore too, since these responses are exactly the ones a returning client can reuse.
Do NOT solve this by dropping the public
cacheControlfrom pages: full-document edge cachingis the win the header exists for, and it is safe (the SSR response is cookieless, since action
CSRF is an Origin /
Sec-Fetch-Sitecheck).Implementation notes (for the implementing agent)
Where to edit
packages/server/src/ssr.js, the reduced-response site whereVary: X-Webjs-Haveisappended (search for
if (reduced) res.headers.append('vary', 'X-Webjs-Have')). TheCache-Controloverride belongs beside it, gated on the samereducedflag.Vary: X-Webjs-Frameis appended toframeResandVary: X-Webjs-Haveis added on top when the subtree came from a reduced render. A framesubtree is sliced by a request header too, so it needs the identical treatment.
return { html: ..., reduced: true }short-circuit). No change needed there; it is context for why the flag exists.
Landmines
cacheEligibleexcludes
x-webjs-haverequests). Do not conflate the two or "fix" it twice.Cache-Controlbyte-for-byte. The wholepoint of dogfood: webjs.dev pages are never cached at any layer #1127 was to make those edge-cacheable; a blanket downgrade would silently undo it.
Assert the unchanged case, not just the changed one.
packages/server/src/conditional-get.jsisCacheable()treats a
privateresponse as NOT eligible for an ETag, so switching toprivateremovesthe validator from fragment responses. That is acceptable (the client router revalidates its
own fetches per fix: a browser-cached page hides a deploy from the client router #1131 and dedupes via its snapshot cache), but it IS a behaviour change to
verify rather than discover.
x-webjs-router: 1on its fetches; that header is not what triggersthe reduced path (
x-webjs-haveis), so do not key the fix off it.Invariants to respect
After this change that must hold even if
Varyis ignored entirely.packages/is plain.jswith JSDoc.Tests + docs surfaces
createRequestHandler: assert a request WITHx-webjs-havethatproduces a reduced body comes back non-shared-cacheable, and that the same URL WITHOUT the
header keeps its public
Cache-Controlunchanged. Same pair for a<webjs-frame>request.references/client-router-and-streaming.mddocuments theVary: X-Webjs-Havecontract andshould state that fragments are additionally non-shared-cacheable, so the guarantee does not
read as depending on
Vary.website/app/docs/cache/page.tsis the user-facing caching page; a sentence thatmetadata.cacheControlis safe to use behind a CDN belongs there, since that is thequestion this bug would otherwise raise.
Acceptance criteria
X-Webjs-Haveresponse cannot be stored by a shared cache, independent ofVary<webjs-frame>subtree response gets the same treatmentCache-Controlis byte-identical to beforeVaryheaders are still sent