Skip to content

fix: fingerprint author-written asset links so a deploy busts the CDN copy #1095

Description

@vivek7405

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 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 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-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:

  • website/app/layout.ts L162 <link rel="stylesheet" href="/public/tailwind.css">
  • docs/app/layout.ts L100 (same)
  • packages/ui/packages/website/app/layout.ts L77 (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 (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.js wrapHead, 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.js withAssetHash 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

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions