diff --git a/docs/content/docs/3.api/5.nuxt-config.md b/docs/content/docs/3.api/5.nuxt-config.md index e38ecde1..af5fb33f 100644 --- a/docs/content/docs/3.api/5.nuxt-config.md +++ b/docs/content/docs/3.api/5.nuxt-config.md @@ -234,3 +234,24 @@ Cache duration for bundled scripts in milliseconds. Scripts older than this will Generates a Subresource Integrity (SRI) hash for each bundled script and adds `integrity` with `crossorigin="anonymous"`. Browsers compare the downloaded script with its declared hash before executing it; see MDN's [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_Integrity) guide. + +## `assets.alwaysBundle`{lang="ts"} + +- Type: `boolean | string[]`{lang="ts"} +- Default: `false` + +Bundles a component's script even when the build cannot prove the component is used. + +A script component only bundles its script once the module graph shows a real importer, so an unused widget never downloads its SDK. A component used only through `nuxt-client` inside a server component has no importer in the client build, so its script falls back to loading from the third-party origin. Name the component here to bundle it anyway. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + assets: { + alwaysBundle: ['ScriptCalendlyInlineWidget'], + }, + }, +}) +``` + +Pass `true` to bundle every script component regardless of usage. If the build detects this case it warns and names the component to add. diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 87ba4c28..3f748c30 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -295,6 +295,20 @@ export interface ModuleOptions { * @default false */ integrity?: boolean | 'sha256' | 'sha384' | 'sha512' + /** + * Bundle a component's script even when the build cannot prove the component is used. + * + * Auto-registered components are only bundled once the module graph shows a real + * importer, so an unused widget never downloads its SDK. A component used only + * through `nuxt-client` inside a server component has no importer in the client + * graph, so it falls back to loading from the third-party origin. Name it here to + * bundle it anyway, or pass `true` to bundle every component's script. + * + * Values are component names, for example `'ScriptCalendlyInlineWidget'`. + * + * @default false + */ + alwaysBundle?: boolean | string[] } /** * Enable standalone devtools mode. @@ -523,8 +537,9 @@ export default defineNuxtModule({ }) } + const runtimeComponentsDir = await resolvePath('./runtime/components') addComponentsDir({ - path: await resolvePath('./runtime/components'), + path: runtimeComponentsDir, pathPrefix: false, }) @@ -885,6 +900,7 @@ export default defineNuxtModule({ addBuildPlugin(NuxtScriptsCheckScripts()) addBuildPlugin(NuxtScriptBundleTransformer({ nuxt, + componentDir: runtimeComponentsDir, scripts: registryScriptsWithImport, registryConfig: nuxt.options.runtimeConfig.public.scripts as Record | undefined, proxyConfigs, @@ -900,6 +916,7 @@ export default defineNuxtModule({ fetchOptions: config.assets?.fetchOptions, cacheMaxAge: config.assets?.cacheMaxAge, integrity: config.assets?.integrity, + alwaysBundle: config.assets?.alwaysBundle, renderedScript, })) diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 138dc5f0..a08aedf0 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,17 @@ 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 + /** + * Bundle a component's script even when the module graph cannot prove the component is + * used. `true` skips the reachability check for every component; an array names the + * components it applies to. + */ + alwaysBundle?: boolean | string[] scripts?: Required[] /** * Merged configuration from both scripts.registry and runtimeConfig.public.scripts @@ -127,7 +139,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 +151,103 @@ 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 +} + +/** A chunk patch: every `pattern` match is replaced by `value`. */ +interface PlaceholderPatch { + /** + * The literal token `pattern` needs in order to match. Every registered component + * contributes a pattern, so without this the cost of patching one chunk grows with the + * number of widgets in the app. `indexOf` rules a pattern out far faster than running it. + */ + token: string + pattern: RegExp + value: string +} + +// Every placeholder token shares this prefix, so a chunk without it needs no patching. +const PLACEHOLDER_PREFIX = '__NUXT_SCRIPT_' +const PLACEHOLDER_TOKEN_RE = /__NUXT_SCRIPT_(?:BUNDLE|INTEGRITY)_[a-f0-9]+__/g + +/** + * 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 integrityRemovalPatch(placeholderIntegrity: string): PlaceholderPatch { + const token = escapeRegExp(placeholderIntegrity) + return { + token: placeholderIntegrity, + pattern: new RegExp(`,\\s*integrity\\s*:\\s*["'\`]${token}["'\`]\\s*,\\s*crossorigin\\s*:\\s*["'\`][^"'\`]*["'\`]`, 'g'), + value: '', + } +} + +/** + * Match the whole quoted literal, not the bare token. The replacement value is a URL we + * do not control (a registry or user `src` can hold a quote, a backslash or `${`), and the + * minifier picks the quote style, so splicing raw text into an existing literal can emit + * broken or injected JavaScript. Swapping the complete literal lets the value be + * serialized safely with `JSON.stringify`. + */ +function urlPatch(placeholder: string, value: string): PlaceholderPatch { + return { + token: placeholder, + pattern: new RegExp(`(["'\`])${escapeRegExp(placeholder)}\\1`, 'g'), + value: JSON.stringify(value), + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Apply every patch to `code` in a single pass. Matches are collected first, then + * applied in source order, because MagicString throws when two edits overlap and an + * integrity removal can swallow a span another patch also matched. + * + * Each pattern is re-instantiated per call: the patch set is shared, chunks render + * concurrently, and a global RegExp carries `lastIndex` between matches. + */ +function applyPlaceholderPatches(code: string, patches: PlaceholderPatch[]): MagicString | undefined { + const edits: { start: number, end: number, value: string }[] = [] + for (const { token, pattern, value } of patches) { + if (!code.includes(token)) + continue + for (const match of code.matchAll(new RegExp(pattern.source, pattern.flags))) { + if (match[0].length === 0) + break + edits.push({ start: match.index, end: match.index + match[0].length, value }) + } + } + if (!edits.length) + return + edits.sort((a, b) => a.start - b.start) + let s: MagicString | undefined + let appliedEnd = -1 + for (const edit of edits) { + if (edit.start < appliedEnd) + continue + s ??= new MagicString(code) + s.overwrite(edit.start, edit.end, edit.value) + appliedEnd = edit.end + } + return s +} + +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 +332,69 @@ 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, + } +} + +/** + * The reachability check reads the client module graph, where a component used only + * through `nuxt-client` inside a server component has no importer. Naming that component + * here bundles it regardless. + */ +function isAlwaysBundled(componentId: string, alwaysBundle: boolean | string[] | undefined): boolean { + if (typeof alwaysBundle === 'boolean') + return alwaysBundle + if (!alwaysBundle?.length) + return false + const name = componentId.slice(componentId.lastIndexOf('/') + 1).replace(VUE_RE, '') + return alwaysBundle.includes(name) +} + +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 +429,173 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti }) return createUnplugin(() => { + // Keyed by module id so a rebuild of one module replaces its own registrations + // instead of appending stale duplicates. Entries survive every emission: a watch + // rebuild re-renders cached chunks that still carry the placeholder tokens, and a + // build that failed must be able to resolve the same components again. + const componentBundles = new Map() + // Vite builds the client and ssr environments from one config, so this factory runs + // once and both share the state here. Each environment has its own module graph and + // reaches its own verdict, so resolution is keyed by environment. Chunks read their + // patch set from the promise they await, never from a field another environment can + // replace mid-render. + const resolutions = new Map>() + // Which environments judged a component unused. The client graph builds first and + // cannot see a component used only through `nuxt-client`, so a later environment + // finding the same component reachable is what exposes the miss. + const unusedIn = new Map>() + const warnedComponents = new Set() + + /** + * 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 + } + + /** + * Classify every registered component against the final module graph and turn the + * outcome into chunk patches. Reachability is decided fresh on each build: a watch + * rebuild can add or drop the importer that makes a widget used, so a decision cached + * from an earlier build would ship the wrong src. + */ + async function resolvePendingBundles(environment: string, getModuleInfo: (id: string) => { importers?: string[], dynamicImporters?: string[] } | undefined | null): Promise { + const pendings = [...componentBundles.values()].flat() + const resolved: PlaceholderPatch[] = [] + // Downloads overlap across pendings: resolveScriptBundle rethrows fatal failures + // (only falling back when explicitly configured), and Promise.all preserves that. + await Promise.all(pendings.map(async (pending) => { + const used = isAlwaysBundled(pending.componentId, options.alwaysBundle) + || reachesOutsideComponentDir(pending.componentId, getModuleInfo) + if (!used) { + (unusedIn.get(pending.componentId) ?? unusedIn.set(pending.componentId, new Set()).get(pending.componentId)!).add(environment) + resolved.push(urlPatch(pending.placeholderUrl, pending.downloadOptions.src)) + if (pending.placeholderIntegrity) + resolved.push(integrityRemovalPatch(pending.placeholderIntegrity)) + return + } + warnWhenAnotherEnvironmentMissedIt(pending) + + const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) + + resolved.push(urlPatch(pending.placeholderUrl, result.url)) + if (pending.placeholderIntegrity) { + resolved.push(result.integrity + ? urlPatch(pending.placeholderIntegrity, result.integrity) + : integrityRemovalPatch(pending.placeholderIntegrity)) + } + })) + // Return only once every download settled, so a rejected build never hands a + // half-built patch set to the chunks awaiting it. + return resolved + } + + /** + * One environment finding a component reachable after another judged it unused means + * the earlier build shipped the third-party src. The client build cannot see a + * component used only through `nuxt-client` inside a server component, and it writes + * its chunks before the ssr graph exists, so this is reported rather than repaired. + */ + function warnWhenAnotherEnvironmentMissedIt(pending: PendingComponentBundle): void { + const missed = unusedIn.get(pending.componentId) + if (!missed?.size || warnedComponents.has(pending.componentId)) + return + warnedComponents.add(pending.componentId) + const name = pending.componentId.slice(pending.componentId.lastIndexOf('/') + 1).replace(VUE_RE, '') + logger.warn(`[Nuxt Scripts: Bundle Transformer] ${name} is used, but the ${[...missed].join(' and ')} build could not prove it. Its script was not bundled there and loads from ${pending.downloadOptions.src}. To bundle it, add '${name}' to \`scripts.assets.alwaysBundle\` in your Nuxt config.`) + } + + /** + * A surviving token would ship as a script `src` or an SRI hash and break at runtime, + * where it is far harder to trace than here. Every token this transform emits is + * quoted, so a miss means the emitted shape stopped matching what the patch expects. + */ + function assertNoPlaceholders(code: string, fileName: string): void { + const survivors = [...new Set(code.match(PLACEHOLDER_TOKEN_RE) ?? [])] + if (!survivors.length) + return + const details = survivors.map((token) => { + for (const pendings of componentBundles.values()) { + for (const pending of pendings) { + if (token === pending.placeholderUrl || token === pending.placeholderIntegrity) + return `${token} (${pending.componentId}, ${pending.downloadOptions.src})` + } + } + return token + }) + throw new Error(`[Nuxt Scripts: Bundle Transformer] Chunk ${fileName} still holds an unresolved script placeholder: ${details.join(', ')}. This is a bug in the bundle transformer. Please report it at https://github.com/nuxt/scripts/issues.`) + } + + /** + * Patching in `renderChunk` keeps the chunk content hash and the sourcemap honest: + * the bundler computes both after this hook. `generateBundle` is too late, since file + * names are already fixed there, so an upstream script change would ship new code + * under an old cache-busted name. + * + * Resolution is awaited here rather than only in `renderStart` because rolldown can + * render chunks before an awaited `renderStart` settles, which shipped unresolved + * placeholders whenever a download outlived rendering. + */ + function resolveFor(ctx: { environment?: { name?: string }, getModuleInfo: (id: string) => any }, restart: boolean): Promise { + const key = ctx.environment?.name ?? 'default' + let resolution = restart ? undefined : resolutions.get(key) + if (!resolution) { + resolution = resolvePendingBundles(key, id => ctx.getModuleInfo(id)) + resolutions.set(key, resolution) + } + return resolution + } + + const outputHooks: Pick = { + async renderStart() { + // Restart here so a rebuild reaches its verdict against the graph it just built. + await resolveFor(this as any, true) + }, + + async renderChunk(code, chunk, outputOptions) { + const patches = await resolveFor(this as any, false) + + if (!code.includes(PLACEHOLDER_PREFIX)) + return + const s = applyPlaceholderPatches(code, patches) + const patched = s ? s.toString() : code + assertNoPlaceholders(patched, chunk.fileName) + if (!s) + return + // A replacement rarely matches the length of its token, so every later mapping in + // the chunk shifts. Hand the bundler a map of the edit instead of letting it keep + // the pre-patch offsets. + return { + code: patched, + map: outputOptions.sourcemap ? s.generateMap({ hires: 'boundary', source: chunk.fileName }) as SourceMapInput : undefined, + } + }, + } + return { name: 'nuxt:scripts:bundler-transformer', + vite: outputHooks, + transform: { filter: { id: { @@ -275,6 +611,11 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti const s = new MagicString(code) const deferredOps: (() => Promise)[] = [] + // Registrations for this module, replacing any from an earlier build. A changed + // `src` retires its old token, so a stale entry can never download a script the + // module no longer asks for. + const registered: PendingComponentBundle[] = [] + componentBundles.delete(id) parseAndWalk(code, id, (_node) => { const calleeName = (_node as any).callee?.name if (!calleeName) @@ -503,42 +844,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 +895,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 = `${PLACEHOLDER_PREFIX}BUNDLE_${token}__` + const placeholderIntegrity = options.integrity ? `${PLACEHOLDER_PREFIX}INTEGRITY_${token}__` : undefined + registered.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) + }) + } } } } @@ -600,6 +939,9 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti await op() } + if (registered.length) + componentBundles.set(id, registered) + if (s.hasChanged()) { return { code: s.toString(), diff --git a/test/e2e/issue-882-island-widget.test.ts b/test/e2e/issue-882-island-widget.test.ts new file mode 100644 index 00000000..801b8225 --- /dev/null +++ b/test/e2e/issue-882-island-widget.test.ts @@ -0,0 +1,50 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { createResolver } from '@nuxt/kit' +import { 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-island'), + build: true, + browser: false, +}) + +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('widget used only inside a client island', () => { + it('bundles its script when alwaysBundle names the component', async () => { + const chunks = await readClientChunks() + expect(chunks.length, 'expected built client chunks on disk').toBeGreaterThan(0) + + // Reachability reads the client module graph, where a `nuxt-client` island component + // has no importer. Without `alwaysBundle` this build emits no bundled asset at all + // and the widget loads from calendly.com. + const widgetChunk = chunks.find(code => code.includes('initInlineWidget')) + expect(widgetChunk, 'expected a client chunk carrying the inline widget').toBeTruthy() + + // Assert on the component's own call site. The registry composable keeps its default + // `scriptInput.src` pointing at calendly.com, and the component overrides it, so the + // remote host still appears elsewhere in the chunk. + expect(widgetChunk!, 'the island widget must load from the bundled asset').toMatch( + /src:\s*["'`]\/_scripts\/assets\/[a-f0-9]{16}\.js["'`]\s*,\s*integrity\s*:/, + ) + + 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-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..ea8da8de --- /dev/null +++ b/test/e2e/issue-882-used-widget.test.ts @@ -0,0 +1,87 @@ +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<{ name: string, dir: string, code: string }[]> { + 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(async name => ({ + name, + dir: clientChunkDir, + code: await 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(chunk => chunk.code.includes(assetUrl!)) + expect(widgetChunk, 'expected a built client chunk referencing the bundled asset').toBeTruthy() + expect(widgetChunk!.code).toMatch(attrValueRe('integrity', expectedIntegrity)) + expect(widgetChunk!.code).toMatch(attrValueRe('crossorigin', 'anonymous')) + + // The fixture builds with client sourcemaps on. Rewriting the chunk shifts every + // later offset, so the rewrite must hand the bundler a map rather than let it keep + // the pre-patch one. + const map = JSON.parse(await readFile(join(widgetChunk!.dir, `${widgetChunk!.name}.map`), 'utf-8')) + expect(map.mappings, 'the rewritten chunk must still ship a populated sourcemap').toBeTruthy() + + // Unused auto-registered widgets fall back to their remote src; their unresolved + // integrity placeholders must not ship into any production chunk either. + for (const chunk of clientChunks) { + expect(chunk.code, 'a client chunk still contains a placeholder token').not.toContain('__NUXT_SCRIPT_') + } + }) +}) diff --git a/test/fixtures/issue-882-island/app.vue b/test/fixtures/issue-882-island/app.vue new file mode 100644 index 00000000..7523b3c9 --- /dev/null +++ b/test/fixtures/issue-882-island/app.vue @@ -0,0 +1,6 @@ + diff --git a/test/fixtures/issue-882-island/components/WidgetIsland.server.vue b/test/fixtures/issue-882-island/components/WidgetIsland.server.vue new file mode 100644 index 00000000..d558a76a --- /dev/null +++ b/test/fixtures/issue-882-island/components/WidgetIsland.server.vue @@ -0,0 +1,9 @@ + diff --git a/test/fixtures/issue-882-island/nuxt.config.ts b/test/fixtures/issue-882-island/nuxt.config.ts new file mode 100644 index 00000000..f009818a --- /dev/null +++ b/test/fixtures/issue-882-island/nuxt.config.ts @@ -0,0 +1,20 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + assets: { + integrity: true, + // The widget is only used through `nuxt-client` inside a server component, so the + // client module graph shows no importer for it. Without this the client build + // treats it as unused and it loads from the third-party origin. + alwaysBundle: ['ScriptCalendlyInlineWidget'], + }, + }, + experimental: { + componentIslands: { + selectiveClient: true, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/issue-882-island/package.json b/test/fixtures/issue-882-island/package.json new file mode 100644 index 00000000..352055cd --- /dev/null +++ b/test/fixtures/issue-882-island/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} 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..9fa15cca --- /dev/null +++ b/test/fixtures/issue-882-used/nuxt.config.ts @@ -0,0 +1,21 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + assets: { + integrity: true, + }, + }, + experimental: { + componentIslands: { + selectiveClient: true, + }, + }, + // Placeholder resolution rewrites emitted chunks, so build with client sourcemaps on + // to prove the rewrite hands the bundler a usable map instead of stale offsets. + sourcemap: { + client: 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..9e755bbc --- /dev/null +++ b/test/unit/bundle-component-integrity.test.ts @@ -0,0 +1,114 @@ +// 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 chunk hashes are computed. + const ctx = { getModuleInfo: () => ({ importers, dynamicImporters: [] }) } + await plugin.renderStart.call(ctx, {}, {}) + const result = await plugin.renderChunk.call(ctx, transformed.code, { fileName: 'entry.js' }, { sourcemap: false }) + return (result?.code ?? transformed.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')}` + const escaped = expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + // The bundler picks the quote style for every emitted literal, so assert on the value. + expect(code).toMatch(new RegExp(`integrity:\\s*["'\`]${escaped}["'\`]`)) + expect(code).toMatch(/crossorigin:\s*["'`]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..2751fc4c --- /dev/null +++ b/test/unit/bundle-component-reachability.test.ts @@ -0,0 +1,113 @@ +// 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 ctx = { getModuleInfo } + await plugin.renderStart.call(ctx, {}, {}) + const result = await plugin.renderChunk.call(ctx, transformedCode, { fileName: 'entry.js' }, { sourcemap: false }) + return (result?.code ?? transformedCode) 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-deferred-resolution.test.ts b/test/unit/bundle-deferred-resolution.test.ts new file mode 100644 index 00000000..2ba10a51 --- /dev/null +++ b/test/unit/bundle-deferred-resolution.test.ts @@ -0,0 +1,332 @@ +// Regression coverage for the deferred component bundling contract: +// - placeholders are patched while the bundler can still hash and map the chunk +// - replacement values are serialized, never spliced into an existing literal +// - reachability is decided against the module graph of the build being emitted +// - a build that failed leaves its registrations intact for the next attempt +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { logger } from '../../packages/script/src/logger' +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().mockResolvedValue(false), +} +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: vi.fn(() => mockBundleStorage), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +const COMPONENT_DIR = '/app/node_modules/@nuxt/scripts/dist/runtime/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 = {}) { + return NuxtScriptBundleTransformer({ + renderedScript: new Map(), + fallbackOnSrcOnBundleFail: true, + componentDir: COMPONENT_DIR, + ...options, + nuxt: makeNuxt(), + }).vite() as any +} + +async function registerComponent(plugin: any, file: string, src: string): Promise { + const code = `const instance = useScript(${JSON.stringify(src)}, { bundle: true })` + const transformed = await plugin.transform.handler.call({}, code, `${COMPONENT_DIR}/${file}?vue&type=script&setup=true&lang.ts`) + expect(transformed?.code, 'the component must register a deferred placeholder').toContain('__NUXT_SCRIPT_BUNDLE_') + return transformed.code as string +} + +function makeContext(importers: string[]) { + return { getModuleInfo: () => ({ importers, dynamicImporters: [] }) } +} + +async function render(plugin: any, code: string, importers: string[], outputOptions: any = { sourcemap: false }) { + const ctx = makeContext(importers) + await plugin.renderStart.call(ctx, {}, {}) + return plugin.renderChunk.call(ctx, code, { fileName: 'entry.js' }, outputOptions) +} + +function mockDownload(body = '/* widget */') { + fetchMock.mockImplementation(() => Promise.resolve({ + ok: true, + headers: { get: () => null }, + _data: Buffer.from(body), + })) +} + +/** Execute the emitted chunk with a stubbed `useScript` and return its first argument. */ +function evaluateScriptArg(code: string): any { + const calls: any[] = [] + // eslint-disable-next-line no-new-func + const run = new Function('useScript', code) + run((...args: any[]) => calls.push(args)) + expect(calls, 'the emitted chunk must still call useScript once').toHaveLength(1) + return calls[0]![0] +} + +describe('deferred component bundle resolution', () => { + beforeEach(() => { + fetchMock.mockReset() + }) + + it('patches the chunk in renderChunk so the bundler can still hash and map it', async () => { + mockDownload() + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + // generateBundle runs after file names are fixed, so patching there would ship new + // code under a stale cache-busted name and an unshifted sourcemap. + expect(plugin.generateBundle).toBeUndefined() + + const result = await render(plugin, code, [APP_IMPORTER], { sourcemap: true }) + + expect(result.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(result.code).not.toContain('__NUXT_SCRIPT_') + expect(result.map?.mappings, 'a rewritten chunk must carry a sourcemap for the shifted offsets').toBeTruthy() + }) + + it('leaves an untouched chunk alone', async () => { + mockDownload() + const plugin = makePlugin() + await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const result = await render(plugin, 'export const unrelated = 1', [APP_IMPORTER], { sourcemap: true }) + + expect(result, 'a chunk with no placeholder must not be rewritten').toBeFalsy() + }) + + it('serializes an unused component src that carries quote characters', async () => { + // eslint-disable-next-line no-template-curly-in-string -- a literal `${` in the src is the point + const src = 'https://example.com/w.js?a=\'b&c="d&e=`f&g=${h}' + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Quoted.vue', src) + + // No importer leaves the components dir, so the widget keeps its remote src. + const result = await render(plugin, code, []) + + expect(fetchMock, 'an unused widget must not download').not.toHaveBeenCalled() + expect(evaluateScriptArg(result.code)).toBe(src) + }) + + it('serializes into a chunk the minifier re-quoted with template literals', async () => { + // eslint-disable-next-line no-template-curly-in-string -- a literal `${` in the src is the point + const src = 'https://example.com/w.js?a=`b&c=${d}' + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Quoted.vue', src) + // oxc/rolldown normalize every string literal to a template literal. + const minified = code.replace(/'/g, '`') + + const result = await render(plugin, minified, []) + + expect(evaluateScriptArg(result.code)).toBe(src) + }) + + it('re-decides reachability on every build instead of reusing the first verdict', async () => { + mockDownload() + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const unused = await render(plugin, code, []) + expect(unused.code).toContain('https://example.com/alpha.js') + expect(fetchMock).not.toHaveBeenCalled() + + // A watch rebuild adds the page that uses the widget: the same chunk must now + // resolve to the bundled asset rather than the verdict cached from build one. + const used = await render(plugin, code, [APP_IMPORTER]) + expect(used.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(used.code).not.toContain('https://example.com/alpha.js') + }) + + it('keeps registrations after a failed build so the retry resolves them', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ + ok: false, + status: 500, + headers: { get: () => null }, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + })) + const plugin = makePlugin({ fallbackOnSrcOnBundleFail: false }) + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + await expect(render(plugin, code, [APP_IMPORTER])).rejects.toThrow(/alpha\.js/) + + mockDownload() + const retry = await render(plugin, code, [APP_IMPORTER]) + + expect(retry.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(retry.code).not.toContain('__NUXT_SCRIPT_') + }) + + it('patches only the placeholders a chunk actually holds', async () => { + // Every auto-registered widget contributes patches, but a chunk carries the tokens of + // one or two of them. Skipping the rest must not skip one that does match, and must + // not leak another widget's resolved value into this chunk. + mockDownload() + const plugin = makePlugin({ integrity: true }) + const names = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'] + const codes: Record = {} + for (const name of names) + codes[name] = await registerComponent(plugin, `${name}.vue`, `https://example.com/${name.toLowerCase()}.js`) + + // Charlie is used, so it bundles; the rest are unreachable and keep their remote src. + const ctx = { + getModuleInfo: (id: string) => ({ + importers: id === `${COMPONENT_DIR}/Charlie.vue` ? [APP_IMPORTER] : [], + dynamicImporters: [], + }), + } + await plugin.renderStart.call(ctx, {}, {}) + const patched = await plugin.renderChunk.call(ctx, codes.Charlie!, { fileName: 'charlie.js' }, { sourcemap: false }) + + expect(patched.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(patched.code).not.toContain('__NUXT_SCRIPT_') + for (const name of names.filter(n => n !== 'Charlie')) + expect(patched.code, `${name} must not bleed into Charlie's chunk`).not.toContain(`${name.toLowerCase()}.js`) + + // A sibling chunk still resolves against the same shared patch set. + const sibling = await plugin.renderChunk.call(ctx, codes.Echo!, { fileName: 'echo.js' }, { sourcemap: false }) + expect(evaluateScriptArg(sibling.code)).toEqual({ src: 'https://example.com/echo.js' }) + expect(sibling.code).not.toContain('__NUXT_SCRIPT_') + expect(sibling.code).not.toContain('crossorigin') + }) + + it('drops a stale registration when the component changes its src', async () => { + mockDownload() + const plugin = makePlugin() + await registerComponent(plugin, 'Alpha.vue', 'https://example.com/old.js') + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/new.js') + + await render(plugin, code, [APP_IMPORTER]) + + expect(fetchMock.mock.calls.map(call => call[0])).toEqual(['https://example.com/new.js']) + }) +}) + +describe('deferred resolution guards', () => { + beforeEach(() => { + fetchMock.mockReset() + }) + + it('fails the build when a placeholder survives patching', async () => { + mockDownload() + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + // A minifier that splits the literal leaves the token unquoted, so no patch matches. + const split = code.replace(/(["'`])(__NUXT_SCRIPT_BUNDLE_[a-f0-9]+__)\1/, '$1$2') + + const ctx = makeContext([APP_IMPORTER]) + await plugin.renderStart.call(ctx, {}, {}) + + await expect(plugin.renderChunk.call(ctx, split, { fileName: 'entry.js' }, { sourcemap: false })) + .rejects + .toThrow(/unresolved script placeholder.*Alpha\.vue.*alpha\.js/s) + }) + + it('reaches a separate verdict per build environment', async () => { + mockDownload() + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + // One plugin instance serves both environments. A chunk must read the patch set of + // its own environment, not whichever one resolved most recently. + const client = { environment: { name: 'client' }, getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }) } + const ssr = { environment: { name: 'ssr' }, getModuleInfo: () => ({ importers: [], dynamicImporters: [] }) } + + await plugin.renderStart.call(client, {}, {}) + await plugin.renderStart.call(ssr, {}, {}) + + const clientChunk = await plugin.renderChunk.call(client, code, { fileName: 'client.js' }, { sourcemap: false }) + const ssrChunk = await plugin.renderChunk.call(ssr, code, { fileName: 'ssr.js' }, { sourcemap: false }) + + expect(clientChunk.code, 'the used client verdict must survive the ssr resolution').toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(ssrChunk.code).toContain('https://example.com/alpha.js') + }) +}) + +describe('alwaysBundle escape hatch', () => { + beforeEach(() => { + fetchMock.mockReset() + }) + + it('bundles a named component the module graph cannot prove is used', async () => { + mockDownload() + const plugin = makePlugin({ alwaysBundle: ['Alpha'] }) + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + // No importer leaves the components dir, which is what a `nuxt-client` island looks + // like in the client graph. + const result = await render(plugin, code, []) + + expect(result.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(result.code).not.toContain('https://example.com/alpha.js') + }) + + it('leaves components it does not name alone', async () => { + mockDownload() + const plugin = makePlugin({ alwaysBundle: ['SomethingElse'] }) + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const result = await render(plugin, code, []) + + expect(fetchMock).not.toHaveBeenCalled() + expect(result.code).toContain('https://example.com/alpha.js') + }) + + it('true bundles every component regardless of reachability', async () => { + mockDownload() + const plugin = makePlugin({ alwaysBundle: true }) + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const result = await render(plugin, code, []) + + expect(result.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + }) + + it('warns when one environment proves a component another environment missed', async () => { + mockDownload() + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const plugin = makePlugin() + await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const client = { environment: { name: 'client' }, getModuleInfo: () => ({ importers: [], dynamicImporters: [] }) } + const ssr = { environment: { name: 'ssr' }, getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }) } + await plugin.renderStart.call(client, {}, {}) + await plugin.renderStart.call(ssr, {}, {}) + + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/Alpha is used, but the client build could not prove it.*alwaysBundle/s)) + warn.mockRestore() + }) + + it('stays quiet when every environment agrees', async () => { + mockDownload() + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const plugin = makePlugin() + await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const client = { environment: { name: 'client' }, getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }) } + const ssr = { environment: { name: 'ssr' }, getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }) } + await plugin.renderStart.call(client, {}, {}) + await plugin.renderStart.call(ssr, {}, {}) + + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/test/unit/bundle-placeholder-minification.test.ts b/test/unit/bundle-placeholder-minification.test.ts new file mode 100644 index 00000000..34ff9203 --- /dev/null +++ b/test/unit/bundle-placeholder-minification.test.ts @@ -0,0 +1,84 @@ +// 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' +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 deferAndMinify(options?: Partial, importers: string[] = []) { + 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 ctx = { getModuleInfo: (id: string) => (id === COMPONENT_ID ? { importers, dynamicImporters: [] } : { importers: [], dynamicImporters: [] }) } + await plugin.renderStart.call(ctx, {}, {}) + const result = await plugin.renderChunk.call(ctx, minified, { fileName: 'entry.js' }, { sourcemap: false }) + return (result?.code ?? minified) 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('used component whose download fails falls back to the remote src without crossorigin', async () => { + // A used component takes the download path; the failed fetch resolves no integrity + // hash, so the whole integrity + crossorigin span has to go. + fetchMock.mockRejectedValue(new Error('network down')) + const imported = await deferAndMinify(undefined, [APP_IMPORTER]) + + expect(fetchMock, 'the used component must attempt its download').toHaveBeenCalled() + 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..06f66c4f --- /dev/null +++ b/test/unit/render-start-concurrent-downloads.test.ts @@ -0,0 +1,187 @@ +// 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 while rendering: an awaited + * `renderStart` with the final module graph, then `renderChunk` per emitted chunk. + * Patching before the chunk hash is computed is what keeps cache-busting names honest. + */ +async function emitChunks(plugin: any, codes: Record, importers: string[] = [APP_IMPORTER]) { + const ctx = { getModuleInfo: () => ({ importers, dynamicImporters: [] }) } + await plugin.renderStart.call(ctx, {}, {}) + const out: Record = {} + for (const [name, code] of Object.entries(codes)) { + const result = await plugin.renderChunk.call(ctx, code, { fileName: name }, { sourcemap: false }) + out[name] = (result?.code ?? code) as string + } + return out +} + +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.renderStart.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]! + 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']! + 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.renderStart.call({ + getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }), + }, {}, {})).rejects.toThrow(/broken\.js/) + }) +})