diff --git a/packages/vite/package.json b/packages/vite/package.json index 67f52892abb05d..382dd7a6d98b8f 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -46,7 +46,8 @@ "#module-sync-enabled": { "module-sync": "./misc/true.js", "default": "./misc/false.js" - } + }, + "#deps-tracker": "./dist/node/deps-tracker.js" }, "files": [ "bin", diff --git a/packages/vite/rollup.config.ts b/packages/vite/rollup.config.ts index bec0eabdd65d38..7baba895d896d1 100644 --- a/packages/vite/rollup.config.ts +++ b/packages/vite/rollup.config.ts @@ -199,6 +199,14 @@ const moduleRunnerConfig = defineConfig({ ], }) +const depsTrackerConfig = defineConfig({ + ...sharedNodeOptions, + input: { + 'deps-tracker': path.resolve(__dirname, 'src/deps-tracker/index.ts'), + }, + plugins: [...createSharedNodePlugins({}), bundleSizeLimit(2)], +}) + const cjsConfig = defineConfig({ ...sharedNodeOptions, input: { @@ -223,6 +231,7 @@ export default defineConfig([ clientConfig, nodeConfig, moduleRunnerConfig, + depsTrackerConfig, cjsConfig, ]) diff --git a/packages/vite/src/deps-tracker/index.ts b/packages/vite/src/deps-tracker/index.ts new file mode 100644 index 00000000000000..37f5274dd04b3e --- /dev/null +++ b/packages/vite/src/deps-tracker/index.ts @@ -0,0 +1,52 @@ +import type { MessagePort } from 'node:worker_threads' +import type { InitializeHook, ResolveHook } from 'node:module' + +const tQueryRE = /(?:\?|&)t=(\d+)(?:&|$)/ +const relativeImportRE = /^\.{1,2}(?:\/|\\)/ + +let port: MessagePort +let enabled = false + +export const initialize: InitializeHook = async ({ + port: _port, + time: _time, +}: { + port: MessagePort + time: string +}) => { + port = _port + port.on('message', (_enabled) => { + enabled = _enabled + }) +} + +export const resolve: ResolveHook = async (specifier, context, nextResolve) => { + const isRelativeImport = relativeImportRE.test(specifier) + const result = await nextResolve(specifier, context) + if (result.format === 'builtin' || !isRelativeImport) return result + + if ( + // when tracking is not enabled + !enabled || + // when parent does not exist (it is not a dependency of config file) + !context.parentURL || + // if the t query is already present, do not add it + tQueryRE.test(result.url) || + // if it's not a file url, no need to add the t query + !result.url.startsWith('file:') + ) + return result + + // propergate the t query + const m = tQueryRE.exec(context.parentURL) + if (m) { + // all dependencies from the config should have the t query + port.postMessage(result.url) + + result.url = result.url.replace( + /(\?)|$/, + (_n, n1) => `?t=${m[1]}${n1 === '?' ? '&' : ''}`, + ) + } + return result +} diff --git a/packages/vite/src/deps-tracker/tsconfig.json b/packages/vite/src/deps-tracker/tsconfig.json new file mode 100644 index 00000000000000..cc0936af88b5c1 --- /dev/null +++ b/packages/vite/src/deps-tracker/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["./"], + "compilerOptions": { + // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping#node-18 + "lib": ["ES2023"], + "target": "ES2022", + "skipLibCheck": true, // lib check is done on final types + "stripInternal": true + } +} diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 74e4015b689bf1..df91fd54c305b0 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -1,11 +1,12 @@ import fs from 'node:fs' import path from 'node:path' import fsp from 'node:fs/promises' -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { performance } from 'node:perf_hooks' import { createRequire } from 'node:module' import crypto from 'node:crypto' +import { MessageChannel } from 'node:worker_threads' import colors from 'picocolors' import type { Alias, AliasOptions } from 'dep-types/alias' import type { RollupOptions } from 'rollup' @@ -1771,6 +1772,14 @@ export async function loadConfigFromFile( } async function nativeImportConfigFile(resolvedPath: string) { + depsTracker ??= await createDepsTracker() + if (depsTracker) { + const { result, dependencies } = await depsTracker.collect( + () => import(pathToFileURL(resolvedPath).href + '?t=' + Date.now()), + ) + return { configExport: result.default, dependencies } + } + const module = await import( pathToFileURL(resolvedPath).href + '?t=' + Date.now() ) @@ -1780,6 +1789,70 @@ async function nativeImportConfigFile(resolvedPath: string) { } } +// rather than a singleton, it would be better to unregister the loader +// so that it doesn't incur the overhead when not needed +// but unregistering a loader is not supported +let depsTracker: DepsTracker | undefined + +type DepsTracker = { + collect: ( + cb: () => Promise, + ) => Promise<{ result: T; dependencies: string[] }> +} + +// This only tracks ESM dependencies that are statically imported (not dynamic imports) +async function createDepsTracker(): Promise { + const { Module } = await import('node:module') + // register only exists in Node.js 18.19.0+, 20.6.0+ + if (!Module.register) { + return + } + + // we want to register this loader as the last one + // this is ensured for the first config load, + // but for the subsequent config loads, a different loader may be registered. + // we hope that that doesn't happen + const { port1, port2 } = new MessageChannel() + Module.register('#deps-tracker', { + parentURL: import.meta.url, + data: { port: port2 }, + transferList: [port2], + }) + port1.unref() + + let collecting = false + return { + async collect(cb) { + if (collecting) throw new Error('already collecting') + collecting = true + + const depsList: string[] = [] + const onMessage = (e: string) => { + depsList.push(e) + } + port1.on('message', onMessage) + port1.postMessage(true) + port1.unref() + + let result: Awaited> + try { + result = await cb() + } finally { + collecting = false + port1.postMessage(false) + port1.off('message', onMessage) + } + + return { + result, + dependencies: depsList + .filter((url) => url.startsWith('file:')) + .map((url) => fileURLToPath(url)), + } + }, + } +} + async function runnerImportConfigFile(resolvedPath: string) { const { module, dependencies } = await runnerImport<{ default: UserConfigExport