UNPKG

@nuxt/scripts

Version:

Load third-party scripts with better performance, privacy and DX in Nuxt Apps.

667 lines (655 loc) 26.7 kB
import { useNuxt, useLogger, addDevServerHandler, tryUseNuxt, createResolver, addTemplate, logger as logger$1, defineNuxtModule, addImportsDir, addComponentsDir, addImports, addPluginTemplate, addBuildPlugin, hasNuxtModule } from '@nuxt/kit'; import { resolvePackageJSON, readPackageJSON } from 'pkg-types'; import { existsSync } from 'node:fs'; import fsp from 'node:fs/promises'; import { createUnplugin } from 'unplugin'; import MagicString from 'magic-string'; import { asyncWalk, walk } from 'estree-walker'; import { joinURL, parseURL, parseQuery, hasProtocol } from 'ufo'; import { hash } from 'ohash'; import { join, resolve, relative } from 'pathe'; import { colors } from 'consola/utils'; import { fetch, $fetch } from 'ofetch'; import { lazyEventHandler, eventHandler, createError } from 'h3'; import { defu } from 'defu'; import { createStorage } from 'unstorage'; import fsDriver from 'unstorage/drivers/fs-lite'; import { pathToFileURL } from 'node:url'; import { isCI, provider } from 'std-env'; import { registry } from './registry.mjs'; const DEVTOOLS_UI_ROUTE = "/__nuxt-scripts"; const DEVTOOLS_UI_LOCAL_PORT = 3300; function setupDevToolsUI(options, resolve, nuxt = useNuxt()) { const clientPath = resolve("./client"); const isProductionBuild = existsSync(clientPath); if (isProductionBuild) { nuxt.hook("vite:serverCreated", async (server) => { const sirv = await import('sirv').then((r) => r.default || r); server.middlewares.use( DEVTOOLS_UI_ROUTE, sirv(clientPath, { dev: true, single: true }) ); }); } else { nuxt.hook("vite:extendConfig", (config) => { config.server = config.server || {}; config.server.proxy = config.server.proxy || {}; config.server.proxy[DEVTOOLS_UI_ROUTE] = { target: `http://localhost:${DEVTOOLS_UI_LOCAL_PORT}${DEVTOOLS_UI_ROUTE}`, changeOrigin: true, followRedirects: true, rewrite: (path) => path.replace(DEVTOOLS_UI_ROUTE, "") }; }); } nuxt.hook("devtools:customTabs", (tabs) => { tabs.push({ // unique identifier name: "nuxt-scripts", // title to display in the tab title: "Scripts", // any icon from Iconify, or a URL to an image icon: "carbon:script", // iframe view view: { type: "iframe", src: DEVTOOLS_UI_ROUTE } }); }); } const logger = useLogger("@nuxt/scripts"); const renderedScript = /* @__PURE__ */ new Map(); const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365; const bundleStorage = () => { const nuxt = tryUseNuxt(); return createStorage({ driver: fsDriver({ base: resolve(nuxt?.options.rootDir || "", "node_modules/.cache/nuxt/scripts") }) }); }; function setupPublicAssetStrategy(options = {}) { const assetsBaseURL = options.prefix || "/_scripts"; const nuxt = useNuxt(); const storage = bundleStorage(); addDevServerHandler({ route: assetsBaseURL, handler: lazyEventHandler(async () => { return eventHandler(async (event) => { const filename = event.path.slice(1); const scriptDescriptor = renderedScript.get(join(assetsBaseURL, event.path.slice(1))); if (!scriptDescriptor || scriptDescriptor instanceof Error) throw createError({ statusCode: 404 }); const key = `bundle:${filename}`; let res = await storage.getItemRaw(key); if (!res) { res = await fetch(scriptDescriptor.src).then((r) => r.arrayBuffer()).then((r) => Buffer.from(r)); await storage.setItemRaw(key, res); } return res; }); }) }); if (nuxt.options.dev) { nuxt.options.routeRules ||= {}; nuxt.options.routeRules[joinURL(assetsBaseURL, "**")] = { cache: { maxAge: ONE_YEAR_IN_SECONDS } }; } nuxt.options.nitro.publicAssets ||= []; const cacheDir = join(nuxt.options.buildDir, "cache", "scripts"); nuxt.options.nitro.publicAssets.push(); nuxt.options.nitro = defu(nuxt.options.nitro, { publicAssets: [{ dir: cacheDir, maxAge: ONE_YEAR_IN_SECONDS, baseURL: assetsBaseURL }], prerender: { ignore: [assetsBaseURL] } }); return { renderedScript }; } function isVue(id, opts = {}) { const { search } = parseURL(decodeURIComponent(pathToFileURL(id).href)); if (id.endsWith(".vue") && !search) { return true; } if (!search) { return false; } const query = parseQuery(search); if (query.nuxt_component) { return false; } if (query.macro && (search === "?macro=true" || !opts.type || opts.type.includes("script"))) { return true; } const type = "setup" in query ? "script" : query.type; if (!("vue" in query) || opts.type && !opts.type.includes(type)) { return false; } return true; } const JS_RE = /\.(?:[cm]?j|t)sx?$/; function isJS(id) { const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)); return JS_RE.test(pathname); } function normalizeScriptData(src, assetsBaseURL = "/_scripts") { if (hasProtocol(src, { acceptRelative: true })) { src = src.replace(/^\/\//, "https://"); const url = parseURL(src); const file = [ `${hash(url)}.js` // force an extension ].filter(Boolean).join("-"); const nuxt = tryUseNuxt(); return { url: joinURL(joinURL(nuxt?.options.app.baseURL || "", assetsBaseURL), file), filename: file }; } return { url: src }; } async function downloadScript(opts, renderedScript, fetchOptions) { const { src, url, filename } = opts; if (src === url || !filename) { return; } const storage = bundleStorage(); const scriptContent = renderedScript.get(src); let res = scriptContent instanceof Error ? void 0 : scriptContent?.content; if (!res) { if (await storage.hasItem(`bundle:${filename}`)) { const res2 = await storage.getItemRaw(`bundle:${filename}`); renderedScript.set(url, { content: res2, size: res2.length / 1024, encoding: "utf-8", src, filename }); return; } let encoding; let size = 0; res = await $fetch.raw(src, { ...fetchOptions, responseType: "arrayBuffer" }).then(async (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 Buffer.from(r._data || await r.arrayBuffer()); }); await storage.setItemRaw(`bundle:${filename}`, res); size = size || res.length / 1024; logger.info(`Downloading script ${colors.gray(`${src} \u2192 ${filename} (${size.toFixed(2)} kB ${encoding})`)}`); renderedScript.set(url, { content: res, size, encoding, src, filename }); } } function NuxtScriptBundleTransformer(options = { renderedScript: /* @__PURE__ */ new Map() }) { const nuxt = useNuxt(); const { renderedScript = /* @__PURE__ */ new Map() } = options; const cacheDir = join(nuxt.options.buildDir, "cache", "scripts"); nuxt.hooks.hook("build:done", async () => { const scripts = [...renderedScript]; if (!scripts.length) { logger.debug("[bundle-script-transformer] No scripts to bundle..."); return; } logger.debug("[bundle-script-transformer] Bundling scripts..."); if (!nuxt.options.dev) { await fsp.rm(cacheDir, { recursive: true, force: true }); } await fsp.mkdir(cacheDir, { recursive: true }); await Promise.all(scripts.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.debug(colors.gray(` \u251C\u2500 ${url} \u2192 ${joinURL(content.src)} (${content.size.toFixed(2)} kB ${content.encoding})`)); })); }); return createUnplugin(() => { return { name: "nuxt:scripts:bundler-transformer", transformInclude(id) { return isVue(id, { type: ["template", "script"] }) || isJS(id); }, async transform(code, id) { if (!code.includes("useScript")) return; const ast = this.parse(code); const s = new MagicString(code); await asyncWalk(ast, { async enter(_node) { const calleeName = _node.callee?.name; if (!calleeName) return; const isValidCallee = calleeName === "useScript" || calleeName?.startsWith("useScript") && /^[A-Z]$/.test(calleeName?.charAt(9)) && !calleeName.startsWith("useScriptTrigger") && !calleeName.startsWith("useScriptEvent"); if (_node.type === "CallExpression" && _node.callee.type === "Identifier" && isValidCallee) { const fnName = _node.callee?.name; const node = _node; let scriptSrcNode; let src; if (fnName === "useScript") { if (node.arguments[0].type === "Literal") { scriptSrcNode = node.arguments[0]; } else if (node.arguments[0].type === "ObjectExpression") { const srcProperty = node.arguments[0].properties.find( (p) => (p.key?.name === "src" || p.key?.value === "src") && p?.value.type === "Literal" ); scriptSrcNode = srcProperty?.value; } } else { const registryNode = options.scripts?.find((i) => i.import.name === fnName); if (!registryNode) { return; } if (!registryNode.scriptBundling && !registryNode.src) return; if (node.arguments[0]?.type === "ObjectExpression") { const optionsNode = node.arguments[0]; const fnArg0 = {}; for (const prop of optionsNode.properties) { if (prop.type === "Property" && prop.value.type === "Literal") fnArg0[prop.key.name] = prop.value.value; } const srcProperty = node.arguments[0].properties.find( (p) => (p.key?.name === "src" || p.key?.value === "src") && p?.value.type === "Literal" && p.type === "Property" ); if (srcProperty?.value?.value) { scriptSrcNode = srcProperty?.value; } else { src = registryNode.scriptBundling && registryNode.scriptBundling(fnArg0); if (src === false) return; } } } if (scriptSrcNode || src) { src = src || (typeof scriptSrcNode?.value === "string" ? scriptSrcNode?.value : false); if (src) { let canBundle = !!options.defaultBundle; if (node.arguments[1]?.type === "ObjectExpression") { const scriptOptionsArg = node.arguments[1]; const bundleProperty = scriptOptionsArg.properties.find( (p) => (p.key?.name === "bundle" || p.key?.value === "bundle") && p.type === "Property" ); if (bundleProperty && bundleProperty.value.type === "Literal") { const value = bundleProperty.value; if (String(value.value) !== "true") { canBundle = false; return; } if (scriptOptionsArg.properties.length === 1) { s.remove(scriptOptionsArg.start, scriptOptionsArg.end); } else { const nextProperty = scriptOptionsArg.properties.find( (p) => p.start > bundleProperty.end && p.type === "Property" ); s.remove(bundleProperty.start, nextProperty ? nextProperty.start : bundleProperty.end); } canBundle = true; } } const scriptOptions = node.arguments[0].properties?.find( (p) => p.key?.name === "scriptOptions" ); 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 (canBundle) { const { url: _url, filename } = normalizeScriptData(src, options.assetsBaseURL); let url = _url; try { await downloadScript({ src, url, filename }, renderedScript, options.fetchOptions); } 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("/")) logger.warn(`[Nuxt Scripts: Bundle Transformer] Relative scripts are already bundled. Skipping bundling for \`${src}\`.`); else logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}.`); } if (scriptSrcNode) { s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${url}'`); } else { const optionsNode = node.arguments[0]; const scriptInputProperty = optionsNode.properties.find( (p) => p.key?.name === "scriptInput" || p.key?.value === "scriptInput" ); if (scriptInputProperty) { const scriptInput = scriptInputProperty.value; if (scriptInput.type === "ObjectExpression") { const srcProperty = scriptInput.properties.find( (p) => p.key?.name === "src" || p.key?.value === "src" ); if (srcProperty) s.overwrite(srcProperty.value.start, srcProperty.value.end, `'${url}'`); else s.appendRight(scriptInput.end, `, src: '${url}'`); } } else { s.appendRight(node.arguments[0].start + 1, ` scriptInput: { src: '${url}' }, `); } } } } } } } }); if (s.hasChanged()) { return { code: s.toString(), map: s.generateMap({ includeContent: true, source: id }) }; } } }; }); } function extendTypes(module, template) { const nuxt = useNuxt(); const { resolve } = createResolver(import.meta.url); const fileName = `${module.replace("/", "-")}.d.ts`; addTemplate({ filename: `modules/${fileName}`, getContents: async () => { const typesPath = relative(resolve(nuxt.options.rootDir, nuxt.options.buildDir, "module"), resolve("runtime/types")); const s = await template({ typesPath }); return `// Generated by ${module} ${s} export {} `; } }); nuxt.hooks.hook("prepare:types", ({ references }) => { references.push({ path: resolve(nuxt.options.buildDir, `modules/${fileName}`) }); }); } const isStackblitz = provider === "stackblitz"; async function promptToInstall(name, installCommand, options) { if (await resolvePackageJSON(name).catch(() => null)) return true; logger$1.info(`Package ${name} is missing`); if (isCI) return false; if (options.prompt === true || options.prompt !== false && !isStackblitz) { const confirm = await logger$1.prompt(`Do you want to install ${name} package?`, { type: "confirm", name: "confirm", initial: true }); if (!confirm) return false; } logger$1.info(`Installing ${name}...`); try { await installCommand(); logger$1.success(`Installed ${name}`); return true; } catch (err) { logger$1.error(err); return false; } } const installPrompts = /* @__PURE__ */ new Set(); function installNuxtModule(name, options) { if (installPrompts.has(name)) return; installPrompts.add(name); const nuxt = tryUseNuxt(); if (!nuxt) return; return promptToInstall(name, async () => { const { runCommand } = await import(String("nuxi")); await runCommand("module", ["add", name, "--cwd", nuxt.options.rootDir]); }, { rootDir: nuxt.options.rootDir, searchPaths: nuxt.options.modulesDir, ...options }); } function NuxtScriptsCheckScripts() { return createUnplugin(() => { return { name: "nuxt-scripts:check-scripts", transformInclude(id) { return isVue(id, { type: ["script"] }); }, async transform(code) { if (!code.includes("useScript")) return; const ast = this.parse(code); let nameNode; let errorNode; walk(ast, { enter(_node) { if (_node.type === "VariableDeclaration" && _node.declarations?.[0]?.id?.type === "ObjectPattern") { const objPattern = _node.declarations[0]?.id; for (const property of objPattern.properties) { if (property.type === "Property" && property.key.type === "Identifier" && property.key.name === "$script" && property.value.type === "Identifier") { nameNode = _node; } } } if (nameNode) { let sequence = _node.type === "SequenceExpression" ? _node : null; let assignmentExpression; if (_node.type === "VariableDeclaration") { if (_node.declarations[0]?.init?.type === "SequenceExpression") { sequence = _node.declarations[0]?.init; assignmentExpression = _node.declarations[0]?.init?.expressions?.[0]; } } if (sequence && !assignmentExpression) { assignmentExpression = sequence.expressions[0]?.type === "AssignmentExpression" ? sequence.expressions[0] : null; } if (assignmentExpression) { const right = assignmentExpression?.right; if (right.callee?.name === "_withAsyncContext") { if (right.arguments[0]?.body?.name === "$script" || right.arguments[0]?.body?.callee?.object?.name === "$script") { errorNode = nameNode; } } } } } }); if (errorNode) { return this.error(new Error("You can't use a top-level await on $script as it will never resolve.")); } } }; }); } function templatePlugin(config, registry) { if (Array.isArray(config.globals)) { config.globals = Object.fromEntries(config.globals.map((i) => [hash(i), i])); logger.warn("The `globals` array option is deprecated, please convert to an object."); } const imports = ["useScript", "defineNuxtPlugin"]; const inits = []; for (const [k, c] of Object.entries(config.registry || {})) { const importDefinition = registry.find((i) => i.import.name === `useScript${k.substring(0, 1).toUpperCase() + k.substring(1)}`); if (importDefinition) { imports.unshift(importDefinition.import.name); const args = (typeof c !== "object" ? {} : c) || {}; if (c === "mock") args.scriptOptions = { trigger: "manual", skipValidation: true }; inits.push(`const ${k} = ${importDefinition.import.name}(${JSON.stringify(args)})`); } } for (const [k, c] of Object.entries(config.globals || {})) { if (typeof c === "string") { inits.push(`const ${k} = useScript(${JSON.stringify({ src: c, key: k })}, { use: () => ({ ${k}: window.${k} }) })`); } else if (Array.isArray(c) && c.length === 2) { inits.push(`const ${k} = useScript(${JSON.stringify({ key: k, ...typeof c[0] === "string" ? { src: c[0] } : c[0] })}, { ...${JSON.stringify(c[1])}, use: () => ({ ${k}: window.${k} }) })`); } else { inits.push(`const ${k} = useScript(${JSON.stringify({ key: k, ...c })}, { use: () => ({ ${k}: window.${k} }) })`); } } return [ `import { ${imports.join(", ")} } from '#imports'`, "", `export default defineNuxtPlugin({`, ` name: "scripts:init",`, ` env: { islands: false },`, ` parallel: true,`, ` setup() {`, ...inits.map((i) => ` ${i}`), ` return { provide: { $scripts: { ${[...Object.keys(config.globals || {}), ...Object.keys(config.registry || {})].join(", ")} } } }`, ` }`, `})` ].join("\n"); } const module = defineNuxtModule({ meta: { name: "@nuxt/scripts", configKey: "scripts", compatibility: { nuxt: ">=3.16", bridge: false } }, defaults: { defaultScriptOptions: { trigger: "onNuxtReady" }, assets: { fetchOptions: { retry: 3, // Specifies the number of retry attempts for failed fetches. retryDelay: 2e3, // Specifies the delay (in milliseconds) between retry attempts. timeout: 15e3 // Configures the maximum time (in milliseconds) allowed for each fetch attempt. } }, enabled: true, debug: false }, async setup(config, nuxt) { const { resolve } = createResolver(import.meta.url); const { version, name } = await readPackageJSON(resolve("../package.json")); nuxt.options.alias["#nuxt-scripts-validator"] = resolve(`./runtime/validation/${nuxt.options.dev || nuxt.options._prepare ? "valibot" : "mock"}`); nuxt.options.alias["#nuxt-scripts"] = resolve("./runtime"); logger.level = config.debug || nuxt.options.debug ? 4 : 3; if (!config.enabled) { logger.debug("The module is disabled, skipping setup."); return; } const { version: unheadVersion } = await readPackageJSON("@unhead/vue", { from: nuxt.options.modulesDir }).catch(() => ({ version: null })); if (unheadVersion?.startsWith("1")) { logger.error(`Nuxt Scripts requires Unhead >= 2, you are using v${unheadVersion}. Please run \`nuxi upgrade --clean\` to upgrade...`); } nuxt.options.runtimeConfig["nuxt-scripts"] = { version }; nuxt.options.runtimeConfig.public["nuxt-scripts"] = { // expose for devtools version: nuxt.options.dev ? version : void 0, defaultScriptOptions: config.defaultScriptOptions }; addImportsDir([ resolve("./runtime/composables"), // auto-imports aren't working without this for some reason // TODO find solution as we're double-registering resolve("./runtime/registry") ]); addComponentsDir({ path: resolve("./runtime/components") }); const scripts = registry(resolve); nuxt.hooks.hook("modules:done", async () => { const registryScripts = [...scripts]; await nuxt.hooks.callHook("scripts:registry", registryScripts); const registryScriptsWithImport = registryScripts.filter((i) => !!i.import?.name); addImports(registryScriptsWithImport.map((i) => { return { priority: -1, ...i.import }; })); const newScripts = registryScriptsWithImport.filter((i) => !scripts.some((r) => r.import?.name === i.import.name)); extendTypes(name, async ({ typesPath }) => { let types = ` declare module '#app' { interface NuxtApp { $scripts: Record<${[...Object.keys(config.globals || {}), ...Object.keys(config.registry || {})].map((k) => `'${k}'`).concat(["string"]).join(" | ")}, (import('#nuxt-scripts/types').UseScriptContext<any>)> _scripts: Record<string, (import('#nuxt-scripts/types').UseScriptContext<any>)> } interface RuntimeNuxtHooks { 'scripts:updated': (ctx: { scripts: Record<string, (import('#nuxt-scripts/types').UseScriptContext<any>)> }) => void | Promise<void> } } `; if (newScripts.length) { types = `${types} declare module '#nuxt-scripts/types' { type NuxtUseScriptOptions = Omit<import('${typesPath}').NuxtUseScriptOptions, 'use' | 'beforeInit'> interface ScriptRegistry { ${newScripts.map((i) => { const key = i.import?.name.replace("useScript", ""); const keyLcFirst = key.substring(0, 1).toLowerCase() + key.substring(1); return ` ${keyLcFirst}?: import('${i.import?.from}').${key}Input | [import('${i.import?.from}').${key}Input, NuxtUseScriptOptions]`; }).join("\n")} } }`; return types; } return types; }); if (Object.keys(config.globals || {}).length || Object.keys(config.registry || {}).length) { addPluginTemplate({ filename: `modules/${name.replace("/", "-")}/plugin.mjs`, getContents() { return templatePlugin(config, registryScriptsWithImport); } }); } const { renderedScript } = setupPublicAssetStrategy(config.assets); const moduleInstallPromises = /* @__PURE__ */ new Map(); addBuildPlugin(NuxtScriptsCheckScripts(), { dev: true }); addBuildPlugin(NuxtScriptBundleTransformer({ scripts: registryScriptsWithImport, defaultBundle: config.defaultScriptOptions?.bundle, moduleDetected(module) { if (nuxt.options.dev && module !== "@nuxt/scripts" && !moduleInstallPromises.has(module) && !hasNuxtModule(module)) moduleInstallPromises.set(module, () => installNuxtModule(module)); }, assetsBaseURL: config.assets?.prefix, fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail, fetchOptions: config.assets?.fetchOptions, renderedScript })); nuxt.hooks.hook("build:done", async () => { const initPromise = Array.from(moduleInstallPromises.values()); for (const p of initPromise) await p?.(); }); }); if (nuxt.options.dev) setupDevToolsUI(config, resolve); } }); export { module as default };