Skip to content

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

Closed
vivek7405 wants to merge 5 commits into
mainfrom
feat/fingerprint-authored-assets
Closed

feat: content-hash author-written asset urls so they cannot go stale#1196
vivek7405 wants to merge 5 commits into
mainfrom
feat/fingerprint-authored-assets

Conversation

@vivek7405

Copy link
Copy Markdown
Collaborator

Summary

An app author's own asset urls never got the framework's content hash, so they sat at stable urls and went stale behind a CDN. That is what shipped two visible regressions on webjs.dev in one day (pre-redesign tailwind.css after #1179, un-fixed logo marks after #1185), and it is why the purge workflow exists.

The framework already fingerprints everything it emits, and versionModuleImports does the same for author-written module specifiers (#369). This closes the last gap: a <link> or <img> written by hand in a layout.

The serving half was already built. Verified live against the origin:

Request Cache-Control
/public/tailwind.css public, max-age=3600
/public/tailwind.css?v=abc123 public, max-age=31536000, immutable

So this makes staleness structurally impossible and upgrades these assets from a 1 hour cache to an immutable one.

Why the matcher is anchored on a tag name

This site renders code samples of markup. A bare src="…" match would rewrite the code a reader is looking at, on /docs and /brand. A highlighted sample escapes < and splits the token across spans, so a literal <img never appears in one and cannot be reached. That property is pinned by a test, because the whole approach rests on it.

Where it hooks in

buildDocumentParts in ssr.js, the one seam every response path funnels through (buffered, streamed, buildDocument), so no call site can be missed. Both branches are covered, including the user-supplied shell the website itself uses.

basePath is a parameter, not module state: a setter a call site forgets fails silently and only on a sub-path deploy, the hardest place to notice.

Test plan

  • 12 tests: transform behaviour, hash agreement with withAssetHash, hash changes with bytes, dev no-op, cross-origin / relative / already-queried / unresolvable untouched, non-asset <a href> untouched, single quotes
  • Code samples provably untouched, both escaped and the real tokenized shape the highlighter emits
  • SSR wiring tested on both buildDocumentParts branches
  • Counterfactuals at both layers: neutering the transform fails the 5 positive unit assertions; neutering the wiring fails the 2 integration assertions
  • Full root suite: failing-test names byte-identical with and without this change (69 pre-existing env failures in a worktree without a built dist, unchanged)
  • webjs check clean

Docs

framework-dev.md said these urls were NOT fingerprinted, now the opposite of the truth. built-ins.md already claimed every public/ asset got ?v=, which was inaccurate before and is accurate now, but also overbroad: an asset reached from a stylesheet url(), a srcset list, or a runtime-built string still keeps the 1h fallback. Both corrected. The purge workflow stays as the safety net for exactly those cases.

Refs #1194

An app author's own asset urls never got the framework's content hash, so
they sat at stable urls and went stale behind a CDN. That is what shipped
two visible regressions on webjs.dev 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 exists at all.

The framework already fingerprints everything IT emits (module
specifiers, importmap targets, modulepreload hints), and
versionModuleImports does the same for author-written module specifiers
in served source. This closes the last gap: a <link> or <img> written by
hand in a layout.

The serving half was already built. dev.js serves a `?v=`-carrying
/public/ request `immutable` for a year while an un-versioned one gets
the 1h fallback, verified live against the origin. So this both makes
staleness structurally impossible (new bytes mean a new url) and
upgrades these assets from a 1 hour cache to an immutable one.

Applied in buildDocumentParts, the one seam every response path funnels
through (buffered, streamed, and buildDocument), so no call site can be
missed. Both branches are covered, including the user-supplied shell
that the website itself uses.

The matcher is anchored on a literal tag name, and that is load-bearing
rather than incidental: this site RENDERS CODE SAMPLES of markup, and a
bare `src="…"` match would rewrite the code a reader is looking at. A
highlighted sample escapes `<` and splits the token across spans, so a
literal `<img` never appears in one and cannot be reached. Tests pin
that, since it is the property the whole approach rests on.

Per-url behaviour delegates to withAssetHash, so every existing guard
holds unchanged: a no-op in dev (output stays byte-identical), and for
cross-origin, protocol-relative, relative, and unresolvable urls. An
author-supplied query is left alone.

basePath is a parameter rather than module state on purpose: a setter a
call site forgets fails silently and only on a sub-path deploy, the
hardest place to notice it.

Counterfactuals verified both layers: neutering the transform fails the
5 positive unit assertions, and neutering the ssr.js wiring fails the 2
integration assertions.

Refs #1194
framework-dev.md said these urls were NOT fingerprinted and that the CDN
purge was what covered them, which is now the opposite of the truth.

built-ins.md already CLAIMED every public/ asset got a `?v=` hash in
production. That was inaccurate before this change (an author-written
url never reached the fingerprinter), and it is accurate now, but it was
also overbroad in a way worth fixing: an asset reached from a stylesheet
`url()`, a srcset candidate list, or a runtime-built string still keeps
its plain path and the 1h fallback. Say which references are covered
rather than implying all of them are.

Both notes keep the purge workflow's justification intact, since it
remains the safety net for exactly those uncovered cases.

The skill lives canonically once at the repo-root .agents/skills/webjs/
(see create.js L643), so a scaffolded app inherits this with no second
copy to sync.

Refs #1194
Review found a coverage gap. A streamed boundary's HTML is enqueued
straight onto the response stream and never passes through
buildDocumentParts, so the SAME `<img src="/public/logo.svg">` picked up
`?v=` when it rendered in the shell but not when it resolved inside a
`<webjs-suspense>`. Nothing broke (a streamed asset kept today's 1h
cache and stayed purge-covered), but behaviour differing by WHERE a
component happens to render is a difference no author could predict
from the code they wrote.

Apply the same transform to the boundary chunk. It is the identical
function with the identical guards, and it is naturally idempotent
because a url already carrying `?v=` is skipped, so a chunk that somehow
passed through twice is unchanged.

Also measured the cost while reviewing, since this runs over the whole
document on every render: 0.22ms at 50KB, 0.60ms at 500KB, and 1.10ms at
1.1MB (the size the changelog page actually reaches). Not a concern.

No new failures against the clean-tree baseline (69 pre-existing
environmental failures, name-for-name identical).

Refs #1194
Deep review found three confirmed defects in this change, all jury-backed
and all reproduced here before fixing.

SECURITY, the serious one. `versionAssetUrls` runs over the whole
rendered document, and an app's markup can carry attacker-controlled
data: `<img src=${user.avatarUrl}>`, a markdown image, a CMS-configured
path. `resolveUrlToFile` maps ANY same-origin path under the project
root and checks containment against that root only, which was harmless
while it saw framework-emitted urls exclusively. Fed author urls, it
turned one attribute into a file-existence and content-fingerprint
oracle: `<img src="/.env">` came back `/.env?v=<hash>`, publishing both
that the file exists and a stable fingerprint of its exact bytes (so a
rotation, or a guessed value, is detectable) for files the serve path
deliberately 404s. Reproduced against /.env, /db/app.db, and a
.server.ts source. It was also a synchronous read of an
attacker-chosen file on the render path.

Mirror the serve gate from dev.js instead: under public/, plus the
root-served remaps, refusing traversal. The check is a pure string test
run BEFORE any resolution, so a rejected path never touches the disk.

FRAGMENTS. The guard skipped a url containing `?` but not `#`, so
`/x.svg#logo` became `/x.svg#logo?v=H`. The browser then requested
`/x.svg` with no query, silently losing the immutable caching this
exists for, and the fragment became `logo?v=H`, matching no element id.
SVG sprites and media fragments are both real. Split the fragment, hash
the path, re-append.

FRAMES. The `x-webjs-frame` branch slices the raw render output and
returns before buildDocumentParts, so the same `<img>` shipped versioned
on a full load and un-versioned on a frame nav, and the frame swap wrote
the stable url over the fingerprinted one in the live DOM. That
re-opened the exact staleness this change closes, and broke
frame-render.js's byte-equivalence invariant. Same shape as the streamed
Suspense gap already fixed here, and missed for the same reason.

Each fix has a regression test with a verified counterfactual, and the
full suite is failure-for-failure identical to the clean-tree baseline.

Refs #1194
Deep review round 2 found three more confirmed defects, all reproduced
here before fixing.

CUSTOM ELEMENTS. `ASSET_TAG_RE` ended in `\b`, which treats `-` as a
word boundary, so it also matched `<img-zoom>`, `<link-preview>`,
`<source-map>`, `<script-editor>`. This framework is web-components-
first, so those are ordinary component names and their `src` / `href` is
a reactive PROP, meaning author data. Rewriting it changed the value the
component hydrated with: `this.src.endsWith('.svg')` went false, an
equality check against the path a server action returned stopped
matching, and the value churned every deploy. Use `(?![-\w])`.

PRELOAD HINTS. A `<link rel=preload>` does not fetch the asset, it warms
the cache for a request some OTHER consumer makes, and that cache is
keyed on the full url including the query. The website preloads three
self-hosted fonts whose real request comes from `@font-face url()` in
the stylesheet, and CSS `url()` is deliberately out of scope here, so
versioning the hint guaranteed a miss: each font fetched twice, with the
browser logging "preloaded but not used", turning the optimization at
layout.ts into a pessimization on the next deploy. Skip hint rels.

ROOT-SERVED PATHS. The gate admitted `/favicon.ico`, `/sw.js`, and
`/offline.html` to mirror the serve path, but the serve path REMAPS them
into `public/` and `resolveUrlToFile` does not. With a stray
`favicon.ico` at the project root, `/favicon.ico` was stamped with the
hash of the ROOT file while the server served `public/favicon.ico`'s
bytes and marked them immutable for a year, so changing the real icon
left the url identical and pinned the stale copy indefinitely, strictly
worse than the 1h cache it replaced. My earlier comment claiming these
"no-op anyway" was only true when no root file existed, and nothing
enforced that. Drop them: they gain nothing, since a page references its
icon as `/public/favicon.svg`.

Each fix has a regression test with a verified counterfactual (18 now),
and the suite stays failure-for-failure identical to the clean baseline.

Refs #1194
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Closing unmerged. Two deep-review rounds found six confirmed major defects, five of them the same structural bug: a regex over the assembled document cannot tell framework output from author data. Full rationale and the replacement design are on #1194. Superseded by an explicit opt-in asset() marker.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant