diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 87ba4c28..c2ce8bc3 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -523,8 +523,9 @@ export default defineNuxtModule({ }) } + const runtimeComponentsDir = await resolvePath('./runtime/components') addComponentsDir({ - path: await resolvePath('./runtime/components'), + path: runtimeComponentsDir, pathPrefix: false, }) @@ -885,6 +886,7 @@ export default defineNuxtModule({ addBuildPlugin(NuxtScriptsCheckScripts()) addBuildPlugin(NuxtScriptBundleTransformer({ nuxt, + componentDir: runtimeComponentsDir, scripts: registryScriptsWithImport, registryConfig: nuxt.options.runtimeConfig.public.scripts as Record | undefined, proxyConfigs, diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 138dc5f0..90dc58f4 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -1,6 +1,7 @@ import type { Nuxt } from '@nuxt/schema' import type { FetchOptions } from 'ofetch' import type { SourceMapInput } from 'rollup' +import type { VitePlugin } from 'unplugin' import type { InferInput } from 'valibot' import type { ProxyConfig, ProxyRewrite, RegistryScript } from '../runtime/types' import { createHash } from 'node:crypto' @@ -65,6 +66,11 @@ export interface RenderedScriptMeta { export interface AssetBundlerTransformerOptions { moduleDetected?: (module: string) => void assetsBaseURL?: string + /** + * Runtime component directory. Bundling waits until the final module graph + * proves that an auto-registered component has a real importer. + */ + componentDir?: string scripts?: Required[] /** * Merged configuration from both scripts.registry and runtimeConfig.public.scripts @@ -127,7 +133,8 @@ function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts/ass } return { url: src } } -async function downloadScript(opts: { + +interface DownloadScriptOptions { src: string url: string filename?: string @@ -138,7 +145,34 @@ async function downloadScript(opts: { skipApiRewrites?: boolean neutralizeCanvas?: boolean assetsBaseURL?: string -}, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number): Promise<{ url: string, filename?: string } | undefined> { +} + +interface PendingComponentBundle { + componentId: string + downloadOptions: DownloadScriptOptions + placeholderIntegrity?: string + placeholderUrl: string +} + +/** + * Dropping an unresolved hash must remove the whole `, integrity: ..., crossorigin: 'anonymous'` + * span: replacing only the placeholder would leave `integrity: ''` plus crossorigin, which + * forces CORS request mode and breaks origins serving scripts without CORS headers. + * + * The patch runs against final minified chunk code, where quote style and whitespace are + * not ours to choose (oxc renders every literal as a template literal), so match the two + * properties structurally instead of comparing exact source text. + */ +function integrityPlaceholderRemoval(placeholderIntegrity: string): RegExp { + const token = escapeRegExp(placeholderIntegrity) + return new RegExp(`,\\s*integrity\\s*:\\s*["'\`]${token}["'\`]\\s*,\\s*crossorigin\\s*:\\s*["'\`][^"'\`]*["'\`]`, 'g') +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +async function downloadScript(opts: DownloadScriptOptions, 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 @@ -223,6 +257,55 @@ async function downloadScript(opts: { return { url: publicUrl, filename: publicFilename } } +async function resolveScriptBundle( + downloadOptions: DownloadScriptOptions, + renderedScript: NonNullable, + options: Pick, +): Promise<{ integrity?: string, url: string }> { + const { src } = downloadOptions + let { url } = downloadOptions + const result = await downloadScript(downloadOptions, renderedScript, options.fetchOptions, options.cacheMaxAge).catch((error: any) => { + if (options.fallbackOnSrcOnBundleFail) { + logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}. Fallback to remote loading.`) + return undefined + } + + const errorMessage = error?.message || 'Unknown error' + if (errorMessage.includes('timeout') || errorMessage.includes('network') || errorMessage.includes('ENOTFOUND') || errorMessage.includes('certificate')) { + logger.error(`[Nuxt Scripts: Bundle Transformer] Network issue while bundling ${src}: ${errorMessage}`) + logger.error(`[Nuxt Scripts: Bundle Transformer] Tip: Set 'fallbackOnSrcOnBundleFail: true' in module options or disable bundling in Docker environments`) + } + throw error + }) + + if (result) + url = result.url + else if (options.fallbackOnSrcOnBundleFail) + url = src + + if (src === url) { + if (src.startsWith('/')) + logger.warn(`[Nuxt Scripts: Bundle Transformer] Relative scripts are already bundled. Skipping bundling for \`${src}\`.`) + else + logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}.`) + } + + const scriptMeta = renderedScript.get(url) + return { + integrity: scriptMeta instanceof Error ? undefined : scriptMeta?.integrity, + url, + } +} + +function getComponentId(id: string, componentDir?: string): string | undefined { + if (!componentDir) + return + const queryIndex = id.indexOf('?') + const componentId = queryIndex === -1 ? id : id.slice(0, queryIndex) + if (componentId === componentDir || componentId.startsWith(`${componentDir}/`)) + return componentId +} + export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOptions = { renderedScript: new Map(), }) { @@ -257,9 +340,125 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti }) return createUnplugin(() => { + const pendingComponentBundles: PendingComponentBundle[] = [] + const replacements = new Map() + + /** + * A pending component is unused unless some importer path reaches a module outside + * the runtime components dir. Direct importers alone miss nested widgets: an + * auto-registered parent that nothing references can still make its children look + * used. Cycles (A imports B imports A) are guarded by the visited set. + */ + function reachesOutsideComponentDir(componentId: string, getModuleInfo: (id: string) => { importers?: string[], dynamicImporters?: string[] } | undefined | null): boolean { + if (!options.componentDir) + return true + const stack = [componentId] + const visited = new Set() + while (stack.length > 0) { + const id = stack.pop()! + if (visited.has(id)) + continue + visited.add(id) + if (getComponentId(id, options.componentDir) === undefined) + return true + const info = getModuleInfo(id) + if (!info) + continue + stack.push(...(info.importers ?? []), ...(info.dynamicImporters ?? [])) + } + return false + } + + function applyReplacements(code: string): string { + let result: MagicString | undefined + for (const [placeholder, replacement] of replacements) { + if (placeholder instanceof RegExp) { + placeholder.lastIndex = 0 + for (let match = placeholder.exec(code); match; match = placeholder.exec(code)) { + result ??= new MagicString(code) + result.remove(match.index, match.index + match[0].length) + if (match[0].length === 0) + break + } + continue + } + let offset = 0 + while (offset < code.length) { + const index = code.indexOf(placeholder, offset) + if (index === -1) + break + result ??= new MagicString(code) + result.overwrite(index, index + placeholder.length, replacement) + offset = index + placeholder.length + } + } + return result ? result.toString() : code + } + + const outputHooks: Pick = { + async generateBundle(_outputOptions, bundle) { + // Watch rebuilds re-emit chunks from cached transformed modules whose code still + // carries placeholder tokens, while replacements persist across emissions. So + // resolution and patching are independent steps: resolve a consumed snapshot of + // the pendings when one exists, then always re-patch emitted chunks whenever we + // hold any replacement. + if (pendingComponentBundles.length > 0) { + // Splice before awaiting: transforms still running while these downloads + // resolve must not have their freshly registered pendings wiped here. + const batch = pendingComponentBundles.splice(0, pendingComponentBundles.length) + + // Bundling has finished handing us the final module graph here and the bundler + // awaits this hook before writing files, so classification, downloads and patching + // land in a single deterministic point. An awaited renderStart cannot do this job: + // rolldown renders chunks without waiting for it, which shipped unresolved + // placeholders whenever a download outlived rendering. + await Promise.all(batch.map(async (pending) => { + const isUnusedComponent = !reachesOutsideComponentDir(pending.componentId, id => this.getModuleInfo(id)) + + if (isUnusedComponent) { + replacements.set(pending.placeholderUrl, pending.downloadOptions.src) + if (pending.placeholderIntegrity) + replacements.set(integrityPlaceholderRemoval(pending.placeholderIntegrity), '') + return + } + + // Downloads overlap across pendings: resolveScriptBundle rethrows fatal failures + // (only falling back when explicitly configured), and Promise.all preserves that. + const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) + + replacements.set(pending.placeholderUrl, result.url) + if (pending.placeholderIntegrity) { + replacements.set( + result.integrity ? pending.placeholderIntegrity : integrityPlaceholderRemoval(pending.placeholderIntegrity), + result.integrity ?? '', + ) + } + })) + } + + if (replacements.size === 0) + return + + // Mutating `bundle` entries is honored on write (rollup contract); renderChunk-based + // patching was not: rolldown may render before any map entry existed. + for (const file of Object.values(bundle)) { + if (file.type !== 'chunk') + continue + const patched = applyReplacements(file.code) + if (patched !== file.code) { + // Edits only touch token spans inserted at transform time, so the existing + // sourcemap stays usable; regenerating one here would lose all chunk mappings. + file.code = patched + } + } + }, + } + return { name: 'nuxt:scripts:bundler-transformer', + vite: outputHooks, + transform: { filter: { id: { @@ -503,42 +702,7 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti ? (proxyConfig.privacy.hardware ?? true) : true - // Defer async download + MagicString operations - deferredOps.push(async () => { - let url = _url - try { - 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) { - logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}. Fallback to remote loading.`) - url = src as string - } - else { - // Provide more helpful error message, especially for Docker/network issues - const errorMessage = e?.message || 'Unknown error' - if (errorMessage.includes('timeout') || errorMessage.includes('network') || errorMessage.includes('ENOTFOUND') || errorMessage.includes('certificate')) { - logger.error(`[Nuxt Scripts: Bundle Transformer] Network issue while bundling ${src}: ${errorMessage}`) - logger.error(`[Nuxt Scripts: Bundle Transformer] Tip: Set 'fallbackOnSrcOnBundleFail: true' in module options or disable bundling in Docker environments`) - } - throw e - } - } - - if (src === url) { - if (src && (src as string).startsWith('/')) - logger.warn(`[Nuxt Scripts: Bundle Transformer] Relative scripts are already bundled. Skipping bundling for \`${src}\`.`) - else - logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}.`) - } - - // Get the integrity hash from rendered script - const scriptMeta = renderedScript.get(url) - const integrityHash = scriptMeta instanceof Error ? undefined : scriptMeta?.integrity - + const rewriteScriptCall = (url: string, integrityHash?: string) => { if (scriptSrcNode) { // For useScript('src') pattern, we need to convert to object form to add integrity if (integrityHash && fnName === 'useScript' && node.arguments[0]?.type === 'Literal') { @@ -589,7 +753,40 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti s.overwrite(node.callee.end, node.end, `({ scriptInput: { src: '${url}'${integrityProps} } })`) } } - }) + } + + const downloadOptions: DownloadScriptOptions = { + src: src as string, + url: _url, + filename, + forceDownload, + proxyRewrites, + sdkPatches, + integrity: options.integrity, + skipApiRewrites, + neutralizeCanvas, + assetsBaseURL: options.assetsBaseURL, + } + const componentId = nuxt.options.dev || nuxt.options.builder !== '@nuxt/vite-builder' + ? undefined + : getComponentId(id, options.componentDir) + + // Nuxt emits every auto-registered component as an entry before it + // knows which components the application imports. Wait for the final + // graph so unused widgets do not trigger third-party downloads. + if (componentId) { + const token = createHash('sha256').update(`${id}:${node.start}:${src}`).digest('hex').slice(0, 16) + const placeholderUrl = `__NUXT_SCRIPT_BUNDLE_${token}__` + const placeholderIntegrity = options.integrity ? `__NUXT_SCRIPT_INTEGRITY_${token}__` : undefined + pendingComponentBundles.push({ componentId, downloadOptions, placeholderIntegrity, placeholderUrl }) + deferredOps.push(async () => rewriteScriptCall(placeholderUrl, placeholderIntegrity)) + } + else { + deferredOps.push(async () => { + const result = await resolveScriptBundle(downloadOptions, renderedScript, options) + rewriteScriptCall(result.url, result.integrity) + }) + } } } } diff --git a/test/e2e/issue-882-unused-widget.test.ts b/test/e2e/issue-882-unused-widget.test.ts new file mode 100644 index 00000000..a5ffedb7 --- /dev/null +++ b/test/e2e/issue-882-unused-widget.test.ts @@ -0,0 +1,45 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { createResolver } from '@nuxt/kit' +import { $fetch, setup, useTestContext } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +const { resolve } = createResolver(import.meta.url) + +await setup({ + rootDir: resolve('../fixtures/issue-882'), + build: true, + browser: false, +}) + +/** + * Placeholder tokens are inserted at transform time and must be fully resolved before + * files are written. The deferred removal path used to key on unminified source text, + * which production minification (e.g. oxc template-literal quoting) never matched. + */ +async function readClientChunks(): Promise { + const ctx = useTestContext() + const nitroOutputDir = ctx.nuxt + ? ctx.nuxt.options.nitro.output.dir + : ctx.options.nuxtConfig?.nitro?.output?.dir + expect(nitroOutputDir, 'expected the test context to expose the nitro output dir').toBeTruthy() + const clientChunkDir = join(nitroOutputDir!, 'public', '_nuxt') + const entries = await readdir(clientChunkDir) + return Promise.all( + entries.filter(name => name.endsWith('.js')).map(name => readFile(join(clientChunkDir, name), 'utf-8')), + ) +} + +describe('unused script widgets', () => { + it('builds without downloading their scripts', async () => { + await expect($fetch('/')).resolves.toContain('Nuxt Scripts') + }) + + it('ships no unresolved bundle placeholders in any client chunk', async () => { + const chunks = await readClientChunks() + expect(chunks.length).toBeGreaterThan(0) + for (const code of chunks) { + expect(code, 'a client chunk still contains a placeholder token').not.toContain('__NUXT_SCRIPT_') + } + }) +}) diff --git a/test/e2e/issue-882-used-widget.test.ts b/test/e2e/issue-882-used-widget.test.ts new file mode 100644 index 00000000..49e4b1d4 --- /dev/null +++ b/test/e2e/issue-882-used-widget.test.ts @@ -0,0 +1,77 @@ +import { createHash } from 'node:crypto' +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { createResolver } from '@nuxt/kit' +import { $fetch, setup, useTestContext } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +const { resolve } = createResolver(import.meta.url) + +await setup({ + rootDir: resolve('../fixtures/issue-882-used'), + build: true, + browser: false, +}) + +/** + * Minifiers pick the quoting style per string literal (esbuild preserves the + * source quotes, oxc/rolldown normalizes to template literals), so attribute + * assertions must accept every quoting form. + */ +function attrValueRe(name: string, value: string): RegExp { + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`${name}\\s*:\\s*["'\`]${escaped}["'\`]`) +} + +/** + * Read the emitted client chunks of the running test-utils build. Chunk naming + * and island import-map reachability differ between a manual `nuxt build` + * (.output) and @nuxt/test-utils builds (.nuxt/test//output), and not every + * dynamically loaded island chunk is reachable by walking module specifiers + * over HTTP — so read the files Nitro copied into its public dir directly. + */ +async function readClientChunks(): Promise { + const ctx = useTestContext() + const nitroOutputDir = ctx.nuxt + ? ctx.nuxt.options.nitro.output.dir + : ctx.options.nuxtConfig?.nitro?.output?.dir + expect(nitroOutputDir, 'expected the test context to expose the nitro output dir').toBeTruthy() + const clientChunkDir = join(nitroOutputDir!, 'public', '_nuxt') + const entries = await readdir(clientChunkDir) + return Promise.all( + entries.filter(name => name.endsWith('.js')).map(name => readFile(join(clientChunkDir, name), 'utf-8')), + ) +} + +describe('used script widget (deferred component path)', () => { + it('bundles the used widget script and keeps integrity + crossorigin through renderStart', async () => { + const html = await $fetch('/') + expect(html).toContain('Nuxt Scripts') + + // The deferred used-component path must resolve the placeholder to the + // content-addressed public bundle URL rather than the remote src. + const assetUrl = html.match(/\/_scripts\/assets\/[a-f0-9]{16}\.js/)?.[0] + expect(assetUrl, 'expected a bundled /_scripts/assets/.js reference in the served page').toBeTruthy() + + // The integrity hash computed on the served bundle must match the hash the + // deferred renderStart path baked into the page (the script preload link). + const assetBody = await $fetch(assetUrl!) + const expectedIntegrity = `sha384-${createHash('sha384').update(assetBody).digest('base64')}` + expect(html).toContain(`integrity="${expectedIntegrity}"`) + + // The same src + integrity + crossorigin must survive into the built client + // chunk that drives the runtime script injection. + const clientChunks = await readClientChunks() + expect(clientChunks.length, 'expected built client chunks on disk').toBeGreaterThan(0) + const widgetChunk = clientChunks.find(code => code.includes(assetUrl!)) + expect(widgetChunk, 'expected a built client chunk referencing the bundled asset').toBeTruthy() + expect(widgetChunk!).toMatch(attrValueRe('integrity', expectedIntegrity)) + expect(widgetChunk!).toMatch(attrValueRe('crossorigin', 'anonymous')) + + // Unused auto-registered widgets fall back to their remote src; their unresolved + // integrity placeholders must not ship into any production chunk either. + for (const code of clientChunks) { + expect(code, 'a client chunk still contains a placeholder token').not.toContain('__NUXT_SCRIPT_') + } + }) +}) diff --git a/test/fixtures/issue-882-used/app.vue b/test/fixtures/issue-882-used/app.vue new file mode 100644 index 00000000..3e48ee06 --- /dev/null +++ b/test/fixtures/issue-882-used/app.vue @@ -0,0 +1,9 @@ + diff --git a/test/fixtures/issue-882-used/nuxt.config.ts b/test/fixtures/issue-882-used/nuxt.config.ts new file mode 100644 index 00000000..1d9f38d5 --- /dev/null +++ b/test/fixtures/issue-882-used/nuxt.config.ts @@ -0,0 +1,16 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + assets: { + integrity: true, + }, + }, + experimental: { + componentIslands: { + selectiveClient: true, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/issue-882-used/package.json b/test/fixtures/issue-882-used/package.json new file mode 100644 index 00000000..352055cd --- /dev/null +++ b/test/fixtures/issue-882-used/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/fixtures/issue-882/app.vue b/test/fixtures/issue-882/app.vue new file mode 100644 index 00000000..d68cfbca --- /dev/null +++ b/test/fixtures/issue-882/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/issue-882/nuxt.config.ts b/test/fixtures/issue-882/nuxt.config.ts new file mode 100644 index 00000000..ae101848 --- /dev/null +++ b/test/fixtures/issue-882/nuxt.config.ts @@ -0,0 +1,20 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + assets: { + fetchOptions: { + onRequest({ request }) { + throw new Error(`Unexpected script download: ${request}`) + }, + }, + }, + }, + experimental: { + componentIslands: { + selectiveClient: true, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/issue-882/package.json b/test/fixtures/issue-882/package.json new file mode 100644 index 00000000..352055cd --- /dev/null +++ b/test/fixtures/issue-882/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/unit/bundle-component-integrity.test.ts b/test/unit/bundle-component-integrity.test.ts new file mode 100644 index 00000000..4782318c --- /dev/null +++ b/test/unit/bundle-component-integrity.test.ts @@ -0,0 +1,112 @@ +// Reproduction for the deferred component bundling integrity finding: +// when a bundled script falls back to its remote src (or no hash resolves), +// the rewrite must not leave `integrity: ''` paired with +// `crossorigin: 'anonymous'`, which silently forces CORS request mode. +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) } +}) + +vi.mocked(hasProtocol).mockImplementation(() => true) +vi.mocked(hash).mockImplementation(src => String((src as any).pathname ?? src)) + +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) + +const COMPONENT_ID = '/app/components/BuyWidget.vue' +const COMPONENT_DIR = '/app/components' +// An importer path must exit the runtime components dir for the component to count as used. +const APP_IMPORTER = '/app/pages/index.vue' + +function makeNuxt() { + return { + options: { + dev: false, + builder: '@nuxt/vite-builder', + buildDir: '.nuxt', + app: { baseURL: '/' }, + runtimeConfig: { app: {} }, + }, + hooks: { hook: vi.fn() }, + } as any +} + +async function buildComponentChunk(options: Partial, importers: string[]) { + mockBundleStorage.hasItem.mockResolvedValue(false) + const code = `const instance = useScript('https://example.com/widget.js', { bundle: true })` + const plugin = NuxtScriptBundleTransformer({ + renderedScript: new Map(), + integrity: true, + fallbackOnSrcOnBundleFail: true, + componentDir: COMPONENT_DIR, + ...options, + nuxt: makeNuxt(), + }).vite() as any + + const transformed = await plugin.transform.handler.call({}, code, `${COMPONENT_ID}?vue&type=script&setup=true&lang.ts`) + expect(transformed?.code).toBeTruthy() + + // Simulate rollup's final module graph before files are written. + const getModuleInfo = () => ({ importers, dynamicImporters: [] }) + const bundle = { 'entry.js': { type: 'chunk', code: transformed.code } as any } + await plugin.generateBundle.call({ getModuleInfo }, {}, bundle) + return bundle['entry.js'].code as string +} + +describe('deferred component bundling integrity placeholders', () => { + it('bundle falls back to remote src -> no empty integrity and no crossorigin', async () => { + fetchMock.mockRejectedValue(new Error('network down')) + const code = await buildComponentChunk({}, [APP_IMPORTER]) + + expect(code).toContain('https://example.com/widget.js') + expect(code).not.toContain(`crossorigin`) + expect(code).not.toMatch(/integrity:\s*['"`]['"`]/) + }) + + it('unused component falls back to remote src -> no empty integrity and no crossorigin', async () => { + fetchMock.mockRejectedValue(new Error('network down')) + const code = await buildComponentChunk({}, []) + + expect(code).toContain('https://example.com/widget.js') + expect(code).not.toContain(`crossorigin`) + expect(code).not.toMatch(/integrity:\s*['"`]['"`]/) + }) + + it('successful bundle keeps the real integrity hash with crossorigin', async () => { + const body = Buffer.from('/* widget */ console.log("widget")') + fetchMock.mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(body), + headers: { get: () => null }, + _data: body, + }) + const code = await buildComponentChunk({}, [APP_IMPORTER]) + + const expected = `sha384-${createHash('sha384').update(body).digest('base64')}` + expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(code).toContain(`integrity: '${expected}'`) + expect(code).toContain(`crossorigin: 'anonymous'`) + }) +}) diff --git a/test/unit/bundle-component-reachability.test.ts b/test/unit/bundle-component-reachability.test.ts new file mode 100644 index 00000000..3bb1f476 --- /dev/null +++ b/test/unit/bundle-component-reachability.test.ts @@ -0,0 +1,112 @@ +// Regression coverage for nested auto-registered widgets: a pending component whose +// importer chain never leaves the runtime components dir must not trigger any +// third-party download, even when its direct importer is another (unreferenced) +// component with importers of its own. +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' + +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: vi.fn(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + getItemRaw: vi.fn(), + setItemRaw: vi.fn(), + hasItem: vi.fn().mockResolvedValue(false), + })), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +const COMPONENT_DIR = '/app/node_modules/@nuxt/scripts/dist/runtime/components' +const TRACKER_ID = `${COMPONENT_DIR}/Tracker.vue` +const PARENT_ID = `${COMPONENT_DIR}/UnusedParent.vue` +const CYCLE_SIBLING_ID = `${COMPONENT_DIR}/CycleSibling.vue` +const PAGE_ID = '/app/pages/index.vue' +const TRACKER_SRC = 'https://example.com/tracker.js' + +function makeNuxt() { + return { + options: { + dev: false, + builder: '@nuxt/vite-builder', + buildDir: '.nuxt', + app: { baseURL: '/' }, + runtimeConfig: { app: {} }, + }, + hooks: { hook: vi.fn() }, + } as any +} + +async function deferScript(options?: Partial, id: string = TRACKER_ID) { + const code = `const instance = useScript('${TRACKER_SRC}', { bundle: true })` + const plugin = NuxtScriptBundleTransformer({ + renderedScript: new Map(), + componentDir: COMPONENT_DIR, + fallbackOnSrcOnBundleFail: true, + ...options, + nuxt: makeNuxt(), + }).vite() as any + + const transformed = await plugin.transform.handler.call({}, code, `${id}?vue&type=script&setup=true&lang.ts`) + expect(transformed?.code).toContain('__NUXT_SCRIPT_BUNDLE_') + return { plugin, transformed } +} + +/** + * Drive the plugin through a production-like emit phase: module info comes from a + * synthetic importer graph and patches apply to the emitted chunk code. + */ +async function emit(plugin: any, transformedCode: string, graph: Record) { + const getModuleInfo = (id: string) => { + const queryIndex = id.indexOf('?') + const cleanId = queryIndex === -1 ? id : id.slice(0, queryIndex) + const entry = graph[cleanId] + return entry ? { importers: entry.importers ?? [], dynamicImporters: entry.dynamicImporters ?? [] } : undefined + } + const bundle = { 'entry.js': { type: 'chunk', code: transformedCode } as any } + await plugin.generateBundle.call({ getModuleInfo }, {}, bundle) + return bundle['entry.js'].code as string +} + +describe('nested unreachable components skip their scripts', () => { + it('tracker imported only by unreferenced parent inside the components dir stays unused', async () => { + const { plugin, transformed } = await deferScript() + + const out = await emit(plugin, transformed.code, { + [TRACKER_ID]: { importers: [PARENT_ID] }, + [PARENT_ID]: { importers: [] }, + }) + + expect(fetchMock, 'no third-party download may start').not.toHaveBeenCalled() + expect(out).toContain(TRACKER_SRC) + expect(out).not.toContain('__NUXT_SCRIPT_BUNDLE_') + expect(out).not.toContain('crossorigin') + }) + + it('importer cycles without an exit stay unused', async () => { + const { plugin, transformed } = await deferScript() + + const out = await emit(plugin, transformed.code, { + [TRACKER_ID]: { importers: [PARENT_ID] }, + [PARENT_ID]: { importers: [CYCLE_SIBLING_ID] }, + [CYCLE_SIBLING_ID]: { importers: [PARENT_ID] }, + }) + + expect(fetchMock).not.toHaveBeenCalled() + expect(out).toContain(TRACKER_SRC) + expect(out).not.toContain('crossorigin') + }) + + it('dynamic importers count toward reachability', async () => { + const { plugin, transformed } = await deferScript() + + await emit(plugin, transformed.code, { + [TRACKER_ID]: { dynamicImporters: [PAGE_ID] }, + [PAGE_ID]: { importers: [] }, + }) + + expect(fetchMock).toHaveBeenCalled() + }) +}) diff --git a/test/unit/bundle-placeholder-minification.test.ts b/test/unit/bundle-placeholder-minification.test.ts new file mode 100644 index 00000000..a3b97121 --- /dev/null +++ b/test/unit/bundle-placeholder-minification.test.ts @@ -0,0 +1,79 @@ +// Regression coverage for integrity placeholders surviving production minification: +// rolldown/oxc renders every string literal as a template literal, so any removal +// keyed on exact unminified source text can never match and the unresolved +// `__NUXT_SCRIPT_INTEGRITY_*__` token ships to browser chunks. +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' + +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: vi.fn(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + getItemRaw: vi.fn(), + setItemRaw: vi.fn(), + hasItem: vi.fn().mockResolvedValue(false), + })), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +const COMPONENT_ID = '/app/node_modules/@nuxt/scripts/dist/runtime/components/BuyWidget.vue' +const COMPONENT_DIR = '/app/node_modules/@nuxt/scripts/dist/runtime/components' +const REMOTE_SRC = 'https://example.com/widget.js' + +function makeNuxt() { + return { + options: { + dev: false, + builder: '@nuxt/vite-builder', + buildDir: '.nuxt', + app: { baseURL: '/' }, + runtimeConfig: { app: {} }, + }, + hooks: { hook: vi.fn() }, + } as any +} + +async function deferAndMinify(options?: Partial, graph: Record | undefined = undefined) { + const code = `const instance = useScript('${REMOTE_SRC}', { bundle: true })` + const plugin = NuxtScriptBundleTransformer({ + renderedScript: new Map(), + integrity: true, + fallbackOnSrcOnBundleFail: true, + componentDir: COMPONENT_DIR, + ...options, + nuxt: makeNuxt(), + }).vite() as any + + const transformed = await plugin.transform.handler.call({}, code, `${COMPONENT_ID}?vue&type=script&setup=true&lang.ts`) + expect(transformed?.code).toBeTruthy() + + // Simulate the oxc minifier shape observed in real emitted chunks: whitespace + // squeezed out and every string literal re-quoted with backticks. + const minified = transformed.code.replace(/\s+/g, '').replace(/'/g, '`') + + const getModuleInfo = (id: string) => (graph?.[id] ? { importers: [], dynamicImporters: [] } : undefined) + const bundle = { 'entry.js': { type: 'chunk', code: minified } as any } + await plugin.generateBundle.call({ getModuleInfo }, {}, bundle) + return bundle['entry.js'].code as string +} + +describe('integrity placeholders under minified rendering', () => { + it('unused component leaves no integrity token or crossorigin in the chunk', async () => { + const code = await deferAndMinify() + + expect(code).toContain(REMOTE_SRC) + expect(code).not.toContain('__NUXT_SCRIPT_INTEGRITY_') + expect(code).not.toContain('crossorigin') + }) + + it('fallback bundle leaves no integrity token or crossorigin in the chunk', async () => { + const imported = await deferAndMinify(undefined, { [COMPONENT_ID]: {} }) + + expect(imported).toContain(REMOTE_SRC) + expect(imported).not.toContain('__NUXT_SCRIPT_INTEGRITY_') + expect(imported).not.toContain('crossorigin') + }) +}) diff --git a/test/unit/render-start-concurrent-downloads.test.ts b/test/unit/render-start-concurrent-downloads.test.ts new file mode 100644 index 00000000..e2455b90 --- /dev/null +++ b/test/unit/render-start-concurrent-downloads.test.ts @@ -0,0 +1,184 @@ +// Regression: deferred component bundle downloads must overlap while they resolve +// during output generation. A sequential `for...of` + `await` makes the build +// wall-clock time the sum of every used component's script latency instead of the +// slowest one. +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' + +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) + +const COMPONENT_DIR = '/app/components' +const APP_IMPORTER = '/app/pages/index.vue' + +function makeNuxt() { + return { + options: { + dev: false, + builder: '@nuxt/vite-builder', + buildDir: '.nuxt', + app: { baseURL: '/' }, + runtimeConfig: { app: {} }, + }, + hooks: { hook: vi.fn() }, + } as any +} + +function makePlugin(options: Partial = {}) { + mockBundleStorage.hasItem.mockResolvedValue(false) + return NuxtScriptBundleTransformer({ + renderedScript: new Map(), + integrity: true, + fallbackOnSrcOnBundleFail: true, + componentDir: COMPONENT_DIR, + ...options, + nuxt: makeNuxt(), + }).vite() as any +} + +async function registerComponent(plugin: any, file: string, src: string) { + const code = `const instance = useScript('${src}', { bundle: true })` + const id = `${COMPONENT_DIR}/${file}?vue&type=script&setup=true&lang.ts` + const transformed = await plugin.transform.handler.call({}, code, id) + expect(transformed?.code).toBeTruthy() + return transformed.code +} + +/** + * Run the deferred pipeline the way the bundler does at emit time: an awaited hook + * with the final module graph, then patches applied to every emitted chunk. + */ +async function emitChunks(plugin: any, codes: Record) { + const getModuleInfo = () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }) + const bundle = Object.fromEntries( + Object.entries(codes).map(([name, code]) => [name, { type: 'chunk', code } as any]), + ) + await plugin.generateBundle.call({ getModuleInfo }, {}, bundle) + return bundle as Record +} + +describe('deferred component downloads stay concurrent', () => { + it('starts every used-component download before the first one resolves', async () => { + const calls: string[] = [] + const gates: Array<() => void> = [] + fetchMock.mockImplementation((url: string) => { + calls.push(url) + let openGate!: () => void + const gate = new Promise((resolve) => { + openGate = resolve + }) + gates.push(openGate) + // The download stays in flight until we release its gate. + return gate.then(() => ({ + ok: true, + headers: { get: () => null }, + _data: Buffer.from(`/* ${url} */`), + })) + }) + + const plugin = makePlugin() + await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + await registerComponent(plugin, 'Beta.vue', 'https://example.com/beta.js') + + const running = plugin.generateBundle.call({ + getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }), + }, {}, {}) + expect(running).toBeInstanceOf(Promise) + + // First download started; the response is still pending. + await vi.waitFor(() => expect(calls.length).toBeGreaterThanOrEqual(1)) + // Sequential execution can never reach the second download while the + // first response is parked, so give it ample microtask/scheduler turns. + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(calls).toEqual([ + 'https://example.com/alpha.js', + 'https://example.com/beta.js', + ]) + + gates.forEach(gate => gate()) + await running + }) + + it('still renders both bundles correctly after overlapping downloads', async () => { + fetchMock.mockImplementation((url: string) => Promise.resolve({ + ok: true, + headers: { get: () => null }, + _data: Buffer.from(`/* ${url} */`), + })) + + const plugin = makePlugin() + const codeA = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + const codeB = await registerComponent(plugin, 'Beta.vue', 'https://example.com/beta.js') + + const bundle = await emitChunks(plugin, { 'chunk-alpha.js': codeA, 'chunk-beta.js': codeB }) + + for (const fileName of ['chunk-alpha.js', 'chunk-beta.js'] as const) { + const code = bundle[fileName]!.code + expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(code).not.toContain('__NUXT_SCRIPT_BUNDLE_') + expect(code).not.toContain('https://example.com') + } + }) + + // Regression: during `nuxt build --watch` the bundler module cache persists, so every + // rebuild re-renders chunk code straight from the transform output, which still holds + // the deferred placeholder tokens. Placeholder resolution must keep applying to those + // later emissions instead of stopping after the first one consumed its pendings. + it('resolves leftover placeholders in re-rendered chunks across watch rebuilds', async () => { + fetchMock.mockImplementation((url: string) => Promise.resolve({ + ok: true, + headers: { get: () => null }, + _data: Buffer.from(`/* ${url} */`), + })) + + const plugin = makePlugin() + const codeA = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + const codeB = await registerComponent(plugin, 'Beta.vue', 'https://example.com/beta.js') + + // First emission consumes the pendings. + await emitChunks(plugin, { 'chunk-1.js': codeA }) + + // Rebuild: rollup re-renders the cached transformed module into a fresh chunk, + // so the placeholder tokens are back even though no new pendings registered. + const rebuildBundle = await emitChunks(plugin, { 'chunk-1.js': codeB }) + + const code = rebuildBundle['chunk-1.js']!.code + expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(code).not.toContain('__NUXT_SCRIPT_BUNDLE_') + expect(code).not.toMatch(/__NUXT_SCRIPT_INTEGRITY_[a-f0-9]{16}__/) + }) + + it('fatal download failure still rejects generateBundle when fallback is disabled', async () => { + fetchMock.mockImplementation((url: string) => { + if (url.includes('broken')) { + return Promise.resolve({ ok: false, status: 500, headers: { get: () => null }, _data: undefined, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }) + } + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + _data: Buffer.from(`/* ${url} */`), + }) + }) + + const plugin = makePlugin({ fallbackOnSrcOnBundleFail: false }) + await registerComponent(plugin, 'Good.vue', 'https://example.com/good.js') + await registerComponent(plugin, 'Broken.vue', 'https://example.com/broken.js') + + await expect(plugin.generateBundle.call({ + getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }), + }, {}, {})).rejects.toThrow(/broken\.js/) + }) +})