diff --git a/packages/vite/src/node/plugins/index.ts b/packages/vite/src/node/plugins/index.ts index e95c45fd8f266d..0452e4f19a73fb 100644 --- a/packages/vite/src/node/plugins/index.ts +++ b/packages/vite/src/node/plugins/index.ts @@ -14,6 +14,7 @@ import { modulePreloadPolyfillPlugin } from './modulePreloadPolyfill' import { webWorkerPlugin } from './worker' import { preAliasPlugin } from './preAlias' import { definePlugin } from './define' +import { ssrRequireHookPlugin } from './ssrRequireHook' export async function resolvePlugins( config: ResolvedConfig, @@ -42,6 +43,7 @@ export async function resolvePlugins( ssrConfig: config.ssr, asSrc: true }), + config.build.ssr ? ssrRequireHookPlugin(config) : null, htmlInlineScriptProxyPlugin(config), cssPlugin(config), config.esbuild !== false ? esbuildPlugin(config.esbuild) : null, diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 49ffcec3bd33e5..bfdea8ba9416bf 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -67,6 +67,7 @@ export interface InternalResolveOptions extends ResolveOptions { tryPrefix?: string skipPackageJson?: boolean preferRelative?: boolean + preserveSymlinks?: boolean isRequire?: boolean // #3040 // when the importer is a ts module, @@ -305,7 +306,7 @@ export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin { function tryFsResolve( fsPath: string, options: InternalResolveOptions, - preserveSymlinks: boolean, + preserveSymlinks?: boolean, tryIndex = true, targetWeb = true ): string | undefined { @@ -426,7 +427,7 @@ function tryResolveFile( options: InternalResolveOptions, tryIndex: boolean, targetWeb: boolean, - preserveSymlinks: boolean, + preserveSymlinks?: boolean, tryPrefix?: string, skipPackageJson?: boolean ): string | undefined { @@ -489,7 +490,7 @@ export const idToPkgMap = new Map() export function tryNodeResolve( id: string, - importer: string | undefined, + importer: string | null | undefined, options: InternalResolveOptions, targetWeb: boolean, server?: ViteDevServer, @@ -522,14 +523,12 @@ export function tryNodeResolve( basedir = root } - const preserveSymlinks = !!server?.config.resolve.preserveSymlinks - // nested node module, step-by-step resolve to the basedir of the nestedPath if (nestedRoot) { - basedir = nestedResolveFrom(nestedRoot, basedir, preserveSymlinks) + basedir = nestedResolveFrom(nestedRoot, basedir, options.preserveSymlinks) } - const pkg = resolvePackageData(pkgId, basedir, preserveSymlinks) + const pkg = resolvePackageData(pkgId, basedir, options.preserveSymlinks) if (!pkg) { return @@ -541,9 +540,9 @@ export function tryNodeResolve( pkg, options, targetWeb, - preserveSymlinks + options.preserveSymlinks ) - : resolvePackageEntry(id, pkg, options, targetWeb, preserveSymlinks) + : resolvePackageEntry(id, pkg, options, targetWeb, options.preserveSymlinks) if (!resolved) { return } @@ -876,7 +875,7 @@ function resolveDeepImport( }: PackageData, options: InternalResolveOptions, targetWeb: boolean, - preserveSymlinks: boolean + preserveSymlinks?: boolean ): string | undefined { const cache = getResolvedCache(id, targetWeb) if (cache) { diff --git a/packages/vite/src/node/plugins/ssrRequireHook.ts b/packages/vite/src/node/plugins/ssrRequireHook.ts new file mode 100644 index 00000000000000..c1d24ca40d5f07 --- /dev/null +++ b/packages/vite/src/node/plugins/ssrRequireHook.ts @@ -0,0 +1,69 @@ +import MagicString from 'magic-string' +import { ResolvedConfig } from '..' +import { Plugin } from '../plugin' + +/** + * This plugin hooks into Node's module resolution algorithm at runtime, + * so that SSR builds can benefit from `resolve.dedupe` like they do + * in development. + */ +export function ssrRequireHookPlugin(config: ResolvedConfig): Plugin | null { + if (config.command !== 'build' || !config.resolve.dedupe?.length) { + return null + } + return { + name: 'vite:ssr-require-hook', + transform(code, id) { + const moduleInfo = this.getModuleInfo(id) + if (moduleInfo?.isEntry) { + const s = new MagicString(code) + s.prepend( + `;(${dedupeRequire.toString()})(${JSON.stringify( + config.resolve.dedupe + )});\n` + ) + return { + code: s.toString(), + map: s.generateMap({ + source: id + }) + } + } + } + } +} + +type NodeResolveFilename = ( + request: string, + parent: NodeModule, + isMain: boolean, + options?: Record +) => string + +/** Respect the `resolve.dedupe` option in production SSR. */ +function dedupeRequire(dedupe: string[]) { + const Module = require('module') as { _resolveFilename: NodeResolveFilename } + const resolveFilename = Module._resolveFilename + Module._resolveFilename = function (request, parent, isMain, options) { + if (request[0] !== '.' && request[0] !== '/') { + const parts = request.split('/') + const pkgName = parts[0][0] === '@' ? parts[0] + '/' + parts[1] : parts[0] + if (dedupe.includes(pkgName)) { + // Use this module as the parent. + parent = module + } + } + return resolveFilename!(request, parent, isMain, options) + } +} + +export function hookNodeResolve( + getResolver: (resolveFilename: NodeResolveFilename) => NodeResolveFilename +): () => void { + const Module = require('module') as { _resolveFilename: NodeResolveFilename } + const prevResolver = Module._resolveFilename + Module._resolveFilename = getResolver(prevResolver) + return () => { + Module._resolveFilename = prevResolver + } +} diff --git a/packages/vite/src/node/ssr/ssrExternal.ts b/packages/vite/src/node/ssr/ssrExternal.ts index 6a2152518f2614..9b68b83d6dce5e 100644 --- a/packages/vite/src/node/ssr/ssrExternal.ts +++ b/packages/vite/src/node/ssr/ssrExternal.ts @@ -32,7 +32,12 @@ export function resolveSSRExternal( seen.add(id) }) - collectExternals(config.root, ssrExternals, seen) + collectExternals( + config.root, + config.resolve.preserveSymlinks, + ssrExternals, + seen + ) for (const dep of knownImports) { // Assume external if not yet seen @@ -59,6 +64,7 @@ export function resolveSSRExternal( // do we need to do this ahead of time or could we do it lazily? function collectExternals( root: string, + preserveSymlinks: boolean | undefined, ssrExternals: Set, seen: Set ) { @@ -75,6 +81,7 @@ function collectExternals( const resolveOptions: InternalResolveOptions = { root, + preserveSymlinks, isProduction: false, isBuild: true } @@ -132,7 +139,7 @@ function collectExternals( // or are there others like SystemJS / AMD that we'd need to handle? // for now, we'll just leave this as is else if (/\.m?js$/.test(esmEntry)) { - if (pkg.type === "module" || esmEntry.endsWith('.mjs')) { + if (pkg.type === 'module' || esmEntry.endsWith('.mjs')) { ssrExternals.add(id) continue } @@ -145,7 +152,7 @@ function collectExternals( } for (const depRoot of depsToTrace) { - collectExternals(depRoot, ssrExternals, seen) + collectExternals(depRoot, preserveSymlinks, ssrExternals, seen) } } diff --git a/packages/vite/src/node/ssr/ssrModuleLoader.ts b/packages/vite/src/node/ssr/ssrModuleLoader.ts index 1200fdf5d9f29d..14c5f41c25656e 100644 --- a/packages/vite/src/node/ssr/ssrModuleLoader.ts +++ b/packages/vite/src/node/ssr/ssrModuleLoader.ts @@ -1,12 +1,9 @@ -import fs from 'fs' import path from 'path' import { pathToFileURL } from 'url' -import { ViteDevServer } from '..' +import { ViteDevServer } from '../server' import { dynamicImport, - cleanUrl, isBuiltin, - resolveFrom, unwrapId, usingDynamicImport } from '../utils' @@ -19,6 +16,8 @@ import { ssrDynamicImportKey } from './ssrTransform' import { transformRequest } from '../server/transformRequest' +import { InternalResolveOptions, tryNodeResolve } from '../plugins/resolve' +import { hookNodeResolve } from '../plugins/ssrRequireHook' interface SSRContext { global: typeof globalThis @@ -96,13 +95,32 @@ async function instantiateModule( urlStack = urlStack.concat(url) const isCircular = (url: string) => urlStack.includes(url) + const { + isProduction, + resolve: { dedupe }, + root + } = server.config + + const resolveOptions: InternalResolveOptions = { + conditions: ['node'], + dedupe, + // Prefer CommonJS modules. + extensions: ['.js', '.mjs', '.ts', '.jsx', '.tsx', '.json'], + isBuild: true, + isProduction, + // Disable "module" condition. + isRequire: true, + mainFields: ['main'], + root + } + // Since dynamic imports can happen in parallel, we need to // account for multiple pending deps and duplicate imports. const pendingDeps: string[] = [] const ssrImport = async (dep: string) => { if (dep[0] !== '.' && dep[0] !== '/') { - return nodeImport(dep, mod.file, server.config) + return nodeImport(dep, mod.file!, resolveOptions) } dep = unwrapId(dep) if (!isCircular(dep) && !pendingImports.get(dep)?.some(isCircular)) { @@ -185,21 +203,48 @@ async function instantiateModule( // In node@12+ we can use dynamic import to load CJS and ESM async function nodeImport( id: string, - importer: string | null, - config: ViteDevServer['config'] + importer: string, + resolveOptions: InternalResolveOptions ) { + // Node's module resolution is hi-jacked so Vite can ensure the + // configured `resolve.dedupe` and `mode` options are respected. + const viteResolve = (id: string, importer: string) => { + const resolved = tryNodeResolve(id, importer, resolveOptions, false) + if (!resolved) { + const err: any = new Error( + `Cannot find module '${id}' imported from '${importer}'` + ) + err.code = 'ERR_MODULE_NOT_FOUND' + throw err + } + return resolved.id + } + + // When an ESM module imports an ESM dependency, this hook is *not* used. + const unhookNodeResolve = hookNodeResolve( + (nodeResolve) => (id, parent, isMain, options) => + id[0] === '.' || isBuiltin(id) + ? nodeResolve(id, parent, isMain, options) + : viteResolve(id, parent.id) + ) + let url: string // `resolve` doesn't handle `node:` builtins, so handle them directly if (id.startsWith('node:') || isBuiltin(id)) { url = id } else { - url = resolve(id, importer, config.root, !!config.resolve.preserveSymlinks) + url = viteResolve(id, importer) if (usingDynamicImport) { url = pathToFileURL(url).toString() } } - const mod = await dynamicImport(url) - return proxyESM(id, mod) + + try { + const mod = await dynamicImport(url) + return proxyESM(id, mod) + } finally { + unhookNodeResolve() + } } // rollup-style default import interop for cjs @@ -216,25 +261,3 @@ function proxyESM(id: string, mod: any) { } }) } - -const resolveCache = new Map() - -function resolve( - id: string, - importer: string | null, - root: string, - preserveSymlinks: boolean -) { - const key = id + importer + root - const cached = resolveCache.get(key) - if (cached) { - return cached - } - const resolveDir = - importer && fs.existsSync(cleanUrl(importer)) - ? path.dirname(importer) - : root - const resolved = resolveFrom(id, resolveDir, preserveSymlinks, true) - resolveCache.set(key, resolved) - return resolved -}