vite
Version:
Native-ESM powered web dev build tool
389 lines (353 loc) • 11.1 kB
text/typescript
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)
}