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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions docs/content/docs/1.guides/2.bundling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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.
Expand Down Expand Up @@ -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.
4 changes: 4 additions & 0 deletions src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const renderedScript = new Map<string, {
filename?: string
} | Error>()

/**
* 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
Expand Down
7 changes: 7 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -235,6 +241,7 @@ export {}`
assetsBaseURL: config.assets?.prefix,
fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail,
fetchOptions: config.assets?.fetchOptions,
cacheMaxAge: config.assets?.cacheMaxAge,
renderedScript,
}))

Expand Down
75 changes: 65 additions & 10 deletions src/plugins/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<RegistryScript>[]
fallbackOnSrcOnBundleFail?: boolean
fetchOptions?: FetchOptions
cacheMaxAge?: number
renderedScript?: Map<string, {
content: Buffer
/**
Expand Down Expand Up @@ -56,8 +68,9 @@ async function downloadScript(opts: {
src: string
url: string
filename?: string
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions) {
const { src, url, filename } = opts
forceDownload?: boolean
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions, cacheMaxAge?: number) {
const { src, url, filename, forceDownload } = opts
if (src === url || !filename) {
return
}
Expand All @@ -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<Buffer>(`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<Buffer>(cacheKey)
renderedScript.set(url, {
content: res!,
size: res!.length / 1024,
Expand All @@ -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, {
Expand Down Expand Up @@ -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 }
Expand All @@ -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
}
Expand All @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions src/runtime/composables/useScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export function resolveScriptKey(input: any): string {
export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>>(input: UseScriptInput, options?: NuxtUseScriptOptions<T>): UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>> {
input = typeof input === 'string' ? { src: input } : input
options = defu(options, useNuxtScriptRuntimeConfig()?.defaultScriptOptions) as NuxtUseScriptOptions<T>

// 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()
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,12 @@ export type NuxtUseScriptOptions<T extends Record<symbol | string, any> = {}> =
* 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.
Expand Down
1 change: 0 additions & 1 deletion src/runtime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import type {
UseFunctionType,
ScriptRegistry, UseScriptContext,
} from '#nuxt-scripts/types'
import { parseQuery, parseURL, withQuery } from 'ufo'

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

Expand Down
Loading
Loading