Skip to content

Commit add80c3

Browse files
authored
feat: allow fallback on bundle fail (#255)
1 parent de6e76f commit add80c3

File tree

5 files changed

+295
-140
lines changed

5 files changed

+295
-140
lines changed

docs/content/docs/3.api/5.nuxt-config.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,10 @@ Disables the Nuxt Scripts module.
4949
- Default: `false`
5050

5151
Enable to see debug logs.
52+
53+
## `fallbackOnSrcOnBundleFail `
54+
55+
- Type: `boolean`
56+
- Default: `false`
57+
58+
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.

src/assets.ts

Lines changed: 27 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,56 @@
1-
import fsp from 'node:fs/promises'
21
import { addDevServerHandler, useNuxt } from '@nuxt/kit'
32
import { createError, eventHandler, lazyEventHandler } from 'h3'
43
import { fetch } from 'ofetch'
5-
import { colors } from 'consola/utils'
64
import { defu } from 'defu'
75
import type { NitroConfig } from 'nitropack'
8-
import { hasProtocol, joinURL, parseURL } from 'ufo'
6+
import { joinURL } from 'ufo'
97
import { join } from 'pathe'
10-
import { hash } from 'ohash'
118
import { createStorage } from 'unstorage'
129
import fsDriver from 'unstorage/drivers/fs-lite'
13-
import { logger } from './logger'
1410

1511
import type { ModuleOptions } from './module'
1612

13+
const renderedScript = new Map<string, {
14+
content: Buffer
15+
/**
16+
* in kb
17+
*/
18+
size: number
19+
encoding?: string
20+
src: string
21+
filename?: string
22+
} | Error>()
23+
1724
const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365
1825

26+
// TODO: refactor to use nitro storage when it can be cached between builds
27+
export const storage = createStorage({
28+
driver: fsDriver({
29+
base: 'node_modules/.cache/nuxt/scripts',
30+
}),
31+
})
32+
1933
// TODO: replace this with nuxt/assets when it is released
2034
export function setupPublicAssetStrategy(options: ModuleOptions['assets'] = {}) {
2135
const assetsBaseURL = options.prefix || '/_scripts'
2236
const nuxt = useNuxt()
23-
const renderedScriptSrc = new Map<string, string>()
24-
25-
// TODO: refactor to use nitro storage when it can be cached between builds
26-
const storage = createStorage({
27-
driver: fsDriver({
28-
base: 'node_modules/.cache/nuxt/scripts',
29-
}),
30-
})
31-
32-
function normalizeScriptData(src: string): string {
33-
if (hasProtocol(src, { acceptRelative: true })) {
34-
src = src.replace(/^\/\//, 'https://')
35-
const url = parseURL(src)
36-
const file = [
37-
`${hash(url)}.js`, // force an extension
38-
].filter(Boolean).join('-')
39-
40-
renderedScriptSrc.set(file, src)
41-
return joinURL(assetsBaseURL, file)
42-
}
43-
return src
44-
}
4537

4638
// Register font proxy URL for development
4739
addDevServerHandler({
4840
route: assetsBaseURL,
4941
handler: lazyEventHandler(async () => {
5042
return eventHandler(async (event) => {
5143
const filename = event.path.slice(1)
52-
const url = renderedScriptSrc.get(event.path.slice(1))
53-
if (!url)
44+
const scriptDescriptor = renderedScript.get(join(assetsBaseURL, event.path.slice(1)))
45+
46+
if (!scriptDescriptor || scriptDescriptor instanceof Error)
5447
throw createError({ statusCode: 404 })
48+
5549
const key = `data:scripts:${filename}`
5650
// Use storage to cache the font data between requests
5751
let res = await storage.getItemRaw(key)
5852
if (!res) {
59-
res = await fetch(url).then(r => r.arrayBuffer()).then(r => Buffer.from(r))
53+
res = await fetch(scriptDescriptor.src).then(r => r.arrayBuffer()).then(r => Buffer.from(r))
6054
await storage.setItemRaw(key, res)
6155
}
6256
return res
@@ -87,47 +81,7 @@ export function setupPublicAssetStrategy(options: ModuleOptions['assets'] = {})
8781
},
8882
} satisfies NitroConfig)
8983

90-
nuxt.hook('nitro:init', async (nitro) => {
91-
if (nuxt.options.dev)
92-
return
93-
nitro.hooks.hook('rollup:before', async () => {
94-
await fsp.rm(cacheDir, { recursive: true, force: true })
95-
await fsp.mkdir(cacheDir, { recursive: true })
96-
let banner = false
97-
const failedScriptDownload = new Set<{ url: string, statusText: string, status: number }>()
98-
for (const [filename, url] of renderedScriptSrc) {
99-
const key = `data:scripts:${filename}`
100-
// Use storage to cache the font data between builds
101-
let res = await storage.getItemRaw(key)
102-
if (!res) {
103-
if (!banner) {
104-
banner = true
105-
logger.info('Downloading scripts...')
106-
}
107-
let encoding
108-
let size = 0
109-
res = await fetch(url).then((r) => {
110-
if (!r.ok) {
111-
failedScriptDownload.add({ url, statusText: r.statusText, status: r.status })
112-
return Buffer.from('')
113-
}
114-
encoding = r.headers.get('content-encoding')
115-
const contentLength = r.headers.get('content-length')
116-
size = contentLength ? Number(contentLength) / 1024 : 0
117-
return r.arrayBuffer()
118-
}).then(r => Buffer.from(r))
119-
logger.log(colors.gray(` ├─ ${url}${joinURL(assetsBaseURL, filename)} (${size.toFixed(2)} kB ${encoding})`))
120-
await storage.setItemRaw(key, res)
121-
}
122-
await fsp.writeFile(join(cacheDir, filename), res)
123-
}
124-
if (failedScriptDownload.size) {
125-
throw new Error(`@nuxt/script: Failed to download scripts:\n${[...failedScriptDownload].map(({ url, statusText, status }) => ` ├─ ${url} (${status} ${statusText})`).join('\n')}`)
126-
}
127-
if (banner)
128-
logger.success('Scripts downloaded and cached.')
129-
})
130-
})
131-
132-
return { normalizeScriptData }
84+
return {
85+
renderedScript
86+
}
13387
}

src/module.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ export interface ModuleOptions {
5454
* TODO Make configurable in future.
5555
*/
5656
strategy?: 'public'
57+
/**
58+
* Fallback to src if bundle fails to load.
59+
* The default behavior is to stop the bundling process if a script fails to be downloaded.
60+
* @default false
61+
*/
62+
fallbackOnSrcOnBundleFail?: boolean
5763
}
5864
/**
5965
* Whether the module is enabled.
@@ -188,8 +194,7 @@ ${newScripts.map((i) => {
188194
},
189195
})
190196
}
191-
const scriptMap = new Map<string, string>()
192-
const { normalizeScriptData } = setupPublicAssetStrategy(config.assets)
197+
const { renderedScript } = setupPublicAssetStrategy(config.assets)
193198

194199
const moduleInstallPromises: Map<string, () => Promise<boolean> | undefined> = new Map()
195200

@@ -203,13 +208,9 @@ ${newScripts.map((i) => {
203208
if (nuxt.options.dev && module !== '@nuxt/scripts' && !moduleInstallPromises.has(module) && !hasNuxtModule(module))
204209
moduleInstallPromises.set(module, () => installNuxtModule(module))
205210
},
206-
resolveScript(src) {
207-
if (scriptMap.has(src))
208-
return scriptMap.get(src) as string
209-
const url = normalizeScriptData(src)
210-
scriptMap.set(src, url)
211-
return url
212-
},
211+
assetsBaseURL: config.assets?.prefix,
212+
fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail,
213+
renderedScript
213214
}))
214215

215216
nuxt.hooks.hook('build:done', async () => {

src/plugins/transform.ts

Lines changed: 123 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,120 @@
1+
import fsp from 'node:fs/promises'
12
import { createUnplugin } from 'unplugin'
23
import MagicString from 'magic-string'
34
import type { SourceMapInput } from 'rollup'
45
import type { Node } from 'estree-walker'
5-
import { walk } from 'estree-walker'
6+
import { asyncWalk } from 'estree-walker'
67
import type { Literal, ObjectExpression, Property, SimpleCallExpression } from 'estree'
78
import type { InferInput } from 'valibot'
9+
import { hasProtocol, parseURL, joinURL } from 'ufo'
10+
import { hash as ohash } from 'ohash'
11+
import { join } from 'pathe'
12+
import { colors } from 'consola/utils'
13+
import { useNuxt } from '@nuxt/kit'
14+
import { logger } from '../logger'
15+
import { storage } from '../assets'
816
import { isJS, isVue } from './util'
917
import type { RegistryScript } from '#nuxt-scripts'
1018

1119
export interface AssetBundlerTransformerOptions {
12-
resolveScript: (src: string) => string
1320
moduleDetected?: (module: string) => void
1421
defaultBundle?: boolean
22+
assetsBaseURL?: string
1523
scripts?: Required<RegistryScript>[]
24+
fallbackOnSrcOnBundleFail?: boolean
25+
renderedScript?: Map<string, {
26+
content: Buffer
27+
/**
28+
* in kb
29+
*/
30+
size: number
31+
encoding?: string
32+
src: string
33+
filename?: string
34+
} | Error>
1635
}
1736

18-
export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOptions) {
37+
function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts'): { url: string, filename?: string } {
38+
if (hasProtocol(src, { acceptRelative: true })) {
39+
src = src.replace(/^\/\//, 'https://')
40+
const url = parseURL(src)
41+
const file = [
42+
`${ohash(url)}.js`, // force an extension
43+
].filter(Boolean).join('-')
44+
return { url: joinURL(assetsBaseURL, file), filename: file }
45+
}
46+
return { url: src }
47+
}
48+
async function downloadScript(opts: {
49+
src: string,
50+
url: string,
51+
filename?: string
52+
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>) {
53+
const { src, url, filename } = opts
54+
if (src === url || !filename) {
55+
return
56+
}
57+
const scriptContent = renderedScript.get(src)
58+
let res: Buffer | undefined = scriptContent instanceof Error ? undefined : scriptContent?.content
59+
if (!res) {
60+
// Use storage to cache the font data between builds
61+
if (await storage.hasItem(`data:scripts:${filename}`)) {
62+
const res = await storage.getItemRaw<Buffer>(`data:scripts:${filename}`)
63+
renderedScript.set(url, {
64+
content: res!,
65+
size: res!.length / 1024,
66+
encoding: 'utf-8',
67+
src,
68+
filename,
69+
})
70+
71+
return
72+
}
73+
let encoding
74+
let size = 0
75+
res = await fetch(src).then((r) => {
76+
if (!r.ok) {
77+
throw new Error(`Failed to fetch ${src}`)
78+
}
79+
encoding = r.headers.get('content-encoding')
80+
const contentLength = r.headers.get('content-length')
81+
size = contentLength ? Number(contentLength) / 1024 : 0
82+
83+
return r.arrayBuffer()
84+
}).then(r => Buffer.from(r))
85+
86+
storage.setItemRaw(`data:scripts:${filename}`, res)
87+
renderedScript.set(url, {
88+
content: res!,
89+
size,
90+
encoding,
91+
src,
92+
filename,
93+
})
94+
}
95+
}
96+
97+
export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOptions = {
98+
renderedScript: new Map()
99+
}) {
100+
const nuxt = useNuxt()
101+
const { renderedScript = new Map() } = options
102+
const cacheDir = join(nuxt.options.buildDir, 'cache', 'scripts')
103+
104+
// done after all transformation is done
105+
// copy all scripts to build
106+
nuxt.hooks.hook('build:done', async () => {
107+
logger.log('[nuxt:scripts:bundler-transformer] Bundling scripts...')
108+
await fsp.rm(cacheDir, { recursive: true, force: true })
109+
await fsp.mkdir(cacheDir, { recursive: true })
110+
await Promise.all([...renderedScript].map(async ([url, content]) => {
111+
if (content instanceof Error || !content.filename)
112+
return
113+
await fsp.writeFile(join(nuxt.options.buildDir, 'cache', 'scripts', content.filename), content.content)
114+
logger.log(colors.gray(` ├─ ${url}${joinURL(content.src)} (${content.size.toFixed(2)} kB ${content.encoding})`))
115+
}))
116+
})
117+
19118
return createUnplugin(() => {
20119
return {
21120
name: 'nuxt:scripts:bundler-transformer',
@@ -30,8 +129,8 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
30129

31130
const ast = this.parse(code)
32131
const s = new MagicString(code)
33-
walk(ast as Node, {
34-
enter(_node) {
132+
await asyncWalk(ast as Node, {
133+
async enter(_node) {
35134
// @ts-expect-error untyped
36135
const calleeName = (_node as SimpleCallExpression).callee?.name
37136
if (!calleeName)
@@ -138,15 +237,28 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
138237
})
139238
canBundle = bundleOption ? bundleOption.value.value : canBundle
140239
if (canBundle) {
141-
const newSrc = options.resolveScript(src)
142-
if (src === newSrc) {
240+
let { url, filename } = normalizeScriptData(src, options.assetsBaseURL)
241+
try {
242+
await downloadScript({src, url, filename }, renderedScript)
243+
}
244+
catch (e) {
245+
if (options.fallbackOnSrcOnBundleFail) {
246+
logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}. Fallback to remote loading.`)
247+
url = src
248+
}
249+
else {
250+
throw e
251+
}
252+
}
253+
254+
if (src === url) {
143255
if (src && src.startsWith('/'))
144256
console.warn(`[Nuxt Scripts: Bundle Transformer] Relative scripts are already bundled. Skipping bundling for \`${src}\`.`)
145257
else
146258
console.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}.`)
147259
}
148260
if (scriptSrcNode) {
149-
s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${newSrc}'`)
261+
s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${url}'`)
150262
}
151263
else {
152264
const optionsNode = node.arguments[0] as ObjectExpression
@@ -163,14 +275,14 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
163275
(p: any) => p.key?.name === 'src' || p.key?.value === 'src',
164276
)
165277
if (srcProperty)
166-
s.overwrite(srcProperty.value.start, srcProperty.value.end, `'${newSrc}'`)
278+
s.overwrite(srcProperty.value.start, srcProperty.value.end, `'${url}'`)
167279
else
168-
s.appendRight(scriptInput.end, `, src: '${newSrc}'`)
280+
s.appendRight(scriptInput.end, `, src: '${url}'`)
169281
}
170282
}
171283
else {
172284
// @ts-expect-error untyped
173-
s.appendRight(node.arguments[0].start + 1, ` scriptInput: { src: '${newSrc}' }, `)
285+
s.appendRight(node.arguments[0].start + 1, ` scriptInput: { src: '${url}' }, `)
174286
}
175287
}
176288
}

0 commit comments

Comments
 (0)