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+
functionsafeFilename(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+
functionbuildAssetUrl(filename: string,assetsBaseURL: string='/_scripts/assets'): string{
102+
constnuxt=tryUseNuxt()
103+
constcdnURL=nuxt?.options.runtimeConfig?.app?.cdnURL||nuxt?.options.app?.cdnURL||''
104+
constbaseURL=cdnURL||nuxt?.options.app.baseURL||''
105+
returnjoinURL(joinURL(baseURL,assetsBaseURL),filename)
106+
}
107+
95108
functionnormalizeScriptData(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
consturl=parseURL(src)
99-
consth=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-
constfile=`${h.startsWith('-') ? `_${h.slice(1)}` : h}.js`
103-
constnuxt=tryUseNuxt()
104-
// Use cdnURL if available, otherwise fall back to baseURL
105-
constcdnURL=nuxt?.options.runtimeConfig?.app?.cdnURL||nuxt?.options.app?.cdnURL||''
106-
constbaseURL=cdnURL||nuxt?.options.app.baseURL||''
107-
return{url: joinURL(joinURL(baseURL,assetsBaseURL),file),filename: file}
112+
constfile=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
conststorage=bundleStorage()
127-
constscriptContent=renderedScript.get(src)
128-
letres: Buffer|undefined=scriptContentinstanceofError ? 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-
constproxyRewritesHash=proxyRewrites?.length ? `-${ohash(proxyRewrites)}` : ''
134-
constcacheKey=proxyRewrites?.length ? `bundle-proxy:${filename.replace('.js',`${proxyRewritesHash}.js`)}` : `bundle:${filename}`
135-
constshouldUseCache=!forceDownload&&awaitstorage.hasItem(cacheKey)&&!(awaitisCacheExpired(storage,filename,cacheMaxAge))
136-
137-
if(shouldUseCache){
138-
constcachedContent=awaitstorage.getItemRaw<Buffer>(cacheKey)
139-
constmeta=awaitstorage.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-
letencoding
151-
letsize=0
134+
letres: Buffer|undefined
135+
letencoding: string|null|undefined
136+
letsize=0
137+
letfetched=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+
constproxyRewritesHash=proxyRewrites?.length ? `-${ohash(proxyRewrites)}` : ''
143+
constcacheKey=proxyRewrites?.length ? `bundle-proxy:${filename.replace('.js',`${proxyRewritesHash}.js`)}` : `bundle:${filename}`
144+
constshouldUseCache=!forceDownload&&awaitstorage.hasItem(cacheKey)&&!(awaitisCacheExpired(storage,filename,cacheMaxAge))
145+
146+
if(shouldUseCache){
147+
res=awaitstorage.getItemRaw<Buffer>(cacheKey)asBuffer
148+
encoding='utf-8'
149+
}
150+
else{
152151
res=await$fetch.raw(src,{ ...fetchOptions,responseType: 'arrayBuffer'}).then(async(r)=>{
153152
if(!r.ok){
154153
thrownewError(`Failed to fetch ${src} (HTTP ${r.status})`)
@@ -158,40 +157,54 @@ async function downloadScript(opts: {
158157
size=contentLength ? Number(contentLength)/1024 : 0
159158
returnBuffer.from(r._data||awaitr.arrayBuffer())
160159
})
160+
fetched=true
161161

162162
awaitstorage.setItemRaw(`bundle:${filename}`,res)
163163
// Apply URL rewrites for proxy mode (AST-based at build time)
164164
if(proxyRewrites?.length&&res){
165165
constcontent=res.toString('utf-8')
166-
constrewritten=rewriteScriptUrlsAST(content,filename||'script.js',proxyRewrites,sdkPatches,{ skipApiRewrites, neutralizeCanvas })
166+
constrewritten=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-
constintegrityHash=integrity&&res
173-
? calculateIntegrity(res,integrity===true ? 'sha384' : integrity)
174-
: undefined
175-
176171
awaitstorage.setItemRaw(cacheKey,res)
177-
// Save metadata with timestamp for cache expiration
178172
awaitstorage.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+
constcontentHash=createHash('sha256').update(res).digest('hex').slice(0,16)
188+
constpublicFilename=safeFilename(contentHash)
189+
constpublicUrl=buildAssetUrl(publicFilename,assetsBaseURL)
190+
191+
constintegrityHash=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
exportfunctionNuxtScriptBundleTransformer(options: AssetBundlerTransformerOptions={
@@ -465,7 +478,10 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
465478
deferredOps.push(async()=>{
466479
leturl=_url
467480
try{
468-
awaitdownloadScript({src: srcasstring, url, filename, forceDownload, proxyRewrites, sdkPatches,integrity: options.integrity, skipApiRewrites, neutralizeCanvas },renderedScript,options.fetchOptions,options.cacheMaxAge)
481+
constresult=awaitdownloadScript({src: srcasstring, 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
awaitpage.waitForTimeout(500)
2121
// get content of #script-src
2222
consttext=awaitpage.$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
awaitpage.waitForTimeout(500)
179179
// get content of #script-src
180180
consttext=awaitpage.$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 }=awaitcreatePage('/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+
importtype{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+
constmod=awaitog<typeofimport('ohash')>()
13+
return{ ...mod,hash: vi.fn(mod.hash)}
14+
})
15+
vi.mock('ufo',async(og)=>{
16+
constmod=awaitog<typeofimport('ufo')>()
17+
return{ ...mod,hasProtocol: vi.fn(mod.hasProtocol)}
18+
})
19+
20+
constmockBundleStorage: 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+
constfetchMock=vi.fn()
32+
vi.stubGlobal('fetch',fetchMock)
33+
34+
vi.mock('@nuxt/kit',async(og)=>{
35+
constmod=awaitog<typeofimport('@nuxt/kit')>()
36+
constnuxt={
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+
asyncfunctionrunTransform(code: string,options?: AssetBundlerTransformerOptions){
49+
mockBundleStorage.hasItem.mockResolvedValue(false)
50+
constplugin=NuxtScriptBundleTransformer({renderedScript: newMap(), ...options}).vite()asany
51+
constout=awaitplugin.transform.handler.call({},code,'file.js')
52+
returnout?.codeasstring
53+
}
54+
55+
functionmockUpstreamBody(bytes: Buffer){
56+
fetchMock.mockResolvedValueOnce({
57+
ok: true,
58+
arrayBuffer: ()=>Promise.resolve(bytes),
59+
headers: {get: ()=>null},
60+
_data: bytes,
61+
}asany)
62+
}
63+
64+
functionextractPublicUrl(code: string): string{
65+
constmatch=code.match(/\/_scripts\/assets\/[^'"]+\.js/)
66+
if(!match)
67+
thrownewError(`no public asset URL in: ${code}`)
68+
returnmatch[0]
69+
}
70+
71+
describe('two-deploy bundle repro (#724)',()=>{
72+
constsrc=`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+
constdeploy1=awaitrunTransform(src)
78+
79+
// Deploy 2: upstream returns content B (Google pushed a JS update)
80+
mockUpstreamBody(Buffer.from('/* adsbygoogle v2 NEW */ (function(){ /* ... */ })()'))
81+
constdeploy2=awaitrunTransform(src)
82+
83+
consturl1=extractPublicUrl(deploy1)
84+
consturl2=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+
constbody=Buffer.from('/* adsbygoogle v1 */ (function(){ /* ... */ })()')
95+
mockUpstreamBody(body)
96+
constdeploy1=awaitrunTransform(src)
97+
mockUpstreamBody(body)
98+
constdeploy2=awaitrunTransform(src)
99+
100+
expect(extractPublicUrl(deploy1)).toBe(extractPublicUrl(deploy2))
101+
})
102+
103+
it('integrity hash matches the final served bytes',async()=>{
104+
constbody=Buffer.from('/* adsbygoogle v1 */')
105+
mockUpstreamBody(body)
106+
constcode=awaitrunTransform(src,{integrity: true})
107+
consturl=extractPublicUrl(code)
108+
constintegrityMatch=code.match(/integrity:'(sha384-[^']+)'/)
109+
110+
constexpectedIntegrity=`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)