diff --git a/docs/config/ssr-options.md b/docs/config/ssr-options.md index 7ff3abb6ccac6f..2fdfff1cf3b9a9 100644 --- a/docs/config/ssr-options.md +++ b/docs/config/ssr-options.md @@ -4,14 +4,14 @@ Unless noted, the options in this section are applied to both dev and build. ## ssr.external -- **Type:** `string[] | true` +- **Type:** `string | RegExp | (string | RegExp)[] | true` - **Related:** [SSR Externals](/guide/ssr#ssr-externals) Externalize the given dependencies and their transitive dependencies for SSR. By default, all dependencies are externalized except for linked dependencies (for HMR). If you prefer to externalize the linked dependency, you can pass its name to this option. If `true`, all dependencies including linked dependencies are externalized. -Note that the explicitly listed dependencies (using `string[]` type) will always take priority if they're also listed in `ssr.noExternal` (using any type). +Note that explicitly listed dependencies (strings or regular expressions) will always take priority if they're also listed in `ssr.noExternal` (using any type). ## ssr.noExternal @@ -20,7 +20,7 @@ Note that the explicitly listed dependencies (using `string[]` type) will always Prevent listed dependencies from being externalized for SSR, which they will get bundled in build. By default, only linked dependencies are not externalized (for HMR). If you prefer to externalize the linked dependency, you can pass its name to the `ssr.external` option. -If `true`, no dependencies are externalized. However, dependencies explicitly listed in `ssr.external` (using `string[]` type) can take priority and still be externalized. If `ssr.target: 'node'` is set, Node.js built-ins will also be externalized by default. +If `true`, no dependencies are externalized. However, dependencies explicitly listed in `ssr.external` (strings or regular expressions) can take priority and still be externalized. If `ssr.target: 'node'` is set, Node.js built-ins will also be externalized by default. Note that if both `ssr.noExternal: true` and `ssr.external: true` are configured, `ssr.noExternal` takes priority and no dependencies are externalized. diff --git a/packages/vite/src/node/__tests__/external.spec.ts b/packages/vite/src/node/__tests__/external.spec.ts index a4c78519fd91ef..07a7ff5f7c8cf4 100644 --- a/packages/vite/src/node/__tests__/external.spec.ts +++ b/packages/vite/src/node/__tests__/external.spec.ts @@ -14,14 +14,63 @@ describe('createIsConfiguredAsExternal', () => { const isExternal = await createIsExternal(true) expect(isExternal('@vitejs/cjs-ssr-dep')).toBe(true) }) + + test('regex external id match', async () => { + const isExternal = await createIsExternal(undefined, [ + /^@vitejs\/regex-match$/, + ]) + expect(isExternal('@vitejs/regex-match')).toBe(true) + expect(isExternal('@vitejs/regex-mismatch')).toBe(false) + }) + + test('regex external package match', async () => { + const isExternal = await createIsExternal(undefined, [ + /^@vitejs\/regex-package/, + ]) + expect(isExternal('@vitejs/regex-package/foo')).toBe(true) + expect(isExternal('@vitejs/regex-package')).toBe(true) + expect(isExternal('@vitejs/not-regex-package/foo')).toBe(false) + }) + + test('regex external takes precedence over noExternal for explicit matches', async () => { + const isExternal = await createIsExternal( + undefined, + [/^@vitejs\/regex-overlap/], + ['@vitejs/regex-overlap'], + ) + expect(isExternal('@vitejs/regex-overlap')).toBe(true) + expect(isExternal('@vitejs/regex-overlap/sub')).toBe(true) + }) + + test('noExternal alone keeps dependencies bundled', async () => { + const isExternal = await createIsExternal(undefined, undefined, [ + '@vitejs/no-external', + ]) + expect(isExternal('@vitejs/no-external')).toBe(false) + expect(isExternal('@vitejs/no-external/sub')).toBe(false) + }) }) -async function createIsExternal(external?: true) { +async function createIsExternal( + external?: true, + resolveExternal?: (string | RegExp)[], + noExternal?: (string | RegExp)[] | true, +) { const resolvedConfig = await resolveConfig( { configFile: false, root: fileURLToPath(new URL('./', import.meta.url)), - resolve: { external }, + resolve: { + external, + }, + environments: { + ssr: { + resolve: { + external: resolveExternal, + noExternal, + }, + }, + }, }, 'serve', ) diff --git a/packages/vite/src/node/__tests__/resolve.spec.ts b/packages/vite/src/node/__tests__/resolve.spec.ts index 4c5465b00833ef..bcf35947c3d2bd 100644 --- a/packages/vite/src/node/__tests__/resolve.spec.ts +++ b/packages/vite/src/node/__tests__/resolve.spec.ts @@ -2,8 +2,10 @@ import { join } from 'node:path' import { describe, expect, onTestFinished, test, vi } from 'vitest' import { createServer } from '../server' import { createServerModuleRunner } from '../ssr/runtime/serverModuleRunner' +import { configDefaults } from '../config' import type { EnvironmentOptions, InlineConfig } from '../config' import { build } from '../build' +import { normalizeExternalOption } from '../plugins/resolve' describe('import and resolveId', () => { async function createTestServer() { @@ -55,6 +57,34 @@ describe('import and resolveId', () => { }) }) +describe('normalizeExternalOption', () => { + test('string to array', () => { + expect(normalizeExternalOption('pkg')).toEqual(['pkg']) + }) + + test('regex to array', () => { + const regexp = /^pkg$/ + expect(normalizeExternalOption(regexp)).toEqual([regexp]) + }) + + test('array untouched', () => { + const value = ['pkg', /^pkg$/] + expect(normalizeExternalOption(value)).toBe(value) + }) + + test('true passthrough', () => { + expect(normalizeExternalOption(true)).toBe(true) + }) + + test('undefined becomes empty array', () => { + expect(normalizeExternalOption(undefined)).toEqual([]) + }) + + test('config default external normalized', () => { + expect(normalizeExternalOption(configDefaults.resolve.external)).toEqual([]) + }) +}) + describe('file url', () => { const fileUrl = new URL('./fixtures/file-url/entry.js', import.meta.url) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 42e07c67ee9235..d3d6b583a72f0d 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -90,6 +90,7 @@ import { type EnvironmentResolveOptions, type InternalResolveOptions, type ResolveOptions, + normalizeExternalOption, tryNodeResolve, } from './plugins/resolve' import type { LogLevel, Logger } from './logger' @@ -247,7 +248,13 @@ type AllResolveOptions = ResolveOptions & { alias?: AliasOptions } -type ResolvedAllResolveOptions = Required & { alias: Alias[] } +export type ResolvedResolveOptions = Required< + Omit +> & { + external: true | (string | RegExp)[] +} + +type ResolvedAllResolveOptions = ResolvedResolveOptions & { alias: Alias[] } export interface SharedEnvironmentOptions { /** @@ -286,8 +293,6 @@ export interface EnvironmentOptions extends SharedEnvironmentOptions { build?: BuildEnvironmentOptions } -export type ResolvedResolveOptions = Required - export type ResolvedEnvironmentOptions = { define?: Record resolve: ResolvedResolveOptions @@ -585,7 +590,7 @@ export interface ResolvedConfig isProduction: boolean envDir: string | false env: Record - resolve: Required & { + resolve: ResolvedResolveOptions & { alias: Alias[] } plugins: readonly Plugin[] @@ -662,7 +667,7 @@ export const configDefaults = Object.freeze({ dedupe: [], /** @experimental */ noExternal: [], - external: [], + external: [] as (string | RegExp)[], preserveSymlinks: false, alias: [], }, @@ -940,33 +945,42 @@ function resolveEnvironmentResolveOptions( // Backward compatibility isSsrTargetWebworkerEnvironment?: boolean, ): ResolvedAllResolveOptions { - const resolvedResolve: ResolvedAllResolveOptions = mergeWithDefaults( - { - ...configDefaults.resolve, - mainFields: - consumer === undefined || - consumer === 'client' || - isSsrTargetWebworkerEnvironment - ? DEFAULT_CLIENT_MAIN_FIELDS - : DEFAULT_SERVER_MAIN_FIELDS, - conditions: - consumer === undefined || - consumer === 'client' || - isSsrTargetWebworkerEnvironment - ? DEFAULT_CLIENT_CONDITIONS - : DEFAULT_SERVER_CONDITIONS.filter((c) => c !== 'browser'), - builtins: - resolve?.builtins ?? - (consumer === 'server' - ? isSsrTargetWebworkerEnvironment && resolve?.noExternal === true - ? [] - : nodeLikeBuiltins - : []), - }, - resolve ?? {}, - ) - resolvedResolve.preserveSymlinks = preserveSymlinks - resolvedResolve.alias = alias + const defaults: ResolvedResolveOptions = { + ...configDefaults.resolve, + mainFields: + consumer === undefined || + consumer === 'client' || + isSsrTargetWebworkerEnvironment + ? Array.from(DEFAULT_CLIENT_MAIN_FIELDS) + : Array.from(DEFAULT_SERVER_MAIN_FIELDS), + conditions: + consumer === undefined || + consumer === 'client' || + isSsrTargetWebworkerEnvironment + ? Array.from(DEFAULT_CLIENT_CONDITIONS) + : DEFAULT_SERVER_CONDITIONS.filter((c) => c !== 'browser'), + builtins: + resolve?.builtins ?? + (consumer === 'server' + ? isSsrTargetWebworkerEnvironment && resolve?.noExternal === true + ? [] + : nodeLikeBuiltins + : []), + preserveSymlinks, + external: normalizeExternalOption(configDefaults.resolve.external), + } + + const mergedResolve = mergeWithDefaults< + ResolvedResolveOptions, + EnvironmentResolveOptions + >(defaults, resolve ?? {}) + + const resolvedResolve: ResolvedAllResolveOptions = { + ...mergedResolve, + external: normalizeExternalOption(mergedResolve.external), + preserveSymlinks, + alias, + } if ( // @ts-expect-error removed field diff --git a/packages/vite/src/node/external.ts b/packages/vite/src/node/external.ts index 6da3fefa840a2f..41847abc3a78ec 100644 --- a/packages/vite/src/node/external.ts +++ b/packages/vite/src/node/external.ts @@ -19,6 +19,33 @@ const isExternalCache = new WeakMap< (id: string, importer?: string) => boolean >() +type ExternalList = Exclude + +function resetAndTestRegExp(regexp: RegExp, value: string): boolean { + regexp.lastIndex = 0 + return regexp.test(value) +} + +function matchesExternalList(list: ExternalList, value: string): boolean { + for (const pattern of list) { + if (typeof pattern === 'string') { + if (pattern === value) { + return true + } + } else if (resetAndTestRegExp(pattern, value)) { + return true + } + } + return false +} + +export function isIdExplicitlyExternal( + external: InternalResolveOptions['external'], + id: string, +): boolean { + return external === true ? true : matchesExternalList(external, id) +} + export function shouldExternalize( environment: Environment, id: string, @@ -38,6 +65,8 @@ export function createIsConfiguredAsExternal( const { config } = environment const { root, resolve } = config const { external, noExternal } = resolve + const externalList: ExternalList | undefined = + external === true ? undefined : external const noExternalFilter = typeof noExternal !== 'boolean' && !(Array.isArray(noExternal) && noExternal.length === 0) && @@ -92,25 +121,38 @@ export function createIsConfiguredAsExternal( // Returns true if it is configured as external, false if it is filtered // by noExternal and undefined if it isn't affected by the explicit config return (id: string, importer?: string) => { - if ( - // If this id is defined as external, force it as external - // Note that individual package entries are allowed in `external` - external !== true && - external.includes(id) - ) { + const explicitIdMatch = + externalList && matchesExternalList(externalList, id) + if (explicitIdMatch) { + const canExternalize = isExternalizable(id, importer, true) + if (!canExternalize) { + debug?.( + `Configured ${JSON.stringify( + id, + )} as external but failed to statically resolve it. ` + + `Falling back to honoring the explicit configuration.`, + ) + } return true } const pkgName = getNpmPackageName(id) if (!pkgName) { return isExternalizable(id, importer, false) } - if ( - // A package name in ssr.external externalizes every - // externalizable package entry - external !== true && - external.includes(pkgName) - ) { - return isExternalizable(id, importer, true) + const explicitPackageMatch = + externalList && matchesExternalList(externalList, pkgName) + if (explicitPackageMatch) { + const canExternalize = isExternalizable(id, importer, true) + if (!canExternalize) { + debug?.( + `Configured package ${JSON.stringify( + pkgName, + )} as external but failed to statically resolve ${JSON.stringify( + id, + )}. Falling back to honoring the explicit configuration.`, + ) + } + return true } if (typeof noExternal === 'boolean') { return !noExternal diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 303e5cc0042365..e5b5c779b175fc 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -37,7 +37,11 @@ import { import { optimizedDepInfoFromFile, optimizedDepInfoFromId } from '../optimizer' import type { DepsOptimizer } from '../optimizer' import type { PackageCache, PackageData } from '../packages' -import { canExternalizeFile, shouldExternalize } from '../external' +import { + canExternalizeFile, + isIdExplicitlyExternal, + shouldExternalize, +} from '../external' import { findNearestMainPackageData, findNearestPackageData, @@ -72,6 +76,18 @@ const debug = createDebugger('vite:resolve-details', { onlyWhenFocused: true, }) +export function normalizeExternalOption( + external: string | RegExp | (string | RegExp)[] | true | undefined, +): true | (string | RegExp)[] { + if (external === true) { + return true + } + if (!external) { + return [] + } + return Array.isArray(external) ? external : [external] +} + export interface EnvironmentResolveOptions { /** * @default ['browser', 'module', 'jsnext:main', 'jsnext'] @@ -96,7 +112,7 @@ export interface EnvironmentResolveOptions { * Only works in server environments for now. Previously this was `ssr.external`. * @experimental */ - external?: string[] | true + external?: string | RegExp | (string | RegExp)[] | true /** * Array of strings or regular expressions that indicate what modules are builtin for the environment. */ @@ -150,8 +166,10 @@ interface ResolvePluginOptions { } export interface InternalResolveOptions - extends Required, - ResolvePluginOptions {} + extends Required>, + ResolvePluginOptions { + external: true | (string | RegExp)[] +} // Defined ResolveOptions are used to overwrite the values for all environments // It is used when creating custom resolvers (for CSS, scanning, etc) @@ -162,6 +180,8 @@ export interface ResolvePluginOptionsWithOverrides export function resolvePlugin( resolveOptions: ResolvePluginOptionsWithOverrides, ): Plugin { + const { external: pluginExternal, ...resolveOptionsWithoutExternal } = + resolveOptions const { root, isProduction, asSrc, preferRelative = false } = resolveOptions // In unix systems, absolute paths inside root first needs to be checked as an @@ -201,8 +221,12 @@ export function resolvePlugin( const options: InternalResolveOptions = { isRequire, ...currentEnvironmentOptions.resolve, - ...resolveOptions, // plugin options + resolve options overrides + ...resolveOptionsWithoutExternal, // plugin options + resolve options overrides scan: resolveOpts.scan ?? resolveOptions.scan, + external: + pluginExternal === undefined + ? currentEnvironmentOptions.resolve.external + : normalizeExternalOption(pluginExternal), } const resolvedImports = resolveSubpathImports(id, importer, options) @@ -390,6 +414,17 @@ export function resolvePlugin( return res } + // For modules that should be externalized but couldn't be resolved by tryNodeResolve, + // we externalize them directly. However, we need to let built-ins go through their + // dedicated handling below to ensure proper moduleSideEffects setting. + if ( + external && + !isBuiltin(options.builtins, id) && + !isNodeLikeBuiltin(id) + ) { + return options.idOnly ? id : { id, external: true } + } + // built-ins // externalize if building for a server environment, otherwise redirect to an empty module if ( @@ -403,7 +438,7 @@ export function resolvePlugin( currentEnvironmentOptions.consumer === 'server' && isNodeLikeBuiltin(id) ) { - if (!(options.external === true || options.external.includes(id))) { + if (!isIdExplicitlyExternal(options.external, id)) { let message = `Automatically externalized node built-in module "${id}"` if (importer) { message += ` imported from "${path.relative( @@ -426,7 +461,7 @@ export function resolvePlugin( 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)) + !isIdExplicitlyExternal(options.external, id) ) { let message = `Cannot bundle built-in module "${id}"` if (importer) { diff --git a/packages/vite/src/node/ssr/index.ts b/packages/vite/src/node/ssr/index.ts index f9505ff9f0ddc0..68f351b196ac91 100644 --- a/packages/vite/src/node/ssr/index.ts +++ b/packages/vite/src/node/ssr/index.ts @@ -7,7 +7,7 @@ export type SsrDepOptimizationConfig = DepOptimizationConfig export interface SSROptions { noExternal?: string | RegExp | (string | RegExp)[] | true - external?: string[] | true + external?: string | RegExp | (string | RegExp)[] | true /** * Define the target for the ssr build. The browser field in package.json diff --git a/playground/ssr-external-regex/__tests__/ssr-external-regex.spec.ts b/playground/ssr-external-regex/__tests__/ssr-external-regex.spec.ts new file mode 100644 index 00000000000000..fc26b9792e0027 --- /dev/null +++ b/playground/ssr-external-regex/__tests__/ssr-external-regex.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from 'vitest' +import { isBuild, readFile } from '~utils' + +describe.runIf(isBuild)('build', () => { + test('ssr external regex keeps npm specifier externalized', async () => { + const contents = readFile('dist/entry-ssr.js') + expect(contents).toContain('npm:react@19.2.0') + }) +}) diff --git a/playground/ssr-external-regex/package.json b/playground/ssr-external-regex/package.json new file mode 100644 index 00000000000000..081d9b2dce9d12 --- /dev/null +++ b/playground/ssr-external-regex/package.json @@ -0,0 +1,9 @@ +{ + "name": "@vitejs/test-ssr-external-regex", + "private": true, + "type": "module", + "version": "0.0.0", + "scripts": { + "build": "vite build" + } +} diff --git a/playground/ssr-external-regex/src/entry-ssr.ts b/playground/ssr-external-regex/src/entry-ssr.ts new file mode 100644 index 00000000000000..f81aa3043bfa58 --- /dev/null +++ b/playground/ssr-external-regex/src/entry-ssr.ts @@ -0,0 +1,3 @@ +import 'npm:react@19.2.0' + +export default 'regex-external-ok' diff --git a/playground/ssr-external-regex/vite.config.ts b/playground/ssr-external-regex/vite.config.ts new file mode 100644 index 00000000000000..4f589c711fefb1 --- /dev/null +++ b/playground/ssr-external-regex/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + ssr: './src/entry-ssr.ts', + minify: false, + }, + environments: { + ssr: { + resolve: { + external: [/^npm:/], + }, + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b0a0f5c1bc9a7..0927e1dd0146ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1592,6 +1592,8 @@ importers: playground/ssr-deps/ts-transpiled-exports: {} + playground/ssr-external-regex: {} + playground/ssr-html: devDependencies: express: