Skip to content

Commit

Permalink
fix: reduce number of fs.realpath calls
Browse files Browse the repository at this point in the history
  • Loading branch information
aleclarson committed Nov 14, 2022
1 parent 0a69985 commit 5b75cad
Show file tree
Hide file tree
Showing 10 changed files with 489 additions and 48 deletions.
158 changes: 158 additions & 0 deletions packages/vite/src/node/__tests__/SymlinkResolver.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { join, relative, resolve } from 'path'
import type { SymlinkResolver } from '../symlinks'
import { createSymlinkResolver } from '../symlinks'

let resolver: SymlinkResolver

const realpathMock = jest.fn()
const readlinkMock = jest.fn()

const root = '/dev/root'
const realpathSync = (p: string) => {
return resolver.realpathSync(resolve(root, p))
}

describe('SymlinkResolver', () => {
beforeEach(() => {
mockRealPath({})
mockReadLink({})
resolver = createSymlinkResolver(root, {
realpathSync: { native: realpathMock },
readlinkSync: readlinkMock
})
})

describe('inside project root', () => {
test('no symlinks present', () => {
const result = realpathSync('foo/bar')
expect(result).toMatchInlineSnapshot(`"/dev/root/foo/bar"`)
expect(resolver.fsCalls).toMatchInlineSnapshot(`2`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`0`)

// …is cached?
expect(realpathSync('foo/bar')).toBe(result)
expect(resolver.fsCalls).toMatchInlineSnapshot(`2`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`1`)
})

test('given path is a symlink', () => {
mockReadLink({ 'foo/bar': './baz' })

const result = realpathSync('foo/bar')
expect(result).toMatchInlineSnapshot(`"/dev/root/foo/baz"`)
expect(resolver.fsCalls).toMatchInlineSnapshot(`3`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`0`)

// …is cached?
expect(realpathSync('foo/bar')).toBe(result)
expect(resolver.fsCalls).toMatchInlineSnapshot(`3`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`1`)
})

test('given path is a symlink pointing out of root', () => {
mockReadLink({ foo: '/dev/foo' })

const result = realpathSync('foo')
expect(result).toMatchInlineSnapshot(`"/dev/foo"`)
expect(resolver.fsCalls).toMatchInlineSnapshot(`4`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`0`)

// …is cached?
expect(realpathSync('foo')).toBe(result)
expect(resolver.fsCalls).toMatchInlineSnapshot(`4`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`1`)
})

test('given path is a symlink within a symlink', () => {
mockRealPath({ foo: 'red' })
mockReadLink({ 'red/bar': './baz' })

const result = realpathSync('foo/bar')
expect(result).toMatchInlineSnapshot(`"/dev/root/red/baz"`)
expect(resolver.fsCalls).toMatchInlineSnapshot(`3`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`0`)

// …is cached?
expect(realpathSync('foo/bar')).toBe(result)
expect(resolver.fsCalls).toMatchInlineSnapshot(`3`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`1`)
})

test('given path has symlink grand parent', () => {
mockRealPath({ 'foo/bar': 'red/bar' })

const result = realpathSync('foo/bar/main.js')
expect(result).toMatchInlineSnapshot(`"/dev/root/red/bar/main.js"`)
expect(resolver.fsCalls).toMatchInlineSnapshot(`2`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`0`)

// …is cached?
expect(realpathSync('foo/bar/main.js')).toBe(result)
expect(realpathSync('foo/bar')).toMatchInlineSnapshot(
`"/dev/root/red/bar"`
)
expect(resolver.fsCalls).toMatchInlineSnapshot(`2`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`2`)
})

test('given path has two symlink parents', () => {
mockRealPath({ 'foo/bar': 'red/blue' })

const result = realpathSync('foo/bar/main.js')
expect(result).toMatchInlineSnapshot(`"/dev/root/red/blue/main.js"`)
expect(resolver.fsCalls).toMatchInlineSnapshot(`2`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`0`)

// …is cached?
expect(realpathSync('foo/bar/main.js')).toBe(result)
expect(realpathSync('foo/bar')).toMatchInlineSnapshot(
`"/dev/root/red/blue"`
)
expect(realpathSync('foo')).toMatchInlineSnapshot(`"/dev/root/red"`)
expect(resolver.fsCalls).toMatchInlineSnapshot(`2`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`3`)
})
})

test('symlink outside project root', () => {
// Mock a symlink that points to another symlink.
mockReadLink({ '../foo': './bar', '../bar': './baz' })

const result = realpathSync('../foo')
expect(result).toMatchInlineSnapshot(`"/dev/baz"`)
expect(resolver.fsCalls).toMatchInlineSnapshot(`4`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`0`)

// …is cached?
expect(realpathSync('../foo')).toBe(result)
expect(realpathSync('../bar')).toMatchInlineSnapshot(`"/dev/baz"`)
expect(realpathSync('../baz')).toMatchInlineSnapshot(`"/dev/baz"`)
expect(resolver.fsCalls).toMatchInlineSnapshot(`4`)
expect(resolver.cacheHits).toMatchInlineSnapshot(`3`)
})
})

