diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 3c824b554..4fd76c31e 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -51,7 +51,7 @@ jobs: matrix: os: [ubuntu, windows] # Don't forget to add all new flavors to this list! - flavor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] + flavor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17] include: # Node 12.15 - flavor: 1 @@ -95,41 +95,64 @@ jobs: nodeFlag: 14 typescript: next typescriptFlag: next + - flavor: 8 + node: 14 + nodeFlag: 14 + typescript: rc + typescriptFlag: rc # Node 16 # Node 16.11.1 # Earliest version that supports old ESM Loader Hooks API: https://github.com/TypeStrong/ts-node/pull/1522 - - flavor: 8 + - flavor: 9 node: 16.11.1 nodeFlag: 16_11_1 typescript: latest typescriptFlag: latest - - flavor: 9 + - flavor: 10 node: 16 nodeFlag: 16 typescript: latest typescriptFlag: latest downgradeNpm: true - - flavor: 10 + - flavor: 11 node: 16 nodeFlag: 16 typescript: 2.7 typescriptFlag: 2_7 downgradeNpm: true - - flavor: 11 + - flavor: 12 node: 16 nodeFlag: 16 typescript: next typescriptFlag: next downgradeNpm: true + - flavor: 13 + node: 16 + nodeFlag: 16 + typescript: rc + typescriptFlag: rc + downgradeNpm: true # Node 18 - - flavor: 12 + - flavor: 14 node: 18 nodeFlag: 18 typescript: latest typescriptFlag: latest downgradeNpm: true + - flavor: 15 + node: 18 + nodeFlag: 18 + typescript: next + typescriptFlag: next + downgradeNpm: true + - flavor: 16 + node: 18 + nodeFlag: 18 + typescript: rc + typescriptFlag: rc + downgradeNpm: true # Node nightly - - flavor: 13 + - flavor: 17 node: nightly nodeFlag: nightly typescript: latest diff --git a/.vscode/launch.json b/.vscode/launch.json index 6a7bf35c8..f597178f1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,29 +10,33 @@ "outputCapture": "std", "skipFiles": [ "/**/*.js" - ], + ] }, { - "name": "Debug resolver tests (example)", + "name": "Debug resolver test", "type": "pwa-node", "request": "launch", - "cwd": "${workspaceFolder}/tests/tmp/resolver-0015-preferSrc-typeModule-allowJs-experimentalSpecifierResolutionNode", + "cwd": "${workspaceFolder}/tests/tmp/resolver-0029-preferSrc-typeModule-allowJs-skipIgnore-experimentalSpecifierResolutionNode", "runtimeArgs": [ - "--loader", "../../../esm.mjs" + "--loader", + "../../../esm.mjs" ], - "program": "./src/entrypoint-0054-src-to-src.mjs" + "program": "./src/entrypoint-0000-src-to-src.cjs" }, { "name": "Debug Example: running a test fixture against local ts-node/esm loader", "type": "pwa-node", "request": "launch", "cwd": "${workspaceFolder}/tests/esm", - "runtimeArgs": ["--loader", "../../ts-node/esm"], + "runtimeArgs": [ + "--loader", + "../../ts-node/esm" + ], "program": "throw error.ts", "outputCapture": "std", "skipFiles": [ "/**/*.js" - ], + ] } ] -} +} \ No newline at end of file diff --git a/development-docs/README.md b/development-docs/README.md new file mode 100644 index 000000000..17ab824fb --- /dev/null +++ b/development-docs/README.md @@ -0,0 +1,10 @@ +This directory contains a variety of documents: + +- notes +- old to-do lists +- design ideas from when I implemented various features +- templates for drafting release notes +- etc + +It is useful to me to keep these notes. If you find their presence +confusing, you can safely ignore this directory. diff --git a/development-docs/nodenextNode16.md b/development-docs/nodenextNode16.md new file mode 100644 index 000000000..89ce02c9a --- /dev/null +++ b/development-docs/nodenextNode16.md @@ -0,0 +1,53 @@ +# Adding support for NodeNext, Node16, `.cts`, `.mts`, `.cjs`, `.mjs` + +*This feature has already been implemented. Here are my notes from when +I was doing the work* + +## TODOs + +Implement node module type classifier: +- if NodeNext or Node12: ask classifier for CJS or ESM determination +Add `ForceNodeNextCJSEmit` + +Does our code check for .d.ts extensions anywhere? +- if so, teach it about .d.cts and .d.mts + +For nodenext and node12, support supplemental "flavor" information: +- + +Think about splitting out index.ts further: +- register.ts - hooking stuff +- types.ts +- env.ts - env vars and global registration types (process.symbol) +- service.ts + +# TESTS + +Matrix: + +- package.json type absent, commonjs, and module +- import and require +- from cjs and esm +- .cts, .cjs +- .mts, .mjs +- typechecking, transpileOnly, and swc +- dynamic import +- import = require +- static import +- allowJs on and off + +Notes about specific matrix entries: +- require mjs, mts from cjs throws error + +Rethink: +`getOutput`: null in transpile-only mode. Also may return emitskipped +`getOutputTranspileOnly`: configured module option +`getOutputForceCommonJS`: `commonjs` module option +`getOutputForceNodeCommonJS`: `nodenext` cjs module option +`getOutputForceESM`: `esnext` module option + +Add second layer of classification to classifier: +if classifier returns `auto` (no `moduleType` override) +- if `getOutput` emits, done +- else call `nodeModuleTypeClassifier` + - delegate to appropriate `getOutput` based on its response diff --git a/notes2.md b/development-docs/rootDirOutDirMapping.md similarity index 93% rename from notes2.md rename to development-docs/rootDirOutDirMapping.md index 93e6bac65..db552772d 100644 --- a/notes2.md +++ b/development-docs/rootDirOutDirMapping.md @@ -1,3 +1,5 @@ +## Musings about resolving between rootDir and outDir + When /dist and /src are understood to be overlaid because of src -> dist compiling /dist/ /src/ diff --git a/NOTES.md b/development-docs/yarnPnpInterop.md similarity index 96% rename from NOTES.md rename to development-docs/yarnPnpInterop.md index 0f0cbf15d..816f921ff 100644 --- a/NOTES.md +++ b/development-docs/yarnPnpInterop.md @@ -1,6 +1,4 @@ -*Delete this file before merging this PR* - -## PnP interop +## Yarn PnP interop Asked about it here: https://discord.com/channels/226791405589233664/654372321225605128/957301175609344070 diff --git a/dist-raw/node-internal-modules-cjs-loader.js b/dist-raw/node-internal-modules-cjs-loader.js index ae17939d6..e2ab5a2e7 100644 --- a/dist-raw/node-internal-modules-cjs-loader.js +++ b/dist-raw/node-internal-modules-cjs-loader.js @@ -5,7 +5,10 @@ 'use strict'; const { + ArrayIsArray, + ArrayPrototypeIncludes, ArrayPrototypeJoin, + ArrayPrototypePush, JSONParse, ObjectKeys, RegExpPrototypeTest, @@ -46,6 +49,8 @@ const { const Module = require('module'); +const isWindows = process.platform === 'win32'; + let statCache = null; function stat(filename) { @@ -133,12 +138,13 @@ function readPackageScope(checkPath) { /** * @param {{ * nodeEsmResolver: ReturnType, - * compiledExtensions: string[], + * extensions: import('../src/file-extensions').Extensions, * preferTsExts * }} opts */ function createCjsLoader(opts) { - const {nodeEsmResolver, compiledExtensions, preferTsExts} = opts; +const {nodeEsmResolver, preferTsExts} = opts; +const {replacementsForCjs, replacementsForJs, replacementsForMjs} = opts.extensions; const { encodedSepRegEx, packageExportsResolve, @@ -209,47 +215,42 @@ function toRealPath(requestPath) { }); } -/** - * 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 extensions = Array.from(new Set([ - ...(preferTsExts ? compiledExtensions : []), - '.js', '.json', '.node', '.mjs', '.cjs', - ...compiledExtensions -])); -const replacementExtensions = { - '.js': extensions.filter(ext => ['.js', '.jsx', '.ts', '.tsx'].includes(ext)), - '.cjs': extensions.filter(ext => ['.cjs', '.cts'].includes(ext)), - '.mjs': extensions.filter(ext => ['.mjs', '.mts'].includes(ext)), -}; - -const replacableExtensionRe = /(\.(?:js|cjs|mjs))$/; - function statReplacementExtensions(p) { - const match = p.match(replacableExtensionRe); - if (match) { - const replacementExts = replacementExtensions[match[1]]; - const pathnameWithoutExtension = p.slice(0, -match[1].length); - for (let i = 0; i < replacementExts.length; i++) { - const filename = pathnameWithoutExtension + replacementExts[i]; - const rc = stat(filename); - if (rc === 0) { - return [rc, filename]; + const lastDotIndex = p.lastIndexOf('.'); + if(lastDotIndex >= 0) { + const ext = p.slice(lastDotIndex); + if (ext === '.js' || ext === '.cjs' || ext === '.mjs') { + const pathnameWithoutExtension = p.slice(0, lastDotIndex); + const replacementExts = + ext === '.js' ? replacementsForJs + : ext === '.mjs' ? replacementsForMjs + : replacementsForCjs; + for (let i = 0; i < replacementExts.length; i++) { + const filename = pathnameWithoutExtension + replacementExts[i]; + const rc = stat(filename); + if (rc === 0) { + return [rc, filename]; + } } } } return [stat(p), p]; } function tryReplacementExtensions(p, isMain) { - const match = p.match(replacableExtensionRe); - if (match) { - const replacementExts = replacementExtensions[match[1]]; - const pathnameWithoutExtension = p.slice(0, -match[1].length); - for (let i = 0; i < replacementExts.length; i++) { - const filename = tryFile(pathnameWithoutExtension + replacementExts[i], isMain); - if (filename) { - return filename; + const lastDotIndex = p.lastIndexOf('.'); + if(lastDotIndex >= 0) { + const ext = p.slice(lastDotIndex); + if (ext === '.js' || ext === '.cjs' || ext === '.mjs') { + const pathnameWithoutExtension = p.slice(0, lastDotIndex); + const replacementExts = + ext === '.js' ? replacementsForJs + : ext === '.mjs' ? replacementsForMjs + : replacementsForCjs; + for (let i = 0; i < replacementExts.length; i++) { + const filename = tryFile(pathnameWithoutExtension + replacementExts[i], isMain); + if (filename) { + return filename; + } } } } @@ -564,11 +565,18 @@ function assertScriptCanLoadAsCJSImpl(service, module, filename) { const pkg = readPackageScope(filename); // ts-node modification: allow our configuration to override - const tsNodeClassification = service.moduleTypeClassifier.classifyModule(normalizeSlashes(filename)); + const tsNodeClassification = service.moduleTypeClassifier.classifyModuleByModuleTypeOverrides(normalizeSlashes(filename)); if(tsNodeClassification.moduleType === 'cjs') return; + // ignore package.json when file extension is ESM-only or CJS-only + // [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] + const lastDotIndex = filename.lastIndexOf('.'); + const ext = lastDotIndex >= 0 ? filename.slice(lastDotIndex) : ''; + + if((ext === '.cts' || ext === '.cjs') && tsNodeClassification.moduleType === 'auto') return; + // Function require shouldn't be used in ES modules. - if (tsNodeClassification.moduleType === 'esm' || (pkg && pkg.data && pkg.data.type === 'module')) { + if (ext === '.mts' || ext === '.mjs' || tsNodeClassification.moduleType === 'esm' || (pkg && pkg.data && pkg.data.type === 'module')) { const parentPath = module.parent && module.parent.filename; const packageJsonPath = pkg ? path.resolve(pkg.path, 'package.json') : null; throw createErrRequireEsm(filename, parentPath, packageJsonPath); @@ -578,5 +586,6 @@ function assertScriptCanLoadAsCJSImpl(service, module, filename) { module.exports = { createCjsLoader, - assertScriptCanLoadAsCJSImpl + assertScriptCanLoadAsCJSImpl, + readPackageScope }; diff --git a/dist-raw/node-internal-modules-esm-resolve.js b/dist-raw/node-internal-modules-esm-resolve.js index b32f8aab1..24df6164c 100644 --- a/dist-raw/node-internal-modules-esm-resolve.js +++ b/dist-raw/node-internal-modules-esm-resolve.js @@ -90,9 +90,18 @@ const DEFAULT_CONDITIONS_SET = new SafeSet(DEFAULT_CONDITIONS); const pendingDeprecation = getOptionValue('--pending-deprecation'); +/** + * @param {{ + * extensions: import('../src/file-extensions').Extensions, + * preferTsExts: boolean | undefined; + * tsNodeExperimentalSpecifierResolution: import('../src/index').ExperimentalSpecifierResolution | undefined; + * }} opts + */ function createResolve(opts) { // TODO receive cached fs implementations here -const {compiledExtensions, preferTsExts, tsNodeExperimentalSpecifierResolution} = opts; +const {preferTsExts, tsNodeExperimentalSpecifierResolution, extensions} = opts; +const esrnExtensions = extensions.experimentalSpecifierResolutionAddsIfOmitted; +const {legacyMainResolveAddsIfOmitted, replacementsForCjs, replacementsForJs, replacementsForMjs} = extensions; // const experimentalSpecifierResolution = tsNodeExperimentalSpecifierResolution ?? getOptionValue('--experimental-specifier-resolution'); const experimentalSpecifierResolution = tsNodeExperimentalSpecifierResolution != null ? tsNodeExperimentalSpecifierResolution : getOptionValue('--experimental-specifier-resolution'); @@ -254,13 +263,13 @@ function legacyMainResolve(packageJSONUrl, packageConfig, base) { packageJSONUrl))) { return guess; } - for(const extension of extensions) { + for(const extension of legacyMainResolveAddsIfOmitted) { if (fileExists(guess = new URL(`./${packageConfig.main}${extension}`, packageJSONUrl))) { return guess; } } - for(const extension of extensions) { + for(const extension of legacyMainResolveAddsIfOmitted) { if (fileExists(guess = new URL(`./${packageConfig.main}/index${extension}`, packageJSONUrl))) { return guess; @@ -268,7 +277,7 @@ function legacyMainResolve(packageJSONUrl, packageConfig, base) { } // Fallthrough. } - for(const extension of extensions) { + for(const extension of legacyMainResolveAddsIfOmitted) { if (fileExists(guess = new URL(`./index${extension}`, packageJSONUrl))) { return guess; } @@ -286,44 +295,33 @@ function resolveExtensionsWithTryExactName(search) { return resolveExtensions(search); } -const extensions = Array.from(new Set([ - ...(preferTsExts ? compiledExtensions : []), - '.js', '.json', '.node', '.mjs', - ...compiledExtensions -])); - // This appends missing extensions function resolveExtensions(search) { - for (let i = 0; i < extensions.length; i++) { - const extension = extensions[i]; + for (let i = 0; i < esrnExtensions.length; i++) { + const extension = esrnExtensions[i]; const guess = new URL(`${search.pathname}${extension}`, search); if (fileExists(guess)) return guess; } return undefined; } -/** - * 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 = { - '.js': extensions.filter(ext => ['.js', '.jsx', '.ts', '.tsx'].includes(ext)), - '.cjs': extensions.filter(ext => ['.cjs', '.cts'].includes(ext)), - '.mjs': extensions.filter(ext => ['.mjs', '.mts'].includes(ext)), -}; - -const replacableExtensionRe = /(\.(?:js|cjs|mjs))$/; /** This replaces JS with TS extensions */ function resolveReplacementExtensions(search) { - const match = search.pathname.match(replacableExtensionRe); - if (match) { - const replacementExts = replacementExtensions[match[1]]; - const pathnameWithoutExtension = search.pathname.slice(0, -match[1].length); - const guess = new URL(search.toString()); - for (let i = 0; i < replacementExts.length; i++) { - const extension = replacementExts[i]; - guess.pathname = `${pathnameWithoutExtension}${extension}`; - if (fileExists(guess)) return guess; + const lastDotIndex = search.pathname.lastIndexOf('.'); + if(lastDotIndex >= 0) { + const ext = search.pathname.slice(lastDotIndex); + if (ext === '.js' || ext === '.cjs' || ext === '.mjs') { + const pathnameWithoutExtension = search.pathname.slice(0, lastDotIndex); + const replacementExts = + ext === '.js' ? replacementsForJs + : ext === '.mjs' ? replacementsForMjs + : replacementsForCjs; + const guess = new URL(search.toString()); + for (let i = 0; i < replacementExts.length; i++) { + const extension = replacementExts[i]; + guess.pathname = `${pathnameWithoutExtension}${extension}`; + if (fileExists(guess)) return guess; + } } } return undefined; diff --git a/notes1.md b/notes1.md deleted file mode 100644 index ee561f48d..000000000 --- a/notes1.md +++ /dev/null @@ -1,9 +0,0 @@ -const patterns = getRegularExpressionsForWildcards(specs, basePath, usage); -const pattern = patterns.map(pattern => `(${pattern})`).join("|"); -// If excluding, match "foo/bar/baz...", but if including, only allow "foo". -const terminator = usage === "exclude" ? "($|/)" : "$"; -return `^(${pattern})${terminator}`; - - -const pattern = spec && getSubPatternFromSpec(spec, basePath, usage, wildcardMatchers[usage]); -return pattern && `^(${pattern})${usage === "exclude" ? "($|/)" : "$"}`; diff --git a/nyc.config.js b/nyc.config.js index 3f15fe94f..5b03f0704 100644 --- a/nyc.config.js +++ b/nyc.config.js @@ -2,6 +2,9 @@ module.exports = { all: true, include: ['tests/node_modules/ts-node/**'], exclude: ['**/*.d.ts', 'tests/node_modules/ts-node/node_modules/**'], + // Very important that nyc does not add additional `require.extensions` hooks. + // It affects module resolution behavior under test + extension: ['.js'], excludeNodeModules: false, excludeAfterRemap: false, }; diff --git a/src/esm.ts b/src/esm.ts index 17bbc16a4..102d32d9b 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -1,10 +1,4 @@ -import { - register, - getExtensions, - RegisterOptions, - Service, - versionGteLt, -} from './index'; +import { register, RegisterOptions, Service, versionGteLt } from './index'; import { parse as parseUrl, format as formatUrl, @@ -328,21 +322,26 @@ 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 - const ext = extname(nativePath); let nodeSays: { format: NodeLoaderHooksFormat }; - const nodeDoesNotUnderstandExt = - extensions.extensionsNodeDoesNotUnderstand.includes(ext); + + // 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); const tsNodeIgnored = tsNodeService.ignored(nativePath); - if (nodeDoesNotUnderstandExt && !tsNodeIgnored) { + const nodeEquivalentExt = extensions.nodeEquivalents.get(ext); + if (nodeEquivalentExt && !tsNodeIgnored) { nodeSays = await entrypointFallback(() => - defer(formatUrl(pathToFileURL(nativePath + '.js'))) + defer(formatUrl(pathToFileURL(nativePath + nodeEquivalentExt))) ); } else { try { nodeSays = await entrypointFallback(defer); } catch (e) { - if (e instanceof Error && tsNodeIgnored && nodeDoesNotUnderstandExt) { + if ( + e instanceof Error && + tsNodeIgnored && + extensions.nodeDoesNotUnderstand.includes(ext) + ) { e.message += `\n\n` + `Hint:\n` + @@ -358,9 +357,10 @@ export function createEsmHooks(tsNodeService: Service) { !tsNodeService.ignored(nativePath) && (nodeSays.format === 'commonjs' || nodeSays.format === 'module') ) { - const { moduleType } = tsNodeService.moduleTypeClassifier.classifyModule( - normalizeSlashes(nativePath) - ); + const { moduleType } = + tsNodeService.moduleTypeClassifier.classifyModuleByModuleTypeOverrides( + normalizeSlashes(nativePath) + ); if (moduleType === 'cjs') { return { format: 'commonjs' }; } else if (moduleType === 'esm') { diff --git a/src/file-extensions.ts b/src/file-extensions.ts new file mode 100644 index 000000000..4f73413fb --- /dev/null +++ b/src/file-extensions.ts @@ -0,0 +1,147 @@ +import type * as _ts from 'typescript'; +import { RegisterOptions, versionGteLt } from '.'; + +/** + * Centralized specification of how we deal with file extensions based on + * project options: + * which ones we do/don't support, in what situations, etc. These rules drive + * logic elsewhere. + * @internal + * */ +export type Extensions = ReturnType; + +const nodeEquivalents = new Map([ + ['.ts', '.js'], + ['.tsx', '.js'], + ['.jsx', '.js'], + ['.mts', '.mjs'], + ['.cts', '.cjs'], +]); + +// All extensions understood by vanilla node +const vanillaNodeExtensions: readonly string[] = [ + '.js', + '.json', + '.node', + '.mjs', + '.cjs', +]; + +// Extensions added by vanilla node's require() if you omit them: +// js, json, node +// Extensions added by vanilla node if you omit them with --experimental-specifier-resolution=node +// js, json, node, mjs +// Extensions added by ESM codepath's legacy package.json "main" resolver +// js, json, node (not mjs!) + +const nodeDoesNotUnderstand: readonly string[] = [ + '.ts', + '.tsx', + '.jsx', + '.cts', + '.mts', +]; + +/** + * [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] + * @internal + */ +export function getExtensions( + config: _ts.ParsedCommandLine, + options: RegisterOptions, + tsVersion: string +) { + // TS 4.5 is first version to understand .cts, .mts, .cjs, and .mjs extensions + const tsSupportsMtsCtsExts = versionGteLt(tsVersion, '4.5.0'); + + const requiresHigherTypescriptVersion: string[] = []; + if (!tsSupportsMtsCtsExts) + requiresHigherTypescriptVersion.push('.cts', '.cjs', '.mts', '.mjs'); + + const allPossibleExtensionsSortedByPreference = Array.from( + new Set([ + ...(options.preferTsExts ? nodeDoesNotUnderstand : []), + ...vanillaNodeExtensions, + ...nodeDoesNotUnderstand, + ]) + ); + + const compiledJsUnsorted: string[] = ['.ts']; + const compiledJsxUnsorted: string[] = []; + + if (config.options.jsx) compiledJsxUnsorted.push('.tsx'); + if (tsSupportsMtsCtsExts) compiledJsUnsorted.push('.mts', '.cts'); + if (config.options.allowJs) { + compiledJsUnsorted.push('.js'); + if (config.options.jsx) compiledJsxUnsorted.push('.jsx'); + if (tsSupportsMtsCtsExts) compiledJsUnsorted.push('.mjs', '.cjs'); + } + + const compiledUnsorted = [...compiledJsUnsorted, ...compiledJsxUnsorted]; + const compiled = allPossibleExtensionsSortedByPreference.filter((ext) => + compiledUnsorted.includes(ext) + ); + + const compiledNodeDoesNotUnderstand = nodeDoesNotUnderstand.filter((ext) => + compiled.includes(ext) + ); + + /** + * TS's resolver can resolve foo.js to foo.ts, by replacing .js extension with several source extensions. + * IMPORTANT: Must preserve ordering according to preferTsExts! + * Must include the .js/.mjs/.cjs extension in the array! + * This affects resolution behavior! + * [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] + */ + const r = allPossibleExtensionsSortedByPreference.filter((ext) => + [...compiledUnsorted, '.js', '.mjs', '.cjs', '.mts', '.cts'].includes(ext) + ); + const replacementsForJs = r.filter((ext) => + ['.js', '.jsx', '.ts', '.tsx'].includes(ext) + ); + const replacementsForMjs = r.filter((ext) => ['.mjs', '.mts'].includes(ext)); + const replacementsForCjs = r.filter((ext) => ['.cjs', '.cts'].includes(ext)); + const replacementsForJsOrMjs = r.filter((ext) => + ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.mts'].includes(ext) + ); + + // Node allows omitting .js or .mjs extension in certain situations (CJS, ESM w/experimental flag) + // So anything that compiles to .js or .mjs can also be omitted. + const experimentalSpecifierResolutionAddsIfOmitted = Array.from( + new Set([...replacementsForJsOrMjs, '.json', '.node']) + ); + // Same as above, except node curiuosly doesn't do .mjs here + const legacyMainResolveAddsIfOmitted = Array.from( + new Set([...replacementsForJs, '.json', '.node']) + ); + + return { + /** All file extensions we transform, ordered by resolution preference according to preferTsExts */ + compiled, + /** Resolved extensions that vanilla node will not understand; we should handle them */ + nodeDoesNotUnderstand, + /** Like the above, but only the ones we're compiling */ + compiledNodeDoesNotUnderstand, + /** + * Mapping from extensions understood by tsc to the equivalent for node, + * as far as getFormat is concerned. + */ + nodeEquivalents, + /** + * Extensions that we can support if the user upgrades their typescript version. + * Used when raising hints. + */ + requiresHigherTypescriptVersion, + /** + * --experimental-specifier-resolution=node will add these extensions. + */ + experimentalSpecifierResolutionAddsIfOmitted, + /** + * ESM loader will add these extensions to package.json "main" field + */ + legacyMainResolveAddsIfOmitted, + replacementsForMjs, + replacementsForCjs, + replacementsForJs, + }; +} diff --git a/src/index.ts b/src/index.ts index d38213fbd..a0079bd24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import type { Transpiler, TranspilerFactory } from './transpilers/types'; import { cachedLookup, createProjectLocalResolveHelper, + hasOwnProperty, normalizeSlashes, once, parse, @@ -30,9 +31,12 @@ import { installCommonjsResolveHooksIfNecessary, ModuleConstructorWithInternals, } from './cjs-resolve-hooks'; +import { classifyModule } from './node-module-type-classifier'; import type * as _nodeInternalModulesEsmResolve from '../dist-raw/node-internal-modules-esm-resolve'; import type * as _nodeInternalModulesEsmGetFormat from '../dist-raw/node-internal-modules-esm-get_format'; import type * as _nodeInternalModulesCjsLoader from '../dist-raw/node-internal-modules-cjs-loader'; +import { Extensions, getExtensions } from './file-extensions'; +import { createTsTranspileModule } from './ts-transpile-module'; export { TSCommon }; export { @@ -347,8 +351,9 @@ export interface CreateOptions { experimentalReplAwait?: boolean; /** * Override certain paths to be compiled and executed as CommonJS or ECMAScript modules. - * When overridden, the tsconfig "module" and package.json "type" fields are overridden. - * This is useful because TypeScript files cannot use the .cjs nor .mjs file extensions; + * When overridden, the tsconfig "module" and package.json "type" fields are overridden, and + * the file extension is ignored. + * This is useful if you cannot use .mts, .cts, .mjs, or .cjs file extensions; * it achieves the same effect. * * Each key is a glob pattern following the same rules as tsconfig's "include" array. @@ -390,7 +395,8 @@ export interface CreateOptions { preferTsExts?: boolean; } -export type ModuleTypes = Record; +export type ModuleTypes = Record; +export type ModuleTypeOverride = 'cjs' | 'esm' | 'package'; /** @internal */ export interface OptionBasePaths { @@ -419,6 +425,8 @@ export interface RegisterOptions extends CreateOptions { experimentalSpecifierResolution?: 'node' | 'explicit'; } +export type ExperimentalSpecifierResolution = 'node' | 'explicit'; + /** * Must be an interface to support `typescript-json-schema`. */ @@ -573,62 +581,9 @@ export interface DiagnosticFilter { diagnosticsIgnored: number[]; } -/** - * Centralized specification of how we deal with file extensions based on - * project options: - * which ones we do/don't support, in what situations, etc. These rules drive - * logic elsewhere. - * @internal - * */ -export type Extensions = ReturnType; - -/** - * @internal - */ -export function getExtensions( - config: _ts.ParsedCommandLine, - options: RegisterOptions -) { - const compiledExtensions: string[] = []; - const extensionsNodeDoesNotUnderstand = [ - '.ts', - '.tsx', - '.jsx', - '.cts', - '.mts', - ]; - - // .js, .cjs, .mjs take precedence if preferTsExts is off - if (!options.preferTsExts && config.options.allowJs) - compiledExtensions.push('.js'); - - compiledExtensions.push('.ts'); - - // Enable additional extensions when JSX or `allowJs` is enabled. - if (config.options.jsx) compiledExtensions.push('.tsx'); - if (config.options.jsx && config.options.allowJs) - compiledExtensions.push('.jsx'); - if (config.options.preferTsExt && config.options.allowJs) - compiledExtensions.push('.js'); - - const compiledExtensionsNodeDoesNotUnderstand = - extensionsNodeDoesNotUnderstand.filter((ext) => - compiledExtensions.includes(ext) - ); - - return { - /** All file extensions we transform, ordered by resolution preference according to preferTsExts */ - compiledExtensions, - /** Resolved extensions that vanilla node will not understand; we should handle them */ - extensionsNodeDoesNotUnderstand, - /** Like the above, but only the ones we're compiling */ - compiledExtensionsNodeDoesNotUnderstand, - }; -} - /** * Create a new TypeScript compiler instance and register it onto node.js - + * * @category Basic */ export function register(opts?: RegisterOptions): Service; @@ -649,7 +604,6 @@ export function register( } const originalJsHandler = require.extensions['.js']; - const { compiledExtensions } = getExtensions(service.config, service.options); // Expose registered instance globally. process[REGISTER_INSTANCE] = service; @@ -657,7 +611,7 @@ export function register( // Register the extensions. registerExtensions( service.options.preferTsExts, - compiledExtensions, + service.extensions.compiled, service, originalJsHandler ); @@ -797,6 +751,7 @@ export function createFromPreloadedConfig( const diagnosticHost: _ts.FormatDiagnosticsHost = { getNewLine: () => ts.sys.newLine, getCurrentDirectory: () => cwd, + // TODO switch to getCanonicalFileName we already create later in scope getCanonicalFileName: ts.sys.useCaseSensitiveFileNames ? (x) => x : (x) => x.toLowerCase(), @@ -807,41 +762,47 @@ export function createFromPreloadedConfig( 'Transformers function is unavailable in "--transpile-only"' ); } - let createTranspiler: - | ((compilerOptions: TSCommon.CompilerOptions) => Transpiler) - | undefined; - if (transpiler) { - if (!transpileOnly) - throw new Error( - 'Custom transpiler can only be used when transpileOnly is enabled.' + let createTranspiler = initializeTranspilerFactory(); + function initializeTranspilerFactory() { + if (transpiler) { + if (!transpileOnly) + throw new Error( + 'Custom transpiler can only be used when transpileOnly is enabled.' + ); + const transpilerName = + typeof transpiler === 'string' ? transpiler : transpiler[0]; + const transpilerOptions = + typeof transpiler === 'string' ? {} : transpiler[1] ?? {}; + const transpilerConfigLocalResolveHelper = transpilerBasePath + ? createProjectLocalResolveHelper(transpilerBasePath) + : projectLocalResolveHelper; + const transpilerPath = transpilerConfigLocalResolveHelper( + transpilerName, + true ); - const transpilerName = - typeof transpiler === 'string' ? transpiler : transpiler[0]; - const transpilerOptions = - typeof transpiler === 'string' ? {} : transpiler[1] ?? {}; - const transpilerConfigLocalResolveHelper = transpilerBasePath - ? createProjectLocalResolveHelper(transpilerBasePath) - : projectLocalResolveHelper; - const transpilerPath = transpilerConfigLocalResolveHelper( - transpilerName, - true - ); - const transpilerFactory = require(transpilerPath) - .create as TranspilerFactory; - createTranspiler = function (compilerOptions) { - return transpilerFactory({ - service: { - options, - config: { - ...config, - options: compilerOptions, + const transpilerFactory = require(transpilerPath) + .create as TranspilerFactory; + return createTranspiler; + + function createTranspiler( + compilerOptions: TSCommon.CompilerOptions, + nodeModuleEmitKind?: NodeModuleEmitKind + ) { + return transpilerFactory?.({ + service: { + options, + config: { + ...config, + options: compilerOptions, + }, + projectLocalResolveHelper, }, - projectLocalResolveHelper, - }, - transpilerConfigLocalResolveHelper, - ...transpilerOptions, - }); - }; + transpilerConfigLocalResolveHelper, + nodeModuleEmitKind, + ...transpilerOptions, + }); + } + } } /** @@ -919,19 +880,39 @@ export function createFromPreloadedConfig( // Render the configuration errors. if (configDiagnosticList.length) reportTSError(configDiagnosticList); + const jsxEmitPreserve = config.options.jsx === ts.JsxEmit.Preserve; /** * Get the extension for a transpiled file. + * [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] */ - const getExtension = - config.options.jsx === ts.JsxEmit.Preserve - ? (path: string) => (/\.[tj]sx$/.test(path) ? '.jsx' : '.js') - : (_: string) => '.js'; + function getEmitExtension(path: string) { + const lastDotIndex = path.lastIndexOf('.'); + if (lastDotIndex >= 0) { + const ext = path.slice(lastDotIndex); + switch (ext) { + case '.js': + case '.ts': + return '.js'; + case '.jsx': + case '.tsx': + return jsxEmitPreserve ? '.jsx' : '.js'; + case '.mjs': + case '.mts': + return '.mjs'; + case '.cjs': + case '.cts': + return '.cjs'; + } + } + return '.js'; + } type GetOutputFunction = (code: string, fileName: string) => SourceOutput; /** - * Create the basic required function using transpile mode. + * Get output from TS compiler w/typechecking. `undefined` in `transpileOnly` + * mode. */ - let getOutput: GetOutputFunction; + let getOutput: GetOutputFunction | undefined; let getTypeInfo: ( _code: string, _fileName: string, @@ -1339,8 +1320,6 @@ export function createFromPreloadedConfig( } } } else { - getOutput = createTranspileOnlyGetOutputFunction(); - getTypeInfo = () => { throw new TypeError( 'Type information is unavailable in "--transpile-only"' @@ -1349,18 +1328,37 @@ export function createFromPreloadedConfig( } function createTranspileOnlyGetOutputFunction( - overrideModuleType?: _ts.ModuleKind + overrideModuleType?: _ts.ModuleKind, + nodeModuleEmitKind?: NodeModuleEmitKind ): GetOutputFunction { const compilerOptions = { ...config.options }; if (overrideModuleType !== undefined) compilerOptions.module = overrideModuleType; - let customTranspiler = createTranspiler?.(compilerOptions); + let customTranspiler = createTranspiler?.( + compilerOptions, + nodeModuleEmitKind + ); + let tsTranspileModule = versionGteLt(ts.version, '4.7.0') + ? createTsTranspileModule(ts, { + compilerOptions, + reportDiagnostics: true, + transformers: transformers as _ts.CustomTransformers | undefined, + }) + : undefined; return (code: string, fileName: string): SourceOutput => { let result: _ts.TranspileOutput; if (customTranspiler) { result = customTranspiler.transpile(code, { fileName, }); + } else if (tsTranspileModule) { + result = tsTranspileModule( + code, + { + fileName, + }, + nodeModuleEmitKind === 'nodeesm' ? 'module' : 'commonjs' + ); } else { result = ts.transpileModule(code, { fileName, @@ -1380,44 +1378,86 @@ export function createFromPreloadedConfig( }; } - // When either is undefined, it means normal `getOutput` should be used - const getOutputForceCommonJS = - config.options.module === ts.ModuleKind.CommonJS - ? undefined - : createTranspileOnlyGetOutputFunction(ts.ModuleKind.CommonJS); + // When true, these mean that a `moduleType` override will cause a different emit + // than the TypeScript compiler, so we *must* overwrite the emit. + const shouldOverwriteEmitWhenForcingCommonJS = + config.options.module !== ts.ModuleKind.CommonJS; // [MUST_UPDATE_FOR_NEW_MODULEKIND] - const getOutputForceESM = + const shouldOverwriteEmitWhenForcingEsm = !( config.options.module === ts.ModuleKind.ES2015 || (ts.ModuleKind.ES2020 && config.options.module === ts.ModuleKind.ES2020) || (ts.ModuleKind.ES2022 && config.options.module === ts.ModuleKind.ES2022) || config.options.module === ts.ModuleKind.ESNext - ? undefined - : // [MUST_UPDATE_FOR_NEW_MODULEKIND] - createTranspileOnlyGetOutputFunction( - ts.ModuleKind.ES2022 || ts.ModuleKind.ES2020 || ts.ModuleKind.ES2015 - ); + ); + /** + * node16 or nodenext + * [MUST_UPDATE_FOR_NEW_MODULEKIND] + */ + const isNodeModuleType = + (ts.ModuleKind.Node16 && config.options.module === ts.ModuleKind.Node16) || + (ts.ModuleKind.NodeNext && + config.options.module === ts.ModuleKind.NodeNext); + const getOutputForceCommonJS = createTranspileOnlyGetOutputFunction( + ts.ModuleKind.CommonJS + ); + const getOutputForceNodeCommonJS = createTranspileOnlyGetOutputFunction( + ts.ModuleKind.NodeNext, + 'nodecjs' + ); + const getOutputForceNodeESM = createTranspileOnlyGetOutputFunction( + ts.ModuleKind.NodeNext, + 'nodeesm' + ); + // [MUST_UPDATE_FOR_NEW_MODULEKIND] + const getOutputForceESM = createTranspileOnlyGetOutputFunction( + ts.ModuleKind.ES2022 || ts.ModuleKind.ES2020 || ts.ModuleKind.ES2015 + ); const getOutputTranspileOnly = createTranspileOnlyGetOutputFunction(); // Create a simple TypeScript compiler proxy. function compile(code: string, fileName: string, lineOffset = 0) { const normalizedFileName = normalizeSlashes(fileName); const classification = - moduleTypeClassifier.classifyModule(normalizedFileName); - // Must always call normal getOutput to throw typechecking errors - let [value, sourceMap, emitSkipped] = getOutput(code, normalizedFileName); + moduleTypeClassifier.classifyModuleByModuleTypeOverrides( + normalizedFileName + ); + let value: string | undefined = ''; + let sourceMap: string | undefined = ''; + let emitSkipped = true; + if (getOutput) { + // Must always call normal getOutput to throw typechecking errors + [value, sourceMap, emitSkipped] = getOutput(code, normalizedFileName); + } // If module classification contradicts the above, call the relevant transpiler - if (classification.moduleType === 'cjs' && getOutputForceCommonJS) { + if ( + classification.moduleType === 'cjs' && + (shouldOverwriteEmitWhenForcingCommonJS || emitSkipped) + ) { [value, sourceMap] = getOutputForceCommonJS(code, normalizedFileName); - } else if (classification.moduleType === 'esm' && getOutputForceESM) { + } else if ( + classification.moduleType === 'esm' && + (shouldOverwriteEmitWhenForcingEsm || emitSkipped) + ) { [value, sourceMap] = getOutputForceESM(code, normalizedFileName); } else if (emitSkipped) { - [value, sourceMap] = getOutputTranspileOnly(code, normalizedFileName); + // Happens when ts compiler skips emit or in transpileOnly mode + const classification = classifyModule(fileName, isNodeModuleType); + [value, sourceMap] = + classification === 'nodecjs' + ? getOutputForceNodeCommonJS(code, normalizedFileName) + : classification === 'nodeesm' + ? getOutputForceNodeESM(code, normalizedFileName) + : classification === 'cjs' + ? getOutputForceCommonJS(code, normalizedFileName) + : classification === 'esm' + ? getOutputForceESM(code, normalizedFileName) + : getOutputTranspileOnly(code, normalizedFileName); } const output = updateOutput( value!, normalizedFileName, sourceMap!, - getExtension + getEmitExtension ); outputCache.set(normalizedFileName, { content: output }); return output; @@ -1426,11 +1466,11 @@ export function createFromPreloadedConfig( let active = true; const enabled = (enabled?: boolean) => enabled === undefined ? active : (active = !!enabled); - const extensions = getExtensions(config, options); + const extensions = getExtensions(config, options, ts.version); const ignored = (fileName: string) => { if (!active) return true; const ext = extname(fileName); - if (extensions.compiledExtensions.includes(ext)) { + if (extensions.compiled.includes(ext)) { return !isScoped(fileName) || shouldIgnore(fileName); } return true; @@ -1449,7 +1489,7 @@ export function createFromPreloadedConfig( ( require('../dist-raw/node-internal-modules-esm-resolve') as typeof _nodeInternalModulesEsmResolve ).createResolve({ - ...extensions, + extensions, preferTsExts: options.preferTsExts, tsNodeExperimentalSpecifierResolution: options.experimentalSpecifierResolution, @@ -1467,7 +1507,7 @@ export function createFromPreloadedConfig( ( require('../dist-raw/node-internal-modules-cjs-loader') as typeof _nodeInternalModulesCjsLoader ).createCjsLoader({ - ...extensions, + extensions, preferTsExts: options.preferTsExts, nodeEsmResolver: getNodeEsmResolver(), }) @@ -1510,17 +1550,6 @@ function createIgnore(ignoreBaseDir: string, ignore: RegExp[]) { }; } -/** - * "Refreshes" an extension on `require.extensions`. - * - * @param {string} ext - */ -function reorderRequireExtension(ext: string) { - const old = require.extensions[ext]; - delete require.extensions[ext]; - require.extensions[ext] = old; -} - /** * Register the extensions to support when importing files. */ @@ -1530,18 +1559,35 @@ function registerExtensions( service: Service, originalJsHandler: (m: NodeModule, filename: string) => any ) { + const exts = new Set(extensions); + // Can't add these extensions cuz would allow omitting file extension; node requires ext for .cjs and .mjs + // Unless they're already registered by something else (nyc does this): + // then we *must* hook them or else our transformer will not be called. + for (const cannotAdd of ['.mts', '.cts', '.mjs', '.cjs']) { + if (exts.has(cannotAdd) && !hasOwnProperty(require.extensions, cannotAdd)) { + // Unrecognized file exts can be transformed via the `.js` handler. + exts.add('.js'); + exts.delete(cannotAdd); + } + } + // 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), ]); - for (const ext of preferredExtensions) reorderRequireExtension(ext); + // Re-sort iteration order of Object.keys() + for (const ext of preferredExtensions) { + const old = Object.getOwnPropertyDescriptor(require.extensions, ext); + delete require.extensions[ext]; + Object.defineProperty(require.extensions, ext, old!); + } } } @@ -1585,7 +1631,7 @@ function updateOutput( outputText: string, fileName: string, sourceMap: string, - getExtension: (fileName: string) => string + getEmitExtension: (fileName: string) => string ) { const base64Map = Buffer.from( updateSourceMap(sourceMap, fileName), @@ -1598,7 +1644,7 @@ function updateOutput( const prefixLength = prefix.length; const baseName = /*foo.tsx*/ basename(fileName); const extName = /*.tsx*/ extname(fileName); - const extension = /*.js*/ getExtension(fileName); + const extension = /*.js*/ getEmitExtension(fileName); const sourcemapFilename = baseName.slice(0, -extName.length) + extension + '.map'; const sourceMapLengthWithoutPercentEncoding = @@ -1705,3 +1751,11 @@ function getTokenAtPosition( export const createEsmHooks: typeof createEsmHooksFn = ( tsNodeService: Service ) => (require('./esm') as typeof import('./esm')).createEsmHooks(tsNodeService); + +/** + * When using `module: nodenext` or `module: node12`, there are two possible styles of emit depending in file extension or package.json "type": + * + * - CommonJS with dynamic imports preserved (not transformed into `require()` calls) + * - ECMAScript modules with `import foo = require()` transformed into `require = createRequire(); const foo = require()` + */ +export type NodeModuleEmitKind = 'nodeesm' | 'nodecjs'; diff --git a/src/module-type-classifier.ts b/src/module-type-classifier.ts index dfe153289..34a4fba5c 100644 --- a/src/module-type-classifier.ts +++ b/src/module-type-classifier.ts @@ -1,20 +1,24 @@ -import { dirname } from 'path'; +import type { ModuleTypeOverride, ModuleTypes } from '.'; import { getPatternFromSpec } from './ts-internals'; import { cachedLookup, normalizeSlashes } from './util'; -// Logic to support out `moduleTypes` option, which allows overriding node's default ESM / CJS +// Logic to support our `moduleTypes` option, which allows overriding node's default ESM / CJS // classification of `.js` files based on package.json `type` field. -/** @internal */ -export type ModuleType = 'cjs' | 'esm' | 'package'; +/** + * Seperate internal type because `auto` is clearer than `package`, but changing + * the public API is a breaking change. + * @internal + */ +export type InternalModuleTypeOverride = 'cjs' | 'esm' | 'auto'; /** @internal */ export interface ModuleTypeClassification { - moduleType: ModuleType; + moduleType: InternalModuleTypeOverride; } /** @internal */ export interface ModuleTypeClassifierOptions { basePath?: string; - patterns?: Record; + patterns?: ModuleTypes; } /** @internal */ export type ModuleTypeClassifier = ReturnType< @@ -42,17 +46,18 @@ export function createModuleTypeClassifier( } ); - const classifications: Record = { - package: { - moduleType: 'package', - }, - cjs: { - moduleType: 'cjs', - }, - esm: { - moduleType: 'esm', - }, - }; + const classifications: Record = + { + package: { + moduleType: 'auto', + }, + cjs: { + moduleType: 'cjs', + }, + esm: { + moduleType: 'esm', + }, + }; const auto = classifications.package; // Passed path must be normalized! @@ -69,7 +74,7 @@ export function createModuleTypeClassifier( } return { - classifyModule: patternTypePairs.length + classifyModuleByModuleTypeOverrides: patternTypePairs.length ? classifyModule : classifyModuleAuto, }; diff --git a/src/node-module-type-classifier.ts b/src/node-module-type-classifier.ts new file mode 100644 index 000000000..19a9e8361 --- /dev/null +++ b/src/node-module-type-classifier.ts @@ -0,0 +1,37 @@ +import { readPackageScope } from '../dist-raw/node-internal-modules-cjs-loader'; + +/** + * Determine how to emit a module based on tsconfig "module" and package.json "type" + * + * Supports module=nodenext/node16 with transpileOnly, where we cannot ask the + * TS typechecker to tell us if a file is CJS or ESM. + * + * Return values indicate: + * - cjs + * - esm + * - nodecjs == node-flavored cjs where dynamic imports are *not* transformed into `require()` + * - undefined == emit according to tsconfig `module` config, whatever that is + * @internal + */ +export function classifyModule( + nativeFilename: string, + isNodeModuleType: boolean +): 'nodecjs' | 'cjs' | 'esm' | 'nodeesm' | undefined { + // [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] + const lastDotIndex = nativeFilename.lastIndexOf('.'); + const ext = lastDotIndex >= 0 ? nativeFilename.slice(lastDotIndex) : ''; + switch (ext) { + case '.cjs': + case '.cts': + return isNodeModuleType ? 'nodecjs' : 'cjs'; + case '.mjs': + case '.mts': + return isNodeModuleType ? 'nodeesm' : 'esm'; + } + if (isNodeModuleType) { + const packageScope = readPackageScope(nativeFilename); + if (packageScope && packageScope.data.type === 'module') return 'nodeesm'; + return 'nodecjs'; + } + return undefined; +} diff --git a/src/resolver-functions.ts b/src/resolver-functions.ts index ecb5d98e2..9884c29c8 100644 --- a/src/resolver-functions.ts +++ b/src/resolver-functions.ts @@ -64,12 +64,17 @@ export function createResolverFunctions(kwargs: { ) => { const { resolvedFileName } = resolvedModule; if (resolvedFileName === undefined) return; - // .ts is always switched to internal + // [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] + // .ts,.mts,.cts is always switched to internal // .js is switched on-demand if ( resolvedModule.isExternalLibraryImport && ((resolvedFileName.endsWith('.ts') && !resolvedFileName.endsWith('.d.ts')) || + (resolvedFileName.endsWith('.cts') && + !resolvedFileName.endsWith('.d.cts')) || + (resolvedFileName.endsWith('.mts') && + !resolvedFileName.endsWith('.d.mts')) || isFileKnownToBeInternal(resolvedFileName) || isFileInInternalBucket(resolvedFileName)) ) { diff --git a/src/test/fs-helpers.ts b/src/test/fs-helpers.ts index 3f609a34d..e34d2d816 100644 --- a/src/test/fs-helpers.ts +++ b/src/test/fs-helpers.ts @@ -41,6 +41,7 @@ export function tempdirProject(name = '') { const tmpdir = fs.mkdtempSync(`${TEST_DIR}/tmp/${name}`); return projectInternal(tmpdir); } + export type Project = ReturnType; export function project(name: string) { return projectInternal(`${TEST_DIR}/tmp/${name}`); diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 23b05d8e6..0a58c5a6b 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -75,6 +75,11 @@ export const tsSupportsTsconfigInheritanceViaNodePackages = semver.gte( ); /** Supports --showConfig: >= v3.2.0 */ export const tsSupportsShowConfig = semver.gte(ts.version, '3.2.0'); +/** Supports module:nodenext and module:node16 as *stable* features */ +export const tsSupportsStableNodeNextNode16 = + ts.version.startsWith('4.7.') || semver.gte(ts.version, '4.7.0'); +// TS 4.5 is first version to understand .cts, .mts, .cjs, and .mjs extensions +export const tsSupportsMtsCtsExtensions = semver.gte(ts.version, '4.5.0'); //#endregion export const xfs = new NodeFS(fs); @@ -201,10 +206,6 @@ export function getStream(stream: Readable, waitForPattern?: string | RegExp) { //#region Reset node environment -// Delete any added by nyc that aren't in vanilla nodejs -for (const ext of Object.keys(require.extensions)) { - if (!['.js', '.json', '.node'].includes(ext)) delete require.extensions[ext]; -} const defaultRequireExtensions = captureObjectState(require.extensions); // Avoid node deprecation warning for accessing _channel const defaultProcess = captureObjectState(process, ['_channel']); @@ -245,7 +246,7 @@ export function resetNodeEnvironment() { resetObject(Error, defaultError); // _resolveFilename et.al. are modified by ts-node, tsconfig-paths, source-map-support, yarn, maybe other things? - resetObject(require('module'), defaultModule); + resetObject(require('module'), defaultModule, undefined, ['wrap', 'wrapper']); // May be modified by REPL tests, since the REPL sets globals. // Avoid deleting nyc's coverage data. @@ -271,7 +272,7 @@ function resetObject( object: any, state: ReturnType, doNotDeleteTheseKeys: string[] = [], - doNotSetTheseKeys: string[] = [], + doNotSetTheseKeys: true | string[] = [], avoidSetterIfUnchanged: string[] = [], reorderProperties = false ) { @@ -284,10 +285,11 @@ function resetObject( // Trigger nyc's setter functions for (const [key, value] of Object.entries(state.values)) { try { - if (doNotSetTheseKeys.includes(key)) continue; + if (doNotSetTheseKeys === true || doNotSetTheseKeys.includes(key)) + continue; if (avoidSetterIfUnchanged.includes(key) && object[key] === value) continue; - object[key] = value; + state.descriptors[key].set?.call(object, value); } catch {} } // Reset descriptors @@ -310,3 +312,8 @@ function resetObject( //#endregion export const delay = promisify(setTimeout); + +/** Essentially Array:includes, but with tweaked types for checks on enums */ +export function isOneOf(value: V, arrayOfPossibilities: ReadonlyArray) { + return arrayOfPossibilities.includes(value as any); +} diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 5571a2480..ca4c2cf85 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -1,4 +1,4 @@ -import { context } from './testlib'; +import { context, ExecutionContext } from './testlib'; import * as expect from 'expect'; import { join, resolve, sep as pathSep } from 'path'; import { tmpdir } from 'os'; @@ -7,8 +7,11 @@ import { BIN_PATH_JS, CMD_TS_NODE_WITH_PROJECT_TRANSPILE_ONLY_FLAG, nodeSupportsEsmHooks, + nodeSupportsSpawningChildProcess, ts, + tsSupportsMtsCtsExtensions, tsSupportsShowConfig, + tsSupportsStableNodeNextNode16, tsSupportsTsconfigInheritanceViaNodePackages, } from './helpers'; import { lstatSync, mkdtempSync } from 'fs'; @@ -30,6 +33,7 @@ import { CMD_TS_NODE_WITHOUT_PROJECT_FLAG, CMD_ESM_LOADER_WITHOUT_PROJECT, } from './helpers'; +import type { CreateOptions } from '..'; const exec = createExec({ cwd: TEST_DIR, @@ -193,6 +197,39 @@ test.suite('ts-node', (test) => { expect(stdout).toBe('hello world\n'); }); + test.suite('should support cts when module = CommonJS', (test) => { + test.runIf(tsSupportsMtsCtsExtensions); + test('test', async (t) => { + const { err, stdout } = await exec( + [ + CMD_TS_NODE_WITHOUT_PROJECT_FLAG, + '-pe "import { main } from \'./index.cjs\';main()"', + ].join(' '), + { + cwd: join(TEST_DIR, 'ts45-ext/ext-cts'), + } + ); + expect(err).toBe(null); + expect(stdout).toBe('hello world\n'); + }); + }); + + test.suite('should support mts when module = ESNext', (test) => { + test.runIf( + nodeSupportsSpawningChildProcess && tsSupportsMtsCtsExtensions + ); + test('test', async () => { + const { err, stdout } = await exec( + [CMD_TS_NODE_WITHOUT_PROJECT_FLAG, './entrypoint.mjs'].join(' '), + { + cwd: join(TEST_DIR, 'ts45-ext/ext-mts'), + } + ); + expect(err).toBe(null); + expect(stdout).toBe('hello world\n'); + }); + }); + test('should eval code', async () => { const { err, stdout } = await exec( `${CMD_TS_NODE_WITH_PROJECT_TRANSPILE_ONLY_FLAG} -e "import * as m from './module';console.log(m.example('test'))"` @@ -981,64 +1018,76 @@ test.suite('ts-node', (test) => { }); test.suite('issue #1098', (test) => { - function testIgnored( - ignored: tsNodeTypes.Service['ignored'], - allowed: string[], - disallowed: string[] + function testAllowedExtensions( + t: ExecutionContext, + compilerOptions: CreateOptions['compilerOptions'], + allowed: string[] ) { + const disallowed = allExtensions.filter((ext) => !allowed.includes(ext)); + const { ignored } = t.context.tsNodeUnderTest.create({ + compilerOptions, + skipProject: true, + }); for (const ext of allowed) { - // should accept ${ext} files + t.log(`Testing that ${ext} files are allowed`); expect(ignored(join(DIST_DIR, `index${ext}`))).toBe(false); } for (const ext of disallowed) { - // should ignore ${ext} files + t.log(`Testing that ${ext} files are ignored`); expect(ignored(join(DIST_DIR, `index${ext}`))).toBe(true); } } + const allExtensions = [ + '.ts', + '.js', + '.d.ts', + '.mts', + '.cts', + '.d.mts', + '.d.cts', + '.mjs', + '.cjs', + '.tsx', + '.jsx', + '.xyz', + '', + ]; + const mtsCts = tsSupportsMtsCtsExtensions + ? ['.mts', '.cts', '.d.mts', '.d.cts'] + : []; + const mjsCjs = tsSupportsMtsCtsExtensions ? ['.mjs', '.cjs'] : []; + test('correctly filters file extensions from the compiler when allowJs=false and jsx=false', (t) => { - const { ignored } = t.context.tsNodeUnderTest.create({ - compilerOptions: {}, - skipProject: true, - }); - testIgnored( - ignored, - ['.ts', '.d.ts'], - ['.js', '.tsx', '.jsx', '.mjs', '.cjs', '.xyz', ''] - ); + testAllowedExtensions(t, {}, ['.ts', '.d.ts', ...mtsCts]); }); test('correctly filters file extensions from the compiler when allowJs=true and jsx=false', (t) => { - const { ignored } = t.context.tsNodeUnderTest.create({ - compilerOptions: { allowJs: true }, - skipProject: true, - }); - testIgnored( - ignored, - ['.ts', '.js', '.d.ts'], - ['.tsx', '.jsx', '.mjs', '.cjs', '.xyz', ''] - ); + testAllowedExtensions(t, { allowJs: true }, [ + '.ts', + '.js', + '.d.ts', + ...mtsCts, + ...mjsCjs, + ]); }); test('correctly filters file extensions from the compiler when allowJs=false and jsx=true', (t) => { - const { ignored } = t.context.tsNodeUnderTest.create({ - compilerOptions: { allowJs: false, jsx: 'preserve' }, - skipProject: true, - }); - testIgnored( - ignored, - ['.ts', '.tsx', '.d.ts'], - ['.js', '.jsx', '.mjs', '.cjs', '.xyz', ''] - ); + testAllowedExtensions(t, { allowJs: false, jsx: 'preserve' }, [ + '.ts', + '.tsx', + '.d.ts', + ...mtsCts, + ]); }); test('correctly filters file extensions from the compiler when allowJs=true and jsx=true', (t) => { - const { ignored } = t.context.tsNodeUnderTest.create({ - compilerOptions: { allowJs: true, jsx: 'preserve' }, - skipProject: true, - }); - testIgnored( - ignored, - ['.ts', '.tsx', '.js', '.jsx', '.d.ts'], - ['.mjs', '.cjs', '.xyz', ''] - ); + testAllowedExtensions(t, { allowJs: true, jsx: 'preserve' }, [ + '.ts', + '.tsx', + '.js', + '.jsx', + '.d.ts', + ...mtsCts, + ...mjsCjs, + ]); }); }); }); @@ -1119,8 +1168,8 @@ test('Detect when typescript adds new ModuleKind values; flag as a failure so we expect(ts.ModuleKind[99]).toBeUndefined(); } check(7, 'ES2022', false); - if (ts.version.startsWith('4.8.') || semver.gte(ts.version, '4.8.0')) { - check(100, 'Node16', false); + if (tsSupportsStableNodeNextNode16) { + check(100, 'Node16', true); } else { check(100, 'Node12', false); } diff --git a/src/test/module-node.spec.ts b/src/test/module-node.spec.ts new file mode 100644 index 000000000..b182444ea --- /dev/null +++ b/src/test/module-node.spec.ts @@ -0,0 +1,366 @@ +import { expect, context } from './testlib'; +import { + CMD_TS_NODE_WITHOUT_PROJECT_FLAG, + isOneOf, + resetNodeEnvironment, + tsSupportsStableNodeNextNode16, +} from './helpers'; +import * as Path from 'path'; +import { ctxTsNode } from './helpers'; +import { exec } from './exec-helpers'; +import { file, project, ProjectAPI as ProjectAPI } from './fs-helpers'; + +const test = context(ctxTsNode); +test.beforeEach(async () => { + resetNodeEnvironment(); +}); +type Test = typeof test; + +// Declare one test case for each permutations of project configuration +test.suite('TypeScript module=NodeNext and Node16', (test) => { + test.runIf(tsSupportsStableNodeNextNode16); + + for (const allowJs of [true, false]) { + for (const typecheckMode of [ + 'typecheck', + 'transpileOnly', + 'swc', + ] as const) { + for (const packageJsonType of [ + undefined, + 'commonjs', + 'module', + ] as const) { + for (const tsModuleMode of ['NodeNext', 'Node16'] as const) { + declareTest(test, { + allowJs, + packageJsonType, + typecheckMode, + tsModuleMode, + }); + } + } + } + } +}); + +function declareTest(test: Test, testParams: TestParams) { + const name = `package-json-type=${testParams.packageJsonType} allowJs=${testParams.allowJs} ${testParams.typecheckMode} tsconfig-module=${testParams.tsModuleMode}`; + + test(name, async (t) => { + const proj = writeFixturesToFilesystem(name, testParams); + + t.log( + `Running this command: ( cd ${proj.cwd} ; ${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} --esm ./index.mjs )` + ); + + // All assertions happen within the fixture scripts + // Zero exit code indicates a passing test + const { stdout, stderr, err } = await exec( + `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} --esm ./index.mjs`, + { cwd: proj.cwd } + ); + t.log(stdout); + t.log(stderr); + expect(err).toBe(null); + }); +} + +type PackageJsonType = typeof packageJsonTypes[number]; +const packageJsonTypes = [undefined, 'commonjs', 'module'] as const; +const typecheckModes = ['typecheck', 'transpileOnly', 'swc'] as const; +const importStyles = [ + 'static import', + 'require', + 'dynamic import', + 'import = require', +] as const; +const importExtension = ['js', 'ts', 'omitted'] as const; + +interface Extension { + ext: string; + jsEquivalentExt?: string; + forcesCjs?: boolean; + forcesEsm?: boolean; + isJs?: boolean; + supportsJsx?: boolean; + isJsxExt?: boolean; + cjsAllowsOmittingExt?: boolean; +} +const extensions: Extension[] = [ + { + ext: 'cts', + jsEquivalentExt: 'cjs', + forcesCjs: true, + }, + { + ext: 'cjs', + forcesCjs: true, + isJs: true, + }, + { + ext: 'mts', + jsEquivalentExt: 'mjs', + forcesEsm: true, + }, + { + ext: 'mjs', + forcesEsm: true, + isJs: true, + }, + { + ext: 'ts', + jsEquivalentExt: 'js', + cjsAllowsOmittingExt: true, + }, + { + ext: 'tsx', + jsEquivalentExt: 'js', + supportsJsx: true, + isJsxExt: true, + cjsAllowsOmittingExt: true, + }, + { + ext: 'jsx', + jsEquivalentExt: 'js', + isJs: true, + supportsJsx: true, + isJsxExt: true, + cjsAllowsOmittingExt: true, + }, + { + ext: 'js', + isJs: true, + cjsAllowsOmittingExt: true, + }, +]; +/** + * Describe how a given project config handles files with this extension. + * For example, projects with allowJs:false do not like .jsx + */ +function getExtensionTreatment(ext: Extension, testParams: TestParams) { + // JSX and any TS extensions get compiled. Everything is compiled in allowJs mode + const isCompiled = testParams.allowJs || !ext.isJs || ext.isJsxExt; + const isExecutedAsEsm = + ext.forcesEsm || + (!ext.forcesCjs && testParams.packageJsonType === 'module'); + const isExecutedAsCjs = !isExecutedAsEsm; + // if allowJs:false, then .jsx files are not allowed + const isAllowed = !ext.isJsxExt || !ext.isJs || testParams.allowJs; + const canHaveJsxSyntax = ext.isJsxExt || (ext.supportsJsx && isCompiled); + return { + isCompiled, + isExecutedAsCjs, + isExecutedAsEsm, + isAllowed, + canHaveJsxSyntax, + }; +} + +interface TestParams { + packageJsonType: PackageJsonType; + typecheckMode: typeof typecheckModes[number]; + allowJs: boolean; + tsModuleMode: 'NodeNext' | 'Node16'; +} + +interface ImporterParams { + importStyle: typeof importStyles[number]; + importerExtension: typeof extensions[number]; +} + +interface ImporteeParams { + importeeExtension: typeof extensions[number]; +} + +function writeFixturesToFilesystem(name: string, testParams: TestParams) { + const { packageJsonType, allowJs, typecheckMode, tsModuleMode } = testParams; + + const proj = project(name.replace(/ /g, '_')); + + proj.addJsonFile('package.json', { + type: packageJsonType, + }); + + proj.addJsonFile('tsconfig.json', { + compilerOptions: { + allowJs, + target: 'esnext', + module: tsModuleMode, + jsx: 'react', + }, + 'ts-node': { + transpileOnly: typecheckMode === 'transpileOnly' || undefined, + swc: typecheckMode === 'swc', + experimentalResolver: true, + }, + }); + + const indexFile = file('index.mjs', ``); + proj.add(indexFile); + + for (const importStyle of importStyles) { + for (const importerExtension of extensions) { + const importer = createImporter(proj, testParams, { + importStyle, + importerExtension, + }); + if (!importer) continue; + + let importSpecifier = `./${Path.relative(proj.cwd, importer.path)}`; + importSpecifier = replaceExtension( + importSpecifier, + importerExtension.jsEquivalentExt ?? importerExtension.ext + ); + indexFile.content += `await import('${importSpecifier}');\n`; + } + } + + proj.rm(); + proj.write(); + return proj; +} + +function createImporter( + proj: ProjectAPI, + testParams: TestParams, + importerParams: ImporterParams +) { + const { importStyle, importerExtension } = importerParams; + const name = `${importStyle} from ${importerExtension.ext}`; + + const importerTreatment = getExtensionTreatment( + importerExtension, + testParams + ); + + if (!importerTreatment.isAllowed) return; + // import = require only allowed in non-js files + if (importStyle === 'import = require' && importerExtension.isJs) return; + // const = require not allowed in ESM + if (importStyle === 'require' && importerTreatment.isExecutedAsEsm) return; + // swc bug: import = require will not work in ESM, because swc does not emit necessary `__require = createRequire()` + if ( + testParams.typecheckMode === 'swc' && + importStyle === 'import = require' && + importerTreatment.isExecutedAsEsm + ) + return; + + const importer = { + path: `${name.replace(/ /g, '_')}.${importerExtension.ext}`, + imports: '', + assertions: '', + get content() { + return ` + ${this.imports} + async function main() { + ${this.assertions} + } + main(); + `; + }, + }; + proj.add(importer); + + if (!importerExtension.isJs) importer.imports += `export {};\n`; + + for (const importeeExtension of extensions) { + const ci = createImportee(testParams, { importeeExtension }); + if (!ci) continue; + const { importee, treatment: importeeTreatment } = ci; + proj.add(importee); + + // dynamic import is the only way to import ESM from CJS + if ( + importeeTreatment.isExecutedAsEsm && + importerTreatment.isExecutedAsCjs && + importStyle !== 'dynamic import' + ) + continue; + // Cannot import = require an ESM file + if (importeeTreatment.isExecutedAsEsm && importStyle === 'import = require') + continue; + // Cannot use static imports in non-compiled non-ESM + if ( + importStyle === 'static import' && + !importerTreatment.isCompiled && + importerTreatment.isExecutedAsCjs + ) + continue; + + let importSpecifier = `./${importeeExtension.ext}`; + if ( + !importeeExtension.cjsAllowsOmittingExt || + isOneOf(importStyle, ['dynamic import', 'static import']) + ) + importSpecifier += + '.' + (importeeExtension.jsEquivalentExt ?? importeeExtension.ext); + + switch (importStyle) { + case 'dynamic import': + importer.assertions += `const ${importeeExtension.ext} = await import('${importSpecifier}');\n`; + break; + case 'import = require': + importer.imports += `import ${importeeExtension.ext} = require('${importSpecifier}');\n`; + break; + case 'require': + importer.imports += `const ${importeeExtension.ext} = require('${importSpecifier}');\n`; + break; + case 'static import': + importer.imports += `import * as ${importeeExtension.ext} from '${importSpecifier}';\n`; + break; + } + + // Check both namespace.ext and namespace.default.ext, because node can't detect named exports from files we transform + const namespaceAsAny = importerExtension.isJs + ? importeeExtension.ext + : `(${importeeExtension.ext} as any)`; + importer.assertions += `if((${importeeExtension.ext}.ext ?? ${namespaceAsAny}.default.ext) !== '${importeeExtension.ext}')\n`; + importer.assertions += ` throw new Error('Wrong export from importee: expected ${importeeExtension.ext} but got ' + ${importeeExtension.ext}.ext + '(importee has these keys: ' + Object.keys(${importeeExtension.ext}) + ')');\n`; + } + return importer; +} +function createImportee( + testParams: TestParams, + importeeParams: ImporteeParams +) { + const { importeeExtension } = importeeParams; + const importee = file(`${importeeExtension.ext}.${importeeExtension.ext}`); + const treatment = getExtensionTreatment(importeeExtension, testParams); + if (!treatment.isAllowed) return; + if (treatment.isCompiled || treatment.isExecutedAsEsm) { + importee.content += `export const ext = '${importeeExtension.ext}';\n`; + } else { + importee.content += `exports.ext = '${importeeExtension.ext}';\n`; + } + if (!importeeExtension.isJs) { + importee.content += `const testTsTypeSyntax: string = 'a string';\n`; + } + if (treatment.isExecutedAsCjs) { + importee.content += `if(typeof __filename !== 'string') throw new Error('expected file to be CJS but __filename is not declared');\n`; + } else { + importee.content += `if(typeof __filename !== 'undefined') throw new Error('expected file to be ESM but __filename is declared');\n`; + importee.content += `if(typeof import.meta.url !== 'string') throw new Error('expected file to be ESM but import.meta.url is not declared');\n`; + } + if (treatment.canHaveJsxSyntax) { + importee.content += ` + const React = { + createElement(tag, dunno, content) { + return {props: {children: [content]}}; + } + }; + const jsxTest = Hello World; + if(jsxTest?.props?.children[0] !== 'Hello World') throw new Error('Expected ${importeeExtension.ext} to support JSX but it did not.'); + `; + } + return { importee, treatment }; +} + +function replaceExtension(path: string, ext: string) { + return Path.posix.format({ + ...Path.parse(path), + ext: '.' + ext, + base: undefined, + }); +} diff --git a/src/test/pluggable-dep-resolution.spec.ts b/src/test/pluggable-dep-resolution.spec.ts index 0751ba78f..480a40bbc 100644 --- a/src/test/pluggable-dep-resolution.spec.ts +++ b/src/test/pluggable-dep-resolution.spec.ts @@ -31,11 +31,22 @@ test.suite( async (t) => { t.teardown(resetNodeEnvironment); - const output = t.context.tsNodeUnderTest - .create({ - project: resolve(TEST_DIR, 'pluggable-dep-resolution', config), - }) - .compile('', 'index.ts'); + // A bit hacky: we've monkey-patched the various dependencies to either: + // a) return transpiled output we expect + // b) throw an error that we expect + // Either way, we've proven that the correct dependency is used, which + // is our goal. + let output: string; + try { + output = t.context.tsNodeUnderTest + .create({ + project: resolve(TEST_DIR, 'pluggable-dep-resolution', config), + }) + .compile('', 'index.ts'); + } catch (e) { + expect(e).toBe(`emit from ${expected}`); + return; + } expect(output).toContain(`emit from ${expected}\n`); }, diff --git a/src/test/resolver.spec.ts b/src/test/resolver.spec.ts index 4dc5905cd..96d6cd8cf 100755 --- a/src/test/resolver.spec.ts +++ b/src/test/resolver.spec.ts @@ -1,11 +1,20 @@ -import { context, ExecutionContext, TestInterface } from './testlib'; -import { ctxTsNode, resetNodeEnvironment, ts } from './helpers'; +import { context, ExecutionContext, expect, TestInterface } from './testlib'; +import { + ctxTsNode, + isOneOf, + resetNodeEnvironment, + ts, + tsSupportsStableNodeNextNode16, +} from './helpers'; import { project as fsProject, Project as FsProject } from './fs-helpers'; import { join } from 'path'; import * as semver from 'semver'; import { padStart } from 'lodash'; import _ = require('lodash'); import { pathToFileURL } from 'url'; +import type { RegisterOptions } from '..'; +import * as fs from 'fs'; +import * as Path from 'path'; /* * Each test case is a separate TS project, with a different permutation of @@ -65,6 +74,10 @@ import { pathToFileURL } from 'url'; // Side-step compiler transformation of import() into require() const dynamicImport = new Function('specifier', 'return import(specifier)'); +// For some reason `new Function` was triggering what *might* be a node bug, +// where `context.parentURL` passed into loader `resolve()` was wrong. +// eval works for unknown reasons. This may change in future node releases. +const declareDynamicImportFunction = `const dynamicImport = eval('(specifier) => import(specifier)');`; const test = context(ctxTsNode); type Test = TestInterface; @@ -79,13 +92,28 @@ interface Project { allowJs: boolean; preferSrc: boolean; typeModule: boolean; + /** Use TS's new module: `nodenext` option */ + useTsNodeNext: boolean; experimentalSpecifierResolutionNode: boolean; skipIgnore: boolean; } +interface EntrypointPermutation { + entrypointExt: 'cjs' | 'mjs'; + withExt: boolean; + entrypointLocation: 'src' | 'out'; + entrypointTargetting: 'src' | 'out'; +} type Entrypoint = string; -interface Target { +interface GenerateTargetOptions { + inSrc: boolean; + inOut: boolean; + srcExt: string; /** If true, is an index.* file within a directory */ isIndex: boolean; + targetPackageStyle: TargetPackageStyle; + packageTypeModule: boolean; +} +interface Target { targetIdentifier: string; outName: string; srcName: string; @@ -93,11 +121,40 @@ interface Target { outExt: string; inSrc: boolean; inOut: boolean; + /** If true, is neither an index.* nor a package */ + isNamedFile: boolean; + /** If true, is an index.* file within a directory */ + isIndex: boolean; /** If true, should be imported as an npm package, not relative import */ isPackage: boolean; - packageFlavor: ExternalPackageFlavor; + packageStyle: TargetPackageStyle; typeModule: boolean; } + +/** When target is actually a mini node_modules package */ +type TargetPackageStyle = typeof targetPackageStyles[number]; +const targetPackageStyles = [ + false, + // test that the package contains /index.* + 'empty-manifest', + // "main": "src/target." + 'main-src-with-extension', + // "main": "src/target." + 'main-src-with-out-extension', + // "main": "out/target." + 'main-out-with-extension', + // "main": "src/target" + 'main-src-extensionless', + // "main": "out/target" + 'main-out-extensionless', + // "exports": {".": "src/target."} + 'exports-src-with-extension', + // "exports": {".": "src/target."} + 'exports-src-with-out-extension', + // "exports": {".": "out/target."} + 'exports-out-with-extension', +] as const; + test.suite('Resolver hooks', (test) => { test.runSerially(); test.runIf( @@ -108,29 +165,34 @@ test.suite('Resolver hooks', (test) => { // // Generate all permutations of projects // - for (const allowJs of [false, true]) { - for (const preferSrc of [false, true]) { - for (const typeModule of [false, true]) { - for (const experimentalSpecifierResolutionNode of [false, true]) { + for (const preferSrc of [false, true]) { + for (const typeModule of [false, true]) { + for (const allowJs of [false, true]) { + for (const useTsNodeNext of [false, true]) { // TODO test against skipIgnore: false, where imports of third-party deps in `node_modules` should not get our mapping behaviors for (const skipIgnore of [/*false, */ true]) { - const project: Project = { - identifier: `resolver-${projectSeq()}-${ - preferSrc ? 'preferSrc' : 'preferOut' - }-${typeModule ? 'typeModule' : 'typeCommonjs'}${ - allowJs ? '-allowJs' : '' - }${skipIgnore ? '-skipIgnore' : ''}${ - experimentalSpecifierResolutionNode - ? '-experimentalSpecifierResolutionNode' - : '' - }`, - allowJs, - preferSrc, - typeModule, - experimentalSpecifierResolutionNode, - skipIgnore, - }; - declareProject(test, project); + for (const experimentalSpecifierResolutionNode of [false, true]) { + let identifier = `resolver-${projectSeq()}`; + identifier += preferSrc ? '-preferSrc' : '-preferOut'; + identifier += typeModule ? '-typeModule' : '-typeCjs---'; + identifier += allowJs ? '-allowJs' : '--------'; + identifier += useTsNodeNext ? '-useTsNodenext' : '--------------'; + identifier += skipIgnore ? '-skipIgnore' : '-----------'; + identifier += experimentalSpecifierResolutionNode + ? '-experimentalSpecifierResolutionNode' + : ''; + + const project: Project = { + identifier, + allowJs, + preferSrc, + typeModule, + useTsNodeNext, + experimentalSpecifierResolutionNode, + skipIgnore, + }; + declareProject(test, project); + } } } } @@ -138,15 +200,11 @@ test.suite('Resolver hooks', (test) => { } }); -function declareProject(test: Test, project: Project) { - const { - allowJs, - experimentalSpecifierResolutionNode, - preferSrc, - typeModule, - skipIgnore, - } = project; - +function declareProject(_test: Test, project: Project) { + const test = + project.useTsNodeNext && !tsSupportsStableNodeNextNode16 + ? _test.skip + : _test; test(`${project.identifier}`, async (t) => { t.teardown(() => { resetNodeEnvironment(); @@ -161,19 +219,23 @@ function declareProject(test: Test, project: Project) { p.addJsonFile('tsconfig.json', { 'ts-node': { experimentalResolver: true, - preferTsExts: preferSrc, + preferTsExts: project.preferSrc, transpileOnly: true, - experimentalSpecifierResolution: experimentalSpecifierResolutionNode - ? 'node' - : undefined, - skipIgnore, - }, + experimentalSpecifierResolution: + project.experimentalSpecifierResolutionNode ? 'node' : undefined, + skipIgnore: project.skipIgnore, + } as RegisterOptions, compilerOptions: { - allowJs, + allowJs: project.allowJs, skipLibCheck: true, // TODO add nodenext permutation - module: typeModule ? 'esnext' : 'commonjs', + module: project.useTsNodeNext + ? 'NodeNext' + : project.typeModule + ? 'esnext' + : 'commonjs', jsx: 'react', + target: 'esnext', }, }); @@ -183,33 +245,11 @@ function declareProject(test: Test, project: Project) { await execute(t, p, entrypoints); }); } -type ExternalPackageFlavor = typeof externalPackageFlavors[number]; -const externalPackageFlavors = [ - false, - // test that the package contains /index.* - 'empty-manifest', - // "main": "src/target." - 'main-src-with-extension', - // "main": "src/target." - 'main-src-with-out-extension', - // "main": "out/target." - 'main-out-with-extension', - // "main": "src/target" - 'main-src-extensionless', - // "main": "out/target" - 'main-out-extensionless', - // "exports": {".": "src/target."} - 'exports-src-with-extension', - // "exports": {".": "src/target."} - 'exports-src-with-out-extension', - // "exports": {".": "out/target."} - 'exports-out-with-extension', -] as const; -function generateTargets(project: Project, p: FsProject) { - // - // Generate all target-* files - // +// +// Generate all target-* files +// +function generateTargets(project: Project, p: FsProject) { /** Array of metadata about target files to be imported */ const targets: Array = []; // TODO does allowJs matter? @@ -225,12 +265,12 @@ function generateTargets(project: Project, p: FsProject) { 'cjs', 'mjs', ]) { - for (const externalPackageFlavor of externalPackageFlavors) { - const targetPackageTypeModulePermutations = externalPackageFlavor + for (const targetPackageStyle of targetPackageStyles) { + const packageTypeModulePermutations = targetPackageStyle ? [true, false] : [project.typeModule]; - for (const targetPackageTypeModule of targetPackageTypeModulePermutations) { - const isIndexPermutations = externalPackageFlavor + for (const packageTypeModule of packageTypeModulePermutations) { + const isIndexPermutations = targetPackageStyle ? [false] : [true, false]; // TODO test main pointing to a directory containing an `index.` file? @@ -248,7 +288,7 @@ function generateTargets(project: Project, p: FsProject) { // TODO re-enable with src <-> out mapping if ( !inOut && - isOneOf(externalPackageFlavor, [ + isOneOf(targetPackageStyle, [ 'main-out-with-extension', 'main-out-extensionless', 'exports-out-with-extension', @@ -257,7 +297,7 @@ function generateTargets(project: Project, p: FsProject) { continue; if ( !inSrc && - isOneOf(externalPackageFlavor, [ + isOneOf(targetPackageStyle, [ 'main-src-with-extension', 'main-src-extensionless', 'exports-src-with-extension', @@ -265,7 +305,7 @@ function generateTargets(project: Project, p: FsProject) { ) continue; if ( - isOneOf(externalPackageFlavor, [ + isOneOf(targetPackageStyle, [ 'main-out-with-extension', 'main-out-extensionless', 'exports-out-with-extension', @@ -274,159 +314,16 @@ function generateTargets(project: Project, p: FsProject) { continue; //#endregion - const outExt = srcExt.replace('ts', 'js').replace('x', ''); - let targetIdentifier = `target-${targetSeq()}-${ - inOut && inSrc ? 'inboth' : inOut ? 'onlyout' : 'onlysrc' - }-${srcExt}`; - - if (externalPackageFlavor) - targetIdentifier = `${targetIdentifier}-${externalPackageFlavor}-${ - targetPackageTypeModule ? 'module' : 'commonjs' - }`; - let prefix = externalPackageFlavor - ? `node_modules/${targetIdentifier}/` - : ''; - let suffix = - externalPackageFlavor === 'empty-manifest' - ? 'index' - : externalPackageFlavor - ? 'target' - : targetIdentifier; - if (isIndex) suffix += '-dir/index'; - const srcDirInfix = - externalPackageFlavor === 'empty-manifest' ? '' : 'src/'; - const outDirInfix = - externalPackageFlavor === 'empty-manifest' ? '' : 'out/'; - const srcName = `${prefix}${srcDirInfix}${suffix}.${srcExt}`; - const srcDirOutExtName = `${prefix}${srcDirInfix}${suffix}.${outExt}`; - const outName = `${prefix}${outDirInfix}${suffix}.${outExt}`; - const selfImporterCjsName = `${prefix}self-import-cjs.cjs`; - const selfImporterMjsName = `${prefix}self-import-mjs.mjs`; - const target: Target = { - srcName, - outName, - srcExt, - outExt, - inSrc, - inOut, - isIndex, - targetIdentifier, - isPackage: !!externalPackageFlavor, - packageFlavor: externalPackageFlavor, - typeModule: targetPackageTypeModule, - }; - targets.push(target); - const { isMjs: targetIsMjs } = fileInfo( - '.' + srcExt, - targetPackageTypeModule, - project.allowJs + targets.push( + generateTarget(project, p, { + inSrc, + inOut, + srcExt, + targetPackageStyle, + packageTypeModule, + isIndex, + }) ); - function targetContent(loc: string) { - let content = ''; - if (targetIsMjs) { - content += String.raw` - const {fileURLToPath} = await import('url'); - const filenameNative = fileURLToPath(import.meta.url); - export const directory = filenameNative.replace(/.*[\\\/](.*?)[\\\/]/, '$1'); - export const filename = filenameNative.replace(/.*[\\\/]/, ''); - export const targetIdentifier = '${targetIdentifier}'; - export const ext = filenameNative.replace(/.*\./, ''); - export const loc = '${loc}'; - `; - } else { - content += String.raw` - const filenameNative = __filename; - exports.filename = filenameNative.replace(/.*[\\\/]/, ''); - exports.directory = filenameNative.replace(/.*[\\\/](.*?)[\\\/].*/, '$1'); - exports.targetIdentifier = '${targetIdentifier}'; - exports.ext = filenameNative.replace(/.*\./, ''); - exports.loc = '${loc}'; - `; - } - return content; - } - if (inOut) { - p.addFile(outName, targetContent('out')); - // TODO so we can test multiple file extensions in a single directory, preferTsExt - p.addFile(srcDirOutExtName, targetContent('out')); - } - if (inSrc) { - p.addFile(srcName, targetContent('src')); - } - if (externalPackageFlavor) { - p.addFile( - selfImporterCjsName, - ` - module.exports = require("${targetIdentifier}"); - ` - ); - p.addFile( - selfImporterMjsName, - ` - export * from "${targetIdentifier}"; - ` - ); - function writePackageJson(obj: any) { - p.addJsonFile(`${prefix}/package.json`, { - name: targetIdentifier, - type: targetPackageTypeModule ? 'module' : undefined, - ...obj, - }); - } - switch (externalPackageFlavor) { - case 'empty-manifest': - writePackageJson({}); - break; - case 'exports-src-with-extension': - writePackageJson({ - exports: { - '.': `./src/${suffix}.${srcExt}`, - }, - }); - break; - case 'exports-src-with-out-extension': - writePackageJson({ - exports: { - '.': `./src/${suffix}.${outExt}`, - }, - }); - break; - case 'exports-out-with-extension': - writePackageJson({ - exports: { - '.': `./out/${suffix}.${outExt}`, - }, - }); - break; - case 'main-src-extensionless': - writePackageJson({ - main: `src/${suffix}`, - }); - break; - case 'main-out-extensionless': - writePackageJson({ - main: `out/${suffix}`, - }); - break; - case 'main-src-with-extension': - writePackageJson({ - main: `src/${suffix}.${srcExt}`, - }); - break; - case 'main-src-with-out-extension': - writePackageJson({ - main: `src/${suffix}.${outExt}`, - }); - break; - case 'main-out-with-extension': - writePackageJson({ - main: `src/${suffix}.${outExt}`, - }); - break; - default: - const _assert: never = externalPackageFlavor; - } - } } } } @@ -436,6 +333,177 @@ function generateTargets(project: Project, p: FsProject) { return targets; } +function generateTarget( + project: Project, + p: FsProject, + options: GenerateTargetOptions +) { + const { + inSrc, + inOut, + srcExt, + targetPackageStyle, + packageTypeModule, + isIndex, + } = options; + + const outExt = srcExt.replace('ts', 'js').replace('x', ''); + let targetIdentifier = `target-${targetSeq()}-${ + inOut && inSrc ? 'inboth' : inOut ? 'onlyout' : 'onlysrc' + }-${srcExt}`; + + if (targetPackageStyle) + targetIdentifier = `${targetIdentifier}-${targetPackageStyle}-${ + packageTypeModule ? 'module' : 'commonjs' + }`; + let prefix = targetPackageStyle ? `node_modules/${targetIdentifier}/` : ''; + let suffix = + targetPackageStyle === 'empty-manifest' + ? 'index' + : targetPackageStyle + ? 'target' + : targetIdentifier; + if (isIndex) suffix += '-dir/index'; + const srcDirInfix = targetPackageStyle === 'empty-manifest' ? '' : 'src/'; + const outDirInfix = targetPackageStyle === 'empty-manifest' ? '' : 'out/'; + const srcName = `${prefix}${srcDirInfix}${suffix}.${srcExt}`; + const srcDirOutExtName = `${prefix}${srcDirInfix}${suffix}.${outExt}`; + const outName = `${prefix}${outDirInfix}${suffix}.${outExt}`; + const selfImporterCjsName = `${prefix}self-import-cjs.cjs`; + const selfImporterMjsName = `${prefix}self-import-mjs.mjs`; + const target: Target = { + targetIdentifier, + srcName, + outName, + srcExt, + outExt, + inSrc, + inOut, + isNamedFile: !isIndex && !targetPackageStyle, + isIndex, + isPackage: !!targetPackageStyle, + packageStyle: targetPackageStyle, + typeModule: packageTypeModule, + }; + const { isMjs: targetIsMjs } = fileInfo( + '.' + srcExt, + packageTypeModule, + project.allowJs + ); + function targetContent(loc: string) { + let content = ''; + if (targetIsMjs) { + content += String.raw` + const {fileURLToPath} = await import('url'); + const filenameNative = fileURLToPath(import.meta.url); + export const directory = filenameNative.replace(/.*[\\\/](.*?)[\\\/]/, '$1'); + export const filename = filenameNative.replace(/.*[\\\/]/, ''); + export const targetIdentifier = '${targetIdentifier}'; + export const ext = filenameNative.replace(/.*\./, ''); + export const loc = '${loc}'; + `; + } else { + content += String.raw` + const filenameNative = __filename; + exports.filename = filenameNative.replace(/.*[\\\/]/, ''); + exports.directory = filenameNative.replace(/.*[\\\/](.*?)[\\\/].*/, '$1'); + exports.targetIdentifier = '${targetIdentifier}'; + exports.ext = filenameNative.replace(/.*\./, ''); + exports.loc = '${loc}'; + `; + } + return content; + } + if (inOut) { + p.addFile(outName, targetContent('out')); + // TODO so we can test multiple file extensions in a single directory, preferTsExt + p.addFile(srcDirOutExtName, targetContent('out')); + } + if (inSrc) { + p.addFile(srcName, targetContent('src')); + } + if (targetPackageStyle) { + const selfImporterIsCompiled = project.allowJs; + const cjsSelfImporterMustUseDynamicImportHack = + !project.useTsNodeNext && selfImporterIsCompiled && targetIsMjs; + p.addFile( + selfImporterCjsName, + targetIsMjs + ? cjsSelfImporterMustUseDynamicImportHack + ? `${declareDynamicImportFunction}\nmodule.exports = dynamicImport('${targetIdentifier}');` + : `module.exports = import("${targetIdentifier}");` + : `module.exports = require("${targetIdentifier}");` + ); + p.addFile( + selfImporterMjsName, + ` + export * from "${targetIdentifier}"; + ` + ); + function writePackageJson(obj: any) { + p.addJsonFile(`${prefix}/package.json`, { + name: targetIdentifier, + type: packageTypeModule ? 'module' : undefined, + ...obj, + }); + } + switch (targetPackageStyle) { + case 'empty-manifest': + writePackageJson({}); + break; + case 'exports-src-with-extension': + writePackageJson({ + exports: { + '.': `./src/${suffix}.${srcExt}`, + }, + }); + break; + case 'exports-src-with-out-extension': + writePackageJson({ + exports: { + '.': `./src/${suffix}.${outExt}`, + }, + }); + break; + case 'exports-out-with-extension': + writePackageJson({ + exports: { + '.': `./out/${suffix}.${outExt}`, + }, + }); + break; + case 'main-src-extensionless': + writePackageJson({ + main: `src/${suffix}`, + }); + break; + case 'main-out-extensionless': + writePackageJson({ + main: `out/${suffix}`, + }); + break; + case 'main-src-with-extension': + writePackageJson({ + main: `src/${suffix}.${srcExt}`, + }); + break; + case 'main-src-with-out-extension': + writePackageJson({ + main: `src/${suffix}.${outExt}`, + }); + break; + case 'main-out-with-extension': + writePackageJson({ + main: `src/${suffix}.${outExt}`, + }); + break; + default: + const _assert: never = targetPackageStyle; + } + } + return target; +} + /** * Generate all entrypoint-* files */ @@ -447,6 +515,7 @@ function generateEntrypoints( /** Array of entrypoint files to be imported during the test */ let entrypoints: string[] = []; for (const entrypointExt of ['cjs', 'mjs'] as const) { + // TODO consider removing this logic; deferring to conditionals in the generateEntrypoint which emit meaningful comments const withExtPermutations = entrypointExt == 'mjs' && project.experimentalSpecifierResolutionNode === false @@ -457,177 +526,218 @@ function generateEntrypoints( for (const entrypointLocation of ['src', 'out'] as const) { // Target of the entrypoint's import statements for (const entrypointTargetting of ['src', 'out'] as const) { - // TODO + // TODO re-enable when we have out <-> src mapping if (entrypointLocation !== 'src') continue; if (entrypointTargetting !== 'src') continue; - const entrypointFilename = `entrypoint-${entrypointSeq()}-${entrypointLocation}-to-${entrypointTargetting}${ - withExt ? '-withext' : '' - }.${entrypointExt}`; - const { isMjs: entrypointIsMjs } = fileInfo( - entrypointFilename, - project.typeModule, - project.allowJs + entrypoints.push( + generateEntrypoint(project, p, targets, { + entrypointExt, + withExt, + entrypointLocation, + entrypointTargetting, + }) ); - let entrypointContent = 'let mod;\n'; - if (entrypointIsMjs) { - entrypointContent += `import assert from 'assert';\n`; - } else { - entrypointContent += `const assert = require('assert');\n`; + } + } + } + } + return entrypoints; +} + +function generateEntrypoint( + project: Project, + p: FsProject, + targets: Target[], + opts: EntrypointPermutation +) { + const { entrypointExt, withExt, entrypointLocation, entrypointTargetting } = + opts; + const entrypointFilename = `entrypoint-${entrypointSeq()}-${entrypointLocation}-to-${entrypointTargetting}${ + withExt ? '-withext' : '' + }.${entrypointExt}`; + const { isMjs: entrypointIsMjs, isCompiled: entrypointIsCompiled } = fileInfo( + entrypointFilename, + project.typeModule, + project.allowJs + ); + let entrypointContent = 'let mod;\n'; + entrypointContent += 'let testsRun = 0;\n'; + if (entrypointIsMjs) { + entrypointContent += `import assert from 'assert';\n`; + } else { + entrypointContent += `const assert = require('assert');\n`; + entrypointContent += `${declareDynamicImportFunction}\n`; + } + entrypointContent += `async function main() {\n`; + + for (const target of targets) { + // TODO re-enable these when we have outDir <-> rootDir mapping + if (target.srcName.includes('onlyout') && entrypointTargetting === 'src') + continue; + if (target.srcName.includes('onlysrc') && entrypointTargetting === 'out') + continue; + + const { + ext: targetSrcExt, + isMjs: targetIsMjs, + isCompiled: targetIsCompiled, + } = fileInfo(target.srcName, target.typeModule, project.allowJs); + + let targetExtPermutations = ['']; + if (!target.isPackage) { + if (entrypointTargetting === 'out' && target.outExt !== target.srcExt) { + // TODO re-enable when we have out <-> src mapping + targetExtPermutations = [target.outExt]; + } else if (target.srcExt !== target.outExt) { + targetExtPermutations = [target.srcExt, target.outExt]; + } else { + targetExtPermutations = [target.srcExt]; + } + } + const externalPackageSelfImportPermutations = target.isPackage + ? [false, true] + : [false]; + for (const targetExt of targetExtPermutations) { + for (const externalPackageSelfImport of externalPackageSelfImportPermutations) { + entrypointContent += `\n// ${target.targetIdentifier}`; + if (target.isPackage) { + entrypointContent += ` node_modules package`; + if (externalPackageSelfImport) { + entrypointContent += ` self-import`; } + } else { + entrypointContent += `.${targetExt}`; + } + entrypointContent += '\n'; + + // should specifier be relative or absolute? + let specifier: string; + if (externalPackageSelfImport) { + specifier = `../node_modules/${target.targetIdentifier}/self-import-${entrypointExt}.${entrypointExt}`; + } else if (target.isPackage) { + specifier = target.targetIdentifier; + } else { + if (entrypointTargetting === entrypointLocation) specifier = './'; + else specifier = `../${entrypointTargetting}/`; + specifier += target.targetIdentifier; + if (target.isIndex) specifier += '-dir'; + if (!target.isIndex && withExt) specifier += '.' + targetExt; + } - entrypoints.push(entrypointLocation + '/' + entrypointFilename); - for (const target of targets) { - // TODO re-enable these when we have outDir <-> rootDir mapping - if ( - target.srcName.includes('onlyout') && - entrypointTargetting === 'src' - ) - continue; - if ( - target.srcName.includes('onlysrc') && - //@ts-expect-error - entrypointTargetting === 'out' - ) - continue; - - const { - ext: targetSrcExt, - isMjs: targetIsMjs, - isCompiled: targetIsCompiled, - } = fileInfo(target.srcName, target.typeModule, project.allowJs); - const { ext: targetOutExt } = fileInfo( - target.outName, - project.typeModule, - project.allowJs - ); - - let targetExtPermutations = ['']; - if (!target.isPackage) { - if ( - // @ts-expect-error - entrypointTargetting === 'out' && - target.outExt !== target.srcExt - ) { - // TODO re-enable when we have out <-> src mapping - targetExtPermutations = [target.outExt]; - } else if (target.srcExt !== target.outExt) { - targetExtPermutations = [target.srcExt, target.outExt]; - } else { - targetExtPermutations = [target.srcExt]; - } - } - const externalPackageSelfImportPermutations = target.isPackage - ? [false, true] - : [false]; - for (const targetExt of targetExtPermutations) { - for (const externalPackageSelfImport of externalPackageSelfImportPermutations) { - entrypointContent += `\n// ${target.targetIdentifier}`; - if (target.isPackage) { - entrypointContent += ` node_modules package`; - if (externalPackageSelfImport) { - entrypointContent += ` self-import`; - } - } else { - entrypointContent += `.${targetExt}`; - } - entrypointContent += '\n'; - - // should specifier be relative or absolute? - let specifier: string; - if (externalPackageSelfImport) { - specifier = `../node_modules/${target.targetIdentifier}/self-import-${entrypointExt}.${entrypointExt}`; - } else if (target.isPackage) { - specifier = target.targetIdentifier; - } else { - if (entrypointTargetting === entrypointLocation) - specifier = './'; - else specifier = `../${entrypointTargetting}/`; - specifier += target.targetIdentifier; - if (target.isIndex) specifier += '-dir'; - if (!target.isIndex && withExt) specifier += '.' + targetExt; - } - - // Do not try to import mjs from cjs - if (targetIsMjs && entrypointExt === 'cjs') { - entrypointContent += `// skipping ${specifier} because we cannot import mjs from cjs\n`; - continue; - } - - // Do not try to import mjs or cjs without extension; node always requires these extensions, even in CommonJS. - if ( - !withExt && - (targetSrcExt === 'cjs' || targetSrcExt === 'mjs') - ) { - entrypointContent += `// skipping ${specifier} because we cannot omit extension from cjs or mjs files; node always requires them\n`; - continue; - } - - // Do not try to import a transpiled file if compiler options disagree with node's extension-based classification - if (targetIsCompiled && targetIsMjs && !project.typeModule) { - entrypointContent += `// skipping ${specifier} because it is compiled and compiler options disagree with node's module classification: extension=${targetSrcExt}, tsconfig module=commonjs\n`; - continue; - } - if (targetIsCompiled && !targetIsMjs && project.typeModule) { - entrypointContent += `// skipping ${specifier} because it is compiled and compiler options disagree with node's module classification: extension=${targetSrcExt}, tsconfig module=esnext\n`; - continue; - } - // Do not try to import cjs/mjs/cts/mts extensions because they are being added by a different pull request - if (['cts', 'mts', 'cjs', 'mjs'].includes(targetSrcExt)) { - entrypointContent += `// skipping ${specifier} because it uses a file extension that requires us to merge the relevant pull request\n`; - continue; - } - - // Do not try to import index from a directory if is forbidden by node's ESM resolver - if ( - entrypointIsMjs && - target.isIndex && - !project.experimentalSpecifierResolutionNode - ) { - entrypointContent += `// skipping ${specifier} because it relies on node automatically resolving a directory to index.*, but experimental-specifier-resolution is not enabled\n`; - continue; - } - - // NOTE: if you try to explicitly import foo.ts, we will load foo.ts, EVEN IF you have `preferTsExts` off - const assertIsSrcOrOut = !target.inSrc - ? 'out' - : !target.inOut - ? 'src' - : project.preferSrc || - (!target.isIndex && - targetExt === target.srcExt && - withExt) || - target.srcExt === target.outExt || // <-- TODO re-enable when we have src <-> out mapping - (target.isPackage && - isOneOf(target.packageFlavor, [ - 'main-src-with-extension', - 'exports-src-with-extension', - ])) - ? 'src' - : 'out'; - const assertHasExt = - assertIsSrcOrOut === 'src' ? target.srcExt : target.outExt; - - entrypointContent += - entrypointExt === 'cjs' - ? `mod = require('${specifier}');\n` - : `mod = await import('${specifier}');\n`; - entrypointContent += `assert.equal(mod.loc, '${assertIsSrcOrOut}');\n`; - entrypointContent += `assert.equal(mod.targetIdentifier, '${target.targetIdentifier}');\n`; - entrypointContent += `assert.equal(mod.ext, '${assertHasExt}');\n`; - } - } + //#region SKIPPING + if (target.isNamedFile && !withExt) { + // Do not try to import cjs/cts without extension; node always requires these extensions + if (target.outExt === 'cjs') { + entrypointContent += `// skipping ${specifier} because we cannot omit extension from cjs / cts files; node always requires them\n`; + continue; + } + // Do not try to import mjs/mts unless experimental-specifier-resolution is turned on + if ( + target.outExt === 'mjs' && + !project.experimentalSpecifierResolutionNode + ) { + entrypointContent += `// skipping ${specifier} because we cannot omit extension from mjs/mts unless experimental-specifier-resolution=node\n`; + continue; + } + // Do not try to import anything extensionless via ESM loader unless experimental-specifier-resolution is turned on + if ( + (targetIsMjs || entrypointIsMjs) && + !project.experimentalSpecifierResolutionNode + ) { + entrypointContent += `// skipping ${specifier} because we cannot omit extension via esm loader unless experimental-specifier-resolution=node\n`; + continue; } - function writeAssertions(specifier: string) {} - p.dir(entrypointLocation).addFile( - entrypointFilename, - entrypointContent - ); } + if ( + target.isPackage && + isOneOf(target.packageStyle, [ + 'empty-manifest', + 'main-out-extensionless', + 'main-src-extensionless', + ]) && + isOneOf(target.outExt, ['cjs', 'mjs']) + ) { + entrypointContent += `// skipping ${specifier} because it points to a node_modules package that tries to omit file extension, and node does not allow omitting cjs/mjs extension\n`; + continue; + } + + // Do not try to import a transpiled file if compiler options disagree with node's extension-based classification + if (!project.useTsNodeNext && targetIsCompiled) { + if (targetIsMjs && !project.typeModule) { + entrypointContent += `// skipping ${specifier} because it is compiled and compiler options disagree with node's module classification: extension=${targetSrcExt}, tsconfig module=commonjs\n`; + continue; + } + if (!targetIsMjs && project.typeModule) { + entrypointContent += `// skipping ${specifier} because it is compiled and compiler options disagree with node's module classification: extension=${targetSrcExt}, tsconfig module=esnext\n`; + continue; + } + } + + // Do not try to import index from a directory if is forbidden by node's ESM resolver + if (target.isIndex) { + if ( + (targetIsMjs || entrypointIsMjs) && + !project.experimentalSpecifierResolutionNode + ) { + entrypointContent += `// skipping ${specifier} because esm loader does not allow directory ./index imports unless experimental-specifier-resolution=node\n`; + continue; + } + if (target.outExt === 'cjs') { + entrypointContent += `// skipping ${specifier} because it relies on node automatically resolving a directory to index.cjs/cts , but node does not support those extensions for index.* files, only .js (and equivalents), .node, .json\n`; + continue; + } + } + //#endregion + + // NOTE: if you try to explicitly import foo.ts, we will load foo.ts, EVEN IF you have `preferTsExts` off + const assertIsSrcOrOut = !target.inSrc + ? 'out' + : !target.inOut + ? 'src' + : project.preferSrc || + (!target.isIndex && targetExt === target.srcExt && withExt) || + target.srcExt === target.outExt || // <-- TODO re-enable when we have src <-> out mapping + (target.isPackage && + isOneOf(target.packageStyle, [ + 'main-src-with-extension', + 'exports-src-with-extension', + ])) + ? 'src' + : 'out'; + const assertHasExt = + assertIsSrcOrOut === 'src' ? target.srcExt : target.outExt; + + // If entrypoint is compiled as CJS, and *not* with TS's nodenext, then TS transforms `import` into `require`, + // so we must hack around the compiler to get a true `import`. + const entrypointMustUseDynamicImportHack = + !project.useTsNodeNext && + entrypointIsCompiled && + !entrypointIsMjs && + !externalPackageSelfImport; + entrypointContent += + entrypointExt === 'cjs' && (externalPackageSelfImport || !targetIsMjs) + ? ` mod = await require('${specifier}');\n` + : entrypointMustUseDynamicImportHack + ? ` mod = await dynamicImport('${specifier}');\n` + : ` mod = await import('${specifier}');\n`; + entrypointContent += ` assert.equal(mod.loc, '${assertIsSrcOrOut}');\n`; + entrypointContent += ` assert.equal(mod.targetIdentifier, '${target.targetIdentifier}');\n`; + entrypointContent += ` assert.equal(mod.ext, '${assertHasExt}');\n`; + entrypointContent += ` testsRun++;\n`; } } } - return entrypoints; + entrypointContent += `}\n`; + entrypointContent += `const result = main().then(() => {return testsRun});\n`; + entrypointContent += `result.mark = 'marked';\n`; + if (entrypointIsMjs) { + entrypointContent += `export {result};\n`; + } else { + entrypointContent += `exports.result = result;\n`; + } + p.dir(entrypointLocation).addFile(entrypointFilename, entrypointContent); + return entrypointLocation + '/' + entrypointFilename; } /** @@ -644,9 +754,32 @@ async function execute(t: T, p: FsProject, entrypoints: Entrypoint[]) { process.__test_setloader__(t.context.tsNodeUnderTest.createEsmHooks(service)); for (const entrypoint of entrypoints) { + t.log(`Importing ${join(p.cwd, entrypoint)}`); try { - await dynamicImport(pathToFileURL(join(p.cwd, entrypoint))); + const { result } = await dynamicImport( + pathToFileURL(join(p.cwd, entrypoint)) + ); + expect(result).toBeInstanceOf(Promise); + expect(result.mark).toBe('marked'); + const testsRun = await result; + t.log(`Entrypoint ran ${testsRun} tests.`); } catch (e) { + try { + const launchJsonPath = Path.resolve( + __dirname, + '../../.vscode/launch.json' + ); + const launchJson = JSON.parse(fs.readFileSync(launchJsonPath, 'utf8')); + const config = launchJson.configurations.find( + (c: any) => c.name === 'Debug resolver test' + ); + config.cwd = Path.join( + '${workspaceFolder}', + Path.relative(Path.resolve(__dirname, '../..'), p.cwd) + ); + config.program = `./${entrypoint}`; + fs.writeFileSync(launchJsonPath, JSON.stringify(launchJson, null, 2)); + } catch {} throw new Error( [ (e as Error).message, @@ -654,7 +787,7 @@ async function execute(t: T, p: FsProject, entrypoints: Entrypoint[]) { '', 'This is an error in a resolver test. It might be easier to investigate by running outside of the test suite.', 'To do that, try pasting this into your bash shell (windows invocation will be similar but maybe not identical):', - `( cd ${p.cwd} ; node --loader ../../../esm.mjs ./${entrypoint} )`, + ` ( cd ${p.cwd} ; node --loader ../../../esm.mjs ./${entrypoint} )`, ].join('\n') ); } @@ -681,8 +814,3 @@ function seqGenerator() { return padStart('' + next++, 4, '0'); }; } - -/** Essentially Array:includes, but with tweaked types for checks on enums */ -function isOneOf(value: V, arrayOfPossibilities: ReadonlyArray) { - return arrayOfPossibilities.includes(value as any); -} diff --git a/src/test/testlib.ts b/src/test/testlib.ts index 12e03987d..780979e88 100644 --- a/src/test/testlib.ts +++ b/src/test/testlib.ts @@ -86,6 +86,15 @@ export interface TestInterface< macros: OneOrMoreMacros, ...rest: T ): void; + skip(title: string, implementation: Implementation): void; + /** Declare a concurrent test that uses one or more macros. Additional arguments are passed to the macro. */ + skip( + title: string, + macros: OneOrMoreMacros, + ...rest: T + ): void; + /** Declare a concurrent test that uses one or more macros. The macro is responsible for generating a unique test title. */ + skip(macros: OneOrMoreMacros, ...rest: T): void; macro( cb: ( @@ -189,7 +198,8 @@ function createTestInterface(opts: { title: string | undefined, macros: AvaMacro[], avaDeclareFunction: Function & { skip: Function }, - args: any[] + args: any[], + skip = false ) { const wrappedMacros = macros.map((macro) => { return async function (t: ExecutionContext, ...args: any[]) { @@ -206,7 +216,7 @@ function createTestInterface(opts: { }; }); const computedTitle = computeTitle(title, macros, ...args); - (automaticallySkip ? avaDeclareFunction.skip : avaDeclareFunction)( + (automaticallySkip || skip ? avaDeclareFunction.skip : avaDeclareFunction)( computedTitle, wrappedMacros, ...args @@ -232,6 +242,11 @@ function createTestInterface(opts: { const { args, macros, title } = parseArgs(inputArgs); return declareTest(title, macros, avaTest.serial, args); }; + test.skip = function (...inputArgs: any[]) { + assertOrderingForDeclaringTest(); + const { args, macros, title } = parseArgs(inputArgs); + return declareTest(title, macros, avaTest, args, true); + }; test.beforeEach = function ( cb: (test: ExecutionContext) => Promise ) { diff --git a/src/transpilers/swc.ts b/src/transpilers/swc.ts index 1d8d1c441..246b70f40 100644 --- a/src/transpilers/swc.ts +++ b/src/transpilers/swc.ts @@ -17,6 +17,7 @@ export function create(createOptions: SwcTranspilerOptions): Transpiler { swc, service: { config, projectLocalResolveHelper }, transpilerConfigLocalResolveHelper, + nodeModuleEmitKind, } = createOptions; // Load swc compiler @@ -78,6 +79,8 @@ export function create(createOptions: SwcTranspilerOptions): Transpiler { } swcTarget = swcTargets[swcTargetIndex]; const keepClassNames = target! >= /* ts.ScriptTarget.ES2016 */ 3; + const isNodeModuleKind = + module === ModuleKind.Node12 || module === ModuleKind.NodeNext; // swc only supports these 4x module options [MUST_UPDATE_FOR_NEW_MODULEKIND] const moduleType = module === ModuleKind.CommonJS @@ -86,6 +89,10 @@ export function create(createOptions: SwcTranspilerOptions): Transpiler { ? 'amd' : module === ModuleKind.UMD ? 'umd' + : isNodeModuleKind && nodeModuleEmitKind === 'nodecjs' + ? 'commonjs' + : isNodeModuleKind && nodeModuleEmitKind === 'nodeesm' + ? 'es6' : 'es6'; // In swc: // strictMode means `"use strict"` is *always* emitted for non-ES module, *never* for ES module where it is assumed it can be omitted. @@ -111,6 +118,8 @@ export function create(createOptions: SwcTranspilerOptions): Transpiler { noInterop: !esModuleInterop, type: moduleType, strictMode, + // For NodeNext and Node12, emit as CJS but do not transform dynamic imports + ignoreDynamic: nodeModuleEmitKind === 'nodecjs', } as swcTypes.ModuleConfig) : undefined, swcrc: false, @@ -198,4 +207,6 @@ const ModuleKind = { ES2015: 5, ES2020: 6, ESNext: 99, + Node12: 100, + NodeNext: 199, } as const; diff --git a/src/transpilers/types.ts b/src/transpilers/types.ts index a15b10a4c..70faf491e 100644 --- a/src/transpilers/types.ts +++ b/src/transpilers/types.ts @@ -1,5 +1,5 @@ import type * as ts from 'typescript'; -import type { Service } from '../index'; +import type { NodeModuleEmitKind, Service } from '../index'; import type { ProjectLocalResolveHelper } from '../util'; /** @@ -34,6 +34,13 @@ export interface CreateTranspilerOptions { * @internal */ transpilerConfigLocalResolveHelper: ProjectLocalResolveHelper; + /** + * When using `module: nodenext` or `module: node12`, there are two possible styles of emit: + * - CommonJS with dynamic imports preserved (not transformed into `require()` calls) + * - ECMAScript modules with `import foo = require()` transformed into `require = createRequire(); const foo = require()` + * @internal + */ + nodeModuleEmitKind?: NodeModuleEmitKind; } /** @category Transpiler */ export interface Transpiler { diff --git a/src/ts-compiler-types.ts b/src/ts-compiler-types.ts index 9c9d180d4..2f961b853 100644 --- a/src/ts-compiler-types.ts +++ b/src/ts-compiler-types.ts @@ -19,7 +19,7 @@ export interface TSCommon { getPreEmitDiagnostics: typeof _ts.getPreEmitDiagnostics; flattenDiagnosticMessageText: typeof _ts.flattenDiagnosticMessageText; transpileModule: typeof _ts.transpileModule; - ModuleKind: typeof _ts.ModuleKind; + ModuleKind: TSCommon.ModuleKindEnum; ScriptTarget: typeof _ts.ScriptTarget; findConfigFile: typeof _ts.findConfigFile; readConfigFile: typeof _ts.readConfigFile; @@ -73,6 +73,12 @@ export namespace TSCommon { _ts.ResolvedModuleWithFailedLookupLocations; export type FileReference = _ts.FileReference; export type SourceFile = _ts.SourceFile; + // Hack until we start building against TS >= 4.7.0 + export type ModuleKindEnum = typeof _ts.ModuleKind & { + Node16: typeof _ts.ModuleKind extends { Node16: any } + ? typeof _ts.ModuleKind['Node16'] + : 100; + }; } /** diff --git a/src/ts-transpile-module.ts b/src/ts-transpile-module.ts new file mode 100644 index 000000000..0744dc4d3 --- /dev/null +++ b/src/ts-transpile-module.ts @@ -0,0 +1,177 @@ +import type { + CompilerHost, + CompilerOptions, + Diagnostic, + SourceFile, + TranspileOptions, + TranspileOutput, +} from 'typescript'; +import type { TSCommon } from './ts-compiler-types'; + +/** @internal */ +export function createTsTranspileModule( + ts: TSCommon, + transpileOptions: Pick< + TranspileOptions, + 'compilerOptions' | 'reportDiagnostics' | 'transformers' + > +) { + const { + createProgram, + createSourceFile, + getDefaultCompilerOptions, + getImpliedNodeFormatForFile, + fixupCompilerOptions, + transpileOptionValueCompilerOptions, + getNewLineCharacter, + fileExtensionIs, + normalizePath, + Debug, + toPath, + getSetExternalModuleIndicator, + getEntries, + addRange, + hasProperty, + getEmitScriptTarget, + getDirectoryPath, + } = ts as any; + + const compilerOptionsDiagnostics: Diagnostic[] = []; + + const options: CompilerOptions = transpileOptions.compilerOptions + ? fixupCompilerOptions( + transpileOptions.compilerOptions, + compilerOptionsDiagnostics + ) + : {}; + + // mix in default options + const defaultOptions = getDefaultCompilerOptions(); + for (const key in defaultOptions) { + if (hasProperty(defaultOptions, key) && options[key] === undefined) { + options[key] = defaultOptions[key]; + } + } + + for (const option of transpileOptionValueCompilerOptions) { + options[option.name] = option.transpileOptionValue; + } + + // transpileModule does not write anything to disk so there is no need to verify that there are no conflicts between input and output paths. + options.suppressOutputPathCheck = true; + + // Filename can be non-ts file. + options.allowNonTsExtensions = true; + + const newLine = getNewLineCharacter(options); + // Create a compilerHost object to allow the compiler to read and write files + const compilerHost: CompilerHost = { + getSourceFile: (fileName) => + fileName === normalizePath(inputFileName) ? sourceFile : undefined, + writeFile: (name, text) => { + if (fileExtensionIs(name, '.map')) { + Debug.assertEqual( + sourceMapText, + undefined, + 'Unexpected multiple source map outputs, file:', + name + ); + sourceMapText = text; + } else { + Debug.assertEqual( + outputText, + undefined, + 'Unexpected multiple outputs, file:', + name + ); + outputText = text; + } + }, + getDefaultLibFileName: () => 'lib.d.ts', + useCaseSensitiveFileNames: () => true, + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => '', + getNewLine: () => newLine, + fileExists: (fileName): boolean => + fileName === inputFileName || fileName === packageJsonFileName, + readFile: (fileName) => + fileName === packageJsonFileName ? `{"type": "${_packageJsonType}"}` : '', + directoryExists: () => true, + getDirectories: () => [], + }; + + let inputFileName: string; + let packageJsonFileName: string; + let _packageJsonType: 'module' | 'commonjs'; + let sourceFile: SourceFile; + let outputText: string | undefined; + let sourceMapText: string | undefined; + + return transpileModule; + + function transpileModule( + input: string, + transpileOptions2: Pick< + TranspileOptions, + 'fileName' | 'moduleName' | 'renamedDependencies' + >, + packageJsonType: 'module' | 'commonjs' = 'commonjs' + ): TranspileOutput { + // if jsx is specified then treat file as .tsx + inputFileName = + transpileOptions2.fileName || + (transpileOptions.compilerOptions && transpileOptions.compilerOptions.jsx + ? 'module.tsx' + : 'module.ts'); + packageJsonFileName = getDirectoryPath(inputFileName) + '/package.json'; + _packageJsonType = packageJsonType; + + sourceFile = createSourceFile(inputFileName, input, { + languageVersion: getEmitScriptTarget(options), + impliedNodeFormat: getImpliedNodeFormatForFile( + toPath(inputFileName, '', compilerHost.getCanonicalFileName), + /*cache*/ undefined, + compilerHost, + options + ), + setExternalModuleIndicator: getSetExternalModuleIndicator(options), + }); + if (transpileOptions2.moduleName) { + sourceFile.moduleName = transpileOptions2.moduleName; + } + + if (transpileOptions2.renamedDependencies) { + (sourceFile as any).renamedDependencies = new Map( + getEntries(transpileOptions2.renamedDependencies) + ); + } + + // Output + outputText = undefined; + sourceMapText = undefined; + + const program = createProgram([inputFileName], options, compilerHost); + + const diagnostics = compilerOptionsDiagnostics.slice(); + + if (transpileOptions.reportDiagnostics) { + addRange( + /*to*/ diagnostics, + /*from*/ program.getSyntacticDiagnostics(sourceFile) + ); + addRange(/*to*/ diagnostics, /*from*/ program.getOptionsDiagnostics()); + } + // Emit + program.emit( + /*targetSourceFile*/ undefined, + /*writeFile*/ undefined, + /*cancellationToken*/ undefined, + /*emitOnlyDtsFiles*/ undefined, + transpileOptions.transformers + ); + + if (outputText === undefined) return Debug.fail('Output generation failed'); + + return { outputText, diagnostics, sourceMapText }; + } +} diff --git a/tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js b/tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js index 806376ab1..2b57fb733 100644 --- a/tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js +++ b/tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js @@ -6,4 +6,7 @@ module.exports = { sourceMapText: '{}', }; }, + createProgram() { + throw 'emit from root custom compiler'; + } }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js index b1a45e628..389b3f04d 100644 --- a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js @@ -6,4 +6,7 @@ module.exports = { sourceMapText: '{}', }; }, + createProgram() { + throw 'emit from shared-config custom compiler'; + } }; 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..0ff9a8a62 --- /dev/null +++ b/tests/ts45-ext/ext-cts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "ts-node": { + "experimentalResolver": true + }, + "compilerOptions": { + "module": "CommonJS" + } +} diff --git a/tests/ts45-ext/ext-mts/entrypoint.mjs b/tests/ts45-ext/ext-mts/entrypoint.mjs new file mode 100644 index 000000000..13d90bee2 --- /dev/null +++ b/tests/ts45-ext/ext-mts/entrypoint.mjs @@ -0,0 +1,2 @@ +import { main } from './index.mjs'; +console.log(main()); 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..c414ea58f --- /dev/null +++ b/tests/ts45-ext/ext-mts/tsconfig.json @@ -0,0 +1,9 @@ +{ + "ts-node": { + "esm": true, + "experimentalResolver": true + }, + "compilerOptions": { + "module": "ESNext" + } +} diff --git a/website/docs/module-type-overrides.md b/website/docs/module-type-overrides.md index 05222ce2f..5247a0218 100644 --- a/website/docs/module-type-overrides.md +++ b/website/docs/module-type-overrides.md @@ -2,18 +2,22 @@ title: Module type overrides --- -When deciding between CommonJS and native ECMAScript modules, ts-node defaults to matching vanilla `node` and `tsc` -behavior. This means TypeScript files are transformed according to your `tsconfig.json` `"module"` option and executed -according to node's rules for the `package.json` `"type"` field. - -In some projects you may need to override this behavior for some files. For example, in a webpack -project, you may have `package.json` configured with `"type": "module"` and `tsconfig.json` with -`"module": "esnext"`. However, webpack uses our CommonJS hook to execute your `webpack.config.ts`, -so you need to force your webpack config and any supporting scripts to execute as CommonJS. - -In these situations, our `moduleTypes` option lets you override certain files, forcing execution as -CommonJS or ESM. Node supports similar overriding via `.cjs` and `.mjs` file extensions, but `.ts` files cannot use them. -`moduleTypes` achieves the same effect, and *also* overrides your `tsconfig.json` `"module"` config appropriately. +> Wherever possible, it is recommended to use TypeScript's [`NodeNext` or `Node16` mode](https://devblogs.microsoft.com/typescript/announcing-typescript-4-7-rc/#ecmascript-module-support-in-node-js) instead of the options described +in this section. `NodeNext`, `.mts`, and `.cts` should work well for most projects. + +When deciding how a file should be compiled and executed -- as either CommonJS or native ECMAScript module -- ts-node matches +`node` and `tsc` behavior. This means TypeScript files are transformed according to your `tsconfig.json` `"module"` +option and executed according to node's rules for the `package.json` `"type"` field. Set `"module": "NodeNext"` and everything should work. + +In rare cases, you may need to override this behavior for some files. For example, some tools read a `name-of-tool.config.ts` +and require that file to execute as CommonJS. If you have `package.json` configured with `"type": "module"` and `tsconfig.json` with +`"module": "esnext"`, the config is native ECMAScript by default and will raise an error. You will need to force the config and +any supporting scripts to execute as CommonJS. + +In these situations, our `moduleTypes` option can override certain files to be +CommonJS or ESM. Similar overriding is possible by using `.mts`, `.cts`, `.cjs` and `.mjs` file extensions. +`moduleTypes` achieves the same effect for `.ts` and `.js` files, and *also* overrides your `tsconfig.json` `"module"` +config appropriately. The following example tells ts-node to execute a webpack config as CommonJS: