diff --git a/packages/vite/src/node/__tests__/config.spec.ts b/packages/vite/src/node/__tests__/config.spec.ts index a6b466e8853b47..94985837a9eb26 100644 --- a/packages/vite/src/node/__tests__/config.spec.ts +++ b/packages/vite/src/node/__tests__/config.spec.ts @@ -803,6 +803,42 @@ describe('loadConfigFromFile', () => { `) }) + test('loadConfigFromFile with import.meta.resolve', async () => { + const result = await loadConfigFromFile( + { command: 'build', mode: 'production' }, + path.resolve(fixtures, './import-meta-resolve/vite.config.mjs'), + path.resolve(fixtures, './import-meta-resolve'), + ) + expect(result).toBeTruthy() + expect(result?.config).toHaveProperty('define') + expect(result?.config.define).toHaveProperty('IMPORT_META_URL') + expect(result?.config.define).toHaveProperty('IMPORT_META_RESOLVE_TEST') + expect(typeof result?.config.define.IMPORT_META_RESOLVE_TEST).toBe('string') + expect(result?.config.define.IMPORT_META_RESOLVE_TEST).toMatch( + /test-module\.js$/, + ) + }) + + test('loadConfigFromFile with import.meta.resolve advanced cases', async () => { + const result = await loadConfigFromFile( + { command: 'build', mode: 'production' }, + path.resolve(fixtures, './import-meta-resolve/vite.advanced.mjs'), + path.resolve(fixtures, './import-meta-resolve'), + ) + expect(result).toBeTruthy() + expect(result?.config).toHaveProperty('define') + + // Check all resolved paths + expect(result?.config.define.RESOLVED_MODULE).toMatch(/test-module\.js$/) + expect(result?.config.define.RESOLVED_WITH_EXT).toMatch(/test-module\.js$/) + expect(result?.config.define.BASE_URL).toMatch(/vite\.advanced\.mjs$/) + + // Both should resolve to the same file + expect(result?.config.define.RESOLVED_MODULE).toBe( + result?.config.define.RESOLVED_WITH_EXT, + ) + }) + describe('loadConfigFromFile with configLoader: native', () => { const fixtureRoot = path.resolve(fixtures, './native-import') diff --git a/packages/vite/src/node/__tests__/fixtures/config/import-meta-resolve/test-module.js b/packages/vite/src/node/__tests__/fixtures/config/import-meta-resolve/test-module.js new file mode 100644 index 00000000000000..5f8e35bf2287d5 --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/config/import-meta-resolve/test-module.js @@ -0,0 +1,2 @@ +// Test module for import.meta.resolve +export const testValue = 'test-module-value' diff --git a/packages/vite/src/node/__tests__/fixtures/config/import-meta-resolve/vite.advanced.mjs b/packages/vite/src/node/__tests__/fixtures/config/import-meta-resolve/vite.advanced.mjs new file mode 100644 index 00000000000000..88d81dd3513531 --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/config/import-meta-resolve/vite.advanced.mjs @@ -0,0 +1,11 @@ +// Test config that uses import.meta.resolve with different cases +export default { + define: { + // Basic usage + RESOLVED_MODULE: import.meta.resolve('./test-module'), + // With explicit extension + RESOLVED_WITH_EXT: import.meta.resolve('./test-module.js'), + // URL context + BASE_URL: import.meta.url, + }, +} diff --git a/packages/vite/src/node/__tests__/fixtures/config/import-meta-resolve/vite.config.mjs b/packages/vite/src/node/__tests__/fixtures/config/import-meta-resolve/vite.config.mjs new file mode 100644 index 00000000000000..0de602f98dd9e8 --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/config/import-meta-resolve/vite.config.mjs @@ -0,0 +1,7 @@ +// Test config that uses import.meta.resolve +export default { + define: { + IMPORT_META_URL: import.meta.url, + IMPORT_META_RESOLVE_TEST: import.meta.resolve('./test-module'), + }, +} diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 92b148fe8cc58a..0bcb0f25ea4037 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -12,6 +12,7 @@ import type { PluginContextMeta, RollupOptions } from 'rollup' import picomatch from 'picomatch' import { build } from 'esbuild' import type { AnymatchFn } from '../types/anymatch' +import { createImportMetaResolver } from '../module-runner/importMetaResolver' import { withTrailingSlash } from '../shared/utils' import { CLIENT_ENTRY, @@ -1922,6 +1923,8 @@ async function bundleConfigFile( const dirnameVarName = '__vite_injected_original_dirname' const filenameVarName = '__vite_injected_original_filename' const importMetaUrlVarName = '__vite_injected_original_import_meta_url' + const importMetaResolveVarName = + '__vite_injected_original_import_meta_resolve' const result = await build({ absWorkingDir: process.cwd(), entryPoints: [fileName], @@ -1941,6 +1944,7 @@ async function bundleConfigFile( 'import.meta.url': importMetaUrlVarName, 'import.meta.dirname': dirnameVarName, 'import.meta.filename': filenameVarName, + 'import.meta.resolve': importMetaResolveVarName, }, plugins: [ { @@ -2034,14 +2038,21 @@ async function bundleConfigFile( setup(build) { build.onLoad({ filter: /\.[cm]?[jt]s$/ }, async (args) => { const contents = await fsp.readFile(args.path, 'utf-8') + const fileUrl = pathToFileURL(args.path).href + const injectValues = `const ${dirnameVarName} = ${JSON.stringify( path.dirname(args.path), )};` + `const ${filenameVarName} = ${JSON.stringify(args.path)};` + - `const ${importMetaUrlVarName} = ${JSON.stringify( - pathToFileURL(args.path).href, - )};` + `const ${importMetaUrlVarName} = ${JSON.stringify(fileUrl)};` + + `const ${importMetaResolveVarName} = (function(specifier, parent) { + // Use Node.js built-in import.meta.resolve when available + if (typeof globalThis !== 'undefined' && globalThis.__vite_import_meta_resolve_impl) { + return globalThis.__vite_import_meta_resolve_impl(specifier, parent || ${JSON.stringify(fileUrl)}); + } + throw new Error('[config] "import.meta.resolve" is not supported.'); + });` return { loader: args.path.endsWith('ts') ? 'ts' : 'js', @@ -2064,72 +2075,143 @@ interface NodeModuleWithCompile extends NodeModule { } const _require = createRequire(/** #__KEEP__ */ import.meta.url) + +// Set up import.meta.resolve support for config loading +let importMetaResolverSetup: Promise | undefined +async function setupImportMetaResolverForConfig() { + if (!importMetaResolverSetup) { + importMetaResolverSetup = (async () => { + try { + await createImportMetaResolver() + } catch { + // Ignore errors - import.meta.resolve may not be available + } + })() + } + return importMetaResolverSetup +} + async function loadConfigFromBundledFile( fileName: string, bundledCode: string, isESM: boolean, ): Promise { - // for esm, before we can register loaders without requiring users to run node - // with --experimental-loader themselves, we have to do a hack here: - // write it to disk, load it with native Node ESM, then delete the file. - if (isESM) { - // Storing the bundled file in node_modules/ is avoided for Deno - // because Deno only supports Node.js style modules under node_modules/ - // and configs with `npm:` import statements will fail when executed. - let nodeModulesDir = - typeof process.versions.deno === 'string' - ? undefined - : findNearestNodeModules(path.dirname(fileName)) - if (nodeModulesDir) { - try { - await fsp.mkdir(path.resolve(nodeModulesDir, '.vite-temp/'), { - recursive: true, - }) - } catch (e) { - if (e.code === 'EACCES') { - // If there is no access permission, a temporary configuration file is created by default. - nodeModulesDir = undefined - } else { - throw e + // Set up import.meta.resolve support + await setupImportMetaResolverForConfig() + + // Create simpler resolver implementation using Node.js built-in capabilities + const createSimpleResolver = () => { + // Try to use native import.meta.resolve if available + try { + const testResolve = eval('import.meta.resolve') + if (typeof testResolve === 'function') { + return (specifier: string, parent: string) => { + return testResolve(specifier, parent) } } + } catch { + // Fall back to require.resolve for relative paths } - const hash = `timestamp-${Date.now()}-${Math.random().toString(16).slice(2)}` - const tempFileName = nodeModulesDir - ? path.resolve( - nodeModulesDir, - `.vite-temp/${path.basename(fileName)}.${hash}.mjs`, - ) - : `${fileName}.${hash}.mjs` - await fsp.writeFile(tempFileName, bundledCode) - try { - return (await import(pathToFileURL(tempFileName).href)).default - } finally { - fs.unlink(tempFileName, () => {}) // Ignore errors + + return (specifier: string, parent: string) => { + if (specifier.startsWith('.')) { + // Handle relative imports + const parentDir = path.dirname(parent.replace(/^file:\/\//, '')) + let resolved = path.resolve(parentDir, specifier) + + // Try to add extension if not present + if (!path.extname(resolved)) { + // Try common extensions + const extensions = ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'] + for (const ext of extensions) { + const withExt = resolved + ext + if (fs.existsSync(withExt)) { + resolved = withExt + break + } + } + } + + return pathToFileURL(resolved).href + } + // For non-relative imports, just return as is or throw + throw new Error(`[config] Cannot resolve "${specifier}" from "${parent}"`) } } - // for cjs, we can register a custom loader via `_require.extensions` - else { - const extension = path.extname(fileName) - // We don't use fsp.realpath() here because it has the same behaviour as - // fs.realpath.native. On some Windows systems, it returns uppercase volume - // letters (e.g. "C:\") while the Node.js loader uses lowercase volume letters. - // See https://github.com/vitejs/vite/issues/12923 - const realFileName = await promisifiedRealpath(fileName) - const loaderExt = extension in _require.extensions ? extension : '.js' - const defaultLoader = _require.extensions[loaderExt]! - _require.extensions[loaderExt] = (module: NodeModule, filename: string) => { - if (filename === realFileName) { - ;(module as NodeModuleWithCompile)._compile(bundledCode, filename) - } else { - defaultLoader(module, filename) + + // @ts-expect-error - adding global function for config loading + globalThis.__vite_import_meta_resolve_impl = createSimpleResolver() + + try { + // for esm, before we can register loaders without requiring users to run node + // with --experimental-loader themselves, we have to do a hack here: + // write it to disk, load it with native Node ESM, then delete the file. + if (isESM) { + // Storing the bundled file in node_modules/ is avoided for Deno + // because Deno only supports Node.js style modules under node_modules/ + // and configs with `npm:` import statements will fail when executed. + let nodeModulesDir = + typeof process.versions.deno === 'string' + ? undefined + : findNearestNodeModules(path.dirname(fileName)) + if (nodeModulesDir) { + try { + await fsp.mkdir(path.resolve(nodeModulesDir, '.vite-temp/'), { + recursive: true, + }) + } catch (e) { + if (e.code === 'EACCES') { + // If there is no access permission, a temporary configuration file is created by default. + nodeModulesDir = undefined + } else { + throw e + } + } + } + const hash = `timestamp-${Date.now()}-${Math.random().toString(16).slice(2)}` + const tempFileName = nodeModulesDir + ? path.resolve( + nodeModulesDir, + `.vite-temp/${path.basename(fileName)}.${hash}.mjs`, + ) + : `${fileName}.${hash}.mjs` + await fsp.writeFile(tempFileName, bundledCode) + try { + return (await import(pathToFileURL(tempFileName).href)).default + } finally { + fs.unlink(tempFileName, () => {}) // Ignore errors + } + } + // for cjs, we can register a custom loader via `_require.extensions` + else { + const extension = path.extname(fileName) + // We don't use fsp.realpath() here because it has the same behaviour as + // fs.realpath.native. On some Windows systems, it returns uppercase volume + // letters (e.g. "C:\") while the Node.js loader uses lowercase volume letters. + // See https://github.com/vitejs/vite/issues/12923 + const realFileName = await promisifiedRealpath(fileName) + const loaderExt = extension in _require.extensions ? extension : '.js' + const defaultLoader = _require.extensions[loaderExt]! + _require.extensions[loaderExt] = ( + module: NodeModule, + filename: string, + ) => { + if (filename === realFileName) { + ;(module as NodeModuleWithCompile)._compile(bundledCode, filename) + } else { + defaultLoader(module, filename) + } } + // clear cache in case of server restart + delete _require.cache[_require.resolve(fileName)] + const raw = _require(fileName) + _require.extensions[loaderExt] = defaultLoader + return raw.__esModule ? raw.default : raw } - // clear cache in case of server restart - delete _require.cache[_require.resolve(fileName)] - const raw = _require(fileName) - _require.extensions[loaderExt] = defaultLoader - return raw.__esModule ? raw.default : raw + } finally { + // Clean up global resolver implementation + // @ts-expect-error - removing global function after config loading + delete globalThis.__vite_import_meta_resolve_impl } }