function mockRealPath(pathMap: Record<string, string>) {
realpathMock.mockReset()
realpathMock.mockImplementation((arg) => {
return resolve(root, pathMap[relative(root, arg)] || arg)
})
}

// Thrown by fs.readlinkSync if given a path that's not a symlink.
const throwInvalid = throwError(-22)

function mockReadLink(linkMap: Record<string, string>) {
readlinkMock.mockReset()
readlinkMock.mockImplementation((arg) => {
return linkMap[relative(root, arg)] || throwInvalid()
})
}

function throwError(errno: number) {
return () => {
const e: any = new Error()
e.errno = errno
throw e
}
}
6 changes: 6 additions & 0 deletions packages/vite/src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import type { PackageCache } from './packages'
import { loadEnv, resolveEnvPrefix } from './env'
import type { ResolvedSSROptions, SSROptions } from './ssr'
import { resolveSSROptions } from './ssr'
import { createSymlinkResolver } from './symlinks'
import type { SymlinkResolver } from './symlinks'

const debug = createDebugger('vite:config')

Expand Down Expand Up @@ -348,6 +350,8 @@ export type ResolvedConfig = Readonly<
createResolver: (options?: Partial<InternalResolveOptions>) => ResolveFn
optimizeDeps: DepOptimizationOptions
/** @internal */
symlinkResolver: SymlinkResolver
/** @internal */
packageCache: PackageCache
worker: ResolveWorkerOptions
appType: AppType
Expand Down Expand Up @@ -558,6 +562,7 @@ export async function resolveConfig(
aliasPlugin({ entries: resolved.resolve.alias }),
resolvePlugin({
...resolved.resolve,
symlinkResolver: resolved.symlinkResolver,
root: resolvedRoot,
isProduction,
isBuild: command === 'build',
Expand Down Expand Up @@ -655,6 +660,7 @@ export async function resolveConfig(
},
logger,
packageCache: new Map(),
symlinkResolver: createSymlinkResolver(resolvedRoot),
createResolver,
optimizeDeps: {
disabled: 'build',
Expand Down
106 changes: 91 additions & 15 deletions packages/vite/src/node/packages.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import fs from 'node:fs'
import path from 'node:path'
import { createDebugger, createFilter, resolveFrom } from './utils'
import { createDebugger, createFilter, getRealPath, resolveFrom } from './utils'
import type { ResolvedConfig } from './config'
import type { Plugin } from './plugin'
import type { SymlinkResolver } from './symlinks'

const isDebug = process.env.DEBUG
const debug = createDebugger('vite:resolve-details', {
Expand Down Expand Up @@ -45,27 +46,52 @@ export function invalidatePackageData(
})
}

/**
* Find and load the `package.json` associated with a module id.
*
* Using the `options.packageCache` argument is highly recommended for
* performance. The easiest way is setting it to the `packageCache`
* property of the `vite.ResolvedConfig` object.
*/
export function resolvePackageData(
id: string,
basedir: string,
preserveSymlinks = false,
options?: LoadPackageOptions
): PackageData | null

/** @deprecated Use `options` object argument instead */
export function resolvePackageData(
id: string,
basedir: string,
preserveSymlinks: boolean | undefined,
packageCache?: PackageCache
): PackageData | null

export function resolvePackageData(
id: string,
basedir: string,
arg3?: boolean | LoadPackageOptions,
arg4?: PackageCache
): PackageData | null {
const options =
typeof arg3 === 'boolean'
? { preserveSymlinks: arg3, packageCache: arg4 }
: arg3 || {}

let pkg: PackageData | undefined
let cacheKey: string | undefined
if (packageCache) {
cacheKey = `${id}&${basedir}&${preserveSymlinks}`
if ((pkg = packageCache.get(cacheKey))) {
if (options.packageCache) {
cacheKey = `${id}&${basedir}&${options.preserveSymlinks || false}`
if ((pkg = options.packageCache.get(cacheKey))) {
return pkg
}
}

let pkgPath: string | undefined
try {
pkgPath = resolveFrom(`${id}/package.json`, basedir, preserveSymlinks)
pkg = loadPackageData(pkgPath, true, packageCache)
if (packageCache) {
packageCache.set(cacheKey!, pkg)
}
pkgPath = resolveFrom(`${id}/package.json`, basedir, true)
pkg = loadPackageData(pkgPath, options)
options.packageCache?.set(cacheKey!, pkg)
return pkg
} catch (e) {
if (e instanceof SyntaxError) {
Expand All @@ -79,17 +105,67 @@ export function resolvePackageData(
return null
}

export type LoadPackageOptions = {
preserveSymlinks?: boolean
symlinkResolver?: SymlinkResolver
packageCache?: PackageCache
cjsInclude?: (string | RegExp)[]
}

/**
* Load a `package.json` file into memory.
*
* Using the `options.packageCache` argument is highly recommended for
* performance. The easiest way is setting it to the `packageCache`
* property of the `vite.ResolvedConfig` object.
*/
export function loadPackageData(
pkgPath: string,
options?: LoadPackageOptions
): PackageData

/** @deprecated Use `options` object argument instead */
export function loadPackageData(
pkgPath: string,
preserveSymlinks?: boolean,
preserveSymlinks: boolean | undefined,
packageCache?: PackageCache
): PackageData

export function loadPackageData(
pkgPath: string,
arg2?: boolean | LoadPackageOptions,
arg3?: PackageCache
): PackageData {
if (!preserveSymlinks) {
pkgPath = fs.realpathSync.native(pkgPath)
const options =
typeof arg2 === 'boolean'
? { preserveSymlinks: arg2, packageCache: arg3 }
: arg2 || {}

if (options.preserveSymlinks !== true) {
const originalPkgPath = pkgPath

// Support uncached realpath calls for backwards compatibility.
pkgPath = getRealPath(
pkgPath,
options.symlinkResolver,
options.preserveSymlinks
)

// In case a linked package is a local clone of a CommonJS dependency,
// we need to ensure @rollup/plugin-commonjs analyzes the package even
// after it's been resolved to its actual file location.
if (options.cjsInclude && pkgPath !== originalPkgPath) {
const filter = createFilter(options.cjsInclude, undefined, {
resolve: false
})
if (!filter(pkgPath) && filter(originalPkgPath)) {
options.cjsInclude.push(path.dirname(pkgPath) + '/**')
}
}
}

let cached: PackageData | undefined
if ((cached = packageCache?.get(pkgPath))) {
if ((cached = options.packageCache?.get(pkgPath))) {
return cached
}

Expand Down Expand Up @@ -127,7 +203,7 @@ export function loadPackageData(
}
}

packageCache?.set(pkgPath, pkg)
options.packageCache?.set(pkgPath, pkg)
return pkg
}

Expand Down
7 changes: 6 additions & 1 deletion packages/vite/src/node/plugins/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,12 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
const getContentWithSourcemap = async (content: string) => {
if (config.css?.devSourcemap) {
const sourcemap = this.getCombinedSourcemap()
await injectSourcesContent(sourcemap, cleanUrl(id), config.logger)
await injectSourcesContent(
sourcemap,
cleanUrl(id),
config.logger,
config.symlinkResolver
)
return getCodeWithSourcemap('css', content, sourcemap)
}
return content
Expand Down
1 change: 1 addition & 0 deletions packages/vite/src/node/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export async function resolvePlugins(
isProduction: config.isProduction,
isBuild,
packageCache: config.packageCache,
symlinkResolver: config.symlinkResolver,
ssrConfig: config.ssr,
asSrc: true,
getDepsOptimizer: (ssr: boolean) => getDepsOptimizer(config, ssr),
Expand Down
Loading

0 comments on commit 5b75cad

Please sign in to comment.