UNPKG

vite

Version:

Native-ESM powered web dev build tool

769 lines (705 loc) 21.4 kB
import fs from 'fs' import path from 'path' import { Plugin } from '../plugin' import chalk from 'chalk' import { FS_PREFIX, SPECIAL_QUERY_RE, DEFAULT_EXTENSIONS, DEFAULT_MAIN_FIELDS, OPTIMIZABLE_ENTRY_RE } from '../constants' import { isBuiltin, bareImportRE, createDebugger, deepImportRE, injectQuery, isExternalUrl, isObject, normalizePath, fsPathFromId, ensureVolumeInPath, resolveFrom, isDataUrl, cleanUrl, slash } from '../utils' import { ViteDevServer, SSRTarget } from '..' import { createFilter } from '@rollup/pluginutils' import { PartialResolvedId } from 'rollup' import { resolve as _resolveExports } from 'resolve.exports' // special id for paths marked with browser: false // https://github.com/defunctzombie/package-browser-field-spec#ignore-a-module export const browserExternalId = '__vite-browser-external' const isDebug = process.env.DEBUG const debug = createDebugger('vite:resolve-details', { onlyWhenFocused: true }) export interface ResolveOptions { mainFields?: string[] conditions?: string[] extensions?: string[] dedupe?: string[] } export interface InternalResolveOptions extends ResolveOptions { root: string isBuild: boolean isProduction: boolean ssrTarget?: SSRTarget /** * src code mode also attempts the following: * - resolving /xxx as URLs * - resolving bare imports from optimized deps */ asSrc?: boolean tryIndex?: boolean tryPrefix?: string preferRelative?: boolean isRequire?: boolean } export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin { const { root, isProduction, asSrc, ssrTarget, preferRelative = false } = baseOptions const requireOptions: InternalResolveOptions = { ...baseOptions, isRequire: true } let server: ViteDevServer | undefined return { name: 'vite:resolve', configureServer(_server) { server = _server }, resolveId(id, importer, resolveOpts, ssr) { if (id.startsWith(browserExternalId)) { return id } // fast path for commonjs proxy modules if (/\?commonjs/.test(id) || id === 'commonjsHelpers.js') { return } const targetWeb = !ssr || ssrTarget === 'webworker' // this is passed by @rollup/plugin-commonjs const isRequire = resolveOpts && resolveOpts.custom && resolveOpts.custom['node-resolve'] && resolveOpts.custom['node-resolve'].isRequire const options = isRequire ? requireOptions : baseOptions let res // explicit fs paths that starts with /@fs/* if (asSrc && id.startsWith(FS_PREFIX)) { const fsPath = fsPathFromId(id) res = tryFsResolve(fsPath, options) isDebug && debug(`[@fs] ${chalk.cyan(id)} -> ${chalk.dim(res)}`) // always return here even if res doesn't exist since /@fs/ is explicit // if the file doesn't exist it should be a 404 return res || fsPath } // URL // /foo -> /fs-root/foo if (asSrc && id.startsWith('/')) { const fsPath = path.resolve(root, id.slice(1)) if ((res = tryFsResolve(fsPath, options))) { isDebug && debug(`[url] ${chalk.cyan(id)} -> ${chalk.dim(res)}`) return res } } // relative if (id.startsWith('.') || (preferRelative && /^\w/.test(id))) { const basedir = importer ? path.dirname(importer) : process.cwd() const fsPath = path.resolve(basedir, id) // handle browser field mapping for relative imports const normalizedFsPath = normalizePath(fsPath) const pathFromBasedir = normalizedFsPath.slice(basedir.length) if (pathFromBasedir.startsWith('/node_modules/')) { // normalize direct imports from node_modules to bare imports, so the // hashing logic is shared and we avoid duplicated modules #2503 const bareImport = pathFromBasedir.slice('/node_modules/'.length) if ( (res = tryNodeResolve( bareImport, importer, options, targetWeb, server, ssr )) && res.id.startsWith(normalizedFsPath) ) { return res } } if ( targetWeb && (res = tryResolveBrowserMapping(fsPath, importer, options, true)) ) { return res } if ((res = tryFsResolve(fsPath, options))) { isDebug && debug(`[relative] ${chalk.cyan(id)} -> ${chalk.dim(res)}`) const pkg = importer != null && idToPkgMap.get(importer) if (pkg) { idToPkgMap.set(res, pkg) return { id: res, moduleSideEffects: pkg.hasSideEffects(res) } } return res } } // absolute fs paths if (path.isAbsolute(id) && (res = tryFsResolve(id, options))) { isDebug && debug(`[fs] ${chalk.cyan(id)} -> ${chalk.dim(res)}`) return res } // external if (isExternalUrl(id)) { return { id, external: true } } // data uri: pass through (this only happens during build and will be // handled by dedicated plugin) if (isDataUrl(id)) { return null } // bare package imports, perform node resolve if (bareImportRE.test(id)) { if ( asSrc && server && !ssr && (res = tryOptimizedResolve(id, server)) ) { return res } if ( targetWeb && (res = tryResolveBrowserMapping(id, importer, options, false)) ) { return res } if ( (res = tryNodeResolve(id, importer, options, targetWeb, server, ssr)) ) { return res } // node built-ins. // externalize if building for SSR, otherwise redirect to empty module if (isBuiltin(id)) { if (ssr) { return { id, external: true } } else { if (!asSrc) { debug( `externalized node built-in "${id}" to empty module. ` + `(imported by: ${chalk.white.dim(importer)})` ) } return isProduction ? browserExternalId : `${browserExternalId}:${id}` } } } isDebug && debug(`[fallthrough] ${chalk.dim(id)}`) }, load(id) { if (id.startsWith(browserExternalId)) { return isProduction ? `export default {}` : `export default new Proxy({}, { get() { throw new Error('Module "${id.slice( browserExternalId.length + 1 )}" has been externalized for browser compatibility and cannot be accessed in client code.') } })` } } } } function tryFsResolve( fsPath: string, options: InternalResolveOptions, tryIndex = true, targetWeb = true ): string | undefined { let file = fsPath let postfix = '' let postfixIndex = fsPath.indexOf('?') if (postfixIndex < 0) { postfixIndex = fsPath.indexOf('#') } if (postfixIndex > 0) { file = fsPath.slice(0, postfixIndex) postfix = fsPath.slice(postfixIndex) } let res: string | undefined if ( (res = tryResolveFile( file, postfix, options, false, targetWeb, options.tryPrefix )) ) { return res } for (const ext of options.extensions || DEFAULT_EXTENSIONS) { if ( (res = tryResolveFile( file + ext, postfix, options, false, targetWeb, options.tryPrefix )) ) { return res } } if ( (res = tryResolveFile( file, postfix, options, tryIndex, targetWeb, options.tryPrefix )) ) { return res } } function tryResolveFile( file: string, postfix: string, options: InternalResolveOptions, tryIndex: boolean, targetWeb: boolean, tryPrefix?: string ): string | undefined { let isReadable = false try { // #2051 if we don't have read permission on a directory, existsSync() still // works and will result in massively slow subsequent checks (which are // unnecessary in the first place) fs.accessSync(file, fs.constants.R_OK) isReadable = true } catch (e) {} if (isReadable) { if (!fs.statSync(file).isDirectory()) { return normalizePath(ensureVolumeInPath(file)) + postfix } else if (tryIndex) { const pkgPath = file + '/package.json' if (fs.existsSync(pkgPath)) { // path points to a node package const pkg = loadPackageData(pkgPath) return resolvePackageEntry(file, pkg, options, targetWeb) } const index = tryFsResolve(file + '/index', options) if (index) return index + postfix } } if (tryPrefix) { const prefixed = `${path.dirname(file)}/${tryPrefix}${path.basename(file)}` return tryResolveFile(prefixed, postfix, options, tryIndex, targetWeb) } } export const idToPkgMap = new Map<string, PackageData>() export function tryNodeResolve( id: string, importer: string | undefined, options: InternalResolveOptions, targetWeb: boolean, server?: ViteDevServer, ssr?: boolean ): PartialResolvedId | undefined { const { root, dedupe, isBuild } = options const deepMatch = id.match(deepImportRE) const pkgId = deepMatch ? deepMatch[1] || deepMatch[2] : id let basedir if (dedupe && dedupe.includes(pkgId)) { basedir = root } else if ( importer && path.isAbsolute(importer) && fs.existsSync(cleanUrl(importer)) ) { basedir = path.dirname(importer) } else { basedir = root } const pkg = resolvePackageData(pkgId, basedir) if (!pkg) { return } let resolved = deepMatch ? resolveDeepImport(id, pkg, options, targetWeb) : resolvePackageEntry(id, pkg, options, targetWeb) if (!resolved) { return } // link id to pkg for browser field mapping check idToPkgMap.set(resolved, pkg) if (isBuild) { // Resolve package side effects for build so that rollup can better // perform tree-shaking return { id: resolved, moduleSideEffects: pkg.hasSideEffects(resolved) } } else { if ( !resolved.includes('node_modules') || // linked !server || // build server._isRunningOptimizer || // optimizing !server._optimizeDepsMetadata ) { return { id: resolved } } // if we reach here, it's a valid dep import that hasn't been optimized. const isJsType = OPTIMIZABLE_ENTRY_RE.test(resolved) const exclude = server.config.optimizeDeps?.exclude if ( !isJsType || importer?.includes('node_modules') || exclude?.includes(pkgId) || exclude?.includes(id) || SPECIAL_QUERY_RE.test(resolved) ) { // excluded from optimization // Inject a version query to npm deps so that the browser // can cache it without re-validation, but only do so for known js types. // otherwise we may introduce duplicated modules for externalized files // from pre-bundled deps. const versionHash = server._optimizeDepsMetadata?.browserHash if (versionHash && isJsType) { resolved = injectQuery(resolved, `v=${versionHash}`) } } else { // this is a missing import. // queue optimize-deps re-run. server._registerMissingImport?.(id, resolved, ssr) } return { id: resolved } } } export function tryOptimizedResolve( id: string, server: ViteDevServer ): string | undefined { const cacheDir = server.config.cacheDir const depData = server._optimizeDepsMetadata if (cacheDir && depData) { const isOptimized = depData.optimized[id] if (isOptimized) { return ( isOptimized.file + `?v=${depData.browserHash}${ isOptimized.needsInterop ? `&es-interop` : `` }` ) } } } export interface PackageData { dir: string hasSideEffects: (id: string) => boolean webResolvedImports: Record<string, string | undefined> nodeResolvedImports: Record<string, string | undefined> setResolvedCache: (key: string, entry: string, targetWeb: boolean) => void getResolvedCache: (key: string, targetWeb: boolean) => string | undefined data: { [field: string]: any version: string main: string module: string browser: string | Record<string, string | false> exports: string | Record<string, any> | string[] dependencies: Record<string, string> } } const packageCache = new Map<string, PackageData>() export function resolvePackageData( id: string, basedir: string ): PackageData | undefined { const cacheKey = id + basedir if (packageCache.has(cacheKey)) { return packageCache.get(cacheKey) } try { const pkgPath = resolveFrom(`${id}/package.json`, basedir) return loadPackageData(pkgPath, cacheKey) } catch (e) { isDebug && debug(`${chalk.red(`[failed loading package.json]`)} ${id}`) } } function loadPackageData(pkgPath: string, cacheKey = pkgPath) { const data = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) const pkgDir = path.dirname(pkgPath) const { sideEffects } = data let hasSideEffects if (typeof sideEffects === 'boolean') { hasSideEffects = () => sideEffects } else if (Array.isArray(sideEffects)) { hasSideEffects = createFilter(sideEffects, null, { resolve: pkgDir }) } else { hasSideEffects = () => true } const pkg: PackageData = { dir: pkgDir, data, hasSideEffects, webResolvedImports: {}, nodeResolvedImports: {}, setResolvedCache(key: string, entry: string, targetWeb: boolean) { if (targetWeb) { pkg.webResolvedImports[key] = entry } else { pkg.nodeResolvedImports[key] = entry } }, getResolvedCache(key: string, targetWeb: boolean) { if (targetWeb) { return pkg.webResolvedImports[key] } else { return pkg.nodeResolvedImports[key] } } } packageCache.set(cacheKey, pkg) return pkg } export function resolvePackageEntry( id: string, { dir, data, setResolvedCache, getResolvedCache }: PackageData, options: InternalResolveOptions, targetWeb: boolean ): string | undefined { const cached = getResolvedCache('.', targetWeb) if (cached) { return cached } let entryPoint: string | undefined | void // resolve exports field with highest priority // using https://github.com/lukeed/resolve.exports if (data.exports) { entryPoint = resolveExports(data, '.', options, targetWeb) } // if exports resolved to .mjs, still resolve other fields. // This is because .mjs files can technically import .cjs files which would // make them invalid for pure ESM environments - so if other module/browser // fields are present, prioritize those instead. if (targetWeb && (!entryPoint || entryPoint.endsWith('.mjs'))) { // check browser field // https://github.com/defunctzombie/package-browser-field-spec const browserEntry = typeof data.browser === 'string' ? data.browser : isObject(data.browser) && data.browser['.'] if (browserEntry) { // check if the package also has a "module" field. if (typeof data.module === 'string' && data.module !== browserEntry) { // if both are present, we may have a problem: some package points both // to ESM, with "module" targeting Node.js, while some packages points // "module" to browser ESM and "browser" to UMD. // the heuristics here is to actually read the browser entry when // possible and check for hints of UMD. If it is UMD, prefer "module" // instead; Otherwise, assume it's ESM and use it. const resolvedBrowserEntry = tryFsResolve( path.join(dir, browserEntry), options ) if (resolvedBrowserEntry) { const content = fs.readFileSync(resolvedBrowserEntry, 'utf-8') if ( (/typeof exports\s*==/.test(content) && /typeof module\s*==/.test(content)) || /module\.exports\s*=/.test(content) ) { // likely UMD or CJS(!!! e.g. firebase 7.x), prefer module entryPoint = data.module } } } else { entryPoint = browserEntry } } } if (!entryPoint || entryPoint.endsWith('.mjs')) { for (const field of options.mainFields || DEFAULT_MAIN_FIELDS) { if (typeof data[field] === 'string') { entryPoint = data[field] break } } } entryPoint = entryPoint || data.main || 'index.js' // resolve object browser field in package.json const { browser: browserField } = data if (targetWeb && isObject(browserField)) { entryPoint = mapWithBrowserField(entryPoint, browserField) || entryPoint } entryPoint = path.join(dir, entryPoint) const resolvedEntryPoint = tryFsResolve(entryPoint, options) if (resolvedEntryPoint) { isDebug && debug( `[package entry] ${chalk.cyan(id)} -> ${chalk.dim(resolvedEntryPoint)}` ) setResolvedCache('.', resolvedEntryPoint, targetWeb) return resolvedEntryPoint } else { throw new Error( `Failed to resolve entry for package "${id}". ` + `The package may have incorrect main/module/exports specified in its package.json.` ) } } function resolveExports( pkg: PackageData['data'], key: string, options: InternalResolveOptions, targetWeb: boolean ) { const conditions = [options.isProduction ? 'production' : 'development'] if (!options.isRequire) { conditions.push('module') } if (options.conditions) { conditions.push(...options.conditions) } return _resolveExports(pkg, key, { browser: targetWeb, require: options.isRequire, conditions }) } function resolveDeepImport( id: string, { webResolvedImports, setResolvedCache, getResolvedCache, dir, data }: PackageData, options: InternalResolveOptions, targetWeb: boolean ): string | undefined { id = '.' + id.slice(data.name.length) const cache = getResolvedCache(id, targetWeb) if (cache) { return cache } let relativeId: string | undefined | void = id const { exports: exportsField, browser: browserField } = data // map relative based on exports data if (exportsField) { if (isObject(exportsField) && !Array.isArray(exportsField)) { relativeId = resolveExports(data, relativeId, options, targetWeb) } else { // not exposed relativeId = undefined } if (!relativeId) { throw new Error( `Package subpath '${relativeId}' is not defined by "exports" in ` + `${path.join(dir, 'package.json')}.` ) } } else if (targetWeb && isObject(browserField)) { const mapped = mapWithBrowserField(relativeId, browserField) if (mapped) { relativeId = mapped } else if (mapped === false) { return (webResolvedImports[id] = browserExternalId) } } if (relativeId) { const resolved = tryFsResolve( path.join(dir, relativeId), options, !exportsField, // try index only if no exports field targetWeb ) if (resolved) { isDebug && debug(`[node/deep-import] ${chalk.cyan(id)} -> ${chalk.dim(resolved)}`) setResolvedCache(id, resolved, targetWeb) return resolved } } } function tryResolveBrowserMapping( id: string, importer: string | undefined, options: InternalResolveOptions, isFilePath: boolean ) { let res: string | undefined const pkg = importer && idToPkgMap.get(importer) if (pkg && isObject(pkg.data.browser)) { const mapId = isFilePath ? './' + slash(path.relative(pkg.dir, id)) : id const browserMappedPath = mapWithBrowserField(mapId, pkg.data.browser) if (browserMappedPath) { const fsPath = path.join(pkg.dir, browserMappedPath) if ((res = tryFsResolve(fsPath, options))) { isDebug && debug(`[browser mapped] ${chalk.cyan(id)} -> ${chalk.dim(res)}`) idToPkgMap.set(res, pkg) return { id: res, moduleSideEffects: pkg.hasSideEffects(res) } } } else if (browserMappedPath === false) { return browserExternalId } } } /** * given a relative path in pkg dir, * return a relative path in pkg dir, * mapped with the "map" object * * - Returning `undefined` means there is no browser mapping for this id * - Returning `false` means this id is explicitly externalized for browser */ function mapWithBrowserField( relativePathInPkgDir: string, map: Record<string, string | false> ): string | false | undefined { const normalizedPath = path.posix.normalize(relativePathInPkgDir) for (const key in map) { const normalizedKey = path.posix.normalize(key) if ( normalizedPath === normalizedKey || equalWithoutSuffix(normalizedPath, normalizedKey, '.js') || equalWithoutSuffix(normalizedPath, normalizedKey, '/index.js') ) { return map[key] } } } function equalWithoutSuffix(path: string, key: string, suffix: string) { return key.endsWith(suffix) && key.slice(0, -suffix.length) === path }