UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

1,082 lines (1,081 loc) • 47.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.NODENEXT_ESM_RESOLVER_SOURCE = void 0; exports.forceRegisterEsmLoader = forceRegisterEsmLoader; exports.ensureNodeNextEsmResolverRegistered = ensureNodeNextEsmResolverRegistered; exports.ensureCjsResolverPatched = ensureCjsResolverPatched; exports.isNativeStripPreferred = isNativeStripPreferred; exports.registerTsProject = registerTsProject; exports.getSwcTranspiler = getSwcTranspiler; exports.getTsNodeTranspiler = getTsNodeTranspiler; exports.getTranspiler = getTranspiler; exports.isNativeTypeStripError = isNativeTypeStripError; exports.isCjsSyntaxError = isCjsSyntaxError; exports.isRequireInEsmScopeError = isRequireInEsmScopeError; exports.isTsEsmSyntaxError = isTsEsmSyntaxError; exports.isTsEsmNamedExportLinkageError = isTsEsmNamedExportLinkageError; exports.loadTsFile = loadTsFile; exports.requireWithTsconfigFallback = requireWithTsconfigFallback; exports.registerTranspiler = registerTranspiler; exports.registerTsConfigPaths = registerTsConfigPaths; exports.getTsNodeCompilerOptions = getTsNodeCompilerOptions; const path_1 = require("path"); const fs_1 = require("fs"); const logger_1 = require("../../../utils/logger"); const workspace_root_1 = require("../../../utils/workspace-root"); const typescript_1 = require("./typescript"); const swcNodeInstalled = packageIsInstalled('@swc-node/register'); const tsNodeInstalled = packageIsInstalled('ts-node/register'); let ts; let isTsEsmLoaderRegistered = false; /** * Force-register an ESM loader (`@swc-node/register/esm` if available, else * `ts-node/esm`) via `Module.register` so dynamic `import()` of TS files * goes through a transpiler. * * **IMPORTANT — global side effect:** `Module.register` is one-shot per * process and applies to *every* subsequent ESM resolution in the process. * Calling this trades native Node.js TypeScript stripping for transpiled * loading on the dynamic-import path for the rest of the run. CJS * `require()` is unaffected (different hook), so `.cts` files via require * keep using native strip + swc-node's `Module._extensions` hook. * * Required for the niche case where an ESM config (`.mts` or `.ts` resolved * as ESM) combines top-level await with TypeScript syntax that native strip * can't handle (`enum`, runtime `namespace`, etc.). TLA forces dynamic * `import()`, which bypasses the CJS hook chain - the only way to intercept * is `Module.register`. * * Idempotent: subsequent calls are no-ops. * * Throws if neither `@swc-node/register` nor `ts-node` is installed. */ function forceRegisterEsmLoader() { ensureEsmLoaderRegistered({ required: true }); } function ensureEsmLoaderRegistered(opts) { if (isTsEsmLoaderRegistered) return; const module = require('node:module'); if (typeof module.register !== 'function') { if (opts.required) { throw new Error(`${logger_1.NX_PREFIX} Module.register is not available in this Node.js version - cannot register an ESM loader for the TypeScript fallback. ${STRIP_TYPES_OPT_OUT_HINT}`); } return; } // ts-node reads compilerOptions from this env var. Setting nodenext // module/resolution avoids surprises when ts-node is the chosen loader. process.env.TS_NODE_COMPILER_OPTIONS ??= JSON.stringify({ moduleResolution: 'nodenext', module: 'nodenext', }); // Prefer @swc-node/register/esm (faster) over ts-node/esm. const swcEsm = tryResolveLoader('@swc-node/register/esm'); const tsNodeEsm = tryResolveLoader('ts-node/esm'); const loaderPath = swcEsm ?? tsNodeEsm; if (!loaderPath) { if (opts.required) { throw new Error(`${logger_1.NX_PREFIX} Cannot register an ESM TypeScript loader to fall back from native stripping. Install @swc-node/register or ts-node, or ${STRIP_TYPES_OPT_OUT_HINT}`); } isTsEsmLoaderRegistered = true; return; } if (process.env.NX_VERBOSE_LOGGING === 'true') { const loaderName = swcEsm ? '@swc-node/register/esm' : 'ts-node/esm'; logger_1.logger.warn((0, logger_1.stripIndent)(`${logger_1.NX_PREFIX} Registering ESM TypeScript loader ${loaderName}. All subsequent ESM imports in this process will go through it - native Node.js TypeScript stripping is forfeited for the dynamic-import path.`)); } const url = require('node:url'); module.register(url.pathToFileURL(loaderPath)); isTsEsmLoaderRegistered = true; } /** * Source of a minimal ESM resolution hook that rewrites TypeScript NodeNext * `.js`/`.mjs`/`.cjs` relative specifiers to their `.ts`/`.mts`/`.cts` sources. * Inlined as a string so it can be registered as a self-contained `data:` * module - it relies only on Node's default resolver (no ts-node/swc-node) and * defers loading to Node's native TypeScript stripping. The ESM counterpart to * the CJS `ensureCjsResolverPatched`. * * Only rewrites when the default resolution fails with ERR_MODULE_NOT_FOUND, * the importing module is itself TypeScript, and the specifier is relative, so * it never hijacks resolution that would otherwise succeed. * * Exported so the hook can be exercised directly in unit tests. */ exports.NODENEXT_ESM_RESOLVER_SOURCE = ` const EXT_FALLBACK = { '.js': ['.ts', '.tsx'], '.mjs': ['.mts'], '.cjs': ['.cts'] }; const TS_PARENT_RE = /\\.(?:ts|tsx|mts|cts)(?:$|\\?)/; export async function resolve(specifier, context, nextResolve) { try { return await nextResolve(specifier, context); } catch (err) { if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err; const parent = context.parentURL; if (!parent || !TS_PARENT_RE.test(parent)) throw err; if (!(specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('file:'))) throw err; const m = specifier.match(/(\\.(?:js|mjs|cjs))($|\\?)/); if (!m) throw err; const fallbacks = EXT_FALLBACK[m[1]]; if (!fallbacks) throw err; const base = specifier.slice(0, m.index); const suffix = specifier.slice(m.index + m[1].length); for (const ext of fallbacks) { try { return await nextResolve(base + ext + suffix, context); } catch {} } throw err; } } `; let nodeNextEsmResolverRegistered = false; /** * Register a self-contained ESM resolution hook (via `Module.register`) that * rewrites TypeScript NodeNext-style `.js`/`.mjs`/`.cjs` relative specifiers to * their `.ts`/`.mts`/`.cts` sources. This is the ESM counterpart to * `ensureCjsResolverPatched`: Node's native type stripping loads the `.ts` * file, but neither native strip nor Node's ESM resolver rewrites the * extension, so `import './foo.js'` from a `.ts` source where only `foo.ts` * exists fails with ERR_MODULE_NOT_FOUND without it. * * The hook is inlined as a `data:` module (see `NODENEXT_ESM_RESOLVER_SOURCE`) * and relies only on Node's default resolver, so it needs no ts-node/swc-node. * * Idempotent and best-effort: a no-op when `Module.register` is unavailable, * when a TypeScript transpiler is already preloaded (see * `isTsTranspilerPreloaded`), or if registration fails. */ function ensureNodeNextEsmResolverRegistered() { if (nodeNextEsmResolverRegistered) return; nodeNextEsmResolverRegistered = true; const module = require('node:module'); if (typeof module.register !== 'function') return; // Skip when a transpiler was preloaded via `--require`/`--import` (e.g. // `--require ts-node/register`, which Nx uses only when it runs from `.ts` // source). `module.register()` spins up a loader-hook worker thread on which // Node re-runs those preloads, resolved relative to the *current* working // directory - and Nx plugin workers `chdir()` into the analyzed workspace // first. If that workspace can't resolve the preloaded module, the loader // worker throws and can leave module resolution in a bad state, so we must // avoid the call entirely; catching it is not a clean recovery. // // Consequence: in that from-`.ts`-source invocation a `type: module` plugin // using NodeNext `.js` specifiers won't get this resolver (a preloaded // `ts-node` does NOT rewrite `.js` -> `.ts` for ESM). Published Nx is // unaffected - its workers run compiled `.js` with no preload. if (isTsTranspilerPreloaded()) return; try { module.register('data:text/javascript,' + encodeURIComponent(exports.NODENEXT_ESM_RESOLVER_SOURCE)); } catch { // Best-effort: leave Node's native handling in place for the // dynamic-import path rather than failing the load. } } /** * Whether this process was started with a TypeScript transpiler preloaded via * a `--require`/`--import`/`--loader` flag (e.g. `--require ts-node/register`), * either directly in `process.execArgv` or through `NODE_OPTIONS`. Used to skip * a redundant ESM loader registration that would otherwise crash a loader-hook * worker re-running the preload from a `chdir()`'d cwd - see * `ensureNodeNextEsmResolverRegistered`. */ function isTsTranspilerPreloaded() { const PRELOAD_FLAGS = [ '-r', '--require', '--import', '--loader', '--experimental-loader', ]; const TRANSPILER_RE = /(?:ts-node|@?swc-node)/; // Flags passed directly (e.g. spawn(..., ['--require', 'ts-node/register'])). const execArgv = process.execArgv ?? []; for (let i = 0; i < execArgv.length; i++) { const arg = execArgv[i]; const eqIdx = arg.indexOf('='); if (eqIdx !== -1) { if (PRELOAD_FLAGS.includes(arg.slice(0, eqIdx)) && TRANSPILER_RE.test(arg.slice(eqIdx + 1))) { return true; } } else if (PRELOAD_FLAGS.includes(arg) && TRANSPILER_RE.test(execArgv[i + 1] ?? '')) { return true; } } // Preloads passed via NODE_OPTIONS don't surface in execArgv. const nodeOptions = process.env.NODE_OPTIONS; if (nodeOptions && TRANSPILER_RE.test(nodeOptions) && PRELOAD_FLAGS.some((flag) => nodeOptions.includes(flag))) { return true; } return false; } let cjsResolverPatched = false; /** * Patches Node's CJS resolver to fall back from `.js`/`.mjs`/`.cjs` to the * corresponding TypeScript source extension (`.ts`/`.tsx`, `.mts`, `.cts`) * when the requesting file is itself a `.ts`/`.tsx`/`.mts`/`.cts` source. * * Required for TypeScript NodeNext-style relative imports: `import './foo.js'` * inside a `.ts` file resolves to `./foo.ts` at compile time, but the `.js` * specifier survives transpilation to CJS. Node's native CJS resolver doesn't * rewrite extensions and there is no officially-supported Node API for this — * Node's native strip-types deliberately doesn't either — so a resolver patch * is the prevailing solution in the ecosystem (used by `tsx` and ts-node's * `experimentalResolver`). * * Patches `Module._resolveFilename` (not `_findPath`, matching tsx's narrower * surface). Gates on the requesting file being TS so vanilla `.js` code * requesting missing `.js` files keeps failing — no silent hijack. Only fires * the fallback on `MODULE_NOT_FOUND` so existing `.js` resolution is * unaffected when both files exist. Idempotent on repeat calls. */ function ensureCjsResolverPatched() { if (cjsResolverPatched) return; cjsResolverPatched = true; const Module = require('node:module'); const original = Module._resolveFilename; if (typeof original !== 'function') return; const TS_PARENT_RE = /\.(?:ts|tsx|mts|cts)$/; const EXT_FALLBACK = { '.js': ['.ts', '.tsx'], '.mjs': ['.mts'], '.cjs': ['.cts'], }; Module._resolveFilename = function (request, parent, ...rest) { try { return original.call(this, request, parent, ...rest); } catch (err) { if (err?.code !== 'MODULE_NOT_FOUND') throw err; if (!parent?.filename || !TS_PARENT_RE.test(parent.filename)) throw err; const match = request.match(/(\.(?:js|mjs|cjs))$/); if (!match) throw err; const fallbacks = EXT_FALLBACK[match[1]]; if (!fallbacks) throw err; const base = request.slice(0, -match[1].length); for (const ext of fallbacks) { try { return original.call(this, base + ext, parent, ...rest); } catch { // try the next fallback } } throw err; } }; } function tryResolveLoader(specifier) { try { return require.resolve(specifier); } catch { return null; } } /** * tsx is a utility to run TypeScript files in node which is growing in popularity: * https://tsx.is * * Behind the scenes it is invoking node with relevant --require and --import flags. * * If the user is invoking Nx via a script which is being invoked via tsx, then we * do not need to register any transpiler at all as the environment will have already * been configured by tsx. In fact, registering a transpiler such as ts-node or swc * in this case causes issues. * * Because node is being invoked by tsx, the tsx binary does not end up in the final * process.argv and so we need to check a few possible things to account for usage * via different package managers (e.g. pnpm does not set process._ to tsx, but rather * pnpm itself, modern yarn does not set process._ at all etc.). */ const isInvokedByTsx = (() => { if (process.env._?.endsWith(`${path_1.sep}tsx`)) { return true; } const requireArgs = []; const importArgs = []; (process.execArgv ?? []).forEach((arg, i) => { if (arg === '-r' || arg === '--require') { requireArgs.push(process.execArgv[i + 1]); } if (arg === '--import') { importArgs.push(process.execArgv[i + 1]); } }); const isTsxPath = (p) => p.includes(`${path_1.sep}tsx${path_1.sep}`); return (requireArgs.some((a) => isTsxPath(a)) || importArgs.some((a) => isTsxPath(a))); })(); /** * Whether the current Node.js runtime exposes native TypeScript type * stripping. This is the authoritative gate - it correctly handles every * way Node ships TS support: * - Node 23.6+: unflagged, on by default * - Node 22.18+ LTS: backported unflagged * - Node 22.6-22.17 + `--experimental-strip-types` (or `--experimental-transform-types`) * * `process.features.typescript` is `'strip' | 'transform' | false`. */ const nodeSupportsNativeTypescript = !!process.features ?.typescript; /** * When process.features.typescript is truthy, default to letting Node.js * handle TypeScript natively via type stripping and skip registering swc-node * or ts-node. Users can opt out by setting NX_PREFER_NODE_STRIP_TYPES=false. * * Some constructs (enum, runtime namespace, legacy decorators, * import = require, parameter properties, etc.) aren't supported by native * type stripping. `loadTsFile` catches these failures and falls back to * registering swc/ts-node + tsconfig-paths automatically. * * Setting NX_PREFER_TS_NODE=true also opts out, since that flag explicitly * requests ts-node for transpilation. * * See: https://nodejs.org/api/typescript.html#full-typescript-support */ const preferNodeStripTypes = (() => { if (!nodeSupportsNativeTypescript) { return false; } if (process.env.NX_PREFER_TS_NODE === 'true') { return false; } return process.env.NX_PREFER_NODE_STRIP_TYPES !== 'false'; })(); /** * Skip tsconfig-paths registration on the swc/ts-node fallback path. Useful * for workspaces relying on package manager workspaces (pnpm, yarn, npm) for * project linking, where tsconfig path aliases aren't needed. */ const disableTsConfigPaths = process.env.NX_DISABLE_TSCONFIG_PATHS === 'true'; /** * Whether Nx will defer to Node's native TypeScript stripping for the next * `.ts` load. Mirrors the gate used by `loadTsFile`/`registerTsProject` so * other registration sites (e.g. plugin transpiler) can stay aligned. */ function isNativeStripPreferred() { return preferNodeStripTypes; } /** * This function registers either ts-node or swc-node to transpile TypeScript files on the fly. * It also registers tsconfig-paths to handle path mapping based on the provided tsconfig. * * The TypeScript transpiler registration is done regardless of NX_PREFER_NODE_STRIP_TYPES. * If you want to skip transpiler registration, it is recommended that you check `process.features.typescript`. * * @returns cleanup function */ function registerTsProject(tsConfigPath) { // See explanation alongside isInvokedByTsx declaration if (isInvokedByTsx) { return () => { }; } const { compilerOptions, tsConfigRaw } = readCompilerOptions(tsConfigPath); const cleanupFunctions = [ registerTsConfigPaths(tsConfigPath), registerTranspiler(compilerOptions, tsConfigRaw), ]; // Best-effort ESM loader registration so dynamic import() of .ts/.mts // files goes through a transpiler. No-op if no ESM loader package is // installed. ensureEsmLoaderRegistered({ required: false }); // CJS-side fallback so NodeNext `.js` specifiers in `.ts` sources resolve // to the matching `.ts` file when require()'d. ensureCjsResolverPatched(); return () => { for (const fn of cleanupFunctions) { fn(); } }; } function getSwcTranspiler(compilerOptions) { // These are requires to prevent it from registering when it shouldn't const register = require('@swc-node/register/register') .register; const cleanupFn = register({ ...compilerOptions, baseUrl: compilerOptions.baseUrl ?? './', }); return typeof cleanupFn === 'function' ? cleanupFn : () => { }; } function getTsNodeTranspiler(compilerOptions, tsNodeOptions, preferTsNode) { const { register } = require('ts-node'); // ts-node doesn't provide a cleanup method const service = register({ ...tsNodeOptions, transpileOnly: true, compilerOptions: getTsNodeCompilerOptions({ ...tsNodeOptions?.compilerOptions, ...compilerOptions, }), // we already read and provide the compiler options, so prevent ts-node from reading them again skipProject: true, }); const { transpiler, swc } = service.options; // Don't warn if a faster transpiler is enabled if (!transpiler && !swc && !preferTsNode) { warnTsNodeUsage(); } return () => { // Do not cleanup ts-node service since other consumers may need it }; } /** * Given the raw "ts-node" sub-object from a tsconfig, return an object with only the properties * recognized by "ts-node" * * Adapted from the function of the same name in ts-node */ function filterRecognizedTsConfigTsNodeOptions(jsonObject) { if (typeof jsonObject !== 'object' || jsonObject === null) { return { recognized: {}, unrecognized: {} }; } const { compiler, compilerHost, compilerOptions, emit, files, ignore, ignoreDiagnostics, logError, preferTsExts, pretty, require, skipIgnore, transpileOnly, typeCheck, transpiler, scope, scopeDir, moduleTypes, experimentalReplAwait, swc, experimentalResolver, esm, experimentalSpecifierResolution, experimentalTsImportSpecifiers, ...unrecognized } = jsonObject; const filteredTsConfigOptions = { compiler, compilerHost, compilerOptions, emit, experimentalReplAwait, files, ignore, ignoreDiagnostics, logError, preferTsExts, pretty, require, skipIgnore, transpileOnly, typeCheck, transpiler, scope, scopeDir, moduleTypes, swc, experimentalResolver, esm, experimentalSpecifierResolution, experimentalTsImportSpecifiers, }; // Use the typechecker to make sure this implementation has the correct set of properties const catchExtraneousProps = null; const catchMissingProps = null; return { recognized: filteredTsConfigOptions, unrecognized }; } const registered = new Map(); function getTranspiler(compilerOptions, tsConfigRaw) { const preferTsNode = process.env.NX_PREFER_TS_NODE === 'true'; if (!ts) { ts = require('typescript'); } compilerOptions.lib = ['es2021']; compilerOptions.module = ts.ModuleKind.CommonJS; // use NodeJs module resolution until support for TS 4.x is dropped and then // we can switch to Node10 compilerOptions.moduleResolution = ts.ModuleResolutionKind.NodeJs; compilerOptions.customConditions = null; compilerOptions.target = ts.ScriptTarget.ES2021; compilerOptions.inlineSourceMap = true; compilerOptions.skipLibCheck = true; // These options are different per project, and since they are not needed for transpilation, we can remove them so we have more cache hits. compilerOptions.outDir = undefined; compilerOptions.outFile = undefined; compilerOptions.declaration = undefined; compilerOptions.declarationMap = undefined; compilerOptions.composite = undefined; compilerOptions.tsBuildInfoFile = undefined; delete compilerOptions.strict; let _getTranspiler; let registrationKey = JSON.stringify(compilerOptions); let tsNodeOptions; 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 registrationEntry = registered.get(registrationKey); if (registered.has(registrationKey)) { registrationEntry.refCount++; return registrationEntry.cleanup; } if (_getTranspiler) { const transpilerCleanup = _getTranspiler(compilerOptions, tsNodeOptions, preferTsNode); const currRegistrationEntry = { refCount: 1, cleanup: () => { return () => { currRegistrationEntry.refCount--; if (currRegistrationEntry.refCount === 0) { registered.delete(registrationKey); transpilerCleanup(); } }; }, }; registered.set(registrationKey, currRegistrationEntry); return currRegistrationEntry.cleanup; } } /** * Node.js throws this code when native type stripping hits an unsupported * construct (enum, runtime namespace, legacy decorators, import = require, * parameter properties on older Node, etc.). * * Exported for tests. */ function isNativeTypeStripError(err) { if (!err || typeof err !== 'object') return false; return (err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'); } /** * Module resolution failures - typically a tsconfig path alias that hasn't * been registered yet, or a workspace lib not surfaced via package manager * symlinks. CJS uses `MODULE_NOT_FOUND`, ESM uses `ERR_MODULE_NOT_FOUND`. */ function isModuleNotFoundError(err) { if (!err || typeof err !== 'object') return false; const code = err.code; return code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND'; } /** * A SyntaxError thrown while parsing a forced-CJS file (`.cts`/`.cjs`) as * CommonJS - typically ESM syntax in a CJS file (e.g. `export default` in * `.cts`). Pre-v23 this worked because swc-node's CJS hook compiled away the * ESM syntax; under native strip swc-node isn't registered, so the file * reaches Node's strict CJS parser. swc-node tolerates ESM syntax in `.cts` * (`register()` forces `module: commonjs` regardless of extension), so * escalating to the swc/ts-node fallback recovers the legacy behavior. */ function isCjsSyntaxError(err, filePath) { if (!(err instanceof SyntaxError)) return false; return filePath.endsWith('.cts') || filePath.endsWith('.cjs'); } /** * A ReferenceError from Node treating a `.ts`/`.mts` file as ESM and the file * relying on a CJS-only global: `require`, `__dirname`, or `__filename`. * Pre-v23 swc-node compiled `.ts` to CJS where these globals exist; under * native strip Node detects ESM via `import`/`export` syntax and these globals * are undefined. Registering swc/ts-node compiles ESM->CJS and restores the * legacy globals. */ function isRequireInEsmScopeError(err, filePath) { if (!(err instanceof ReferenceError)) return false; if (!(filePath.endsWith('.ts') || filePath.endsWith('.mts'))) return false; // Node's exact phrasing varies across versions / strip modes. Match the // bare-name form too (e.g. `__dirname is not defined`) so the fallback // still triggers when the trailing "in ES module scope" is absent. const msg = err.message; return /(require|__dirname|__filename) is not defined/.test(msg); } function isTsEsmSyntaxError(err, filePath) { if (!(err instanceof SyntaxError)) return false; if (!filePath.endsWith('.ts')) return false; // Node has multiple phrasings for ESM-in-CJS-scope syntax errors depending // on whether the offending token is `import` or `export` and which parser // path triggered: "Cannot use import statement outside a module" or // "Unexpected token 'export'" / "Unexpected token 'import'". swc-node's // CJS hook compiles ESM->CJS regardless of the surface error, so all of // these should escalate to the same fallback. const msg = err.message; return (msg.includes('Cannot use import statement outside a module') || /Unexpected token ['"](export|import)['"]/.test(msg)); } function isTsEsmNamedExportLinkageError(err, filePath) { if (!(err instanceof SyntaxError)) return false; return ((filePath.endsWith('.ts') || filePath.endsWith('.mts')) && err.message.includes('does not provide an export named')); } /** * Hint appended to errors that the lazy fallback couldn't recover from. * Points users at the env opt-out for cases native strip can't reach (e.g. * ESM with top-level await + unsupported TS syntax, where swc-node's CJS * Module._extensions hook can't intercept dynamic `import()`). */ const NX_PREFER_NODE_STRIP_TYPES_DOCS_URL = 'https://nx.dev/docs/reference/environment-variables#nx-prefer-node-strip-types'; const STRIP_TYPES_OPT_OUT_HINT = `Set NX_PREFER_NODE_STRIP_TYPES=false to opt out of Node's native TypeScript stripping and use swc/ts-node instead. See ${NX_PREFER_NODE_STRIP_TYPES_DOCS_URL}`; /** * Load a TypeScript file via `require()`. * * When the runtime exposes native TypeScript stripping * (`process.features.typescript`) and the user hasn't opted out via * `NX_PREFER_NODE_STRIP_TYPES=false`, the file loads directly with no * swc/ts-node and no tsconfig-paths registration. If Node throws on an * unsupported construct (enum, runtime namespace, legacy decorators, etc.), * this registers swc/ts-node + tsconfig-paths and retries - matching the * pre-v23 registration. Set `NX_DISABLE_TSCONFIG_PATHS=true` to skip * tsconfig-paths even on fallback (useful when relying on package manager * workspaces). Set `NX_VERBOSE_LOGGING=true` to log when fallback triggers. * * When native strip is opted out (`NX_PREFER_NODE_STRIP_TYPES=false` or * unsupported Node), uses the legacy `registerTsProject` path. * * `tsConfigPath` is only consulted on the swc/ts-node fallback path (for * compilerOptions) and for tsconfig-paths registration. Native strip ignores * it. When omitted, defaults to the workspace root tsconfig. * * Note on ESM: Node 22.12+ supports `require()` of synchronous ESM by default, * so most ESM `.ts` configs load via this function without issue. Modules * that use top-level await throw `ERR_REQUIRE_ASYNC_MODULE` and must be * loaded with dynamic `import()` instead. `ERR_REQUIRE_ESM` (legacy code) * bubbles unchanged for the rare case it still fires, so async-aware callers * can dispatch to `import()`. * * @returns the loaded module */ function loadTsFile(filePath, tsConfigPath) { const resolvedTsConfigPath = tsConfigPath ?? (0, typescript_1.getRootTsConfigPath)(); if (isInvokedByTsx) { return require(filePath); } if (!preferNodeStripTypes) { if (!resolvedTsConfigPath) { throw new Error(`${logger_1.NX_PREFIX} loadTsFile could not find a workspace tsconfig while loading ${filePath} on the swc/ts-node path. Pass an explicit tsConfigPath or add a tsconfig.base.json/tsconfig.json at the workspace root.`); } const cleanup = registerTsProject(resolvedTsConfigPath); try { return require(filePath); } finally { cleanup(); } } // Native strip path: no registration up front. pnpm/npm/yarn workspaces // resolve aliases without tsconfig-paths. On failure, lazy-register what // the specific error code indicates is needed and retry: // - MODULE_NOT_FOUND -> first try tsconfig-paths (alias resolution). // If that still fails (e.g. extensionless `import './foo'` when // `foo.ts` is adjacent - Node's resolver doesn't add `.ts`), escalate // to swc/ts-node which handles `.ts` extension resolution. // - ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX -> register swc/ts-node + tsconfig-paths // Each registration kind runs at most once, so a file recovers in at // most three attempts. let pathsRegistered = false; let transpilerRegistered = false; const cleanups = []; const registerTranspilerFallback = (err) => { if (!swcNodeInstalled && !tsNodeInstalled) { const original = err instanceof Error ? err.message : String(err); throw new Error(`${logger_1.NX_PREFIX} ${filePath} could not be loaded under Node's native TypeScript stripping (${original}). Install @swc-node/register and @swc/core (or ts-node) to enable the swc/ts-node fallback, or ${STRIP_TYPES_OPT_OUT_HINT}`); } if (!resolvedTsConfigPath) { throw new Error(`${logger_1.NX_PREFIX} ${filePath} requires the swc/ts-node fallback but no workspace tsconfig was found. Pass an explicit tsConfigPath or add a tsconfig.base.json/tsconfig.json at the workspace root. ${STRIP_TYPES_OPT_OUT_HINT}`); } if (!pathsRegistered && !disableTsConfigPaths) { cleanups.push(registerTsConfigPaths(resolvedTsConfigPath)); pathsRegistered = true; } const { compilerOptions, tsConfigRaw } = readCompilerOptions(resolvedTsConfigPath); cleanups.push(registerTranspiler(compilerOptions, tsConfigRaw)); transpilerRegistered = true; }; try { for (let attempt = 1;; attempt++) { try { if (attempt > 1) { try { delete require.cache[require.resolve(filePath)]; } catch { // require.resolve may throw if the failed load never reached cache } } return require(filePath); } catch (err) { // Cheap fallback first: register tsconfig-paths and retry. if (isModuleNotFoundError(err) && !pathsRegistered && !disableTsConfigPaths && resolvedTsConfigPath) { logFallback(filePath, err, 'Module not found; registering tsconfig-paths and retrying.'); cleanups.push(registerTsConfigPaths(resolvedTsConfigPath)); pathsRegistered = true; continue; } // Heavy fallback: register swc/ts-node (+ paths) and retry. Triggered // by: // - strip-types failure (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`) // - module resolution failure that tsconfig-paths alone can't fix // (extensionless `./foo` -> `./foo.ts`) // - SyntaxError in a `.cts`/`.cjs` file (ESM syntax in a forced-CJS // file). swc-node compiles ESM->CJS regardless of extension. // - SyntaxError from Node parsing a `.ts` config with ESM syntax as // CJS. swc/ts-node preserves the pre-v23 behavior for these files. // - ReferenceError from Node treating a `.ts`/`.mts` config as ESM // when it contains legacy CJS `require`. // - SyntaxError from native ESM linkage when generated TS config // imports a type-only symbol as a runtime named export. if ((isNativeTypeStripError(err) || isModuleNotFoundError(err) || isCjsSyntaxError(err, filePath) || isTsEsmSyntaxError(err, filePath) || isRequireInEsmScopeError(err, filePath) || isTsEsmNamedExportLinkageError(err, filePath)) && !transpilerRegistered) { logFallback(filePath, err, isNativeTypeStripError(err) ? 'Native Node.js TypeScript stripping failed; falling back to swc/ts-node + tsconfig-paths.' : isCjsSyntaxError(err, filePath) ? 'ESM syntax in forced-CJS file; falling back to swc/ts-node + tsconfig-paths.' : isTsEsmSyntaxError(err, filePath) ? 'ESM syntax in TypeScript file parsed as CommonJS; falling back to swc/ts-node + tsconfig-paths.' : isRequireInEsmScopeError(err, filePath) ? 'CommonJS require in native ESM TypeScript file; falling back to swc/ts-node + tsconfig-paths.' : isTsEsmNamedExportLinkageError(err, filePath) ? 'Native ESM named export linkage failed; falling back to swc/ts-node + tsconfig-paths.' : 'Module not found after tsconfig-paths; falling back to swc/ts-node + tsconfig-paths.'); registerTranspilerFallback(err); continue; } throw augmentLoadFailure(filePath, err); } } } finally { for (const fn of cleanups) fn(); } } /** * Plain `require()` with a lazy `tsconfig-paths` fallback. Use for files that * are NOT TypeScript (no transpilation needed) but may still import workspace * packages through TS path aliases (e.g. a `.js` changelog renderer that * `require`s `@my-org/lib`). * * `tsconfig-paths` is only registered after the first `require()` fails with * a module-resolution error, so workspaces that resolve aliases through * package-manager symlinks pay nothing. Set `NX_DISABLE_TSCONFIG_PATHS=true` * to skip the fallback entirely. * * @returns the loaded module */ function requireWithTsconfigFallback(filePath, tsConfigPath) { try { return require(filePath); } catch (err) { if (!isModuleNotFoundError(err) || disableTsConfigPaths) { throw err; } const resolvedTsConfigPath = tsConfigPath ?? (0, typescript_1.getRootTsConfigPath)(); if (!resolvedTsConfigPath) { throw err; } const cleanup = registerTsConfigPaths(resolvedTsConfigPath); try { delete require.cache[require.resolve(filePath)]; } catch { // require.resolve may throw if the failed load never reached cache } try { return require(filePath); } finally { cleanup(); } } } /** * Append the `NX_PREFER_NODE_STRIP_TYPES=false` opt-out hint so users know * there's an escape hatch for cases native strip can't reach (e.g. ESM with * top-level await + unsupported TS syntax). Skipped for: * - ESM-redispatch signals callers expect to handle * (`ERR_REQUIRE_ESM`, `ERR_REQUIRE_ASYNC_MODULE`) * - plain module-resolution failures (`MODULE_NOT_FOUND`, * `ERR_MODULE_NOT_FOUND`) - disabling strip-types doesn't fix a missing * module, the hint just misleads. */ function augmentLoadFailure(filePath, err) { if (!(err instanceof Error)) return err; const code = err.code; if (code === 'ERR_REQUIRE_ESM' || code === 'ERR_REQUIRE_ASYNC_MODULE' || code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') { return err; } if (err.message.includes(NX_PREFER_NODE_STRIP_TYPES_DOCS_URL)) { return err; } err.message = `${err.message}\n\n${logger_1.NX_PREFIX} Failed to load ${filePath} under Node's native TypeScript stripping. ${STRIP_TYPES_OPT_OUT_HINT}`; return err; } function logFallback(filePath, err, summary) { if (process.env.NX_VERBOSE_LOGGING !== 'true') { return; } const message = err instanceof Error ? err.message : String(err); logger_1.logger.warn((0, logger_1.stripIndent)(`${logger_1.NX_PREFIX} ${summary} (${filePath}) ${message}`)); } /** * Register ts-node or swc-node given a set of compiler options. * * Note: Several options require enums from typescript. To avoid importing typescript, * use import type + raw values * * @returns cleanup method */ function registerTranspiler(compilerOptions, tsConfigRaw) { // Function to register transpiler that returns cleanup function const transpiler = getTranspiler(compilerOptions, tsConfigRaw); if (!transpiler) { // If Node.js natively supports TypeScript (22.6+), no transpiler is needed. // Don't warn - Node will handle .ts files via type stripping. if (!nodeSupportsNativeTypescript) { warnNoTranspiler(); } return () => { }; } return transpiler(); } /** * @param tsConfigPath Adds the paths from a tsconfig file into node resolutions * @returns cleanup function */ function registerTsConfigPaths(tsConfigPath) { try { /** * Load the ts config from the source project */ const tsconfigPaths = loadTsConfigPaths(); const tsConfigResult = tsconfigPaths.loadConfig(tsConfigPath); /** * Register the custom workspace path mappings with node so that workspace libraries * can be imported and used within project */ if (tsConfigResult.resultType === 'success') { // Short-circuit when the tsconfig has no `paths` entries. Installing // tsconfig-paths' resolver hook adds a per-require cost on every // module load; in package-manager-workspace setups (which resolve via // symlinks instead of TS path mappings), the hook never has anything // to do. Avoid paying that overhead on workspaces that don't use // `paths`. if (!tsConfigResult.paths || Object.keys(tsConfigResult.paths).length === 0) { return () => { }; } return tsconfigPaths.register({ baseUrl: resolvePathsBaseUrl(tsConfigPath), paths: tsConfigResult.paths, }); } } catch (err) { if (err instanceof Error) { throw new Error(`Unable to load ${tsConfigPath}: ` + err.message); } } throw new Error(`Unable to load ${tsConfigPath}`); } function readCompilerOptions(tsConfigPath) { const preferTsNode = process.env.NX_PREFER_TS_NODE === 'true'; if (swcNodeInstalled && !preferTsNode) { return { compilerOptions: readCompilerOptionsWithSwc(tsConfigPath), }; } else { return readCompilerOptionsWithTypescript(tsConfigPath); } } function readCompilerOptionsWithSwc(tsConfigPath) { const { readDefaultTsConfig, } = require('@swc-node/register/read-default-tsconfig'); const compilerOptions = readDefaultTsConfig(tsConfigPath); // This is returned in compiler options for some reason, but not part of the typings. // @swc-node/register filters the files to transpile based on it, but it can be limiting when processing // files not part of the received tsconfig included files (e.g. shared helpers, or config files not in source, etc.). delete compilerOptions.files; // @swc-node/register's readDefaultTsConfig auto-sets baseUrl to the // dirname of the tsconfig when not explicitly configured. This is incorrect // when paths are inherited via "extends" from a parent tsconfig at a // different directory level (e.g., tsconfig.base.json at workspace root), // because SWC will resolve "./"-prefixed paths relative to the wrong // directory. Use the workspace root as baseUrl in that case. // baseUrl will not be configured when using newer versions of TypeScript like `tsgo`. if (compilerOptions.paths) { const { options: tsOptions } = (0, typescript_1.readTsConfigWithoutFiles)(tsConfigPath); if (!tsOptions.baseUrl) { compilerOptions.baseUrl = workspace_root_1.workspaceRoot; } } return compilerOptions; } function readCompilerOptionsWithTypescript(tsConfigPath) { const { options, raw } = (0, typescript_1.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 { compilerOptions: options, tsConfigRaw: raw, }; } function loadTsConfigPaths() { try { return require('tsconfig-paths'); } catch { warnNoTsconfigPaths(); } } function warnTsNodeUsage() { logger_1.logger.warn((0, logger_1.stripIndent)(`${logger_1.NX_PREFIX} Falling back to ts-node for local typescript execution. This may be a little slower. - To fix this, ensure @swc-node/register and @swc/core have been installed`)); } function warnNoTsconfigPaths() { logger_1.logger.warn((0, logger_1.stripIndent)(`${logger_1.NX_PREFIX} Unable to load tsconfig-paths, workspace libraries may be inaccessible. - To fix this, install tsconfig-paths with npm/yarn/pnpm`)); } function warnNoTranspiler() { logger_1.logger.warn((0, logger_1.stripIndent)(`${logger_1.NX_PREFIX} Unable to locate swc-node or ts-node. Nx will be unable to run local ts files without transpiling. - To fix this, ensure @swc-node/register and @swc/core have been installed`)); } function packageIsInstalled(m) { try { const p = require.resolve(m); return true; } catch { return false; } } /** * ts-node requires string values for enum based typescript options. * `register`'s signature just types the field as `object`, so we * unfortunately do not get any kind of type safety on this. */ function getTsNodeCompilerOptions(compilerOptions) { if (!ts) { ts = require('typescript'); } const flagMap = { module: 'ModuleKind', target: 'ScriptTarget', moduleDetection: 'ModuleDetectionKind', newLine: 'NewLineKind', moduleResolution: 'ModuleResolutionKind', importsNotUsedAsValues: 'ImportsNotUsedAsValues', }; const result = { ...compilerOptions, }; for (const flag in flagMap) { if (compilerOptions[flag]) { result[flag] = ts[flagMap[flag]][compilerOptions[flag]]; } } delete result.pathsBasePath; delete result.configFilePath; // instead of mapping to enum value we just remove it as it shouldn't ever need to be set for ts-node delete result.jsx; // lib option is in the format `lib.es2022.d.ts`, so we need to remove the leading `lib.` and trailing `.d.ts` to make it valid result.lib = result.lib?.map((value) => { return value.replace(/^lib\./, '').replace(/\.d\.ts$/, ''); }); if (result.moduleResolution) { result.moduleResolution = result.moduleResolution === 'NodeJs' ? 'node' : result.moduleResolution.toLowerCase(); } return result; } function resolvePathsBaseUrl(tsconfigPath) { const chain = []; const queue = [tsconfigPath]; while (queue.length > 0) { const absolute = (0, path_1.resolve)(queue.shift()); const dir = (0, path_1.dirname)(absolute); try { const raw = JSON.parse((0, fs_1.readFileSync)(absolute, 'utf-8')); chain.push({ dir, raw }); const exts = raw.extends ? Array.isArray(raw.extends) ? raw.extends : [raw.extends] : []; for (const ext of exts) { const resolved = resolveExtendsPath(ext, dir); if (resolved) { queue.push(resolved); } } } catch { // skip unreadable files } } let pathsIndex = -1; for (let i = 0; i < chain.length; i++) { if (chain[i].raw.compilerOptions?.paths && Object.keys(chain[i].raw.compilerOptions.paths).length > 0) { pathsIndex = i; break; } } const searchStart = pathsIndex >= 0 ? pathsIndex : 0; for (let i = searchStart; i < chain.length; i++) { if (chain[i].raw.compilerOptions?.baseUrl) { return (0, path_1.resolve)(chain[i].dir, chain[i].raw.compilerOptions.baseUrl); } } return pathsIndex >= 0 ? chain[pathsIndex].dir : (0, path_1.dirname)((0, path_1.resolve)(tsconfigPath)); } function resolveExtendsPath(ext, fromDir) { if (ext.startsWith('.') || (0, path_1.isAbsolute)(ext)) { let resolved = (0, path_1.resolve)(fromDir, ext); if ((0, fs_1.existsSync)(resolved)) return resolved; if (!resolved.endsWith('.json')) { resolved += '.json'; if ((0, fs_1.existsSync)(resolved)) return resolved; } return null; } try { return require.resolve(ext, { paths: [fromDir] }); } catch { try { return require.resolve(`${ext}/tsconfig.json`, { paths: [fromDir] }); } catch { return null; } } }