diff --git a/docs/content/docs/1.guides/2.bundling.md b/docs/content/docs/1.guides/2.bundling.md index a36795b3..c0e6cd28 100644 --- a/docs/content/docs/1.guides/2.bundling.md +++ b/docs/content/docs/1.guides/2.bundling.md @@ -69,6 +69,11 @@ To decide if an individual script should be bundled, use the `bundle` option. useScript('https://example.com/script.js', { bundle: true, }) + +// Force download bypassing cache +useScript('https://example.com/script.js', { + bundle: 'force', +}) ``` ```ts [Registry Script] @@ -79,9 +84,27 @@ useScriptGoogleAnalytics({ bundle: true } }) + +// bundle without cache +useScriptGoogleAnalytics({ + id: 'GA_MEASUREMENT_ID', + scriptOptions: { + bundle: 'force' + } +}) ``` :: +#### Bundle Options + +The `bundle` option accepts the following values: + +- `false` - Do not bundle the script (default) +- `true` - Bundle the script and use cached version if available +- `'force'` - Bundle the script and force download, bypassing cache + +**Note**: Using `'force'` will re-download scripts on every build, which may increase build time and provide less security. + ### Global Bundling Adjust the default behavior for all scripts using the Nuxt Config. This example sets all scripts to be bundled by default. @@ -221,18 +244,31 @@ $script.add({ }) ``` -### Change Asset Behavior +### Asset Configuration -Use the `assets` option in your configuration to customize how scripts are bundled, such as changing the output directory for the bundled scripts. +Use the `assets` option in your configuration to customize how scripts are bundled and cached. ```ts [nuxt.config.ts] export default defineNuxtConfig({ scripts: { assets: { prefix: '/_custom-script-path/', + cacheMaxAge: 86400000, // 1 day in milliseconds } } }) ``` -More configuration options will be available in future updates. +#### Available Options + +- **`prefix`** - Custom path where bundled scripts are served (default: `/_scripts/`) +- **`cacheMaxAge`** - Cache duration for bundled scripts in milliseconds (default: 7 days) + +#### Cache Behavior + +The bundling system uses two different cache strategies: + +- **Build-time cache**: Controlled by `cacheMaxAge` (default: 7 days). Scripts older than this are re-downloaded during builds to ensure freshness. +- **Runtime cache**: Bundled scripts are served with 1-year cache headers since they are content-addressed by hash. + +This dual approach ensures both build performance and reliable browser caching. diff --git a/src/assets.ts b/src/assets.ts index e0b5a386..8b113595 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -21,6 +21,10 @@ const renderedScript = new Map() +/** + * Cache duration for bundled scripts in production (1 year). + * Scripts are cached with long expiration since they are content-addressed by hash. + */ const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 // TODO: refactor to use nitro storage when it can be cached between builds diff --git a/src/module.ts b/src/module.ts index 46e6c189..54c87941 100644 --- a/src/module.ts +++ b/src/module.ts @@ -62,6 +62,12 @@ export interface ModuleOptions { * Configure the fetch options used for downloading scripts. */ fetchOptions?: FetchOptions + /** + * Cache duration for bundled scripts in milliseconds. + * Scripts older than this will be re-downloaded during builds. + * @default 604800000 (7 days) + */ + cacheMaxAge?: number } /** * Whether the module is enabled. @@ -235,6 +241,7 @@ export {}` assetsBaseURL: config.assets?.prefix, fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail, fetchOptions: config.assets?.fetchOptions, + cacheMaxAge: config.assets?.cacheMaxAge, renderedScript, })) diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts index 7bc3d4a3..da78fb60 100644 --- a/src/plugins/transform.ts +++ b/src/plugins/transform.ts @@ -18,13 +18,25 @@ import { bundleStorage } from '../assets' import { isJS, isVue } from './util' import type { RegistryScript } from '#nuxt-scripts/types' +const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000 + +export async function isCacheExpired(storage: any, filename: string, cacheMaxAge: number = SEVEN_DAYS_IN_MS): Promise { + const metaKey = `bundle-meta:${filename}` + const meta = await storage.getItem(metaKey) + if (!meta || !meta.timestamp) { + return true // No metadata means expired/invalid cache + } + return Date.now() - meta.timestamp > cacheMaxAge +} + export interface AssetBundlerTransformerOptions { moduleDetected?: (module: string) => void - defaultBundle?: boolean + defaultBundle?: boolean | 'force' assetsBaseURL?: string scripts?: Required[] fallbackOnSrcOnBundleFail?: boolean fetchOptions?: FetchOptions + cacheMaxAge?: number renderedScript?: Map, fetchOptions?: FetchOptions) { - const { src, url, filename } = opts + forceDownload?: boolean +}, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number) { + const { src, url, filename, forceDownload } = opts if (src === url || !filename) { return } @@ -66,8 +79,11 @@ async function downloadScript(opts: { 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(`bundle:${filename}`)) { - const res = await storage.getItemRaw(`bundle:${filename}`) + const cacheKey = `bundle:${filename}` + const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge)) + + if (shouldUseCache) { + const res = await storage.getItemRaw(cacheKey) renderedScript.set(url, { content: res!, size: res!.length / 1024, @@ -91,6 +107,12 @@ async function downloadScript(opts: { }) await storage.setItemRaw(`bundle:${filename}`, res) + // Save metadata with timestamp for cache expiration + await storage.setItem(`bundle-meta:${filename}`, { + timestamp: Date.now(), + src, + filename, + }) size = size || res!.length / 1024 logger.info(`Downloading script ${colors.gray(`${src} → ${filename} (${size.toFixed(2)} kB ${encoding})`)}`) renderedScript.set(url, { @@ -214,10 +236,37 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti } } + // Check for dynamic src with bundle option - warn user and replace with 'unsupported' + if (!scriptSrcNode && !src) { + // This is a dynamic src case, check if bundle option is specified + const hasBundleOption = node.arguments[1]?.type === 'ObjectExpression' + && (node.arguments[1] as ObjectExpression).properties.some( + (p: any) => (p.key?.name === 'bundle' || p.key?.value === 'bundle') && p.type === 'Property', + ) + + if (hasBundleOption) { + const scriptOptionsArg = node.arguments[1] as ObjectExpression & { start: number, end: number } + const bundleProperty = scriptOptionsArg.properties.find( + (p: any) => (p.key?.name === 'bundle' || p.key?.value === 'bundle') && p.type === 'Property', + ) as Property & { start: number, end: number } | undefined + + if (bundleProperty && bundleProperty.value.type === 'Literal') { + const bundleValue = bundleProperty.value.value + if (bundleValue === true || bundleValue === 'force' || String(bundleValue) === 'true') { + // Replace bundle value with 'unsupported' - runtime will handle the warning + const valueNode = bundleProperty.value as any + s.overwrite(valueNode.start, valueNode.end, `'unsupported'`) + } + } + } + return + } + if (scriptSrcNode || src) { src = src || (typeof scriptSrcNode?.value === 'string' ? scriptSrcNode?.value : false) if (src) { - let canBundle = !!options.defaultBundle + let canBundle = options.defaultBundle === true || options.defaultBundle === 'force' + let forceDownload = options.defaultBundle === 'force' // useScript if (node.arguments[1]?.type === 'ObjectExpression') { const scriptOptionsArg = node.arguments[1] as ObjectExpression & { start: number, end: number } @@ -227,7 +276,8 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti ) as Property & { start: number, end: number } | undefined if (bundleProperty && bundleProperty.value.type === 'Literal') { const value = bundleProperty.value as Literal - if (String(value.value) !== 'true') { + const bundleValue = value.value + if (bundleValue !== true && bundleValue !== 'force' && String(bundleValue) !== 'true') { canBundle = false return } @@ -242,23 +292,28 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti s.remove(bundleProperty.start, nextProperty ? nextProperty.start : bundleProperty.end) } canBundle = true + forceDownload = bundleValue === 'force' } } // @ts-expect-error untyped const scriptOptions = node.arguments[0].properties?.find( (p: any) => (p.key?.name === 'scriptOptions'), ) as Property | undefined - // we need to check if scriptOptions contains bundle: true, if it exists + // we need to check if scriptOptions contains bundle: true/false/'force', if it exists // @ts-expect-error untyped const bundleOption = scriptOptions?.value.properties?.find((prop) => { return prop.type === 'Property' && prop.key?.name === 'bundle' && prop.value.type === 'Literal' }) - canBundle = bundleOption ? bundleOption.value.value : canBundle + if (bundleOption) { + const bundleValue = bundleOption.value.value + canBundle = bundleValue === true || bundleValue === 'force' || String(bundleValue) === 'true' + forceDownload = bundleValue === 'force' + } if (canBundle) { const { url: _url, filename } = normalizeScriptData(src, options.assetsBaseURL) let url = _url try { - await downloadScript({ src, url, filename }, renderedScript, options.fetchOptions) + await downloadScript({ src, url, filename, forceDownload }, renderedScript, options.fetchOptions, options.cacheMaxAge) } catch (e) { if (options.fallbackOnSrcOnBundleFail) { diff --git a/src/runtime/composables/useScript.ts b/src/runtime/composables/useScript.ts index 3a8bc1d0..4d004264 100644 --- a/src/runtime/composables/useScript.ts +++ b/src/runtime/composables/useScript.ts @@ -18,6 +18,14 @@ export function resolveScriptKey(input: any): string { export function useScript = Record>(input: UseScriptInput, options?: NuxtUseScriptOptions): UseScriptContext, T>> { input = typeof input === 'string' ? { src: input } : input options = defu(options, useNuxtScriptRuntimeConfig()?.defaultScriptOptions) as NuxtUseScriptOptions + + // Warn about unsupported bundling for dynamic sources (internal value set by transform) + if (import.meta.dev && (options.bundle as any) === 'unsupported') { + console.warn('[Nuxt Scripts] Bundling is not supported for dynamic script sources. Static URLs are required for bundling.') + // Reset to false to prevent any unexpected behavior + options.bundle = false + } + // browser hint optimizations const id = String(resolveScriptKey(input) as keyof typeof nuxtApp._scripts) const nuxtApp = useNuxtApp() diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 98303a87..28b5c3a1 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -49,9 +49,12 @@ export type NuxtUseScriptOptions = {}> = * performance by avoiding the extra DNS lookup and reducing the number of requests. It also * improves privacy by not sharing the user's IP address with third-party servers. * - `true` - Bundle the script as an asset. + * - `'force'` - Bundle the script and force download, bypassing cache. Useful for development. * - `false` - Do not bundle the script. (default) + * + * Note: Using 'force' may significantly increase build time as scripts will be re-downloaded on every build. */ - bundle?: boolean + bundle?: boolean | 'force' /** * Skip any schema validation for the script input. This is useful for loading the script stubs for development without * loading the actual script and not getting warnings. diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index ad8209a4..a1b363d8 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -13,7 +13,6 @@ import type { UseFunctionType, ScriptRegistry, UseScriptContext, } from '#nuxt-scripts/types' -import { parseQuery, parseURL, withQuery } from 'ufo' export type MaybePromise = Promise | T diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts index c42b1bad..01ce1093 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -36,6 +36,18 @@ vi.mock('ufo', async (og) => { hasProtocol: mock, } }) + +// Mock bundleStorage for cache invalidation tests +const mockBundleStorage: any = { + getItem: vi.fn(), + setItem: vi.fn(), + getItemRaw: vi.fn(), + setItemRaw: vi.fn(), + hasItem: vi.fn(), +} +vi.mock('../../src/assets', () => ({ + bundleStorage: vi.fn(() => mockBundleStorage), +})) vi.stubGlobal('fetch', vi.fn(() => { return Promise.resolve({ arrayBuffer: vi.fn(() => Buffer.from('')), ok: true, headers: { get: vi.fn() } }) })) @@ -126,6 +138,14 @@ describe('nuxtScriptTransformer', () => { expect(code).toMatchInlineSnapshot(`undefined`) }) + it('dynamic src with bundle option becomes unsupported', async () => { + const code = await transform( + + `const instance = useScript(\`https://example.com/$\{version}.js\`, { bundle: true })`, + ) + expect(code).toMatchInlineSnapshot(`"const instance = useScript(\`https://example.com/$\{version}.js\`, { bundle: 'unsupported' })"`) + }) + it('supplied src integration is transformed - opt-in', async () => { const code = await transform( `const instance = useScriptFathomAnalytics({ src: 'https://cdn.fathom/custom.js' }, { bundle: true, })`, @@ -414,6 +434,281 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/beacon.min.js', )"`) }) + it('bundle: "force" works the same as bundle: true', async () => { + vi.mocked(hash).mockImplementationOnce(() => 'beacon.min') + const code = await transform( + `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', { + bundle: 'force', + })`, + + ) + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/beacon.min.js', )"`) + }) + + it('registry script with scriptOptions.bundle: "force"', async () => { + vi.mocked(hash).mockImplementationOnce(() => 'analytics') + const code = await transform( + `const instance = useScriptGoogleAnalytics({ + id: 'GA_MEASUREMENT_ID', + scriptOptions: { + bundle: 'force' + } + })`, + { + defaultBundle: false, + scripts: [ + { + scriptBundling() { + return 'https://www.googletagmanager.com/gtag/js' + }, + import: { + name: 'useScriptGoogleAnalytics', + from: '', + }, + }, + ], + }, + ) + expect(code).toMatchInlineSnapshot(` + "const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/analytics.js' }, + id: 'GA_MEASUREMENT_ID', + scriptOptions: { + bundle: 'force' + } + })" + `) + }) + + it('top-level bundle: "force"', async () => { + vi.mocked(hash).mockImplementationOnce(() => 'gtag/js') + const code = await transform( + `const instance = useScriptGoogleAnalytics({ + id: 'GA_MEASUREMENT_ID' + }, { + bundle: 'force' + })`, + { + defaultBundle: false, + scripts: [ + { + scriptBundling() { + return 'https://www.googletagmanager.com/gtag/js' + }, + import: { + name: 'useScriptGoogleAnalytics', + from: '', + }, + }, + ], + }, + ) + expect(code).toMatchInlineSnapshot(` + "const instance = useScriptGoogleAnalytics({ scriptInput: { src: '/_scripts/gtag/js.js' }, + id: 'GA_MEASUREMENT_ID' + }, )" + `) + }) + + it('custom cache max age is passed through', async () => { + vi.mocked(hash).mockImplementationOnce(() => 'beacon.min') + const customCacheMaxAge = 3600000 // 1 hour + + const code = await transform( + `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', { + bundle: true, + })`, + { + cacheMaxAge: customCacheMaxAge, + }, + ) + + // Verify transformation still works with custom cache duration + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/beacon.min.js', )"`) + }) + + describe('cache invalidation', () => { + beforeEach(() => { + // Reset all mocks for bundleStorage + mockBundleStorage.getItem.mockReset() + mockBundleStorage.setItem.mockReset() + mockBundleStorage.getItemRaw.mockReset() + mockBundleStorage.setItemRaw.mockReset() + mockBundleStorage.hasItem.mockReset() + vi.clearAllMocks() + }) + + it('should detect expired cache when metadata is missing', async () => { + // Mock storage to not have metadata + mockBundleStorage.getItem.mockResolvedValue(null) + + // Import the isCacheExpired function - we need to access it for testing + const { isCacheExpired } = await import('../../src/plugins/transform') + + const isExpired = await isCacheExpired(mockBundleStorage, 'test-file.js') + expect(isExpired).toBe(true) + expect(mockBundleStorage.getItem).toHaveBeenCalledWith('bundle-meta:test-file.js') + }) + + it('should detect expired cache when timestamp is missing', async () => { + // Mock storage to have metadata without timestamp + mockBundleStorage.getItem.mockResolvedValue({}) + + const { isCacheExpired } = await import('../../src/plugins/transform') + + const isExpired = await isCacheExpired(mockBundleStorage, 'test-file.js') + expect(isExpired).toBe(true) + }) + + it('should detect expired cache when cache is older than maxAge', async () => { + const now = Date.now() + const twoDaysAgo = now - (2 * 24 * 60 * 60 * 1000) + const oneDayInMs = 24 * 60 * 60 * 1000 + + // Mock storage to have old timestamp + mockBundleStorage.getItem.mockResolvedValue({ timestamp: twoDaysAgo }) + + const { isCacheExpired } = await import('../../src/plugins/transform') + + const isExpired = await isCacheExpired(mockBundleStorage, 'test-file.js', oneDayInMs) + expect(isExpired).toBe(true) + }) + + it('should detect fresh cache when within maxAge', async () => { + const now = Date.now() + const oneHourAgo = now - (60 * 60 * 1000) + const oneDayInMs = 24 * 60 * 60 * 1000 + + // Mock storage to have recent timestamp + mockBundleStorage.getItem.mockResolvedValue({ timestamp: oneHourAgo }) + + const { isCacheExpired } = await import('../../src/plugins/transform') + + const isExpired = await isCacheExpired(mockBundleStorage, 'test-file.js', oneDayInMs) + expect(isExpired).toBe(false) + }) + + it('should use custom cacheMaxAge when provided', async () => { + const now = Date.now() + const twoHoursAgo = now - (2 * 60 * 60 * 1000) + const oneHourInMs = 60 * 60 * 1000 + + // Mock storage to have timestamp older than custom maxAge + mockBundleStorage.getItem.mockResolvedValue({ timestamp: twoHoursAgo }) + + const { isCacheExpired } = await import('../../src/plugins/transform') + + const isExpired = await isCacheExpired(mockBundleStorage, 'test-file.js', oneHourInMs) + expect(isExpired).toBe(true) + }) + + it('should bypass cache when forceDownload is true', async () => { + vi.mocked(hash).mockImplementationOnce(() => 'beacon.min') + + // Mock that cache exists and is fresh + mockBundleStorage.hasItem.mockResolvedValue(true) + mockBundleStorage.getItem.mockResolvedValue({ timestamp: Date.now() }) + mockBundleStorage.getItemRaw.mockResolvedValue(Buffer.from('cached content')) + + // Mock successful fetch for force download + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + headers: { get: () => null }, + } as any) + + const code = await transform( + `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', { + bundle: 'force', + })`, + { + renderedScript: new Map(), + }, + ) + + // Verify the script was fetched (not just cached) + expect(fetch).toHaveBeenCalled() + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/beacon.min.js', )"`) + }) + + it('should store bundle metadata with timestamp on download', async () => { + vi.mocked(hash).mockImplementationOnce(() => 'beacon.min') + + // Mock that cache doesn't exist + mockBundleStorage.hasItem.mockResolvedValue(false) + + // Mock successful fetch + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + headers: { get: () => null }, + } as any) + + const renderedScript = new Map() + + const code = await transform( + `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', { + bundle: true, + })`, + { + renderedScript, + }, + ) + + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/beacon.min.js', )"`) + + // Verify metadata was stored + const metadataCall = mockBundleStorage.setItem.mock.calls.find(call => + call[0].startsWith('bundle-meta:'), + ) + expect(metadataCall).toBeDefined() + expect(metadataCall[1]).toMatchObject({ + timestamp: expect.any(Number), + src: 'https://static.cloudflareinsights.com/beacon.min.js', + filename: expect.stringContaining('beacon.min'), + }) + }) + + it('should use cached content when cache is fresh', async () => { + vi.mocked(hash).mockImplementationOnce(() => 'beacon.min') + + const cachedContent = Buffer.from('cached script content') + + // Mock that cache exists and is fresh + mockBundleStorage.hasItem.mockResolvedValue(true) + mockBundleStorage.getItem.mockResolvedValue({ timestamp: Date.now() }) + mockBundleStorage.getItemRaw.mockResolvedValue(cachedContent) + + const renderedScript = new Map() + + const code = await transform( + `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', { + bundle: true, + })`, + { + renderedScript, + cacheMaxAge: 24 * 60 * 60 * 1000, // 1 day + }, + ) + + expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/beacon.min.js', )"`) + + // Verify fetch was not called (used cache) + expect(fetch).not.toHaveBeenCalled() + + // Verify cache methods were called correctly + expect(mockBundleStorage.hasItem).toHaveBeenCalledWith('bundle:beacon.min.js') + expect(mockBundleStorage.getItem).toHaveBeenCalledWith('bundle-meta:beacon.min.js') + expect(mockBundleStorage.getItemRaw).toHaveBeenCalledWith('bundle:beacon.min.js') + + // Verify the cached content was used (check both possible keys) + const scriptEntry = renderedScript.get('https://static.cloudflareinsights.com/beacon.min.js') + || renderedScript.get('/_scripts/beacon.min.js') + expect(scriptEntry).toBeDefined() + expect(scriptEntry?.content).toBe(cachedContent) + expect(scriptEntry?.size).toBe(cachedContent.length / 1024) + }) + }) + describe.todo('fallbackOnSrcOnBundleFail', () => { beforeEach(() => { vi.mocked($fetch).mockImplementationOnce(() => Promise.reject(new Error('fetch error')))