From c9b61c47b04977bbcc2771394ac6c89eeb9ea20c Mon Sep 17 00:00:00 2001 From: patak Date: Tue, 12 Dec 2023 10:32:51 +0100 Subject: [PATCH] perf: cached fs utils (#15279) --- packages/vite/src/node/config.ts | 2 + packages/vite/src/node/fsUtils.ts | 435 ++++++++++++++++++ .../vite/src/node/plugins/importAnalysis.ts | 5 +- packages/vite/src/node/plugins/index.ts | 2 + packages/vite/src/node/plugins/preAlias.ts | 5 +- packages/vite/src/node/plugins/resolve.ts | 67 ++- packages/vite/src/node/server/index.ts | 29 +- .../node/server/middlewares/htmlFallback.ts | 10 +- .../src/node/server/middlewares/indexHtml.ts | 7 +- playground/html/vite.config.js | 3 + 10 files changed, 510 insertions(+), 55 deletions(-) create mode 100644 packages/vite/src/node/fsUtils.ts diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 13db8fdab595fe..a8d61f03a5a090 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -41,6 +41,7 @@ import { normalizePath, withTrailingSlash, } from './utils' +import { getFsUtils } from './fsUtils' import { createPluginHookUtils, getHookHandler, @@ -633,6 +634,7 @@ export async function resolveConfig( tryIndex: true, ...options, idOnly: true, + fsUtils: getFsUtils(resolved), }), ], })) diff --git a/packages/vite/src/node/fsUtils.ts b/packages/vite/src/node/fsUtils.ts new file mode 100644 index 00000000000000..ec9c6431bfa645 --- /dev/null +++ b/packages/vite/src/node/fsUtils.ts @@ -0,0 +1,435 @@ +import fs from 'node:fs' +import path from 'node:path' +import colors from 'picocolors' +import type { FSWatcher } from 'dep-types/chokidar' +import type { ResolvedConfig } from './config' +import { + isInNodeModules, + normalizePath, + safeRealpathSync, + tryStatSync, +} from './utils' + +export interface FsUtils { + existsSync: (path: string) => boolean + isDirectory: (path: string) => boolean + + tryResolveRealFile: ( + path: string, + preserveSymlinks?: boolean, + ) => string | undefined + tryResolveRealFileWithExtensions: ( + path: string, + extensions: string[], + preserveSymlinks?: boolean, + ) => string | undefined + tryResolveRealFileOrType: ( + path: string, + preserveSymlinks?: boolean, + ) => { path?: string; type: 'directory' | 'file' } | undefined + + initWatcher?: (watcher: FSWatcher) => void +} + +// An implementation of fsUtils without caching +export const commonFsUtils: FsUtils = { + existsSync: fs.existsSync, + isDirectory, + + tryResolveRealFile, + tryResolveRealFileWithExtensions, + tryResolveRealFileOrType, +} + +const cachedFsUtilsMap = new WeakMap() +export function getFsUtils(config: ResolvedConfig): FsUtils { + let fsUtils = cachedFsUtilsMap.get(config) + if (!fsUtils) { + if (config.command !== 'serve' || !config.server.fs.cachedChecks) { + // cached fsUtils is only used in the dev server for now, and only when the watcher isn't configured + // we can support custom ignored patterns later + fsUtils = commonFsUtils + } /* TODO: Enabling for testing, we need to review if this guard is needed + else if (config.server.watch === null || config.server.watch?.ignored) { + config.logger.warn( + colors.yellow( + `${colors.bold( + `(!)`, + )} server.fs.cachedChecks isn't supported if server.watch is null or a custom server.watch.ignored is configured\n`, + ), + ) + fsUtils = commonFsUtils + } */ else if ( + !config.resolve.preserveSymlinks && + config.root !== getRealPath(config.root) + ) { + config.logger.warn( + colors.yellow( + `${colors.bold( + `(!)`, + )} server.fs.cachedChecks isn't supported when resolve.preserveSymlinks is false and root is symlinked\n`, + ), + ) + fsUtils = commonFsUtils + } else { + fsUtils = createCachedFsUtils(config) + } + cachedFsUtilsMap.set(config, fsUtils) + } + return fsUtils +} + +type DirentsMap = Map + +type DirentCacheType = + | 'directory' + | 'file' + | 'symlink' + | 'error' + | 'directory_maybe_symlink' + | 'file_maybe_symlink' + +interface DirentCache { + dirents?: DirentsMap + type: DirentCacheType +} + +function readDirCacheSync(file: string): undefined | DirentsMap { + let dirents: fs.Dirent[] + try { + dirents = fs.readdirSync(file, { withFileTypes: true }) + } catch { + return + } + return direntsToDirentMap(dirents) +} + +function direntsToDirentMap(fsDirents: fs.Dirent[]): DirentsMap { + const dirents: DirentsMap = new Map() + for (const dirent of fsDirents) { + // We ignore non directory, file, and symlink entries + const type = dirent.isDirectory() + ? 'directory' + : dirent.isSymbolicLink() + ? 'symlink' + : dirent.isFile() + ? 'file' + : undefined + if (type) { + dirents.set(dirent.name, { type }) + } + } + return dirents +} + +function ensureFileMaybeSymlinkIsResolved( + direntCache: DirentCache, + filePath: string, +) { + if (direntCache.type !== 'file_maybe_symlink') return + + const isSymlink = fs + .lstatSync(filePath, { throwIfNoEntry: false }) + ?.isSymbolicLink() + direntCache.type = + isSymlink === undefined ? 'error' : isSymlink ? 'symlink' : 'file' +} + +function pathUntilPart(root: string, parts: string[], i: number): string { + let p = root + for (let k = 0; k < i; k++) p += '/' + parts[k] + return p +} + +export function createCachedFsUtils(config: ResolvedConfig): FsUtils { + const root = config.root // root is resolved and normalized, so it doesn't have a trailing slash + const rootDirPath = `${root}/` + const rootCache: DirentCache = { type: 'directory' } // dirents will be computed lazily + + const getDirentCacheSync = (parts: string[]): DirentCache | undefined => { + let direntCache: DirentCache = rootCache + for (let i = 0; i < parts.length; i++) { + if (direntCache.type === 'directory') { + let dirPath + if (!direntCache.dirents) { + dirPath = pathUntilPart(root, parts, i) + const dirents = readDirCacheSync(dirPath) + if (!dirents) { + direntCache.type = 'error' + return + } + direntCache.dirents = dirents + } + const nextDirentCache = direntCache.dirents!.get(parts[i]) + if (!nextDirentCache) { + return + } + if (nextDirentCache.type === 'directory_maybe_symlink') { + dirPath ??= pathUntilPart(root, parts, i) + const isSymlink = fs + .lstatSync(dirPath, { throwIfNoEntry: false }) + ?.isSymbolicLink() + direntCache.type = isSymlink ? 'symlink' : 'directory' + } + direntCache = nextDirentCache + } else if (direntCache.type === 'symlink') { + // early return if we encounter a symlink + return direntCache + } else if (direntCache.type === 'error') { + return direntCache + } else { + if (i !== parts.length - 1) { + return + } + if (direntCache.type === 'file_maybe_symlink') { + ensureFileMaybeSymlinkIsResolved( + direntCache, + pathUntilPart(root, parts, i), + ) + return direntCache + } else if (direntCache.type === 'file') { + return direntCache + } else { + return + } + } + } + return direntCache + } + + function getDirentCacheFromPath( + normalizedFile: string, + ): DirentCache | false | undefined { + if (normalizedFile === root) { + return rootCache + } + if (!normalizedFile.startsWith(rootDirPath)) { + return undefined + } + const pathFromRoot = normalizedFile.slice(rootDirPath.length) + const parts = pathFromRoot.split('/') + const direntCache = getDirentCacheSync(parts) + if (!direntCache || direntCache.type === 'error') { + return false + } + return direntCache + } + + function onPathAdd( + file: string, + type: 'directory_maybe_symlink' | 'file_maybe_symlink', + ) { + const direntCache = getDirentCacheFromPath(path.dirname(file)) + if ( + direntCache && + direntCache.type === 'directory' && + direntCache.dirents + ) { + direntCache.dirents.set(path.basename(file), { type }) + } + } + + function onPathUnlink(file: string) { + const direntCache = getDirentCacheFromPath(path.dirname(file)) + if ( + direntCache && + direntCache.type === 'directory' && + direntCache.dirents + ) { + direntCache.dirents.delete(path.basename(file)) + } + } + + return { + existsSync(file: string) { + if (isInNodeModules(file)) { + return fs.existsSync(file) + } + const normalizedFile = normalizePath(file) + const direntCache = getDirentCacheFromPath(normalizedFile) + if ( + direntCache === undefined || + (direntCache && direntCache.type === 'symlink') + ) { + // fallback to built-in fs for out-of-root and symlinked files + return fs.existsSync(file) + } + return !!direntCache + }, + tryResolveRealFile( + file: string, + preserveSymlinks?: boolean, + ): string | undefined { + if (isInNodeModules(file)) { + return tryResolveRealFile(file, preserveSymlinks) + } + const normalizedFile = normalizePath(file) + const direntCache = getDirentCacheFromPath(normalizedFile) + if ( + direntCache === undefined || + (direntCache && direntCache.type === 'symlink') + ) { + // fallback to built-in fs for out-of-root and symlinked files + return tryResolveRealFile(file, preserveSymlinks) + } + if (!direntCache || direntCache.type === 'directory') { + return + } + // We can avoid getRealPath even if preserveSymlinks is false because we know it's + // a file without symlinks in its path + return normalizedFile + }, + tryResolveRealFileWithExtensions( + file: string, + extensions: string[], + preserveSymlinks?: boolean, + ): string | undefined { + if (isInNodeModules(file)) { + return tryResolveRealFileWithExtensions( + file, + extensions, + preserveSymlinks, + ) + } + const normalizedFile = normalizePath(file) + const dirPath = path.posix.dirname(normalizedFile) + const direntCache = getDirentCacheFromPath(dirPath) + if ( + direntCache === undefined || + (direntCache && direntCache.type === 'symlink') + ) { + // fallback to built-in fs for out-of-root and symlinked files + return tryResolveRealFileWithExtensions( + file, + extensions, + preserveSymlinks, + ) + } + if (!direntCache || direntCache.type !== 'directory') { + return + } + + if (!direntCache.dirents) { + const dirents = readDirCacheSync(dirPath) + if (!dirents) { + direntCache.type = 'error' + return + } + direntCache.dirents = dirents + } + + const base = path.posix.basename(normalizedFile) + for (const ext of extensions) { + const fileName = base + ext + const fileDirentCache = direntCache.dirents.get(fileName) + if (fileDirentCache) { + const filePath = dirPath + '/' + fileName + ensureFileMaybeSymlinkIsResolved(fileDirentCache, filePath) + if (fileDirentCache.type === 'symlink') { + // fallback to built-in fs for symlinked files + return tryResolveRealFile(filePath, preserveSymlinks) + } + if (fileDirentCache.type === 'file') { + return filePath + } + } + } + }, + tryResolveRealFileOrType( + file: string, + preserveSymlinks?: boolean, + ): { path?: string; type: 'directory' | 'file' } | undefined { + if (isInNodeModules(file)) { + return tryResolveRealFileOrType(file, preserveSymlinks) + } + const normalizedFile = normalizePath(file) + const direntCache = getDirentCacheFromPath(normalizedFile) + if ( + direntCache === undefined || + (direntCache && direntCache.type === 'symlink') + ) { + // fallback to built-in fs for out-of-root and symlinked files + return tryResolveRealFileOrType(file, preserveSymlinks) + } + if (!direntCache) { + return + } + if (direntCache.type === 'directory') { + return { type: 'directory' } + } + // We can avoid getRealPath even if preserveSymlinks is false because we know it's + // a file without symlinks in its path + return { path: normalizedFile, type: 'file' } + }, + isDirectory(dirPath: string) { + if (isInNodeModules(dirPath)) { + return isDirectory(dirPath) + } + const direntCache = getDirentCacheFromPath(normalizePath(dirPath)) + if ( + direntCache === undefined || + (direntCache && direntCache.type === 'symlink') + ) { + // fallback to built-in fs for out-of-root and symlinked files + return isDirectory(dirPath) + } + return direntCache && direntCache.type === 'directory' + }, + + initWatcher(watcher: FSWatcher) { + watcher.on('add', (file) => { + onPathAdd(file, 'file_maybe_symlink') + }) + watcher.on('addDir', (dir) => { + onPathAdd(dir, 'directory_maybe_symlink') + }) + watcher.on('unlink', onPathUnlink) + watcher.on('unlinkDir', onPathUnlink) + }, + } +} + +function tryResolveRealFile( + file: string, + preserveSymlinks?: boolean, +): string | undefined { + const stat = tryStatSync(file) + if (stat?.isFile()) return getRealPath(file, preserveSymlinks) +} + +function tryResolveRealFileWithExtensions( + filePath: string, + extensions: string[], + preserveSymlinks?: boolean, +): string | undefined { + for (const ext of extensions) { + const res = tryResolveRealFile(filePath + ext, preserveSymlinks) + if (res) return res + } +} + +function tryResolveRealFileOrType( + file: string, + preserveSymlinks?: boolean, +): { path?: string; type: 'directory' | 'file' } | undefined { + const fileStat = tryStatSync(file) + if (fileStat?.isFile()) { + return { path: getRealPath(file, preserveSymlinks), type: 'file' } + } + if (fileStat?.isDirectory()) { + return { type: 'directory' } + } + return +} + +function getRealPath(resolved: string, preserveSymlinks?: boolean): string { + if (!preserveSymlinks) { + resolved = safeRealpathSync(resolved) + } + return normalizePath(resolved) +} + +function isDirectory(path: string): boolean { + const stat = tryStatSync(path) + return stat?.isDirectory() ?? false +} diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index 89b7238ddf197a..575eeeb35646ae 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs' import path from 'node:path' import { performance } from 'node:perf_hooks' import colors from 'picocolors' @@ -52,6 +51,7 @@ import { withTrailingSlash, wrapId, } from '../utils' +import { getFsUtils } from '../fsUtils' import { checkPublicFile } from '../publicDir' import { getDepOptimizationConfig } from '../config' import type { ResolvedConfig } from '../config' @@ -174,6 +174,7 @@ function extractImportedBindings( */ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { const { root, base } = config + const fsUtils = getFsUtils(config) const clientPublicPath = path.posix.join(base, CLIENT_PUBLIC_PATH) const enablePartialAccept = config.experimental?.hmrPartialAccept let server: ViteDevServer @@ -338,7 +339,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { } else if ( depsOptimizer?.isOptimizedDepFile(resolved.id) || (path.isAbsolute(cleanUrl(resolved.id)) && - fs.existsSync(cleanUrl(resolved.id))) + fsUtils.existsSync(cleanUrl(resolved.id))) ) { // an optimized deps may not yet exists in the filesystem, or // a regular file exists but is out of root: rewrite to absolute /@fs/ paths diff --git a/packages/vite/src/node/plugins/index.ts b/packages/vite/src/node/plugins/index.ts index cefc11bb147ebb..87f8aea70d630c 100644 --- a/packages/vite/src/node/plugins/index.ts +++ b/packages/vite/src/node/plugins/index.ts @@ -6,6 +6,7 @@ import type { HookHandler, Plugin, PluginWithRequiredHook } from '../plugin' import { getDepsOptimizer } from '../optimizer' import { shouldExternalizeForSSR } from '../ssr/ssrExternal' import { watchPackageDataPlugin } from '../packages' +import { getFsUtils } from '../fsUtils' import { jsonPlugin } from './json' import { resolvePlugin } from './resolve' import { optimizedDepsBuildPlugin, optimizedDepsPlugin } from './optimizedDeps' @@ -67,6 +68,7 @@ export async function resolvePlugins( packageCache: config.packageCache, ssrConfig: config.ssr, asSrc: true, + fsUtils: getFsUtils(config), getDepsOptimizer: (ssr: boolean) => getDepsOptimizer(config, ssr), shouldExternalize: isBuild && config.build.ssr diff --git a/packages/vite/src/node/plugins/preAlias.ts b/packages/vite/src/node/plugins/preAlias.ts index 029bca0ac9f067..33df60b2d58b2e 100644 --- a/packages/vite/src/node/plugins/preAlias.ts +++ b/packages/vite/src/node/plugins/preAlias.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs' import path from 'node:path' import type { Alias, @@ -16,6 +15,7 @@ import { moduleListContains, withTrailingSlash, } from '../utils' +import { getFsUtils } from '../fsUtils' import { getDepsOptimizer } from '../optimizer' import { tryOptimizedResolve } from './resolve' @@ -26,6 +26,7 @@ export function preAliasPlugin(config: ResolvedConfig): Plugin { const findPatterns = getAliasPatterns(config.resolve.alias) const isConfiguredAsExternal = createIsConfiguredAsSsrExternal(config) const isBuild = config.command === 'build' + const fsUtils = getFsUtils(config) return { name: 'vite:pre-alias', async resolveId(id, importer, options) { @@ -63,7 +64,7 @@ export function preAliasPlugin(config: ResolvedConfig): Plugin { const isVirtual = resolvedId === id || resolvedId.includes('\0') if ( !isVirtual && - fs.existsSync(resolvedId) && + fsUtils.existsSync(resolvedId) && !moduleListContains(optimizeDeps.exclude, id) && path.isAbsolute(resolvedId) && (isInNodeModules(resolvedId) || diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 449ba03ecf2c48..71577872e5bed8 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -43,6 +43,8 @@ import { optimizedDepInfoFromFile, optimizedDepInfoFromId } from '../optimizer' import type { DepsOptimizer } from '../optimizer' import type { SSROptions } from '..' import type { PackageCache, PackageData } from '../packages' +import type { FsUtils } from '../fsUtils' +import { commonFsUtils } from '../fsUtils' import { findNearestMainPackageData, findNearestPackageData, @@ -92,6 +94,7 @@ export interface InternalResolveOptions extends Required { isProduction: boolean ssrConfig?: SSROptions packageCache?: PackageCache + fsUtils?: FsUtils /** * src code mode also attempts the following: * - resolving /xxx as URLs @@ -566,25 +569,29 @@ function tryCleanFsResolve( ): string | undefined { const { tryPrefix, extensions, preserveSymlinks } = options - const fileStat = tryStatSync(file) + const fsUtils = options.fsUtils ?? commonFsUtils - // Try direct match first - if (fileStat?.isFile()) return getRealPath(file, options.preserveSymlinks) + // Optimization to get the real type or file type (directory, file, other) + const fileResult = fsUtils.tryResolveRealFileOrType( + file, + options.preserveSymlinks, + ) + + if (fileResult?.path) return fileResult.path let res: string | undefined // If path.dirname is a valid directory, try extensions and ts resolution logic const possibleJsToTs = options.isFromTsImporter && isPossibleTsOutput(file) - if (possibleJsToTs || extensions.length || tryPrefix) { + if (possibleJsToTs || options.extensions.length || tryPrefix) { const dirPath = path.dirname(file) - const dirStat = tryStatSync(dirPath) - if (dirStat?.isDirectory()) { + if (fsUtils.isDirectory(dirPath)) { if (possibleJsToTs) { // try resolve .js, .mjs, .cjs or .jsx import to typescript file const fileExt = path.extname(file) const fileName = file.slice(0, -fileExt.length) if ( - (res = tryResolveRealFile( + (res = fsUtils.tryResolveRealFile( fileName + fileExt.replace('js', 'ts'), preserveSymlinks, )) @@ -593,13 +600,16 @@ function tryCleanFsResolve( // for .js, also try .tsx if ( fileExt === '.js' && - (res = tryResolveRealFile(fileName + '.tsx', preserveSymlinks)) + (res = fsUtils.tryResolveRealFile( + fileName + '.tsx', + preserveSymlinks, + )) ) return res } if ( - (res = tryResolveRealFileWithExtensions( + (res = fsUtils.tryResolveRealFileWithExtensions( file, extensions, preserveSymlinks, @@ -610,10 +620,11 @@ function tryCleanFsResolve( if (tryPrefix) { const prefixed = `${dirPath}/${options.tryPrefix}${path.basename(file)}` - if ((res = tryResolveRealFile(prefixed, preserveSymlinks))) return res + if ((res = fsUtils.tryResolveRealFile(prefixed, preserveSymlinks))) + return res if ( - (res = tryResolveRealFileWithExtensions( + (res = fsUtils.tryResolveRealFileWithExtensions( prefixed, extensions, preserveSymlinks, @@ -624,14 +635,14 @@ function tryCleanFsResolve( } } - if (tryIndex && fileStat) { + if (tryIndex && fileResult?.type === 'directory') { // Path points to a directory, check for package.json and entry and /index file const dirPath = file if (!skipPackageJson) { let pkgPath = `${dirPath}/package.json` try { - if (fs.existsSync(pkgPath)) { + if (fsUtils.existsSync(pkgPath)) { if (!options.preserveSymlinks) { pkgPath = safeRealpathSync(pkgPath) } @@ -647,7 +658,7 @@ function tryCleanFsResolve( } if ( - (res = tryResolveRealFileWithExtensions( + (res = fsUtils.tryResolveRealFileWithExtensions( `${dirPath}/index`, extensions, preserveSymlinks, @@ -657,7 +668,7 @@ function tryCleanFsResolve( if (tryPrefix) { if ( - (res = tryResolveRealFileWithExtensions( + (res = fsUtils.tryResolveRealFileWithExtensions( `${dirPath}/${options.tryPrefix}index`, extensions, preserveSymlinks, @@ -668,25 +679,6 @@ function tryCleanFsResolve( } } -function tryResolveRealFile( - file: string, - preserveSymlinks: boolean, -): string | undefined { - const stat = tryStatSync(file) - if (stat?.isFile()) return getRealPath(file, preserveSymlinks) -} - -function tryResolveRealFileWithExtensions( - filePath: string, - extensions: string[], - preserveSymlinks: boolean, -): string | undefined { - for (const ext of extensions) { - const res = tryResolveRealFile(filePath + ext, preserveSymlinks) - if (res) return res - } -} - export type InternalResolveOptionsWithOverrideConditions = InternalResolveOptions & { /** @@ -1297,10 +1289,3 @@ 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) { - resolved = safeRealpathSync(resolved) - } - return normalizePath(resolved) -} diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 35a4fb02a9149a..ebb9e5829810c6 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -35,6 +35,7 @@ import { resolveHostname, resolveServerUrls, } from '../utils' +import { getFsUtils } from '../fsUtils' import { ssrLoadModule } from '../ssr/ssrModuleLoader' import { ssrFixStacktrace, ssrRewriteStacktrace } from '../ssr/ssrStacktrace' import { ssrTransform } from '../ssr/ssrTransform' @@ -188,6 +189,14 @@ export interface FileSystemServeOptions { * @default ['.env', '.env.*', '*.crt', '*.pem'] */ deny?: string[] + + /** + * Enable caching of fs calls. + * + * @experimental + * @default false + */ + cachedChecks?: boolean } export type ServerHook = ( @@ -666,8 +675,14 @@ export async function _createServer( await onHMRUpdate(file, false) }) - watcher.on('add', (file) => onFileAddUnlink(file, false)) - watcher.on('unlink', (file) => onFileAddUnlink(file, true)) + getFsUtils(config).initWatcher?.(watcher) + + watcher.on('add', (file) => { + onFileAddUnlink(file, false) + }) + watcher.on('unlink', (file) => { + onFileAddUnlink(file, true) + }) ws.on('vite:invalidate', async ({ path, message }: InvalidatePayload) => { const mod = moduleGraph.urlToModuleMap.get(path) @@ -759,7 +774,13 @@ export async function _createServer( // html fallback if (config.appType === 'spa' || config.appType === 'mpa') { - middlewares.use(htmlFallbackMiddleware(root, config.appType === 'spa')) + middlewares.use( + htmlFallbackMiddleware( + root, + config.appType === 'spa', + getFsUtils(config), + ), + ) } // run post config hooks @@ -927,6 +948,8 @@ export function resolveServerOptions( strict: server.fs?.strict ?? true, allow: allowDirs, deny, + cachedChecks: + server.fs?.cachedChecks ?? !!process.env.VITE_SERVER_FS_CACHED_CHECKS, } if (server.origin?.endsWith('/')) { diff --git a/packages/vite/src/node/server/middlewares/htmlFallback.ts b/packages/vite/src/node/server/middlewares/htmlFallback.ts index 5af9a34b2df2dd..0662c413062077 100644 --- a/packages/vite/src/node/server/middlewares/htmlFallback.ts +++ b/packages/vite/src/node/server/middlewares/htmlFallback.ts @@ -1,13 +1,15 @@ -import fs from 'node:fs' import path from 'node:path' import type { Connect } from 'dep-types/connect' import { cleanUrl, createDebugger } from '../../utils' +import type { FsUtils } from '../../fsUtils' +import { commonFsUtils } from '../../fsUtils' const debug = createDebugger('vite:html-fallback') export function htmlFallbackMiddleware( root: string, spaFallback: boolean, + fsUtils: FsUtils = commonFsUtils, ): Connect.NextHandleFunction { // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` return function viteHtmlFallbackMiddleware(req, res, next) { @@ -32,7 +34,7 @@ export function htmlFallbackMiddleware( // so we need to check if the file exists if (pathname.endsWith('.html')) { const filePath = path.join(root, pathname) - if (fs.existsSync(filePath)) { + if (fsUtils.existsSync(filePath)) { debug?.(`Rewriting ${req.method} ${req.url} to ${url}`) req.url = url return next() @@ -41,7 +43,7 @@ export function htmlFallbackMiddleware( // trailing slash should check for fallback index.html else if (pathname[pathname.length - 1] === '/') { const filePath = path.join(root, pathname, 'index.html') - if (fs.existsSync(filePath)) { + if (fsUtils.existsSync(filePath)) { const newUrl = url + 'index.html' debug?.(`Rewriting ${req.method} ${req.url} to ${newUrl}`) req.url = newUrl @@ -51,7 +53,7 @@ export function htmlFallbackMiddleware( // non-trailing slash should check for fallback .html else { const filePath = path.join(root, pathname + '.html') - if (fs.existsSync(filePath)) { + if (fsUtils.existsSync(filePath)) { const newUrl = url + '.html' debug?.(`Rewriting ${req.method} ${req.url} to ${newUrl}`) req.url = newUrl diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index 7aa2ba7aaa401e..4ebc2a41e86bfe 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs' import fsp from 'node:fs/promises' import path from 'node:path' import MagicString from 'magic-string' @@ -41,6 +40,7 @@ import { unwrapId, wrapId, } from '../../utils' +import { getFsUtils } from '../../fsUtils' import { checkPublicFile } from '../../publicDir' import { isCSSRequest } from '../../plugins/css' import { getCodeWithSourcemap, injectSourcesContent } from '../sourcemap' @@ -189,7 +189,7 @@ const devHtmlHook: IndexHtmlTransformHook = async ( let proxyModuleUrl: string const trailingSlash = htmlPath.endsWith('/') - if (!trailingSlash && fs.existsSync(filename)) { + if (!trailingSlash && getFsUtils(config).existsSync(filename)) { proxyModulePath = htmlPath proxyModuleUrl = joinUrlSegments(base, htmlPath) } else { @@ -407,6 +407,7 @@ export function indexHtmlMiddleware( server: ViteDevServer | PreviewServer, ): Connect.NextHandleFunction { const isDev = isDevServer(server) + const fsUtils = getFsUtils(server.config) // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` return async function viteIndexHtmlMiddleware(req, res, next) { @@ -424,7 +425,7 @@ export function indexHtmlMiddleware( filePath = path.join(root, decodeURIComponent(url)) } - if (fs.existsSync(filePath)) { + if (fsUtils.existsSync(filePath)) { const headers = isDev ? server.config.server.headers : server.config.preview.headers diff --git a/playground/html/vite.config.js b/playground/html/vite.config.js index 625a990e531891..a7f3964cb165bc 100644 --- a/playground/html/vite.config.js +++ b/playground/html/vite.config.js @@ -44,6 +44,9 @@ export default defineConfig({ }, server: { + fs: { + cachedChecks: true, + }, warmup: { clientFiles: ['./warmup/*'], },