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 deploy does not invalidate the CDN copy of an app's stylesheet, so a freshly deployed
page can render against CSS built before the deploy. Caught in production: after #1089
shipped /what-is-webjs, the live page rendered with content edge to edge, the capability
grid collapsed to one column, and unpadded FAQ rows.
Verified root cause, not inferred:
curl -sSI https://webjs.dev/public/tailwind.css
cache-control: public, max-age=14400
cf-cache-status: HIT
age: 3146
The cached body is missingmax-w-[62ch], max-w-[14ch], min-[700px]:grid-cols-2, min-[1000px]:grid-cols-3, marker:content-none. The same URL with a cache-busting
query returns a body containing all of them. So the origin is correct and the edge copy
is stale.
The reason is that the stylesheet is referenced at an un-versioned URL. Every in-repo
app writes it by hand:
The framework already solves exactly this for the URLs it emits itself. asset-hash.js
(#243) appends ?v=<content-hash> to framework-emitted same-origin URLs (importmap
targets, modulepreload hrefs, boot specifiers) and dev.js serves any ?v-carrying
request immutable. Author-written <link> hrefs in a layout template are hoisted into <head> by wrapHead but are never fingerprinted, so they fall back to the
un-versioned 1h/4h cache and go stale across a deploy.
This is not website-specific. Every scaffolded app writes the same hand-authored
stylesheet link, so every WebJs app on a CDN has the same deploy-staleness bug. It is
mostly invisible when a deploy only changes existing classes (the old CSS still covers
them) and maximally visible when a deploy adds a page using new arbitrary-value utilities,
which is exactly what happened here.
Design / approach
Extend the existing #243 fingerprinting to author-written same-origin asset links in the
SSR-hoisted <head>, rather than inventing a second mechanism.
withAssetHash is already the right primitive: it is a no-op in dev, a no-op for
cross-origin URLs, and resolves a same-origin root-absolute URL to a file, hashes its
bytes, and appends ?v=. Applying it to a hoisted <link rel="stylesheet" href="/...">
and <link rel="icon" href="/..."> gives deploy-busting for free and upgrades those assets
from a 1h TTL to immutable, which is strictly better caching as well as correct.
Alternatives considered and rejected:
A per-deploy env var (?v=$COMMIT_SHA) in each layout. Railway does not expose a commit
SHA to these services (checked: the variable set has no RAILWAY_GIT_COMMIT_SHA), it
would need wiring per host, and it leaves every scaffolded app broken by default.
Reading the file hash in the layout. A layout module loads in the browser, so it cannot
import node:fs.
Lowering the TTL. Shrinks the window without fixing it, and gives up caching.
Implementation notes (for the implementing agent)
Where to edit:
packages/server/src/ssr.jswrapHead, where hoisted <link> tags are assembled into <head>. This is the same function that already applies fp() / withAssetHash to
framework-emitted modulepreload hrefs and boot specifiers, so the helper is already in
scope. Apply it to hoisted <link> hrefs that are same-origin and root-absolute.
packages/server/src/asset-hash.jswithAssetHash needs no change: it already no-ops on
cross-origin, protocol-relative, relative, and unresolvable URLs, and already composes
with withBasePath.
Landmines / gotchas:
Dev must stay byte-identical. Fingerprinting is disabled in dev (setAssetRoots is
passed enabled: !dev), so withAssetHash is already a pure no-op there. Preserve that:
there is an explicit invariant plus tests that dev output is unchanged.
Do not fingerprint a cross-origin <link>. A CDN font or stylesheet must keep its
exact URL (SRI in Emit SRI integrity for live-resolved (unpinned) vendor imports #235 is keyed on the un-hashed URL). withAssetHash already guards
this, so route through it rather than string-concatenating a query.
public/tailwind.css is gitignored and built at deploy via webjs.start.before, so
the file exists at serve time but not in the repo. The hash is computed at serve time
from bytes on disk, which is exactly right, but any test must generate the file first.
Do not fingerprint a URL that already carries a query. Check for an existing ?.
An already-version-named /__webjs/vendor/* bundle is deliberately left alone by withAssetHash; do not regress that.
The ?v serve path already exists (fileResponse's immutable flag in dev.js), so no
serve-side change should be needed. Confirm rather than assume.
Invariants to respect:packages/server/AGENTS.md invariant on dev byte-identity for
the asset-hash feature, and the framework-root rule that packages/ is plain .js with
JSDoc (no .ts).
Tests + docs surfaces:
Unit: packages/server/test/ (an asset-hash or ssr head test) asserting a hoisted
same-origin <link rel="stylesheet"> gains ?v=<hash>, a cross-origin one does not, and
dev output is unchanged.
A counterfactual: changing the stylesheet's bytes must change the emitted ?v.
E2E or in-repo app check that /public/tailwind.css?v=... is served immutable.
Docs: references/built-ins.md (the caching section) and any docs-site page describing ?v asset hashing, since this widens what gets fingerprinted.
Acceptance criteria
A hoisted author-written same-origin <link rel="stylesheet" href="/public/...">
is emitted with ?v=<content-hash> in prod
Changing the stylesheet bytes changes the emitted hash (deploy-busts)
A cross-origin <link> href is left byte-identical
Dev output is byte-identical to before the change
The fingerprinted URL is served Cache-Control: public, max-age=31536000, immutable
A counterfactual proves the deploy-bust test actually fires
Docs updated for the widened fingerprinting surface
Problem
A deploy does not invalidate the CDN copy of an app's stylesheet, so a freshly deployed
page can render against CSS built before the deploy. Caught in production: after #1089
shipped
/what-is-webjs, the live page rendered with content edge to edge, the capabilitygrid collapsed to one column, and unpadded FAQ rows.
Verified root cause, not inferred:
The cached body is missing
max-w-[62ch],max-w-[14ch],min-[700px]:grid-cols-2,min-[1000px]:grid-cols-3,marker:content-none. The same URL with a cache-bustingquery returns a body containing all of them. So the origin is correct and the edge copy
is stale.
The reason is that the stylesheet is referenced at an un-versioned URL. Every in-repo
app writes it by hand:
website/app/layout.tsL162<link rel="stylesheet" href="/public/tailwind.css">docs/app/layout.tsL100 (same)packages/ui/packages/website/app/layout.tsL77 (same)The framework already solves exactly this for the URLs it emits itself.
asset-hash.js(#243) appends
?v=<content-hash>to framework-emitted same-origin URLs (importmaptargets, modulepreload hrefs, boot specifiers) and
dev.jsserves any?v-carryingrequest
immutable. Author-written<link>hrefs in a layout template are hoisted into<head>bywrapHeadbut are never fingerprinted, so they fall back to theun-versioned 1h/4h cache and go stale across a deploy.
This is not website-specific. Every scaffolded app writes the same hand-authored
stylesheet link, so every WebJs app on a CDN has the same deploy-staleness bug. It is
mostly invisible when a deploy only changes existing classes (the old CSS still covers
them) and maximally visible when a deploy adds a page using new arbitrary-value utilities,
which is exactly what happened here.
Design / approach
Extend the existing #243 fingerprinting to author-written same-origin asset links in the
SSR-hoisted
<head>, rather than inventing a second mechanism.withAssetHashis already the right primitive: it is a no-op in dev, a no-op forcross-origin URLs, and resolves a same-origin root-absolute URL to a file, hashes its
bytes, and appends
?v=. Applying it to a hoisted<link rel="stylesheet" href="/...">and
<link rel="icon" href="/...">gives deploy-busting for free and upgrades those assetsfrom a 1h TTL to
immutable, which is strictly better caching as well as correct.Alternatives considered and rejected:
?v=$COMMIT_SHA) in each layout. Railway does not expose a commitSHA to these services (checked: the variable set has no
RAILWAY_GIT_COMMIT_SHA), itwould need wiring per host, and it leaves every scaffolded app broken by default.
import
node:fs.Implementation notes (for the implementing agent)
Where to edit:
packages/server/src/ssr.jswrapHead, where hoisted<link>tags are assembled into<head>. This is the same function that already appliesfp()/withAssetHashtoframework-emitted modulepreload hrefs and boot specifiers, so the helper is already in
scope. Apply it to hoisted
<link>hrefs that are same-origin and root-absolute.packages/server/src/asset-hash.jswithAssetHashneeds no change: it already no-ops oncross-origin, protocol-relative, relative, and unresolvable URLs, and already composes
with
withBasePath.Landmines / gotchas:
setAssetRootsispassed
enabled: !dev), sowithAssetHashis already a pure no-op there. Preserve that:there is an explicit invariant plus tests that dev output is unchanged.
<link>. A CDN font or stylesheet must keep itsexact URL (SRI in Emit SRI integrity for live-resolved (unpinned) vendor imports #235 is keyed on the un-hashed URL).
withAssetHashalready guardsthis, so route through it rather than string-concatenating a query.
public/tailwind.cssis gitignored and built at deploy viawebjs.start.before, sothe file exists at serve time but not in the repo. The hash is computed at serve time
from bytes on disk, which is exactly right, but any test must generate the file first.
?./__webjs/vendor/*bundle is deliberately left alone bywithAssetHash; do not regress that.?vserve path already exists (fileResponse'simmutableflag indev.js), so noserve-side change should be needed. Confirm rather than assume.
Invariants to respect:
packages/server/AGENTS.mdinvariant on dev byte-identity forthe asset-hash feature, and the framework-root rule that
packages/is plain.jswithJSDoc (no
.ts).Tests + docs surfaces:
packages/server/test/(an asset-hash or ssr head test) asserting a hoistedsame-origin
<link rel="stylesheet">gains?v=<hash>, a cross-origin one does not, anddev output is unchanged.
?v./public/tailwind.css?v=...is servedimmutable.references/built-ins.md(the caching section) and any docs-site page describing?vasset hashing, since this widens what gets fingerprinted.Acceptance criteria
<link rel="stylesheet" href="/public/...">is emitted with
?v=<content-hash>in prod<link>href is left byte-identicalCache-Control: public, max-age=31536000, immutable