diff --git a/src/configuration.ts b/src/configuration.ts index b536926dd..ff38ddd44 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -10,7 +10,7 @@ import { import type { TSInternal } from './ts-compiler-types'; import { createTsInternals } from './ts-internals'; import { getDefaultTsconfigJsonForNodeVersion } from './tsconfigs'; -import { assign, createRequire } from './util'; +import { assign, createProjectLocalResolveHelper } from './util'; /** * TypeScript compiler option values required by `ts-node` which cannot be overridden. @@ -172,9 +172,11 @@ export function readConfig( // Some options are relative to the config file, so must be converted to absolute paths here if (options.require) { // Modules are found relative to the tsconfig file, not the `dir` option - const tsconfigRelativeRequire = createRequire(configPath); + const tsconfigRelativeResolver = createProjectLocalResolveHelper( + dirname(configPath) + ); options.require = options.require.map((path: string) => - tsconfigRelativeRequire.resolve(path) + tsconfigRelativeResolver(path, false) ); } if (options.scopeDir) { @@ -185,6 +187,12 @@ export function readConfig( if (options.moduleTypes) { optionBasePaths.moduleTypes = basePath; } + if (options.transpiler != null) { + optionBasePaths.transpiler = basePath; + } + if (options.compiler != null) { + optionBasePaths.compiler = basePath; + } assign(tsNodeOptionsFromTsconfig, options); } diff --git a/src/index.ts b/src/index.ts index ee542e733..6e0104020 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,8 +12,11 @@ import { assign, attemptRequireWithV8CompileCache, cachedLookup, + createProjectLocalResolveHelper, + getBasePathForProjectLocalDependencyResolution, normalizeSlashes, parse, + ProjectLocalResolveHelper, split, yn, } from './util'; @@ -265,6 +268,8 @@ export interface CreateOptions { * Transpile with swc instead of the TypeScript compiler, and skip typechecking. * * Equivalent to setting both `transpileOnly: true` and `transpiler: 'ts-node/transpilers/swc'` + * + * For complete instructions: https://typestrong.org/ts-node/docs/transpilers */ swc?: boolean; /** @@ -371,6 +376,8 @@ type ModuleTypes = Record; /** @internal */ export interface OptionBasePaths { moduleTypes?: string; + transpiler?: string; + compiler?: string; } /** @@ -499,6 +506,8 @@ export interface Service { enableExperimentalEsmLoaderInterop(): void; /** @internal */ transpileOnly: boolean; + /** @internal */ + projectLocalResolveHelper: ProjectLocalResolveHelper; } /** @@ -587,17 +596,22 @@ export function create(rawOptions: CreateOptions = {}): Service { * be changed by the tsconfig, so we have to do this twice. */ function loadCompiler(name: string | undefined, relativeToPath: string) { - const compiler = require.resolve(name || 'typescript', { - paths: [relativeToPath, __dirname], - }); + const projectLocalResolveHelper = + createProjectLocalResolveHelper(relativeToPath); + const compiler = projectLocalResolveHelper(name || 'typescript', true); const ts: typeof _ts = attemptRequireWithV8CompileCache(require, compiler); - return { compiler, ts }; + return { compiler, ts, projectLocalResolveHelper }; } // Compute minimum options to read the config file. - let { compiler, ts } = loadCompiler( + let { compiler, ts, projectLocalResolveHelper } = loadCompiler( compilerName, - rawOptions.projectSearchDir ?? rawOptions.project ?? cwd + getBasePathForProjectLocalDependencyResolution( + undefined, + rawOptions.projectSearchDir, + rawOptions.project, + cwd + ) ); // Read config file and merge new options between env and CLI options. @@ -615,6 +629,21 @@ export function create(rawOptions: CreateOptions = {}): Service { ...(rawOptions.require || []), ]; + // Re-load the compiler in case it has changed. + // Compiler is loaded relative to tsconfig.json, so tsconfig discovery may cause us to load a + // different compiler than we did above, even if the name has not changed. + if (configFilePath) { + ({ compiler, ts, projectLocalResolveHelper } = loadCompiler( + options.compiler, + getBasePathForProjectLocalDependencyResolution( + configFilePath, + rawOptions.projectSearchDir, + rawOptions.project, + cwd + ) + )); + } + // Experimental REPL await is not compatible targets lower than ES2018 const targetSupportsTla = config.options.target! >= ts.ScriptTarget.ES2018; if (options.experimentalReplAwait === true && !targetSupportsTla) { @@ -635,13 +664,6 @@ export function create(rawOptions: CreateOptions = {}): Service { tsVersionSupportsTla && targetSupportsTla; - // Re-load the compiler in case it has changed. - // Compiler is loaded relative to tsconfig.json, so tsconfig discovery may cause us to load a - // different compiler than we did above, even if the name has not changed. - if (configFilePath) { - ({ compiler, ts } = loadCompiler(options.compiler, configFilePath)); - } - // swc implies two other options // typeCheck option was implemented specifically to allow overriding tsconfig transpileOnly from the command-line // So we should allow using typeCheck to override swc @@ -733,14 +755,10 @@ export function create(rawOptions: CreateOptions = {}): Service { typeof transpiler === 'string' ? transpiler : transpiler[0]; const transpilerOptions = typeof transpiler === 'string' ? {} : transpiler[1] ?? {}; - // TODO mimic fixed resolution logic from loadCompiler main - // TODO refactor into a more generic "resolve dep relative to project" helper - const transpilerPath = require.resolve(transpilerName, { - paths: [cwd, __dirname], - }); + const transpilerPath = projectLocalResolveHelper(transpilerName, true); const transpilerFactory: TranspilerFactory = require(transpilerPath).create; customTranspiler = transpilerFactory({ - service: { options, config }, + service: { options, config, projectLocalResolveHelper }, ...transpilerOptions, }); } @@ -925,7 +943,7 @@ export function create(rawOptions: CreateOptions = {}): Service { ts, cwd, config, - configFilePath, + projectLocalResolveHelper, }); serviceHost.resolveModuleNames = resolveModuleNames; serviceHost.getResolvedModuleWithFailedLookupLocationsFromCache = @@ -1076,10 +1094,10 @@ export function create(rawOptions: CreateOptions = {}): Service { } = createResolverFunctions({ host, cwd, - configFilePath, config, ts, getCanonicalFileName, + projectLocalResolveHelper, }); host.resolveModuleNames = resolveModuleNames; host.resolveTypeReferenceDirectives = resolveTypeReferenceDirectives; @@ -1356,6 +1374,7 @@ export function create(rawOptions: CreateOptions = {}): Service { installSourceMapSupport, enableExperimentalEsmLoaderInterop, transpileOnly, + projectLocalResolveHelper, }; } diff --git a/src/resolver-functions.ts b/src/resolver-functions.ts index a624e6940..f032bf0a8 100644 --- a/src/resolver-functions.ts +++ b/src/resolver-functions.ts @@ -1,5 +1,6 @@ import { resolve } from 'path'; import type * as _ts from 'typescript'; +import type { ProjectLocalResolveHelper } from './util'; /** * @internal @@ -11,10 +12,16 @@ export function createResolverFunctions(kwargs: { cwd: string; getCanonicalFileName: (filename: string) => string; config: _ts.ParsedCommandLine; - configFilePath: string | undefined; + projectLocalResolveHelper: ProjectLocalResolveHelper; }) { - const { host, ts, config, cwd, getCanonicalFileName, configFilePath } = - kwargs; + const { + host, + ts, + config, + cwd, + getCanonicalFileName, + projectLocalResolveHelper, + } = kwargs; const moduleResolutionCache = ts.createModuleResolutionCache( cwd, getCanonicalFileName, @@ -136,11 +143,9 @@ export function createResolverFunctions(kwargs: { // Resolve @types/node relative to project first, then __dirname (copy logic from elsewhere / refactor into reusable function) let typesNodePackageJsonPath: string | undefined; try { - typesNodePackageJsonPath = require.resolve( + typesNodePackageJsonPath = projectLocalResolveHelper( '@types/node/package.json', - { - paths: [configFilePath ?? cwd, __dirname], - } + true ); } catch {} // gracefully do nothing when @types/node is not installed for any reason if (typesNodePackageJsonPath) { diff --git a/src/transpilers/swc.ts b/src/transpilers/swc.ts index 3111bfc16..fedc6a3af 100644 --- a/src/transpilers/swc.ts +++ b/src/transpilers/swc.ts @@ -15,23 +15,26 @@ export interface SwcTranspilerOptions extends CreateTranspilerOptions { export function create(createOptions: SwcTranspilerOptions): Transpiler { const { swc, - service: { config }, + service: { config, projectLocalResolveHelper }, } = createOptions; // Load swc compiler let swcInstance: typeof swcWasm; if (typeof swc === 'string') { - swcInstance = require(swc) as typeof swcWasm; + swcInstance = require(projectLocalResolveHelper( + swc, + true + )) as typeof swcWasm; } else if (swc == null) { let swcResolved; try { - swcResolved = require.resolve('@swc/core'); + swcResolved = projectLocalResolveHelper('@swc/core', true); } catch (e) { try { - swcResolved = require.resolve('@swc/wasm'); + swcResolved = projectLocalResolveHelper('@swc/wasm', true); } catch (e) { throw new Error( - 'swc compiler requires either @swc/core or @swc/wasm to be installed as dependencies' + 'swc compiler requires either @swc/core or @swc/wasm to be installed as a dependency. See https://typestrong.org/ts-node/docs/transpilers' ); } } diff --git a/src/transpilers/types.ts b/src/transpilers/types.ts index 3c1e7afc3..ab524cbdc 100644 --- a/src/transpilers/types.ts +++ b/src/transpilers/types.ts @@ -16,7 +16,11 @@ export type TranspilerFactory = ( ) => Transpiler; export interface CreateTranspilerOptions { // TODO this is confusing because its only a partial Service. Rename? - service: Pick; + // Careful: must avoid stripInternal breakage by guarding with Extract<> + service: Pick< + Service, + Extract<'config' | 'options' | 'projectLocalResolveHelper', keyof Service> + >; } export interface Transpiler { // TODOs diff --git a/src/util.ts b/src/util.ts index 217dc6669..adc5eaca9 100644 --- a/src/util.ts +++ b/src/util.ts @@ -4,6 +4,7 @@ import { } from 'module'; import type _createRequire from 'create-require'; import * as ynModule from 'yn'; +import { dirname } from 'path'; /** @internal */ export const createRequire = @@ -113,3 +114,45 @@ export function attemptRequireWithV8CompileCache( return requireFn(specifier); } } + +/** + * Helper to discover dependencies relative to a user's project, optionally + * falling back to relative to ts-node. This supports global installations of + * ts-node, for example where someone does `#!/usr/bin/env -S ts-node --swc` and + * we need to fallback to a global install of @swc/core + * @internal + */ +export function createProjectLocalResolveHelper(localDirectory: string) { + return function projectLocalResolveHelper( + specifier: string, + fallbackToTsNodeRelative: boolean + ) { + return require.resolve(specifier, { + paths: fallbackToTsNodeRelative + ? [localDirectory, __dirname] + : [localDirectory], + }); + }; +} +/** @internal */ +export type ProjectLocalResolveHelper = ReturnType< + typeof createProjectLocalResolveHelper +>; + +/** + * Used as a reminder of all the factors we must consider when finding project-local dependencies and when a config file + * on disk may or may not exist. + * @internal + */ +export function getBasePathForProjectLocalDependencyResolution( + configFilePath: string | undefined, + projectSearchDirOption: string | undefined, + projectOption: string | undefined, + cwdOption: string +) { + if (configFilePath != null) return dirname(configFilePath); + return projectSearchDirOption ?? projectOption ?? cwdOption; + // TODO technically breaks if projectOption is path to a file, not a directory, + // and we attempt to resolve relative specifiers. By the time we resolve relative specifiers, + // should have configFilePath, so not reach this codepath. +}