Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 71 additions & 55 deletions packages/script/src/plugins/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,25 @@ export interface AssetBundlerTransformerOptions {
partytownScripts?: Set<string>
}

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 }
}
Expand All @@ -118,37 +124,30 @@ async function downloadScript(opts: {
integrity?: boolean | IntegrityAlgorithm
skipApiRewrites?: boolean
neutralizeCanvas?: boolean
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions, cacheMaxAge?: number) {
const { src, url, filename, forceDownload, integrity, proxyRewrites, sdkPatches, skipApiRewrites, neutralizeCanvas } = opts
assetsBaseURL?: string
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, 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<Buffer>(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<Buffer>(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})`)
Expand All @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"`)
})
})
2 changes: 1 addition & 1 deletion test/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
116 changes: 116 additions & 0 deletions test/unit/bundle-two-deploy-repro.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Comment thread
coderabbitai[bot] marked this conversation as resolved.

vi.mock('ohash', async (og) => {
const mod = await og<typeof import('ohash')>()
return { ...mod, hash: vi.fn(mod.hash) }
})
vi.mock('ufo', async (og) => {
const mod = await og<typeof import('ufo')>()
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<typeof import('@nuxt/kit')>()
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.
})
})
Loading
Loading