feat(devkit): allow requiring cts config files (#31103)

When migrating our project to esm, we encountered an issue with the
playright plugin, but more generally with the `loadConfigFile` from the
devkit.
Our configuration is a `.cts` file, but it's not treated as commonjs:
`__dirname` and `__filename` are not available.

![CleanShot 2025-05-07 at 15 31
04@2x](https://github.com/user-attachments/assets/e8299b4e-153b-4cb4-98b7-d806e537ab12)

## Expected Behavior
`.cts` files are interpreted as commonJS files when in a module context.

---------

Co-authored-by: Jack Hsu <jack.hsu@gmail.com>
This commit is contained in:
Nicolas Marien 2025-05-16 17:58:34 +02:00 committed by GitHub
parent 1709b107bb
commit bd88b0efe4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -1,8 +1,8 @@
import { dirname, extname, join, sep } from 'path';
import { existsSync, readdirSync } from 'fs'; import { existsSync, readdirSync } from 'fs';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { workspaceRoot } from 'nx/src/devkit-exports'; import { workspaceRoot } from 'nx/src/devkit-exports';
import { registerTsProject } from 'nx/src/devkit-internals'; import { registerTsProject } from 'nx/src/devkit-internals';
import { dirname, extname, join, sep } from 'path';
export let dynamicImport = new Function( export let dynamicImport = new Function(
'modulePath', 'modulePath',
@ -12,28 +12,68 @@ export let dynamicImport = new Function(
export async function loadConfigFile<T extends object = any>( export async function loadConfigFile<T extends object = any>(
configFilePath: string configFilePath: string
): Promise<T> { ): Promise<T> {
{ const extension = extname(configFilePath);
let module: any; const module = await loadModule(configFilePath, extension);
return module.default ?? module;
}
if (extname(configFilePath) === '.ts') { async function loadModule(path: string, extension: string): Promise<any> {
const siblingFiles = readdirSync(dirname(configFilePath)); if (isTypeScriptFile(extension)) {
const tsConfigPath = siblingFiles.includes('tsconfig.json') return await loadTypeScriptModule(path, extension);
? join(dirname(configFilePath), 'tsconfig.json') }
: getRootTsConfigPath(); return await loadJavaScriptModule(path, extension);
if (tsConfigPath) { }
const unregisterTsProject = registerTsProject(tsConfigPath);
try { function isTypeScriptFile(extension: string): boolean {
module = await load(configFilePath); return extension.endsWith('ts');
} finally { }
unregisterTsProject();
} async function loadTypeScriptModule(
} else { path: string,
module = await load(configFilePath); extension: string
} ): Promise<any> {
} else { const tsConfigPath = getTypeScriptConfigPath(path);
module = await load(configFilePath);
if (tsConfigPath) {
const unregisterTsProject = registerTsProject(tsConfigPath);
try {
return await loadModuleByExtension(path, extension);
} finally {
unregisterTsProject();
} }
return module.default ?? module; }
return await loadModuleByExtension(path, extension);
}
function getTypeScriptConfigPath(path: string): string | null {
const siblingFiles = readdirSync(dirname(path));
return siblingFiles.includes('tsconfig.json')
? join(dirname(path), 'tsconfig.json')
: getRootTsConfigPath();
}
async function loadJavaScriptModule(
path: string,
extension: string
): Promise<any> {
return await loadModuleByExtension(path, extension);
}
async function loadModuleByExtension(
path: string,
extension: string
): Promise<any> {
switch (extension) {
case '.cts':
case '.cjs':
return await loadCommonJS(path);
case '.mjs':
return await loadESM(path);
default:
// For both .ts and .mts files, try to load them as CommonJS first, then try ESM.
// It's possible that the file is written like ESM (e.g. using `import`) but uses CJS features like `__dirname` or `__filename`.
return await load(path);
} }
} }
@ -66,27 +106,34 @@ export function clearRequireCache(): void {
} }
} }
/**
* Load the module after ensuring that the require cache is cleared.
*/
async function load(path: string): Promise<any> { async function load(path: string): Promise<any> {
// Clear cache if the path is in the cache
if (require.cache[path]) {
clearRequireCache();
}
try { try {
// Try using `require` first, which works for CJS modules. // Try using `require` first, which works for CJS modules.
// Modules are CJS unless it is named `.mjs` or `package.json` sets type to "module". // Modules are CJS unless it is named `.mjs` or `package.json` sets type to "module".
return require(path); return loadCommonJS(path);
} catch (e: any) { } catch (e: any) {
if (e.code === 'ERR_REQUIRE_ESM') { if (e.code === 'ERR_REQUIRE_ESM') {
// If `require` fails to load ESM, try dynamic `import()`. ESM requires file url protocol for handling absolute paths. // If `require` fails to load ESM, try dynamic `import()`. ESM requires file url protocol for handling absolute paths.
const pathAsFileUrl = pathToFileURL(path).pathname; return loadESM(path);
return await dynamicImport(`${pathAsFileUrl}?t=${Date.now()}`);
} }
// Re-throw all other errors // Re-throw all other errors
throw e; throw e;
} }
} }
/**
* Load the module after ensuring that the require cache is cleared.
*/
async function loadCommonJS(path: string): Promise<any> {
// Clear cache if the path is in the cache
if (require.cache[path]) {
clearRequireCache();
}
return require(path);
}
async function loadESM(path: string): Promise<any> {
const pathAsFileUrl = pathToFileURL(path).pathname;
return await dynamicImport(`${pathAsFileUrl}?t=${Date.now()}`);
}