Skip to content

Commit

Permalink
feat: allow fallback on bundle fail (#255)
Browse files Browse the repository at this point in the history
  • Loading branch information
huang-julien authored Sep 13, 2024
1 parent de6e76f commit add80c3
Show file tree
Hide file tree
Showing 5 changed files with 295 additions and 140 deletions.
7 changes: 7 additions & 0 deletions docs/content/docs/3.api/5.nuxt-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,10 @@ Disables the Nuxt Scripts module.
- Default: `false`

Enable to see debug logs.

## `fallbackOnSrcOnBundleFail `

- Type: `boolean`
- Default: `false`

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.
100 changes: 27 additions & 73 deletions src/assets.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,56 @@
import fsp from 'node:fs/promises'
import { addDevServerHandler, useNuxt } from '@nuxt/kit'
import { createError, eventHandler, lazyEventHandler } from 'h3'
import { fetch } from 'ofetch'
import { colors } from 'consola/utils'
import { defu } from 'defu'
import type { NitroConfig } from 'nitropack'
import { hasProtocol, joinURL, parseURL } from 'ufo'
import { joinURL } from 'ufo'
import { join } from 'pathe'
import { hash } from 'ohash'
import { createStorage } from 'unstorage'
import fsDriver from 'unstorage/drivers/fs-lite'
import { logger } from './logger'

import type { ModuleOptions } from './module'

const renderedScript = new Map<string, {
content: Buffer
/**
* in kb
*/
size: number
encoding?: string
src: string
filename?: string
} | Error>()

const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365

// TODO: refactor to use nitro storage when it can be cached between builds
export const storage = createStorage({
driver: fsDriver({
base: 'node_modules/.cache/nuxt/scripts',
}),
})

// TODO: replace this with nuxt/assets when it is released
export function setupPublicAssetStrategy(options: ModuleOptions['assets'] = {}) {
const assetsBaseURL = options.prefix || '/_scripts'
const nuxt = useNuxt()
const renderedScriptSrc = new Map<string, string>()

// TODO: refactor to use nitro storage when it can be cached between builds
const storage = createStorage({
driver: fsDriver({
base: 'node_modules/.cache/nuxt/scripts',
}),
})

function normalizeScriptData(src: string): string {
if (hasProtocol(src, { acceptRelative: true })) {
src = src.replace(/^\/\//, 'https://')
const url = parseURL(src)
const file = [
`${hash(url)}.js`, // force an extension
].filter(Boolean).join('-')

renderedScriptSrc.set(file, src)
return joinURL(assetsBaseURL, file)
}
return src
}

// Register font proxy URL for development
addDevServerHandler({
route: assetsBaseURL,
handler: lazyEventHandler(async () => {
return eventHandler(async (event) => {
const filename = event.path.slice(1)
const url = renderedScriptSrc.get(event.path.slice(1))
if (!url)
const scriptDescriptor = renderedScript.get(join(assetsBaseURL, event.path.slice(1)))

if (!scriptDescriptor || scriptDescriptor instanceof Error)
throw createError({ statusCode: 404 })

const key = `data:scripts:${filename}`
// Use storage to cache the font data between requests
let res = await storage.getItemRaw(key)
if (!res) {
res = await fetch(url).then(r => r.arrayBuffer()).then(r => Buffer.from(r))
res = await fetch(scriptDescriptor.src).then(r => r.arrayBuffer()).then(r => Buffer.from(r))
await storage.setItemRaw(key, res)
}
return res
Expand Down Expand Up @@ -87,47 +81,7 @@ export function setupPublicAssetStrategy(options: ModuleOptions['assets'] = {})
},
} satisfies NitroConfig)

nuxt.hook('nitro:init', async (nitro) => {
if (nuxt.options.dev)
return
nitro.hooks.hook('rollup:before', async () => {
await fsp.rm(cacheDir, { recursive: true, force: true })
await fsp.mkdir(cacheDir, { recursive: true })
let banner = false
const failedScriptDownload = new Set<{ url: string, statusText: string, status: number }>()
for (const [filename, url] of renderedScriptSrc) {
const key = `data:scripts:${filename}`
// Use storage to cache the font data between builds
let res = await storage.getItemRaw(key)
if (!res) {
if (!banner) {
banner = true
logger.info('Downloading scripts...')
}
let encoding
let size = 0
res = await fetch(url).then((r) => {
if (!r.ok) {
failedScriptDownload.add({ url, statusText: r.statusText, status: r.status })
return Buffer.from('')
}
encoding = r.headers.get('content-encoding')
const contentLength = r.headers.get('content-length')
size = contentLength ? Number(contentLength) / 1024 : 0
return r.arrayBuffer()
}).then(r => Buffer.from(r))
logger.log(colors.gray(` ├─ ${url}${joinURL(assetsBaseURL, filename)} (${size.toFixed(2)} kB ${encoding})`))
await storage.setItemRaw(key, res)
}
await fsp.writeFile(join(cacheDir, filename), res)
}
if (failedScriptDownload.size) {
throw new Error(`@nuxt/script: Failed to download scripts:\n${[...failedScriptDownload].map(({ url, statusText, status }) => ` ├─ ${url} (${status} ${statusText})`).join('\n')}`)
}
if (banner)
logger.success('Scripts downloaded and cached.')
})
})

return { normalizeScriptData }
return {
renderedScript
}
}
19 changes: 10 additions & 9 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export interface ModuleOptions {
* TODO Make configurable in future.
*/
strategy?: 'public'
/**
* Fallback to src if bundle fails to load.
* The default behavior is to stop the bundling process if a script fails to be downloaded.
* @default false
*/
fallbackOnSrcOnBundleFail?: boolean
}
/**
* Whether the module is enabled.
Expand Down Expand Up @@ -188,8 +194,7 @@ ${newScripts.map((i) => {
},
})
}
const scriptMap = new Map<string, string>()
const { normalizeScriptData } = setupPublicAssetStrategy(config.assets)
const { renderedScript } = setupPublicAssetStrategy(config.assets)

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

Expand All @@ -203,13 +208,9 @@ ${newScripts.map((i) => {
if (nuxt.options.dev && module !== '@nuxt/scripts' && !moduleInstallPromises.has(module) && !hasNuxtModule(module))
moduleInstallPromises.set(module, () => installNuxtModule(module))
},
resolveScript(src) {
if (scriptMap.has(src))
return scriptMap.get(src) as string
const url = normalizeScriptData(src)
scriptMap.set(src, url)
return url
},
assetsBaseURL: config.assets?.prefix,
fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail,
renderedScript
}))

nuxt.hooks.hook('build:done', async () => {
Expand Down
134 changes: 123 additions & 11 deletions src/plugins/transform.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,120 @@
import fsp from 'node:fs/promises'
import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string'
import type { SourceMapInput } from 'rollup'
import type { Node } from 'estree-walker'
import { walk } from 'estree-walker'
import { asyncWalk } from 'estree-walker'
import type { Literal, ObjectExpression, Property, SimpleCallExpression } from 'estree'
import type { InferInput } from 'valibot'
import { hasProtocol, parseURL, joinURL } from 'ufo'
import { hash as ohash } from 'ohash'
import { join } from 'pathe'
import { colors } from 'consola/utils'
import { useNuxt } from '@nuxt/kit'
import { logger } from '../logger'
import { storage } from '../assets'
import { isJS, isVue } from './util'
import type { RegistryScript } from '#nuxt-scripts'

export interface AssetBundlerTransformerOptions {
resolveScript: (src: string) => string
moduleDetected?: (module: string) => void
defaultBundle?: boolean
assetsBaseURL?: string
scripts?: Required<RegistryScript>[]
fallbackOnSrcOnBundleFail?: boolean
renderedScript?: Map<string, {
content: Buffer
/**
* in kb
*/
size: number
encoding?: string
src: string
filename?: string
} | Error>
}

export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOptions) {
function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts'): { url: string, filename?: string } {
if (hasProtocol(src, { acceptRelative: true })) {
src = src.replace(/^\/\//, 'https://')
const url = parseURL(src)
const file = [
`${ohash(url)}.js`, // force an extension
].filter(Boolean).join('-')
return { url: joinURL(assetsBaseURL, file), filename: file }
}
return { url: src }
}
async function downloadScript(opts: {
src: string,
url: string,
filename?: string
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>) {
const { src, url, filename } = opts
if (src === url || !filename) {
return
}
const scriptContent = renderedScript.get(src)
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(`data:scripts:${filename}`)) {
const res = await storage.getItemRaw<Buffer>(`data:scripts:${filename}`)
renderedScript.set(url, {
content: res!,
size: res!.length / 1024,
encoding: 'utf-8',
src,
filename,
})

return
}
let encoding
let size = 0
res = await fetch(src).then((r) => {
if (!r.ok) {
throw new Error(`Failed to fetch ${src}`)
}
encoding = r.headers.get('content-encoding')
const contentLength = r.headers.get('content-length')
size = contentLength ? Number(contentLength) / 1024 : 0

return r.arrayBuffer()
}).then(r => Buffer.from(r))

storage.setItemRaw(`data:scripts:${filename}`, res)
renderedScript.set(url, {
content: res!,
size,
encoding,
src,
filename,
})
}
}

export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOptions = {
renderedScript: new Map()
}) {
const nuxt = useNuxt()
const { renderedScript = new Map() } = options
const cacheDir = join(nuxt.options.buildDir, 'cache', 'scripts')

// done after all transformation is done
// copy all scripts to build
nuxt.hooks.hook('build:done', async () => {
logger.log('[nuxt:scripts:bundler-transformer] Bundling scripts...')
await fsp.rm(cacheDir, { recursive: true, force: true })
await fsp.mkdir(cacheDir, { recursive: true })
await Promise.all([...renderedScript].map(async ([url, content]) => {
if (content instanceof Error || !content.filename)
return
await fsp.writeFile(join(nuxt.options.buildDir, 'cache', 'scripts', content.filename), content.content)
logger.log(colors.gray(` ├─ ${url}${joinURL(content.src)} (${content.size.toFixed(2)} kB ${content.encoding})`))
}))
})

return createUnplugin(() => {
return {
name: 'nuxt:scripts:bundler-transformer',
Expand All @@ -30,8 +129,8 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti

const ast = this.parse(code)
const s = new MagicString(code)
walk(ast as Node, {
enter(_node) {
await asyncWalk(ast as Node, {
async enter(_node) {
// @ts-expect-error untyped
const calleeName = (_node as SimpleCallExpression).callee?.name
if (!calleeName)
Expand Down Expand Up @@ -138,15 +237,28 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
})
canBundle = bundleOption ? bundleOption.value.value : canBundle
if (canBundle) {
const newSrc = options.resolveScript(src)
if (src === newSrc) {
let { url, filename } = normalizeScriptData(src, options.assetsBaseURL)
try {
await downloadScript({src, url, filename }, renderedScript)
}
catch (e) {
if (options.fallbackOnSrcOnBundleFail) {
logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}. Fallback to remote loading.`)
url = src
}
else {
throw e
}
}

if (src === url) {
if (src && src.startsWith('/'))
console.warn(`[Nuxt Scripts: Bundle Transformer] Relative scripts are already bundled. Skipping bundling for \`${src}\`.`)
else
console.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}.`)
}
if (scriptSrcNode) {
s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${newSrc}'`)
s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${url}'`)
}
else {
const optionsNode = node.arguments[0] as ObjectExpression
Expand All @@ -163,14 +275,14 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
(p: any) => p.key?.name === 'src' || p.key?.value === 'src',
)
if (srcProperty)
s.overwrite(srcProperty.value.start, srcProperty.value.end, `'${newSrc}'`)
s.overwrite(srcProperty.value.start, srcProperty.value.end, `'${url}'`)
else
s.appendRight(scriptInput.end, `, src: '${newSrc}'`)
s.appendRight(scriptInput.end, `, src: '${url}'`)
}
}
else {
// @ts-expect-error untyped
s.appendRight(node.arguments[0].start + 1, ` scriptInput: { src: '${newSrc}' }, `)
s.appendRight(node.arguments[0].start + 1, ` scriptInput: { src: '${url}' }, `)
}
}
}
Expand Down
Loading

0 comments on commit add80c3

Please sign in to comment.