diff --git a/packages/vite/src/node/__tests__/resolve.spec.ts b/packages/vite/src/node/__tests__/resolve.spec.ts index 111cdb99a6b1a0..ad15dcdd73a547 100644 --- a/packages/vite/src/node/__tests__/resolve.spec.ts +++ b/packages/vite/src/node/__tests__/resolve.spec.ts @@ -2,7 +2,7 @@ import { join } from 'node:path' import { describe, expect, onTestFinished, test } from 'vitest' import { createServer } from '../server' import { createServerModuleRunner } from '../ssr/runtime/serverModuleRunner' -import type { InlineConfig } from '../config' +import type { EnvironmentOptions, InlineConfig } from '../config' import { build } from '../build' describe('import and resolveId', () => { @@ -116,6 +116,137 @@ describe('file url', () => { expect(mod4.default).toBe(mod) }) + describe('environment builtins', () => { + function getConfig( + targetEnv: 'client' | 'ssr' | string, + builtins: NonNullable['builtins'], + ): InlineConfig { + return { + configFile: false, + root: join(import.meta.dirname, 'fixtures/file-url'), + logLevel: 'error', + server: { + middlewareMode: true, + }, + environments: { + [targetEnv]: { + resolve: { + builtins, + }, + }, + }, + } + } + + async function run({ + builtins, + targetEnv = 'custom', + testEnv = 'custom', + idToResolve, + }: { + builtins?: NonNullable['builtins'] + targetEnv?: 'client' | 'ssr' | string + testEnv?: 'client' | 'ssr' | string + idToResolve: string + }) { + const server = await createServer(getConfig(targetEnv, builtins)) + onTestFinished(() => server.close()) + + return server.environments[testEnv]?.pluginContainer.resolveId( + idToResolve, + ) + } + + test('declared builtin string', async () => { + const resolved = await run({ + builtins: ['my-env:custom-builtin'], + idToResolve: 'my-env:custom-builtin', + }) + expect(resolved?.external).toBe(true) + }) + + test('declared builtin regexp', async () => { + const resolved = await run({ + builtins: [/^my-env:\w/], + idToResolve: 'my-env:custom-builtin', + }) + expect(resolved?.external).toBe(true) + }) + + test('non declared builtin', async () => { + const resolved = await run({ + builtins: [ + /* empty */ + ], + idToResolve: 'my-env:custom-builtin', + }) + expect(resolved).toBeNull() + }) + + test('non declared node builtin', async () => { + await expect( + run({ + builtins: [ + /* empty */ + ], + idToResolve: 'node:fs', + }), + ).rejects.toThrowError( + /Automatically externalized node built-in module "node:fs"/, + ) + }) + + test('default to node-like builtins', async () => { + const resolved = await run({ + idToResolve: 'node:fs', + }) + expect(resolved?.external).toBe(true) + }) + + test('default to node-like builtins for ssr environment', async () => { + const resolved = await run({ + idToResolve: 'node:fs', + testEnv: 'ssr', + }) + expect(resolved?.external).toBe(true) + }) + + test('no default to node-like builtins for client environment', async () => { + const resolved = await run({ + idToResolve: 'node:fs', + testEnv: 'client', + }) + expect(resolved?.id).toEqual('__vite-browser-external:node:fs') + }) + + test('no builtins overriding for client environment', async () => { + const resolved = await run({ + idToResolve: 'node:fs', + testEnv: 'client', + targetEnv: 'client', + }) + expect(resolved?.id).toEqual('__vite-browser-external:node:fs') + }) + + test('declared node builtin', async () => { + const resolved = await run({ + builtins: [/^node:/], + idToResolve: 'node:fs', + }) + expect(resolved?.external).toBe(true) + }) + + test('declared builtin string in different environment', async () => { + const resolved = await run({ + builtins: ['my-env:custom-builtin'], + idToResolve: 'my-env:custom-builtin', + targetEnv: 'custom', + testEnv: 'ssr', + }) + expect(resolved).toBe(null) + }) + }) + test('build', async () => { await build({ ...getConfig(), diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index c2c3bda7a16147..744754285c9f43 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -61,16 +61,17 @@ import { asyncFlatten, createDebugger, createFilter, - isBuiltin, isExternalUrl, isFilePathESM, isInNodeModules, isNodeBuiltin, + isNodeLikeBuiltin, isObject, isParentDirectory, mergeAlias, mergeConfig, mergeWithDefaults, + nodeLikeBuiltins, normalizeAlias, normalizePath, } from './utils' @@ -881,7 +882,11 @@ function resolveEnvironmentResolveOptions( consumer === 'client' || isSsrTargetWebworkerEnvironment ? DEFAULT_CLIENT_CONDITIONS : DEFAULT_SERVER_CONDITIONS.filter((c) => c !== 'browser'), - enableBuiltinNoExternalCheck: !!isSsrTargetWebworkerEnvironment, + builtins: + resolve?.builtins ?? + (consumer === 'server' && !isSsrTargetWebworkerEnvironment + ? nodeLikeBuiltins + : []), }, resolve ?? {}, ) @@ -1753,6 +1758,7 @@ async function bundleConfigFile( preserveSymlinks: false, packageCache, isRequire, + builtins: nodeLikeBuiltins, })?.id } @@ -1771,7 +1777,7 @@ async function bundleConfigFile( // With the `isNodeBuiltin` check above, this check captures if the builtin is a // non-node built-in, which esbuild doesn't know how to handle. In that case, we // externalize it so the non-node runtime handles it instead. - if (isBuiltin(id)) { + if (isNodeLikeBuiltin(id)) { return { external: true } } diff --git a/packages/vite/src/node/external.ts b/packages/vite/src/node/external.ts index 58d30cfc096371..f0777188212be0 100644 --- a/packages/vite/src/node/external.ts +++ b/packages/vite/src/node/external.ts @@ -155,7 +155,9 @@ function createIsExternal( } let isExternal = false if (id[0] !== '.' && !path.isAbsolute(id)) { - isExternal = isBuiltin(id) || isConfiguredAsExternal(id, importer) + isExternal = + isBuiltin(environment.config.resolve.builtins, id) || + isConfiguredAsExternal(id, importer) } processedIds.set(id, isExternal) return isExternal diff --git a/packages/vite/src/node/optimizer/esbuildDepPlugin.ts b/packages/vite/src/node/optimizer/esbuildDepPlugin.ts index 7afbb7fbdea82f..a84c99f122b4ec 100644 --- a/packages/vite/src/node/optimizer/esbuildDepPlugin.ts +++ b/packages/vite/src/node/optimizer/esbuildDepPlugin.ts @@ -115,7 +115,7 @@ export function esbuildDepPlugin( namespace: 'optional-peer-dep', } } - if (environment.config.consumer === 'server' && isBuiltin(resolved)) { + if (isBuiltin(environment.config.resolve.builtins, resolved)) { return } if (isExternalUrl(resolved)) { diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index f13d920fb83647..b74b4fd4d288d2 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -519,7 +519,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { if (shouldExternalize(environment, specifier, importer)) { return } - if (isBuiltin(specifier)) { + if (isBuiltin(environment.config.resolve.builtins, specifier)) { return } } diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index f75798b84abe8e..121a12b3254ff3 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -25,6 +25,7 @@ import { isDataUrl, isExternalUrl, isInNodeModules, + isNodeLikeBuiltin, isNonDriveRelativeAbsolutePath, isObject, isOptimizable, @@ -97,9 +98,9 @@ export interface EnvironmentResolveOptions { */ external?: string[] | true /** - * @internal + * Array of strings or regular expressions that indicate what modules are builtin for the environment. */ - enableBuiltinNoExternalCheck?: boolean + builtins?: (string | RegExp)[] } export interface ResolveOptions extends EnvironmentResolveOptions { @@ -173,11 +174,8 @@ interface ResolvePluginOptions { } export interface InternalResolveOptions - extends Required>, - ResolvePluginOptions { - /** @internal this is always optional for backward compat */ - enableBuiltinNoExternalCheck?: boolean -} + extends Required, + ResolvePluginOptions {} // Defined ResolveOptions are used to overwrite the values for all environments // It is used when creating custom resolvers (for CSS, scanning, etc) @@ -422,47 +420,67 @@ export function resolvePlugin( return res } - // node built-ins. - // externalize if building for a node compatible environment, otherwise redirect to empty module - if (isBuiltin(id)) { - if (currentEnvironmentOptions.consumer === 'server') { - if ( - options.enableBuiltinNoExternalCheck && - options.noExternal === true && - // if both noExternal and external are true, noExternal will take the higher priority and bundle it. - // only if the id is explicitly listed in external, we will externalize it and skip this error. - (options.external === true || !options.external.includes(id)) - ) { - let message = `Cannot bundle Node.js built-in "${id}"` - if (importer) { - message += ` imported from "${path.relative( - process.cwd(), - importer, - )}"` - } - message += `. Consider disabling environments.${this.environment.name}.noExternal or remove the built-in dependency.` - this.error(message) + // built-ins + // externalize if building for a server environment, otherwise redirect to an empty module + if ( + currentEnvironmentOptions.consumer === 'server' && + isBuiltin(options.builtins, id) + ) { + return options.idOnly + ? id + : { id, external: true, moduleSideEffects: false } + } else if ( + currentEnvironmentOptions.consumer === 'server' && + isNodeLikeBuiltin(id) + ) { + if (!(options.external === true || options.external.includes(id))) { + let message = `Automatically externalized node built-in module "${id}"` + if (importer) { + message += ` imported from "${path.relative( + process.cwd(), + importer, + )}"` } + message += `. Consider adding it to environments.${this.environment.name}.external if it is intended.` + this.error(message) + } - return options.idOnly - ? id - : { id, external: true, moduleSideEffects: false } - } else { - if (!asSrc) { - debug?.( - `externalized node built-in "${id}" to empty module. ` + - `(imported by: ${colors.white(colors.dim(importer))})`, - ) - } else if (isProduction) { - this.warn( - `Module "${id}" has been externalized for browser compatibility, imported by "${importer}". ` + - `See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.`, - ) + return options.idOnly + ? id + : { id, external: true, moduleSideEffects: false } + } else if ( + currentEnvironmentOptions.consumer === 'client' && + isNodeLikeBuiltin(id) + ) { + if ( + options.noExternal === true && + // if both noExternal and external are true, noExternal will take the higher priority and bundle it. + // only if the id is explicitly listed in external, we will externalize it and skip this error. + (options.external === true || !options.external.includes(id)) + ) { + let message = `Cannot bundle built-in module "${id}"` + if (importer) { + message += ` imported from "${path.relative( + process.cwd(), + importer, + )}"` } - return isProduction - ? browserExternalId - : `${browserExternalId}:${id}` + message += `. Consider disabling environments.${this.environment.name}.noExternal or remove the built-in dependency.` + this.error(message) + } + + if (!asSrc) { + debug?.( + `externalized node built-in "${id}" to empty module. ` + + `(imported by: ${colors.white(colors.dim(importer))})`, + ) + } else if (isProduction) { + this.warn( + `Module "${id}" has been externalized for browser compatibility, imported by "${importer}". ` + + `See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.`, + ) } + return isProduction ? browserExternalId : `${browserExternalId}:${id}` } } @@ -720,8 +738,10 @@ export function tryNodeResolve( basedir = root } + const isModuleBuiltin = (id: string) => isBuiltin(options.builtins, id) + let selfPkg = null - if (!isBuiltin(id) && !id.includes('\0') && bareImportRE.test(id)) { + if (!isModuleBuiltin(id) && !id.includes('\0') && bareImportRE.test(id)) { // check if it's a self reference dep. const selfPackageData = findNearestPackageData(basedir, packageCache) selfPkg = @@ -738,7 +758,7 @@ export function tryNodeResolve( // if so, we can resolve to a special id that errors only when imported. if ( basedir !== root && // root has no peer dep - !isBuiltin(id) && + !isModuleBuiltin(id) && !id.includes('\0') && bareImportRE.test(id) ) { diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index d3851b37e7053c..e5d40fe2934220 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -28,8 +28,10 @@ export async function fetchModule( importer?: string, options: FetchModuleOptions = {}, ): Promise { - // builtins should always be externalized - if (url.startsWith('data:') || isBuiltin(url)) { + if ( + url.startsWith('data:') || + isBuiltin(environment.config.resolve.builtins, url) + ) { return { externalize: url, type: 'builtin' } } @@ -57,6 +59,7 @@ export async function fetchModule( isProduction, root, packageCache: environment.config.packageCache, + builtins: environment.config.resolve.builtins, }) if (!resolved) { const err: any = new Error( diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 794a333276782f..f2d84b2d3f5490 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -102,11 +102,43 @@ const BUN_BUILTIN_NAMESPACE = 'bun:' // Some runtimes like Bun injects namespaced modules here, which is not a node builtin const nodeBuiltins = builtinModules.filter((id) => !id.includes(':')) -// TODO: Use `isBuiltin` from `node:module`, but Deno doesn't support it -export function isBuiltin(id: string): boolean { - if (process.versions.deno && id.startsWith(NPM_BUILTIN_NAMESPACE)) return true - if (process.versions.bun && id.startsWith(BUN_BUILTIN_NAMESPACE)) return true - return isNodeBuiltin(id) +const isBuiltinCache = new WeakMap< + (string | RegExp)[], + (id: string, importer?: string) => boolean +>() + +export function isBuiltin(builtins: (string | RegExp)[], id: string): boolean { + let isBuiltin = isBuiltinCache.get(builtins) + if (!isBuiltin) { + isBuiltin = createIsBuiltin(builtins) + isBuiltinCache.set(builtins, isBuiltin) + } + return isBuiltin(id) +} + +export function createIsBuiltin( + builtins: (string | RegExp)[], +): (id: string) => boolean { + const plainBuiltinsSet = new Set( + builtins.filter((builtin) => typeof builtin === 'string'), + ) + const regexBuiltins = builtins.filter( + (builtin) => typeof builtin !== 'string', + ) + + return (id) => + plainBuiltinsSet.has(id) || regexBuiltins.some((regexp) => regexp.test(id)) +} + +export const nodeLikeBuiltins = [ + ...nodeBuiltins, + new RegExp(`^${NODE_BUILTIN_NAMESPACE}`), + new RegExp(`^${NPM_BUILTIN_NAMESPACE}`), + new RegExp(`^${BUN_BUILTIN_NAMESPACE}`), +] + +export function isNodeLikeBuiltin(id: string): boolean { + return isBuiltin(nodeLikeBuiltins, id) } export function isNodeBuiltin(id: string): boolean {