Skip to content

Commit bf491b5

Browse files
authored
feat: bundling cache expiration & bypass (#497)
1 parent 695b7f1 commit bf491b5

File tree

8 files changed

+422
-15
lines changed

8 files changed

+422
-15
lines changed

docs/content/docs/1.guides/2.bundling.md

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ To decide if an individual script should be bundled, use the `bundle` option.
6969
useScript('https://example.com/script.js', {
7070
bundle: true,
7171
})
72+
73+
// Force download bypassing cache
74+
useScript('https://example.com/script.js', {
75+
bundle: 'force',
76+
})
7277
```
7378

7479
```ts [Registry Script]
@@ -79,9 +84,27 @@ useScriptGoogleAnalytics({
7984
bundle: true
8085
}
8186
})
87+
88+
// bundle without cache
89+
useScriptGoogleAnalytics({
90+
id: 'GA_MEASUREMENT_ID',
91+
scriptOptions: {
92+
bundle: 'force'
93+
}
94+
})
8295
```
8396
::
8497

98+
#### Bundle Options
99+
100+
The `bundle` option accepts the following values:
101+
102+
- `false` - Do not bundle the script (default)
103+
- `true` - Bundle the script and use cached version if available
104+
- `'force'` - Bundle the script and force download, bypassing cache
105+
106+
**Note**: Using `'force'` will re-download scripts on every build, which may increase build time and provide less security.
107+
85108
### Global Bundling
86109

87110
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({
221244
})
222245
```
223246

224-
### Change Asset Behavior
247+
### Asset Configuration
225248

226-
Use the `assets` option in your configuration to customize how scripts are bundled, such as changing the output directory for the bundled scripts.
249+
Use the `assets` option in your configuration to customize how scripts are bundled and cached.
227250

228251
```ts [nuxt.config.ts]
229252
export default defineNuxtConfig({
230253
scripts: {
231254
assets: {
232255
prefix: '/_custom-script-path/',
256+
cacheMaxAge: 86400000, // 1 day in milliseconds
233257
}
234258
}
235259
})
236260
```
237261

238-
More configuration options will be available in future updates.
262+
#### Available Options
263+
264+
- **`prefix`** - Custom path where bundled scripts are served (default: `/_scripts/`)
265+
- **`cacheMaxAge`** - Cache duration for bundled scripts in milliseconds (default: 7 days)
266+
267+
#### Cache Behavior
268+
269+
The bundling system uses two different cache strategies:
270+
271+
- **Build-time cache**: Controlled by `cacheMaxAge` (default: 7 days). Scripts older than this are re-downloaded during builds to ensure freshness.
272+
- **Runtime cache**: Bundled scripts are served with 1-year cache headers since they are content-addressed by hash.
273+
274+
This dual approach ensures both build performance and reliable browser caching.

src/assets.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ const renderedScript = new Map<string, {
2121
filename?: string
2222
} | Error>()
2323

24+
/**
25+
* Cache duration for bundled scripts in production (1 year).
26+
* Scripts are cached with long expiration since they are content-addressed by hash.
27+
*/
2428
const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365
2529

2630
// TODO: refactor to use nitro storage when it can be cached between builds

