Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/config/ssr-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down
53 changes: 51 additions & 2 deletions packages/vite/src/node/__tests__/external.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
Expand Down
30 changes: 30 additions & 0 deletions packages/vite/src/node/__tests__/resolve.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)

Expand Down
78 changes: 46 additions & 32 deletions packages/vite/src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
type EnvironmentResolveOptions,
type InternalResolveOptions,
type ResolveOptions,
normalizeExternalOption,
tryNodeResolve,
} from './plugins/resolve'
import type { LogLevel, Logger } from './logger'
Expand Down Expand Up @@ -247,7 +248,13 @@ type AllResolveOptions = ResolveOptions & {
alias?: AliasOptions
}

type ResolvedAllResolveOptions = Required<ResolveOptions> & { alias: Alias[] }
export type ResolvedResolveOptions = Required<
Omit<ResolveOptions, 'external'>
> & {
external: true | (string | RegExp)[]
}

type ResolvedAllResolveOptions = ResolvedResolveOptions & { alias: Alias[] }

export interface SharedEnvironmentOptions {
/**
Expand Down Expand Up @@ -286,8 +293,6 @@ export interface EnvironmentOptions extends SharedEnvironmentOptions {
build?: BuildEnvironmentOptions
}

export type ResolvedResolveOptions = Required<ResolveOptions>

export type ResolvedEnvironmentOptions = {
define?: Record<string, any>
resolve: ResolvedResolveOptions
Expand Down Expand Up @@ -585,7 +590,7 @@ export interface ResolvedConfig
isProduction: boolean
envDir: string | false
env: Record<string, any>
resolve: Required<ResolveOptions> & {
resolve: ResolvedResolveOptions & {
alias: Alias[]
}
plugins: readonly Plugin[]
Expand Down Expand Up @@ -662,7 +667,7 @@ export const configDefaults = Object.freeze({
dedupe: [],
/** @experimental */
noExternal: [],
external: [],
external: [] as (string | RegExp)[],
preserveSymlinks: false,
alias: [],
},
Expand Down Expand Up @@ -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
Expand Down
68 changes: 55 additions & 13 deletions packages/vite/src/node/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,33 @@ const isExternalCache = new WeakMap<
(id: string, importer?: string) => boolean
>()

type ExternalList = Exclude<InternalResolveOptions['external'], true>

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,
Expand All @@ -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) &&
Expand Down Expand Up @@ -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
Expand Down
Loading