From c83af879300e41ba8635fe0c527d471e35102717 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 23 Apr 2026 15:34:37 +1000 Subject: [PATCH 1/4] fix(bundle): content-address bundled script filenames for stable SRI Bundled script public URLs were hashed from the upstream src, so the filename stayed constant across deploys even when the fetched content (or proxy rewrites) changed. Long-cached JS then got served against a new integrity hash in fresh HTML, breaking SRI on the second deploy. Derive the public filename from a sha256 of the final bundled bytes so content changes always flip the URL, and drop the cached integrity hash (it is now computed fresh from cached content each build). Resolves #724 --- packages/script/src/plugins/transform.ts | 126 +++++++++++++---------- test/unit/transform.test.ts | 78 +++++++------- 2 files changed, 108 insertions(+), 96 deletions(-) diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 384484d2..466cc71c 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -92,19 +92,25 @@ export interface AssetBundlerTransformerOptions { partytownScripts?: Set } +function safeFilename(h: string): string { + // Prefix hashes starting with '-' — Nitro's publicAssets handler cannot serve + // files whose names begin with a dash (they get omitted from the asset manifest). + return `${h.startsWith('-') ? `_${h.slice(1)}` : h}.js` +} + +function buildAssetUrl(filename: string, assetsBaseURL: string = '/_scripts/assets'): string { + const nuxt = tryUseNuxt() + const cdnURL = nuxt?.options.runtimeConfig?.app?.cdnURL || nuxt?.options.app?.cdnURL || '' + const baseURL = cdnURL || nuxt?.options.app.baseURL || '' + return joinURL(joinURL(baseURL, assetsBaseURL), filename) +} + function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts/assets'): { url: string, filename?: string } { if (hasProtocol(src, { acceptRelative: true })) { src = src.replace(PROTOCOL_RELATIVE_RE, 'https://') const url = parseURL(src) - const h = ohash(url) - // Prefix hashes starting with '-' — Nitro's publicAssets handler cannot serve - // files whose names begin with a dash (they get omitted from the asset manifest). - const file = `${h.startsWith('-') ? `_${h.slice(1)}` : h}.js` - const nuxt = tryUseNuxt() - // Use cdnURL if available, otherwise fall back to baseURL - const cdnURL = nuxt?.options.runtimeConfig?.app?.cdnURL || nuxt?.options.app?.cdnURL || '' - const baseURL = cdnURL || nuxt?.options.app.baseURL || '' - return { url: joinURL(joinURL(baseURL, assetsBaseURL), file), filename: file } + const file = safeFilename(ohash(url)) + return { url: buildAssetUrl(file, assetsBaseURL), filename: file } } return { url: src } } @@ -118,37 +124,30 @@ async function downloadScript(opts: { integrity?: boolean | IntegrityAlgorithm skipApiRewrites?: boolean neutralizeCanvas?: boolean -}, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number) { - const { src, url, filename, forceDownload, integrity, proxyRewrites, sdkPatches, skipApiRewrites, neutralizeCanvas } = opts + assetsBaseURL?: string +}, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number): Promise<{ url: string, filename?: string } | undefined> { + const { src, url, filename, forceDownload, integrity, proxyRewrites, sdkPatches, skipApiRewrites, neutralizeCanvas, assetsBaseURL } = opts if (src === url || !filename) { return } const storage = bundleStorage() - const scriptContent = renderedScript.get(src) - let res: Buffer | undefined = scriptContent instanceof Error ? undefined : scriptContent?.content - if (!res) { - // Use storage to cache the font data between builds - // Include proxy in cache key to differentiate proxied vs non-proxied versions - // Also include a hash of proxyRewrites content to handle different proxyPrefix values - const proxyRewritesHash = proxyRewrites?.length ? `-${ohash(proxyRewrites)}` : '' - const cacheKey = proxyRewrites?.length ? `bundle-proxy:${filename.replace('.js', `${proxyRewritesHash}.js`)}` : `bundle:${filename}` - const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge)) - - if (shouldUseCache) { - const cachedContent = await storage.getItemRaw(cacheKey) - const meta = await storage.getItem(`bundle-meta:${filename}`) as { integrity?: string } | null - renderedScript.set(url, { - content: cachedContent!, - size: cachedContent!.length / 1024, - encoding: 'utf-8', - src, - filename, - integrity: meta?.integrity, - }) - return - } - let encoding - let size = 0 + let res: Buffer | undefined + let encoding: string | null | undefined + let size = 0 + let fetched = false + + // Use storage to cache the font data between builds + // Include proxy in cache key to differentiate proxied vs non-proxied versions + // Also include a hash of proxyRewrites content to handle different proxyPrefix values + const proxyRewritesHash = proxyRewrites?.length ? `-${ohash(proxyRewrites)}` : '' + const cacheKey = proxyRewrites?.length ? `bundle-proxy:${filename.replace('.js', `${proxyRewritesHash}.js`)}` : `bundle:${filename}` + const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge)) + + if (shouldUseCache) { + res = await storage.getItemRaw(cacheKey) as Buffer + encoding = 'utf-8' + } + else { res = await $fetch.raw(src, { ...fetchOptions, responseType: 'arrayBuffer' }).then(async (r) => { if (!r.ok) { throw new Error(`Failed to fetch ${src} (HTTP ${r.status})`) @@ -158,40 +157,54 @@ async function downloadScript(opts: { size = contentLength ? Number(contentLength) / 1024 : 0 return Buffer.from(r._data || await r.arrayBuffer()) }) + fetched = true await storage.setItemRaw(`bundle:${filename}`, res) // Apply URL rewrites for proxy mode (AST-based at build time) if (proxyRewrites?.length && res) { const content = res.toString('utf-8') - const rewritten = rewriteScriptUrlsAST(content, filename || 'script.js', proxyRewrites, sdkPatches, { skipApiRewrites, neutralizeCanvas }) + const rewritten = rewriteScriptUrlsAST(content, filename, proxyRewrites, sdkPatches, { skipApiRewrites, neutralizeCanvas }) res = Buffer.from(rewritten, 'utf-8') logger.debug(`Rewrote ${proxyRewrites.length} URL patterns in ${filename}`) } - // Calculate integrity hash after rewrites so the hash matches the served content - const integrityHash = integrity && res - ? calculateIntegrity(res, integrity === true ? 'sha384' : integrity) - : undefined - await storage.setItemRaw(cacheKey, res) - // Save metadata with timestamp for cache expiration await storage.setItem(`bundle-meta:${filename}`, { timestamp: Date.now(), src, filename, - integrity: integrityHash, - }) - size = size || res!.length / 1024 - logger.info(`Downloading script ${colors.gray(`${src} → ${filename} (${size.toFixed(2)} kB ${encoding})${integrityHash ? ` [${integrityHash.slice(0, 15)}...]` : ''}`)}`) - renderedScript.set(url, { - content: res!, - size, - encoding, - src, - filename, - integrity: integrityHash, }) } + + if (!res) { + return + } + + // Content-address the public filename so when the upstream script or proxy + // rewrites change between deployments, the URL changes too. Without this, + // long-cached JS at an unchanged URL ends up served against a new integrity + // hash in fresh HTML, breaking SRI on the second deploy. + const contentHash = createHash('sha256').update(res).digest('hex').slice(0, 16) + const publicFilename = safeFilename(contentHash) + const publicUrl = buildAssetUrl(publicFilename, assetsBaseURL) + + const integrityHash = integrity + ? calculateIntegrity(res, integrity === true ? 'sha384' : integrity) + : undefined + + size = size || res.length / 1024 + if (fetched) { + logger.info(`Downloading script ${colors.gray(`${src} → ${publicFilename} (${size.toFixed(2)} kB ${encoding})${integrityHash ? ` [${integrityHash.slice(0, 15)}...]` : ''}`)}`) + } + renderedScript.set(publicUrl, { + content: res, + size, + encoding: encoding || undefined, + src, + filename: publicFilename, + integrity: integrityHash, + }) + return { url: publicUrl, filename: publicFilename } } export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOptions = { @@ -465,7 +478,10 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti deferredOps.push(async () => { let url = _url try { - await downloadScript({ src: src as string, url, filename, forceDownload, proxyRewrites, sdkPatches, integrity: options.integrity, skipApiRewrites, neutralizeCanvas }, renderedScript, options.fetchOptions, options.cacheMaxAge) + const result = await downloadScript({ src: src as string, url, filename, forceDownload, proxyRewrites, sdkPatches, integrity: options.integrity, skipApiRewrites, neutralizeCanvas, assetsBaseURL: options.assetsBaseURL }, renderedScript, options.fetchOptions, options.cacheMaxAge) + if (result) { + url = result.url + } } catch (e: any) { if (options.fallbackOnSrcOnBundleFail) { diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts index 135c1c18..98e99487 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -115,7 +115,7 @@ describe('nuxtScriptTransformer', () => { })`, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/beacon.min.js', )"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/e3b0c44298fc1c14.js', )"`) }) it('options arg', async () => { @@ -126,7 +126,7 @@ describe('nuxtScriptTransformer', () => { })`, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScript({ defer: true, src: '/_scripts/assets/beacon.min.js' }, )"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScript({ defer: true, src: '/_scripts/assets/e3b0c44298fc1c14.js' }, )"`) }) it('dynamic src is not transformed', async () => { @@ -164,7 +164,7 @@ describe('nuxtScriptTransformer', () => { ], }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScriptFathomAnalytics({ src: '/_scripts/assets/custom.js.js' }, )"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScriptFathomAnalytics({ src: '/_scripts/assets/e3b0c44298fc1c14.js' }, )"`) }) it('registry script with scriptOptions.bundle - correct usage', async () => { @@ -193,7 +193,7 @@ describe('nuxtScriptTransformer', () => { }, ) expect(code).toMatchInlineSnapshot(` - "const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/assets/analytics.js' }, + "const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/assets/e3b0c44298fc1c14.js' }, id: 'GA_MEASUREMENT_ID', scriptOptions: { bundle: true @@ -227,7 +227,7 @@ describe('nuxtScriptTransformer', () => { }, ) expect(code).toMatchInlineSnapshot(` - "const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/assets/gtag/js.js' }, + "const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/assets/e3b0c44298fc1c14.js' }, id: 'GA_MEASUREMENT_ID' }, )" `) @@ -252,7 +252,7 @@ describe('nuxtScriptTransformer', () => { ], }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScriptFathomAnalytics({ scriptInput: { src: '/_scripts/assets/script.js.js' }, site: '123' }, )"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScriptFathomAnalytics({ scriptInput: { src: '/_scripts/assets/e3b0c44298fc1c14.js' }, site: '123' }, )"`) }) it('static src integration is transformed - opt-out', async () => { @@ -300,7 +300,7 @@ describe('nuxtScriptTransformer', () => { }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScriptIntercom({ scriptInput: { src: '/_scripts/assets/widget/123.js' }, app_id: '123' })"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScriptIntercom({ scriptInput: { src: '/_scripts/assets/e3b0c44298fc1c14.js' }, app_id: '123' })"`) }) it('dynamic src integration can be opted-out explicit', async () => { @@ -347,7 +347,7 @@ describe('nuxtScriptTransformer', () => { }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScriptIntercom({ scriptInput: { src: '/_scripts/assets/widget/123.js' }, app_id: '123' }, )"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScriptIntercom({ scriptInput: { src: '/_scripts/assets/e3b0c44298fc1c14.js' }, app_id: '123' }, )"`) }) it('can re-use opt-in once it\'s loaded', async () => { @@ -370,8 +370,8 @@ describe('nuxtScriptTransformer', () => { }, ) expect(code).toMatchInlineSnapshot(` - "const instance = useScriptIntercom({ scriptInput: { src: '/_scripts/assets/widget/123.js' }, app_id: '123' }, ) - const instance2 = useScriptIntercom({ scriptInput: { src: '/_scripts/assets/widget.js' } })" + "const instance = useScriptIntercom({ scriptInput: { src: '/_scripts/assets/e3b0c44298fc1c14.js' }, app_id: '123' }, ) + const instance2 = useScriptIntercom({ scriptInput: { src: '/_scripts/assets/e3b0c44298fc1c14.js' } })" `) }) @@ -396,7 +396,7 @@ describe('nuxtScriptTransformer', () => { }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScriptNpm({ scriptInput: { src: '/_scripts/assets/jKysJQD_rnWtMaRpo62kJcIJ4PsW_O2f1NXNqksJbMk.js' }, packageName: 'jsconfetti', version: '1.0.0', file: 'dist/index.js' })"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScriptNpm({ scriptInput: { src: '/_scripts/assets/e3b0c44298fc1c14.js' }, packageName: 'jsconfetti', version: '1.0.0', file: 'dist/index.js' })"`) }) it('useScript broken #1', async () => { @@ -425,7 +425,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ } });`, ) - expect(code.includes('useScript(\'/_scripts/assets/vFJ41_fzYQOTRPr3v6G1PkI0hc5tMy0HGrgFjhaJhOI.js\', {')).toBeTruthy() + expect(code).toMatch(/useScript\('\/_scripts\/assets\/[a-f0-9]{16}\.js', \{/) }) it('uses baseURL without cdnURL', async () => { @@ -441,7 +441,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ ) // Without cdnURL configured, it should use baseURL - expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/beacon.min.js', )"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/e3b0c44298fc1c14.js', )"`) }) it('bundle: "force" works the same as bundle: true', async () => { @@ -452,7 +452,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ })`, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/beacon.min.js', )"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/e3b0c44298fc1c14.js', )"`) }) it('registry script with scriptOptions.bundle: "force"', async () => { @@ -481,7 +481,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, ) expect(code).toMatchInlineSnapshot(` - "const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/assets/analytics.js' }, + "const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/assets/e3b0c44298fc1c14.js' }, id: 'GA_MEASUREMENT_ID', scriptOptions: { bundle: 'force' @@ -515,7 +515,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, ) expect(code).toMatchInlineSnapshot(` - "const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/assets/gtag/js.js' }, + "const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/assets/e3b0c44298fc1c14.js' }, id: 'GA_MEASUREMENT_ID' }, )" `) @@ -535,7 +535,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ ) // Verify transformation still works with custom cache duration - expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/beacon.min.js', )"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/e3b0c44298fc1c14.js', )"`) }) describe('cache invalidation', () => { @@ -639,7 +639,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ // Verify the script was fetched (not just cached) expect(fetch).toHaveBeenCalled() - expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/beacon.min.js', )"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/e3b0c44298fc1c14.js', )"`) }) it('should store bundle metadata with timestamp on download', async () => { @@ -666,7 +666,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/beacon.min.js', )"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/e3b0c44298fc1c14.js', )"`) // Verify metadata was stored const metadataCall = mockBundleStorage.setItem.mock.calls.find(call => @@ -702,7 +702,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/beacon.min.js', )"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/951c324253eef4b3.js', )"`) // Verify fetch was not called (used cache) expect(fetch).not.toHaveBeenCalled() @@ -712,9 +712,8 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ expect(mockBundleStorage.getItem).toHaveBeenCalledWith('bundle-meta:beacon.min.js') expect(mockBundleStorage.getItemRaw).toHaveBeenCalledWith('bundle:beacon.min.js') - // Verify the cached content was used (check both possible keys) - const scriptEntry = renderedScript.get('https://static.cloudflareinsights.com/beacon.min.js') - || renderedScript.get('/_scripts/assets/beacon.min.js') + // renderedScript is keyed by the content-addressed public URL, so locate the entry by source. + const scriptEntry = [...renderedScript.values()].find(e => !(e instanceof Error) && e.src === 'https://static.cloudflareinsights.com/beacon.min.js') expect(scriptEntry).toBeDefined() expect(scriptEntry?.content).toBe(cachedContent) expect(scriptEntry?.size).toBe(cachedContent.length / 1024) @@ -749,7 +748,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ ], }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/assets/gtm.js.js' } })"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/assets/951c324253eef4b3.js' } })"`) }) describe('configuration merging', () => { @@ -788,7 +787,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ ], }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/assets/gtm.js.js' } })"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/assets/951c324253eef4b3.js' } })"`) }) it('merges multiple properties from registry config', async () => { @@ -830,7 +829,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ ], }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/assets/gtm.js.js' } })"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/assets/951c324253eef4b3.js' } })"`) }) it('function arguments override merged registry config', async () => { @@ -872,7 +871,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, ) // Function args take precedence: id from function, debug and l from registry - expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/assets/gtm.js.js' }, id: 'GTM-FUNCTION-OVERRIDE', customParam: 'test' })"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/assets/951c324253eef4b3.js' }, id: 'GTM-FUNCTION-OVERRIDE', customParam: 'test' })"`) }) it('works with empty registry config', async () => { @@ -967,7 +966,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ ], }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/assets/gtag/js.js' } })"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/assets/951c324253eef4b3.js' } })"`) }) }) @@ -1005,7 +1004,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ ], }, ) - expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/assets/gtm.js.js' }, id: 'GTM-FUNCTION-ARG' })"`) + expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/assets/951c324253eef4b3.js' }, id: 'GTM-FUNCTION-ARG' })"`) }) describe('integrity', () => { @@ -1188,16 +1187,14 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ expect(code).not.toContain('crossorigin:') }) - it('loads cached integrity hash', async () => { + it('computes integrity from cached content', async () => { vi.mocked(hash).mockImplementationOnce(() => 'beacon.min') const cachedContent = Buffer.from('cached script content') - const cachedIntegrity = 'sha384-cachedHashValue' mockBundleStorage.hasItem.mockResolvedValue(true) mockBundleStorage.getItem.mockResolvedValue({ timestamp: Date.now(), - integrity: cachedIntegrity, }) mockBundleStorage.getItemRaw.mockResolvedValue(cachedContent) @@ -1211,10 +1208,11 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, ) - expect(code).toContain(cachedIntegrity) + // Integrity is computed fresh from cached content, yielding a deterministic SRI hash. + expect(code).toMatch(/integrity: 'sha384-[A-Za-z0-9+/=]+'/) }) - it('stores integrity hash in metadata', async () => { + it('uses content-addressed public filename', async () => { vi.mocked(hash).mockImplementationOnce(() => 'beacon.min') mockBundleStorage.hasItem.mockResolvedValue(false) @@ -1226,7 +1224,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ _data: scriptContent, } as any) - await transform( + const code = await transform( `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', { bundle: true, })`, @@ -1236,12 +1234,10 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, ) - const metadataCall = mockBundleStorage.setItem.mock.calls.find(call => - call[0].startsWith('bundle-meta:'), - ) - expect(metadataCall).toBeDefined() - expect(metadataCall[1].integrity).toBeDefined() - expect(metadataCall[1].integrity).toMatch(/^sha384-/) + // Public URL should not be derived from the source URL hash ('beacon.min'); + // it must be a content hash so changed content yields a changed filename. + expect(code).not.toContain('/_scripts/assets/beacon.min.js') + expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) }) }) From 184120b13fb036252ccd30985258fc1cd0dc0da7 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 23 Apr 2026 15:43:53 +1000 Subject: [PATCH 2/4] test(bundle): mechanical two-deploy repro for #724 Guards against regression of URL-hash-only filenames producing SRI mismatches when upstream content changes between deployments. --- test/unit/bundle-two-deploy-repro.test.ts | 113 ++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 test/unit/bundle-two-deploy-repro.test.ts diff --git a/test/unit/bundle-two-deploy-repro.test.ts b/test/unit/bundle-two-deploy-repro.test.ts new file mode 100644 index 00000000..8b8906c5 --- /dev/null +++ b/test/unit/bundle-two-deploy-repro.test.ts @@ -0,0 +1,113 @@ +// Mechanical reproduction of nuxt/scripts#724: +// Same source URL, different content between deployments must yield different public URLs, +// otherwise a long-cached asset serves stale bytes against a fresh SRI hash. +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { hash } from 'ohash' +import { hasProtocol } from 'ufo' +import { describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' + +vi.mock('ohash', async (og) => { + const mod = await og() + return { ...mod, hash: vi.fn(mod.hash) } +}) +vi.mock('ufo', async (og) => { + const mod = await og() + return { ...mod, hasProtocol: vi.fn(mod.hasProtocol) } +}) + +const mockBundleStorage: any = { + getItem: vi.fn(), + setItem: vi.fn(), + getItemRaw: vi.fn(), + setItemRaw: vi.fn(), + hasItem: vi.fn(), +} +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: vi.fn(() => mockBundleStorage), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +vi.mock('@nuxt/kit', async (og) => { + const mod = await og() + const nuxt = { + options: { buildDir: '.nuxt', app: { baseURL: '/' }, runtimeConfig: { app: {} } }, + hooks: { hook: vi.fn() }, + } + return { ...mod, useNuxt: () => nuxt, tryUseNuxt: () => nuxt } +}) + +vi.mocked(hasProtocol).mockImplementation(() => true) +// Source URL hash is stable across both "deploys" — the URL doesn't change between deployments, +// only the upstream bytes do. This is exactly the real-world scenario in #724. +vi.mocked(hash).mockImplementation(() => 'adsbygoogle') + +async function runTransform(code: string, options?: AssetBundlerTransformerOptions) { + mockBundleStorage.hasItem.mockResolvedValue(false) + const plugin = NuxtScriptBundleTransformer({ renderedScript: new Map(), ...options }).vite() as any + const out = await plugin.transform.handler.call({}, code, 'file.js') + return out?.code as string +} + +function mockUpstreamBody(bytes: Buffer) { + fetchMock.mockResolvedValueOnce({ + ok: true, + arrayBuffer: () => Promise.resolve(bytes), + headers: { get: () => null }, + _data: bytes, + } as any) +} + +function extractPublicUrl(code: string): string { + const match = code.match(/\/_scripts\/assets\/[^'"]+\.js/) + if (!match) + throw new Error(`no public asset URL in: ${code}`) + return match[0] +} + +describe('two-deploy bundle repro (#724)', () => { + const src = `const instance = useScript('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js', { bundle: true })` + + it('same source URL + changed upstream content -> different public filenames', async () => { + // Deploy 1: upstream returns content A + mockUpstreamBody(Buffer.from('/* adsbygoogle v1 */ (function(){ /* ... */ })()')) + const deploy1 = await runTransform(src) + + // Deploy 2: upstream returns content B (Google pushed a JS update) + mockUpstreamBody(Buffer.from('/* adsbygoogle v2 NEW */ (function(){ /* ... */ })()')) + const deploy2 = await runTransform(src) + + const url1 = extractPublicUrl(deploy1) + const url2 = extractPublicUrl(deploy2) + + // With the bug present, these would be identical (URL-hash-only filename), + // so a long-cached v1 asset would be served against a v2 integrity hash. + expect(url1).not.toBe(url2) + expect(url1).toMatch(/[a-f0-9]{16}\.js$/) + expect(url2).toMatch(/[a-f0-9]{16}\.js$/) + }) + + it('same source URL + same content -> identical public filenames (caching preserved)', async () => { + const body = Buffer.from('/* adsbygoogle v1 */ (function(){ /* ... */ })()') + mockUpstreamBody(body) + const deploy1 = await runTransform(src) + mockUpstreamBody(body) + const deploy2 = await runTransform(src) + + expect(extractPublicUrl(deploy1)).toBe(extractPublicUrl(deploy2)) + }) + + it('integrity hash matches the final served bytes', async () => { + mockUpstreamBody(Buffer.from('/* adsbygoogle v1 */')) + const code = await runTransform(src, { integrity: true }) + const url = extractPublicUrl(code) + const integrityMatch = code.match(/integrity: '(sha384-[^']+)'/) + + expect(url).toMatch(/[a-f0-9]{16}\.js$/) + expect(integrityMatch).toBeTruthy() + // Filename and integrity both derive from the same post-rewrite bytes, + // so they cannot drift apart across deployments. + }) +}) From 1fcdabc9ac6077853e2c9cdfc98ca3ac44bbbf86 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 23 Apr 2026 15:53:26 +1000 Subject: [PATCH 3/4] test(e2e): update bundle snapshots for content-addressed filenames --- test/e2e/base.test.ts | 2 +- test/e2e/basic.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/base.test.ts b/test/e2e/base.test.ts index 95722af3..5cb7a4f7 100644 --- a/test/e2e/base.test.ts +++ b/test/e2e/base.test.ts @@ -20,6 +20,6 @@ describe('base', async () => { await page.waitForTimeout(500) // get content of #script-src const text = await page.$eval('#script-src', el => el.textContent) - expect(text).toMatchInlineSnapshot(`"/foo/_scripts/assets/6bEy8slcRmYcRT4E2QbQZ1CMyWw9PpHA7L87BtvSs2U.js"`) + expect(text).toMatchInlineSnapshot(`"/foo/_scripts/assets/ff1523fb7389539c.js"`) }) }) diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index 8dd0a578..cbbdf4a7 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -178,7 +178,7 @@ describe('basic', () => { await page.waitForTimeout(500) // get content of #script-src const text = await page.$eval('#script-src', el => el.textContent) - expect(text).toMatchInlineSnapshot(`"/_scripts/assets/6bEy8slcRmYcRT4E2QbQZ1CMyWw9PpHA7L87BtvSs2U.js"`) + expect(text).toMatchInlineSnapshot(`"/_scripts/assets/ff1523fb7389539c.js"`) }) it('partytown adds type attribute', async () => { const { page } = await createPage('/partytown') From ede57c7f06ed93ca22e738d47e74bdc4808d66a1 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 23 Apr 2026 15:59:40 +1000 Subject: [PATCH 4/4] test(bundle): assert exact SRI value, not just sha384 prefix --- test/unit/bundle-two-deploy-repro.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/unit/bundle-two-deploy-repro.test.ts b/test/unit/bundle-two-deploy-repro.test.ts index 8b8906c5..8c244881 100644 --- a/test/unit/bundle-two-deploy-repro.test.ts +++ b/test/unit/bundle-two-deploy-repro.test.ts @@ -2,6 +2,7 @@ // Same source URL, different content between deployments must yield different public URLs, // otherwise a long-cached asset serves stale bytes against a fresh SRI hash. import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { createHash } from 'node:crypto' import { hash } from 'ohash' import { hasProtocol } from 'ufo' import { describe, expect, it, vi } from 'vitest' @@ -100,13 +101,15 @@ describe('two-deploy bundle repro (#724)', () => { }) it('integrity hash matches the final served bytes', async () => { - mockUpstreamBody(Buffer.from('/* adsbygoogle v1 */')) + const body = Buffer.from('/* adsbygoogle v1 */') + mockUpstreamBody(body) const code = await runTransform(src, { integrity: true }) const url = extractPublicUrl(code) const integrityMatch = code.match(/integrity: '(sha384-[^']+)'/) + const expectedIntegrity = `sha384-${createHash('sha384').update(body).digest('base64')}` expect(url).toMatch(/[a-f0-9]{16}\.js$/) - expect(integrityMatch).toBeTruthy() + expect(integrityMatch?.[1]).toBe(expectedIntegrity) // Filename and integrity both derive from the same post-rewrite bytes, // so they cannot drift apart across deployments. })