src/module.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ export interface ModuleOptions {
6262
* Configure the fetch options used for downloading scripts.
6363
*/
6464
fetchOptions?: FetchOptions
65+
/**
66+
* Cache duration for bundled scripts in milliseconds.
67+
* Scripts older than this will be re-downloaded during builds.
68+
* @default 604800000 (7 days)
69+
*/
70+
cacheMaxAge?: number
6571
}
6672
/**
6773
* Whether the module is enabled.
@@ -236,6 +242,7 @@ export {}`
236242
assetsBaseURL: config.assets?.prefix,
237243
fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail,
238244
fetchOptions: config.assets?.fetchOptions,
245+
cacheMaxAge: config.assets?.cacheMaxAge,
239246
renderedScript,
240247
}))
241248

src/plugins/transform.ts

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,25 @@ import { bundleStorage } from '../assets'
1818
import { isJS, isVue } from './util'
1919
import type { RegistryScript } from '#nuxt-scripts/types'
2020

21+
const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000
22+
23+
export async function isCacheExpired(storage: any, filename: string, cacheMaxAge: number = SEVEN_DAYS_IN_MS): Promise<boolean> {
24+
const metaKey = `bundle-meta:${filename}`
25+
const meta = await storage.getItem(metaKey)
26+
if (!meta || !meta.timestamp) {
27+
return true // No metadata means expired/invalid cache
28+
}
29+
return Date.now() - meta.timestamp > cacheMaxAge
30+
}
31+
2132
export interface AssetBundlerTransformerOptions {
2233
moduleDetected?: (module: string) => void
23-
defaultBundle?: boolean
34+
defaultBundle?: boolean | 'force'
2435
assetsBaseURL?: string
2536
scripts?: Required<RegistryScript>[]
2637
fallbackOnSrcOnBundleFail?: boolean
2738
fetchOptions?: FetchOptions
39+
cacheMaxAge?: number
2840
renderedScript?: Map<string, {
2941
content: Buffer
3042
/**
@@ -56,8 +68,9 @@ async function downloadScript(opts: {
5668
src: string
5769
url: string
5870
filename?: string
59-
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions) {
60-
const { src, url, filename } = opts
71+
forceDownload?: boolean
72+
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions, cacheMaxAge?: number) {
73+
const { src, url, filename, forceDownload } = opts
6174
if (src === url || !filename) {
6275
return
6376
}
@@ -66,8 +79,11 @@ async function downloadScript(opts: {
6679
let res: Buffer | undefined = scriptContent instanceof Error ? undefined : scriptContent?.content
6780
if (!res) {
6881
// Use storage to cache the font data between builds
69-
if (await storage.hasItem(`bundle:${filename}`)) {
70-
const res = await storage.getItemRaw<Buffer>(`bundle:${filename}`)
82+
const cacheKey = `bundle:${filename}`
83+
const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge))
84+
85+
if (shouldUseCache) {
86+
const res = await storage.getItemRaw<Buffer>(cacheKey)
7187
renderedScript.set(url, {
7288
content: res!,
7389
size: res!.length / 1024,
@@ -91,6 +107,12 @@ async function downloadScript(opts: {
91107
})
92108

93109
await storage.setItemRaw(`bundle:${filename}`, res)
110+
// Save metadata with timestamp for cache expiration
111+
await storage.setItem(`bundle-meta:${filename}`, {
112+
timestamp: Date.now(),
113+
src,
114+
filename,
115+
})
94116
size = size || res!.length / 1024
95117
logger.info(`Downloading script ${colors.gray(`${src}${filename} (${size.toFixed(2)} kB ${encoding})`)}`)
96118
renderedScript.set(url, {
@@ -214,10 +236,37 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
214236
}
215237
}
216238

239+
// Check for dynamic src with bundle option - warn user and replace with 'unsupported'
240+
if (!scriptSrcNode && !src) {
241+
// This is a dynamic src case, check if bundle option is specified
242+
const hasBundleOption = node.arguments[1]?.type === 'ObjectExpression'
243+
&& (node.arguments[1] as ObjectExpression).properties.some(
244+
(p: any) => (p.key?.name === 'bundle' || p.key?.value === 'bundle') && p.type === 'Property',
245+
)
246+
247+
if (hasBundleOption) {
248+
const scriptOptionsArg = node.arguments[1] as ObjectExpression & { start: number, end: number }
249+
const bundleProperty = scriptOptionsArg.properties.find(
250+
(p: any) => (p.key?.name === 'bundle' || p.key?.value === 'bundle') && p.type === 'Property',
251+
) as Property & { start: number, end: number } | undefined
252+
253+
if (bundleProperty && bundleProperty.value.type === 'Literal') {
254+
const bundleValue = bundleProperty.value.value
255+
if (bundleValue === true || bundleValue === 'force' || String(bundleValue) === 'true') {
256+
// Replace bundle value with 'unsupported' - runtime will handle the warning
257+
const valueNode = bundleProperty.value as any
258+
s.overwrite(valueNode.start, valueNode.end, `'unsupported'`)
259+
}
260+
}
261+
}
262+
return
263+
}
264+
217265
if (scriptSrcNode || src) {
218266
src = src || (typeof scriptSrcNode?.value === 'string' ? scriptSrcNode?.value : false)
219267
if (src) {
220-
let canBundle = !!options.defaultBundle
268+
let canBundle = options.defaultBundle === true || options.defaultBundle === 'force'
269+
let forceDownload = options.defaultBundle === 'force'
221270
// useScript
222271
if (node.arguments[1]?.type === 'ObjectExpression') {
223272
const scriptOptionsArg = node.arguments[1] as ObjectExpression & { start: number, end: number }
@@ -227,7 +276,8 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
227276
) as Property & { start: number, end: number } | undefined
228277
if (bundleProperty && bundleProperty.value.type === 'Literal') {
229278
const value = bundleProperty.value as Literal
230-
if (String(value.value) !== 'true') {
279+
const bundleValue = value.value
280+
if (bundleValue !== true && bundleValue !== 'force' && String(bundleValue) !== 'true') {
231281
canBundle = false
232282
return
233283
}
@@ -242,23 +292,28 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
242292
s.remove(bundleProperty.start, nextProperty ? nextProperty.start : bundleProperty.end)
243293
}
244294
canBundle = true
295+
forceDownload = bundleValue === 'force'
245296
}
246297
}
247298
// @ts-expect-error untyped
248299
const scriptOptions = node.arguments[0].properties?.find(
249300
(p: any) => (p.key?.name === 'scriptOptions'),
250301
) as Property | undefined
251-
// we need to check if scriptOptions contains bundle: true, if it exists
302+
// we need to check if scriptOptions contains bundle: true/false/'force', if it exists
252303
// @ts-expect-error untyped
253304
const bundleOption = scriptOptions?.value.properties?.find((prop) => {
254305
return prop.type === 'Property' && prop.key?.name === 'bundle' && prop.value.type === 'Literal'
255306
})
256-
canBundle = bundleOption ? bundleOption.value.value : canBundle
307+
if (bundleOption) {
308+
const bundleValue = bundleOption.value.value
309+
canBundle = bundleValue === true || bundleValue === 'force' || String(bundleValue) === 'true'
310+
forceDownload = bundleValue === 'force'
311+
}
257312
if (canBundle) {
258313
const { url: _url, filename } = normalizeScriptData(src, options.assetsBaseURL)
259314
let url = _url
260315
try {
261-
await downloadScript({ src, url, filename }, renderedScript, options.fetchOptions)
316+
await downloadScript({ src, url, filename, forceDownload }, renderedScript, options.fetchOptions, options.cacheMaxAge)
262317
}
263318
catch (e) {
264319
if (options.fallbackOnSrcOnBundleFail) {

src/runtime/composables/useScript.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ export function resolveScriptKey(input: any): string {
1818
export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>>(input: UseScriptInput, options?: NuxtUseScriptOptions<T>): UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>> {
1919
input = typeof input === 'string' ? { src: input } : input
2020
options = defu(options, useNuxtScriptRuntimeConfig()?.defaultScriptOptions) as NuxtUseScriptOptions<T>
21+
22+
// Warn about unsupported bundling for dynamic sources (internal value set by transform)
23+
if (import.meta.dev && (options.bundle as any) === 'unsupported') {
24+
console.warn('[Nuxt Scripts] Bundling is not supported for dynamic script sources. Static URLs are required for bundling.')
25+
// Reset to false to prevent any unexpected behavior
26+
options.bundle = false
27+
}
28+
2129
// browser hint optimizations
2230
const id = String(resolveScriptKey(input) as keyof typeof nuxtApp._scripts)
2331
const nuxtApp = useNuxtApp()

src/runtime/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,12 @@ export type NuxtUseScriptOptions<T extends Record<symbol | string, any> = {}> =
4949
* performance by avoiding the extra DNS lookup and reducing the number of requests. It also
5050
* improves privacy by not sharing the user's IP address with third-party servers.
5151
* - `true` - Bundle the script as an asset.
52+
* - `'force'` - Bundle the script and force download, bypassing cache. Useful for development.
5253
* - `false` - Do not bundle the script. (default)
54+
*
55+
* Note: Using 'force' may significantly increase build time as scripts will be re-downloaded on every build.
5356
*/
54-
bundle?: boolean
57+
bundle?: boolean | 'force'
5558
/**
5659
* Skip any schema validation for the script input. This is useful for loading the script stubs for development without
5760
* loading the actual script and not getting warnings.

src/runtime/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import type {
1313
UseFunctionType,
1414
ScriptRegistry, UseScriptContext,
1515
} from '#nuxt-scripts/types'
16-
import { parseQuery, parseURL, withQuery } from 'ufo'
1716

1817
export type MaybePromise<T> = Promise<T> | T
1918

0 commit comments

Comments
 (0)