diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 0019cc0ddebe2a..23494b42356c1d 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -25,6 +25,7 @@ import { createDebugger, createFilter, dynamicImport, + getCodeHash, isBuiltin, isExternalUrl, isObject, @@ -64,6 +65,7 @@ import type { PackageCache } from './packages' import { loadEnv, resolveEnvPrefix } from './env' import type { ResolvedSSROptions, SSROptions } from './ssr' import { resolveSSROptions } from './ssr' +import { resolvePersistentCacheOptions } from './server/persistentCache' const debug = createDebugger('vite:config') @@ -292,6 +294,52 @@ export interface ExperimentalOptions { * @default false */ hmrPartialAccept?: boolean + /** + * Enables persistent cache for the development server. + * + * @experimental + * @default null + */ + serverPersistentCache?: ServerPersistentCacheOptions | true +} + +export interface ServerPersistentCacheOptions { + /** + * Enable or disable persistent cache. + */ + enabled?: boolean + /** + * Paths to files that should be taken into account when determining whether to clear the cache. + * By default will use the vite config file and your package lock file (for npm, yarn or pnpm). + */ + cacheVersionFromFiles?: string[] + /** + * Manual version string that should be taken into account when determining whether to clear the cache. + * Will be added to the hash of `cacheVersionFromFiles`. + */ + cacheVersion?: string + /** + * Exclude requests from being cached. + */ + exclude?: (url: string) => boolean + /** + * Name of the cache directory. + * If you have multiple vite servers running (e.g. Nuxt), you can use this to differentiate them. + * @default 'server-cache' + */ + cacheDir?: string +} + +export interface ResolvedExperimentalOptions + extends Omit { + serverPersistentCache: ResolvedServerPersistentCacheOptions | null +} + +export interface ResolvedServerPersistentCacheOptions { + cacheDir: string + cacheVersionFromFiles: string[] + cacheVersion: string + exclude?: (url: string) => boolean } export interface LegacyOptions { @@ -317,8 +365,12 @@ export interface InlineConfig extends UserConfig { } export type ResolvedConfig = Readonly< - Omit & { + Omit< + UserConfig, + 'plugins' | 'assetsInclude' | 'optimizeDeps' | 'worker' | 'experimental' + > & { configFile: string | undefined + configFileHash: string | undefined configFileDependencies: string[] inlineConfig: InlineConfig root: string @@ -349,7 +401,7 @@ export type ResolvedConfig = Readonly< packageCache: PackageCache worker: ResolveWorkerOptions appType: AppType - experimental: ExperimentalOptions + experimental: ResolvedExperimentalOptions } & PluginHookUtils > @@ -373,6 +425,7 @@ export async function resolveConfig( defaultMode = 'development' ): Promise { let config = inlineConfig + let configFileHash: string | undefined let configFileDependencies: string[] = [] let mode = inlineConfig.mode || defaultMode @@ -404,6 +457,7 @@ export async function resolveConfig( if (loadResult) { config = mergeConfig(loadResult.config, config) configFile = loadResult.path + configFileHash = loadResult.hash configFileDependencies = loadResult.dependencies } } @@ -616,8 +670,15 @@ export async function resolveConfig( getSortedPluginHooks: undefined! } + const serverPersistentCache = resolvePersistentCacheOptions({ + config, + cacheDir, + resolvedRoot + }) + const resolvedConfig: ResolvedConfig = { configFile: configFile ? normalizePath(configFile) : undefined, + configFileHash, configFileDependencies: configFileDependencies.map((name) => normalizePath(path.resolve(name)) ), @@ -663,7 +724,8 @@ export async function resolveConfig( experimental: { importGlobRestoreExtension: false, hmrPartialAccept: false, - ...config.experimental + ...config.experimental, + serverPersistentCache }, getSortedPlugins: undefined!, getSortedPluginHooks: undefined! @@ -865,6 +927,7 @@ export async function loadConfigFromFile( path: string config: UserConfig dependencies: string[] + hash: string } | null> { const start = performance.now() const getTime = () => `${(performance.now() - start).toFixed(2)}ms` @@ -922,7 +985,8 @@ export async function loadConfigFromFile( return { path: normalizePath(resolvedPath), config, - dependencies: bundled.dependencies + dependencies: bundled.dependencies, + hash: getCodeHash(bundled.code) } } catch (e) { createLogger(logLevel).error( diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index f0d1276ecbc604..9d9cb3b6be59b9 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -95,6 +95,15 @@ export type { TransformResult } from './server/transformRequest' export type { HmrOptions, HmrContext } from './server/hmr' +export type { + PersistentCache, + PersistentCacheEntry, + PersistentSimpleCacheEntry, + PersistentFullCacheEntry, + PersistentCacheFile, + PersistentCacheManifest, + PersistentCacheResult +} from './server/persistentCache' export type { HMRPayload, diff --git a/packages/vite/src/node/optimizer/index.ts b/packages/vite/src/node/optimizer/index.ts index 94447b9403393b..3cd571169b07ae 100644 --- a/packages/vite/src/node/optimizer/index.ts +++ b/packages/vite/src/node/optimizer/index.ts @@ -25,6 +25,7 @@ import { } from '../utils' import { transformWithEsbuild } from '../plugins/esbuild' import { ESBUILD_MODULES_TARGET } from '../constants' +import type { ViteDevServer } from '../index' import { esbuildCjsExternalPlugin, esbuildDepPlugin } from './esbuildDepPlugin' import { scanImports } from './scan' export { @@ -450,7 +451,8 @@ export async function runOptimizeDeps( resolvedConfig: ResolvedConfig, depsInfo: Record, ssr: boolean = resolvedConfig.command === 'build' && - !!resolvedConfig.build.ssr + !!resolvedConfig.build.ssr, + server?: ViteDevServer ): Promise { const isBuild = resolvedConfig.command === 'build' const config: ResolvedConfig = { @@ -671,6 +673,10 @@ export async function runOptimizeDeps( const dataPath = path.join(processingCacheDir, '_metadata.json') writeFile(dataPath, stringifyDepsOptimizerMetadata(metadata, depsCacheDir)) + if (server?._persistentCache) { + await server._persistentCache.updateDepsMetadata(metadata) + } + debug(`deps bundled in ${(performance.now() - start).toFixed(2)}ms`) return processingResult diff --git a/packages/vite/src/node/optimizer/optimizer.ts b/packages/vite/src/node/optimizer/optimizer.ts index 37061cdaf0c6f5..7edb030626ba6b 100644 --- a/packages/vite/src/node/optimizer/optimizer.ts +++ b/packages/vite/src/node/optimizer/optimizer.ts @@ -230,7 +230,12 @@ async function createDepsOptimizer( // run on the background, but we wait until crawling has ended // to decide if we send this result to the browser or we need to // do another optimize step - postScanOptimizationResult = runOptimizeDeps(config, knownDeps) + postScanOptimizationResult = runOptimizeDeps( + config, + knownDeps, + undefined, + server + ) } } catch (e) { logger.error(e.message) @@ -271,7 +276,7 @@ async function createDepsOptimizer( startNextDiscoveredBatch() - return await runOptimizeDeps(config, knownDeps) + return await runOptimizeDeps(config, knownDeps, undefined, server) } function prepareKnownDeps() { diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 7ea1d846183bb5..2311da30b2934d 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -79,6 +79,8 @@ import { openBrowser } from './openBrowser' import type { TransformOptions, TransformResult } from './transformRequest' import { transformRequest } from './transformRequest' import { searchForWorkspaceRoot } from './searchRoot' +import type { PersistentCache } from './persistentCache' +import { createPersistentCache } from './persistentCache' export { searchForWorkspaceRoot } from './searchRoot' @@ -301,6 +303,10 @@ export interface ViteDevServer { * @internal */ _fsDenyGlob: Matcher + /** + * @internal + */ + _persistentCache: PersistentCache | null } export interface ResolvedServerUrls { @@ -452,7 +458,8 @@ export async function createServer( _importGlobMap: new Map(), _forceOptimizeOnRestart: false, _pendingRequests: new Map(), - _fsDenyGlob: picomatch(config.server.fs.deny, { matchBase: true }) + _fsDenyGlob: picomatch(config.server.fs.deny, { matchBase: true }), + _persistentCache: await createPersistentCache(config) } server.transformIndexHtml = createDevHtmlTransformFn(server) diff --git a/packages/vite/src/node/server/persistentCache.ts b/packages/vite/src/node/server/persistentCache.ts new file mode 100644 index 00000000000000..d74cb206500f5f --- /dev/null +++ b/packages/vite/src/node/server/persistentCache.ts @@ -0,0 +1,506 @@ +import fs from 'node:fs' +import path from 'node:path' +import colors from 'picocolors' +import type { DepOptimizationMetadata } from '../index' +import type { + InlineConfig, + ResolvedConfig, + ResolvedServerPersistentCacheOptions +} from '../config' +import { normalizePath, version } from '../publicUtils' +import { createDebugger, getCodeHash, isDefined, lookupFile } from '../utils' +import type { Logger } from '../logger' +import type { ModuleNode } from './moduleGraph' + +export interface PersistentCache { + manifest: PersistentCacheManifest + getKey: (code: string, ssr: boolean) => string + read: (key: string) => Promise + write: ( + key: string, + id: string, + url: string | undefined, + mod: ModuleNode | null, + ssr: boolean, + file: string, + code: string, + map?: any + ) => Promise + queueManifestWrite: () => void + updateDepsMetadata: (metadata: DepOptimizationMetadata) => Promise +} + +export interface PersistentCacheManifest { + version: string + modules: Record + files: Record +} + +export interface PersistentSimpleCacheEntry { + id: string + url?: string + file: string + fileCode: string + fileMap?: string +} + +export interface PersistentFullCacheEntry extends PersistentSimpleCacheEntry { + importedModules: { id: string; file: string; url: string }[] + importedBindings: Record + acceptedHmrDeps: string[] + acceptedHmrExports: string[] + isSelfAccepting?: boolean + ssr: boolean +} + +export type PersistentCacheEntry = + | PersistentSimpleCacheEntry + | PersistentFullCacheEntry + +export function isFullCacheEntry( + entry: PersistentCacheEntry +): entry is PersistentFullCacheEntry { + return Array.isArray((entry as PersistentFullCacheEntry).importedModules) +} + +export interface PersistentCacheResult { + code: string + map?: any +} + +export interface PersistentCacheFile { + relatedModules: Record +} + +const debugLog = createDebugger('vite:persistent-cache') + +export async function createPersistentCache( + config: ResolvedConfig +): Promise { + const { + logger, + experimental: { serverPersistentCache: options } + } = config + + if (!options) { + return null + } + + // Cache directory + + const resolvedCacheDir = normalizePath(path.resolve(options.cacheDir)) + + if (!fs.existsSync(resolvedCacheDir)) { + fs.mkdirSync(resolvedCacheDir, { recursive: true }) + } + + // Cache version + + const cacheVersion = await computeCacheVersion(config, options) + + // Manifest + + const { manifest, queueManifestWrite } = await useCacheManifest( + resolvedCacheDir, + cacheVersion, + logger + ) + + /** + * This is used to skip read for modules that were patched by the cache for future warm restarts + * in case the same module is red from persistent cache again during the same session. + * For example: rewriting optimized deps imports should be "reverted" for the current session + * as they will be incorrect otherwise (vite keeps the version query stable until next restart). + */ + const patchedDuringCurrentSession = new Set() + + // Main methods + + function getKey(code: string, ssr: boolean) { + return getCodeHash(code) + (ssr ? '-ssr' : '') + } + + async function read(key: string): Promise { + const entry = manifest.modules[key] + if (!entry) { + return null + } + + if (patchedDuringCurrentSession.has(key)) { + return null + } + + try { + debugLog(`read ${key} from ${entry.fileCode}`) + const code = await fs.promises.readFile(entry.fileCode, 'utf8') + const map = entry.fileMap + ? JSON.parse(await fs.promises.readFile(entry.fileMap, 'utf8')) + : undefined + + return { + code, + map + } + } catch (e) { + logger.warn( + colors.yellow( + `Failed to read persistent cache entry '${key}' (${entry.file}): ${e.message}` + ) + ) + return null + } + } + + async function write( + key: string, + id: string, + url: string | undefined, + mod: ModuleNode | null, + ssr: boolean, + file: string, + code: string, + map?: any + ) { + try { + const fileCode = path.resolve(resolvedCacheDir, 'c-' + key) + const fileMap = map ? fileCode + '-map' : undefined + debugLog(`write ${key} to ${fileCode}`) + + let wasPatched = false + + // Rewrite optimized deps imports using the final browserHash + // The version query will change after first time they are optimized + // (They are not updated during first run to keep urls stable) + if (depsMetadata && mod) { + for (const m of mod.importedModules) { + if (m.file) { + for (const depId in depsMetadata.optimized) { + const dep = depsMetadata.optimized[depId] + if (dep.file === m.file) { + code = code.replaceAll( + m.url, + m.url.replace(/v=[\w\d]+/, `v=${depsMetadata.browserHash}`) + ) + wasPatched = true + break + } + } + } + } + } + + // Create cache entry + + const entry: PersistentCacheEntry = { + id, + url, + file, + fileCode, + fileMap, + ssr + } + + if (mod) { + const fullEntry = entry as PersistentFullCacheEntry + fullEntry.importedModules = Array.from(mod.importedModules) + .filter((m) => !!m.id && !!m.file) + .map((m) => ({ + id: m.id!, + url: m.url, + file: m.file! + })) + const importedBindings: any = {} + if (mod.importedBindings) { + for (const k in mod.importedBindings) { + const s = mod.importedBindings.get(k) + if (s) { + importedBindings[k] = Array.from(s) + } + } + } + fullEntry.importedBindings = importedBindings + + fullEntry.acceptedHmrDeps = Array.from(mod.acceptedHmrDeps) + .map((m) => m.url) + .filter(isDefined) + + fullEntry.acceptedHmrExports = mod.acceptedHmrExports + ? (Array.from(mod.acceptedHmrExports).filter(Boolean) as string[]) + : [] + + fullEntry.isSelfAccepting = mod.isSelfAccepting + } + + manifest.modules[key] = entry + + queueManifestWrite() + + // Write files + + if (wasPatched) { + patchedDuringCurrentSession.add(key) + } else { + patchedDuringCurrentSession.delete(key) + } + + await fs.promises.writeFile(fileCode, code, 'utf8') + if (map && fileMap) { + await fs.promises.writeFile(fileMap, JSON.stringify(map), 'utf8') + } + } catch (e) { + logger.warn( + colors.yellow( + `Failed to write persistent cache entry '${key}' (${file}): ${e.message}` + ) + ) + } + } + + // Optimized deps + + let depsMetadata: DepOptimizationMetadata | null = null + + async function updateDepsMetadata(metadata: DepOptimizationMetadata) { + depsMetadata = metadata + + // Update existing cache files + await Promise.all( + Object.keys(manifest.modules).map(async (key) => { + const entry = manifest.modules[key] + if (entry && isFullCacheEntry(entry)) { + // Gather code changes + const optimizedDeps: [string, string][] = [] + for (const m of entry.importedModules) { + for (const depId in metadata.optimized) { + const dep = metadata.optimized[depId] + if (dep.file === m.file) { + optimizedDeps.push([ + m.url, + m.url.replace(/v=[\w\d]+/, `v=${metadata.browserHash}`) + ]) + break + } + } + } + // Apply code changes + if (optimizedDeps.length) { + let code = await fs.promises.readFile(entry.fileCode, 'utf8') + patchedDuringCurrentSession.add(key) + for (const [from, to] of optimizedDeps) { + code = code.replaceAll(from, to) + } + await fs.promises.writeFile(entry.fileCode, code, 'utf8') + debugLog( + `Updated ${ + entry.id + } with new optimized deps imports: ${optimizedDeps + .map(([from, to]) => `${from} -> ${to}`) + .join(', ')}` + ) + } + } + }) + ) + } + + return { + manifest, + getKey, + read, + write, + queueManifestWrite, + updateDepsMetadata + } +} + +async function computeCacheVersion( + config: ResolvedConfig, + options: ResolvedServerPersistentCacheOptions +): Promise { + const hashedVersionFiles = await Promise.all( + options.cacheVersionFromFiles.map((file) => { + if (!fs.existsSync(file)) { + throw new Error(`Persistent cache version file not found: ${file}`) + } + return fs.promises.readFile(file, 'utf-8') + }) + ).then((codes) => getCodeHash(codes.join(''))) + + const defineHash = config.define + ? getCodeHash(JSON.stringify(config.define)) + : '' + + const envHash = getCodeHash(JSON.stringify(config.env)) + + const cacheVersion = [ + options.cacheVersion, + `vite:${version}`, + config.configFileHash, + hashedVersionFiles, + defineHash, + envHash + ] + .filter(Boolean) + .join('-') + + return cacheVersion +} + +async function useCacheManifest( + resolvedCacheDir: string, + cacheVersion: string, + logger: Logger +) { + const manifestPath = path.join(resolvedCacheDir, 'manifest.json') + let manifest: PersistentCacheManifest | null = null + if (fs.existsSync(manifestPath)) { + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) + if (manifest && manifest.version !== cacheVersion) { + // Bust cache if version changed + debugLog( + `Clearing persistent cache (${cacheVersion} from ${manifest.version})...` + ) + try { + // Empty the directory + const files = await fs.promises.readdir(resolvedCacheDir) + await Promise.all( + files.map((file) => + fs.promises.unlink(path.join(resolvedCacheDir, file)) + ) + ) + logger.info(`Deleted ${files.length} files.`) + } catch (e) { + logger.warn( + colors.yellow( + `Failed to empty persistent cache directory '${resolvedCacheDir}': ${e.message}` + ) + ) + } + manifest = null + } + } catch (e) { + logger.warn( + colors.yellow( + `Failed to load persistent cache manifest '${manifestPath}': ${e.message}` + ) + ) + } + } + const resolvedManifest: PersistentCacheManifest = manifest ?? { + version: cacheVersion, + modules: {}, + files: {} + } + + // Manifest write queue + + let isManifestWriteQueued = false + let isManifestWriting = false + let manifestWriteTimer: any = null + + function queueManifestWrite() { + if (isManifestWriteQueued) { + return + } + isManifestWriteQueued = true + if (isManifestWriting) { + return + } + + writeManifest() + } + + function writeManifest() { + clearTimeout(manifestWriteTimer) + manifestWriteTimer = setTimeout(async () => { + isManifestWriting = true + try { + await fs.promises.writeFile( + manifestPath, + JSON.stringify(resolvedManifest, null, 2) + ) + debugLog(`Persistent cache manifest saved`) + } catch (e) { + logger.warn( + colors.yellow( + `Failed to write persistent cache manifest '${manifestPath}': ${e.message}` + ) + ) + } + isManifestWriting = false + + if (isManifestWriteQueued) { + isManifestWriteQueued = false + writeManifest() + } + }, 1000) + } + + return { + manifest: resolvedManifest, + queueManifestWrite + } +} + +interface ResolveServerPersistentCacheConfigPayload { + config: InlineConfig + cacheDir: string + resolvedRoot: string +} + +export function resolvePersistentCacheOptions( + payload: ResolveServerPersistentCacheConfigPayload +): ResolvedServerPersistentCacheOptions | null { + const { config, resolvedRoot } = payload + + if ( + !config.experimental?.serverPersistentCache || + (typeof config.experimental?.serverPersistentCache === 'object' && + config.experimental.serverPersistentCache?.enabled === false) + ) { + return null + } + + const castedToObject = + typeof config.experimental?.serverPersistentCache === 'object' + ? config.experimental.serverPersistentCache + : null + const cacheDir = path.join( + payload.cacheDir, + castedToObject?.cacheDir ?? `server-cache` + ) + + const cacheVersionFromFiles: string[] = ( + castedToObject?.cacheVersionFromFiles ?? [] + ).map((file) => path.join(resolvedRoot, file)) + + const packageLockFile = lookupFile( + resolvedRoot, + [ + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'bun.lockb', + 'npm-shrinkwrap.json' + ], + { pathOnly: true } + ) + if (packageLockFile) { + cacheVersionFromFiles.push(packageLockFile) + } + + const tsconfigFile = lookupFile(resolvedRoot, ['tsconfig.json'], { + pathOnly: true + }) + if (tsconfigFile) { + cacheVersionFromFiles.push(tsconfigFile) + } + + return { + cacheDir, + cacheVersionFromFiles, + cacheVersion: castedToObject?.cacheVersion ?? '', + exclude: castedToObject?.exclude + } +} diff --git a/packages/vite/src/node/server/transformRequest.ts b/packages/vite/src/node/server/transformRequest.ts index f2b78516e63670..b203c3542d0d56 100644 --- a/packages/vite/src/node/server/transformRequest.ts +++ b/packages/vite/src/node/server/transformRequest.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { performance } from 'node:perf_hooks' import getEtag from 'etag' import convertSourceMap from 'convert-source-map' -import type { SourceDescription, SourceMap } from 'rollup' +import type { LoadResult, SourceDescription, SourceMap } from 'rollup' import colors from 'picocolors' import type { ViteDevServer } from '..' import { @@ -18,8 +18,10 @@ import { } from '../utils' import { checkPublicFile } from '../plugins/asset' import { getDepsOptimizer } from '../optimizer' +import { DEP_VERSION_RE } from '../constants' import { injectSourcesContent } from './sourcemap' import { isFileServingAllowed } from './middlewares/static' +import { isFullCacheEntry } from './persistentCache' const debugLoad = createDebugger('vite:load') const debugTransform = createDebugger('vite:transform') @@ -157,7 +159,8 @@ async function loadAndTransform( options: TransformOptions, timestamp: number ) { - const { config, pluginContainer, moduleGraph, watcher } = server + const { config, pluginContainer, moduleGraph, watcher, _persistentCache } = + server const { root, logger } = config const prettyUrl = isDebug ? prettifyUrl(url, config.root) : '' const ssr = !!options.ssr @@ -169,7 +172,61 @@ async function loadAndTransform( // load const loadStart = isDebug ? performance.now() : 0 - const loadResult = await pluginContainer.load(id, { ssr }) + let loadResult: LoadResult + + const loadCacheKey = id.replace(DEP_VERSION_RE, '') + + loadResult = await pluginContainer.load(id, { ssr }) + + const includedInPersistentCache = + _persistentCache && + !file.includes(server.config.cacheDir) && + !file.includes('vite/dist/client') && + (!server.config.experimental.serverPersistentCache?.exclude || + !server.config.experimental.serverPersistentCache.exclude(url)) + + // Persist load result just in case it depends on a previous `transform` call + // that got cached (aka skipped) + // For example: svelte component CSS subrequest + // - `transform` is called on `MyComponent.svelte` => saves `