Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,8 +523,9 @@ export default defineNuxtModule<ModuleOptions>({
})
}

const runtimeComponentsDir = await resolvePath('./runtime/components')
addComponentsDir({
path: await resolvePath('./runtime/components'),
path: runtimeComponentsDir,
pathPrefix: false,
})

Expand Down Expand Up @@ -885,6 +886,7 @@ export default defineNuxtModule<ModuleOptions>({
addBuildPlugin(NuxtScriptsCheckScripts())
addBuildPlugin(NuxtScriptBundleTransformer({
nuxt,
componentDir: runtimeComponentsDir,
scripts: registryScriptsWithImport,
registryConfig: nuxt.options.runtimeConfig.public.scripts as Record<string, any> | undefined,
proxyConfigs,
Expand Down
265 changes: 226 additions & 39 deletions packages/script/src/plugins/transform.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<RegistryScript>[]
/**
* Merged configuration from both scripts.registry and runtimeConfig.public.scripts
Expand Down Expand Up @@ -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
Expand All @@ -138,7 +145,34 @@ async function downloadScript(opts: {
skipApiRewrites?: boolean
neutralizeCanvas?: boolean
assetsBaseURL?: string
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, 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<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions, cacheMaxAge?: number): Promise<{ url: string, filename?: string } | undefined> {
const { src, url, filename, forceDownload, integrity, proxyRewrites, sdkPatches, skipApiRewrites, neutralizeCanvas, assetsBaseURL } = opts
if (src === url || !filename) {
return
Expand Down Expand Up @@ -223,6 +257,55 @@ async function downloadScript(opts: {
return { url: publicUrl, filename: publicFilename }
}

async function resolveScriptBundle(
downloadOptions: DownloadScriptOptions,
renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>,
options: Pick<AssetBundlerTransformerOptions, 'cacheMaxAge' | 'fallbackOnSrcOnBundleFail' | 'fetchOptions'>,
): 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(),
}) {
Expand Down Expand Up @@ -257,9 +340,115 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
})

return createUnplugin(() => {
const pendingComponentBundles: PendingComponentBundle[] = []
const replacements = new Map<string | RegExp, string>()

/**
* 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<string>()
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<VitePlugin, 'generateBundle'> = {
async generateBundle(_outputOptions, bundle) {
if (pendingComponentBundles.length === 0)
return

// 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(pendingComponentBundles.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 ?? '',
)
}
}))
pendingComponentBundles.length = 0

// 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
},
}

return {
name: 'nuxt:scripts:bundler-transformer',

vite: outputHooks,

transform: {
filter: {
id: {
Expand Down Expand Up @@ -503,42 +692,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') {
Expand Down Expand Up @@ -589,7 +743,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)
})
}
}
}
}
Expand Down
45 changes: 45 additions & 0 deletions test/e2e/issue-882-unused-widget.test.ts
Original file line number Diff line number Diff line change
@@ -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<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(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_')
}
})
})
Loading
Loading