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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
275 changes: 236 additions & 39 deletions packages/script/src/plugins/transform.ts

Large diffs are not rendered by default.

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_')
}
})
})
77 changes: 77 additions & 0 deletions test/e2e/issue-882-used-widget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { createHash } from 'node:crypto'
import { readdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createResolver } from '@nuxt/kit'
import { $fetch, setup, useTestContext } from '@nuxt/test-utils/e2e'
import { describe, expect, it } from 'vitest'

const { resolve } = createResolver(import.meta.url)

await setup({
rootDir: resolve('../fixtures/issue-882-used'),
build: true,
browser: false,
})

/**
* Minifiers pick the quoting style per string literal (esbuild preserves the
* source quotes, oxc/rolldown normalizes to template literals), so attribute
* assertions must accept every quoting form.
*/
function attrValueRe(name: string, value: string): RegExp {
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(`${name}\\s*:\\s*["'\`]${escaped}["'\`]`)
}

/**
* Read the emitted client chunks of the running test-utils build. Chunk naming
* and island import-map reachability differ between a manual `nuxt build`
* (.output) and @nuxt/test-utils builds (.nuxt/test/<id>/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<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('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 clientChunks = await readClientChunks()
expect(clientChunks.length, 'expected built client chunks on disk').toBeGreaterThan(0)
const widgetChunk = clientChunks.find(code => code.includes(assetUrl!))
expect(widgetChunk, 'expected a built client chunk referencing the bundled asset').toBeTruthy()
expect(widgetChunk!).toMatch(attrValueRe('integrity', expectedIntegrity))
expect(widgetChunk!).toMatch(attrValueRe('crossorigin', 'anonymous'))

// Unused auto-registered widgets fall back to their remote src; their unresolved
// integrity placeholders must not ship into any production chunk either.
for (const code of clientChunks) {
expect(code, 'a client chunk still contains a placeholder token').not.toContain('__NUXT_SCRIPT_')
}
})
})
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>
16 changes: 16 additions & 0 deletions test/fixtures/issue-882-used/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineNuxtConfig } from 'nuxt/config'

export default defineNuxtConfig({
modules: ['@nuxt/scripts'],
scripts: {
assets: {
integrity: true,
},
},
experimental: {
componentIslands: {
selectiveClient: true,
},
},
compatibilityDate: '2024-07-05',
})
3 changes: 3 additions & 0 deletions test/fixtures/issue-882-used/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"private": true
}
3 changes: 3 additions & 0 deletions test/fixtures/issue-882/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<main>Nuxt Scripts</main>
</template>
20 changes: 20 additions & 0 deletions test/fixtures/issue-882/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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',
})
3 changes: 3 additions & 0 deletions test/fixtures/issue-882/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"private": true
}
112 changes: 112 additions & 0 deletions test/unit/bundle-component-integrity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Reproduction for the deferred component bundling integrity finding:
// when a bundled script falls back to its remote src (or no hash resolves),
// the rewrite must not leave `integrity: ''` paired with
// `crossorigin: 'anonymous'`, which silently forces CORS request mode.
import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform'
import { createHash } from 'node:crypto'
import { hash } from 'ohash'
import { hasProtocol } from 'ufo'
import { describe, expect, it, vi } from 'vitest'
import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform'

vi.mock('ohash', async (og) => {
const mod = await og<typeof import('ohash')>()
return { ...mod, hash: vi.fn(mod.hash) }
})
vi.mock('ufo', async (og) => {
const mod = await og<typeof import('ufo')>()
return { ...mod, hasProtocol: vi.fn(mod.hasProtocol) }
})

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<AssetBundlerTransformerOptions>, importers: string[]) {
mockBundleStorage.hasItem.mockResolvedValue(false)
const code = `const instance = useScript('https://example.com/widget.js', { bundle: true })`
const plugin = NuxtScriptBundleTransformer({
renderedScript: new Map(),
integrity: true,
fallbackOnSrcOnBundleFail: true,
componentDir: COMPONENT_DIR,
...options,
nuxt: makeNuxt(),
}).vite() as any

const transformed = await plugin.transform.handler.call({}, code, `${COMPONENT_ID}?vue&type=script&setup=true&lang.ts`)
expect(transformed?.code).toBeTruthy()

// Simulate rollup's final module graph before files are written.
const getModuleInfo = () => ({ importers, dynamicImporters: [] })
const bundle = { 'entry.js': { type: 'chunk', code: transformed.code } as any }
await plugin.generateBundle.call({ getModuleInfo }, {}, bundle)
return bundle['entry.js'].code as string
}

describe('deferred component bundling integrity placeholders', () => {
it('bundle falls back to remote src -> no empty integrity and no crossorigin', async () => {
fetchMock.mockRejectedValue(new Error('network down'))
const code = await buildComponentChunk({}, [APP_IMPORTER])

expect(code).toContain('https://example.com/widget.js')
expect(code).not.toContain(`crossorigin`)
expect(code).not.toMatch(/integrity:\s*['"`]['"`]/)
})

it('unused component falls back to remote src -> no empty integrity and no crossorigin', async () => {
fetchMock.mockRejectedValue(new Error('network down'))
const code = await buildComponentChunk({}, [])

expect(code).toContain('https://example.com/widget.js')
expect(code).not.toContain(`crossorigin`)
expect(code).not.toMatch(/integrity:\s*['"`]['"`]/)
})

it('successful bundle keeps the real integrity hash with crossorigin', async () => {
const body = Buffer.from('/* widget */ console.log("widget")')
fetchMock.mockResolvedValue({
ok: true,
arrayBuffer: () => Promise.resolve(body),
headers: { get: () => null },
_data: body,
})
const code = await buildComponentChunk({}, [APP_IMPORTER])

const expected = `sha384-${createHash('sha384').update(body).digest('base64')}`
expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/)
expect(code).toContain(`integrity: '${expected}'`)
expect(code).toContain(`crossorigin: 'anonymous'`)
})
})
Loading
Loading