diff --git a/package.json b/package.json index 1a3b2566830736..663c18e83c7525 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,8 @@ "acorn@8.12.0": "patches/acorn@8.12.0.patch", "chokidar@3.6.0": "patches/chokidar@3.6.0.patch", "http-proxy@1.18.1": "patches/http-proxy@1.18.1.patch", - "sirv@2.0.4": "patches/sirv@2.0.4.patch" + "sirv@2.0.4": "patches/sirv@2.0.4.patch", + "rolldown@0.12.1": "patches/rolldown@0.12.1.patch" }, "peerDependencyRules": { "allowedVersions": { diff --git a/packages/vite/LICENSE.md b/packages/vite/LICENSE.md index ddd5a6209512dc..912e980504e45a 100644 --- a/packages/vite/LICENSE.md +++ b/packages/vite/LICENSE.md @@ -1006,27 +1006,6 @@ License: MIT By: Mathias Bynens Repository: https://github.com/mathiasbynens/cssesc.git -> Copyright Mathias Bynens -> -> Permission is hereby granted, free of charge, to any person obtaining -> a copy of this software and associated documentation files (the -> "Software"), to deal in the Software without restriction, including -> without limitation the rights to use, copy, modify, merge, publish, -> distribute, sublicense, and/or sell copies of the Software, and to -> permit persons to whom the Software is furnished to do so, subject to -> the following conditions: -> -> The above copyright notice and this permission notice shall be -> included in all copies or substantial portions of the Software. -> -> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -> MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -> LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -> OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -> WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------------------------------- ## debug @@ -2681,29 +2660,6 @@ License: MIT By: Ben Briggs, Chris Eppstein Repository: postcss/postcss-selector-parser -> Copyright (c) Ben Briggs (http://beneb.info) -> -> Permission is hereby granted, free of charge, to any person -> obtaining a copy of this software and associated documentation -> files (the "Software"), to deal in the Software without -> restriction, including without limitation the rights to use, -> copy, modify, merge, publish, distribute, sublicense, and/or sell -> copies of the Software, and to permit persons to whom the -> Software is furnished to do so, subject to the following -> conditions: -> -> The above copyright notice and this permission notice shall be -> included in all copies or substantial portions of the Software. -> -> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -> OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -> HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -> WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -> OTHER DEALINGS IN THE SOFTWARE. - --------------------------------------- ## postcss-value-parser diff --git a/packages/vite/package.json b/packages/vite/package.json index 1b032821464ef7..0c12728ac31245 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -87,7 +87,8 @@ "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.39", - "rollup": "^4.13.0" + "rollup": "^4.13.0", + "rolldown": "^0.12.1" }, "optionalDependencies": { "fsevents": "~2.3.3" diff --git a/packages/vite/rollup.dts.config.ts b/packages/vite/rollup.dts.config.ts index 41b1da1458a31e..be9a04625c7be4 100644 --- a/packages/vite/rollup.dts.config.ts +++ b/packages/vite/rollup.dts.config.ts @@ -97,7 +97,7 @@ function patchTypes(): Plugin { validateRuntimeChunk.call(this, chunk) } else { validateChunkImports.call(this, chunk) - code = replaceConfusingTypeNames.call(this, code, chunk) + if (0) code = replaceConfusingTypeNames.call(this, code, chunk) code = stripInternalTypes.call(this, code, chunk) code = cleanUnnecessaryComments(code) } diff --git a/packages/vite/src/node/optimizer/esbuildDepPlugin.ts b/packages/vite/src/node/optimizer/esbuildDepPlugin.ts deleted file mode 100644 index 1f4c4dab16748d..00000000000000 --- a/packages/vite/src/node/optimizer/esbuildDepPlugin.ts +++ /dev/null @@ -1,342 +0,0 @@ -import path from 'node:path' -import type { ImportKind, Plugin } from 'esbuild' -import { KNOWN_ASSET_TYPES } from '../constants' -import type { PackageCache } from '../packages' -import { getDepOptimizationConfig } from '../config' -import type { ResolvedConfig } from '../config' -import { - escapeRegex, - flattenId, - isBuiltin, - isExternalUrl, - moduleListContains, - normalizePath, -} from '../utils' -import { browserExternalId, optionalPeerDepId } from '../plugins/resolve' -import { isCSSRequest, isModuleCSSRequest } from '../plugins/css' - -const externalWithConversionNamespace = - 'vite:dep-pre-bundle:external-conversion' -const convertedExternalPrefix = 'vite-dep-pre-bundle-external:' - -const cjsExternalFacadeNamespace = 'vite:cjs-external-facade' -const nonFacadePrefix = 'vite-cjs-external-facade:' - -const externalTypes = [ - 'css', - // supported pre-processor types - 'less', - 'sass', - 'scss', - 'styl', - 'stylus', - 'pcss', - 'postcss', - // wasm - 'wasm', - // known SFC types - 'vue', - 'svelte', - 'marko', - 'astro', - 'imba', - // JSX/TSX may be configured to be compiled differently from how esbuild - // handles it by default, so exclude them as well - 'jsx', - 'tsx', - ...KNOWN_ASSET_TYPES, -] - -export function esbuildDepPlugin( - qualified: Record, - external: string[], - config: ResolvedConfig, - ssr: boolean, -): Plugin { - const { extensions } = getDepOptimizationConfig(config, ssr) - - // remove optimizable extensions from `externalTypes` list - const allExternalTypes = extensions - ? externalTypes.filter((type) => !extensions?.includes('.' + type)) - : externalTypes - - // use separate package cache for optimizer as it caches paths around node_modules - // and it's unlikely for the core Vite process to traverse into node_modules again - const esmPackageCache: PackageCache = new Map() - const cjsPackageCache: PackageCache = new Map() - - // default resolver which prefers ESM - const _resolve = config.createResolver({ - asSrc: false, - scan: true, - packageCache: esmPackageCache, - }) - - // cjs resolver that prefers Node - const _resolveRequire = config.createResolver({ - asSrc: false, - isRequire: true, - scan: true, - packageCache: cjsPackageCache, - }) - - const resolve = ( - id: string, - importer: string, - kind: ImportKind, - resolveDir?: string, - ): Promise => { - let _importer: string - // explicit resolveDir - this is passed only during yarn pnp resolve for - // entries - if (resolveDir) { - _importer = normalizePath(path.join(resolveDir, '*')) - } else { - // map importer ids to file paths for correct resolution - _importer = importer in qualified ? qualified[importer] : importer - } - const resolver = kind.startsWith('require') ? _resolveRequire : _resolve - return resolver(id, _importer, undefined, ssr) - } - - const resolveResult = (id: string, resolved: string) => { - if (resolved.startsWith(browserExternalId)) { - return { - path: id, - namespace: 'browser-external', - } - } - if (resolved.startsWith(optionalPeerDepId)) { - return { - path: resolved, - namespace: 'optional-peer-dep', - } - } - if (ssr && isBuiltin(resolved)) { - return - } - if (isExternalUrl(resolved)) { - return { - path: resolved, - external: true, - } - } - return { - path: path.resolve(resolved), - } - } - - return { - name: 'vite:dep-pre-bundle', - setup(build) { - // clear package cache when esbuild is finished - build.onEnd(() => { - esmPackageCache.clear() - cjsPackageCache.clear() - }) - - // externalize assets and commonly known non-js file types - // See #8459 for more details about this require-import conversion - build.onResolve( - { - filter: new RegExp( - `\\.(` + allExternalTypes.join('|') + `)(\\?.*)?$`, - ), - }, - async ({ path: id, importer, kind }) => { - // if the prefix exist, it is already converted to `import`, so set `external: true` - if (id.startsWith(convertedExternalPrefix)) { - return { - path: id.slice(convertedExternalPrefix.length), - external: true, - } - } - - const resolved = await resolve(id, importer, kind) - if (resolved) { - if (kind === 'require-call') { - // #16116 fix: Import the module.scss path, which is actually module.scss.js - if (resolved.endsWith('.js')) { - return { - path: resolved, - external: false, - } - } - - // here it is not set to `external: true` to convert `require` to `import` - return { - path: resolved, - namespace: externalWithConversionNamespace, - } - } - return { - path: resolved, - external: true, - } - } - }, - ) - build.onLoad( - { filter: /./, namespace: externalWithConversionNamespace }, - (args) => { - // import itself with prefix (this is the actual part of require-import conversion) - const modulePath = `"${convertedExternalPrefix}${args.path}"` - return { - contents: - isCSSRequest(args.path) && !isModuleCSSRequest(args.path) - ? `import ${modulePath};` - : `export { default } from ${modulePath};` + - `export * from ${modulePath};`, - loader: 'js', - } - }, - ) - - function resolveEntry(id: string) { - const flatId = flattenId(id) - if (flatId in qualified) { - return { - path: qualified[flatId], - } - } - } - - build.onResolve( - { filter: /^[\w@][^:]/ }, - async ({ path: id, importer, kind }) => { - if (moduleListContains(external, id)) { - return { - path: id, - external: true, - } - } - - // ensure esbuild uses our resolved entries - let entry: { path: string } | undefined - // if this is an entry, return entry namespace resolve result - if (!importer) { - if ((entry = resolveEntry(id))) return entry - // check if this is aliased to an entry - also return entry namespace - const aliased = await _resolve(id, undefined, true) - if (aliased && (entry = resolveEntry(aliased))) { - return entry - } - } - - // use vite's own resolver - const resolved = await resolve(id, importer, kind) - if (resolved) { - return resolveResult(id, resolved) - } - }, - ) - - build.onLoad( - { filter: /.*/, namespace: 'browser-external' }, - ({ path }) => { - if (config.isProduction) { - return { - contents: 'module.exports = {}', - } - } else { - return { - // Return in CJS to intercept named imports. Use `Object.create` to - // create the Proxy in the prototype to workaround esbuild issue. Why? - // - // In short, esbuild cjs->esm flow: - // 1. Create empty object using `Object.create(Object.getPrototypeOf(module.exports))`. - // 2. Assign props of `module.exports` to the object. - // 3. Return object for ESM use. - // - // If we do `module.exports = new Proxy({}, {})`, step 1 returns empty object, - // step 2 does nothing as there's no props for `module.exports`. The final object - // is just an empty object. - // - // Creating the Proxy in the prototype satisfies step 1 immediately, which means - // the returned object is a Proxy that we can intercept. - // - // Note: Skip keys that are accessed by esbuild and browser devtools. - contents: `\ -module.exports = Object.create(new Proxy({}, { - get(_, key) { - if ( - key !== '__esModule' && - key !== '__proto__' && - key !== 'constructor' && - key !== 'splice' - ) { - console.warn(\`Module "${path}" has been externalized for browser compatibility. Cannot access "${path}.\${key}" in client code. See https://vitejs.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\`) - } - } -}))`, - } - } - }, - ) - - build.onLoad( - { filter: /.*/, namespace: 'optional-peer-dep' }, - ({ path }) => { - if (config.isProduction) { - return { - contents: 'module.exports = {}', - } - } else { - const [, peerDep, parentDep] = path.split(':') - return { - contents: `throw new Error(\`Could not resolve "${peerDep}" imported by "${parentDep}". Is it installed?\`)`, - } - } - }, - ) - }, - } -} - -const matchesEntireLine = (text: string) => `^${escapeRegex(text)}$` - -// esbuild doesn't transpile `require('foo')` into `import` statements if 'foo' is externalized -// https://github.com/evanw/esbuild/issues/566#issuecomment-735551834 -export function esbuildCjsExternalPlugin( - externals: string[], - platform: 'node' | 'browser', -): Plugin { - return { - name: 'cjs-external', - setup(build) { - const filter = new RegExp(externals.map(matchesEntireLine).join('|')) - - build.onResolve({ filter: new RegExp(`^${nonFacadePrefix}`) }, (args) => { - return { - path: args.path.slice(nonFacadePrefix.length), - external: true, - } - }) - - build.onResolve({ filter }, (args) => { - // preserve `require` for node because it's more accurate than converting it to import - if (args.kind === 'require-call' && platform !== 'node') { - return { - path: args.path, - namespace: cjsExternalFacadeNamespace, - } - } - - return { - path: args.path, - external: true, - } - }) - - build.onLoad( - { filter: /.*/, namespace: cjsExternalFacadeNamespace }, - (args) => ({ - contents: - `import * as m from ${JSON.stringify( - nonFacadePrefix + args.path, - )};` + `module.exports = m;`, - }), - ) - }, - } -} diff --git a/packages/vite/src/node/optimizer/index.ts b/packages/vite/src/node/optimizer/index.ts index e62d78fdf1b956..ad54c7c83c30c7 100644 --- a/packages/vite/src/node/optimizer/index.ts +++ b/packages/vite/src/node/optimizer/index.ts @@ -4,13 +4,15 @@ import path from 'node:path' import { promisify } from 'node:util' import { performance } from 'node:perf_hooks' import colors from 'picocolors' -import type { BuildContext, BuildOptions as EsbuildBuildOptions } from 'esbuild' -import esbuild, { build } from 'esbuild' +import type { BuildOptions as EsbuildBuildOptions } from 'esbuild' import { init, parse } from 'es-module-lexer' import glob from 'fast-glob' +import type { RollupOptions } from 'rolldown' +import * as rolldown from 'rolldown' import { getDepOptimizationConfig } from '../config' import type { ResolvedConfig } from '../config' import { + asyncFlatten, createDebugger, flattenId, getHash, @@ -22,13 +24,13 @@ import { tryStatSync, unique, } from '../utils' -import { - defaultEsbuildSupported, - transformWithEsbuild, -} from '../plugins/esbuild' +import { transformWithEsbuild } from '../plugins/esbuild' import { ESBUILD_MODULES_TARGET, METADATA_FILENAME } from '../constants' import { isWindows } from '../../shared/utils' -import { esbuildCjsExternalPlugin, esbuildDepPlugin } from './esbuildDepPlugin' +import { + rolldownCjsExternalPlugin, + rolldownDepPlugin, +} from './rolldownDepPlugin' import { scanImports } from './scan' import { createOptimizeDepsIncludeResolver, expandGlobIds } from './resolve' export { @@ -40,7 +42,7 @@ export { const debug = createDebugger('vite:deps') const jsExtensionRE = /\.js$/i -const jsMapExtensionRE = /\.js\.map$/i +// const jsMapExtensionRE = /\.js\.map$/i export type ExportsData = { hasModuleSyntax: boolean @@ -106,6 +108,8 @@ export interface DepOptimizationConfig { | 'outExtension' | 'metafile' > + + rollupOptions?: RollupOptions /** * List of file extensions that can be optimized. A corresponding esbuild * plugin must exist to handle the specific extension. @@ -194,6 +198,7 @@ export interface OptimizedDepInfo { * data used both to define if interop is needed and when pre-bundling */ exportsData?: Promise + isDynamicEntry?: boolean } export interface DepOptimizationMetadata { @@ -593,7 +598,7 @@ export function runOptimizeDeps( const start = performance.now() - const preparedRun = prepareEsbuildOptimizerRun( + const preparedRun = prepareRolldownOptimizerRun( resolvedConfig, depsInfo, ssr, @@ -601,76 +606,59 @@ export function runOptimizeDeps( optimizerContext, ) - const runResult = preparedRun.then(({ context, idToExports }) => { - function disposeContext() { - return context?.dispose().catch((e) => { - config.logger.error('Failed to dispose esbuild context', { error: e }) - }) - } - if (!context || optimizerContext.cancelled) { - disposeContext() + const runResult = preparedRun.then(({ build, idToExports }) => { + if (!build || optimizerContext.cancelled) { return cancelledResult } - return context - .rebuild() + return build() .then((result) => { - const meta = result.metafile! - - // the paths in `meta.outputs` are relative to `process.cwd()` - const processingCacheDirOutputPath = path.relative( - process.cwd(), - processingCacheDir, - ) - - for (const id in depsInfo) { - const output = esbuildOutputFromId( - meta.outputs, - id, - processingCacheDir, - ) - - const { exportsData, ...info } = depsInfo[id] - addOptimizedDepInfo(metadata, 'optimized', { - ...info, - // We only need to hash the output.imports in to check for stability, but adding the hash - // and file path gives us a unique hash that may be useful for other things in the future - fileHash: getHash( - metadata.hash + - depsInfo[id].file + - JSON.stringify(output.imports), - ), - browserHash: metadata.browserHash, - // After bundling we have more information and can warn the user about legacy packages - // that require manual configuration - needsInterop: needsInterop( - config, - ssr, - id, - idToExports[id], - output, - ), - }) - } - - for (const o of Object.keys(meta.outputs)) { - if (!jsMapExtensionRE.test(o)) { - const id = path - .relative(processingCacheDirOutputPath, o) - .replace(jsExtensionRE, '') - const file = getOptimizedDepPath(id, resolvedConfig, ssr) - if ( - !findOptimizedDepInfoInRecord( - metadata.optimized, - (depInfo) => depInfo.file === file, + for (const chunk of result.output) { + if (chunk.type === 'chunk') { + if (chunk.isEntry) { + // One chunk maybe corresponding multiply entry + const deps = Object.values(depsInfo).filter( + (d) => d.src === chunk.facadeModuleId!, ) - ) { - addOptimizedDepInfo(metadata, 'chunks', { - id, - file, - needsInterop: false, - browserHash: metadata.browserHash, - }) + for (const { exportsData, file, id, ...info } of deps) { + addOptimizedDepInfo(metadata, 'optimized', { + id, + file, + ...info, + // We only need to hash the output.imports in to check for stability, but adding the hash + // and file path gives us a unique hash that may be useful for other things in the future + fileHash: getHash( + metadata.hash + file + JSON.stringify(chunk.modules), + ), + browserHash: metadata.browserHash, + // After bundling we have more information and can warn the user about legacy packages + // that require manual configuration + needsInterop: needsInterop( + config, + ssr, + id, + idToExports[id], + chunk, + ), + }) + } + } else { + const id = chunk.fileName.replace(jsExtensionRE, '') + const file = getOptimizedDepPath(id, resolvedConfig, ssr) + if ( + !findOptimizedDepInfoInRecord( + metadata.optimized, + (depInfo) => depInfo.file === file, + ) + ) { + addOptimizedDepInfo(metadata, 'chunks', { + id, + file, + needsInterop: false, + browserHash: metadata.browserHash, + isDynamicEntry: chunk.isDynamicEntry, + }) + } } } } @@ -683,16 +671,8 @@ export function runOptimizeDeps( }) .catch((e) => { - if (e.errors && e.message.includes('The build was canceled')) { - // esbuild logs an error when cancelling, but this is expected so - // return an empty result instead - return cancelledResult - } throw e }) - .finally(() => { - return disposeContext() - }) }) runResult.catch(() => { @@ -702,24 +682,23 @@ export function runOptimizeDeps( return { async cancel() { optimizerContext.cancelled = true - const { context } = await preparedRun - await context?.cancel() cleanUp() }, result: runResult, } } -async function prepareEsbuildOptimizerRun( +async function prepareRolldownOptimizerRun( resolvedConfig: ResolvedConfig, depsInfo: Record, ssr: boolean, processingCacheDir: string, optimizerContext: { cancelled: boolean }, ): Promise<{ - context?: BuildContext + build?: () => Promise idToExports: Record }> { + const isBuild = resolvedConfig.command === 'build' const config: ResolvedConfig = { ...resolvedConfig, command: 'build', @@ -736,21 +715,19 @@ async function prepareEsbuildOptimizerRun( const optimizeDeps = getDepOptimizationConfig(config, ssr) - const { plugins: pluginsFromConfig = [], ...esbuildOptions } = - optimizeDeps?.esbuildOptions ?? {} + const { plugins: pluginsFromConfig = [], ...rollupOptions } = + optimizeDeps?.rollupOptions ?? {} + let jsxLoader = false await Promise.all( Object.keys(depsInfo).map(async (id) => { const src = depsInfo[id].src! const exportsData = await (depsInfo[id].exportsData ?? extractExportsData(src, config, ssr)) - if (exportsData.jsxLoader && !esbuildOptions.loader?.['.js']) { + if (exportsData.jsxLoader) { // Ensure that optimization won't fail by defaulting '.js' to the JSX parser. // This is useful for packages such as Gatsby. - esbuildOptions.loader = { - '.js': 'jsx', - ...esbuildOptions.loader, - } + jsxLoader = true } const flatId = flattenId(id) flatIdDeps[flatId] = src @@ -758,10 +735,14 @@ async function prepareEsbuildOptimizerRun( }), ) - if (optimizerContext.cancelled) return { context: undefined, idToExports } + if (optimizerContext.cancelled) return { build: undefined, idToExports } + // In lib mode, we need to keep process.env.NODE_ENV untouched const define = { - 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || config.mode), + 'process.env.NODE_ENV': + isBuild && config.build.lib + ? 'process.env.NODE_ENV' + : JSON.stringify(process.env.NODE_ENV || config.mode), } const platform = @@ -769,46 +750,87 @@ async function prepareEsbuildOptimizerRun( const external = [...(optimizeDeps?.exclude ?? [])] - const plugins = [...pluginsFromConfig] + if (isBuild) { + let rollupOptionsExternal = config?.build?.rollupOptions?.external + if (rollupOptionsExternal) { + if (typeof rollupOptionsExternal === 'string') { + rollupOptionsExternal = [rollupOptionsExternal] + } + // TODO: decide whether to support RegExp and function options + // They're not supported yet because `optimizeDeps.exclude` currently only accepts strings + if ( + !Array.isArray(rollupOptionsExternal) || + rollupOptionsExternal.some((ext) => typeof ext !== 'string') + ) { + throw new Error( + `[vite] 'build.rollupOptions.external' can only be an array of strings or a string when using esbuild optimization at build time.`, + ) + } + external.push(...(rollupOptionsExternal as string[])) + } + } + + const plugins = await asyncFlatten( + Array.isArray(pluginsFromConfig) ? pluginsFromConfig : [pluginsFromConfig], + ) if (external.length) { - plugins.push(esbuildCjsExternalPlugin(external, platform)) + plugins.push(rolldownCjsExternalPlugin(external, platform)) } - plugins.push(esbuildDepPlugin(flatIdDeps, external, config, ssr)) - - const context = await esbuild.context({ - absWorkingDir: process.cwd(), - entryPoints: Object.keys(flatIdDeps), - bundle: true, - // We can't use platform 'neutral', as esbuild has custom handling - // when the platform is 'node' or 'browser' that can't be emulated - // by using mainFields and conditions - platform, - define, - format: 'esm', - // See https://github.com/evanw/esbuild/issues/1921#issuecomment-1152991694 - banner: - platform === 'node' - ? { - js: `import { createRequire } from 'module';const require = createRequire(import.meta.url);`, - } - : undefined, - target: ESBUILD_MODULES_TARGET, - external, - logLevel: 'error', - splitting: true, - sourcemap: true, - outdir: processingCacheDir, - ignoreAnnotations: true, - metafile: true, - plugins, - charset: 'utf8', - ...esbuildOptions, - supported: { - ...defaultEsbuildSupported, - ...esbuildOptions.supported, + plugins.push(rolldownDepPlugin(flatIdDeps, external, config, ssr)) + plugins.push({ + name: 'optimizer-transform', + async transform(code, id) { + if (/\.(?:m?[jt]s|[jt]sx)$/.test(id)) { + const result = await transformWithEsbuild(code, id, { + sourcemap: true, + sourcefile: id, + loader: jsxLoader && /\.js$/.test(id) ? 'jsx' : undefined, + define, + target: isBuild + ? config.build.target || undefined + : ESBUILD_MODULES_TARGET, + }) + // result.warnings.forEach((m) => { + // this.warn(prettifyMessage(m, code)) + // }) + return { + code: result.code, + map: result.map, + } + } }, }) - return { context, idToExports } + + async function build() { + const bundle = await rolldown.rolldown({ + input: Object.keys(flatIdDeps), + // external, + logLevel: 'warn', + plugins, + resolve: { + mainFields: ['module', 'main'], + aliasFields: [['browser']], + extensions: ['.js', '.css'], + }, + ...rollupOptions, + }) + return await bundle.write({ + format: 'esm', + sourcemap: true, + dir: processingCacheDir, + banner: + platform === 'node' + ? // TODO: use async to workaround https://github.com/rolldown/rolldown/issues/1655 + async (chunk) => + chunk.fileName.endsWith('.js') + ? `import { createRequire } from 'module';const require = createRequire(import.meta.url);` + : '' + : undefined, + ...rollupOptions.output, + }) + } + + return { build, idToExports } } export async function addManuallyIncludedOptimizeDeps( @@ -1009,19 +1031,23 @@ function stringifyDepsOptimizerMetadata( browserHash, optimized: Object.fromEntries( Object.values(optimized).map( - ({ id, src, file, fileHash, needsInterop }) => [ + ({ id, src, file, fileHash, needsInterop, isDynamicEntry }) => [ id, { src, file, fileHash, needsInterop, + isDynamicEntry, }, ], ), ), chunks: Object.fromEntries( - Object.values(chunks).map(({ id, file }) => [id, { file }]), + Object.values(chunks).map(({ id, file, isDynamicEntry }) => [ + id, + { file, isDynamicEntry }, + ]), ), }, (key: string, value: string) => { @@ -1036,29 +1062,6 @@ function stringifyDepsOptimizerMetadata( ) } -function esbuildOutputFromId( - outputs: Record, - id: string, - cacheDirOutputPath: string, -): any { - const cwd = process.cwd() - const flatId = flattenId(id) + '.js' - const normalizedOutputPath = normalizePath( - path.relative(cwd, path.join(cacheDirOutputPath, flatId)), - ) - const output = outputs[normalizedOutputPath] - if (output) { - return output - } - // If the root dir was symlinked, esbuild could return output keys as `../cwd/` - // Normalize keys to support this case too - for (const [key, value] of Object.entries(outputs)) { - if (normalizePath(path.relative(cwd, key)) === normalizedOutputPath) { - return value - } - } -} - export async function extractExportsData( filePath: string, config: ResolvedConfig, @@ -1068,18 +1071,20 @@ export async function extractExportsData( const optimizeDeps = getDepOptimizationConfig(config, ssr) - const esbuildOptions = optimizeDeps?.esbuildOptions ?? {} + const rollupOptions = optimizeDeps?.rollupOptions ?? {} if (optimizeDeps.extensions?.some((ext) => filePath.endsWith(ext))) { // For custom supported extensions, build the entry file to transform it into JS, // and then parse with es-module-lexer. Note that the `bundle` option is not `true`, // so only the entry file is being transformed. - const result = await build({ - ...esbuildOptions, - entryPoints: [filePath], - write: false, + const rolldownBuild = await rolldown.rolldown({ + ...rollupOptions, + input: [filePath], + }) + const result = await rolldownBuild.generate({ + ...rollupOptions.output, format: 'esm', }) - const [, exports, , hasModuleSyntax] = parse(result.outputFiles[0].text) + const [, exports, , hasModuleSyntax] = parse(result.output[0].code) return { hasModuleSyntax, exports: exports.map((e) => e.n), @@ -1093,7 +1098,7 @@ export async function extractExportsData( try { parseResult = parse(entryContent) } catch { - const loader = esbuildOptions.loader?.[path.extname(filePath)] || 'jsx' + const loader = rollupOptions.moduleTypes?.[path.extname(filePath)] || 'jsx' debug?.( `Unable to parse: ${filePath}.\n Trying again with a ${loader} transform.`, ) diff --git a/packages/vite/src/node/optimizer/rolldownDepPlugin.ts b/packages/vite/src/node/optimizer/rolldownDepPlugin.ts new file mode 100644 index 00000000000000..02f03198f80a2a --- /dev/null +++ b/packages/vite/src/node/optimizer/rolldownDepPlugin.ts @@ -0,0 +1,331 @@ +import path from 'node:path' +import type { Plugin, ImportKind } from 'rolldown' +import { KNOWN_ASSET_TYPES } from '../constants' +import type { PackageCache } from '../packages' +import { getDepOptimizationConfig } from '../config' +import type { ResolvedConfig } from '../config' +import { + escapeRegex, + flattenId, + isBuiltin, + isExternalUrl, + moduleListContains, + normalizePath, +} from '../utils' +import { browserExternalId, optionalPeerDepId } from '../plugins/resolve' +import { isCSSRequest, isModuleCSSRequest } from '../plugins/css' + +const externalWithConversionNamespace = + 'vite:dep-pre-bundle:external-conversion' +const convertedExternalPrefix = 'vite-dep-pre-bundle-external:' + +const cjsExternalFacadeNamespace = 'vite:cjs-external-facade' +const nonFacadePrefix = 'vite-cjs-external-facade:' + +const externalTypes = [ + 'css', + // supported pre-processor types + 'less', + 'sass', + 'scss', + 'styl', + 'stylus', + 'pcss', + 'postcss', + // wasm + 'wasm', + // known SFC types + 'vue', + 'svelte', + 'marko', + 'astro', + 'imba', + // JSX/TSX may be configured to be compiled differently from how esbuild + // handles it by default, so exclude them as well + 'jsx', + 'tsx', + ...KNOWN_ASSET_TYPES, +] + + +const optionalPeerDepNamespace = 'optional-peer-dep:' +const browserExternalNamespace = 'browser-external:' + +export function rolldownDepPlugin( + qualified: Record, + external: string[], + config: ResolvedConfig, + ssr: boolean, +): Plugin { + const { extensions } = getDepOptimizationConfig(config, ssr) + + // remove optimizable extensions from `externalTypes` list + const allExternalTypes = extensions + ? externalTypes.filter((type) => !extensions?.includes('.' + type)) + : externalTypes + + // use separate package cache for optimizer as it caches paths around node_modules + // and it's unlikely for the core Vite process to traverse into node_modules again + const esmPackageCache: PackageCache = new Map() + const cjsPackageCache: PackageCache = new Map() + + // default resolver which prefers ESM + const _resolve = config.createResolver({ + asSrc: false, + scan: true, + packageCache: esmPackageCache, + }) + + // cjs resolver that prefers Node + const _resolveRequire = config.createResolver({ + asSrc: false, + isRequire: true, + scan: true, + packageCache: cjsPackageCache, + }) + + const resolve = ( + id: string, + importer: string | undefined, + kind: ImportKind, + resolveDir?: string, + ): Promise => { + let _importer: string | undefined = undefined + // explicit resolveDir - this is passed only during yarn pnp resolve for + // entries + if (resolveDir) { + _importer = normalizePath(path.join(resolveDir, '*')) + } else if (importer) { + // map importer ids to file paths for correct resolution + _importer = importer in qualified ? qualified[importer] : importer + } + const resolver = kind.startsWith('require') ? _resolveRequire : _resolve + return resolver(id, _importer, undefined, ssr) + } + + const resolveResult = (id: string, resolved: string) => { + if (resolved.startsWith(browserExternalId)) { + return { + id: browserExternalNamespace + id, + } + } + if (resolved.startsWith(optionalPeerDepId)) { + return { + id: optionalPeerDepNamespace + resolved, + } + } + if (ssr && isBuiltin(resolved)) { + return + } + if (isExternalUrl(resolved)) { + return { + id: resolved, + external: true, + } + } + return { + id: path.resolve(resolved), + } + } + + const allExternalTypesReg = new RegExp( + `\\.(` + allExternalTypes.join('|') + `)(\\?.*)?$`, + ) + + function resolveEntry(id: string) { + const flatId = flattenId(id) + if (flatId in qualified) { + return { + id: qualified[flatId], + } + } + } + + return { + name: 'vite:dep-pre-bundle', + // clear package cache when build is finished + buildEnd() { + esmPackageCache.clear() + cjsPackageCache.clear() + }, + resolveId: async function (id, importer, options) { + const kind = options.kind + // externalize assets and commonly known non-js file types + // See #8459 for more details about this require-import conversion + if (allExternalTypesReg.test(id)) { + // if the prefix exist, it is already converted to `import`, so set `external: true` + if (id.startsWith(convertedExternalPrefix)) { + return { + id: id.slice(convertedExternalPrefix.length), + external: true, + } + } + + const resolved = await resolve(id, importer, kind) + if (resolved) { + if (kind === 'require-call') { + // #16116 fix: Import the module.scss path, which is actually module.scss.js + if (resolved.endsWith('.js')) { + return { + id: resolved, + external: false, + } + } + + // here it is not set to `external: true` to convert `require` to `import` + return { + id: externalWithConversionNamespace + resolved, + } + } + return { + id: resolved, + external: true, + } + } + } + + if (/^[\w@][^:]/.test(id)) { + if (moduleListContains(external, id)) { + return { + id: id, + external: true, + } + } + + // ensure esbuild uses our resolved entries + let entry: { id: string } | undefined + // if this is an entry, return entry namespace resolve result + if (!importer) { + if ((entry = resolveEntry(id))) return entry + // check if this is aliased to an entry - also return entry namespace + const aliased = await _resolve(id, undefined, true) + if (aliased && (entry = resolveEntry(aliased))) { + return entry + } + } + + // use vite's own resolver + const resolved = await resolve(id, importer, kind) + if (resolved) { + return resolveResult(id, resolved) + } + } + }, + load(id) { + if (id.startsWith(externalWithConversionNamespace)) { + const path = id.slice(externalWithConversionNamespace.length) + // import itself with prefix (this is the actual part of require-import conversion) + const modulePath = `"${convertedExternalPrefix}${path}"` + return { + code: + isCSSRequest(path) && !isModuleCSSRequest(path) + ? `import ${modulePath};` + : `export { default } from ${modulePath};` + + `export * from ${modulePath};`, + } + } + + if (id.startsWith(browserExternalNamespace)) { + const path = id.slice(browserExternalNamespace.length) + if (config.isProduction) { + return { + code: 'module.exports = {}', + } + } else { + // Return in CJS to intercept named imports. Use `Object.create` to + // create the Proxy in the prototype to workaround esbuild issue. Why? + // + // In short, esbuild cjs->esm flow: + // 1. Create empty object using `Object.create(Object.getPrototypeOf(module.exports))`. + // 2. Assign props of `module.exports` to the object. + // 3. Return object for ESM use. + // + // If we do `module.exports = new Proxy({}, {})`, step 1 returns empty object, + // step 2 does nothing as there's no props for `module.exports`. The final object + // is just an empty object. + // + // Creating the Proxy in the prototype satisfies step 1 immediately, which means + // the returned object is a Proxy that we can intercept. + // + // Note: Skip keys that are accessed by esbuild and browser devtools. + return { + code: `\ + module.exports = Object.create(new Proxy({}, { + get(_, key) { + if ( + key !== '__esModule' && + key !== '__proto__' && + key !== 'constructor' && + key !== 'splice' + ) { + console.warn(\`Module "${path}" has been externalized for browser compatibility. Cannot access "${path}.\${key}" in client code. See http://vitejs.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\`) + } + } + }))`, + } + } + } + + if (id.startsWith(optionalPeerDepNamespace)) { + if (config.isProduction) { + return { + code: 'module.exports = {}', + } + } else { + const path = id.slice(externalWithConversionNamespace.length) + const [, peerDep, parentDep] = path.split(':') + return { + code: `throw new Error(\`Could not resolve "${peerDep}" imported by "${parentDep}". Is it installed?\`)`, + } + } + } + }, + } +} + +const matchesEntireLine = (text: string) => `^${escapeRegex(text)}$` + +// esbuild doesn't transpile `require('foo')` into `import` statements if 'foo' is externalized +// https://github.com/evanw/esbuild/issues/566#issuecomment-735551834 +export function rolldownCjsExternalPlugin( + externals: string[], + platform: 'node' | 'browser', +): Plugin { + const filter = new RegExp(externals.map(matchesEntireLine).join('|')) + + return { + name: 'cjs-external', + resolveId(id, importer, options) { + if (id.startsWith(nonFacadePrefix)) { + return { + id: id.slice(nonFacadePrefix.length), + external: true, + } + } + + if (filter.test(id)) { + const kind = options.kind + if (kind === 'require-call' && platform !== 'node') { + return { + id: cjsExternalFacadeNamespace + id, + } + } + + return { + id, + external: true, + } + } + }, + load(id) { + if (id.startsWith(cjsExternalFacadeNamespace)) { + return { + code: + `import * as m from ${JSON.stringify( + nonFacadePrefix + id.slice(cjsExternalFacadeNamespace.length), + )};` + `module.exports = m;`, + } + } + }, + } +} \ No newline at end of file diff --git a/packages/vite/src/node/optimizer/scan.ts b/packages/vite/src/node/optimizer/scan.ts index 1cdef6c339c103..2d2f3705899c21 100644 --- a/packages/vite/src/node/optimizer/scan.ts +++ b/packages/vite/src/node/optimizer/scan.ts @@ -3,14 +3,10 @@ import fsp from 'node:fs/promises' import path from 'node:path' import { performance } from 'node:perf_hooks' import glob from 'fast-glob' -import type { - BuildContext, - Loader, - OnLoadArgs, - OnLoadResult, - Plugin, -} from 'esbuild' -import esbuild, { formatMessages, transform } from 'esbuild' +import type { Plugin } from 'rolldown' +import * as rolldown from 'rolldown' +import type { Loader } from 'esbuild' +import { transform } from 'esbuild' import colors from 'picocolors' import type { ResolvedConfig } from '..' import { @@ -21,6 +17,7 @@ import { } from '../constants' import { arraify, + asyncFlatten, createDebugger, dataUrlRE, externalRE, @@ -30,6 +27,7 @@ import { moduleListContains, multilineCommentsRE, normalizePath, + parseRequest, singlelineCommentsRE, virtualModulePrefix, virtualModuleRE, @@ -38,7 +36,6 @@ import type { PluginContainer } from '../server/pluginContainer' import { createPluginContainer } from '../server/pluginContainer' import { transformGlobImport } from '../plugins/importMetaGlob' import { cleanUrl } from '../../shared/utils' -import { loadTsconfigJsonForFile } from '../plugins/esbuild' type ResolveIdOptions = Parameters[2] @@ -73,9 +70,7 @@ export function scanImports(config: ResolvedConfig): { const scanContext = { cancelled: false } - const esbuildContext: Promise = computeEntries( - config, - ).then((computedEntries) => { + const context = computeEntries(config).then((computedEntries) => { entries = computedEntries if (!entries.length) { @@ -97,22 +92,16 @@ export function scanImports(config: ResolvedConfig): { .map((entry) => `\n ${colors.dim(entry)}`) .join('')}`, ) - return prepareEsbuildScanner(config, entries, deps, missing, scanContext) + return prepareRolldownScanner(config, entries, deps, missing, scanContext) }) - const result = esbuildContext + const result = context .then((context) => { - function disposeContext() { - return context?.dispose().catch((e) => { - config.logger.error('Failed to dispose esbuild context', { error: e }) - }) - } if (!context || scanContext?.cancelled) { - disposeContext() return { deps: {}, missing: {} } } return context - .rebuild() + .build() .then(() => { return { // Ensure a fixed order so hashes are stable and improve logs @@ -120,31 +109,28 @@ export function scanImports(config: ResolvedConfig): { missing, } }) - .finally(() => { - return disposeContext() - }) }) .catch(async (e) => { - if (e.errors && e.message.includes('The build was canceled')) { - // esbuild logs an error when cancelling, but this is expected so - // return an empty result instead - return { deps: {}, missing: {} } - } + // if (e.errors && e.message.includes('The build was canceled')) { + // // esbuild logs an error when cancelling, but this is expected so + // // return an empty result instead + // return { deps: {}, missing: {} } + // } const prependMessage = colors.red(`\ Failed to scan for dependencies from entries: ${entries.join('\n')} `) - if (e.errors) { - const msgs = await formatMessages(e.errors, { - kind: 'error', - color: true, - }) - e.message = prependMessage + msgs.join('\n') - } else { + // if (e.errors) { + // const msgs = await formatMessages(e.errors, { + // kind: 'error', + // color: true, + // }) + // e.message = prependMessage + msgs.join('\n') + // } else { e.message = prependMessage + e.message - } + // } throw e }) .finally(() => { @@ -162,7 +148,7 @@ export function scanImports(config: ResolvedConfig): { return { cancel: async () => { scanContext.cancelled = true - return esbuildContext.then((context) => context?.cancel()) + // return esbuildContext.then((context) => context?.cancel()) }, result, } @@ -202,51 +188,96 @@ async function computeEntries(config: ResolvedConfig) { return entries } -async function prepareEsbuildScanner( +// async function prepareEsbuildScanner( +// config: ResolvedConfig, +// entries: string[], +// deps: Record, +// missing: Record, +// scanContext?: { cancelled: boolean }, +// ): Promise { +// const container = await createPluginContainer(config) + +// if (scanContext?.cancelled) return + +// const plugin = esbuildScanPlugin(config, container, deps, missing, entries) + +// const { plugins = [], ...esbuildOptions } = +// config.optimizeDeps?.esbuildOptions ?? {} + +// // The plugin pipeline automatically loads the closest tsconfig.json. +// // But esbuild doesn't support reading tsconfig.json if the plugin has resolved the path (https://github.com/evanw/esbuild/issues/2265). +// // Due to syntax incompatibilities between the experimental decorators in TypeScript and TC39 decorators, +// // we cannot simply set `"experimentalDecorators": true` or `false`. (https://github.com/vitejs/vite/pull/15206#discussion_r1417414715) +// // Therefore, we use the closest tsconfig.json from the root to make it work in most cases. +// let tsconfigRaw = esbuildOptions.tsconfigRaw +// if (!tsconfigRaw && !esbuildOptions.tsconfig) { +// const tsconfigResult = await loadTsconfigJsonForFile( +// path.join(config.root, '_dummy.js'), +// ) +// if (tsconfigResult.compilerOptions?.experimentalDecorators) { +// tsconfigRaw = { compilerOptions: { experimentalDecorators: true } } +// } +// } + +// return await esbuild.context({ +// absWorkingDir: process.cwd(), +// write: false, +// stdin: { +// contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'), +// loader: 'js', +// }, +// bundle: true, +// format: 'esm', +// logLevel: 'silent', +// plugins: [...plugins, plugin], +// ...esbuildOptions, +// tsconfigRaw, +// }) +// } + +async function prepareRolldownScanner( config: ResolvedConfig, entries: string[], deps: Record, missing: Record, scanContext?: { cancelled: boolean }, -): Promise { +): Promise< + | { + build: () => Promise + } + | undefined +> { const container = await createPluginContainer(config) if (scanContext?.cancelled) return - const plugin = esbuildScanPlugin(config, container, deps, missing, entries) + if (config.optimizeDeps.esbuildOptions) { + config.logger.error( + `You've set "optimizeDeps.esbuildOptions" in your config. ` + + `This is deprecated and vite already use rollup to optimize packages. ` + + `Please use "optimizeDeps.rollupOptions" instead.`, + ) + } - const { plugins = [], ...esbuildOptions } = - config.optimizeDeps?.esbuildOptions ?? {} + const { plugins: pluginsFromConfig = [], ...rollupOptions } = + config.optimizeDeps.rollupOptions ?? {} - // The plugin pipeline automatically loads the closest tsconfig.json. - // But esbuild doesn't support reading tsconfig.json if the plugin has resolved the path (https://github.com/evanw/esbuild/issues/2265). - // Due to syntax incompatibilities between the experimental decorators in TypeScript and TC39 decorators, - // we cannot simply set `"experimentalDecorators": true` or `false`. (https://github.com/vitejs/vite/pull/15206#discussion_r1417414715) - // Therefore, we use the closest tsconfig.json from the root to make it work in most cases. - let tsconfigRaw = esbuildOptions.tsconfigRaw - if (!tsconfigRaw && !esbuildOptions.tsconfig) { - const tsconfigResult = await loadTsconfigJsonForFile( - path.join(config.root, '_dummy.js'), - ) - if (tsconfigResult.compilerOptions?.experimentalDecorators) { - tsconfigRaw = { compilerOptions: { experimentalDecorators: true } } - } + const plugins = await asyncFlatten( + Array.isArray(pluginsFromConfig) ? pluginsFromConfig : [pluginsFromConfig], + ) + + plugins.push(rolldownScanPlugin(config, container, deps, missing, entries)) + + async function build() { + await rolldown.experimental_scan({ + input: entries, + logLevel: 'silent', + plugins, + ...rollupOptions, + }) } - return await esbuild.context({ - absWorkingDir: process.cwd(), - write: false, - stdin: { - contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'), - loader: 'js', - }, - bundle: true, - format: 'esm', - logLevel: 'silent', - plugins: [...plugins, plugin], - ...esbuildOptions, - tsconfigRaw, - }) + return { build } } function orderedDependencies(deps: Record) { @@ -286,7 +317,393 @@ const typeRE = /\btype\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i const langRE = /\blang\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i const contextRE = /\bcontext\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i -function esbuildScanPlugin( +// function esbuildScanPlugin( +// config: ResolvedConfig, +// container: PluginContainer, +// depImports: Record, +// missing: Record, +// entries: string[], +// ): Plugin { +// const seen = new Map() + +// const resolve = async ( +// id: string, +// importer?: string, +// options?: ResolveIdOptions, +// ) => { +// const key = id + (importer && path.dirname(importer)) +// if (seen.has(key)) { +// return seen.get(key) +// } +// const resolved = await container.resolveId( +// id, +// importer && normalizePath(importer), +// { +// ...options, +// scan: true, +// }, +// ) +// const res = resolved?.id +// seen.set(key, res) +// return res +// } + +// const include = config.optimizeDeps?.include +// const exclude = [ +// ...(config.optimizeDeps?.exclude || []), +// '@vite/client', +// '@vite/env', +// ] + +// const isUnlessEntry = (path: string) => !entries.includes(path) + +// const externalUnlessEntry = ({ path }: { path: string }) => ({ +// path, +// external: isUnlessEntry(path), +// }) + +// const doTransformGlobImport = async ( +// contents: string, +// id: string, +// loader: Loader, +// ) => { +// let transpiledContents +// // transpile because `transformGlobImport` only expects js +// if (loader !== 'js') { +// transpiledContents = (await transform(contents, { loader })).code +// } else { +// transpiledContents = contents +// } + +// const result = await transformGlobImport( +// transpiledContents, +// id, +// config.root, +// resolve, +// ) + +// return result?.s.toString() || transpiledContents +// } + +// return { +// name: 'vite:dep-scan', +// setup(build) { +// const scripts: Record = {} + +// // external urls +// build.onResolve({ filter: externalRE }, ({ path }) => ({ +// path, +// external: true, +// })) + +// // data urls +// build.onResolve({ filter: dataUrlRE }, ({ path }) => ({ +// path, +// external: true, +// })) + +// // local scripts (`