diff --git a/docs/content/docs/3.api/5.nuxt-config.md b/docs/content/docs/3.api/5.nuxt-config.md index 206f198d..69b89157 100644 --- a/docs/content/docs/3.api/5.nuxt-config.md +++ b/docs/content/docs/3.api/5.nuxt-config.md @@ -49,3 +49,10 @@ Disables the Nuxt Scripts module. - Default: `false` Enable to see debug logs. + +## `fallbackOnSrcOnBundleFail ` + +- Type: `boolean` +- Default: `false` + +Fallback to the remote src URL when `bundle` fails when enabled. By default, the bundling process stops if the third-party script can't be downloaded. diff --git a/src/assets.ts b/src/assets.ts index dd5ab9eb..876efbe7 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -1,47 +1,39 @@ -import fsp from 'node:fs/promises' import { addDevServerHandler, useNuxt } from '@nuxt/kit' import { createError, eventHandler, lazyEventHandler } from 'h3' import { fetch } from 'ofetch' -import { colors } from 'consola/utils' import { defu } from 'defu' import type { NitroConfig } from 'nitropack' -import { hasProtocol, joinURL, parseURL } from 'ufo' +import { joinURL } from 'ufo' import { join } from 'pathe' -import { hash } from 'ohash' import { createStorage } from 'unstorage' import fsDriver from 'unstorage/drivers/fs-lite' -import { logger } from './logger' import type { ModuleOptions } from './module' +const renderedScript = new Map() + const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 +// TODO: refactor to use nitro storage when it can be cached between builds +export const storage = createStorage({ + driver: fsDriver({ + base: 'node_modules/.cache/nuxt/scripts', + }), +}) + // TODO: replace this with nuxt/assets when it is released export function setupPublicAssetStrategy(options: ModuleOptions['assets'] = {}) { const assetsBaseURL = options.prefix || '/_scripts' const nuxt = useNuxt() - const renderedScriptSrc = new Map() - - // TODO: refactor to use nitro storage when it can be cached between builds - const storage = createStorage({ - driver: fsDriver({ - base: 'node_modules/.cache/nuxt/scripts', - }), - }) - - function normalizeScriptData(src: string): string { - if (hasProtocol(src, { acceptRelative: true })) { - src = src.replace(/^\/\//, 'https://') - const url = parseURL(src) - const file = [ - `${hash(url)}.js`, // force an extension - ].filter(Boolean).join('-') - - renderedScriptSrc.set(file, src) - return joinURL(assetsBaseURL, file) - } - return src - } // Register font proxy URL for development addDevServerHandler({ @@ -49,14 +41,16 @@ export function setupPublicAssetStrategy(options: ModuleOptions['assets'] = {}) handler: lazyEventHandler(async () => { return eventHandler(async (event) => { const filename = event.path.slice(1) - const url = renderedScriptSrc.get(event.path.slice(1)) - if (!url) + const scriptDescriptor = renderedScript.get(join(assetsBaseURL, event.path.slice(1))) + + if (!scriptDescriptor || scriptDescriptor instanceof Error) throw createError({ statusCode: 404 }) + const key = `data:scripts:${filename}` // Use storage to cache the font data between requests let res = await storage.getItemRaw(key) if (!res) { - res = await fetch(url).then(r => r.arrayBuffer()).then(r => Buffer.from(r)) + res = await fetch(scriptDescriptor.src).then(r => r.arrayBuffer()).then(r => Buffer.from(r)) await storage.setItemRaw(key, res) } return res @@ -87,47 +81,7 @@ export function setupPublicAssetStrategy(options: ModuleOptions['assets'] = {}) }, } satisfies NitroConfig) - nuxt.hook('nitro:init', async (nitro) => { - if (nuxt.options.dev) - return - nitro.hooks.hook('rollup:before', async () => { - await fsp.rm(cacheDir, { recursive: true, force: true }) - await fsp.mkdir(cacheDir, { recursive: true }) - let banner = false - const failedScriptDownload = new Set<{ url: string, statusText: string, status: number }>() - for (const [filename, url] of renderedScriptSrc) { - const key = `data:scripts:${filename}` - // Use storage to cache the font data between builds - let res = await storage.getItemRaw(key) - if (!res) { - if (!banner) { - banner = true - logger.info('Downloading scripts...') - } - let encoding - let size = 0 - res = await fetch(url).then((r) => { - if (!r.ok) { - failedScriptDownload.add({ url, statusText: r.statusText, status: r.status }) - return Buffer.from('') - } - encoding = r.headers.get('content-encoding') - const contentLength = r.headers.get('content-length') - size = contentLength ? Number(contentLength) / 1024 : 0 - return r.arrayBuffer() - }).then(r => Buffer.from(r)) - logger.log(colors.gray(` ├─ ${url} → ${joinURL(assetsBaseURL, filename)} (${size.toFixed(2)} kB ${encoding})`)) - await storage.setItemRaw(key, res) - } - await fsp.writeFile(join(cacheDir, filename), res) - } - if (failedScriptDownload.size) { - throw new Error(`@nuxt/script: Failed to download scripts:\n${[...failedScriptDownload].map(({ url, statusText, status }) => ` ├─ ${url} (${status} ${statusText})`).join('\n')}`) - } - if (banner) - logger.success('Scripts downloaded and cached.') - }) - }) - - return { normalizeScriptData } + return { + renderedScript + } } diff --git a/src/module.ts b/src/module.ts index c7cceef9..b021ee14 100644 --- a/src/module.ts +++ b/src/module.ts @@ -54,6 +54,12 @@ export interface ModuleOptions { * TODO Make configurable in future. */ strategy?: 'public' + /** + * Fallback to src if bundle fails to load. + * The default behavior is to stop the bundling process if a script fails to be downloaded. + * @default false + */ + fallbackOnSrcOnBundleFail?: boolean } /** * Whether the module is enabled. @@ -188,8 +194,7 @@ ${newScripts.map((i) => { }, }) } - const scriptMap = new Map() - const { normalizeScriptData } = setupPublicAssetStrategy(config.assets) + const { renderedScript } = setupPublicAssetStrategy(config.assets) const moduleInstallPromises: Map Promise | undefined> = new Map() @@ -203,13 +208,9 @@ ${newScripts.map((i) => { if (nuxt.options.dev && module !== '@nuxt/scripts' && !moduleInstallPromises.has(module) && !hasNuxtModule(module)) moduleInstallPromises.set(module, () => installNuxtModule(module)) }, - resolveScript(src) { - if (scriptMap.has(src)) - return scriptMap.get(src) as string - const url = normalizeScriptData(src) - scriptMap.set(src, url) - return url - }, + assetsBaseURL: config.assets?.prefix, + fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail, + renderedScript })) nuxt.hooks.hook('build:done', async () => { diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts index 966f1bee..79d5dbeb 100644 --- a/src/plugins/transform.ts +++ b/src/plugins/transform.ts @@ -1,21 +1,120 @@ +import fsp from 'node:fs/promises' import { createUnplugin } from 'unplugin' import MagicString from 'magic-string' import type { SourceMapInput } from 'rollup' import type { Node } from 'estree-walker' -import { walk } from 'estree-walker' +import { asyncWalk } from 'estree-walker' import type { Literal, ObjectExpression, Property, SimpleCallExpression } from 'estree' import type { InferInput } from 'valibot' +import { hasProtocol, parseURL, joinURL } from 'ufo' +import { hash as ohash } from 'ohash' +import { join } from 'pathe' +import { colors } from 'consola/utils' +import { useNuxt } from '@nuxt/kit' +import { logger } from '../logger' +import { storage } from '../assets' import { isJS, isVue } from './util' import type { RegistryScript } from '#nuxt-scripts' export interface AssetBundlerTransformerOptions { - resolveScript: (src: string) => string moduleDetected?: (module: string) => void defaultBundle?: boolean + assetsBaseURL?: string scripts?: Required[] + fallbackOnSrcOnBundleFail?: boolean + renderedScript?: Map } -export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOptions) { +function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts'): { url: string, filename?: string } { + if (hasProtocol(src, { acceptRelative: true })) { + src = src.replace(/^\/\//, 'https://') + const url = parseURL(src) + const file = [ + `${ohash(url)}.js`, // force an extension + ].filter(Boolean).join('-') + return { url: joinURL(assetsBaseURL, file), filename: file } + } + return { url: src } +} +async function downloadScript(opts: { + src: string, + url: string, + filename?: string +}, renderedScript: NonNullable) { + const { src, url, filename } = opts + if (src === url || !filename) { + return + } + const scriptContent = renderedScript.get(src) + let res: Buffer | undefined = scriptContent instanceof Error ? undefined : scriptContent?.content + if (!res) { + // Use storage to cache the font data between builds + if (await storage.hasItem(`data:scripts:${filename}`)) { + const res = await storage.getItemRaw(`data:scripts:${filename}`) + renderedScript.set(url, { + content: res!, + size: res!.length / 1024, + encoding: 'utf-8', + src, + filename, + }) + + return + } + let encoding + let size = 0 + res = await fetch(src).then((r) => { + if (!r.ok) { + throw new Error(`Failed to fetch ${src}`) + } + encoding = r.headers.get('content-encoding') + const contentLength = r.headers.get('content-length') + size = contentLength ? Number(contentLength) / 1024 : 0 + + return r.arrayBuffer() + }).then(r => Buffer.from(r)) + + storage.setItemRaw(`data:scripts:${filename}`, res) + renderedScript.set(url, { + content: res!, + size, + encoding, + src, + filename, + }) + } +} + +export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOptions = { + renderedScript: new Map() +}) { + const nuxt = useNuxt() + const { renderedScript = new Map() } = options + const cacheDir = join(nuxt.options.buildDir, 'cache', 'scripts') + + // done after all transformation is done + // copy all scripts to build + nuxt.hooks.hook('build:done', async () => { + logger.log('[nuxt:scripts:bundler-transformer] Bundling scripts...') + await fsp.rm(cacheDir, { recursive: true, force: true }) + await fsp.mkdir(cacheDir, { recursive: true }) + await Promise.all([...renderedScript].map(async ([url, content]) => { + if (content instanceof Error || !content.filename) + return + await fsp.writeFile(join(nuxt.options.buildDir, 'cache', 'scripts', content.filename), content.content) + logger.log(colors.gray(` ├─ ${url} → ${joinURL(content.src)} (${content.size.toFixed(2)} kB ${content.encoding})`)) + })) + }) + return createUnplugin(() => { return { name: 'nuxt:scripts:bundler-transformer', @@ -30,8 +129,8 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti const ast = this.parse(code) const s = new MagicString(code) - walk(ast as Node, { - enter(_node) { + await asyncWalk(ast as Node, { + async enter(_node) { // @ts-expect-error untyped const calleeName = (_node as SimpleCallExpression).callee?.name if (!calleeName) @@ -138,15 +237,28 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti }) canBundle = bundleOption ? bundleOption.value.value : canBundle if (canBundle) { - const newSrc = options.resolveScript(src) - if (src === newSrc) { + let { url, filename } = normalizeScriptData(src, options.assetsBaseURL) + try { + await downloadScript({src, url, filename }, renderedScript) + } + catch (e) { + if (options.fallbackOnSrcOnBundleFail) { + logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}. Fallback to remote loading.`) + url = src + } + else { + throw e + } + } + + if (src === url) { if (src && src.startsWith('/')) console.warn(`[Nuxt Scripts: Bundle Transformer] Relative scripts are already bundled. Skipping bundling for \`${src}\`.`) else console.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}.`) } if (scriptSrcNode) { - s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${newSrc}'`) + s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${url}'`) } else { const optionsNode = node.arguments[0] as ObjectExpression @@ -163,14 +275,14 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti (p: any) => p.key?.name === 'src' || p.key?.value === 'src', ) if (srcProperty) - s.overwrite(srcProperty.value.start, srcProperty.value.end, `'${newSrc}'`) + s.overwrite(srcProperty.value.start, srcProperty.value.end, `'${url}'`) else - s.appendRight(scriptInput.end, `, src: '${newSrc}'`) + s.appendRight(scriptInput.end, `, src: '${url}'`) } } else { // @ts-expect-error untyped - s.appendRight(node.arguments[0].start + 1, ` scriptInput: { src: '${newSrc}' }, `) + s.appendRight(node.arguments[0].start + 1, ` scriptInput: { src: '${url}' }, `) } } } diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts index f59b374a..3f3467fc 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -1,13 +1,56 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { parse } from 'acorn-loose' -import { joinURL, parseURL, withBase } from 'ufo' +import { joinURL, withBase, hasProtocol } from 'ufo' import { hash } from 'ohash' import type { AssetBundlerTransformerOptions } from '../../src/plugins/transform' import { NuxtScriptBundleTransformer } from '../../src/plugins/transform' import type { IntercomInput } from '~/src/runtime/registry/intercom' import type { NpmInput } from '~/src/runtime/registry/npm' -async function transform(code: string | string[], options: AssetBundlerTransformerOptions) { +const ohash = (await vi.importActual('ohash')).hash +vi.mock('ohash', async (og) => { + const mod = (await og()) + const mock = vi.fn(mod.hash) + return { + ...mod, + hash: mock, + } +}) + +vi.mock('ufo', async (og) => { + const mod = (await og()) + const mock = vi.fn(mod.hasProtocol) + return { + ...mod, + hasProtocol: mock, + } +}) +vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ arrayBuffer: vi.fn(() => Buffer.from('')), ok: true, headers: { get: vi.fn() } }))) + +vi.mock('@nuxt/kit', async (og) => { + const mod = await og() + + return { + ...mod, + useNuxt() { + return { + options: { + buildDir: '.nuxt', + }, + hooks: { + hook: vi.fn(), + }, + } + }, + } +}) + +// we want to control normalizeScriptData() output +vi.mocked(hasProtocol).mockImplementation(() => true) +// hash receive a URL object, we want to mock it to return the pathname by default +vi.mocked(hash).mockImplementation(src => src.pathname) + +async function transform(code: string | string[], options?: AssetBundlerTransformerOptions) { const plugin = NuxtScriptBundleTransformer(options).vite() as any const res = await plugin.transform.call( { parse: (code: string) => parse(code, { ecmaVersion: 2022, sourceType: 'module', allowImportExportEverywhere: true, allowAwaitOutsideFunction: true }) }, @@ -19,29 +62,23 @@ async function transform(code: string | string[], options: AssetBundlerTransform describe('nuxtScriptTransformer', () => { it('string arg', async () => { + vi.mocked(hash).mockImplementationOnce(() => 'beacon.min') const code = await transform( `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', { bundle: true, })`, - { - resolveScript(src) { - return `/_scripts${parseURL(src).pathname}` - }, - }, + ) expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/beacon.min.js', )"`) }) it('options arg', async () => { + vi.mocked(hash).mockImplementationOnce(() => 'beacon.min') const code = await transform( `const instance = useScript({ defer: true, src: 'https://static.cloudflareinsights.com/beacon.min.js' }, { bundle: true, })`, - { - resolveScript(src) { - return `/_scripts${parseURL(src).pathname}` - }, - }, + ) expect(code).toMatchInlineSnapshot(`"const instance = useScript({ defer: true, src: '/_scripts/beacon.min.js' }, )"`) }) @@ -50,11 +87,6 @@ describe('nuxtScriptTransformer', () => { const code = await transform( // eslint-disable-next-line no-useless-escape `const instance = useScript({ key: 'cloudflareAnalytics', src: \`https://static.cloudflareinsights.com/$\{123\}beacon.min.js\` })`, - { - resolveScript(src) { - return `/_scripts${parseURL(src).pathname}` - }, - }, ) expect(code).toMatchInlineSnapshot(`undefined`) }) @@ -75,9 +107,6 @@ describe('nuxtScriptTransformer', () => { }, }, ], - resolveScript(src) { - return `/_scripts${parseURL(src).pathname}.js` - }, }, ) expect(code).toMatchInlineSnapshot(`"const instance = useScriptFathomAnalytics({ src: '/_scripts/custom.js.js' }, )"`) @@ -99,9 +128,6 @@ describe('nuxtScriptTransformer', () => { }, }, ], - resolveScript(src) { - return `/_scripts${parseURL(src).pathname}.js` - }, }, ) expect(code).toMatchInlineSnapshot(`"const instance = useScriptFathomAnalytics({ scriptInput: { src: '/_scripts/script.js.js' }, site: '123' }, )"`) @@ -123,15 +149,14 @@ describe('nuxtScriptTransformer', () => { }, }, ], - resolveScript(src) { - return `/_scripts${parseURL(src).pathname}.js` - }, + }, ) expect(code).toMatchInlineSnapshot(`undefined`) }) it('dynamic src integration is transformed - default', async () => { + vi.mocked(hash).mockImplementationOnce(src => (src.pathname)) const code = await transform( `const instance = useScriptIntercom({ app_id: '123' })`, { @@ -147,9 +172,7 @@ describe('nuxtScriptTransformer', () => { }, }, ], - resolveScript(src) { - return `/_scripts${parseURL(src).pathname}.js` - }, + }, ) expect(code).toMatchInlineSnapshot(`"const instance = useScriptIntercom({ scriptInput: { src: '/_scripts/widget/123.js' }, app_id: '123' })"`) @@ -171,15 +194,14 @@ describe('nuxtScriptTransformer', () => { }, }, ], - resolveScript(src) { - return `/_scripts${parseURL(src).pathname}.js` - }, + }, ) expect(code).toMatchInlineSnapshot(`undefined`) }) it('dynamic src integration can be opt-in explicit', async () => { + vi.mocked(hash).mockImplementationOnce(src => src.pathname) const code = await transform( `const instance = useScriptIntercom({ app_id: '123' }, { bundle: true })`, { @@ -195,9 +217,7 @@ describe('nuxtScriptTransformer', () => { }, }, ], - resolveScript(src) { - return `/_scripts${parseURL(src).pathname}.js` - }, + }, ) expect(code).toMatchInlineSnapshot(`"const instance = useScriptIntercom({ scriptInput: { src: '/_scripts/widget/123.js' }, app_id: '123' }, )"`) @@ -219,9 +239,6 @@ describe('nuxtScriptTransformer', () => { }, }, ], - resolveScript(src) { - return `/_scripts${parseURL(src).pathname}.js` - }, }, ) expect(code).toMatchInlineSnapshot(` @@ -231,6 +248,7 @@ describe('nuxtScriptTransformer', () => { }) it('useScriptNpm', async () => { + vi.mocked(hash).mockImplementationOnce(src => ohash(src.pathname)) const code = await transform( `const instance = useScriptNpm({ packageName: 'jsconfetti', version: '1.0.0', file: 'dist/index.js' })`, { @@ -246,15 +264,15 @@ describe('nuxtScriptTransformer', () => { }, }, ], - resolveScript(src) { - return `/_scripts/${hash(parseURL(src).pathname)}.js` - }, + }, ) expect(code).toMatchInlineSnapshot(`"const instance = useScriptNpm({ scriptInput: { src: '/_scripts/soMXoYlUxl.js' }, packageName: 'jsconfetti', version: '1.0.0', file: 'dist/index.js' })"`) }) it('useScript broken #1', async () => { + vi.mocked(hash).mockImplementationOnce(src => ohash(src.pathname)) + const code = await transform( `import { defineComponent as _defineComponent } from "vue"; import { useScript } from "#imports"; @@ -277,12 +295,75 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ return __returned__; } });`, - { - resolveScript(src) { - return `/_scripts/${hash(parseURL(src).pathname)}.js` - }, - }, ) expect(code.includes('useScript(\'/_scripts/JvFMRwu6zQ.js\', {')).toBeTruthy() }) + + describe('fallbackOnSrcOnBundleFail', () => { + beforeEach(() => { + vi.mocked(fetch).mockImplementationOnce(() => Promise.reject(new Error('fetch error'))) + }) + + const scripts = [ { + label: 'NPM', + scriptBundling(options?: NpmInput) { + return 'bundle.js' + }, + logo: ``, + category: 'utility', + import: { + name: 'useScriptNpm', + // key is based on package name + from: 'somewhere', + }, + },] + it('should throw error if bundle fails and fallbackOnSrcOnBundleFail is false', async () => { + await expect(async () => await transform(`const { then } = useScriptNpm({ + packageName: 'js-confetti', + file: 'dist/js-confetti.browser.js', + version: '0.12.0', + scriptOptions: { + trigger: useScriptTriggerElement({ trigger: 'mouseover', el: mouseOverEl }), + use() { + return { JSConfetti: window.JSConfetti } + }, + bundle: true + }, +})`, { fallbackOnSrcOnBundleFail: false, scripts})).rejects.toThrow(`fetch error`) + }) + + it('should not throw error if bundle fails and fallbackOnSrcOnBundleFail is true', async () => { + vi.mocked(hash).mockImplementationOnce(src => ohash(src.pathname)) + + vi.mocked(fetch).mockImplementationOnce(() => Promise.reject(new Error('fetch error'))) + + const code = await transform(`const instance = useScriptNpm({ + packageName: 'js-confetti', + file: 'dist/js-confetti.browser.js', + version: '0.12.0', + scriptOptions: { + trigger: useScriptTriggerElement({ trigger: 'mouseover', el: mouseOverEl }), + use() { + return { JSConfetti: window.JSConfetti } + }, + bundle: true + }, +})`, { fallbackOnSrcOnBundleFail: true,scripts }) + expect(code).toMatchInlineSnapshot(` + "const instance = useScriptNpm({ scriptInput: { src: 'bundle.js' }, + packageName: 'js-confetti', + file: 'dist/js-confetti.browser.js', + version: '0.12.0', + scriptOptions: { + trigger: useScriptTriggerElement({ trigger: 'mouseover', el: mouseOverEl }), + use() { + return { JSConfetti: window.JSConfetti } + }, + bundle: true + }, + })" + `) + expect(code).toContain('bundle.js') + }) + }) })