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/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') 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..8c244881 --- /dev/null +++ b/test/unit/bundle-two-deploy-repro.test.ts @@ -0,0 +1,116 @@ +// 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 { createHash } from 'node:crypto' +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 () => { + 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?.[1]).toBe(expectedIntegrity) + // Filename and integrity both derive from the same post-rewrite bytes, + // so they cannot drift apart across deployments. + }) +}) 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/) }) })