From 93234039c9e2ea6d967ad464379f7d83ca968a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Thu, 5 Jun 2025 20:04:36 +0200 Subject: [PATCH] fix(core): use `ts-node` option from tsconfig files when creating transpiler (#31469) ## Current Behavior When creating a `ts-node` transpiler, only `compilerOptions` are provided. Because we instruct `ts-node` to skip reading the tsconfig (this was previously done to avoid some edge cases), other options in the tsconfig files are lost (e.g. `ts-node` specific options). This was previously reported at https://github.com/nrwl/nx/issues/21695 and fixed by https://github.com/nrwl/nx/pull/21723, but a rework at a later point caused a regression. ## Expected Behavior When creating a `ts-node` transpiler, we should provide `compilerOptions` and the `ts-node` options. --- packages/nx/src/plugins/js/utils/register.ts | 79 ++++++++++++------- .../nx/src/plugins/js/utils/typescript.ts | 38 ++++----- .../src/project-graph/plugins/transpiler.ts | 23 +++--- 3 files changed, 82 insertions(+), 58 deletions(-) diff --git a/packages/nx/src/plugins/js/utils/register.ts b/packages/nx/src/plugins/js/utils/register.ts index 4d5105a04e..ba5ada15ae 100644 --- a/packages/nx/src/plugins/js/utils/register.ts +++ b/packages/nx/src/plugins/js/utils/register.ts @@ -1,7 +1,8 @@ -import { dirname, join, sep } from 'path'; +import { join, sep } from 'path'; import type { TsConfigOptions } from 'ts-node'; import type { CompilerOptions } from 'typescript'; import { logger, NX_PREFIX, stripIndent } from '../../../utils/logger'; +import { readTsConfigWithoutFiles } from './typescript'; const swcNodeInstalled = packageIsInstalled('@swc-node/register'); const tsNodeInstalled = packageIsInstalled('ts-node/register'); @@ -79,11 +80,11 @@ export function registerTsProject( } const tsConfigPath = configFilename ? join(path, configFilename) : path; - const compilerOptions: CompilerOptions = readCompilerOptions(tsConfigPath); + const { compilerOptions, tsConfigRaw } = readCompilerOptions(tsConfigPath); const cleanupFunctions: ((...args: unknown[]) => unknown)[] = [ registerTsConfigPaths(tsConfigPath), - registerTranspiler(compilerOptions), + registerTranspiler(compilerOptions, tsConfigRaw), ]; // Add ESM support for `.ts` files. @@ -128,13 +129,18 @@ export function getSwcTranspiler( } export function getTsNodeTranspiler( - compilerOptions: CompilerOptions + compilerOptions: CompilerOptions, + tsNodeOptions?: TsConfigOptions ): (...args: unknown[]) => unknown { const { register } = require('ts-node') as typeof import('ts-node'); // ts-node doesn't provide a cleanup method const service = register({ + ...tsNodeOptions, transpileOnly: true, - compilerOptions: getTsNodeCompilerOptions(compilerOptions), + compilerOptions: getTsNodeCompilerOptions({ + ...tsNodeOptions?.compilerOptions, + ...compilerOptions, + }), // we already read and provide the compiler options, so prevent ts-node from reading them again skipProject: true, }); @@ -250,24 +256,36 @@ export function getTranspiler( compilerOptions.inlineSourceMap = true; compilerOptions.skipLibCheck = true; + let _getTranspiler: ( + compilerOptions: CompilerOptions, + tsNodeOptions?: TsConfigOptions + ) => (...args: unknown[]) => unknown; + + let registrationKey = JSON.stringify(compilerOptions); + let tsNodeOptions: TsConfigOptions | undefined; + if (swcNodeInstalled && !preferTsNode) { + _getTranspiler = getSwcTranspiler; + } else if (tsNodeInstalled) { + // We can fall back on ts-node if it's available + _getTranspiler = getTsNodeTranspiler; + tsNodeOptions = filterRecognizedTsConfigTsNodeOptions( + tsConfigRaw?.['ts-node'] + ).recognized; + // include ts-node options in the registration key + registrationKey += JSON.stringify(tsNodeOptions); + } else { + _getTranspiler = undefined; + } + // Just return if transpiler was already registered before. - const registrationKey = JSON.stringify(compilerOptions); const registrationEntry = registered.get(registrationKey); if (registered.has(registrationKey)) { registrationEntry.refCount++; return registrationEntry.cleanup; } - const _getTranspiler = - swcNodeInstalled && !preferTsNode - ? getSwcTranspiler - : tsNodeInstalled - ? // We can fall back on ts-node if it's available - getTsNodeTranspiler - : undefined; - if (_getTranspiler) { - const transpilerCleanup = _getTranspiler(compilerOptions); + const transpilerCleanup = _getTranspiler(compilerOptions, tsNodeOptions); const currRegistrationEntry = { refCount: 1, cleanup: () => { @@ -294,10 +312,11 @@ export function getTranspiler( * @returns cleanup method */ export function registerTranspiler( - compilerOptions: CompilerOptions + compilerOptions: CompilerOptions, + tsConfigRaw?: unknown ): () => void { // Function to register transpiler that returns cleanup function - const transpiler = getTranspiler(compilerOptions); + const transpiler = getTranspiler(compilerOptions, tsConfigRaw); if (!transpiler) { warnNoTranspiler(); @@ -336,11 +355,16 @@ export function registerTsConfigPaths(tsConfigPath): () => void { throw new Error(`Unable to load ${tsConfigPath}`); } -function readCompilerOptions(tsConfigPath): CompilerOptions { +function readCompilerOptions(tsConfigPath): { + compilerOptions: CompilerOptions; + tsConfigRaw?: unknown; +} { const preferTsNode = process.env.NX_PREFER_TS_NODE === 'true'; if (swcNodeInstalled && !preferTsNode) { - return readCompilerOptionsWithSwc(tsConfigPath); + return { + compilerOptions: readCompilerOptionsWithSwc(tsConfigPath), + }; } else { return readCompilerOptionsWithTypescript(tsConfigPath); } @@ -359,20 +383,15 @@ function readCompilerOptionsWithSwc(tsConfigPath) { } function readCompilerOptionsWithTypescript(tsConfigPath) { - if (!ts) { - ts = require('typescript'); - } - const { readConfigFile, parseJsonConfigFileContent, sys } = ts; - const jsonContent = readConfigFile(tsConfigPath, sys.readFile); - const { options } = parseJsonConfigFileContent( - jsonContent.config, - sys, - dirname(tsConfigPath) - ); + const { options, raw } = readTsConfigWithoutFiles(tsConfigPath); // This property is returned in compiler options for some reason, but not part of the typings. // ts-node fails on unknown props, so we have to remove it. delete options.configFilePath; - return options; + + return { + compilerOptions: options, + tsConfigRaw: raw, + }; } function loadTsConfigPaths(): typeof import('tsconfig-paths') | null { diff --git a/packages/nx/src/plugins/js/utils/typescript.ts b/packages/nx/src/plugins/js/utils/typescript.ts index 4c01c837c7..1a1deaa8a8 100644 --- a/packages/nx/src/plugins/js/utils/typescript.ts +++ b/packages/nx/src/plugins/js/utils/typescript.ts @@ -8,43 +8,45 @@ const normalizedAppRoot = workspaceRoot.replace(/\\/g, '/'); let tsModule: typeof import('typescript'); -export function readTsConfig(tsConfigPath: string) { +export function readTsConfig( + tsConfigPath: string, + sys?: ts.System +): ts.ParsedCommandLine { if (!tsModule) { tsModule = require('typescript'); } - const readResult = tsModule.readConfigFile( - tsConfigPath, - tsModule.sys.readFile - ); + + sys ??= tsModule.sys; + + const readResult = tsModule.readConfigFile(tsConfigPath, sys.readFile); return tsModule.parseJsonConfigFileContent( readResult.config, - tsModule.sys, + sys, dirname(tsConfigPath) ); } -export function readTsConfigOptions(tsConfigPath: string) { +export function readTsConfigWithoutFiles( + tsConfigPath: string +): ts.ParsedCommandLine { if (!tsModule) { tsModule = require('typescript'); } - const readResult = tsModule.readConfigFile( - tsConfigPath, - tsModule.sys.readFile - ); - // We only care about options, so we don't need to scan source files, and thus // `readDirectory` is stubbed for performance. - const host = { + const sys = { ...tsModule.sys, readDirectory: () => [], }; - return tsModule.parseJsonConfigFileContent( - readResult.config, - host, - dirname(tsConfigPath) - ).options; + return readTsConfig(tsConfigPath, sys); +} + +export function readTsConfigOptions(tsConfigPath: string): ts.CompilerOptions { + const { options } = readTsConfigWithoutFiles(tsConfigPath); + + return options; } let compilerHost: { diff --git a/packages/nx/src/project-graph/plugins/transpiler.ts b/packages/nx/src/project-graph/plugins/transpiler.ts index 63f10a9daa..4a371155fd 100644 --- a/packages/nx/src/project-graph/plugins/transpiler.ts +++ b/packages/nx/src/project-graph/plugins/transpiler.ts @@ -1,12 +1,12 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path/posix'; -import { workspaceRoot } from '../../utils/workspace-root'; +import type * as ts from 'typescript'; import { registerTranspiler, registerTsConfigPaths, } from '../../plugins/js/utils/register'; -import { readTsConfigOptions } from '../../plugins/js/utils/typescript'; -import type * as ts from 'typescript'; +import { readTsConfigWithoutFiles } from '../../plugins/js/utils/typescript'; +import { workspaceRoot } from '../../utils/workspace-root'; export let unregisterPluginTSTranspiler: (() => void) | null = null; @@ -25,16 +25,19 @@ export function registerPluginTSTranspiler() { return; } - const tsConfigOptions: Partial = tsConfigName - ? readTsConfigOptions(tsConfigName) + const tsConfig: Partial = tsConfigName + ? readTsConfigWithoutFiles(tsConfigName) : {}; const cleanupFns = [ registerTsConfigPaths(tsConfigName), - registerTranspiler({ - experimentalDecorators: true, - emitDecoratorMetadata: true, - ...tsConfigOptions, - }), + registerTranspiler( + { + experimentalDecorators: true, + emitDecoratorMetadata: true, + ...tsConfig.options, + }, + tsConfig.raw + ), ]; unregisterPluginTSTranspiler = () => { cleanupFns.forEach((fn) => fn?.());