From 7bf3ff991ba523a224901a4453154a755ddc796f Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Tue, 4 Feb 2025 15:17:10 +0900 Subject: [PATCH 1/3] feat: track dependencies when loading config with native --- packages/vite/package.json | 3 +- packages/vite/rollup.config.ts | 9 +++ packages/vite/src/deps-tracker/index.ts | 52 ++++++++++++++ packages/vite/src/deps-tracker/tsconfig.json | 11 +++ packages/vite/src/node/config.ts | 75 +++++++++++++++++++- 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 packages/vite/src/deps-tracker/index.ts create mode 100644 packages/vite/src/deps-tracker/tsconfig.json diff --git a/packages/vite/package.json b/packages/vite/package.json index affb225d6833ca..cca0cc46eb29a0 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 45c81fc9172aeb..84672b42de5a41 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' @@ -1777,6 +1778,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() ) @@ -1786,6 +1795,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 From 19bb049bb9cea539f05a4d8f23f9d6284135f65d Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Thu, 6 Feb 2025 19:04:21 +0900 Subject: [PATCH 2/3] fix: support loading configs concurrently --- packages/vite/src/deps-tracker/index.ts | 13 +++----- packages/vite/src/node/config.ts | 40 ++++++++++++++----------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/packages/vite/src/deps-tracker/index.ts b/packages/vite/src/deps-tracker/index.ts index 37f5274dd04b3e..c82def5728f363 100644 --- a/packages/vite/src/deps-tracker/index.ts +++ b/packages/vite/src/deps-tracker/index.ts @@ -1,11 +1,10 @@ import type { MessagePort } from 'node:worker_threads' import type { InitializeHook, ResolveHook } from 'node:module' -const tQueryRE = /(?:\?|&)t=(\d+)(?:&|$)/ +const tQueryRE = /(?:\?|&)t=(\d+),([^&]+)(?:&|$)/ const relativeImportRE = /^\.{1,2}(?:\/|\\)/ let port: MessagePort -let enabled = false export const initialize: InitializeHook = async ({ port: _port, @@ -15,9 +14,6 @@ export const initialize: InitializeHook = async ({ time: string }) => { port = _port - port.on('message', (_enabled) => { - enabled = _enabled - }) } export const resolve: ResolveHook = async (specifier, context, nextResolve) => { @@ -26,8 +22,6 @@ export const resolve: ResolveHook = async (specifier, context, nextResolve) => { 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 @@ -40,12 +34,13 @@ export const resolve: ResolveHook = async (specifier, context, nextResolve) => { // propergate the t query const m = tQueryRE.exec(context.parentURL) if (m) { + const [, time, contextFile] = m // all dependencies from the config should have the t query - port.postMessage(result.url) + port.postMessage({ context: contextFile, url: result.url }) result.url = result.url.replace( /(\?)|$/, - (_n, n1) => `?t=${m[1]}${n1 === '?' ? '&' : ''}`, + (_n, n1) => `?t=${time},${contextFile}${n1 === '?' ? '&' : ''}`, ) } return result diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 84672b42de5a41..eb72a7f044a945 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -4,7 +4,7 @@ import fsp from 'node:fs/promises' import { fileURLToPath, pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { performance } from 'node:perf_hooks' -import { createRequire } from 'node:module' +import { Module, createRequire } from 'node:module' import crypto from 'node:crypto' import { MessageChannel } from 'node:worker_threads' import colors from 'picocolors' @@ -1777,11 +1777,17 @@ export async function loadConfigFromFile( } } -async function nativeImportConfigFile(resolvedPath: string) { - depsTracker ??= await createDepsTracker() +export async function nativeImportConfigFile( + resolvedPath: string, +): Promise<{ configExport: any; dependencies: string[] }> { + depsTracker ??= createDepsTracker() if (depsTracker) { const { result, dependencies } = await depsTracker.collect( - () => import(pathToFileURL(resolvedPath).href + '?t=' + Date.now()), + resolvedPath, + () => + import( + pathToFileURL(resolvedPath).href + `?t=${Date.now()},${resolvedPath}` + ), ) return { configExport: result.default, dependencies } } @@ -1802,15 +1808,18 @@ let depsTracker: DepsTracker | undefined type DepsTracker = { collect: ( + context: string, 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') +function createDepsTracker(): DepsTracker | undefined { // register only exists in Node.js 18.19.0+, 20.6.0+ - if (!Module.register) { + // bail out if it is not supported + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const register = Module.register + if (!register) { return } @@ -1819,33 +1828,28 @@ async function createDepsTracker(): Promise { // 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', { + 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 - + async collect(context, cb) { const depsList: string[] = [] - const onMessage = (e: string) => { - depsList.push(e) + const onMessage = (e: { context: string; url: string }) => { + if (e.context === context) { + depsList.push(e.url) + } } 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) } From 271198250484a0c5ce89b017757ef905d5666605 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Thu, 6 Feb 2025 19:05:35 +0900 Subject: [PATCH 3/3] chore: update --- packages/vite/src/node/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index eb72a7f044a945..097ae4ed2f893a 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -1777,7 +1777,7 @@ export async function loadConfigFromFile( } } -export async function nativeImportConfigFile( +async function nativeImportConfigFile( resolvedPath: string, ): Promise<{ configExport: any; dependencies: string[] }> { depsTracker ??= createDepsTracker()