@@ -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+
95108function 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
197210export 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 ) {
0 commit comments