UNPKG

vite

Version:

Native-ESM powered web dev build tool

389 lines (353 loc) 11.1 kB
import fs from 'fs' import path from 'path' import chalk from 'chalk' import { createHash } from 'crypto' import { build, BuildOptions as EsbuildBuildOptions } from 'esbuild' import { ResolvedConfig } from '../config' import { createDebugger, emptyDir, lookupFile, normalizePath, writeFile, flattenId } from '../utils' import { esbuildDepPlugin } from './esbuildDepPlugin' import { ImportSpecifier, init, parse } from 'es-module-lexer' import { scanImports } from './scan' const debug = createDebugger('vite:deps') export type ExportsData = [ImportSpecifier[], string[]] & { // es-module-lexer has a facade detection but isn't always accurate for our // use case when the module has default export hasReExports?: true } export interface DepOptimizationOptions { /** * By default, Vite will crawl your index.html to detect dependencies that * need to be pre-bundled. If build.rollupOptions.input is specified, Vite * will crawl those entry points instead. * * If neither of these fit your needs, you can specify custom entries using * this option - the value should be a fast-glob pattern or array of patterns * (https://github.com/mrmlnc/fast-glob#basic-syntax) that are relative from * vite project root. This will overwrite default entries inference. */ entries?: string | string[] /** * Force optimize listed dependencies (must be resolvable import paths, * cannot be globs). */ include?: string[] /** * Do not optimize these dependencies (must be resolvable import paths, * cannot be globs). */ exclude?: string[] /** * Options to pass to esbuild during the dep scanning and optimization * * Certain options are omitted since changing them would not be compatible * with Vite's dep optimization. * * - `external` is also omitted, use Vite's `optimizeDeps.exclude` option * - `plugins` are merged with Vite's dep plugin * - `keepNames` takes precedence over the deprecated `optimizeDeps.keepNames` * * https://esbuild.github.io/api */ esbuildOptions?: Omit< EsbuildBuildOptions, | 'bundle' | 'entryPoints' | 'external' | 'write' | 'watch' | 'outdir' | 'outfile' | 'outbase' | 'outExtension' | 'metafile' > /** * @deprecated use `esbuildOptions.keepNames` */ keepNames?: boolean } export interface DepOptimizationMetadata { /** * The main hash is determined by user config and dependency lockfiles. * This is checked on server startup to avoid unnecessary re-bundles. */ hash: string /** * The browser hash is determined by the main hash plus additional dependencies * discovered at runtime. This is used to invalidate browser requests to * optimized deps. */ browserHash: string optimized: Record< string, { file: string src: string needsInterop: boolean } > } export async function optimizeDeps( config: ResolvedConfig, force = config.server.force, asCommand = false, newDeps?: Record<string, string>, // missing imports encountered after server has started ssr?: boolean ): Promise<DepOptimizationMetadata | null> { config = { ...config, command: 'build' } const { root, logger, cacheDir } = config const log = asCommand ? logger.info : debug if (!cacheDir) { log(`No cache directory. Skipping.`) return null } const dataPath = path.join(cacheDir, '_metadata.json') const mainHash = getDepHash(root, config) const data: DepOptimizationMetadata = { hash: mainHash, browserHash: mainHash, optimized: {} } if (!force) { let prevData try { prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8')) } catch (e) {} // hash is consistent, no need to re-bundle if (prevData && prevData.hash === data.hash) { log('Hash is consistent. Skipping. Use --force to override.') return prevData } } if (fs.existsSync(cacheDir)) { emptyDir(cacheDir) } else { fs.mkdirSync(cacheDir, { recursive: true }) } // a hint for Node.js // all files in the cache directory should be recognized as ES modules writeFile( path.resolve(cacheDir, 'package.json'), JSON.stringify({ type: 'module' }) ) let deps: Record<string, string>, missing: Record<string, string> if (!newDeps) { ;({ deps, missing } = await scanImports(config)) } else { deps = newDeps missing = {} } // update browser hash data.browserHash = createHash('sha256') .update(data.hash + JSON.stringify(deps)) .digest('hex') .substr(0, 8) const missingIds = Object.keys(missing) if (missingIds.length) { throw new Error( `The following dependencies are imported but could not be resolved:\n\n ${missingIds .map( (id) => `${chalk.cyan(id)} ${chalk.white.dim( `(imported by ${missing[id]})` )}` ) .join(`\n `)}\n\nAre they installed?` ) } const include = config.optimizeDeps?.include if (include) { const resolve = config.createResolver({ asSrc: false }) for (const id of include) { if (!deps[id]) { const entry = await resolve(id) if (entry) { deps[id] = entry } else { throw new Error( `Failed to resolve force included dependency: ${chalk.cyan(id)}` ) } } } } const qualifiedIds = Object.keys(deps) if (!qualifiedIds.length) { writeFile(dataPath, JSON.stringify(data, null, 2)) log(`No dependencies to bundle. Skipping.\n\n\n`) return data } const total = qualifiedIds.length const maxListed = 5 const listed = Math.min(total, maxListed) const extra = Math.max(0, total - maxListed) const depsString = chalk.yellow( qualifiedIds.slice(0, listed).join(`\n `) + (extra > 0 ? `\n (...and ${extra} more)` : ``) ) if (!asCommand) { if (!newDeps) { // This is auto run on server start - let the user know that we are // pre-optimizing deps logger.info( chalk.greenBright(`Pre-bundling dependencies:\n ${depsString}`) ) logger.info( `(this will be run only when your dependencies or config have changed)` ) } } else { logger.info(chalk.greenBright(`Optimizing dependencies:\n ${depsString}`)) } // esbuild generates nested directory output with lowest common ancestor base // this is unpredictable and makes it difficult to analyze entry / output // mapping. So what we do here is: // 1. flatten all ids to eliminate slash // 2. in the plugin, read the entry ourselves as virtual files to retain the // path. const flatIdDeps: Record<string, string> = {} const idToExports: Record<string, ExportsData> = {} const flatIdToExports: Record<string, ExportsData> = {} await init for (const id in deps) { const flatId = flattenId(id) flatIdDeps[flatId] = deps[id] const entryContent = fs.readFileSync(deps[id], 'utf-8') const exportsData = parse(entryContent) as ExportsData for (const { ss, se } of exportsData[0]) { const exp = entryContent.slice(ss, se) if (/export\s+\*\s+from/.test(exp)) { exportsData.hasReExports = true } } idToExports[id] = exportsData flatIdToExports[flatId] = exportsData } const define: Record<string, string> = { 'process.env.NODE_ENV': JSON.stringify(config.mode) } for (const key in config.define) { const value = config.define[key] define[key] = typeof value === 'string' ? value : JSON.stringify(value) } const start = Date.now() const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {} const result = await build({ absWorkingDir: process.cwd(), entryPoints: Object.keys(flatIdDeps), bundle: true, format: 'esm', external: config.optimizeDeps?.exclude, logLevel: 'error', splitting: true, sourcemap: true, outdir: cacheDir, treeShaking: 'ignore-annotations', metafile: true, define, plugins: [ ...plugins, esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr) ], ...esbuildOptions }) const meta = result.metafile! // the paths in `meta.outputs` are relative to `process.cwd()` const cacheDirOutputPath = path.relative(process.cwd(), cacheDir) for (const id in deps) { const entry = deps[id] data.optimized[id] = { file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')), src: entry, needsInterop: needsInterop( id, idToExports[id], meta.outputs, cacheDirOutputPath ) } } writeFile(dataPath, JSON.stringify(data, null, 2)) debug(`deps bundled in ${Date.now() - start}ms`) return data } // https://github.com/vitejs/vite/issues/1724#issuecomment-767619642 // a list of modules that pretends to be ESM but still uses `require`. // this causes esbuild to wrap them as CJS even when its entry appears to be ESM. const KNOWN_INTEROP_IDS = new Set(['moment']) function needsInterop( id: string, exportsData: ExportsData, outputs: Record<string, any>, cacheDirOutputPath: string ): boolean { if (KNOWN_INTEROP_IDS.has(id)) { return true } const [imports, exports] = exportsData // entry has no ESM syntax - likely CJS or UMD if (!exports.length && !imports.length) { return true } // if a peer dependency used require() on a ESM dependency, esbuild turns the // ESM dependency's entry chunk into a single default export... detect // such cases by checking exports mismatch, and force interop. const flatId = flattenId(id) + '.js' let generatedExports: string[] | undefined for (const output in outputs) { if ( normalizePath(output) === normalizePath(path.join(cacheDirOutputPath, flatId)) ) { generatedExports = outputs[output].exports break } } if ( !generatedExports || (isSingleDefaultExport(generatedExports) && !isSingleDefaultExport(exports)) ) { return true } return false } function isSingleDefaultExport(exports: string[]) { return exports.length === 1 && exports[0] === 'default' } const lockfileFormats = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'] function getDepHash(root: string, config: ResolvedConfig): string { let content = lookupFile(root, lockfileFormats) || '' // also take config into account // only a subset of config options that can affect dep optimization content += JSON.stringify( { mode: config.mode, root: config.root, resolve: config.resolve, assetsInclude: config.assetsInclude, plugins: config.plugins.map((p) => p.name), optimizeDeps: { include: config.optimizeDeps?.include, exclude: config.optimizeDeps?.exclude } }, (_, value) => { if (typeof value === 'function' || value instanceof RegExp) { return value.toString() } return value } ) return createHash('sha256').update(content).digest('hex').substr(0, 8) }