diff --git a/docs/config/index.md b/docs/config/index.md index dc8bcac1ce221e..3efc77e1313302 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -206,6 +206,16 @@ export default defineConfig(async ({ command, mode }) => { List of file extensions to try for imports that omit extensions. Note it is **NOT** recommended to omit extensions for custom import types (e.g. `.vue`) since it can interfere with IDE and type support. +### resolve.preserveSymlinks + +- **Type:** `boolean` +- **Default:** `false` + + Enabling this setting causes vite to determine file identity by the original file path (i.e. the path without following symlinks) instead of the real file path (i.e. the path after following symlinks). + +- **Related:** [esbuild#preserve-symlinks](https://esbuild.github.io/api/#preserve-symlinks), [webpack#resolve.symlinks + ](https://webpack.js.org/configuration/resolve/#resolvesymlinks) + ### css.modules - **Type:** diff --git a/packages/playground/preserve-symlinks/__tests__/preload.spec.ts b/packages/playground/preserve-symlinks/__tests__/preload.spec.ts new file mode 100644 index 00000000000000..7e0b546d7dbdbb --- /dev/null +++ b/packages/playground/preserve-symlinks/__tests__/preload.spec.ts @@ -0,0 +1,9 @@ +test('should have no 404s', () => { + browserLogs.forEach((msg) => { + expect(msg).not.toMatch('404') + }) +}) + +test('not-preserve-symlinks', async () => { + expect(await page.textContent('#root')).toBe('hello vite') +}) diff --git a/packages/playground/preserve-symlinks/__tests__/serve.js b/packages/playground/preserve-symlinks/__tests__/serve.js new file mode 100644 index 00000000000000..94d19390dbd6c0 --- /dev/null +++ b/packages/playground/preserve-symlinks/__tests__/serve.js @@ -0,0 +1,108 @@ +// @ts-check +// this is automtically detected by scripts/jestPerTestSetup.ts and will replace +// the default e2e test serve behavior + +const path = require('path') +const http = require('http') +const sirv = require('sirv') +const fs = require('fs') + +const port = (exports.port = 9527) + +/** + * @param {string} root + * @param {boolean} isBuildTest + */ +exports.serve = async function serve(root, isBuildTest) { + const testDist = path.resolve(__dirname, '../moduleA/dist') + + if (fs.existsSync(testDist)) { + emptyDir(testDist) + } else { + fs.mkdirSync(testDist, { recursive: true }) + } + + fs.symlinkSync( + path.resolve(testDist, '../src/index.js'), + path.resolve(testDist, 'symlinks-moduleA.esm.js') + ) + + if (!isBuildTest) { + const { createServer } = require('vite') + process.env.VITE_INLINE = 'inline-serve' + let viteServer = await ( + await createServer({ + root: root, + logLevel: 'silent', + server: { + watch: { + usePolling: true, + interval: 100 + }, + host: true, + fs: { + strict: !isBuildTest + } + }, + build: { + target: 'esnext' + } + }) + ).listen() + // use resolved port/base from server + const base = viteServer.config.base === '/' ? '' : viteServer.config.base + const url = + (global.viteTestUrl = `http://localhost:${viteServer.config.server.port}${base}`) + await page.goto(url) + + return viteServer + } else { + const { build } = require('vite') + await build({ + root, + logLevel: 'silent', + configFile: path.resolve(__dirname, '../vite.config.js') + }) + + // start static file server + const serve = sirv(path.resolve(root, 'dist')) + const httpServer = http.createServer((req, res) => { + if (req.url === '/ping') { + res.statusCode = 200 + res.end('pong') + } else { + serve(req, res) + } + }) + + return new Promise((resolve, reject) => { + try { + const server = httpServer.listen(port, async () => { + await page.goto(`http://localhost:${port}`) + resolve({ + // for test teardown + async close() { + await new Promise((resolve) => { + server.close(resolve) + }) + } + }) + }) + } catch (e) { + reject(e) + } + }) + } +} + +function emptyDir(dir) { + for (const file of fs.readdirSync(dir)) { + const abs = path.resolve(dir, file) + if (fs.lstatSync(abs).isDirectory()) { + emptyDir(abs) + fs.rmdirSync(abs) + } else { + fs.unlinkSync(abs) + } + } +} diff --git a/packages/playground/preserve-symlinks/index.html b/packages/playground/preserve-symlinks/index.html new file mode 100644 index 00000000000000..07f82cb05c3eec --- /dev/null +++ b/packages/playground/preserve-symlinks/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite App + + +
+ + + diff --git a/packages/playground/preserve-symlinks/moduleA/package.json b/packages/playground/preserve-symlinks/moduleA/package.json new file mode 100644 index 00000000000000..cb535e54edeeeb --- /dev/null +++ b/packages/playground/preserve-symlinks/moduleA/package.json @@ -0,0 +1,17 @@ +{ + "name": "@symlinks/moduleA", + "version": "0.0.0", + "module": "dist/symlinks-moduleA.esm.js", + "main": "dist/symlinks-moduleA.cjs.js", + "preconstruct": { + "entrypoints": [ + "index.js" + ] + }, + "scripts": { + "dev": "preconstruct dev" + }, + "devDependencies": { + "@preconstruct/cli": "^2.0.6" + } +} diff --git a/packages/playground/preserve-symlinks/moduleA/src/data.js b/packages/playground/preserve-symlinks/moduleA/src/data.js new file mode 100644 index 00000000000000..e1bc98ec67da12 --- /dev/null +++ b/packages/playground/preserve-symlinks/moduleA/src/data.js @@ -0,0 +1,3 @@ +export const data = { + msg: 'hello vite' +} diff --git a/packages/playground/preserve-symlinks/moduleA/src/index.js b/packages/playground/preserve-symlinks/moduleA/src/index.js new file mode 100644 index 00000000000000..b564d013e6b5e8 --- /dev/null +++ b/packages/playground/preserve-symlinks/moduleA/src/index.js @@ -0,0 +1,5 @@ +import { data } from './data' + +export function sayHi() { + return data +} diff --git a/packages/playground/preserve-symlinks/package.json b/packages/playground/preserve-symlinks/package.json new file mode 100644 index 00000000000000..a751bf639f0d40 --- /dev/null +++ b/packages/playground/preserve-symlinks/package.json @@ -0,0 +1,13 @@ +{ + "name": "preserveSymlinks", + "version": "0.0.0", + "scripts": { + "dev": "vite --force", + "build": "vite build", + "serve": "vite preview" + }, + "workspaces": {}, + "dependencies": { + "@symlinks/moduleA": "link:./moduleA" + } +} diff --git a/packages/playground/preserve-symlinks/src/main.js b/packages/playground/preserve-symlinks/src/main.js new file mode 100644 index 00000000000000..7257c44f1ba83f --- /dev/null +++ b/packages/playground/preserve-symlinks/src/main.js @@ -0,0 +1,3 @@ +import { sayHi } from '@symlinks/moduleA' + +document.getElementById('root').innerText = sayHi().msg diff --git a/packages/playground/preserve-symlinks/vite.config.js b/packages/playground/preserve-symlinks/vite.config.js new file mode 100644 index 00000000000000..0c9322b79f6f27 --- /dev/null +++ b/packages/playground/preserve-symlinks/vite.config.js @@ -0,0 +1,6 @@ +// https://vitejs.dev/config/ +module.exports = { + resolve: { + preserveSymlinks: false + } +} diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 272331e47b87eb..4a181c097b26a9 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -448,6 +448,7 @@ export async function resolveConfig( ...config.optimizeDeps, esbuildOptions: { keepNames: config.optimizeDeps?.keepNames, + preserveSymlinks: config.resolve?.preserveSymlinks, ...config.optimizeDeps?.esbuildOptions } } diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 5c88daafcf83ad..d101a87cc1262b 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -45,6 +45,7 @@ export interface ResolveOptions { conditions?: string[] extensions?: string[] dedupe?: string[] + preserveSymlinks?: boolean } export interface InternalResolveOptions extends ResolveOptions { @@ -109,12 +110,14 @@ export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin { const options = isRequire ? requireOptions : baseOptions + const preserveSymlinks = !!server?.config.resolve.preserveSymlinks + let res: string | PartialResolvedId | undefined // explicit fs paths that starts with /@fs/* if (asSrc && id.startsWith(FS_PREFIX)) { const fsPath = fsPathFromId(id) - res = tryFsResolve(fsPath, options) + res = tryFsResolve(fsPath, options, preserveSymlinks) isDebug && debug(`[@fs] ${chalk.cyan(id)} -> ${chalk.dim(res)}`) // always return here even if res doesn't exist since /@fs/ is explicit // if the file doesn't exist it should be a 404 @@ -125,7 +128,7 @@ export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin { // /foo -> /fs-root/foo if (asSrc && id.startsWith('/')) { const fsPath = path.resolve(root, id.slice(1)) - if ((res = tryFsResolve(fsPath, options))) { + if ((res = tryFsResolve(fsPath, options, preserveSymlinks))) { isDebug && debug(`[url] ${chalk.cyan(id)} -> ${chalk.dim(res)}`) return res } @@ -160,12 +163,18 @@ export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin { if ( targetWeb && - (res = tryResolveBrowserMapping(fsPath, importer, options, true)) + (res = tryResolveBrowserMapping( + fsPath, + importer, + options, + true, + preserveSymlinks + )) ) { return res } - if ((res = tryFsResolve(fsPath, options))) { + if ((res = tryFsResolve(fsPath, options, preserveSymlinks))) { isDebug && debug(`[relative] ${chalk.cyan(id)} -> ${chalk.dim(res)}`) const pkg = importer != null && idToPkgMap.get(importer) if (pkg) { @@ -180,7 +189,10 @@ export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin { } // absolute fs paths - if (path.isAbsolute(id) && (res = tryFsResolve(id, options))) { + if ( + path.isAbsolute(id) && + (res = tryFsResolve(id, options, preserveSymlinks)) + ) { isDebug && debug(`[fs] ${chalk.cyan(id)} -> ${chalk.dim(res)}`) return res } @@ -212,7 +224,13 @@ export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin { if ( targetWeb && - (res = tryResolveBrowserMapping(id, importer, options, false)) + (res = tryResolveBrowserMapping( + id, + importer, + options, + false, + preserveSymlinks + )) ) { return res } @@ -279,6 +297,7 @@ export function resolvePlugin(baseOptions: InternalResolveOptions): Plugin { function tryFsResolve( fsPath: string, options: InternalResolveOptions, + preserveSymlinks: boolean, tryIndex = true, targetWeb = true ): string | undefined { @@ -302,6 +321,7 @@ function tryFsResolve( options, false, targetWeb, + preserveSymlinks, options.tryPrefix, options.skipPackageJson )) @@ -317,6 +337,7 @@ function tryFsResolve( options, false, targetWeb, + preserveSymlinks, options.tryPrefix, options.skipPackageJson )) @@ -332,6 +353,7 @@ function tryFsResolve( options, tryIndex, targetWeb, + preserveSymlinks, options.tryPrefix, options.skipPackageJson )) @@ -346,6 +368,7 @@ function tryResolveFile( options: InternalResolveOptions, tryIndex: boolean, targetWeb: boolean, + preserveSymlinks: boolean, tryPrefix?: string, skipPackageJson?: boolean ): string | undefined { @@ -366,16 +389,30 @@ function tryResolveFile( if (fs.existsSync(pkgPath)) { // path points to a node package const pkg = loadPackageData(pkgPath) - return resolvePackageEntry(file, pkg, options, targetWeb) + const resolved = resolvePackageEntry( + file, + pkg, + options, + targetWeb, + preserveSymlinks + ) + return resolved ? getRealPath(resolved, preserveSymlinks) : resolved } } - const index = tryFsResolve(file + '/index', options) + const index = tryFsResolve(file + '/index', options, preserveSymlinks) if (index) return index + postfix } } if (tryPrefix) { const prefixed = `${path.dirname(file)}/${tryPrefix}${path.basename(file)}` - return tryResolveFile(prefixed, postfix, options, tryIndex, targetWeb) + return tryResolveFile( + prefixed, + postfix, + options, + tryIndex, + targetWeb, + preserveSymlinks + ) } } @@ -416,23 +453,28 @@ 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) + basedir = nestedResolveFrom(nestedRoot, basedir, preserveSymlinks) } - const pkg = resolvePackageData(pkgId, basedir) + const pkg = resolvePackageData(pkgId, basedir, preserveSymlinks) if (!pkg) { return } let resolved = deepMatch - ? resolveDeepImport(id, pkg, options, targetWeb) - : resolvePackageEntry(id, pkg, options, targetWeb) + ? resolveDeepImport(id, pkg, options, targetWeb, preserveSymlinks) + : resolvePackageEntry(id, pkg, options, targetWeb, preserveSymlinks) if (!resolved) { return } + + resolved = getRealPath(resolved, preserveSymlinks) + // link id to pkg for browser field mapping check idToPkgMap.set(resolved, pkg) if (isBuild) { @@ -556,14 +598,15 @@ const packageCache = new Map() export function resolvePackageData( id: string, - basedir: string + basedir: string, + preserveSymlinks = false ): PackageData | undefined { const cacheKey = id + basedir if (packageCache.has(cacheKey)) { return packageCache.get(cacheKey) } try { - const pkgPath = resolveFrom(`${id}/package.json`, basedir) + const pkgPath = resolveFrom(`${id}/package.json`, basedir, preserveSymlinks) return loadPackageData(pkgPath, cacheKey) } catch (e) { isDebug && debug(`${chalk.red(`[failed loading package.json]`)} ${id}`) @@ -612,7 +655,8 @@ export function resolvePackageEntry( id: string, { dir, data, setResolvedCache, getResolvedCache }: PackageData, options: InternalResolveOptions, - targetWeb: boolean + targetWeb: boolean, + preserveSymlinks = false ): string | undefined { const cached = getResolvedCache('.', targetWeb) if (cached) { @@ -649,7 +693,8 @@ export function resolvePackageEntry( // instead; Otherwise, assume it's ESM and use it. const resolvedBrowserEntry = tryFsResolve( path.join(dir, browserEntry), - options + options, + preserveSymlinks ) if (resolvedBrowserEntry) { const content = fs.readFileSync(resolvedBrowserEntry, 'utf-8') @@ -695,7 +740,11 @@ export function resolvePackageEntry( } entryPoint = path.join(dir, entryPoint) - const resolvedEntryPoint = tryFsResolve(entryPoint, options) + const resolvedEntryPoint = tryFsResolve( + entryPoint, + options, + preserveSymlinks + ) if (resolvedEntryPoint) { isDebug && @@ -752,7 +801,8 @@ function resolveDeepImport( data }: PackageData, options: InternalResolveOptions, - targetWeb: boolean + targetWeb: boolean, + preserveSymlinks: boolean ): string | undefined { id = '.' + id.slice(data.name.length) const cache = getResolvedCache(id, targetWeb) @@ -790,6 +840,7 @@ function resolveDeepImport( const resolved = tryFsResolve( path.join(dir, relativeId), options, + preserveSymlinks, !exportsField, // try index only if no exports field targetWeb ) @@ -806,7 +857,8 @@ function tryResolveBrowserMapping( id: string, importer: string | undefined, options: InternalResolveOptions, - isFilePath: boolean + isFilePath: boolean, + preserveSymlinks: boolean ) { let res: string | undefined const pkg = importer && idToPkgMap.get(importer) @@ -815,7 +867,7 @@ function tryResolveBrowserMapping( const browserMappedPath = mapWithBrowserField(mapId, pkg.data.browser) if (browserMappedPath) { const fsPath = path.join(pkg.dir, browserMappedPath) - if ((res = tryFsResolve(fsPath, options))) { + if ((res = tryFsResolve(fsPath, options, preserveSymlinks))) { isDebug && debug(`[browser mapped] ${chalk.cyan(id)} -> ${chalk.dim(res)}`) idToPkgMap.set(res, pkg) @@ -859,3 +911,10 @@ function mapWithBrowserField( function equalWithoutSuffix(path: string, key: string, suffix: string) { return key.endsWith(suffix) && key.slice(0, -suffix.length) === path } + +function getRealPath(resolved: string, preserveSymlinks?: boolean): string { + if (!preserveSymlinks && browserExternalId !== resolved) { + return normalizePath(fs.realpathSync(resolved)) + } + return resolved +} diff --git a/packages/vite/src/node/ssr/ssrExternal.ts b/packages/vite/src/node/ssr/ssrExternal.ts index 8baf7b07bfc8ee..99dcd0d44aa3fb 100644 --- a/packages/vite/src/node/ssr/ssrExternal.ts +++ b/packages/vite/src/node/ssr/ssrExternal.ts @@ -103,7 +103,9 @@ export function resolveSSRExternal( } for (const id of depsToTrace) { - const depRoot = path.dirname(resolveFrom(`${id}/package.json`, root)) + const depRoot = path.dirname( + resolveFrom(`${id}/package.json`, root, !!config.resolve.preserveSymlinks) + ) resolveSSRExternal( { ...config, diff --git a/packages/vite/src/node/ssr/ssrModuleLoader.ts b/packages/vite/src/node/ssr/ssrModuleLoader.ts index 6a64d6a0fdaca9..24f005bd22aac8 100644 --- a/packages/vite/src/node/ssr/ssrModuleLoader.ts +++ b/packages/vite/src/node/ssr/ssrModuleLoader.ts @@ -91,7 +91,12 @@ async function instantiateModule( const ssrImport = async (dep: string) => { if (dep[0] !== '.' && dep[0] !== '/') { - return nodeRequire(dep, mod.file, server.config.root) + return nodeRequire( + dep, + mod.file, + server.config.root, + !!server.config.resolve.preserveSymlinks + ) } dep = unwrapId(dep) if (!isCircular(dep) && !pendingImports.get(dep)?.some(isCircular)) { @@ -169,8 +174,13 @@ async function instantiateModule( return Object.freeze(ssrModule) } -function nodeRequire(id: string, importer: string | null, root: string) { - const mod = require(resolve(id, importer, root)) +function nodeRequire( + id: string, + importer: string | null, + root: string, + preserveSymlinks: boolean +) { + const mod = require(resolve(id, importer, root, preserveSymlinks)) const defaultExport = mod.__esModule ? mod.default : mod // rollup-style default import interop for cjs return new Proxy(mod, { @@ -183,7 +193,12 @@ function nodeRequire(id: string, importer: string | null, root: string) { const resolveCache = new Map() -function resolve(id: string, importer: string | null, root: string) { +function resolve( + id: string, + importer: string | null, + root: string, + preserveSymlinks: boolean +) { const key = id + importer + root const cached = resolveCache.get(key) if (cached) { @@ -193,7 +208,7 @@ function resolve(id: string, importer: string | null, root: string) { importer && fs.existsSync(cleanUrl(importer)) ? path.dirname(importer) : root - const resolved = resolveFrom(id, resolveDir, true) + const resolved = resolveFrom(id, resolveDir, preserveSymlinks, true) resolveCache.set(key, resolved) return resolved } diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 7fa813e4293c47..9c8be23b590f58 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -50,12 +50,17 @@ try { const ssrExtensions = ['.js', '.cjs', '.json', '.node'] -export function resolveFrom(id: string, basedir: string, ssr = false): string { +export function resolveFrom( + id: string, + basedir: string, + preserveSymlinks = false, + ssr = false +): string { return resolve.sync(id, { basedir, extensions: ssr ? ssrExtensions : DEFAULT_EXTENSIONS, // necessary to work with pnpm - preserveSymlinks: isRunningWithYarnPnp || false + preserveSymlinks: preserveSymlinks || isRunningWithYarnPnp || false }) } @@ -63,11 +68,15 @@ export function resolveFrom(id: string, basedir: string, ssr = false): string { * like `resolveFrom` but supports resolving `>` path in `id`, * for example: `foo > bar > baz` */ -export function nestedResolveFrom(id: string, basedir: string): string { +export function nestedResolveFrom( + id: string, + basedir: string, + preserveSymlinks = false +): string { const pkgs = id.split('>').map((pkg) => pkg.trim()) try { for (const pkg of pkgs) { - basedir = resolveFrom(pkg, basedir) + basedir = resolveFrom(pkg, basedir, preserveSymlinks) } } catch {} return basedir @@ -291,7 +300,9 @@ export function numberToPos( ): { line: number; column: number } { if (typeof offset !== 'number') return offset if (offset > source.length) { - throw new Error(`offset is longer than source length! offset ${offset} > length ${source.length}`); + throw new Error( + `offset is longer than source length! offset ${offset} > length ${source.length}` + ) } const lines = source.split(splitRE) let counted = 0