From 9759e4f9571d4aee276abd182d0f12118f469034 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 18 Sep 2025 09:43:07 +1000 Subject: [PATCH 1/4] feat: add forceDownload option to bypass script cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add forceDownload option for development workflows where the latest version of scripts should always be downloaded, bypassing cache. - Add forceDownload option to NuxtUseScriptOptions type with comprehensive documentation - Update AssetBundlerTransformerOptions to include defaultForceDownload configuration - Modify downloadScript function to skip cache when forceDownload is true - Add logic to extract forceDownload option from script configuration - Pass defaultForceDownload from module configuration to transformer - Includes performance warnings about build time impact Resolves #485 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/module.ts | 1 + src/plugins/transform.ts | 15 ++++++++++++--- src/runtime/types.ts | 23 ++++++++++++++++------- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/module.ts b/src/module.ts index 092d355a..3835e9b0 100644 --- a/src/module.ts +++ b/src/module.ts @@ -227,6 +227,7 @@ export {}` addBuildPlugin(NuxtScriptBundleTransformer({ scripts: registryScriptsWithImport, defaultBundle: config.defaultScriptOptions?.bundle, + defaultForceDownload: config.defaultScriptOptions?.forceDownload, moduleDetected(module) { if (nuxt.options.dev && module !== '@nuxt/scripts' && !moduleInstallPromises.has(module) && !hasNuxtModule(module)) moduleInstallPromises.set(module, () => installNuxtModule(module)) diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts index 7bc3d4a3..d14f82cb 100644 --- a/src/plugins/transform.ts +++ b/src/plugins/transform.ts @@ -21,6 +21,7 @@ import type { RegistryScript } from '#nuxt-scripts/types' export interface AssetBundlerTransformerOptions { moduleDetected?: (module: string) => void defaultBundle?: boolean + defaultForceDownload?: boolean assetsBaseURL?: string scripts?: Required[] fallbackOnSrcOnBundleFail?: boolean @@ -56,8 +57,9 @@ async function downloadScript(opts: { src: string url: string filename?: string + forceDownload?: boolean }, renderedScript: NonNullable, fetchOptions?: FetchOptions) { - const { src, url, filename } = opts + const { src, url, filename, forceDownload } = opts if (src === url || !filename) { return } @@ -66,7 +68,7 @@ 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}`)) { + if (!forceDownload && await storage.hasItem(`bundle:${filename}`)) { const res = await storage.getItemRaw(`bundle:${filename}`) renderedScript.set(url, { content: res!, @@ -254,11 +256,18 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti return prop.type === 'Property' && prop.key?.name === 'bundle' && prop.value.type === 'Literal' }) canBundle = bundleOption ? bundleOption.value.value : canBundle + + // check if scriptOptions contains forceDownload: true + // @ts-expect-error untyped + const forceDownloadOption = scriptOptions?.value.properties?.find((prop) => { + return prop.type === 'Property' && prop.key?.name === 'forceDownload' && prop.value.type === 'Literal' + }) + const forceDownload = forceDownloadOption ? forceDownloadOption.value.value : (options.defaultForceDownload || false) 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) } catch (e) { if (options.fallbackOnSrcOnBundleFail) { diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 6f45bc7d..117cc235 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -50,6 +50,15 @@ export type NuxtUseScriptOptions = {}> = * - `false` - Do not bundle the script. (default) */ bundle?: boolean + /** + * Force download of the script even if it exists in cache. Useful for development workflows + * where you want to ensure the latest version is always downloaded. + * - `true` - Force download, bypass cache. + * - `false` - Use cached version if available. (default) + * + * Note: This may significantly increase build time as scripts will be re-downloaded on every build. + */ + forceDownload?: boolean /** * 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. @@ -173,16 +182,16 @@ export type RegistryScriptInput< Usable extends boolean = false, CanBypassOptions extends boolean = true, > - = (InferIfSchema - & { + = (InferIfSchema + & { /** * A unique key to use for the script, this can be used to load multiple of the same script with different options. */ - key?: string - scriptInput?: ScriptInput - scriptOptions?: Omit - }) - | Partial> & ( + key?: string + scriptInput?: ScriptInput + scriptOptions?: Omit + }) + | Partial> & ( CanBypassOptions extends true ? { /** * A unique key to use for the script, this can be used to load multiple of the same script with different options. From 22b312acecec524ca90524dbca457e8023aec04b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 22 Sep 2025 15:02:54 +1000 Subject: [PATCH 2/4] chore: progress --- docs/content/docs/1.guides/2.bundling.md | 25 ++++++- src/assets.ts | 4 ++ src/module.ts | 1 - src/plugins/transform.ts | 75 ++++++++++++++++----- src/runtime/composables/useScript.ts | 8 +++ src/runtime/types.ts | 17 ++--- src/runtime/utils.ts | 2 +- test/unit/transform.test.ts | 83 ++++++++++++++++++++++++ 8 files changed, 186 insertions(+), 29 deletions(-) diff --git a/docs/content/docs/1.guides/2.bundling.md b/docs/content/docs/1.guides/2.bundling.md index a36795b3..4c47b2ac 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 (useful for development) +useScript('https://example.com/script.js', { + bundle: 'force', +}) ``` ```ts [Registry Script] @@ -79,9 +84,27 @@ useScriptGoogleAnalytics({ bundle: true } }) + +// Force download for development +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 significantly increase build time. This is primarily useful during development when you want to ensure you always have the latest version of a script. + ### Global Bundling Adjust the default behavior for all scripts using the Nuxt Config. This example sets all scripts to be bundled by default. @@ -90,7 +113,7 @@ Adjust the default behavior for all scripts using the Nuxt Config. This example export default defineNuxtConfig({ scripts: { defaultScriptOptions: { - bundle: true, + bundle: true, // or 'force' for development } } }) 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 3835e9b0..092d355a 100644 --- a/src/module.ts +++ b/src/module.ts @@ -227,7 +227,6 @@ export {}` addBuildPlugin(NuxtScriptBundleTransformer({ scripts: registryScriptsWithImport, defaultBundle: config.defaultScriptOptions?.bundle, - defaultForceDownload: config.defaultScriptOptions?.forceDownload, moduleDetected(module) { if (nuxt.options.dev && module !== '@nuxt/scripts' && !moduleInstallPromises.has(module) && !hasNuxtModule(module)) moduleInstallPromises.set(module, () => installNuxtModule(module)) diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts index d14f82cb..4100fdc5 100644 --- a/src/plugins/transform.ts +++ b/src/plugins/transform.ts @@ -18,10 +18,20 @@ 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 + +async function isCacheExpired(storage: any, filename: string): 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 > SEVEN_DAYS_IN_MS +} + export interface AssetBundlerTransformerOptions { moduleDetected?: (module: string) => void - defaultBundle?: boolean - defaultForceDownload?: boolean + defaultBundle?: boolean | 'force' assetsBaseURL?: string scripts?: Required[] fallbackOnSrcOnBundleFail?: boolean @@ -68,8 +78,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 (!forceDownload && 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)) + + if (shouldUseCache) { + const res = await storage.getItemRaw(cacheKey) renderedScript.set(url, { content: res!, size: res!.length / 1024, @@ -93,6 +106,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, { @@ -216,10 +235,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 } @@ -229,7 +275,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 } @@ -244,25 +291,23 @@ 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 - - // check if scriptOptions contains forceDownload: true - // @ts-expect-error untyped - const forceDownloadOption = scriptOptions?.value.properties?.find((prop) => { - return prop.type === 'Property' && prop.key?.name === 'forceDownload' && prop.value.type === 'Literal' - }) - const forceDownload = forceDownloadOption ? forceDownloadOption.value.value : (options.defaultForceDownload || false) + 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 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 fe4c74ed..1ce8eca4 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -3,7 +3,7 @@ import type { } from '@unhead/vue/types' import type { UseScriptInput, VueScriptInstance, UseScriptOptions } from '@unhead/vue' import type { ComputedRef, Ref } from 'vue' -import type {InferInput, ObjectSchema, ValiError} from 'valibot' +import type { InferInput, ObjectSchema, ValiError } from 'valibot' import type { Import } from 'unimport' import type { SegmentInput } from './registry/segment' import type { CloudflareWebAnalyticsInput } from './registry/cloudflare-web-analytics' @@ -49,18 +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) - */ - bundle?: boolean - /** - * Force download of the script even if it exists in cache. Useful for development workflows - * where you want to ensure the latest version is always downloaded. - * - `true` - Force download, bypass cache. - * - `false` - Use cached version if available. (default) * - * Note: This may significantly increase build time as scripts will be re-downloaded on every build. + * Note: Using 'force' may significantly increase build time as scripts will be re-downloaded on every build. */ - forceDownload?: 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. @@ -96,7 +90,8 @@ export type NuxtUseScriptOptions = {}> = registryMeta?: Record } /** - * @internal Used to run custom validation logic in dev mode. + * Used to run custom validation logic in dev mode. + * @internal */ _validate?: () => ValiError | null | undefined } diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 2dc1fc19..0abfc036 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -22,7 +22,7 @@ function validateScriptInputSchema(key: string, schema: } catch (_e) { const e = _e as ValiError - console.error(e.issues.map(i => `${key}.${i.path?.map(i => i.key).join(',')}: ${i.message}`).join('\n')) + console.error(e.issues.map((i: any) => `${key}.${i.path?.map((i: any) => i.key).join(',')}: ${i.message}`).join('\n')) return e } } diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts index c42b1bad..ca5615e2 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -126,6 +126,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 +422,81 @@ 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' + }, )" + `) + }) + describe.todo('fallbackOnSrcOnBundleFail', () => { beforeEach(() => { vi.mocked($fetch).mockImplementationOnce(() => Promise.reject(new Error('fetch error'))) From 56b3d30f89871f52028b574bb72e26623cbc804b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 22 Sep 2025 15:11:28 +1000 Subject: [PATCH 3/4] chore: progress --- docs/content/docs/1.guides/2.bundling.md | 19 ++++++++++++++++--- src/module.ts | 7 +++++++ src/plugins/transform.ts | 11 ++++++----- test/unit/transform.test.ts | 17 +++++++++++++++++ 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/content/docs/1.guides/2.bundling.md b/docs/content/docs/1.guides/2.bundling.md index 4c47b2ac..7bfd9d9e 100644 --- a/docs/content/docs/1.guides/2.bundling.md +++ b/docs/content/docs/1.guides/2.bundling.md @@ -244,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/module.ts b/src/module.ts index 092d355a..af3cc87e 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. @@ -234,6 +240,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 4100fdc5..31197fd2 100644 --- a/src/plugins/transform.ts +++ b/src/plugins/transform.ts @@ -20,13 +20,13 @@ import type { RegistryScript } from '#nuxt-scripts/types' const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000 -async function isCacheExpired(storage: any, filename: string): Promise { +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 > SEVEN_DAYS_IN_MS + return Date.now() - meta.timestamp > cacheMaxAge } export interface AssetBundlerTransformerOptions { @@ -36,6 +36,7 @@ export interface AssetBundlerTransformerOptions { scripts?: Required[] fallbackOnSrcOnBundleFail?: boolean fetchOptions?: FetchOptions + cacheMaxAge?: number renderedScript?: Map, fetchOptions?: FetchOptions) { +}, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number) { const { src, url, filename, forceDownload } = opts if (src === url || !filename) { return @@ -79,7 +80,7 @@ async function downloadScript(opts: { if (!res) { // Use storage to cache the font data between builds const cacheKey = `bundle:${filename}` - const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename)) + const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge)) if (shouldUseCache) { const res = await storage.getItemRaw(cacheKey) @@ -312,7 +313,7 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti const { url: _url, filename } = normalizeScriptData(src, options.assetsBaseURL) let url = _url try { - await downloadScript({ src, url, filename, forceDownload }, renderedScript, options.fetchOptions) + await downloadScript({ src, url, filename, forceDownload }, renderedScript, options.fetchOptions, options.cacheMaxAge) } catch (e) { if (options.fallbackOnSrcOnBundleFail) { diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts index ca5615e2..4e4dfec7 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -497,6 +497,23 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ `) }) + 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.todo('fallbackOnSrcOnBundleFail', () => { beforeEach(() => { vi.mocked($fetch).mockImplementationOnce(() => Promise.reject(new Error('fetch error'))) From 9fc355510261099eb4a60f205ff92c83ae3b3bc4 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 22 Sep 2025 19:38:49 +1000 Subject: [PATCH 4/4] chore: tidy up --- docs/content/docs/1.guides/2.bundling.md | 8 +- src/plugins/transform.ts | 2 +- src/runtime/utils.ts | 1 - test/unit/transform.test.ts | 195 +++++++++++++++++++++++ 4 files changed, 200 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/1.guides/2.bundling.md b/docs/content/docs/1.guides/2.bundling.md index 7bfd9d9e..c0e6cd28 100644 --- a/docs/content/docs/1.guides/2.bundling.md +++ b/docs/content/docs/1.guides/2.bundling.md @@ -70,7 +70,7 @@ useScript('https://example.com/script.js', { bundle: true, }) -// Force download bypassing cache (useful for development) +// Force download bypassing cache useScript('https://example.com/script.js', { bundle: 'force', }) @@ -85,7 +85,7 @@ useScriptGoogleAnalytics({ } }) -// Force download for development +// bundle without cache useScriptGoogleAnalytics({ id: 'GA_MEASUREMENT_ID', scriptOptions: { @@ -103,7 +103,7 @@ The `bundle` option accepts the following values: - `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 significantly increase build time. This is primarily useful during development when you want to ensure you always have the latest version of a script. +**Note**: Using `'force'` will re-download scripts on every build, which may increase build time and provide less security. ### Global Bundling @@ -113,7 +113,7 @@ Adjust the default behavior for all scripts using the Nuxt Config. This example export default defineNuxtConfig({ scripts: { defaultScriptOptions: { - bundle: true, // or 'force' for development + bundle: true, } } }) diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts index 31197fd2..da78fb60 100644 --- a/src/plugins/transform.ts +++ b/src/plugins/transform.ts @@ -20,7 +20,7 @@ import type { RegistryScript } from '#nuxt-scripts/types' const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000 -async function isCacheExpired(storage: any, filename: string, cacheMaxAge: number = SEVEN_DAYS_IN_MS): Promise { +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) { 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 4e4dfec7..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() } }) })) @@ -514,6 +526,189 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ 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')))