diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index e554513b8..16733c5dc 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -82,7 +82,7 @@ export const revalidate = 60; // cache this page's HTML for 60s ### Content-hash asset URLs and conditional GET -Both are automatic, prod-focused, and need no config. In production every served module and `public/` asset gets a per-file `?v=` and `Cache-Control: public, max-age=31536000, immutable`, so a returning client fetches a changed file only when its bytes change. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing). +Both are automatic, prod-focused, and need no config. In production every served module gets a per-file `?v=`, and so does a `public/` asset you reference from markup: an `href` / `src` on a ``, ``; controller.enqueue(encoder.encode(chunk)); } diff --git a/packages/server/test/importmap/version-asset-urls.test.js b/packages/server/test/importmap/version-asset-urls.test.js new file mode 100644 index 000000000..f96abba71 --- /dev/null +++ b/packages/server/test/importmap/version-asset-urls.test.js @@ -0,0 +1,256 @@ +/** + * Unit tests for `versionAssetUrls` (issue #1194): the SSR pass that appends + * `?v=` to same-origin asset urls an APP AUTHOR wrote by hand in a + * template, so a `` cannot serve + * stale bytes at a live url after a deploy. + * + * Two invariants carry the weight here: + * + * - the hash is byte-identical to what `withAssetHash` computes for the same + * file, so an authored url and a framework-emitted one agree on the cache + * key rather than splitting into two; + * - a RENDERED CODE SAMPLE is never rewritten. The docs and brand pages + * display markup, and silently editing the code a reader is looking at + * would be a lie in the output. The matcher is anchored on a literal tag + * name for exactly this reason, since a highlighted sample escapes `<`. + */ +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + setAssetRoots, + clearAssetHashCache, + withAssetHash, + versionAssetUrls, +} from '../../src/asset-hash.js'; + +let root; +let appDir; +let coreDir; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'webjs-versassets-')); + appDir = join(root, 'app-root'); + coreDir = join(root, 'core-root'); + mkdirSync(join(appDir, 'public', 'brand'), { recursive: true }); + mkdirSync(coreDir, { recursive: true }); + writeFileSync(join(appDir, 'public', 'app.css'), 'body{color:red}'); + writeFileSync(join(appDir, 'public', 'brand', 'logo.svg'), ''); + clearAssetHashCache(); + setAssetRoots({ appDir, coreDir, enabled: true }); +}); + +afterEach(() => { + setAssetRoots({ appDir: '', coreDir: '', enabled: false }); + clearAssetHashCache(); + rmSync(root, { recursive: true, force: true }); +}); + +test('an authored stylesheet link is fingerprinted', () => { + const out = versionAssetUrls(''); + const expected = withAssetHash('/public/app.css'); + assert.notEqual(expected, '/public/app.css', 'precondition: the file hashes'); + assert.equal(out, ``); +}); + +test('img and script sources are fingerprinted too', () => { + const img = versionAssetUrls('logo'); + assert.match(img, /src="\/public\/brand\/logo\.svg\?v=[0-9a-f]+"/); + assert.match(img, /alt="logo"/, 'other attributes survive untouched'); +}); + +test('the hash matches what the framework computes for its own urls', () => { + // If these ever diverge, an authored url and a framework-emitted one become + // two cache keys for one file, which is the bug #369 fixed for modules. + const out = versionAssetUrls(''); + const direct = withAssetHash('/public/app.css'); + assert.ok(out.includes(direct), `authored url ${out} should carry ${direct}`); +}); + +test('the hash changes when the file bytes change', () => { + const before = versionAssetUrls(''); + writeFileSync(join(appDir, 'public', 'app.css'), 'body{color:blue}'); + clearAssetHashCache(); + const after = versionAssetUrls(''); + assert.notEqual(before, after, 'new bytes must produce a new url'); +}); + +test('a rendered code sample is never rewritten', () => { + // A highlighter escapes `<`, so the sample never contains a literal `<img ' + + 'src=' + + '"/public/app.css"'; + assert.equal(versionAssetUrls(tokenized), tokenized); +}); + +test('cross-origin, relative, and unresolvable urls are untouched', () => { + for (const url of [ + 'https://cdn.example.com/app.css', + '//cdn.example.com/app.css', + './app.css', + '/public/does-not-exist.css', + ]) { + const html = ``; + assert.equal(versionAssetUrls(html), html, `${url} must not be rewritten`); + } +}); + +test('a url that already carries a query is left alone', () => { + // Author-controlled and possibly meaningful, so we do not append to it. + const html = ''; + assert.equal(versionAssetUrls(html), html); +}); + +test('a non-asset tag is not rewritten', () => { + // An points at a page, not an asset; versioning it would change a + // user-visible link. + const html = 'download'; + assert.equal(versionAssetUrls(html), html); +}); + +test('single-quoted attributes are handled', () => { + const out = versionAssetUrls(""); + assert.match(out, /href='\/public\/app\.css\?v=[0-9a-f]+'/); +}); + +test('it is a no-op when fingerprinting is disabled', () => { + // Dev must stay byte-identical, which is why dev never calls setAssetRoots. + setAssetRoots({ appDir: '', coreDir: '', enabled: false }); + const html = ''; + assert.equal(versionAssetUrls(html), html); +}); + +/* + * Security: the transform runs over the whole rendered document, and an app's + * markup can carry ATTACKER-CONTROLLED data (``, a + * markdown image, a CMS path). `resolveUrlToFile` maps any same-origin path + * under the PROJECT ROOT, so without a gate the renderer would readFileSync + + * hash any file there and publish the result, leaking existence and a stable + * fingerprint of the bytes for files the serve path deliberately 404s. + */ +test('a private file is never read or fingerprinted', () => { + writeFileSync(join(appDir, '.env'), 'DATABASE_URL=postgres://u:SECRET@h/db'); + mkdirSync(join(appDir, 'db'), { recursive: true }); + writeFileSync(join(appDir, 'db', 'app.db'), 'SQLITE FORMAT 3'); + mkdirSync(join(appDir, 'lib'), { recursive: true }); + writeFileSync(join(appDir, 'lib', 'session.server.ts'), 'export const KEY = 1'); + + for (const url of ['/.env', '/db/app.db', '/lib/session.server.ts']) { + const html = ``; + assert.equal( + versionAssetUrls(html), html, + `${url} must not be fingerprinted: publishing a hash leaks its existence and its bytes`, + ); + } +}); + +test('a traversal out of public/ is refused', () => { + writeFileSync(join(appDir, '.env'), 'SECRET=1'); + // `join` normalises `..`, so without an explicit refusal these would resolve + // to appDir/.env while still looking like a public path. + for (const url of ['/public/../.env', '/public/%2E%2E/.env']) { + const html = ``; + assert.equal(versionAssetUrls(html), html, `${url} must not escape public/`); + } +}); + +test('a hyphenated custom element is never rewritten', () => { + // This framework is web-components-first, so `img-*` / `link-*` / `source-*` + // are ordinary component names and their `src` / `href` is a reactive PROP, + // i.e. author data. A `\b` guard treats `-` as a word boundary and matched + // them, so a component hydrated with a mutated prop: `this.src` no longer + // ended in `.svg`, an equality check against the path a server action + // returned stopped matching, and the value churned on every deploy. + for (const tag of [ + '', + '', + '', + '', + ]) { + assert.equal(versionAssetUrls(tag), tag, `${tag} is a component, not an asset tag`); + } +}); + +test('a preload hint is left on the url its consumer will request', () => { + // A 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. A font's real request comes from `@font-face url()` in CSS, + // which is out of scope here, so versioning the hint guarantees a miss: the + // font is fetched twice and the browser logs "preloaded but not used". + for (const rel of ['preload', 'prefetch', 'preconnect']) { + const tag = ``; + assert.equal(versionAssetUrls(tag), tag, `rel=${rel} is a hint, not the request`); + } + // A normal stylesheet link IS the request, so it still gets versioned. + assert.match( + versionAssetUrls(''), + /\?v=[0-9a-f]+/, + ); +}); + +test('a root-served path is not fingerprinted against the wrong file', () => { + // The serve path REMAPS /favicon.ico into public/, and resolveUrlToFile does + // not. Admitting it hashed a stray project-root file while the server served + // public/'s bytes, and stamped them immutable for a year, so changing the + // real icon left the url identical and pinned the stale copy indefinitely. + writeFileSync(join(appDir, 'favicon.ico'), 'STRAY-ROOT-FILE'); + writeFileSync(join(appDir, 'public', 'favicon.ico'), 'THE-REAL-ONE'); + for (const url of ['/favicon.ico', '/sw.js', '/offline.html']) { + const html = ``; + assert.equal(versionAssetUrls(html), html, `${url} must not be versioned from the wrong file`); + } +}); + +test('a fragment is preserved and the query goes before it', () => { + // An SVG sprite (`#icon`) and a media fragment (`#t=10,20`) are real. Naively + // appending would emit `/x.svg#logo?v=H`: the browser then requests `/x.svg` + // with NO query, losing the immutable caching this exists for, and the + // fragment becomes `logo?v=H`, matching no element id. + const out = versionAssetUrls(''); + assert.match(out, /src="\/public\/brand\/logo\.svg\?v=[0-9a-f]+#logo"/); + assert.ok(!/#logo\?v=/.test(out), 'the query must not land after the fragment'); +}); + +/* + * Wiring: the unit tests above prove the transform, these prove it is actually + * REACHED by the SSR document assembly. `buildDocumentParts` is the single seam + * every response path funnels through (buffered, streamed, and `buildDocument`), + * which is why the call lives there rather than at each response site. + */ +const SSR_OPTS = { + moduleUrls: [], preloadUrls: [], metadata: {}, + importMap: null, lazyComponents: null, nonce: '', dev: false, +}; + +test('SSR fingerprints an authored asset in the framework-owned shell', async () => { + const { _buildDocumentParts } = await import('../../src/ssr.js'); + const { streamBody } = _buildDocumentParts( + 'logo', + SSR_OPTS, + ); + assert.match(streamBody, /src="\/public\/brand\/logo\.svg\?v=[0-9a-f]+"/); +}); + +test('SSR fingerprints an authored asset in a user-supplied shell', async () => { + // The root layout may write its own …, which takes a + // different branch of buildDocumentParts. Both must fingerprint, or the site + // that writes its own shell (this one does) silently keeps stale urls. + const { _buildDocumentParts } = await import('../../src/ssr.js'); + const { prefix, streamBody } = _buildDocumentParts( + '' + + '', + SSR_OPTS, + ); + assert.match(prefix, /href="\/public\/app\.css\?v=[0-9a-f]+"/, 'head link fingerprinted'); + assert.match(streamBody, /src="\/public\/brand\/logo\.svg\?v=[0-9a-f]+"/, 'body img fingerprinted'); +});