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
Author-written asset urls in an app's own markup never get the framework's
content hash, so they sit at STABLE urls and go stale behind a CDN. On
webjs.dev this caused two visible regressions in one day (a pre-redesign tailwind.css after #1179, then the un-fixed logo marks after #1185), and it
is the reason the purge workflow (#1188, #1192) exists at all. That workflow is
a workaround for this issue, not a fix.
The framework already fingerprints what IT emits: module specifiers, importmap
targets, and modulepreload hints all get ?v=<content-hash> (#243, see packages/server/src/asset-hash.jswithAssetHash / versionModuleImports).
What it does not touch is a url an author writes by hand in a template, for
example website/app/layout.ts L232:
The serving half is already fully built. Verified against the live origin:
Request
Cache-Control
/public/tailwind.css
public, max-age=3600
/public/tailwind.css?v=abc123
public, max-age=31536000, immutable
packages/server/src/dev.js L2020 already passes immutable: versioned for
the /public/ branch, so a fingerprinted url is served immutable for a year
today. Only the EMISSION is missing. Closing that gap therefore buys two things
at once: staleness becomes structurally impossible (new bytes mean a new url),
and these assets go from a 1 hour cache to a 1 year immutable one.
Design / approach
The blocker is WHERE the hash gets applied, and there is a real constraint that
rules out the obvious answer.
Rejected: export a helper for apps to call.withAssetHash lives in @webjsdev/server, which is not re-exported from packages/server/index.js
today. Exporting an assetUrl() and calling it from app/layout.ts would put
a server-only import inside a layout, which is invariant 1 and would trip no-server-import-in-browser-module the moment that layout ships to the
browser. It also puts the burden on every author to remember the call, which is
exactly the failure mode that produced this issue.
Preferred: fingerprint author-written asset urls in the SSR'd HTML. The
framework already rewrites author-written MODULE specifiers in served source
(versionModuleImports, added for #369 for the same "the author wrote a bare
url and it missed the fingerprint" reason), so extending the same treatment to
asset urls in emitted HTML is the established pattern rather than a new one. It
requires no app change, no new import, and no author discipline.
Scope it tightly to attribute positions, not raw text:
<link href="...">, <script src="...">, <img src="...">, and the <source srcset> / <use href> cases if cheap.
Only same-origin ROOT-ABSOLUTE urls that resolve to a readable file under the
public root, reusing withAssetHash's existing guards (it already no-ops on
cross-origin, protocol-relative, relative, unresolvable, and when
fingerprinting is disabled, which keeps dev output byte-identical).
Leave a url that already carries a query alone.
The landmine that makes the tight scope mandatory: this site RENDERS CODE
SAMPLES, including ones that show markup. A naive text substitution over the
HTML would rewrite the sample source on /docs and /brand and lie to the
reader. Sample code is emitted as escaped text inside <pre><code> (<link
rather than <link), so an attribute-position match cannot reach it, but any
implementation MUST have a test proving a rendered code sample is untouched.
Implementation notes (for the implementing agent)
Where to edit:
packages/server/src/asset-hash.js: add the HTML rewriting function
beside versionModuleImports (which is the closest existing analogue,
around L396, and shows the redaction-mask discipline used for the module
case). withAssetHash (L249) is the per-url primitive to reuse; it already
handles base path, resolution under the app dir, and every no-op case.
packages/server/src/ssr.js: apply it to the assembled document. ssr.js
already imports withAssetHash (L6) and composes basePath then hash via fp (L1343), so follow that same order.
packages/server/index.js: only if a public export turns out to be needed.
Prefer not to widen the public surface.
Verify the site's own consumers pick it up with no app change: website/app/layout.ts (the stylesheet L232, plus the preloaded fonts, the
og image, and the favicon links) and website/lib/design/brand.ts (the
logo <img> variants, which are the assets that actually went stale).
Landmines:
Rendered code samples must not be rewritten (see above). Non-negotiable
test.
Fingerprinting is PROD-only. In dev withAssetHash is a no-op by design so
dev output stays byte-identical; any test must enable it explicitly via setAssetRoots rather than assuming.
The website sets "webjs": { "seed": false }; unrelated, leave it.
Do NOT change the /public/ cache policy in dev.js. It is already
correct: immutable: versioned at L2020, verified live.
Invariants: invariant 1 (no server-only import in a layout) is the reason the
helper approach is rejected. Framework source is plain .js with JSDoc, never .ts.
Tests + docs:
Unit, packages/server/test/**: a fingerprint-enabled SSR render emits /public/x.css?v=<hash> for an authored <link>; the hash changes when the
file's bytes change; a cross-origin and an already-queried url are
untouched; a rendered code sample containing markup is untouched; a
counterfactual proving each fires.
Browser/e2e are not obviously implicated (no client behaviour changes), but
confirm the site still boots and the stylesheet resolves.
Docs: framework-dev.md's "CDN cache" section currently says these urls are
NOT fingerprinted and that the purge covers them; that becomes stale. references/built-ins.md documents the ?v= caching story (Content-hash asset URLs for immutable cache, add preconnect hints #243) and needs
the author-written case added. AGENTS.md mentions content-hash asset urls in
the caching bullet.
The purge workflow (.github/workflows/purge-cdn.yml) should STAY: it is
still the safety net for anything on a stable url. Note in its comments
that the main offenders are now fingerprinted.
Acceptance criteria
An author-written <link href="/public/x.css"> in a layout is served as /public/x.css?v=<hash> in prod, with no app-side change
The hash changes when the file's bytes change, so a deploy cannot serve
stale bytes at a live url
Dev output is byte-identical to today (fingerprinting stays prod-only)
A rendered code sample showing markup is provably NOT rewritten
Cross-origin, relative, already-queried, and unresolvable urls untouched
webjs.basePath composes correctly (basePath then hash)
Tests at the unit layer with counterfactuals; webjs check clean
framework-dev.md, references/built-ins.md, and AGENTS.md updated
Problem
Author-written asset urls in an app's own markup never get the framework's
content hash, so they sit at STABLE urls and go stale behind a CDN. On
webjs.dev this caused two visible regressions in one day (a pre-redesign
tailwind.cssafter #1179, then the un-fixed logo marks after #1185), and itis the reason the purge workflow (#1188, #1192) exists at all. That workflow is
a workaround for this issue, not a fix.
The framework already fingerprints what IT emits: module specifiers, importmap
targets, and modulepreload hints all get
?v=<content-hash>(#243, seepackages/server/src/asset-hash.jswithAssetHash/versionModuleImports).What it does not touch is a url an author writes by hand in a template, for
example
website/app/layout.tsL232:The serving half is already fully built. Verified against the live origin:
Cache-Control/public/tailwind.csspublic, max-age=3600/public/tailwind.css?v=abc123public, max-age=31536000, immutablepackages/server/src/dev.jsL2020 already passesimmutable: versionedforthe
/public/branch, so a fingerprinted url is served immutable for a yeartoday. Only the EMISSION is missing. Closing that gap therefore buys two things
at once: staleness becomes structurally impossible (new bytes mean a new url),
and these assets go from a 1 hour cache to a 1 year immutable one.
Design / approach
The blocker is WHERE the hash gets applied, and there is a real constraint that
rules out the obvious answer.
Rejected: export a helper for apps to call.
withAssetHashlives in@webjsdev/server, which is not re-exported frompackages/server/index.jstoday. Exporting an
assetUrl()and calling it fromapp/layout.tswould puta server-only import inside a layout, which is invariant 1 and would trip
no-server-import-in-browser-modulethe moment that layout ships to thebrowser. It also puts the burden on every author to remember the call, which is
exactly the failure mode that produced this issue.
Preferred: fingerprint author-written asset urls in the SSR'd HTML. The
framework already rewrites author-written MODULE specifiers in served source
(
versionModuleImports, added for #369 for the same "the author wrote a bareurl and it missed the fingerprint" reason), so extending the same treatment to
asset urls in emitted HTML is the established pattern rather than a new one. It
requires no app change, no new import, and no author discipline.
Scope it tightly to attribute positions, not raw text:
<link href="...">,<script src="...">,<img src="...">, and the<source srcset>/<use href>cases if cheap.public root, reusing
withAssetHash's existing guards (it already no-ops oncross-origin, protocol-relative, relative, unresolvable, and when
fingerprinting is disabled, which keeps dev output byte-identical).
The landmine that makes the tight scope mandatory: this site RENDERS CODE
SAMPLES, including ones that show markup. A naive text substitution over the
HTML would rewrite the sample source on
/docsand/brandand lie to thereader. Sample code is emitted as escaped text inside
<pre><code>(<linkrather than
<link), so an attribute-position match cannot reach it, but anyimplementation MUST have a test proving a rendered code sample is untouched.
Implementation notes (for the implementing agent)
packages/server/src/asset-hash.js: add the HTML rewriting functionbeside
versionModuleImports(which is the closest existing analogue,around L396, and shows the redaction-mask discipline used for the module
case).
withAssetHash(L249) is the per-url primitive to reuse; it alreadyhandles base path, resolution under the app dir, and every no-op case.
packages/server/src/ssr.js: apply it to the assembled document.ssr.jsalready imports
withAssetHash(L6) and composes basePath then hash viafp(L1343), so follow that same order.packages/server/index.js: only if a public export turns out to be needed.Prefer not to widen the public surface.
website/app/layout.ts(the stylesheet L232, plus the preloaded fonts, theog image, and the favicon links) and
website/lib/design/brand.ts(thelogo
<img>variants, which are the assets that actually went stale).test.
webjs.basePath(Support basePath/assetPrefix for sub-path deployments #256): apply basePath BEFORE hashing, matchingssr.jsL1343 and the comment block atasset-hash.jsL229.withAssetHashis a no-op by design sodev output stays byte-identical; any test must enable it explicitly via
setAssetRootsrather than assuming."webjs": { "seed": false }; unrelated, leave it./public/cache policy indev.js. It is alreadycorrect:
immutable: versionedat L2020, verified live.helper approach is rejected. Framework source is plain
.jswith JSDoc, never.ts.packages/server/test/**: a fingerprint-enabled SSR render emits/public/x.css?v=<hash>for an authored<link>; the hash changes when thefile's bytes change; a cross-origin and an already-queried url are
untouched; a rendered code sample containing markup is untouched; a
counterfactual proving each fires.
confirm the site still boots and the stylesheet resolves.
framework-dev.md's "CDN cache" section currently says these urls areNOT fingerprinted and that the purge covers them; that becomes stale.
references/built-ins.mddocuments the?v=caching story (Content-hash asset URLs for immutable cache, add preconnect hints #243) and needsthe author-written case added. AGENTS.md mentions content-hash asset urls in
the caching bullet.
.github/workflows/purge-cdn.yml) should STAY: it isstill the safety net for anything on a stable url. Note in its comments
that the main offenders are now fingerprinted.
Acceptance criteria
<link href="/public/x.css">in a layout is served as/public/x.css?v=<hash>in prod, with no app-side changestale bytes at a live url
webjs.basePathcomposes correctly (basePath then hash)webjs checkcleanframework-dev.md,references/built-ins.md, and AGENTS.md updated