Skip to content

feat: content-hash author-written asset urls so they cannot go stale #1194

Description

@vivek7405

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.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.js withAssetHash / versionModuleImports).
What it does not touch is a url an author writes by hand in a template, for
example website/app/layout.ts L232:

<link rel="stylesheet" href="/public/tailwind.css">

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> (&lt;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.
    • webjs.basePath (Support basePath/assetPrefix for sub-path deployments #256): apply basePath BEFORE hashing, matching
      ssr.js L1343 and the comment block at asset-hash.js L229.
    • 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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions