Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
217 changes: 178 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,26 @@ 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
}

/**
* Every rewrite emits `, integrity: '<placeholder>', crossorigin: 'anonymous'` as one
* unit, so dropping an unresolved hash must remove that whole span: replacing only the
* placeholder would leave `integrity: ''` plus crossorigin, which forces CORS request
* mode and breaks origins serving scripts without CORS headers.
*/
function integrityPlaceholderRemoval(placeholderIntegrity: string): string {
return `, integrity: '${placeholderIntegrity}', crossorigin: 'anonymous'`
}

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 +249,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 +332,75 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
})

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

const outputHooks: Pick<VitePlugin, 'renderChunk' | 'renderStart'> = {
async renderStart() {
// Downloads must overlap: awaiting inside the loop would make the build
// wall-clock time the sum of every script's latency. Each item keeps its
// own catch/rethrow semantics (fallback or fatal) via resolveScriptBundle;
// Promise.all preserves fatal-error propagation.
const settled = await Promise.all(pendingComponentBundles.map(async (pending): Promise<
{ pending: PendingComponentBundle, result?: { integrity?: string, url: string } }
> => {
const componentInfo = this.getModuleInfo(pending.componentId)
const isUnusedComponent = componentInfo
&& componentInfo.importers.length === 0
&& componentInfo.dynamicImporters.length === 0

if (isUnusedComponent)
return { pending }

return { pending, result: await resolveScriptBundle(pending.downloadOptions, renderedScript, options) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}))
for (const { pending, result } of settled) {
if (!result) {
bundleReplacements.set(pending.placeholderUrl, pending.downloadOptions.src)
if (pending.placeholderIntegrity)
bundleReplacements.set(integrityPlaceholderRemoval(pending.placeholderIntegrity), '')
continue
}

bundleReplacements.set(pending.placeholderUrl, result.url)
if (pending.placeholderIntegrity) {
bundleReplacements.set(
result.integrity
? pending.placeholderIntegrity
: integrityPlaceholderRemoval(pending.placeholderIntegrity),
result.integrity ?? '',
)
}
}
pendingComponentBundles.length = 0
},

renderChunk(code, chunk) {
const s = new MagicString(code)
for (const [placeholder, replacement] of bundleReplacements) {
let offset = 0
while (offset < code.length) {
const index = code.indexOf(placeholder, offset)
if (index === -1)
break
s.overwrite(index, index + placeholder.length, replacement)
offset = index + placeholder.length
}
}
if (s.hasChanged()) {
return {
code: s.toString(),
map: s.generateMap({ includeContent: true, source: chunk.fileName }) as SourceMapInput,
}
}
},
}

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

vite: outputHooks,

transform: {
filter: {
id: {
Expand Down Expand Up @@ -503,42 +644,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 +695,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
17 changes: 17 additions & 0 deletions test/e2e/issue-882-unused-widget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } 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,
})

describe('unused script widgets', () => {
it('builds without downloading their scripts', async () => {
await expect($fetch('/')).resolves.toContain('Nuxt Scripts')
})
})
76 changes: 76 additions & 0 deletions test/e2e/issue-882-used-widget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { createHash } from 'node:crypto'
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } 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,
})

const ABS_CHUNK_RE = /\/_nuxt\/[\w-]+\.js/g
const REL_CHUNK_RE = /\.\/([\w-]+\.js)/g

/**
* Walk the built client module graph over HTTP, starting from the entry chunk
* referenced by the served page's import map, until we find the chunk that
* carries the rewritten `useScript*` call for the bundled widget. Chunks are
* connected by both absolute (`/_nuxt/x.js`) and relative (`./x.js`) specifiers.
*/
async function findWidgetChunk(entryUrl: string, marker: string): Promise<string[]> {
const queue = [entryUrl]
const visited = new Set<string>()
const hits: string[] = []
let guard = 0
while (queue.length && guard < 200) {
guard++
const url = queue.shift()!
if (visited.has(url))
continue
visited.add(url)
const code = await $fetch(url)
const refs = new Set<string>()
for (const ref of code.match(ABS_CHUNK_RE) || [])
refs.add(ref)
for (const ref of code.match(REL_CHUNK_RE) || [])
refs.add(`/_nuxt/${ref.slice(2)}`)
for (const ref of refs) {
if (!visited.has(ref))
queue.push(ref)
}
if (code.includes(marker))
hits.push(code)
}
return hits
}

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/<hash>.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 entry = html.match(/"#entry":"(\/_nuxt\/[\w-]+\.js)"/)?.[1]
expect(entry, 'expected an entry chunk in the served page import map').toBeTruthy()
const widgetChunks = await findWidgetChunk(entry!, assetUrl!)
expect(widgetChunks.length, 'expected a built client chunk referencing the bundled asset').toBeGreaterThan(0)
const rewritten = widgetChunks.join('\n')
expect(rewritten).toContain(`integrity:\`${expectedIntegrity}\``)
expect(rewritten).toContain(`crossorigin:\`anonymous\``)
})
})
9 changes: 9 additions & 0 deletions test/fixtures/issue-882-used/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template>
<main>
Nuxt Scripts
<ScriptCalendlyInlineWidget
url="https://calendly.com/example/30min"
trigger="onNuxtReady"
/>
</main>
</template>
Loading
Loading