vite
Version:
Native-ESM powered web dev build tool
962 lines (884 loc) • 27 kB
text/typescript
import fs from 'fs'
import path from 'path'
import { Plugin } from './plugin'
import { BuildOptions, resolveBuildOptions } from './build'
import {
ResolvedServerOptions,
resolveServerOptions,
ServerOptions
} from './server'
import { CSSOptions } from './plugins/css'
import {
createDebugger,
isExternalUrl,
isObject,
lookupFile,
normalizePath
} from './utils'
import { resolvePlugins } from './plugins'
import chalk from 'chalk'
import { ESBuildOptions } from './plugins/esbuild'
import dotenv from 'dotenv'
import dotenvExpand from 'dotenv-expand'
import { Alias, AliasOptions } from 'types/alias'
import { CLIENT_DIR, DEFAULT_ASSETS_RE } from './constants'
import {
InternalResolveOptions,
ResolveOptions,
resolvePlugin
} from './plugins/resolve'
import { createLogger, Logger, LogLevel } from './logger'
import { DepOptimizationOptions } from './optimizer'
import { createFilter } from '@rollup/pluginutils'
import { ResolvedBuildOptions } from '.'
import { parse as parseUrl } from 'url'
import { JsonOptions } from './plugins/json'
import {
createPluginContainer,
PluginContainer
} from './server/pluginContainer'
import aliasPlugin from '@rollup/plugin-alias'
import { build } from 'esbuild'
const debug = createDebugger('vite:config')
// NOTE: every export in this file is re-exported from ./index.ts so it will
// be part of the public API.
export interface ConfigEnv {
command: 'build' | 'serve'
mode: string
}
export type UserConfigFn = (env: ConfigEnv) => UserConfig | Promise<UserConfig>
export type UserConfigExport = UserConfig | Promise<UserConfig> | UserConfigFn
/**
* Type helper to make it easier to use vite.config.ts
* accepts a direct {@link UserConfig} object, or a function that returns it.
* The function receives a {@link ConfigEnv} object that exposes two properties:
* `command` (either `'build'` or `'serve'`), and `mode`.
*/
export function defineConfig(config: UserConfigExport): UserConfigExport {
return config
}
export type PluginOption = Plugin | false | null | undefined
export interface UserConfig {
/**
* Project root directory. Can be an absolute path, or a path relative from
* the location of the config file itself.
* @default process.cwd()
*/
root?: string
/**
* Base public path when served in development or production.
* @default '/'
*/
base?: string
/**
* Directory to serve as plain static assets. Files in this directory are
* served and copied to build dist dir as-is without transform. The value
* can be either an absolute file system path or a path relative to <root>.
*
* Set to `false` or an empty string to disable copied static assets to build dist dir.
* @default 'public'
*/
publicDir?: string | false
/**
* Directory to save cache files. Files in this directory are pre-bundled
* deps or some other cache files that generated by vite, which can improve
* the performance. You can use `--force` flag or manually delete the directory
* to regenerate the cache files. The value can be either an absolute file
* system path or a path relative to <root>.
* @default 'node_modules/.vite'
*/
cacheDir?: string
/**
* Explicitly set a mode to run in. This will override the default mode for
* each command, and can be overridden by the command line --mode option.
*/
mode?: string
/**
* Define global variable replacements.
* Entries will be defined on `window` during dev and replaced during build.
*/
define?: Record<string, any>
/**
* Array of vite plugins to use.
*/
plugins?: (PluginOption | PluginOption[])[]
/**
* Configure resolver
*/
resolve?: ResolveOptions & { alias?: AliasOptions }
/**
* CSS related options (preprocessors and CSS modules)
*/
css?: CSSOptions
/**
* JSON loading options
*/
json?: JsonOptions
/**
* Transform options to pass to esbuild.
* Or set to `false` to disable esbuild.
*/
esbuild?: ESBuildOptions | false
/**
* Specify additional files to be treated as static assets.
*/
assetsInclude?: string | RegExp | (string | RegExp)[]
/**
* Server specific options, e.g. host, port, https...
*/
server?: ServerOptions
/**
* Build specific options
*/
build?: BuildOptions
/**
* Dep optimization options
*/
optimizeDeps?: DepOptimizationOptions
/**
* SSR specific options
* @alpha
*/
ssr?: SSROptions
/**
* Log level.
* Default: 'info'
*/
logLevel?: LogLevel
/**
* Default: true
*/
clearScreen?: boolean
/**
* Environment files directory. Can be an absolute path, or a path relative from
* the location of the config file itself.
* @default root
*/
envDir?: string
/**
* Import aliases
* @deprecated use `resolve.alias` instead
*/
alias?: AliasOptions
/**
* Force Vite to always resolve listed dependencies to the same copy (from
* project root).
* @deprecated use `resolve.dedupe` instead
*/
dedupe?: string[]
}
export type SSRTarget = 'node' | 'webworker'
export interface SSROptions {
external?: string[]
noExternal?: string | RegExp | (string | RegExp)[]
/**
* Define the target for the ssr build. The browser field in package.json
* is ignored for node but used if webworker is the target
* Default: 'node'
*/
target?: SSRTarget
}
export interface InlineConfig extends UserConfig {
configFile?: string | false
envFile?: false
}
export type ResolvedConfig = Readonly<
Omit<
UserConfig,
'plugins' | 'alias' | 'dedupe' | 'assetsInclude' | 'optimizeDeps'
> & {
configFile: string | undefined
configFileDependencies: string[]
inlineConfig: InlineConfig
root: string
base: string
publicDir: string
command: 'build' | 'serve'
mode: string
isProduction: boolean
env: Record<string, any>
resolve: ResolveOptions & {
alias: Alias[]
}
plugins: readonly Plugin[]
server: ResolvedServerOptions
build: ResolvedBuildOptions
assetsInclude: (file: string) => boolean
logger: Logger
createResolver: (options?: Partial<InternalResolveOptions>) => ResolveFn
optimizeDeps: Omit<DepOptimizationOptions, 'keepNames'>
}
>
export type ResolveFn = (
id: string,
importer?: string,
aliasOnly?: boolean,
ssr?: boolean
) => Promise<string | undefined>
export async function resolveConfig(
inlineConfig: InlineConfig,
command: 'build' | 'serve',
defaultMode = 'development'
): Promise<ResolvedConfig> {
let config = inlineConfig
let configFileDependencies: string[] = []
let mode = inlineConfig.mode || defaultMode
// some dependencies e.g. @vue/compiler-* relies on NODE_ENV for getting
// production-specific behavior, so set it here even though we haven't
// resolve the final mode yet
if (mode === 'production') {
process.env.NODE_ENV = 'production'
}
const configEnv = {
mode,
command
}
let { configFile } = config
if (configFile !== false) {
const loadResult = await loadConfigFromFile(
configEnv,
configFile,
config.root,
config.logLevel
)
if (loadResult) {
config = mergeConfig(loadResult.config, config)
configFile = loadResult.path
configFileDependencies = loadResult.dependencies
}
}
// Define logger
const logger = createLogger(config.logLevel, {
allowClearScreen: config.clearScreen
})
// user config may provide an alternative mode
mode = config.mode || mode
// resolve plugins
const rawUserPlugins = (config.plugins || []).flat().filter((p) => {
return p && (!p.apply || p.apply === command)
}) as Plugin[]
const [prePlugins, normalPlugins, postPlugins] =
sortUserPlugins(rawUserPlugins)
// run config hooks
const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins]
for (const p of userPlugins) {
if (p.config) {
const res = await p.config(config, configEnv)
if (res) {
config = mergeConfig(config, res)
}
}
}
// resolve root
const resolvedRoot = normalizePath(
config.root ? path.resolve(config.root) : process.cwd()
)
// resolve alias with internal client alias
const resolvedAlias = mergeAlias(
// #1732 the CLIENT_DIR may contain $$ which cannot be used as direct
// replacement string.
// @ts-ignore because @rollup/plugin-alias' type doesn't allow function
// replacement, but its implementation does work with function values.
[{ find: /^\/@vite\//, replacement: () => CLIENT_DIR + '/' }],
config.resolve?.alias || config.alias || []
)
const resolveOptions: ResolvedConfig['resolve'] = {
dedupe: config.dedupe,
...config.resolve,
alias: resolvedAlias
}
// load .env files
const envDir = config.envDir
? normalizePath(path.resolve(resolvedRoot, config.envDir))
: resolvedRoot
const userEnv = inlineConfig.envFile !== false && loadEnv(mode, envDir)
// Note it is possible for user to have a custom mode, e.g. `staging` where
// production-like behavior is expected. This is indicated by NODE_ENV=production
// loaded from `.staging.env` and set by us as VITE_USER_NODE_ENV
const isProduction = (process.env.VITE_USER_NODE_ENV || mode) === 'production'
if (isProduction) {
// in case default mode was not production and is overwritten
process.env.NODE_ENV = 'production'
}
// resolve public base url
const BASE_URL = resolveBaseUrl(config.base, command === 'build', logger)
const resolvedBuildOptions = resolveBuildOptions(config.build)
// resolve cache directory
const pkgPath = lookupFile(
resolvedRoot,
[`package.json`],
true /* pathOnly */
)
const cacheDir = config.cacheDir
? path.resolve(resolvedRoot, config.cacheDir)
: pkgPath && path.join(path.dirname(pkgPath), `node_modules/.vite`)
const assetsFilter = config.assetsInclude
? createFilter(config.assetsInclude)
: () => false
// create an internal resolver to be used in special scenarios, e.g.
// optimizer & handling css @imports
const createResolver: ResolvedConfig['createResolver'] = (options) => {
let aliasContainer: PluginContainer | undefined
let resolverContainer: PluginContainer | undefined
return async (id, importer, aliasOnly, ssr) => {
let container: PluginContainer
if (aliasOnly) {
container =
aliasContainer ||
(aliasContainer = await createPluginContainer({
...resolved,
plugins: [aliasPlugin({ entries: resolved.resolve.alias })]
}))
} else {
container =
resolverContainer ||
(resolverContainer = await createPluginContainer({
...resolved,
plugins: [
aliasPlugin({ entries: resolved.resolve.alias }),
resolvePlugin({
...resolved.resolve,
root: resolvedRoot,
isProduction,
isBuild: command === 'build',
ssrTarget: resolved.ssr?.target,
asSrc: true,
preferRelative: false,
tryIndex: true,
...options
})
]
}))
}
return (await container.resolveId(id, importer, undefined, ssr))?.id
}
}
const { publicDir } = config
const resolvedPublicDir =
publicDir !== false && publicDir !== ''
? path.resolve(
resolvedRoot,
typeof publicDir === 'string' ? publicDir : 'public'
)
: ''
const resolved: ResolvedConfig = {
...config,
configFile: configFile ? normalizePath(configFile) : undefined,
configFileDependencies,
inlineConfig,
root: resolvedRoot,
base: BASE_URL,
resolve: resolveOptions,
publicDir: resolvedPublicDir,
cacheDir,
command,
mode,
isProduction,
plugins: userPlugins,
server: resolveServerOptions(resolvedRoot, config.server),
build: resolvedBuildOptions,
env: {
...userEnv,
BASE_URL,
MODE: mode,
DEV: !isProduction,
PROD: isProduction
},
assetsInclude(file: string) {
return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file)
},
logger,
createResolver,
optimizeDeps: {
...config.optimizeDeps,
esbuildOptions: {
keepNames: config.optimizeDeps?.keepNames,
...config.optimizeDeps?.esbuildOptions
}
}
}
;(resolved.plugins as Plugin[]) = await resolvePlugins(
resolved,
prePlugins,
normalPlugins,
postPlugins
)
// call configResolved hooks
await Promise.all(userPlugins.map((p) => p.configResolved?.(resolved)))
if (process.env.DEBUG) {
debug(`using resolved config: %O`, {
...resolved,
plugins: resolved.plugins.map((p) => p.name)
})
}
// TODO Deprecation warnings - remove when out of beta
const logDeprecationWarning = (
deprecatedOption: string,
hint: string,
error?: Error
) => {
logger.warn(
chalk.yellow.bold(
`(!) "${deprecatedOption}" option is deprecated. ${hint}${
error ? `\n${error.stack}` : ''
}`
)
)
}
if (config.build?.base) {
logDeprecationWarning(
'build.base',
'"base" is now a root-level config option.'
)
config.base = config.build.base
}
Object.defineProperty(resolvedBuildOptions, 'base', {
enumerable: false,
get() {
logDeprecationWarning(
'build.base',
'"base" is now a root-level config option.',
new Error()
)
return resolved.base
}
})
if (config.alias) {
logDeprecationWarning('alias', 'Use "resolve.alias" instead.')
}
Object.defineProperty(resolved, 'alias', {
enumerable: false,
get() {
logDeprecationWarning(
'alias',
'Use "resolve.alias" instead.',
new Error()
)
return resolved.resolve.alias
}
})
if (config.dedupe) {
logDeprecationWarning('dedupe', 'Use "resolve.dedupe" instead.')
}
Object.defineProperty(resolved, 'dedupe', {
enumerable: false,
get() {
logDeprecationWarning(
'dedupe',
'Use "resolve.dedupe" instead.',
new Error()
)
return resolved.resolve.dedupe
}
})
if (config.optimizeDeps?.keepNames) {
logDeprecationWarning(
'optimizeDeps.keepNames',
'Use "optimizeDeps.esbuildOptions.keepNames" instead.'
)
}
Object.defineProperty(resolved.optimizeDeps, 'keepNames', {
enumerable: false,
get() {
logDeprecationWarning(
'optimizeDeps.keepNames',
'Use "optimizeDeps.esbuildOptions.keepNames" instead.',
new Error()
)
return resolved.optimizeDeps.esbuildOptions?.keepNames
}
})
return resolved
}
/**
* Resolve base. Note that some users use Vite to build for non-web targets like
* electron or expects to deploy
*/
function resolveBaseUrl(
base: UserConfig['base'] = '/',
isBuild: boolean,
logger: Logger
): string {
// #1669 special treatment for empty for same dir relative base
if (base === '' || base === './') {
return isBuild ? base : '/'
}
if (base.startsWith('.')) {
logger.warn(
chalk.yellow.bold(
`(!) invalid "base" option: ${base}. The value can only be an absolute ` +
`URL, ./, or an empty string.`
)
)
base = '/'
}
// external URL
if (isExternalUrl(base)) {
if (!isBuild) {
// get base from full url during dev
const parsed = parseUrl(base)
base = parsed.pathname || '/'
}
} else {
// ensure leading slash
if (!base.startsWith('/')) {
logger.warn(
chalk.yellow.bold(`(!) "base" option should start with a slash.`)
)
base = '/' + base
}
}
// ensure ending slash
if (!base.endsWith('/')) {
logger.warn(chalk.yellow.bold(`(!) "base" option should end with a slash.`))
base += '/'
}
return base
}
function mergeConfigRecursively(
a: Record<string, any>,
b: Record<string, any>,
rootPath: string
) {
const merged: Record<string, any> = { ...a }
for (const key in b) {
const value = b[key]
if (value == null) {
continue
}
const existing = merged[key]
if (Array.isArray(existing) && Array.isArray(value)) {
merged[key] = [...existing, ...value]
continue
}
if (isObject(existing) && isObject(value)) {
merged[key] = mergeConfigRecursively(
existing,
value,
rootPath ? `${rootPath}.${key}` : key
)
continue
}
// fields that require special handling
if (existing != null) {
if (key === 'alias' && (rootPath === 'resolve' || rootPath === '')) {
merged[key] = mergeAlias(existing, value)
continue
} else if (key === 'assetsInclude' && rootPath === '') {
merged[key] = [].concat(existing, value)
continue
}
}
merged[key] = value
}
return merged
}
export function mergeConfig(
a: Record<string, any>,
b: Record<string, any>,
isRoot = true
): Record<string, any> {
return mergeConfigRecursively(a, b, isRoot ? '' : '.')
}
function mergeAlias(a: AliasOptions = [], b: AliasOptions = []): Alias[] {
return [...normalizeAlias(a), ...normalizeAlias(b)]
}
function normalizeAlias(o: AliasOptions): Alias[] {
return Array.isArray(o)
? o.map(normalizeSingleAlias)
: Object.keys(o).map((find) =>
normalizeSingleAlias({
find,
replacement: (o as any)[find]
})
)
}
// https://github.com/vitejs/vite/issues/1363
// work around https://github.com/rollup/plugins/issues/759
function normalizeSingleAlias({ find, replacement }: Alias): Alias {
if (
typeof find === 'string' &&
find.endsWith('/') &&
replacement.endsWith('/')
) {
find = find.slice(0, find.length - 1)
replacement = replacement.slice(0, replacement.length - 1)
}
return { find, replacement }
}
export function sortUserPlugins(
plugins: (Plugin | Plugin[])[] | undefined
): [Plugin[], Plugin[], Plugin[]] {
const prePlugins: Plugin[] = []
const postPlugins: Plugin[] = []
const normalPlugins: Plugin[] = []
if (plugins) {
plugins.flat().forEach((p) => {
if (p.enforce === 'pre') prePlugins.push(p)
else if (p.enforce === 'post') postPlugins.push(p)
else normalPlugins.push(p)
})
}
return [prePlugins, normalPlugins, postPlugins]
}
export async function loadConfigFromFile(
configEnv: ConfigEnv,
configFile?: string,
configRoot: string = process.cwd(),
logLevel?: LogLevel
): Promise<{
path: string
config: UserConfig
dependencies: string[]
} | null> {
const start = Date.now()
let resolvedPath: string | undefined
let isTS = false
let isMjs = false
let dependencies: string[] = []
// check package.json for type: "module" and set `isMjs` to true
try {
const pkg = lookupFile(configRoot, ['package.json'])
if (pkg && JSON.parse(pkg).type === 'module') {
isMjs = true
}
} catch (e) {}
if (configFile) {
// explicit config path is always resolved from cwd
resolvedPath = path.resolve(configFile)
isTS = configFile.endsWith('.ts')
} else {
// implicit config file loaded from inline root (if present)
// otherwise from cwd
const jsconfigFile = path.resolve(configRoot, 'vite.config.js')
if (fs.existsSync(jsconfigFile)) {
resolvedPath = jsconfigFile
}
if (!resolvedPath) {
const mjsconfigFile = path.resolve(configRoot, 'vite.config.mjs')
if (fs.existsSync(mjsconfigFile)) {
resolvedPath = mjsconfigFile
isMjs = true
}
}
if (!resolvedPath) {
const tsconfigFile = path.resolve(configRoot, 'vite.config.ts')
if (fs.existsSync(tsconfigFile)) {
resolvedPath = tsconfigFile
isTS = true
}
}
}
if (!resolvedPath) {
debug('no config file found.')
return null
}
try {
let userConfig: UserConfigExport | undefined
if (isMjs) {
const fileUrl = require('url').pathToFileURL(resolvedPath)
if (isTS) {
// before we can register loaders without requiring users to run node
// with --experimental-loader themselves, we have to do a hack here:
// bundle the config file w/ ts transforms first, write it to disk,
// load it with native Node ESM, then delete the file.
const bundled = await bundleConfigFile(resolvedPath, true)
dependencies = bundled.dependencies
fs.writeFileSync(resolvedPath + '.js', bundled.code)
userConfig = (await eval(`import(fileUrl + '.js?t=${Date.now()}')`))
.default
fs.unlinkSync(resolvedPath + '.js')
debug(
`TS + native esm config loaded in ${Date.now() - start}ms`,
fileUrl
)
} else {
// using eval to avoid this from being compiled away by TS/Rollup
// append a query so that we force reload fresh config in case of
// server restart
userConfig = (await eval(`import(fileUrl + '?t=${Date.now()}')`))
.default
debug(`native esm config loaded in ${Date.now() - start}ms`, fileUrl)
}
}
if (!userConfig && !isTS && !isMjs) {
// 1. try to directly require the module (assuming commonjs)
try {
// clear cache in case of server restart
delete require.cache[require.resolve(resolvedPath)]
userConfig = require(resolvedPath)
debug(`cjs config loaded in ${Date.now() - start}ms`)
} catch (e) {
const ignored = new RegExp(
[
`Cannot use import statement`,
`Must use import to load ES Module`,
// #1635, #2050 some Node 12.x versions don't have esm detection
// so it throws normal syntax errors when encountering esm syntax
`Unexpected token`,
`Unexpected identifier`
].join('|')
)
if (!ignored.test(e.message)) {
throw e
}
}
}
if (!userConfig) {
// 2. if we reach here, the file is ts or using es import syntax, or
// the user has type: "module" in their package.json (#917)
// transpile es import syntax to require syntax using rollup.
// lazy require rollup (it's actually in dependencies)
const bundled = await bundleConfigFile(resolvedPath)
dependencies = bundled.dependencies
userConfig = await loadConfigFromBundledFile(resolvedPath, bundled.code)
debug(`bundled config file loaded in ${Date.now() - start}ms`)
}
const config = await (typeof userConfig === 'function'
? userConfig(configEnv)
: userConfig)
if (!isObject(config)) {
throw new Error(`config must export or return an object.`)
}
return {
path: normalizePath(resolvedPath),
config,
dependencies
}
} catch (e) {
createLogger(logLevel).error(
chalk.red(`failed to load config from ${resolvedPath}`)
)
throw e
}
}
async function bundleConfigFile(
fileName: string,
mjs = false
): Promise<{ code: string; dependencies: string[] }> {
const result = await build({
absWorkingDir: process.cwd(),
entryPoints: [fileName],
outfile: 'out.js',
write: false,
platform: 'node',
bundle: true,
format: mjs ? 'esm' : 'cjs',
sourcemap: 'inline',
metafile: true,
plugins: [
{
name: 'externalize-deps',
setup(build) {
build.onResolve({ filter: /.*/ }, (args) => {
const id = args.path
if (id[0] !== '.' && !path.isAbsolute(id)) {
return {
external: true
}
}
})
}
},
{
name: 'replace-import-meta',
setup(build) {
build.onLoad({ filter: /\.[jt]s$/ }, async (args) => {
const contents = await fs.promises.readFile(args.path, 'utf8')
return {
loader: args.path.endsWith('.ts') ? 'ts' : 'js',
contents: contents
.replace(
/\bimport\.meta\.url\b/g,
JSON.stringify(`file://${args.path}`)
)
.replace(
/\b__dirname\b/g,
JSON.stringify(path.dirname(args.path))
)
.replace(/\b__filename\b/g, JSON.stringify(args.path))
}
})
}
}
]
})
const { text } = result.outputFiles[0]
return {
code: text,
dependencies: result.metafile ? Object.keys(result.metafile.inputs) : []
}
}
interface NodeModuleWithCompile extends NodeModule {
_compile(code: string, filename: string): any
}
async function loadConfigFromBundledFile(
fileName: string,
bundledCode: string
): Promise<UserConfig> {
const extension = path.extname(fileName)
const defaultLoader = require.extensions[extension]!
require.extensions[extension] = (module: NodeModule, filename: string) => {
if (filename === fileName) {
;(module as NodeModuleWithCompile)._compile(bundledCode, filename)
} else {
defaultLoader(module, filename)
}
}
// clear cache in case of server restart
delete require.cache[require.resolve(fileName)]
const raw = require(fileName)
const config = raw.__esModule ? raw.default : raw
require.extensions[extension] = defaultLoader
return config
}
export function loadEnv(
mode: string,
envDir: string,
prefix = 'VITE_'
): Record<string, string> {
if (mode === 'local') {
throw new Error(
`"local" cannot be used as a mode name because it conflicts with ` +
`the .local postfix for .env files.`
)
}
const env: Record<string, string> = {}
const envFiles = [
/** mode local file */ `.env.${mode}.local`,
/** mode file */ `.env.${mode}`,
/** local file */ `.env.local`,
/** default file */ `.env`
]
// check if there are actual env variables starting with VITE_*
// these are typically provided inline and should be prioritized
for (const key in process.env) {
if (key.startsWith(prefix) && env[key] === undefined) {
env[key] = process.env[key] as string
}
}
for (const file of envFiles) {
const path = lookupFile(envDir, [file], true)
if (path) {
const parsed = dotenv.parse(fs.readFileSync(path), {
debug: !!process.env.DEBUG || undefined
})
// let environment variables use each other
dotenvExpand({
parsed,
// prevent process.env mutation
ignoreProcessEnv: true
} as any)
// only keys that start with prefix are exposed to client
for (const [key, value] of Object.entries(parsed)) {
if (key.startsWith(prefix) && env[key] === undefined) {
env[key] = value
} else if (key === 'NODE_ENV') {
// NODE_ENV override in .env file
process.env.VITE_USER_NODE_ENV = value
}
}
}
}
return env
}