diff --git a/dist-raw/node-cjs-loader-utils.js b/dist-raw/node-cjs-loader-utils.js index b7ec0d531..8ddeca084 100644 --- a/dist-raw/node-cjs-loader-utils.js +++ b/dist-raw/node-cjs-loader-utils.js @@ -23,6 +23,8 @@ function assertScriptCanLoadAsCJSImpl(service, module, filename) { const tsNodeClassification = service.moduleTypeClassifier.classifyModule(normalizeSlashes(filename)); if(tsNodeClassification.moduleType === 'cjs') return; + // TODO modify to ignore package.json when file extension is ESM-only + // Function require shouldn't be used in ES modules. if (tsNodeClassification.moduleType === 'esm' || (pkg && pkg.data && pkg.data.type === 'module')) { const parentPath = module.parent && module.parent.filename; diff --git a/dist-raw/node-esm-resolve-implementation.js b/dist-raw/node-esm-resolve-implementation.js index 04ea84668..ce2ffad16 100644 --- a/dist-raw/node-esm-resolve-implementation.js +++ b/dist-raw/node-esm-resolve-implementation.js @@ -330,11 +330,19 @@ function resolveExtensions(search) { * TS's resolver can resolve foo.js to foo.ts, by replacing .js extension with several source extensions. * IMPORTANT: preserve ordering according to preferTsExts; this affects resolution behavior! */ -const replacementExtensions = extensions.filter(ext => ['.js', '.jsx', '.ts', '.tsx'].includes(ext)); +const replacementExtensionsForJs = extensions.filter(ext => ['.js', '.jsx', '.ts', '.tsx'].includes(ext)); +const replacementExtensionsForMjs = extensions.filter(ext => ['.mjs', '.mts'].includes(ext)); +const replacementExtensionsForCjs = extensions.filter(ext => ['.cjs', '.cts'].includes(ext)); function resolveReplacementExtensions(search) { - if (search.pathname.match(/\.js$/)) { - const pathnameWithoutExtension = search.pathname.slice(0, search.pathname.length - 3); + const lastDotIndex = search.pathname.lastIndexOf('.'); + const ext = search.pathname.slice(lastDotIndex); + if (ext === '.js' || ext === '.cjs' || ext === '.mjs') { + const pathnameWithoutExtension = search.pathname.slice(0, lastDotIndex); + const replacementExtensions = + ext === '.js' ? replacementExtensionsForJs + : ext === '.mjs' ? replacementExtensionsForMjs + : replacementExtensionsForCjs; for (let i = 0; i < replacementExtensions.length; i++) { const extension = replacementExtensions[i]; const guess = new URL(search.toString()); diff --git a/src/esm.ts b/src/esm.ts index c83fd22c4..f95987231 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -96,7 +96,7 @@ export function createEsmHooks(tsNodeService: Service) { // Custom implementation that considers additional file extensions and automatically adds file extensions const nodeResolveImplementation = createResolve({ - ...getExtensions(tsNodeService.config), + ...tsNodeService.extensions, preferTsExts: tsNodeService.options.preferTsExts, }); @@ -112,7 +112,6 @@ export function createEsmHooks(tsNodeService: Service) { const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI ? { resolve, load, getFormat: undefined, transformSource: undefined } : { resolve, getFormat, transformSource, load: undefined }; - return hooksAPI; function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) { // We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo` @@ -205,6 +204,16 @@ export function createEsmHooks(tsNodeService: Service) { return { format, source }; } + // Mapping from extensions understood by tsc to the equivalent for node, + // as far as getFormat is concerned. + const nodeEquivalentExtensions = new Map([ + ['.ts', '.js'], + ['.tsx', '.js'], + ['.jsx', '.js'], + ['.mts', '.mjs'], + ['.cts', '.cjs'] + ]); + async function getFormat( url: string, context: {}, @@ -227,11 +236,13 @@ export function createEsmHooks(tsNodeService: Service) { const nativePath = fileURLToPath(url); - // If file has .ts, .tsx, or .jsx extension, then ask node how it would treat this file if it were .js + // If file has extension not understood by node, then ask node how it would treat the emitted extension. + // E.g. .mts compiles to .mjs, so ask node how to classify an .mjs file. const ext = extname(nativePath); let nodeSays: { format: NodeLoaderHooksFormat }; - if (ext !== '.js' && !tsNodeService.ignored(nativePath)) { - nodeSays = await defer(formatUrl(pathToFileURL(nativePath + '.js'))); + const nodeEquivalentExt = nodeEquivalentExtensions.get(ext); + if (nodeEquivalentExt && !tsNodeService.ignored(nativePath)) { + nodeSays = await defer(formatUrl(pathToFileURL(nativePath + nodeEquivalentExt))); } else { nodeSays = await defer(); } @@ -283,4 +294,6 @@ export function createEsmHooks(tsNodeService: Service) { return { source: emittedJs }; } + + return hooksAPI; } diff --git a/src/index.ts b/src/index.ts index b3506a4cd..86e254dcd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -479,6 +479,8 @@ export interface Service { installSourceMapSupport(): void; /** @internal */ enableExperimentalEsmLoaderInterop(): void; + /** @internal */ + extensions: Extensions; } /** @@ -499,14 +501,24 @@ export interface DiagnosticFilter { } /** @internal */ -export function getExtensions(config: _ts.ParsedCommandLine) { +export type Extensions = ReturnType; + +/** @internal */ +export function getExtensions(config: _ts.ParsedCommandLine, tsVersion: string) { + // TS 4.5 is first version to understand .cts, .mts, .cjs, and .mjs extensions + const tsSupportsMtsCtsExts = versionGteLt(tsVersion, '4.5.0'); const tsExtensions = ['.ts']; const jsExtensions = []; + if(tsSupportsMtsCtsExts) tsExtensions.push('.mts', '.cts'); + // Enable additional extensions when JSX or `allowJs` is enabled. if (config.options.jsx) tsExtensions.push('.tsx'); - if (config.options.allowJs) jsExtensions.push('.js'); - if (config.options.jsx && config.options.allowJs) jsExtensions.push('.jsx'); + if (config.options.allowJs) { + jsExtensions.push('.js', '.mjs', '.cjs'); + if (config.options.jsx) jsExtensions.push('.jsx'); + if (tsSupportsMtsCtsExts) tsExtensions.push('.mjs', '.cjs'); + } return { tsExtensions, jsExtensions }; } @@ -529,7 +541,7 @@ export function register( } const originalJsHandler = require.extensions['.js']; - const { tsExtensions, jsExtensions } = getExtensions(service.config); + const { tsExtensions, jsExtensions } = service.extensions; const extensions = [...tsExtensions, ...jsExtensions]; // Expose registered instance globally. @@ -1296,7 +1308,7 @@ export function create(rawOptions: CreateOptions = {}): Service { let active = true; const enabled = (enabled?: boolean) => enabled === undefined ? active : (active = !!enabled); - const extensions = getExtensions(config); + const extensions = getExtensions(config, ts.version); const ignored = (fileName: string) => { if (!active) return true; const ext = extname(fileName); @@ -1333,6 +1345,7 @@ export function create(rawOptions: CreateOptions = {}): Service { addDiagnosticFilter, installSourceMapSupport, enableExperimentalEsmLoaderInterop, + extensions, }; } @@ -1368,14 +1381,29 @@ function registerExtensions( service: Service, originalJsHandler: (m: NodeModule, filename: string) => any ) { + const exts = new Set(extensions); + // Only way to transform .mts and .cts is via the .js extension. + // Can't register those extensions cuz would allow omitting file extension; node requires ext for .cjs and .mjs + if(exts.has('.mts') || exts.has('.cts')) exts.add('.js'); + // Filter extensions which should not be added to `require.extensions` + // They may still be handled via the `.js` extension handler. + exts.delete('.mts'); + exts.delete('.cts'); + exts.delete('.mjs'); + exts.delete('.cjs'); + + // TODO do we care about overriding moduleType for mjs? No, I don't think so. + // Could conditionally register `.mjs` extension when moduleType overrides are configured, + // since that is the only situation where we want to avoid node throwing an error. + // Register new extensions. - for (const ext of extensions) { + for (const ext of exts) { registerExtension(ext, service, originalJsHandler); } if (preferTsExts) { const preferredExtensions = new Set([ - ...extensions, + ...exts, ...Object.keys(require.extensions), ]); diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 3b128afdc..b5f8adad4 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -183,6 +183,30 @@ test.suite('ts-node', (test) => { expect(err).toBe(null); expect(stdout).toBe('hello world\n'); }); + + test('should support cts when module = CommonJS', async () => { + const { err, stdout } = await exec( + [ + CMD_TS_NODE_WITH_PROJECT_FLAG, + '-O "{\\"module\\":"CommonJS"}"', + '-pe "import { main } from \'./ts45-ext/ext-cts/index\';main()"', + ].join(' ') + ); + expect(err).toBe(null); + expect(stdout).toBe('hello world\n'); + }); + + test('should support cts when module = ESNext', async () => { + const { err, stdout } = await exec( + [ + CMD_TS_NODE_WITH_PROJECT_FLAG, + '-O "{\\"module\\":"ESNext"}"', + '-pe "import { main } from \'./ts45-ext/ext-mts/index\';main()"', + ].join(' ') + ); + expect(err).toBe(null); + expect(stdout).toBe('hello world\n'); + }); } test('should eval code', async () => { diff --git a/tests/ts45-ext/ext-cts/index.cts b/tests/ts45-ext/ext-cts/index.cts new file mode 100644 index 000000000..9001625ad --- /dev/null +++ b/tests/ts45-ext/ext-cts/index.cts @@ -0,0 +1,3 @@ +export function main() { + return 'hello world'; +} diff --git a/tests/ts45-ext/ext-cts/tsconfig.json b/tests/ts45-ext/ext-cts/tsconfig.json new file mode 100644 index 000000000..28900bb1b --- /dev/null +++ b/tests/ts45-ext/ext-cts/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "module": "CommonJS" + } +} diff --git a/tests/ts45-ext/ext-mts/index.mts b/tests/ts45-ext/ext-mts/index.mts new file mode 100644 index 000000000..9001625ad --- /dev/null +++ b/tests/ts45-ext/ext-mts/index.mts @@ -0,0 +1,3 @@ +export function main() { + return 'hello world'; +} diff --git a/tests/ts45-ext/ext-mts/tsconfig.json b/tests/ts45-ext/ext-mts/tsconfig.json new file mode 100644 index 000000000..1ac61592b --- /dev/null +++ b/tests/ts45-ext/ext-mts/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "module": "ESNext" + } +}