Skip to content

Commit f979ede

Browse files
authored
fix(bundle): content-address bundled script filenames for stable SRI (#725)
1 parent 9a77830 commit f979ede

5 files changed

Lines changed: 226 additions & 98 deletions

File tree

packages/script/src/plugins/transform.ts

Lines changed: 71 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,25 @@ export interface AssetBundlerTransformerOptions {
9292
partytownScripts?: Set<string>
9393
}
9494

95+
function safeFilename(h: string): string {
96+
// Prefix hashes starting with '-' — Nitro's publicAssets handler cannot serve
97+
// files whose names begin with a dash (they get omitted from the asset manifest).
98+
return `${h.startsWith('-') ? `_${h.slice(1)}` : h}.js`
99+
}
100+
101+
function buildAssetUrl(filename: string, assetsBaseURL: string = '/_scripts/assets'): string {
102+
const nuxt = tryUseNuxt()
103+
const cdnURL = nuxt?.options.runtimeConfig?.app?.cdnURL || nuxt?.options.app?.cdnURL || ''
104+
const baseURL = cdnURL || nuxt?.options.app.baseURL || ''
105+
return joinURL(joinURL(baseURL, assetsBaseURL), filename)
106+
}
107+
95108
function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts/assets'): { url: string, filename?: string } {
96109
if (hasProtocol(src, { acceptRelative: true })) {
97110
src = src.replace(PROTOCOL_RELATIVE_RE, 'https://')
98111
const url = parseURL(src)
99-
const h = ohash(url)
100-
// Prefix hashes starting with '-' — Nitro's publicAssets handler cannot serve
101-
// files whose names begin with a dash (they get omitted from the asset manifest).
102-
const file = `${h.startsWith('-') ? `_${h.slice(1)}` : h}.js`
103-
const nuxt = tryUseNuxt()
104-
// Use cdnURL if available, otherwise fall back to baseURL
105-
const cdnURL = nuxt?.options.runtimeConfig?.app?.cdnURL || nuxt?.options.app?.cdnURL || ''
106-
const baseURL = cdnURL || nuxt?.options.app.baseURL || ''
107-
return { url: joinURL(joinURL(baseURL, assetsBaseURL), file), filename: file }
112+
const file = safeFilename(ohash(url))
113+
return { url: buildAssetUrl(file, assetsBaseURL), filename: file }
108114
}
109115
return { url: src }
110116
}
@@ -118,37 +124,30 @@ async function downloadScript(opts: {
118124
integrity?: boolean | IntegrityAlgorithm
119125
skipApiRewrites?: boolean
120126
neutralizeCanvas?: boolean
121-
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions, cacheMaxAge?: number) {
122-
const { src, url, filename, forceDownload, integrity, proxyRewrites, sdkPatches, skipApiRewrites, neutralizeCanvas } = opts
127+
assetsBaseURL?: string
128+
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions, cacheMaxAge?: number): Promise<{ url: string, filename?: string } | undefined> {
129+
const { src, url, filename, forceDownload, integrity, proxyRewrites, sdkPatches, skipApiRewrites, neutralizeCanvas, assetsBaseURL } = opts
123130
if (src === url || !filename) {
124131
return
125132
}
126133
const storage = bundleStorage()
127-
const scriptContent = renderedScript.get(src)
128-
let res: Buffer | undefined = scriptContent instanceof Error ? undefined : scriptContent?.content
129-
if (!res) {
130-
// Use storage to cache the font data between builds
131-
// Include proxy in cache key to differentiate proxied vs non-proxied versions
132-
// Also include a hash of proxyRewrites content to handle different proxyPrefix values
133-
const proxyRewritesHash = proxyRewrites?.length ? `-${ohash(proxyRewrites)}` : ''
134-
const cacheKey = proxyRewrites?.length ? `bundle-proxy:${filename.replace('.js', `${proxyRewritesHash}.js`)}` : `bundle:${filename}`
135-
const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge))
136-
137-
if (shouldUseCache) {
138-
const cachedContent = await storage.getItemRaw<Buffer>(cacheKey)
139-
const meta = await storage.getItem(`bundle-meta:${filename}`) as { integrity?: string } | null
140-
renderedScript.set(url, {
141-
content: cachedContent!,
142-
size: cachedContent!.length / 1024,
143-
encoding: 'utf-8',
144-
src,
145-
filename,
146-
integrity: meta?.integrity,
147-
})
148-
return
149-
}
150-
let encoding
151-
let size = 0
134+
let res: Buffer | undefined
135+
let encoding: string | null | undefined
136+
let size = 0
137+
let fetched = false
138+
139+
// Use storage to cache the font data between builds
140+
// Include proxy in cache key to differentiate proxied vs non-proxied versions
141+
// Also include a hash of proxyRewrites content to handle different proxyPrefix values
142+
const proxyRewritesHash = proxyRewrites?.length ? `-${ohash(proxyRewrites)}` : ''
143+
const cacheKey = proxyRewrites?.length ? `bundle-proxy:${filename.replace('.js', `${proxyRewritesHash}.js`)}` : `bundle:${filename}`
144+
const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge))
145+
146+
if (shouldUseCache) {
147+
res = await storage.getItemRaw<Buffer>(cacheKey) as Buffer
148+
encoding = 'utf-8'
149+
}
150+
else {
152151
res = await $fetch.raw(src, { ...fetchOptions, responseType: 'arrayBuffer' }).then(async (r) => {
153152
if (!r.ok) {
154153
throw new Error(`Failed to fetch ${src} (HTTP ${r.status})`)
@@ -158,40 +157,54 @@ async function downloadScript(opts: {
158157
size = contentLength ? Number(contentLength) / 1024 : 0
159158
return Buffer.from(r._data || await r.arrayBuffer())
160159
})
160+
fetched = true
161161

162162
await storage.setItemRaw(`bundle:${filename}`, res)
163163
// Apply URL rewrites for proxy mode (AST-based at build time)
164164
if (proxyRewrites?.length && res) {
165165
const content = res.toString('utf-8')
166-
const rewritten = rewriteScriptUrlsAST(content, filename || 'script.js', proxyRewrites, sdkPatches, { skipApiRewrites, neutralizeCanvas })
166+
const rewritten = rewriteScriptUrlsAST(content, filename, proxyRewrites, sdkPatches, { skipApiRewrites, neutralizeCanvas })
167167
res = Buffer.from(rewritten, 'utf-8')
168168
logger.debug(`Rewrote ${proxyRewrites.length} URL patterns in ${filename}`)
169169
}
170170

171-
// Calculate integrity hash after rewrites so the hash matches the served content
172-
const integrityHash = integrity && res
173-
? calculateIntegrity(res, integrity === true ? 'sha384' : integrity)
174-
: undefined
175-
176171
await storage.setItemRaw(cacheKey, res)
177-
// Save metadata with timestamp for cache expiration
178172
await storage.setItem(`bundle-meta:${filename}`, {
179173
timestamp: Date.now(),
180174
src,
181175
filename,
182-
integrity: integrityHash,
183-
})
184-
size = size || res!.length / 1024
185-
logger.info(`Downloading script ${colors.gray(`${src}${filename} (${size.toFixed(2)} kB ${encoding})${integrityHash ? ` [${integrityHash.slice(0, 15)}...]` : ''}`)}`)
186-
renderedScript.set(url, {
187-
content: res!,
188-
size,
189-
encoding,
190-
src,
191-
filename,
192-
integrity: integrityHash,
193176
})
194177
}
178+
179+
if (!res) {
180+
return
181+
}
182+
183+
// Content-address the public filename so when the upstream script or proxy
184+
// rewrites change between deployments, the URL changes too. Without this,
185+
// long-cached JS at an unchanged URL ends up served against a new integrity
186+
// hash in fresh HTML, breaking SRI on the second deploy.
187+
const contentHash = createHash('sha256').update(res).digest('hex').slice(0, 16)
188+
const publicFilename = safeFilename(contentHash)
189+
const publicUrl = buildAssetUrl(publicFilename, assetsBaseURL)
190+
191+
const integrityHash = integrity
192+
? calculateIntegrity(res, integrity === true ? 'sha384' : integrity)
193+
: undefined
194+
195+
size = size || res.length / 1024
196+
if (fetched) {
197+
logger.info(`Downloading script ${colors.gray(`${src}${publicFilename} (${size.toFixed(2)} kB ${encoding})${integrityHash ? ` [${integrityHash.slice(0, 15)}...]` : ''}`)}`)
198+
}
199+
renderedScript.set(publicUrl, {
200+
content: res,
201+
size,
202+
encoding: encoding || undefined,
203+
src,
204+
filename: publicFilename,
205+
integrity: integrityHash,
206+
})
207+
return { url: publicUrl, filename: publicFilename }
195208
}
196209

197210
export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOptions = {
@@ -465,7 +478,10 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
465478
deferredOps.push(async () => {
466479
let url = _url
467480
try {
468-
await downloadScript({ src: src as string, url, filename, forceDownload, proxyRewrites, sdkPatches, integrity: options.integrity, skipApiRewrites, neutralizeCanvas }, renderedScript, options.fetchOptions, options.cacheMaxAge)
481+
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)
482+
if (result) {
483+
url = result.url
484+
}
469485
}
470486
catch (e: any) {
471487
if (options.fallbackOnSrcOnBundleFail) {

test/e2e/base.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,6 @@ describe('base', async () => {
2020
await page.waitForTimeout(500)
2121
// get content of #script-src
2222
const text = await page.$eval('#script-src', el => el.textContent)
23-
expect(text).toMatchInlineSnapshot(`"/foo/_scripts/assets/6bEy8slcRmYcRT4E2QbQZ1CMyWw9PpHA7L87BtvSs2U.js"`)
23+
expect(text).toMatchInlineSnapshot(`"/foo/_scripts/assets/ff1523fb7389539c.js"`)
2424
})
2525
})

test/e2e/basic.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ describe('basic', () => {
178178
await page.waitForTimeout(500)
179179
// get content of #script-src
180180
const text = await page.$eval('#script-src', el => el.textContent)
181-
expect(text).toMatchInlineSnapshot(`"/_scripts/assets/6bEy8slcRmYcRT4E2QbQZ1CMyWw9PpHA7L87BtvSs2U.js"`)
181+
expect(text).toMatchInlineSnapshot(`"/_scripts/assets/ff1523fb7389539c.js"`)
182182
})
183183
it('partytown adds type attribute', async () => {
184184
const { page } = await createPage('/partytown')
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Mechanical reproduction of nuxt/scripts#724:
2+
// Same source URL, different content between deployments must yield different public URLs,
3+
// otherwise a long-cached asset serves stale bytes against a fresh SRI hash.
4+
import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform'
5+
import { createHash } from 'node:crypto'
6+
import { hash } from 'ohash'
7+
import { hasProtocol } from 'ufo'
8+
import { describe, expect, it, vi } from 'vitest'
9+
import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform'
10+
11+
vi.mock('ohash', async (og) => {
12+
const mod = await og<typeof import('ohash')>()
13+
return { ...mod, hash: vi.fn(mod.hash) }
14+
})
15+
vi.mock('ufo', async (og) => {
16+
const mod = await og<typeof import('ufo')>()
17+
return { ...mod, hasProtocol: vi.fn(mod.hasProtocol) }
18+
})
19+
20+
const mockBundleStorage: any = {
21+
getItem: vi.fn(),
22+
setItem: vi.fn(),
23+
getItemRaw: vi.fn(),
24+
setItemRaw: vi.fn(),
25+
hasItem: vi.fn(),
26+
}
27+
vi.mock('../../packages/script/src/assets', () => ({
28+
bundleStorage: vi.fn(() => mockBundleStorage),
29+
}))
30+
31+
const fetchMock = vi.fn()
32+
vi.stubGlobal('fetch', fetchMock)
33+
34+
vi.mock('@nuxt/kit', async (og) => {
35+
const mod = await og<typeof import('@nuxt/kit')>()
36+
const nuxt = {
37+
options: { buildDir: '.nuxt', app: { baseURL: '/' }, runtimeConfig: { app: {} } },
38+
hooks: { hook: vi.fn() },
39+
}
40+
return { ...mod, useNuxt: () => nuxt, tryUseNuxt: () => nuxt }
41+
})
42+
43+
vi.mocked(hasProtocol).mockImplementation(() => true)
44+
// Source URL hash is stable across both "deploys" — the URL doesn't change between deployments,
45+
// only the upstream bytes do. This is exactly the real-world scenario in #724.
46+
vi.mocked(hash).mockImplementation(() => 'adsbygoogle')
47+
48+
async function runTransform(code: string, options?: AssetBundlerTransformerOptions) {
49+
mockBundleStorage.hasItem.mockResolvedValue(false)
50+
const plugin = NuxtScriptBundleTransformer({ renderedScript: new Map(), ...options }).vite() as any
51+
const out = await plugin.transform.handler.call({}, code, 'file.js')
52+
return out?.code as string
53+
}
54+
55+
function mockUpstreamBody(bytes: Buffer) {
56+
fetchMock.mockResolvedValueOnce({
57+
ok: true,
58+
arrayBuffer: () => Promise.resolve(bytes),
59+
headers: { get: () => null },
60+
_data: bytes,
61+
} as any)
62+
}
63+
64+
function extractPublicUrl(code: string): string {
65+
const match = code.match(/\/_scripts\/assets\/[^'"]+\.js/)
66+
if (!match)
67+
throw new Error(`no public asset URL in: ${code}`)
68+
return match[0]
69+
}
70+
71+
describe('two-deploy bundle repro (#724)', () => {
72+
const src = `const instance = useScript('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js', { bundle: true })`
73+
74+
it('same source URL + changed upstream content -> different public filenames', async () => {
75+
// Deploy 1: upstream returns content A
76+
mockUpstreamBody(Buffer.from('/* adsbygoogle v1 */ (function(){ /* ... */ })()'))
77+
const deploy1 = await runTransform(src)
78+
79+
// Deploy 2: upstream returns content B (Google pushed a JS update)
80+
mockUpstreamBody(Buffer.from('/* adsbygoogle v2 NEW */ (function(){ /* ... */ })()'))
81+
const deploy2 = await runTransform(src)
82+
83+
const url1 = extractPublicUrl(deploy1)
84+
const url2 = extractPublicUrl(deploy2)
85+
86+
// With the bug present, these would be identical (URL-hash-only filename),
87+
// so a long-cached v1 asset would be served against a v2 integrity hash.
88+
expect(url1).not.toBe(url2)
89+
expect(url1).toMatch(/[a-f0-9]{16}\.js$/)
90+
expect(url2).toMatch(/[a-f0-9]{16}\.js$/)
91+
})
92+
93+
it('same source URL + same content -> identical public filenames (caching preserved)', async () => {
94+
const body = Buffer.from('/* adsbygoogle v1 */ (function(){ /* ... */ })()')
95+
mockUpstreamBody(body)
96+
const deploy1 = await runTransform(src)
97+
mockUpstreamBody(body)
98+
const deploy2 = await runTransform(src)
99+
100+
expect(extractPublicUrl(deploy1)).toBe(extractPublicUrl(deploy2))
101+
})
102+
103+
it('integrity hash matches the final served bytes', async () => {
104+
const body = Buffer.from('/* adsbygoogle v1 */')
105+
mockUpstreamBody(body)
106+
const code = await runTransform(src, { integrity: true })
107+
const url = extractPublicUrl(code)
108+
const integrityMatch = code.match(/integrity: '(sha384-[^']+)'/)
109+
110+
const expectedIntegrity = `sha384-${createHash('sha384').update(body).digest('base64')}`
111+
expect(url).toMatch(/[a-f0-9]{16}\.js$/)
112+
expect(integrityMatch?.[1]).toBe(expectedIntegrity)
113+
// Filename and integrity both derive from the same post-rewrite bytes,
114+
// so they cannot drift apart across deployments.
115+
})
116+
})

0 commit comments

Comments
 (0)