From 801b113113c940b5d4b3b531e8398a19f07aac9c Mon Sep 17 00:00:00 2001 From: bluelovers Date: Thu, 9 Dec 2021 23:19:39 +0800 Subject: [PATCH 01/37] feat: support .mts .cts --- src/index.ts | 14 ++++++++++++-- src/test/index.spec.ts | 24 ++++++++++++++++++++++++ tests/ts45-ext/ext-cts/index.cts | 3 +++ tests/ts45-ext/ext-cts/tsconfig.json | 5 +++++ tests/ts45-ext/ext-mts/index.mts | 3 +++ tests/ts45-ext/ext-mts/tsconfig.json | 5 +++++ 6 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 tests/ts45-ext/ext-cts/index.cts create mode 100644 tests/ts45-ext/ext-cts/tsconfig.json create mode 100644 tests/ts45-ext/ext-mts/index.mts create mode 100644 tests/ts45-ext/ext-mts/tsconfig.json diff --git a/src/index.ts b/src/index.ts index b3506a4cd..2bf4be322 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import { } from './module-type-classifier'; import { createResolverFunctions } from './resolver-functions'; import type { createEsmHooks as createEsmHooksFn } from './esm'; +import { ModuleKind } from 'typescript'; export { TSCommon }; export { @@ -502,11 +503,20 @@ export interface DiagnosticFilter { export function getExtensions(config: _ts.ParsedCommandLine) { const tsExtensions = ['.ts']; const jsExtensions = []; + const useESNext = [ModuleKind.ES2015, ModuleKind.ES2020, 7 as any, ModuleKind.ESNext, 199 as any].indexOf(config.options.module) !== -1; + const useCommonJS = [ModuleKind.CommonJS, 100 as any].indexOf(config.options.module) !== -1; // Enable additional extensions when JSX or `allowJs` is enabled. if (config.options.jsx) tsExtensions.push('.tsx'); - if (config.options.allowJs) jsExtensions.push('.js'); - if (config.options.jsx && config.options.allowJs) jsExtensions.push('.jsx'); + // Support .mts .cts + if (useESNext) tsExtensions.push('.mts'); + if (useCommonJS) tsExtensions.push('.cts'); + if (config.options.allowJs) { + jsExtensions.push('.js'); + if (config.options.jsx) jsExtensions.push('.jsx'); + if (useESNext) tsExtensions.push('.mjs'); + if (useCommonJS) tsExtensions.push('.cjs'); + } return { tsExtensions, jsExtensions }; } diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 3b128afdc..b5f8adad4 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -183,6 +183,30 @@ test.suite('ts-node', (test) => { expect(err).toBe(null); expect(stdout).toBe('hello world\n'); }); + + test('should support cts when module = CommonJS', async () => { + const { err, stdout } = await exec( + [ + CMD_TS_NODE_WITH_PROJECT_FLAG, + '-O "{\\"module\\":"CommonJS"}"', + '-pe "import { main } from \'./ts45-ext/ext-cts/index\';main()"', + ].join(' ') + ); + expect(err).toBe(null); + expect(stdout).toBe('hello world\n'); + }); + + test('should support cts when module = ESNext', async () => { + const { err, stdout } = await exec( + [ + CMD_TS_NODE_WITH_PROJECT_FLAG, + '-O "{\\"module\\":"ESNext"}"', + '-pe "import { main } from \'./ts45-ext/ext-mts/index\';main()"', + ].join(' ') + ); + expect(err).toBe(null); + expect(stdout).toBe('hello world\n'); + }); } test('should eval code', async () => { diff --git a/tests/ts45-ext/ext-cts/index.cts b/tests/ts45-ext/ext-cts/index.cts new file mode 100644 index 000000000..9001625ad --- /dev/null +++ b/tests/ts45-ext/ext-cts/index.cts @@ -0,0 +1,3 @@ +export function main() { + return 'hello world'; +} diff --git a/tests/ts45-ext/ext-cts/tsconfig.json b/tests/ts45-ext/ext-cts/tsconfig.json new file mode 100644 index 000000000..28900bb1b --- /dev/null +++ b/tests/ts45-ext/ext-cts/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "module": "CommonJS" + } +} diff --git a/tests/ts45-ext/ext-mts/index.mts b/tests/ts45-ext/ext-mts/index.mts new file mode 100644 index 000000000..9001625ad --- /dev/null +++ b/tests/ts45-ext/ext-mts/index.mts @@ -0,0 +1,3 @@ +export function main() { + return 'hello world'; +} diff --git a/tests/ts45-ext/ext-mts/tsconfig.json b/tests/ts45-ext/ext-mts/tsconfig.json new file mode 100644 index 000000000..1ac61592b --- /dev/null +++ b/tests/ts45-ext/ext-mts/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "module": "ESNext" + } +} From 9b1d42ddb9c674a6322b14e2a4d5f8270f2c7c34 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Wed, 26 Jan 2022 22:45:12 -0500 Subject: [PATCH 02/37] stuff --- src/esm.ts | 23 ++++++++++++++++++----- src/index.ts | 27 +++++++++++++++------------ 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/esm.ts b/src/esm.ts index c83fd22c4..f95987231 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -96,7 +96,7 @@ export function createEsmHooks(tsNodeService: Service) { // Custom implementation that considers additional file extensions and automatically adds file extensions const nodeResolveImplementation = createResolve({ - ...getExtensions(tsNodeService.config), + ...tsNodeService.extensions, preferTsExts: tsNodeService.options.preferTsExts, }); @@ -112,7 +112,6 @@ export function createEsmHooks(tsNodeService: Service) { const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI ? { resolve, load, getFormat: undefined, transformSource: undefined } : { resolve, getFormat, transformSource, load: undefined }; - return hooksAPI; function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) { // We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo` @@ -205,6 +204,16 @@ export function createEsmHooks(tsNodeService: Service) { return { format, source }; } + // Mapping from extensions understood by tsc to the equivalent for node, + // as far as getFormat is concerned. + const nodeEquivalentExtensions = new Map([ + ['.ts', '.js'], + ['.tsx', '.js'], + ['.jsx', '.js'], + ['.mts', '.mjs'], + ['.cts', '.cjs'] + ]); + async function getFormat( url: string, context: {}, @@ -227,11 +236,13 @@ export function createEsmHooks(tsNodeService: Service) { const nativePath = fileURLToPath(url); - // If file has .ts, .tsx, or .jsx extension, then ask node how it would treat this file if it were .js + // If file has extension not understood by node, then ask node how it would treat the emitted extension. + // E.g. .mts compiles to .mjs, so ask node how to classify an .mjs file. const ext = extname(nativePath); let nodeSays: { format: NodeLoaderHooksFormat }; - if (ext !== '.js' && !tsNodeService.ignored(nativePath)) { - nodeSays = await defer(formatUrl(pathToFileURL(nativePath + '.js'))); + const nodeEquivalentExt = nodeEquivalentExtensions.get(ext); + if (nodeEquivalentExt && !tsNodeService.ignored(nativePath)) { + nodeSays = await defer(formatUrl(pathToFileURL(nativePath + nodeEquivalentExt))); } else { nodeSays = await defer(); } @@ -283,4 +294,6 @@ export function createEsmHooks(tsNodeService: Service) { return { source: emittedJs }; } + + return hooksAPI; } diff --git a/src/index.ts b/src/index.ts index 2bf4be322..679d047fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,6 @@ import { } from './module-type-classifier'; import { createResolverFunctions } from './resolver-functions'; import type { createEsmHooks as createEsmHooksFn } from './esm'; -import { ModuleKind } from 'typescript'; export { TSCommon }; export { @@ -480,6 +479,8 @@ export interface Service { installSourceMapSupport(): void; /** @internal */ enableExperimentalEsmLoaderInterop(): void; + /** @internal */ + extensions: Extensions; } /** @@ -500,22 +501,23 @@ export interface DiagnosticFilter { } /** @internal */ -export function getExtensions(config: _ts.ParsedCommandLine) { +export type Extensions = ReturnType; + +/** @internal */ +export function getExtensions(config: _ts.ParsedCommandLine, tsVersion: string) { + // TS 4.5 is first version to understand .cts, .mts, .cjs, and .mjs extensions + const tsSupportsMtsCtsExts = versionGteLt(tsVersion, '4.5.0'); const tsExtensions = ['.ts']; const jsExtensions = []; - const useESNext = [ModuleKind.ES2015, ModuleKind.ES2020, 7 as any, ModuleKind.ESNext, 199 as any].indexOf(config.options.module) !== -1; - const useCommonJS = [ModuleKind.CommonJS, 100 as any].indexOf(config.options.module) !== -1; + + if(tsSupportsMtsCtsExts) tsExtensions.push('.mts', '.cts'); // Enable additional extensions when JSX or `allowJs` is enabled. if (config.options.jsx) tsExtensions.push('.tsx'); - // Support .mts .cts - if (useESNext) tsExtensions.push('.mts'); - if (useCommonJS) tsExtensions.push('.cts'); if (config.options.allowJs) { - jsExtensions.push('.js'); + jsExtensions.push('.js', '.mjs', '.cjs'); if (config.options.jsx) jsExtensions.push('.jsx'); - if (useESNext) tsExtensions.push('.mjs'); - if (useCommonJS) tsExtensions.push('.cjs'); + if (tsSupportsMtsCtsExts) tsExtensions.push('.mjs', '.cjs'); } return { tsExtensions, jsExtensions }; } @@ -539,7 +541,7 @@ export function register( } const originalJsHandler = require.extensions['.js']; - const { tsExtensions, jsExtensions } = getExtensions(service.config); + const { tsExtensions, jsExtensions } = service.extensions; const extensions = [...tsExtensions, ...jsExtensions]; // Expose registered instance globally. @@ -1306,7 +1308,7 @@ export function create(rawOptions: CreateOptions = {}): Service { let active = true; const enabled = (enabled?: boolean) => enabled === undefined ? active : (active = !!enabled); - const extensions = getExtensions(config); + const extensions = getExtensions(config, ts.version); const ignored = (fileName: string) => { if (!active) return true; const ext = extname(fileName); @@ -1343,6 +1345,7 @@ export function create(rawOptions: CreateOptions = {}): Service { addDiagnosticFilter, installSourceMapSupport, enableExperimentalEsmLoaderInterop, + extensions, }; } From 49832c9643240e1fa07b7c6ae4b117f15cddfad0 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Thu, 27 Jan 2022 00:23:44 -0500 Subject: [PATCH 03/37] WIP with TODOs --- dist-raw/node-cjs-loader-utils.js | 2 ++ dist-raw/node-esm-resolve-implementation.js | 14 +++++++++++--- src/index.ts | 19 +++++++++++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/dist-raw/node-cjs-loader-utils.js b/dist-raw/node-cjs-loader-utils.js index b7ec0d531..8ddeca084 100644 --- a/dist-raw/node-cjs-loader-utils.js +++ b/dist-raw/node-cjs-loader-utils.js @@ -23,6 +23,8 @@ function assertScriptCanLoadAsCJSImpl(service, module, filename) { const tsNodeClassification = service.moduleTypeClassifier.classifyModule(normalizeSlashes(filename)); if(tsNodeClassification.moduleType === 'cjs') return; + // TODO modify to ignore package.json when file extension is ESM-only + // Function require shouldn't be used in ES modules. if (tsNodeClassification.moduleType === 'esm' || (pkg && pkg.data && pkg.data.type === 'module')) { const parentPath = module.parent && module.parent.filename; diff --git a/dist-raw/node-esm-resolve-implementation.js b/dist-raw/node-esm-resolve-implementation.js index 04ea84668..ce2ffad16 100644 --- a/dist-raw/node-esm-resolve-implementation.js +++ b/dist-raw/node-esm-resolve-implementation.js @@ -330,11 +330,19 @@ function resolveExtensions(search) { * TS's resolver can resolve foo.js to foo.ts, by replacing .js extension with several source extensions. * IMPORTANT: preserve ordering according to preferTsExts; this affects resolution behavior! */ -const replacementExtensions = extensions.filter(ext => ['.js', '.jsx', '.ts', '.tsx'].includes(ext)); +const replacementExtensionsForJs = extensions.filter(ext => ['.js', '.jsx', '.ts', '.tsx'].includes(ext)); +const replacementExtensionsForMjs = extensions.filter(ext => ['.mjs', '.mts'].includes(ext)); +const replacementExtensionsForCjs = extensions.filter(ext => ['.cjs', '.cts'].includes(ext)); function resolveReplacementExtensions(search) { - if (search.pathname.match(/\.js$/)) { - const pathnameWithoutExtension = search.pathname.slice(0, search.pathname.length - 3); + const lastDotIndex = search.pathname.lastIndexOf('.'); + const ext = search.pathname.slice(lastDotIndex); + if (ext === '.js' || ext === '.cjs' || ext === '.mjs') { + const pathnameWithoutExtension = search.pathname.slice(0, lastDotIndex); + const replacementExtensions = + ext === '.js' ? replacementExtensionsForJs + : ext === '.mjs' ? replacementExtensionsForMjs + : replacementExtensionsForCjs; for (let i = 0; i < replacementExtensions.length; i++) { const extension = replacementExtensions[i]; const guess = new URL(search.toString()); diff --git a/src/index.ts b/src/index.ts index 679d047fc..86e254dcd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1381,14 +1381,29 @@ function registerExtensions( service: Service, originalJsHandler: (m: NodeModule, filename: string) => any ) { + const exts = new Set(extensions); + // Only way to transform .mts and .cts is via the .js extension. + // Can't register those extensions cuz would allow omitting file extension; node requires ext for .cjs and .mjs + if(exts.has('.mts') || exts.has('.cts')) exts.add('.js'); + // Filter extensions which should not be added to `require.extensions` + // They may still be handled via the `.js` extension handler. + exts.delete('.mts'); + exts.delete('.cts'); + exts.delete('.mjs'); + exts.delete('.cjs'); + + // TODO do we care about overriding moduleType for mjs? No, I don't think so. + // Could conditionally register `.mjs` extension when moduleType overrides are configured, + // since that is the only situation where we want to avoid node throwing an error. + // Register new extensions. - for (const ext of extensions) { + for (const ext of exts) { registerExtension(ext, service, originalJsHandler); } if (preferTsExts) { const preferredExtensions = new Set([ - ...extensions, + ...exts, ...Object.keys(require.extensions), ]); From 7e0561410f3f5fb35485c851687520130a01f0a7 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 20 Mar 2022 13:19:49 -0400 Subject: [PATCH 04/37] WIP --- dist-raw/node-cjs-loader-utils.d.ts | 18 +++ dist-raw/node-cjs-loader-utils.js | 13 +- dist-raw/node-esm-resolve-implementation.js | 4 +- src/esm.ts | 2 +- src/index.ts | 136 +++++++++++++++----- src/module-type-classifier.ts | 22 ++-- src/node-module-type-classifier.ts | 36 ++++++ src/transpilers/swc.ts | 10 ++ src/transpilers/types.ts | 9 +- 9 files changed, 200 insertions(+), 50 deletions(-) create mode 100644 dist-raw/node-cjs-loader-utils.d.ts create mode 100644 src/node-module-type-classifier.ts diff --git a/dist-raw/node-cjs-loader-utils.d.ts b/dist-raw/node-cjs-loader-utils.d.ts new file mode 100644 index 000000000..8e1debf74 --- /dev/null +++ b/dist-raw/node-cjs-loader-utils.d.ts @@ -0,0 +1,18 @@ +export function assertScriptCanLoadAsCJSImpl( + service: import('../src/index').Service, + module: NodeJS.Module, + filename: string +): void; + +export function readPackageScope(checkPath: string): PackageScope | false; + +export interface PackageScope { + path: string, + data: { + name: string, + main?: string, + exports?: object, + imports?: object, + type?: string + } +} diff --git a/dist-raw/node-cjs-loader-utils.js b/dist-raw/node-cjs-loader-utils.js index 8ddeca084..869964104 100644 --- a/dist-raw/node-cjs-loader-utils.js +++ b/dist-raw/node-cjs-loader-utils.js @@ -8,6 +8,7 @@ const {JSONParse} = require('./node-primordials'); const {normalizeSlashes} = require('../dist/util'); module.exports.assertScriptCanLoadAsCJSImpl = assertScriptCanLoadAsCJSImpl; +module.exports.readPackageScope = readPackageScope; /** * copied from Module._extensions['.js'] @@ -20,13 +21,19 @@ 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; - // TODO modify to ignore package.json when file extension is ESM-only + + // 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') 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); diff --git a/dist-raw/node-esm-resolve-implementation.js b/dist-raw/node-esm-resolve-implementation.js index ce2ffad16..6d2fff9d1 100644 --- a/dist-raw/node-esm-resolve-implementation.js +++ b/dist-raw/node-esm-resolve-implementation.js @@ -309,6 +309,7 @@ function resolveExtensionsWithTryExactName(search) { return resolveExtensions(search); } +// [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] const extensions = Array.from(new Set([ ...(preferTsExts ? tsExtensions : []), '.js', @@ -329,6 +330,7 @@ function resolveExtensions(search) { /** * TS's resolver can resolve foo.js to foo.ts, by replacing .js extension with several source extensions. * IMPORTANT: preserve ordering according to preferTsExts; this affects resolution behavior! + * [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] */ const replacementExtensionsForJs = extensions.filter(ext => ['.js', '.jsx', '.ts', '.tsx'].includes(ext)); const replacementExtensionsForMjs = extensions.filter(ext => ['.mjs', '.mts'].includes(ext)); @@ -336,7 +338,7 @@ const replacementExtensionsForCjs = extensions.filter(ext => ['.cjs', '.cts'].in function resolveReplacementExtensions(search) { const lastDotIndex = search.pathname.lastIndexOf('.'); - const ext = search.pathname.slice(lastDotIndex); + const ext = lastDotIndex >= 0 ? search.pathname.slice(lastDotIndex) : ''; if (ext === '.js' || ext === '.cjs' || ext === '.mjs') { const pathnameWithoutExtension = search.pathname.slice(0, lastDotIndex); const replacementExtensions = diff --git a/src/esm.ts b/src/esm.ts index 1f0a72a92..7b3488a3d 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -348,7 +348,7 @@ export function createEsmHooks(tsNodeService: Service) { !tsNodeService.ignored(nativePath) && (nodeSays.format === 'commonjs' || nodeSays.format === 'module') ) { - const { moduleType } = tsNodeService.moduleTypeClassifier.classifyModule( + const { moduleType } = tsNodeService.moduleTypeClassifier.classifyModuleByModuleTypeOverrides( normalizeSlashes(nativePath) ); if (moduleType === 'cjs') { diff --git a/src/index.ts b/src/index.ts index 0a96ed0c5..7c187924a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import { installCommonjsResolveHookIfNecessary, ModuleConstructorWithInternals, } from './cjs-resolve-filename-hook'; +import { classifyModule } from './node-module-type-classifier'; export { TSCommon }; export { @@ -384,7 +385,8 @@ export interface CreateOptions { preferTsExts?: boolean; } -export type ModuleTypes = Record; +export type ModuleTypes = Record; +export type ModuleTypeOverride = 'cjs' | 'esm' | 'package'; /** @internal */ export interface OptionBasePaths { @@ -545,7 +547,10 @@ export interface DiagnosticFilter { /** @internal */ export type Extensions = ReturnType; -/** @internal */ +/** + * [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] + * @internal + */ export function getExtensions(config: _ts.ParsedCommandLine, tsVersion: string) { // TS 4.5 is first version to understand .cts, .mts, .cjs, and .mjs extensions const tsSupportsMtsCtsExts = versionGteLt(tsVersion, '4.5.0'); @@ -566,7 +571,7 @@ export function getExtensions(config: _ts.ParsedCommandLine, tsVersion: string) /** * Create a new TypeScript compiler instance and register it onto node.js - + * * @category Basic */ export function register(opts?: RegisterOptions): Service; @@ -858,19 +863,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, @@ -1278,8 +1303,6 @@ export function createFromPreloadedConfig( } } } else { - getOutput = createTranspileOnlyGetOutputFunction(); - getTypeInfo = () => { throw new TypeError( 'Type information is unavailable in "--transpile-only"' @@ -1288,19 +1311,30 @@ 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); - return (code: string, fileName: string): SourceOutput => { + return (code: string, _fileName: string): SourceOutput => { + let fileName = _fileName; let result: _ts.TranspileOutput; if (customTranspiler) { result = customTranspiler.transpile(code, { fileName, }); } else { + // [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] + // The only way to tell `ts.transpileModule` to emit node-flavored ESM is to set file extension to `.mts` or `.mjs` + if(nodeModuleEmitKind === 'nodeesm') { + const lastDotIndex = _fileName.lastIndexOf('.'); + const ext = _fileName.slice(lastDotIndex); + const fileNameSansExtension = lastDotIndex >= 0 ? _fileName.slice(0, lastDotIndex) : _fileName; + if(ext === '.ts' || ext === '.tsx') fileName = fileNameSansExtension + '.mts'; + if(ext === '.js' || ext === '.jsx') fileName = fileNameSansExtension + '.mjs'; + } result = ts.transpileModule(code, { fileName, compilerOptions, @@ -1319,44 +1353,68 @@ 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 = - config.options.module === ts.ModuleKind.ES2015 || + 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 - ); + config.options.module === ts.ModuleKind.ESNext); + /** + * node12 or nodenext + * [MUST_UPDATE_FOR_NEW_MODULEKIND] + */ + const isNodeModuleType = + (ts.ModuleKind.Node12 && config.options.module === ts.ModuleKind.Node12) || + (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(normalizedFileName, 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; @@ -1511,7 +1569,7 @@ function updateOutput( outputText: string, fileName: string, sourceMap: string, - getExtension: (fileName: string) => string + getEmitExtension: (fileName: string) => string, ) { const base64Map = Buffer.from( updateSourceMap(sourceMap, fileName), @@ -1524,7 +1582,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 = @@ -1631,3 +1689,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..4d014121d 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,9 +46,9 @@ export function createModuleTypeClassifier( } ); - const classifications: Record = { + const classifications: Record = { package: { - moduleType: 'package', + moduleType: 'auto', }, cjs: { moduleType: 'cjs', @@ -69,7 +73,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..000b5e7e9 --- /dev/null +++ b/src/node-module-type-classifier.ts @@ -0,0 +1,36 @@ +import {readPackageScope} from '../dist-raw/node-cjs-loader-utils'; + +/** + * TODO https://github.com/microsoft/TypeScript/issues/46452#issuecomment-1073145723 + * + * Determine how to emit a module based on tsconfig "module" and package.json "type" + * + * Supports module=nodenext/node12 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(filename: string, isNodeModuleType: boolean): 'nodecjs' | 'cjs' | 'esm' | 'nodeesm' | undefined { + // [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] + const lastDotIndex = filename.lastIndexOf('.'); + const ext = lastDotIndex >= 0 ? filename.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(filename); + if(packageScope && packageScope.data.type === 'module') return 'nodeesm'; + return 'nodecjs'; + } + return undefined; +} diff --git a/src/transpilers/swc.ts b/src/transpilers/swc.ts index 1d8d1c441..b447efafc 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,7 @@ 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 +88,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 +117,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 +206,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 { From f6fcc9a5fe8c2f2c255606a59a378dc59e9a48bd Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 20 Mar 2022 13:20:04 -0400 Subject: [PATCH 05/37] lint-fix --- src/esm.ts | 13 +++-- src/index.ts | 91 +++++++++++++++++++----------- src/module-type-classifier.ts | 23 ++++---- src/node-module-type-classifier.ts | 13 +++-- src/transpilers/swc.ts | 5 +- 5 files changed, 90 insertions(+), 55 deletions(-) diff --git a/src/esm.ts b/src/esm.ts index 7b3488a3d..f410de3e9 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -295,7 +295,7 @@ export function createEsmHooks(tsNodeService: Service) { ['.tsx', '.js'], ['.jsx', '.js'], ['.mts', '.mjs'], - ['.cts', '.cjs'] + ['.cts', '.cjs'], ]); async function getFormat( @@ -339,7 +339,9 @@ export function createEsmHooks(tsNodeService: Service) { let nodeSays: { format: NodeLoaderHooksFormat }; const nodeEquivalentExt = nodeEquivalentExtensions.get(ext); if (nodeEquivalentExt && !tsNodeService.ignored(nativePath)) { - nodeSays = await entrypointFallback(() => defer(formatUrl(pathToFileURL(nativePath + nodeEquivalentExt)))); + nodeSays = await entrypointFallback(() => + defer(formatUrl(pathToFileURL(nativePath + nodeEquivalentExt))) + ); } else { nodeSays = await entrypointFallback(defer); } @@ -348,9 +350,10 @@ export function createEsmHooks(tsNodeService: Service) { !tsNodeService.ignored(nativePath) && (nodeSays.format === 'commonjs' || nodeSays.format === 'module') ) { - const { moduleType } = tsNodeService.moduleTypeClassifier.classifyModuleByModuleTypeOverrides( - normalizeSlashes(nativePath) - ); + const { moduleType } = + tsNodeService.moduleTypeClassifier.classifyModuleByModuleTypeOverrides( + normalizeSlashes(nativePath) + ); if (moduleType === 'cjs') { return { format: 'commonjs' }; } else if (moduleType === 'esm') { diff --git a/src/index.ts b/src/index.ts index 7c187924a..f6795582b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -551,13 +551,16 @@ export type Extensions = ReturnType; * [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] * @internal */ -export function getExtensions(config: _ts.ParsedCommandLine, tsVersion: string) { +export function getExtensions( + config: _ts.ParsedCommandLine, + tsVersion: string +) { // TS 4.5 is first version to understand .cts, .mts, .cjs, and .mjs extensions const tsSupportsMtsCtsExts = versionGteLt(tsVersion, '4.5.0'); const tsExtensions = ['.ts']; const jsExtensions = []; - if(tsSupportsMtsCtsExts) tsExtensions.push('.mts', '.cts'); + if (tsSupportsMtsCtsExts) tsExtensions.push('.mts', '.cts'); // Enable additional extensions when JSX or `allowJs` is enabled. if (config.options.jsx) tsExtensions.push('.tsx'); @@ -870,15 +873,15 @@ export function createFromPreloadedConfig( */ function getEmitExtension(path: string) { const lastDotIndex = path.lastIndexOf('.'); - if(lastDotIndex >= 0) { + if (lastDotIndex >= 0) { const ext = path.slice(lastDotIndex); - switch(ext) { + switch (ext) { case '.js': case '.ts': return '.js'; case '.jsx': case '.tsx': - return jsxEmitPreserve ? '.jsx' : '.js' + return jsxEmitPreserve ? '.jsx' : '.js'; case '.mjs': case '.mts': return '.mjs'; @@ -1328,12 +1331,15 @@ export function createFromPreloadedConfig( } else { // [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] // The only way to tell `ts.transpileModule` to emit node-flavored ESM is to set file extension to `.mts` or `.mjs` - if(nodeModuleEmitKind === 'nodeesm') { + if (nodeModuleEmitKind === 'nodeesm') { const lastDotIndex = _fileName.lastIndexOf('.'); const ext = _fileName.slice(lastDotIndex); - const fileNameSansExtension = lastDotIndex >= 0 ? _fileName.slice(0, lastDotIndex) : _fileName; - if(ext === '.ts' || ext === '.tsx') fileName = fileNameSansExtension + '.mts'; - if(ext === '.js' || ext === '.jsx') fileName = fileNameSansExtension + '.mjs'; + const fileNameSansExtension = + lastDotIndex >= 0 ? _fileName.slice(0, lastDotIndex) : _fileName; + if (ext === '.ts' || ext === '.tsx') + fileName = fileNameSansExtension + '.mts'; + if (ext === '.js' || ext === '.jsx') + fileName = fileNameSansExtension + '.mjs'; } result = ts.transpileModule(code, { fileName, @@ -1358,21 +1364,31 @@ export function createFromPreloadedConfig( const shouldOverwriteEmitWhenForcingCommonJS = config.options.module !== ts.ModuleKind.CommonJS; // [MUST_UPDATE_FOR_NEW_MODULEKIND] - const shouldOverwriteEmitWhenForcingEsm = - !(config.options.module === ts.ModuleKind.ES2015 || + 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); + config.options.module === ts.ModuleKind.ESNext + ); /** * node12 or nodenext * [MUST_UPDATE_FOR_NEW_MODULEKIND] */ const isNodeModuleType = (ts.ModuleKind.Node12 && config.options.module === ts.ModuleKind.Node12) || - (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'); + (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 @@ -1383,38 +1399,49 @@ export function createFromPreloadedConfig( function compile(code: string, fileName: string, lineOffset = 0) { const normalizedFileName = normalizeSlashes(fileName); const classification = - moduleTypeClassifier.classifyModuleByModuleTypeOverrides(normalizedFileName); + moduleTypeClassifier.classifyModuleByModuleTypeOverrides( + normalizedFileName + ); let value: string | undefined = ''; let sourceMap: string | undefined = ''; let emitSkipped = true; - if(getOutput) { + 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' && (shouldOverwriteEmitWhenForcingCommonJS || emitSkipped)) { + if ( + classification.moduleType === 'cjs' && + (shouldOverwriteEmitWhenForcingCommonJS || emitSkipped) + ) { [value, sourceMap] = getOutputForceCommonJS(code, normalizedFileName); - } else if (classification.moduleType === 'esm' && (shouldOverwriteEmitWhenForcingEsm || emitSkipped)) { + } else if ( + classification.moduleType === 'esm' && + (shouldOverwriteEmitWhenForcingEsm || emitSkipped) + ) { [value, sourceMap] = getOutputForceESM(code, normalizedFileName); } else if (emitSkipped) { // Happens when ts compiler skips emit or in transpileOnly mode - const classification = classifyModule(normalizedFileName, isNodeModuleType); + const classification = classifyModule( + normalizedFileName, + 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); + ? 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!, - getEmitExtension, + getEmitExtension ); outputCache.set(normalizedFileName, { content: output }); return output; @@ -1502,7 +1529,7 @@ function registerExtensions( const exts = new Set(extensions); // Only way to transform .mts and .cts is via the .js extension. // Can't register those extensions cuz would allow omitting file extension; node requires ext for .cjs and .mjs - if(exts.has('.mts') || exts.has('.cts')) exts.add('.js'); + if (exts.has('.mts') || exts.has('.cts')) exts.add('.js'); // Filter extensions which should not be added to `require.extensions` // They may still be handled via the `.js` extension handler. exts.delete('.mts'); @@ -1569,7 +1596,7 @@ function updateOutput( outputText: string, fileName: string, sourceMap: string, - getEmitExtension: (fileName: string) => string, + getEmitExtension: (fileName: string) => string ) { const base64Map = Buffer.from( updateSourceMap(sourceMap, fileName), diff --git a/src/module-type-classifier.ts b/src/module-type-classifier.ts index 4d014121d..34a4fba5c 100644 --- a/src/module-type-classifier.ts +++ b/src/module-type-classifier.ts @@ -46,17 +46,18 @@ export function createModuleTypeClassifier( } ); - const classifications: Record = { - package: { - moduleType: 'auto', - }, - 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! diff --git a/src/node-module-type-classifier.ts b/src/node-module-type-classifier.ts index 000b5e7e9..1d083d6d6 100644 --- a/src/node-module-type-classifier.ts +++ b/src/node-module-type-classifier.ts @@ -1,4 +1,4 @@ -import {readPackageScope} from '../dist-raw/node-cjs-loader-utils'; +import { readPackageScope } from '../dist-raw/node-cjs-loader-utils'; /** * TODO https://github.com/microsoft/TypeScript/issues/46452#issuecomment-1073145723 @@ -15,11 +15,14 @@ import {readPackageScope} from '../dist-raw/node-cjs-loader-utils'; * - undefined == emit according to tsconfig `module` config, whatever that is * @internal */ -export function classifyModule(filename: string, isNodeModuleType: boolean): 'nodecjs' | 'cjs' | 'esm' | 'nodeesm' | undefined { +export function classifyModule( + filename: string, + isNodeModuleType: boolean +): 'nodecjs' | 'cjs' | 'esm' | 'nodeesm' | undefined { // [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] const lastDotIndex = filename.lastIndexOf('.'); const ext = lastDotIndex >= 0 ? filename.slice(lastDotIndex) : ''; - switch(ext) { + switch (ext) { case '.cjs': case '.cts': return isNodeModuleType ? 'nodecjs' : 'cjs'; @@ -27,9 +30,9 @@ export function classifyModule(filename: string, isNodeModuleType: boolean): 'no case '.mts': return isNodeModuleType ? 'nodeesm' : 'esm'; } - if(isNodeModuleType) { + if (isNodeModuleType) { const packageScope = readPackageScope(filename); - if(packageScope && packageScope.data.type === 'module') return 'nodeesm'; + if (packageScope && packageScope.data.type === 'module') return 'nodeesm'; return 'nodecjs'; } return undefined; diff --git a/src/transpilers/swc.ts b/src/transpilers/swc.ts index b447efafc..246b70f40 100644 --- a/src/transpilers/swc.ts +++ b/src/transpilers/swc.ts @@ -17,7 +17,7 @@ export function create(createOptions: SwcTranspilerOptions): Transpiler { swc, service: { config, projectLocalResolveHelper }, transpilerConfigLocalResolveHelper, - nodeModuleEmitKind + nodeModuleEmitKind, } = createOptions; // Load swc compiler @@ -79,7 +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; + 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 From 637d2323bd472bead29f8a35445785a0ba849f58 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Fri, 25 Mar 2022 10:22:30 -0400 Subject: [PATCH 06/37] WIP --- CAVEATS.md | 10 +++ TODOs.md | 48 ++++++++++++ src/test/module-node.spec.ts | 104 ++++++++++++++++++++++++++ tests/module-node/package.json | 3 + tests/module-node/src/targets/cjs.cjs | 1 + tests/module-node/src/targets/cts.cts | 0 tests/module-node/src/targets/jsx.jsx | 0 tests/module-node/src/targets/mjs.mjs | 0 tests/module-node/src/targets/mts.mts | 0 tests/module-node/src/targets/ts.ts | 0 tests/module-node/src/targets/tsx.tsx | 0 tests/module-node/tsconfig.json | 3 + 12 files changed, 169 insertions(+) create mode 100644 CAVEATS.md create mode 100644 TODOs.md create mode 100644 src/test/module-node.spec.ts create mode 100644 tests/module-node/package.json create mode 100644 tests/module-node/src/targets/cjs.cjs create mode 100644 tests/module-node/src/targets/cts.cts create mode 100644 tests/module-node/src/targets/jsx.jsx create mode 100644 tests/module-node/src/targets/mjs.mjs create mode 100644 tests/module-node/src/targets/mts.mts create mode 100644 tests/module-node/src/targets/ts.ts create mode 100644 tests/module-node/src/targets/tsx.tsx create mode 100644 tests/module-node/tsconfig.json diff --git a/CAVEATS.md b/CAVEATS.md new file mode 100644 index 000000000..bda316ecc --- /dev/null +++ b/CAVEATS.md @@ -0,0 +1,10 @@ +CAVEATS + +Node does not have require.extensions for mjs nor cjs +Thus they must be require()d including the extension. +Today, `ts-node` relies in extension omission to support CJS compilation. + +How do we support .cts and .mts? +Allow someone to import .cts / .mts directly? +Wait for our resolver hook to be implemented? +Merge file extension resolving PR? (#1361) diff --git a/TODOs.md b/TODOs.md new file mode 100644 index 000000000..f24891cf4 --- /dev/null +++ b/TODOs.md @@ -0,0 +1,48 @@ +#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/src/test/module-node.spec.ts b/src/test/module-node.spec.ts new file mode 100644 index 000000000..aa4b701f8 --- /dev/null +++ b/src/test/module-node.spec.ts @@ -0,0 +1,104 @@ +import { _test, expect } from './testlib'; +import { resetNodeEnvironment, ts } from './helpers'; +import * as fs from 'fs'; +import semver = require('semver'); +import { + CMD_TS_NODE_WITH_PROJECT_FLAG, + contextTsNodeUnderTest, + getStream, + TEST_DIR, +} from './helpers'; +import { createExec, createExecTester } from './exec-helpers'; +import { promisify } from 'util'; +import { createImportEqualsDeclaration } from 'typescript'; + +const test = _test.context(contextTsNodeUnderTest); +test.beforeEach(async t => { + resetNodeEnvironment(); +}) + +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; +const extensions = [ + { + ext: 'cts', + isCjs: true, + }, { + ext: 'cjs', + isCjs: true, + isJs: true, + }, { + ext: 'mts', + isEsm: true, + }, { + ext: 'mjs', + isEsm: true, + isJs: true, + }, { + ext: 'ts', + }, { + ext: 'tsx', + isJsx: true, + }, { + ext: 'jsx', + isJsx: true, + isJs: true, + }, { + ext: 'js' + } +] as const; + +interface TestParams { + packageJsonType: typeof packageJsonTypes[number]; + typecheckMode: typeof typecheckModes[number]; + allowJs: boolean; +} + +interface SubtestParams { + importStyle: typeof importStyles[number], + importerExtension: typeof extensions[number] +} + +interface ImporteeParams { + importeeExtension: typeof extensions[number] +} + +for(const allowJs of [true, false]) { + for(const typecheckMode of ['typecheck', 'transpileOnly', 'swc'] as const) { + for(const packageJsonType of [undefined, 'commonjs', 'module'] as const) { + createTest({allowJs, packageJsonType, typecheckMode}); + } + } +} + +function createTest(params: TestParams) { + const {allowJs, packageJsonType, typecheckMode} = params; + const name = `package.json-type=${packageJsonType} allowJs=${allowJs} ${typecheckMode}`; + const dir = fs.mkdtempSync(`${ TEST_DIR }/tmp-`); + + for(const importStyle of importStyles) { + for(const importerExtension of extensions) { + createSubtest({importStyle, importerExtension}); + } + } + + function createSubtest(subtestParams: SubtestParams) { + const {importStyle, importerExtension} = subtestParams; + const name = `${importerExtension.ext} ${importStyle}`; + + let importerSource = ''; + + for(const importeeExtension of extensions) { + createImportee({importeeExtension}); + } + + function createImportee(importeeParams: ImporteeParams) { + const {importeeExtension} = importeeParams; + fs.writeFileSync(`${ dir }/${ name }/${importeeExtension.ext}.${importeeExtension.ext}`, ` + + `); + } + } +} diff --git a/tests/module-node/package.json b/tests/module-node/package.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/tests/module-node/package.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/tests/module-node/src/targets/cjs.cjs b/tests/module-node/src/targets/cjs.cjs new file mode 100644 index 000000000..d5637593f --- /dev/null +++ b/tests/module-node/src/targets/cjs.cjs @@ -0,0 +1 @@ +export diff --git a/tests/module-node/src/targets/cts.cts b/tests/module-node/src/targets/cts.cts new file mode 100644 index 000000000..e69de29bb diff --git a/tests/module-node/src/targets/jsx.jsx b/tests/module-node/src/targets/jsx.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/tests/module-node/src/targets/mjs.mjs b/tests/module-node/src/targets/mjs.mjs new file mode 100644 index 000000000..e69de29bb diff --git a/tests/module-node/src/targets/mts.mts b/tests/module-node/src/targets/mts.mts new file mode 100644 index 000000000..e69de29bb diff --git a/tests/module-node/src/targets/ts.ts b/tests/module-node/src/targets/ts.ts new file mode 100644 index 000000000..e69de29bb diff --git a/tests/module-node/src/targets/tsx.tsx b/tests/module-node/src/targets/tsx.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/tests/module-node/tsconfig.json b/tests/module-node/tsconfig.json new file mode 100644 index 000000000..0db3279e4 --- /dev/null +++ b/tests/module-node/tsconfig.json @@ -0,0 +1,3 @@ +{ + +} From 26aff190290388b6f015e912aea83fa0754495ff Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Fri, 25 Mar 2022 20:58:09 -0400 Subject: [PATCH 07/37] WIP --- src/test/fs-helpers.ts | 80 ++++++++++++++++++++++ src/test/module-node.spec.ts | 129 +++++++++++++++++++++++++++++++---- 2 files changed, 195 insertions(+), 14 deletions(-) create mode 100644 src/test/fs-helpers.ts diff --git a/src/test/fs-helpers.ts b/src/test/fs-helpers.ts new file mode 100644 index 000000000..f5a32090f --- /dev/null +++ b/src/test/fs-helpers.ts @@ -0,0 +1,80 @@ +import { TEST_DIR } from "./helpers"; +import * as fs from "fs"; +import * as Path from "path"; + +// Helpers to describe a bunch of files in a project programmatically, +// then write them to disk in a temp directory. + +export interface File { + path: string; + content: string; +} +export interface JsonFile extends File { + obj: T; +} +export interface DirectoryApi { + add(file: File): void; + addFile(...args: Parameters): void; + addJsonFile(...args: Parameters): void; + dir(dirPath: string, cb?: (dir: DirectoryApi) => void): DirectoryApi; +} + +export function file(path: string, content = '') { + return {path, content}; +} +export function jsonFile(path: string, obj: T) { + const file: JsonFile = { + path, + obj, + get content() { + return JSON.stringify(obj, null, 2); + } + }; + return file; +} + +export function tempdirProject() { + const tmpdir = fs.mkdtempSync(`${ TEST_DIR }/tmp/`); + const files: File[] = []; + function write() { + for(const file of files) { + const outPath = Path.join(tmpdir, file.path); + fs.mkdirSync(Path.dirname(outPath), {recursive: true}); + fs.writeFileSync(outPath, file.content); + } + } + function rm() { + fs.rmdirSync(tmpdir, {recursive: true}); + } + const {add, addFile, addJsonFile, dir} = createDirectory(tmpdir); + function createDirectory(dirPath: string, cb?: (dir: DirectoryApi) => void): DirectoryApi { + function add(file: File) { + file.path = Path.join(dirPath, file.path); + files.push(file); + } + function addFile(...args: Parameters) { + add(file(...args)); + } + function addJsonFile(...args: Parameters) { + add(jsonFile(...args)); + } + function dir(path: string, cb?: (dir: DirectoryApi) => void) { + return createDirectory(Path.join(dirPath, path), cb); + } + const _dir: DirectoryApi = { + add, addFile, addJsonFile, dir + } + cb?.(_dir); + return _dir; + } + return { + cwd: tmpdir, + files: [], + dir, + add, + addFile, + addJsonFile, + write, + rm, + } +} diff --git a/src/test/module-node.spec.ts b/src/test/module-node.spec.ts index aa4b701f8..a639b5913 100644 --- a/src/test/module-node.spec.ts +++ b/src/test/module-node.spec.ts @@ -10,7 +10,8 @@ import { } from './helpers'; import { createExec, createExecTester } from './exec-helpers'; import { promisify } from 'util'; -import { createImportEqualsDeclaration } from 'typescript'; +import { createImportEqualsDeclaration, isPartiallyEmittedExpression } from 'typescript'; +import { file, tempdirProject } from './fs-helpers'; const test = _test.context(contextTsNodeUnderTest); test.beforeEach(async t => { @@ -21,32 +22,62 @@ 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; + forcesCjs: boolean; + forcesEsm: boolean; + isJs: boolean; + supportsJsx: boolean; +} const extensions = [ { ext: 'cts', - isCjs: true, + forcesCjs: true, + forcesEsm: false, + isJs: false, + supportsJsx: true }, { ext: 'cjs', - isCjs: true, + forcesCjs: true, + forcesEsm: false, isJs: true, + supportsJsx: true, }, { ext: 'mts', - isEsm: true, + forcesCjs: false, + forcesEsm: true, + isJs: false, + supportsJsx: true, }, { ext: 'mjs', - isEsm: true, + forcesCjs: false, + forcesEsm: true, isJs: true, + supportsJsx: true, }, { ext: 'ts', + forcesCjs: false, + forcesEsm: false, + isJs: false, + supportsJsx: false, }, { ext: 'tsx', - isJsx: true, + forcesCjs: false, + forcesEsm: false, + isJs: false, + supportsJsx: true, }, { ext: 'jsx', - isJsx: true, + forcesCjs: false, + forcesEsm: false, isJs: true, + supportsJsx: true, }, { - ext: 'js' + ext: 'js', + forcesCjs: false, + forcesEsm: false, + isJs: true, + supportsJsx: false, } ] as const; @@ -75,8 +106,8 @@ for(const allowJs of [true, false]) { function createTest(params: TestParams) { const {allowJs, packageJsonType, typecheckMode} = params; - const name = `package.json-type=${packageJsonType} allowJs=${allowJs} ${typecheckMode}`; - const dir = fs.mkdtempSync(`${ TEST_DIR }/tmp-`); + const name = `package-json-type=${packageJsonType} allowJs=${allowJs} ${typecheckMode}`; + const tempProject = tempdirProject(); for(const importStyle of importStyles) { for(const importerExtension of extensions) { @@ -88,17 +119,87 @@ function createTest(params: TestParams) { const {importStyle, importerExtension} = subtestParams; const name = `${importerExtension.ext} ${importStyle}`; - let importerSource = ''; + const dir = tempProject.dir(name); + + dir.addJsonFile('package.json', { + type: packageJsonType + }); + + dir.addJsonFile('tsconfig.json', { + compilerOptions: { + allowJs, + target: 'esnext', + module: 'nodenext' + }, + 'ts-node': { + transpileOnly: typecheckMode === 'transpileOnly', + swc: typecheckMode === 'swc' + } + }); + + let importer = file(`importer.${importerExtension.ext}`, ` + async function main() { + `); + dir.add(importer); for(const importeeExtension of extensions) { createImportee({importeeExtension}); + switch(importStyle) { + case 'dynamic import': + importer.content += `await import('./${importeeExtension.ext}');\n`; + break; + case 'import = require': + importer.content += `import ${importeeExtension.ext} = require('./${importeeExtension.ext}');\n`; + break; + case 'require': + importer.content += `const ${importeeExtension.ext} = require('./${importeeExtension.ext}');\n`; + break; + case 'static import': + importer.content += `import * as ${importeeExtension.ext} from './${importeeExtension.ext}';\n`; + break; + } + importer.content += `if(${importeeExtension.ext}.ext !== '${importeeExtension.ext}') throw new Error('Wrong export from importee: expected ${importeeExtension.ext} but got ' + ${importeeExtension.ext}.ext);\n` } + importer.content += ` + } + main(); + `; + function createImportee(importeeParams: ImporteeParams) { const {importeeExtension} = importeeParams; - fs.writeFileSync(`${ dir }/${ name }/${importeeExtension.ext}.${importeeExtension.ext}`, ` - - `); + const f = file(`${importeeExtension.ext}.${importeeExtension.ext}`); + const isCompiled = allowJs || !importeeExtension.isJs; + const isExecutedAsEsm = importeeExtension.forcesEsm || (!importeeExtension.forcesCjs && packageJsonType === 'module'); + const isExecutedAsCjs = !isExecutedAsEsm; + if(isCompiled || isExecutedAsEsm) { + f.content += `export const ext = '${importeeExtension.ext}';\n`; + } else { + f.content += `exports.ext = '${importeeExtension.ext}';\n`; + } + if(!importeeExtension.isJs) { + f.content += `const testTsTypeSyntax: string = 'a string';\n`; + } + if(isExecutedAsCjs) { + f.content += `if(typeof __filename !== 'string') throw new Error('expected file to be CJS but __filename is not declared');\n`; + } else { + f.content += `if(typeof __filename !== 'undefined') throw new Error('expected file to be ESM but __filename is declared');\n`; + f.content += `if(typeof import.meta.url !== 'string') throw new Error('expected file to be ESM but import.meta.url is not declared');\n`; + } + if(importeeExtension.supportsJsx) { + f.content += ` + const React = { + createElement(tag, dunno, content) { + return content + } + }; + const jsxTest = Hello World; + if(jsxTest !== 'Hello World') throw new Error('Expected ${importeeExtension.ext} to support JSX but it did not.'); + `; + } + dir.add(f); } } + + tempProject.write(); } From 75bc08d38b317428c797310a0042463d1a80429e Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 26 Mar 2022 00:19:27 -0400 Subject: [PATCH 08/37] WIP --- .gitignore | 1 + .prettierignore | 13 +- src/index.ts | 77 ++--- src/test/exec-helpers.ts | 1 + src/test/fs-helpers.ts | 64 +++-- src/test/module-node.spec.ts | 394 ++++++++++++++++---------- tests/module-node/package.json | 3 - tests/module-node/src/targets/cjs.cjs | 1 - tests/module-node/src/targets/cts.cts | 0 tests/module-node/src/targets/jsx.jsx | 0 tests/module-node/src/targets/mjs.mjs | 0 tests/module-node/src/targets/mts.mts | 0 tests/module-node/src/targets/ts.ts | 0 tests/module-node/src/targets/tsx.tsx | 0 tests/module-node/tsconfig.json | 3 - 15 files changed, 345 insertions(+), 212 deletions(-) delete mode 100644 tests/module-node/package.json delete mode 100644 tests/module-node/src/targets/cjs.cjs delete mode 100644 tests/module-node/src/targets/cts.cts delete mode 100644 tests/module-node/src/targets/jsx.jsx delete mode 100644 tests/module-node/src/targets/mjs.mjs delete mode 100644 tests/module-node/src/targets/mts.mts delete mode 100644 tests/module-node/src/targets/ts.ts delete mode 100644 tests/module-node/src/targets/tsx.tsx delete mode 100644 tests/module-node/tsconfig.json diff --git a/.gitignore b/.gitignore index f8e97ed5f..790e47d87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /node_modules/ /tests/node_modules/ +/tests/tmp .nyc_output/ coverage/ .DS_Store diff --git a/.prettierignore b/.prettierignore index 3afc6e862..fae3d8b6c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,9 +12,10 @@ /website/docs /website/readme-sources /website/static -tests/main-realpath/symlink/tsconfig.json -tests/throw error.ts -tests/throw error react tsx.tsx -tests/esm/throw error.ts -tests/legacy-source-map-support-interop/index.ts -tests/main-realpath/symlink/symlink.tsx +/tests/main-realpath/symlink/tsconfig.json +/tests/throw error.ts +/tests/throw error react tsx.tsx +/tests/esm/throw error.ts +/tests/legacy-source-map-support-interop/index.ts +/tests/main-realpath/symlink/symlink.tsx +/tests/tmp diff --git a/src/index.ts b/src/index.ts index f6795582b..a7af1132a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -754,41 +754,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, + }); + } + } } /** @@ -1320,7 +1326,10 @@ export function createFromPreloadedConfig( const compilerOptions = { ...config.options }; if (overrideModuleType !== undefined) compilerOptions.module = overrideModuleType; - let customTranspiler = createTranspiler?.(compilerOptions); + let customTranspiler = createTranspiler?.( + compilerOptions, + nodeModuleEmitKind + ); return (code: string, _fileName: string): SourceOutput => { let fileName = _fileName; let result: _ts.TranspileOutput; diff --git a/src/test/exec-helpers.ts b/src/test/exec-helpers.ts index bf0766475..c98c4c7ba 100644 --- a/src/test/exec-helpers.ts +++ b/src/test/exec-helpers.ts @@ -100,6 +100,7 @@ export function createSpawn>( } const defaultExec = createExec(); +export { defaultExec as exec }; export interface ExecTesterOptions { cmd: string; diff --git a/src/test/fs-helpers.ts b/src/test/fs-helpers.ts index f5a32090f..09376cc62 100644 --- a/src/test/fs-helpers.ts +++ b/src/test/fs-helpers.ts @@ -1,6 +1,6 @@ -import { TEST_DIR } from "./helpers"; -import * as fs from "fs"; -import * as Path from "path"; +import { TEST_DIR } from './helpers'; +import * as fs from 'fs'; +import * as Path from 'path'; // Helpers to describe a bunch of files in a project programmatically, // then write them to disk in a temp directory. @@ -13,14 +13,16 @@ export interface JsonFile extends File { obj: T; } export interface DirectoryApi { - add(file: File): void; - addFile(...args: Parameters): void; - addJsonFile(...args: Parameters): void; + add(file: File): File; + addFile(...args: Parameters): File; + addJsonFile(...args: Parameters): JsonFile; dir(dirPath: string, cb?: (dir: DirectoryApi) => void): DirectoryApi; } +export type ProjectAPI = ReturnType; + export function file(path: string, content = '') { - return {path, content}; + return { path, content }; } export function jsonFile(path: string, obj: T) { const file: JsonFile = { @@ -28,47 +30,63 @@ export function jsonFile(path: string, obj: T) { obj, get content() { return JSON.stringify(obj, null, 2); - } + }, }; return file; } -export function tempdirProject() { - const tmpdir = fs.mkdtempSync(`${ TEST_DIR }/tmp/`); +export function tempdirProject(name = '') { + const rootTmpDir = `${TEST_DIR}/tmp/`; + fs.mkdirSync(rootTmpDir, { recursive: true }); + const tmpdir = fs.mkdtempSync(`${TEST_DIR}/tmp/${name}`); + return projectInternal(tmpdir); +} + +export function project(name: string) { + return projectInternal(`${TEST_DIR}/tmp/${name}`); +} + +function projectInternal(cwd: string) { const files: File[] = []; function write() { - for(const file of files) { - const outPath = Path.join(tmpdir, file.path); - fs.mkdirSync(Path.dirname(outPath), {recursive: true}); - fs.writeFileSync(outPath, file.content); + for (const file of files) { + fs.mkdirSync(Path.dirname(file.path), { recursive: true }); + fs.writeFileSync(file.path, file.content); } } function rm() { - fs.rmdirSync(tmpdir, {recursive: true}); + fs.rmdirSync(cwd, { recursive: true }); } - const {add, addFile, addJsonFile, dir} = createDirectory(tmpdir); - function createDirectory(dirPath: string, cb?: (dir: DirectoryApi) => void): DirectoryApi { + const { add, addFile, addJsonFile, dir } = createDirectory(cwd); + function createDirectory( + dirPath: string, + cb?: (dir: DirectoryApi) => void + ): DirectoryApi { function add(file: File) { file.path = Path.join(dirPath, file.path); files.push(file); + return file; } function addFile(...args: Parameters) { - add(file(...args)); + return add(file(...args)); } function addJsonFile(...args: Parameters) { - add(jsonFile(...args)); + return add(jsonFile(...args)) as JsonFile; } function dir(path: string, cb?: (dir: DirectoryApi) => void) { return createDirectory(Path.join(dirPath, path), cb); } const _dir: DirectoryApi = { - add, addFile, addJsonFile, dir - } + add, + addFile, + addJsonFile, + dir, + }; cb?.(_dir); return _dir; } return { - cwd: tmpdir, + cwd, files: [], dir, add, @@ -76,5 +94,5 @@ export function tempdirProject() { addJsonFile, write, rm, - } + }; } diff --git a/src/test/module-node.spec.ts b/src/test/module-node.spec.ts index a639b5913..8fdd2bdce 100644 --- a/src/test/module-node.spec.ts +++ b/src/test/module-node.spec.ts @@ -1,193 +1,303 @@ -import { _test, expect } from './testlib'; -import { resetNodeEnvironment, ts } from './helpers'; -import * as fs from 'fs'; -import semver = require('semver'); +import { expect, context } from './testlib'; import { - CMD_TS_NODE_WITH_PROJECT_FLAG, - contextTsNodeUnderTest, - getStream, - TEST_DIR, + CMD_TS_NODE_WITHOUT_PROJECT_FLAG, + resetNodeEnvironment, } from './helpers'; -import { createExec, createExecTester } from './exec-helpers'; -import { promisify } from 'util'; -import { createImportEqualsDeclaration, isPartiallyEmittedExpression } from 'typescript'; -import { file, tempdirProject } from './fs-helpers'; +import * as Path from 'path'; +import { contextTsNodeUnderTest } from './helpers'; +import { exec } from './exec-helpers'; +import { file, project, ProjectAPI as ProjectAPI } from './fs-helpers'; -const test = _test.context(contextTsNodeUnderTest); -test.beforeEach(async t => { +const test = context(contextTsNodeUnderTest); +test.beforeEach(async () => { resetNodeEnvironment(); -}) +}); +// Declare one test case for each permutations of project configuration +for (const allowJs of [true, false]) { + for (const typecheckMode of ['typecheck', 'transpileOnly', 'swc'] as const) { + for (const packageJsonType of [undefined, 'commonjs', 'module'] as const) { + declareTest({ allowJs, packageJsonType, typecheckMode }); + } + } +} + +function declareTest(testParams: TestParams) { + const name = `package-json-type=${testParams.packageJsonType} allowJs=${testParams.allowJs} ${testParams.typecheckMode}`; + + test(name, async (t) => { + const proj = writeFixturesToFilesystem(name, testParams); + + // 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 importStyles = [ + 'static import', + 'require', + 'dynamic import', + 'import = require', +] as const; const importExtension = ['js', 'ts', 'omitted'] as const; + interface Extension { ext: string; - forcesCjs: boolean; - forcesEsm: boolean; - isJs: boolean; - supportsJsx: boolean; + jsEquivalentExt?: string; + forcesCjs?: boolean; + forcesEsm?: boolean; + isJs?: boolean; + supportsJsx?: boolean; + isJsxExt?: boolean; + cjsAllowsOmittingExt?: boolean; } -const extensions = [ +const extensions: Extension[] = [ { ext: 'cts', + jsEquivalentExt: 'cjs', forcesCjs: true, - forcesEsm: false, - isJs: false, - supportsJsx: true - }, { + supportsJsx: true, + }, + { ext: 'cjs', forcesCjs: true, - forcesEsm: false, isJs: true, supportsJsx: true, - }, { + }, + { ext: 'mts', - forcesCjs: false, + jsEquivalentExt: 'mjs', forcesEsm: true, - isJs: false, supportsJsx: true, - }, { + }, + { ext: 'mjs', - forcesCjs: false, forcesEsm: true, isJs: true, supportsJsx: true, - }, { + }, + { ext: 'ts', - forcesCjs: false, - forcesEsm: false, - isJs: false, - supportsJsx: false, - }, { + jsEquivalentExt: 'js', + cjsAllowsOmittingExt: true, + }, + { ext: 'tsx', - forcesCjs: false, - forcesEsm: false, - isJs: false, + jsEquivalentExt: 'js', supportsJsx: true, - }, { + isJsxExt: true, + cjsAllowsOmittingExt: true, + }, + { ext: 'jsx', - forcesCjs: false, - forcesEsm: false, + jsEquivalentExt: 'js', isJs: true, supportsJsx: true, - }, { + isJsxExt: true, + cjsAllowsOmittingExt: true, + }, + { ext: 'js', - forcesCjs: false, - forcesEsm: false, isJs: true, - supportsJsx: false, - } -] as const; + 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: typeof packageJsonTypes[number]; + packageJsonType: PackageJsonType; typecheckMode: typeof typecheckModes[number]; allowJs: boolean; } -interface SubtestParams { - importStyle: typeof importStyles[number], - importerExtension: typeof extensions[number] +interface ImporterParams { + importStyle: typeof importStyles[number]; + importerExtension: typeof extensions[number]; } interface ImporteeParams { - importeeExtension: typeof extensions[number] + importeeExtension: typeof extensions[number]; } -for(const allowJs of [true, false]) { - for(const typecheckMode of ['typecheck', 'transpileOnly', 'swc'] as const) { - for(const packageJsonType of [undefined, 'commonjs', 'module'] as const) { - createTest({allowJs, packageJsonType, typecheckMode}); +function writeFixturesToFilesystem(name: string, testParams: TestParams) { + const { packageJsonType, allowJs, typecheckMode } = testParams; + + const proj = project(name.replace(/ /g, '_')); + + proj.addJsonFile('package.json', { + type: packageJsonType, + }); + + proj.addJsonFile('tsconfig.json', { + compilerOptions: { + allowJs, + target: 'esnext', + module: 'NodeNext', + jsx: 'react', + }, + 'ts-node': { + transpileOnly: typecheckMode === 'transpileOnly' || undefined, + swc: typecheckMode === 'swc', + }, + }); + + 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 createTest(params: TestParams) { - const {allowJs, packageJsonType, typecheckMode} = params; - const name = `package-json-type=${packageJsonType} allowJs=${allowJs} ${typecheckMode}`; - const tempProject = tempdirProject(); +function createImporter( + proj: ProjectAPI, + testParams: TestParams, + importerParams: ImporterParams +) { + const { importStyle, importerExtension } = importerParams; + const name = `${importStyle} from ${importerExtension.ext}`; - for(const importStyle of importStyles) { - for(const importerExtension of extensions) { - createSubtest({importStyle, importerExtension}); - } - } + const importerTreatment = getExtensionTreatment( + importerExtension, + testParams + ); + + if (!importerTreatment.isAllowed) return; + // import = require only allowed in TS files + if (importStyle === 'import = require' && importerExtension.isJs) 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); - function createSubtest(subtestParams: SubtestParams) { - const {importStyle, importerExtension} = subtestParams; - const name = `${importerExtension.ext} ${importStyle}`; - - const dir = tempProject.dir(name); - - dir.addJsonFile('package.json', { - type: packageJsonType - }); - - dir.addJsonFile('tsconfig.json', { - compilerOptions: { - allowJs, - target: 'esnext', - module: 'nodenext' - }, - 'ts-node': { - transpileOnly: typecheckMode === 'transpileOnly', - swc: typecheckMode === 'swc' - } - }); - - let importer = file(`importer.${importerExtension.ext}`, ` - async function main() { - `); - dir.add(importer); - - for(const importeeExtension of extensions) { - createImportee({importeeExtension}); - switch(importStyle) { - case 'dynamic import': - importer.content += `await import('./${importeeExtension.ext}');\n`; - break; - case 'import = require': - importer.content += `import ${importeeExtension.ext} = require('./${importeeExtension.ext}');\n`; - break; - case 'require': - importer.content += `const ${importeeExtension.ext} = require('./${importeeExtension.ext}');\n`; - break; - case 'static import': - importer.content += `import * as ${importeeExtension.ext} from './${importeeExtension.ext}';\n`; - break; - } - importer.content += `if(${importeeExtension.ext}.ext !== '${importeeExtension.ext}') throw new Error('Wrong export from importee: expected ${importeeExtension.ext} but got ' + ${importeeExtension.ext}.ext);\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; + + let importSpecifier = `./${importeeExtension.ext}`; + if ( + !importeeExtension.cjsAllowsOmittingExt || + importeeTreatment.isExecutedAsEsm + ) + 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; } - importer.content += ` - } - main(); - `; - - function createImportee(importeeParams: ImporteeParams) { - const {importeeExtension} = importeeParams; - const f = file(`${importeeExtension.ext}.${importeeExtension.ext}`); - const isCompiled = allowJs || !importeeExtension.isJs; - const isExecutedAsEsm = importeeExtension.forcesEsm || (!importeeExtension.forcesCjs && packageJsonType === 'module'); - const isExecutedAsCjs = !isExecutedAsEsm; - if(isCompiled || isExecutedAsEsm) { - f.content += `export const ext = '${importeeExtension.ext}';\n`; - } else { - f.content += `exports.ext = '${importeeExtension.ext}';\n`; - } - if(!importeeExtension.isJs) { - f.content += `const testTsTypeSyntax: string = 'a string';\n`; - } - if(isExecutedAsCjs) { - f.content += `if(typeof __filename !== 'string') throw new Error('expected file to be CJS but __filename is not declared');\n`; - } else { - f.content += `if(typeof __filename !== 'undefined') throw new Error('expected file to be ESM but __filename is declared');\n`; - f.content += `if(typeof import.meta.url !== 'string') throw new Error('expected file to be ESM but import.meta.url is not declared');\n`; - } - if(importeeExtension.supportsJsx) { - f.content += ` + importer.assertions += `if(${importeeExtension.ext}.ext !== '${importeeExtension.ext}') throw new Error('Wrong export from importee: expected ${importeeExtension.ext} but got ' + ${importeeExtension.ext}.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 content @@ -196,10 +306,10 @@ function createTest(params: TestParams) { const jsxTest = Hello World; if(jsxTest !== 'Hello World') throw new Error('Expected ${importeeExtension.ext} to support JSX but it did not.'); `; - } - dir.add(f); - } } + return { importee, treatment }; +} - tempProject.write(); +function replaceExtension(path: string, ext: string) { + return Path.format({ ...Path.parse(path), ext: '.' + ext, base: undefined }); } diff --git a/tests/module-node/package.json b/tests/module-node/package.json deleted file mode 100644 index 0db3279e4..000000000 --- a/tests/module-node/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - -} diff --git a/tests/module-node/src/targets/cjs.cjs b/tests/module-node/src/targets/cjs.cjs deleted file mode 100644 index d5637593f..000000000 --- a/tests/module-node/src/targets/cjs.cjs +++ /dev/null @@ -1 +0,0 @@ -export diff --git a/tests/module-node/src/targets/cts.cts b/tests/module-node/src/targets/cts.cts deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/module-node/src/targets/jsx.jsx b/tests/module-node/src/targets/jsx.jsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/module-node/src/targets/mjs.mjs b/tests/module-node/src/targets/mjs.mjs deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/module-node/src/targets/mts.mts b/tests/module-node/src/targets/mts.mts deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/module-node/src/targets/ts.ts b/tests/module-node/src/targets/ts.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/module-node/src/targets/tsx.tsx b/tests/module-node/src/targets/tsx.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/module-node/tsconfig.json b/tests/module-node/tsconfig.json deleted file mode 100644 index 0db3279e4..000000000 --- a/tests/module-node/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - -} From f43292dde3794d28e139791e8c624514456b1aa9 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 15 May 2022 17:38:15 -0400 Subject: [PATCH 09/37] fixes --- dist-raw/node-cjs-loader-utils.d.ts | 18 ------------------ dist-raw/node-internal-modules-cjs-loader.js | 5 +++-- src/esm.ts | 6 +++++- src/index.ts | 5 +++-- src/node-module-type-classifier.ts | 4 ++-- src/test/module-node.spec.ts | 4 ++-- 6 files changed, 15 insertions(+), 27 deletions(-) delete mode 100644 dist-raw/node-cjs-loader-utils.d.ts diff --git a/dist-raw/node-cjs-loader-utils.d.ts b/dist-raw/node-cjs-loader-utils.d.ts deleted file mode 100644 index 8e1debf74..000000000 --- a/dist-raw/node-cjs-loader-utils.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function assertScriptCanLoadAsCJSImpl( - service: import('../src/index').Service, - module: NodeJS.Module, - filename: string -): void; - -export function readPackageScope(checkPath: string): PackageScope | false; - -export interface PackageScope { - path: string, - data: { - name: string, - main?: string, - exports?: object, - imports?: object, - type?: string - } -} diff --git a/dist-raw/node-internal-modules-cjs-loader.js b/dist-raw/node-internal-modules-cjs-loader.js index 149122e34..e2820c9fc 100644 --- a/dist-raw/node-internal-modules-cjs-loader.js +++ b/dist-raw/node-internal-modules-cjs-loader.js @@ -564,7 +564,7 @@ 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 @@ -585,5 +585,6 @@ function assertScriptCanLoadAsCJSImpl(service, module, filename) { module.exports = { createCjsLoader, - assertScriptCanLoadAsCJSImpl + assertScriptCanLoadAsCJSImpl, + readPackageScope }; diff --git a/src/esm.ts b/src/esm.ts index 98c6a6882..901b1343e 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -343,7 +343,11 @@ export function createEsmHooks(tsNodeService: Service) { try { nodeSays = await entrypointFallback(defer); } catch (e) { - if (e instanceof Error && tsNodeIgnored && extensions.extensionsNodeDoesNotUnderstand.includes(ext)) { + if ( + e instanceof Error && + tsNodeIgnored && + extensions.extensionsNodeDoesNotUnderstand.includes(ext) + ) { e.message += `\n\n` + `Hint:\n` + diff --git a/src/index.ts b/src/index.ts index f5804cfe3..d3cacb29f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -627,7 +627,8 @@ export function getExtensions( if (tsSupportsMtsCtsExts) compiledExtensions.push('.mts', '.cts'); else extensionsRequiringHigherTypescriptVersion.push('.mts', '.cts'); if (config.options.jsx) compiledExtensions.push('.tsx'); - if (config.options.jsx && config.options.allowJs) compiledExtensions.push('.jsx'); + if (config.options.jsx && config.options.allowJs) + compiledExtensions.push('.jsx'); if (options.preferTsExts && config.options.allowJs) { addJsExtensions(); } @@ -653,7 +654,7 @@ export function getExtensions( * Extensions that we can support if the user upgrades their typescript version * Used when raising hints. */ - extensionsRequiringHigherTypescriptVersion + extensionsRequiringHigherTypescriptVersion, }; } diff --git a/src/node-module-type-classifier.ts b/src/node-module-type-classifier.ts index 1d083d6d6..4f5e90051 100644 --- a/src/node-module-type-classifier.ts +++ b/src/node-module-type-classifier.ts @@ -1,11 +1,11 @@ -import { readPackageScope } from '../dist-raw/node-cjs-loader-utils'; +import { readPackageScope } from '../dist-raw/node-internal-modules-cjs-loader'; /** * TODO https://github.com/microsoft/TypeScript/issues/46452#issuecomment-1073145723 * * Determine how to emit a module based on tsconfig "module" and package.json "type" * - * Supports module=nodenext/node12 with transpileOnly, where we cannot ask the + * 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: diff --git a/src/test/module-node.spec.ts b/src/test/module-node.spec.ts index 8fdd2bdce..8ac3244fb 100644 --- a/src/test/module-node.spec.ts +++ b/src/test/module-node.spec.ts @@ -4,11 +4,11 @@ import { resetNodeEnvironment, } from './helpers'; import * as Path from 'path'; -import { contextTsNodeUnderTest } from './helpers'; +import { ctxTsNode } from './helpers'; import { exec } from './exec-helpers'; import { file, project, ProjectAPI as ProjectAPI } from './fs-helpers'; -const test = context(contextTsNodeUnderTest); +const test = context(ctxTsNode); test.beforeEach(async () => { resetNodeEnvironment(); }); From 842fe566bb3d9da8f91b914622d5a9d7750634b8 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 15 May 2022 17:39:57 -0400 Subject: [PATCH 10/37] flatten resolver tests by splitting into more functions --- src/test/resolver.spec.ts | 720 ++++++++++++++++++++------------------ 1 file changed, 375 insertions(+), 345 deletions(-) diff --git a/src/test/resolver.spec.ts b/src/test/resolver.spec.ts index 4dc5905cd..462e190b5 100755 --- a/src/test/resolver.spec.ts +++ b/src/test/resolver.spec.ts @@ -82,7 +82,22 @@ interface Project { experimentalSpecifierResolutionNode: boolean; skipIgnore: boolean; } +interface EntrypointPermutation { + entrypointExt: 'cjs' | 'mjs'; + withExt: boolean; + entrypointLocation: 'src' | 'out'; + entrypointTargetting: 'src' | 'out'; +} type Entrypoint = string; +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 { /** If true, is an index.* file within a directory */ isIndex: boolean; @@ -95,9 +110,34 @@ interface Target { inOut: 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( @@ -183,33 +223,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 +243,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 +266,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 +275,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 +283,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 +292,14 @@ 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, + generateTarget(project, p, { inSrc, inOut, + srcExt, + targetPackageStyle, + packageTypeModule, isIndex, - targetIdentifier, - isPackage: !!externalPackageFlavor, - packageFlavor: externalPackageFlavor, - typeModule: targetPackageTypeModule, - }; - targets.push(target); - const { isMjs: targetIsMjs } = fileInfo( - '.' + srcExt, - targetPackageTypeModule, - 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 (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 +309,171 @@ 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 = { + srcName, + outName, + srcExt, + outExt, + inSrc, + inOut, + isIndex, + targetIdentifier, + 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) { + 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: 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 */ @@ -457,177 +495,169 @@ 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; +} - 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`; - } - } +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 } = fileInfo( + entrypointFilename, + project.typeModule, + project.allowJs + ); + let entrypointContent = 'let mod;\n'; + if (entrypointIsMjs) { + entrypointContent += `import assert from 'assert';\n`; + } else { + entrypointContent += `const assert = require('assert');\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`; } - function writeAssertions(specifier: string) {} - p.dir(entrypointLocation).addFile( - entrypointFilename, - entrypointContent - ); + } 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.packageStyle, [ + '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`; } } } - return entrypoints; + p.dir(entrypointLocation).addFile(entrypointFilename, entrypointContent); + return entrypointLocation + '/' + entrypointFilename; } /** From d09c552deb5fa1be8ed731c31ec61b9a01d79524 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 15 May 2022 20:55:37 -0400 Subject: [PATCH 11/37] address extension-handling code --- dist-raw/node-internal-modules-cjs-loader.js | 60 ++++---- dist-raw/node-internal-modules-esm-resolve.js | 44 +++--- src/esm.ts | 4 +- src/file-extensions.ts | 136 ++++++++++++++++++ src/index.ts | 96 ++----------- 5 files changed, 192 insertions(+), 148 deletions(-) create mode 100644 src/file-extensions.ts diff --git a/dist-raw/node-internal-modules-cjs-loader.js b/dist-raw/node-internal-modules-cjs-loader.js index e2820c9fc..e0fc89b63 100644 --- a/dist-raw/node-internal-modules-cjs-loader.js +++ b/dist-raw/node-internal-modules-cjs-loader.js @@ -6,6 +6,7 @@ const { ArrayPrototypeJoin, + ArrayPrototypePush, JSONParse, ObjectKeys, RegExpPrototypeTest, @@ -133,12 +134,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,43 +211,37 @@ 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); + 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) { diff --git a/dist-raw/node-internal-modules-esm-resolve.js b/dist-raw/node-internal-modules-esm-resolve.js index abd1ecc7a..24df6164c 100644 --- a/dist-raw/node-internal-modules-esm-resolve.js +++ b/dist-raw/node-internal-modules-esm-resolve.js @@ -84,16 +84,24 @@ const CJSModule = Module; // const packageJsonReader = require('internal/modules/package_json_reader'); const packageJsonReader = require('./node-internal-modules-package_json_reader'); -const { assign } = require('lodash'); const userConditions = getOptionValue('--conditions'); const DEFAULT_CONDITIONS = ObjectFreeze(['node', 'import', ...userConditions]); 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'); @@ -255,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; @@ -269,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; } @@ -287,32 +295,16 @@ function resolveExtensionsWithTryExactName(search) { return resolveExtensions(search); } -// [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] -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! - * [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] - */ -const replacementExtensionsForJs = extensions.filter(ext => ['.js', '.jsx', '.ts', '.tsx'].includes(ext)); -const replacementExtensionsForMjs = extensions.filter(ext => ['.mjs', '.mts'].includes(ext)); -const replacementExtensionsForCjs = extensions.filter(ext => ['.cjs', '.cts'].includes(ext)); - /** This replaces JS with TS extensions */ function resolveReplacementExtensions(search) { const lastDotIndex = search.pathname.lastIndexOf('.'); @@ -321,9 +313,9 @@ function resolveReplacementExtensions(search) { if (ext === '.js' || ext === '.cjs' || ext === '.mjs') { const pathnameWithoutExtension = search.pathname.slice(0, lastDotIndex); const replacementExts = - ext === '.js' ? replacementExtensionsForJs - : ext === '.mjs' ? replacementExtensionsForMjs - : replacementExtensionsForCjs; + 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]; diff --git a/src/esm.ts b/src/esm.ts index 901b1343e..3dab2b125 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -334,7 +334,7 @@ export function createEsmHooks(tsNodeService: Service) { // E.g. .mts compiles to .mjs, so ask node how to classify an .mjs file. const ext = extname(nativePath); const tsNodeIgnored = tsNodeService.ignored(nativePath); - const nodeEquivalentExt = extensions.nodeEquivalentExtensions.get(ext); + const nodeEquivalentExt = extensions.nodeEquivalents.get(ext); if (nodeEquivalentExt && !tsNodeIgnored) { nodeSays = await entrypointFallback(() => defer(formatUrl(pathToFileURL(nativePath + nodeEquivalentExt))) @@ -346,7 +346,7 @@ export function createEsmHooks(tsNodeService: Service) { if ( e instanceof Error && tsNodeIgnored && - extensions.extensionsNodeDoesNotUnderstand.includes(ext) + extensions.nodeDoesNotUnderstand.includes(ext) ) { e.message += `\n\n` + diff --git a/src/file-extensions.ts b/src/file-extensions.ts new file mode 100644 index 000000000..70c73195e --- /dev/null +++ b/src/file-extensions.ts @@ -0,0 +1,136 @@ +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 compiledUnsorted: string[] = ['.ts']; + const compiledJsxUnsorted: string[] = []; + + if (config.options.jsx) compiledJsxUnsorted.push('.tsx'); + if (tsSupportsMtsCtsExts) compiledUnsorted.push('.mts', '.cts'); + if(config.options.allowJs) { + compiledUnsorted.push('.js'); + if (config.options.jsx) compiledJsxUnsorted.push('.jsx'); + if (tsSupportsMtsCtsExts) compiledUnsorted.push('.mjs', '.cjs'); + } + + const allUnsorted = [...compiledUnsorted, ...compiledJsxUnsorted]; + const compiled = allPossibleExtensionsSortedByPreference.filter(ext => allUnsorted.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 d3cacb29f..fdd0d305c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ 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'; export { TSCommon }; export { @@ -421,6 +422,8 @@ export interface RegisterOptions extends CreateOptions { experimentalSpecifierResolution?: 'node' | 'explicit'; } +export type ExperimentalSpecifierResolution = 'node' | 'explicit'; + /** * Must be an interface to support `typescript-json-schema`. */ @@ -575,89 +578,6 @@ 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; - -/** - * [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] - * @internal - */ -export function getExtensions( - config: _ts.ParsedCommandLine, - options: RegisterOptions, - tsVersion: string -) { - const nodeEquivalentExtensions = new Map([ - ['.ts', '.js'], - ['.tsx', '.js'], - ['.jsx', '.js'], - ['.mts', '.mjs'], - ['.cts', '.cjs'], - ]); - - // TS 4.5 is first version to understand .cts, .mts, .cjs, and .mjs extensions - const tsSupportsMtsCtsExts = versionGteLt(tsVersion, '4.5.0'); - const compiledExtensions: string[] = []; - const extensionsNodeDoesNotUnderstand = [ - '.ts', - '.tsx', - '.jsx', - '.cts', - '.mts', - ]; - const extensionsRequiringHigherTypescriptVersion: string[] = []; - - function addJsExtensions() { - compiledExtensions.push('.js'); - if (tsSupportsMtsCtsExts) compiledExtensions.push('.mjs', '.cjs'); - else extensionsRequiringHigherTypescriptVersion.push('.mjs', '.cjs'); - } - - // .js, .cjs, .mjs take precedence if preferTsExts is off - if (!options.preferTsExts && config.options.allowJs) { - addJsExtensions(); - } - compiledExtensions.push('.ts'); - if (tsSupportsMtsCtsExts) compiledExtensions.push('.mts', '.cts'); - else extensionsRequiringHigherTypescriptVersion.push('.mts', '.cts'); - if (config.options.jsx) compiledExtensions.push('.tsx'); - if (config.options.jsx && config.options.allowJs) - compiledExtensions.push('.jsx'); - if (options.preferTsExts && config.options.allowJs) { - addJsExtensions(); - } - - 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, - /** - * Mapping from extensions understood by tsc to the equivalent for node, - * as far as getFormat is concerned. - */ - nodeEquivalentExtensions, - /** - * Extensions that we can support if the user upgrades their typescript version - * Used when raising hints. - */ - extensionsRequiringHigherTypescriptVersion, - }; -} - /** * Create a new TypeScript compiler instance and register it onto node.js * @@ -1465,11 +1385,11 @@ export function createFromPreloadedConfig( config.options.module === ts.ModuleKind.ESNext ); /** - * node12 or nodenext + * node16 or nodenext * [MUST_UPDATE_FOR_NEW_MODULEKIND] */ const isNodeModuleType = - (ts.ModuleKind.Node12 && config.options.module === ts.ModuleKind.Node12) || + (ts.ModuleKind.Node16 && config.options.module === ts.ModuleKind.Node16) || (ts.ModuleKind.NodeNext && config.options.module === ts.ModuleKind.NodeNext); const getOutputForceCommonJS = createTranspileOnlyGetOutputFunction( @@ -1548,7 +1468,7 @@ export function createFromPreloadedConfig( 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; @@ -1567,7 +1487,7 @@ export function createFromPreloadedConfig( ( require('../dist-raw/node-internal-modules-esm-resolve') as typeof _nodeInternalModulesEsmResolve ).createResolve({ - ...extensions, + extensions, preferTsExts: options.preferTsExts, tsNodeExperimentalSpecifierResolution: options.experimentalSpecifierResolution, @@ -1585,7 +1505,7 @@ export function createFromPreloadedConfig( ( require('../dist-raw/node-internal-modules-cjs-loader') as typeof _nodeInternalModulesCjsLoader ).createCjsLoader({ - ...extensions, + extensions, preferTsExts: options.preferTsExts, nodeEsmResolver: getNodeEsmResolver(), }) From a09275fe3c91b5d54ece6be5b96ebc5073849895 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 15 May 2022 20:57:21 -0400 Subject: [PATCH 12/37] lint-fix --- dist-raw/node-internal-modules-cjs-loader.js | 9 +-- src/file-extensions.ts | 65 ++++++++++++-------- 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/dist-raw/node-internal-modules-cjs-loader.js b/dist-raw/node-internal-modules-cjs-loader.js index e0fc89b63..03eb36051 100644 --- a/dist-raw/node-internal-modules-cjs-loader.js +++ b/dist-raw/node-internal-modules-cjs-loader.js @@ -242,10 +242,11 @@ function tryReplacementExtensions(p, isMain) { 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; + for (let i = 0; i < replacementExts.length; i++) { + const filename = tryFile(pathnameWithoutExtension + replacementExts[i], isMain); + if (filename) { + return filename; + } } } } diff --git a/src/file-extensions.ts b/src/file-extensions.ts index 70c73195e..38edfa343 100644 --- a/src/file-extensions.ts +++ b/src/file-extensions.ts @@ -19,7 +19,13 @@ const nodeEquivalents = new Map([ ]); // All extensions understood by vanilla node -const vanillaNodeExtensions: readonly string[] = ['.js', '.json', '.node', '.mjs', '.cjs']; +const vanillaNodeExtensions: readonly string[] = [ + '.js', + '.json', + '.node', + '.mjs', + '.cjs', +]; // Extensions added by vanilla node's require() if you omit them: // js, json, node @@ -45,37 +51,40 @@ export function getExtensions( 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'); + if (!tsSupportsMtsCtsExts) + requiresHigherTypescriptVersion.push('.cts', '.cjs', '.mts', '.mjs'); - const allPossibleExtensionsSortedByPreference = Array.from(new Set([ - ...(options.preferTsExts ? nodeDoesNotUnderstand : []), - ...vanillaNodeExtensions, - ...nodeDoesNotUnderstand - ])); + const allPossibleExtensionsSortedByPreference = Array.from( + new Set([ + ...(options.preferTsExts ? nodeDoesNotUnderstand : []), + ...vanillaNodeExtensions, + ...nodeDoesNotUnderstand, + ]) + ); const compiledUnsorted: string[] = ['.ts']; const compiledJsxUnsorted: string[] = []; if (config.options.jsx) compiledJsxUnsorted.push('.tsx'); if (tsSupportsMtsCtsExts) compiledUnsorted.push('.mts', '.cts'); - if(config.options.allowJs) { + if (config.options.allowJs) { compiledUnsorted.push('.js'); if (config.options.jsx) compiledJsxUnsorted.push('.jsx'); if (tsSupportsMtsCtsExts) compiledUnsorted.push('.mjs', '.cjs'); } const allUnsorted = [...compiledUnsorted, ...compiledJsxUnsorted]; - const compiled = allPossibleExtensionsSortedByPreference.filter(ext => allUnsorted.includes(ext)); + const compiled = allPossibleExtensionsSortedByPreference.filter((ext) => + allUnsorted.includes(ext) + ); - const compiledNodeDoesNotUnderstand = - nodeDoesNotUnderstand.filter((ext) => - compiled.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. @@ -84,25 +93,27 @@ export function getExtensions( * 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)); + 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', - ])); + 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', - ])); + new Set([...replacementsForJs, '.json', '.node']) + ); return { /** All file extensions we transform, ordered by resolution preference according to preferTsExts */ From 7f5f1833c104ef93809d05d0b1cda925845edbbb Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 15 May 2022 23:48:49 -0400 Subject: [PATCH 13/37] Fix; update resolver tests to test more things --- .vscode/launch.json | 20 ++-- src/esm.ts | 8 +- src/file-extensions.ts | 12 +- src/index.ts | 3 +- src/test/resolver.spec.ts | 243 ++++++++++++++++++++++++-------------- 5 files changed, 177 insertions(+), 109 deletions(-) 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/src/esm.ts b/src/esm.ts index 3dab2b125..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, diff --git a/src/file-extensions.ts b/src/file-extensions.ts index 38edfa343..4f73413fb 100644 --- a/src/file-extensions.ts +++ b/src/file-extensions.ts @@ -66,20 +66,20 @@ export function getExtensions( ]) ); - const compiledUnsorted: string[] = ['.ts']; + const compiledJsUnsorted: string[] = ['.ts']; const compiledJsxUnsorted: string[] = []; if (config.options.jsx) compiledJsxUnsorted.push('.tsx'); - if (tsSupportsMtsCtsExts) compiledUnsorted.push('.mts', '.cts'); + if (tsSupportsMtsCtsExts) compiledJsUnsorted.push('.mts', '.cts'); if (config.options.allowJs) { - compiledUnsorted.push('.js'); + compiledJsUnsorted.push('.js'); if (config.options.jsx) compiledJsxUnsorted.push('.jsx'); - if (tsSupportsMtsCtsExts) compiledUnsorted.push('.mjs', '.cjs'); + if (tsSupportsMtsCtsExts) compiledJsUnsorted.push('.mjs', '.cjs'); } - const allUnsorted = [...compiledUnsorted, ...compiledJsxUnsorted]; + const compiledUnsorted = [...compiledJsUnsorted, ...compiledJsxUnsorted]; const compiled = allPossibleExtensionsSortedByPreference.filter((ext) => - allUnsorted.includes(ext) + compiledUnsorted.includes(ext) ); const compiledNodeDoesNotUnderstand = nodeDoesNotUnderstand.filter((ext) => diff --git a/src/index.ts b/src/index.ts index fdd0d305c..cde67039e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -601,7 +601,6 @@ export function register( } const originalJsHandler = require.extensions['.js']; - const { compiledExtensions } = service.extensions; // Expose registered instance globally. process[REGISTER_INSTANCE] = service; @@ -609,7 +608,7 @@ export function register( // Register the extensions. registerExtensions( service.options.preferTsExts, - compiledExtensions, + service.extensions.compiled, service, originalJsHandler ); diff --git a/src/test/resolver.spec.ts b/src/test/resolver.spec.ts index 462e190b5..24b465449 100755 --- a/src/test/resolver.spec.ts +++ b/src/test/resolver.spec.ts @@ -1,4 +1,4 @@ -import { context, ExecutionContext, TestInterface } from './testlib'; +import { context, ExecutionContext, expect, TestInterface } from './testlib'; import { ctxTsNode, resetNodeEnvironment, ts } from './helpers'; import { project as fsProject, Project as FsProject } from './fs-helpers'; import { join } from 'path'; @@ -6,6 +6,9 @@ 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 @@ -79,6 +82,8 @@ interface Project { allowJs: boolean; preferSrc: boolean; typeModule: boolean; + /** Use TS's new module: `nodenext` option */ + useTsNodeNext: boolean; experimentalSpecifierResolutionNode: boolean; skipIgnore: boolean; } @@ -99,8 +104,6 @@ interface GenerateTargetOptions { packageTypeModule: boolean; } interface Target { - /** If true, is an index.* file within a directory */ - isIndex: boolean; targetIdentifier: string; outName: string; srcName: string; @@ -108,6 +111,10 @@ 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; packageStyle: TargetPackageStyle; @@ -151,26 +158,31 @@ test.suite('Resolver hooks', (test) => { for (const allowJs of [false, true]) { for (const preferSrc of [false, true]) { for (const typeModule of [false, true]) { - for (const experimentalSpecifierResolutionNode 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 useTsNodeNext of [false, true]) { + for (const experimentalSpecifierResolutionNode 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' : '' + }${useTsNodeNext ? '-useTsNodenext' : ''}${ + skipIgnore ? '-skipIgnore' : '' + }${ + experimentalSpecifierResolutionNode + ? '-experimentalSpecifierResolutionNode' + : '' + }`, + allowJs, + preferSrc, + typeModule, + useTsNodeNext, + experimentalSpecifierResolutionNode, + skipIgnore, + }; + declareProject(test, project); + } } } } @@ -179,14 +191,6 @@ test.suite('Resolver hooks', (test) => { }); function declareProject(test: Test, project: Project) { - const { - allowJs, - experimentalSpecifierResolutionNode, - preferSrc, - typeModule, - skipIgnore, - } = project; - test(`${project.identifier}`, async (t) => { t.teardown(() => { resetNodeEnvironment(); @@ -201,18 +205,21 @@ 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', }, }); @@ -292,14 +299,16 @@ function generateTargets(project: Project, p: FsProject) { continue; //#endregion - generateTarget(project, p, { - inSrc, - inOut, - srcExt, - targetPackageStyle, - packageTypeModule, - isIndex, - }); + targets.push( + generateTarget(project, p, { + inSrc, + inOut, + srcExt, + targetPackageStyle, + packageTypeModule, + isIndex, + }) + ); } } } @@ -348,14 +357,15 @@ function generateTarget( 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, - targetIdentifier, isPackage: !!targetPackageStyle, packageStyle: targetPackageStyle, typeModule: packageTypeModule, @@ -400,9 +410,9 @@ function generateTarget( if (targetPackageStyle) { p.addFile( selfImporterCjsName, - ` - module.exports = require("${targetIdentifier}"); - ` + targetIsMjs + ? `module.exports = import("${targetIdentifier}");` + : `module.exports = require("${targetIdentifier}");` ); p.addFile( selfImporterMjsName, @@ -485,6 +495,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 @@ -535,7 +546,9 @@ function generateEntrypoint( entrypointContent += `import assert from 'assert';\n`; } else { entrypointContent += `const assert = require('assert');\n`; + entrypointContent += `const dynamicImport = new Function("specifier", "return import(specifier)");\n`; } + entrypointContent += `async function main() {\n`; for (const target of targets) { // TODO re-enable these when we have outDir <-> rootDir mapping @@ -591,42 +604,70 @@ function generateEntrypoint( 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; + //#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; + } } - - // 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`; + 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 (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; + 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 ( - 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; + 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 @@ -647,15 +688,23 @@ function generateEntrypoint( 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`; + entrypointExt === 'cjs' && (externalPackageSelfImport || !targetIsMjs) + ? ` mod = await 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`; } } } + entrypointContent += `}\n`; + entrypointContent += `const result = main();\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; } @@ -674,9 +723,31 @@ async function execute(t: T, p: FsProject, entrypoints: Entrypoint[]) { process.__test_setloader__(t.context.tsNodeUnderTest.createEsmHooks(service)); for (const entrypoint of entrypoints) { + console.dir(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'); + await result; } 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, @@ -684,7 +755,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') ); } From a83b0264190538b7c6cacdae1069636c7b330037 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 16 May 2022 14:27:28 -0400 Subject: [PATCH 14/37] fix --- dist-raw/node-internal-modules-cjs-loader.js | 4 ++ src/test/helpers.ts | 7 +-- src/test/resolver.spec.ts | 55 ++++++++++++++------ 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/dist-raw/node-internal-modules-cjs-loader.js b/dist-raw/node-internal-modules-cjs-loader.js index 03eb36051..1d78bc2be 100644 --- a/dist-raw/node-internal-modules-cjs-loader.js +++ b/dist-raw/node-internal-modules-cjs-loader.js @@ -5,6 +5,8 @@ 'use strict'; const { + ArrayIsArray, + ArrayPrototypeIncludes, ArrayPrototypeJoin, ArrayPrototypePush, JSONParse, @@ -47,6 +49,8 @@ const { const Module = require('module'); +const isWindows = process.platform === 'win32'; + let statCache = null; function stat(filename) { diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 23b05d8e6..3c2b72677 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -245,7 +245,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 +271,7 @@ function resetObject( object: any, state: ReturnType, doNotDeleteTheseKeys: string[] = [], - doNotSetTheseKeys: string[] = [], + doNotSetTheseKeys: true | string[] = [], avoidSetterIfUnchanged: string[] = [], reorderProperties = false ) { @@ -284,7 +284,8 @@ 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; diff --git a/src/test/resolver.spec.ts b/src/test/resolver.spec.ts index 24b465449..dc697d990 100755 --- a/src/test/resolver.spec.ts +++ b/src/test/resolver.spec.ts @@ -68,6 +68,10 @@ import * as Path from 'path'; // 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; @@ -162,18 +166,18 @@ test.suite('Resolver hooks', (test) => { for (const experimentalSpecifierResolutionNode 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]) { + 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: `resolver-${projectSeq()}-${ - preferSrc ? 'preferSrc' : 'preferOut' - }-${typeModule ? 'typeModule' : 'typeCommonjs'}${ - allowJs ? '-allowJs' : '' - }${useTsNodeNext ? '-useTsNodenext' : ''}${ - skipIgnore ? '-skipIgnore' : '' - }${ - experimentalSpecifierResolutionNode - ? '-experimentalSpecifierResolutionNode' - : '' - }`, + identifier, allowJs, preferSrc, typeModule, @@ -408,10 +412,15 @@ function generateTarget( p.addFile(srcName, targetContent('src')); } if (targetPackageStyle) { + const selfImporterIsCompiled = project.allowJs; + const cjsSelfImporterMustUseDynamicImportHack = + !project.useTsNodeNext && selfImporterIsCompiled && targetIsMjs; p.addFile( selfImporterCjsName, targetIsMjs - ? `module.exports = import("${targetIdentifier}");` + ? cjsSelfImporterMustUseDynamicImportHack + ? `${declareDynamicImportFunction}\nmodule.exports = dynamicImport('${targetIdentifier}');` + : `module.exports = import("${targetIdentifier}");` : `module.exports = require("${targetIdentifier}");` ); p.addFile( @@ -536,17 +545,18 @@ function generateEntrypoint( const entrypointFilename = `entrypoint-${entrypointSeq()}-${entrypointLocation}-to-${entrypointTargetting}${ withExt ? '-withext' : '' }.${entrypointExt}`; - const { isMjs: entrypointIsMjs } = fileInfo( + 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 += `const dynamicImport = new Function("specifier", "return import(specifier)");\n`; + entrypointContent += `${declareDynamicImportFunction}\n`; } entrypointContent += `async function main() {\n`; @@ -687,18 +697,28 @@ function generateEntrypoint( 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`; } } } entrypointContent += `}\n`; - entrypointContent += `const result = main();\n`; + entrypointContent += `const result = main().then(() => {return testsRun});\n`; entrypointContent += `result.mark = 'marked';\n`; if (entrypointIsMjs) { entrypointContent += `export {result};\n`; @@ -723,14 +743,15 @@ async function execute(t: T, p: FsProject, entrypoints: Entrypoint[]) { process.__test_setloader__(t.context.tsNodeUnderTest.createEsmHooks(service)); for (const entrypoint of entrypoints) { - console.dir(join(p.cwd, entrypoint)); + t.log(`Importing ${join(p.cwd, entrypoint)}`); try { const { result } = await dynamicImport( pathToFileURL(join(p.cwd, entrypoint)) ); expect(result).toBeInstanceOf(Promise); expect(result.mark).toBe('marked'); - await result; + const testsRun = await result; + t.log(`Entrypoint ran ${testsRun} tests.`); } catch (e) { try { const launchJsonPath = Path.resolve( From 54eccee1524eedc937dc6914d59351f6b3ba6a9c Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 16 May 2022 17:39:36 -0400 Subject: [PATCH 15/37] fix --- src/test/helpers.ts | 5 ++++ src/test/module-node.spec.ts | 56 +++++++++++++++++++++++++----------- src/test/resolver.spec.ts | 7 +---- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 3c2b72677..74054f325 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -311,3 +311,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/module-node.spec.ts b/src/test/module-node.spec.ts index 8ac3244fb..3721c85ea 100644 --- a/src/test/module-node.spec.ts +++ b/src/test/module-node.spec.ts @@ -1,6 +1,7 @@ import { expect, context } from './testlib'; import { CMD_TS_NODE_WITHOUT_PROJECT_FLAG, + isOneOf, resetNodeEnvironment, } from './helpers'; import * as Path from 'path'; @@ -14,16 +15,33 @@ test.beforeEach(async () => { }); // Declare one test case for each permutations of project configuration -for (const allowJs of [true, false]) { - for (const typecheckMode of ['typecheck', 'transpileOnly', 'swc'] as const) { - for (const packageJsonType of [undefined, 'commonjs', 'module'] as const) { - declareTest({ allowJs, packageJsonType, typecheckMode }); +test.suite('TypeScript module=NodeNext and Node16', (test) => { + 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({ + allowJs, + packageJsonType, + typecheckMode, + tsModuleMode, + }); + } + } } } -} +}); function declareTest(testParams: TestParams) { - const name = `package-json-type=${testParams.packageJsonType} allowJs=${testParams.allowJs} ${testParams.typecheckMode}`; + 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); @@ -66,25 +84,21 @@ const extensions: Extension[] = [ ext: 'cts', jsEquivalentExt: 'cjs', forcesCjs: true, - supportsJsx: true, }, { ext: 'cjs', forcesCjs: true, isJs: true, - supportsJsx: true, }, { ext: 'mts', jsEquivalentExt: 'mjs', forcesEsm: true, - supportsJsx: true, }, { ext: 'mjs', forcesEsm: true, isJs: true, - supportsJsx: true, }, { ext: 'ts', @@ -139,6 +153,7 @@ interface TestParams { packageJsonType: PackageJsonType; typecheckMode: typeof typecheckModes[number]; allowJs: boolean; + tsModuleMode: 'NodeNext' | 'Node16'; } interface ImporterParams { @@ -151,7 +166,7 @@ interface ImporteeParams { } function writeFixturesToFilesystem(name: string, testParams: TestParams) { - const { packageJsonType, allowJs, typecheckMode } = testParams; + const { packageJsonType, allowJs, typecheckMode, tsModuleMode } = testParams; const proj = project(name.replace(/ /g, '_')); @@ -163,12 +178,13 @@ function writeFixturesToFilesystem(name: string, testParams: TestParams) { compilerOptions: { allowJs, target: 'esnext', - module: 'NodeNext', + module: tsModuleMode, jsx: 'react', }, 'ts-node': { transpileOnly: typecheckMode === 'transpileOnly' || undefined, swc: typecheckMode === 'swc', + experimentalResolver: true, }, }); @@ -246,11 +262,18 @@ function createImporter( // 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 || - importeeTreatment.isExecutedAsEsm + isOneOf(importStyle, ['dynamic import', 'static import']) ) importSpecifier += '.' + (importeeExtension.jsEquivalentExt ?? importeeExtension.ext); @@ -270,7 +293,8 @@ function createImporter( break; } - importer.assertions += `if(${importeeExtension.ext}.ext !== '${importeeExtension.ext}') throw new Error('Wrong export from importee: expected ${importeeExtension.ext} but got ' + ${importeeExtension.ext}.ext);\n`; + importer.assertions += `if(${importeeExtension.ext}.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; } @@ -300,11 +324,11 @@ function createImportee( importee.content += ` const React = { createElement(tag, dunno, content) { - return content + return {props: {children: [content]}}; } }; const jsxTest = Hello World; - if(jsxTest !== 'Hello World') throw new Error('Expected ${importeeExtension.ext} to support JSX but it did not.'); + if(jsxTest?.props?.children[0] !== 'Hello World') throw new Error('Expected ${importeeExtension.ext} to support JSX but it did not.'); `; } return { importee, treatment }; diff --git a/src/test/resolver.spec.ts b/src/test/resolver.spec.ts index dc697d990..f638ff9b7 100755 --- a/src/test/resolver.spec.ts +++ b/src/test/resolver.spec.ts @@ -1,5 +1,5 @@ import { context, ExecutionContext, expect, TestInterface } from './testlib'; -import { ctxTsNode, resetNodeEnvironment, ts } from './helpers'; +import { ctxTsNode, isOneOf, resetNodeEnvironment, ts } from './helpers'; import { project as fsProject, Project as FsProject } from './fs-helpers'; import { join } from 'path'; import * as semver from 'semver'; @@ -803,8 +803,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); -} From b7875c1a4029840995a2236c68928f4c6bed1825 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 16 May 2022 22:59:30 -0400 Subject: [PATCH 16/37] test against TS rc on nodes 14, 16, 18 (good sanity-checking with these new nodenext features) --- .github/workflows/continuous-integration.yml | 37 ++++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) 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 From d0ef354912cedb2ac9cbb4c0bed6a5ced8398274 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 16 May 2022 23:02:00 -0400 Subject: [PATCH 17/37] Teach ts.transpileModule to handle NodeNext correctly --- src/index.ts | 11 ++++ src/ts-transpile-module.ts | 111 +++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 src/ts-transpile-module.ts diff --git a/src/index.ts b/src/index.ts index cde67039e..98296e30d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ import type * as _nodeInternalModulesEsmResolve from '../dist-raw/node-internal- 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 { @@ -1334,6 +1335,12 @@ export function createFromPreloadedConfig( 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 fileName = _fileName; let result: _ts.TranspileOutput; @@ -1341,6 +1348,10 @@ export function createFromPreloadedConfig( result = customTranspiler.transpile(code, { fileName, }); + } else if(tsTranspileModule) { + result = tsTranspileModule(code, { + fileName + }, nodeModuleEmitKind === 'nodeesm' ? 'module' : 'commonjs'); } else { // [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] // The only way to tell `ts.transpileModule` to emit node-flavored ESM is to set file extension to `.mts` or `.mjs` diff --git a/src/ts-transpile-module.ts b/src/ts-transpile-module.ts new file mode 100644 index 000000000..28e2bbebc --- /dev/null +++ b/src/ts-transpile-module.ts @@ -0,0 +1,111 @@ +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) { + 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: () => false, + 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, 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 }; + } +} From 26001ed9b0e4ca1192700d335face2a127d4ea3a Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 16 May 2022 23:02:57 -0400 Subject: [PATCH 18/37] tweak tests --- src/index.ts | 37 ++++---- src/test/module-node.spec.ts | 28 +++++- src/ts-transpile-module.ts | 162 ++++++++++++++++++++++++----------- 3 files changed, 153 insertions(+), 74 deletions(-) diff --git a/src/index.ts b/src/index.ts index 98296e30d..7ff88c48a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1335,12 +1335,13 @@ export function createFromPreloadedConfig( compilerOptions, nodeModuleEmitKind ); - let tsTranspileModule = versionGteLt(ts.version, '4.7.0') ? - createTsTranspileModule(ts, { - compilerOptions, - reportDiagnostics: true, - transformers: transformers as _ts.CustomTransformers | undefined - }) : undefined; + 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 fileName = _fileName; let result: _ts.TranspileOutput; @@ -1348,23 +1349,15 @@ export function createFromPreloadedConfig( result = customTranspiler.transpile(code, { fileName, }); - } else if(tsTranspileModule) { - result = tsTranspileModule(code, { - fileName - }, nodeModuleEmitKind === 'nodeesm' ? 'module' : 'commonjs'); + } else if (tsTranspileModule) { + result = tsTranspileModule( + code, + { + fileName, + }, + nodeModuleEmitKind === 'nodeesm' ? 'module' : 'commonjs' + ); } else { - // [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] - // The only way to tell `ts.transpileModule` to emit node-flavored ESM is to set file extension to `.mts` or `.mjs` - if (nodeModuleEmitKind === 'nodeesm') { - const lastDotIndex = _fileName.lastIndexOf('.'); - const ext = _fileName.slice(lastDotIndex); - const fileNameSansExtension = - lastDotIndex >= 0 ? _fileName.slice(0, lastDotIndex) : _fileName; - if (ext === '.ts' || ext === '.tsx') - fileName = fileNameSansExtension + '.mts'; - if (ext === '.js' || ext === '.jsx') - fileName = fileNameSansExtension + '.mjs'; - } result = ts.transpileModule(code, { fileName, compilerOptions, diff --git a/src/test/module-node.spec.ts b/src/test/module-node.spec.ts index 3721c85ea..cbfe1aade 100644 --- a/src/test/module-node.spec.ts +++ b/src/test/module-node.spec.ts @@ -13,6 +13,7 @@ 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) => { @@ -28,7 +29,7 @@ test.suite('TypeScript module=NodeNext and Node16', (test) => { 'module', ] as const) { for (const tsModuleMode of ['NodeNext', 'Node16'] as const) { - declareTest({ + declareTest(test, { allowJs, packageJsonType, typecheckMode, @@ -40,12 +41,16 @@ test.suite('TypeScript module=NodeNext and Node16', (test) => { } }); -function declareTest(testParams: TestParams) { +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( @@ -227,8 +232,17 @@ function createImporter( ); if (!importerTreatment.isAllowed) return; - // import = require only allowed in TS files + // 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}`, @@ -246,6 +260,8 @@ function createImporter( }; proj.add(importer); + if (!importerExtension.isJs) importer.imports += `export {};\n`; + for (const importeeExtension of extensions) { const ci = createImportee(testParams, { importeeExtension }); if (!ci) continue; @@ -293,7 +309,11 @@ function createImporter( break; } - importer.assertions += `if(${importeeExtension.ext}.ext !== '${importeeExtension.ext}')\n`; + // 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; diff --git a/src/ts-transpile-module.ts b/src/ts-transpile-module.ts index 28e2bbebc..518bb908f 100644 --- a/src/ts-transpile-module.ts +++ b/src/ts-transpile-module.ts @@ -1,28 +1,60 @@ -import type { CompilerHost, CompilerOptions, Diagnostic, SourceFile, TranspileOptions, TranspileOutput } from 'typescript'; +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) { +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 + 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) : {}; + 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]; - } + if (hasProperty(defaultOptions, key) && options[key] === undefined) { + options[key] = defaultOptions[key]; + } } for (const option of transpileOptionValueCompilerOptions) { - options[option.name] = option.transpileOptionValue; + 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. @@ -34,26 +66,38 @@ export function createTsTranspileModule(ts: TSCommon, transpileOptions: Pick 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: () => false, - getCanonicalFileName: fileName => fileName, - getCurrentDirectory: () => "", - getNewLine: () => newLine, - fileExists: (fileName): boolean => fileName === inputFileName || fileName === packageJsonFileName, - readFile: (fileName) => fileName === packageJsonFileName ? `{"type": "${_packageJsonType}"}` : "", - directoryExists: () => true, - getDirectories: () => [] + 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: () => false, + 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; @@ -65,28 +109,41 @@ export function createTsTranspileModule(ts: TSCommon, transpileOptions: Pick, packageJsonType: "module" | "commonjs" = "commonjs"): TranspileOutput { - + 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"); + 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) - } - ); + 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; + sourceFile.moduleName = transpileOptions2.moduleName; } if (transpileOptions2.renamedDependencies) { - (sourceFile as any).renamedDependencies = new Map(getEntries(transpileOptions2.renamedDependencies)); + (sourceFile as any).renamedDependencies = new Map( + getEntries(transpileOptions2.renamedDependencies) + ); } // Output @@ -98,13 +155,22 @@ export function createTsTranspileModule(ts: TSCommon, transpileOptions: Pick Date: Mon, 16 May 2022 23:38:31 -0400 Subject: [PATCH 19/37] fix test typos; update pluggable dep tests; update ignore() tests for new file extensions --- src/test/index.spec.ts | 58 +++++++++---------- src/test/pluggable-dep-resolution.spec.ts | 21 +++++-- .../node_modules/custom-compiler/index.js | 3 + .../node_modules/custom-compiler/index.js | 3 + 4 files changed, 48 insertions(+), 37 deletions(-) diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index c36a913ef..b74a9b05b 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'; @@ -30,6 +30,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, @@ -209,7 +210,7 @@ test.suite('ts-node', (test) => { const { err, stdout } = await exec( [ CMD_TS_NODE_WITH_PROJECT_FLAG, - '-O "{\\"module\\":"ESNext"}"', + '-O "{\\"module\\":\\"ESNext\\"}"', '-pe "import { main } from \'./ts45-ext/ext-mts/index\';main()"', ].join(' ') ); @@ -1006,62 +1007,55 @@ test.suite('ts-node', (test) => { test.suite('issue #1098', (test) => { function testIgnored( - ignored: tsNodeTypes.Service['ignored'], + t: ExecutionContext, + compilerOptions: CreateOptions['compilerOptions'], allowed: string[], disallowed: string[] ) { + 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); } } 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'], + t, + {}, + ['.ts', '.d.ts', '.mts', '.cts'], ['.js', '.tsx', '.jsx', '.mjs', '.cjs', '.xyz', ''] ); }); 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', ''] + t, + { allowJs: true }, + ['.ts', '.js', '.d.ts', '.mts', '.cts', '.mjs', '.cjs'], + ['.tsx', '.jsx', '.xyz', ''] ); }); 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'], + t, + { allowJs: false, jsx: 'preserve' }, + ['.ts', '.tsx', '.d.ts', '.mts', '.cts'], ['.js', '.jsx', '.mjs', '.cjs', '.xyz', ''] ); }); 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', ''] + t, + { allowJs: true, jsx: 'preserve' }, + ['.ts', '.tsx', '.js', '.jsx', '.d.ts', '.mts', '.cts', '.mjs', '.cjs'], + ['.xyz', ''] ); }); }); @@ -1143,7 +1137,7 @@ 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')) { + if (ts.version.startsWith('4.7.') || semver.gte(ts.version, '4.7.0')) { check(100, 'Node16', false); } else { check(100, 'Node12', false); 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/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'; + } }; From 44680b2a5c43fc3cb039480d4b156a6943bea58d Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 16 May 2022 23:47:44 -0400 Subject: [PATCH 20/37] fix tests --- src/test/index.spec.ts | 22 +++++++++++++--------- tests/ts45-ext/ext-cts/tsconfig.json | 3 +++ tests/ts45-ext/ext-mts/tsconfig.json | 3 +++ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index b74a9b05b..407dd1041 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -197,22 +197,26 @@ test.suite('ts-node', (test) => { test('should support cts when module = CommonJS', async () => { const { err, stdout } = await exec( [ - CMD_TS_NODE_WITH_PROJECT_FLAG, - '-O "{\\"module\\":"CommonJS"}"', - '-pe "import { main } from \'./ts45-ext/ext-cts/index\';main()"', - ].join(' ') + 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('should support cts when module = ESNext', async () => { + test('should support mts when module = ESNext', async () => { const { err, stdout } = await exec( [ - CMD_TS_NODE_WITH_PROJECT_FLAG, - '-O "{\\"module\\":\\"ESNext\\"}"', - '-pe "import { main } from \'./ts45-ext/ext-mts/index\';main()"', - ].join(' ') + CMD_TS_NODE_WITHOUT_PROJECT_FLAG, + '-pe "import { main } from \'./index.mjs\';main()"', + ].join(' '), + { + cwd: join(TEST_DIR, 'ts45-ext/ext-mts'), + } ); expect(err).toBe(null); expect(stdout).toBe('hello world\n'); diff --git a/tests/ts45-ext/ext-cts/tsconfig.json b/tests/ts45-ext/ext-cts/tsconfig.json index 28900bb1b..0ff9a8a62 100644 --- a/tests/ts45-ext/ext-cts/tsconfig.json +++ b/tests/ts45-ext/ext-cts/tsconfig.json @@ -1,4 +1,7 @@ { + "ts-node": { + "experimentalResolver": true + }, "compilerOptions": { "module": "CommonJS" } diff --git a/tests/ts45-ext/ext-mts/tsconfig.json b/tests/ts45-ext/ext-mts/tsconfig.json index 1ac61592b..8e0358ee9 100644 --- a/tests/ts45-ext/ext-mts/tsconfig.json +++ b/tests/ts45-ext/ext-mts/tsconfig.json @@ -1,4 +1,7 @@ { + "ts-node": { + "experimentalResolver": true + }, "compilerOptions": { "module": "ESNext" } From db98db618095b69dd1919146dcf48cbe89fccc6f Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 00:24:52 -0400 Subject: [PATCH 21/37] fix --- src/test/index.spec.ts | 5 +---- tests/ts45-ext/ext-mts/entrypoint.mjs | 2 ++ tests/ts45-ext/ext-mts/tsconfig.json | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 tests/ts45-ext/ext-mts/entrypoint.mjs diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 407dd1041..5c8b4f551 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -210,10 +210,7 @@ test.suite('ts-node', (test) => { test('should support mts when module = ESNext', async () => { const { err, stdout } = await exec( - [ - CMD_TS_NODE_WITHOUT_PROJECT_FLAG, - '-pe "import { main } from \'./index.mjs\';main()"', - ].join(' '), + [CMD_TS_NODE_WITHOUT_PROJECT_FLAG, './entrypoint.mjs'].join(' '), { cwd: join(TEST_DIR, 'ts45-ext/ext-mts'), } 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/tsconfig.json b/tests/ts45-ext/ext-mts/tsconfig.json index 8e0358ee9..c414ea58f 100644 --- a/tests/ts45-ext/ext-mts/tsconfig.json +++ b/tests/ts45-ext/ext-mts/tsconfig.json @@ -1,5 +1,6 @@ { "ts-node": { + "esm": true, "experimentalResolver": true }, "compilerOptions": { From 7d4e1b1fd2b5f665af727c97fbec7f4c8ccac980 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 00:34:39 -0400 Subject: [PATCH 22/37] fix build against stable ts, no need for 4.7.0 just yet --- src/ts-compiler-types.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ts-compiler-types.ts b/src/ts-compiler-types.ts index 9c9d180d4..d90de53f0 100644 --- a/src/ts-compiler-types.ts +++ b/src/ts-compiler-types.ts @@ -19,7 +19,10 @@ export interface TSCommon { getPreEmitDiagnostics: typeof _ts.getPreEmitDiagnostics; flattenDiagnosticMessageText: typeof _ts.flattenDiagnosticMessageText; transpileModule: typeof _ts.transpileModule; - ModuleKind: typeof _ts.ModuleKind; + ModuleKind: typeof _ts.ModuleKind & { + // Hack until we start building against TS >= 4.7.0 + Node16?: 100; + }; ScriptTarget: typeof _ts.ScriptTarget; findConfigFile: typeof _ts.findConfigFile; readConfigFile: typeof _ts.readConfigFile; From 26a26bbdf6acdc728da958ef494dd10c65a53fa7 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 09:13:17 -0400 Subject: [PATCH 23/37] Gate nodenext/node16 tests behind a TS version check; fix TSCommon types --- src/test/helpers.ts | 3 +++ src/test/index.spec.ts | 5 +++-- src/test/module-node.spec.ts | 3 +++ src/ts-compiler-types.ts | 9 +++++---- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 74054f325..0a4ab9f9f 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -75,6 +75,9 @@ 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'); //#endregion export const xfs = new NodeFS(fs); diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 5c8b4f551..42bdf3b77 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -9,6 +9,7 @@ import { nodeSupportsEsmHooks, ts, tsSupportsShowConfig, + tsSupportsStableNodeNextNode16, tsSupportsTsconfigInheritanceViaNodePackages, } from './helpers'; import { lstatSync, mkdtempSync } from 'fs'; @@ -1138,8 +1139,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.7.') || semver.gte(ts.version, '4.7.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 index cbfe1aade..435432cf3 100644 --- a/src/test/module-node.spec.ts +++ b/src/test/module-node.spec.ts @@ -3,6 +3,7 @@ import { CMD_TS_NODE_WITHOUT_PROJECT_FLAG, isOneOf, resetNodeEnvironment, + tsSupportsStableNodeNextNode16, } from './helpers'; import * as Path from 'path'; import { ctxTsNode } from './helpers'; @@ -17,6 +18,8 @@ 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', diff --git a/src/ts-compiler-types.ts b/src/ts-compiler-types.ts index d90de53f0..3b23e1c88 100644 --- a/src/ts-compiler-types.ts +++ b/src/ts-compiler-types.ts @@ -19,10 +19,7 @@ export interface TSCommon { getPreEmitDiagnostics: typeof _ts.getPreEmitDiagnostics; flattenDiagnosticMessageText: typeof _ts.flattenDiagnosticMessageText; transpileModule: typeof _ts.transpileModule; - ModuleKind: typeof _ts.ModuleKind & { - // Hack until we start building against TS >= 4.7.0 - Node16?: 100; - }; + ModuleKind: typeof _ts.ModuleKind; ScriptTarget: typeof _ts.ScriptTarget; findConfigFile: typeof _ts.findConfigFile; readConfigFile: typeof _ts.readConfigFile; @@ -76,6 +73,10 @@ export namespace TSCommon { _ts.ResolvedModuleWithFailedLookupLocations; export type FileReference = _ts.FileReference; export type SourceFile = _ts.SourceFile; + export namespace ModuleKind { + // Hack until we start building against TS >= 4.7.0 + export declare const Node16: 100 | undefined; + } } /** From d4df204e9026f4406c269c36cf7cb4bb5afa003a Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 11:44:25 -0400 Subject: [PATCH 24/37] Fix nyc require.extensions issues --- nyc.config.js | 3 +++ src/index.ts | 20 +++++++++++--------- src/test/helpers.ts | 4 ---- 3 files changed, 14 insertions(+), 13 deletions(-) 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/index.ts b/src/index.ts index 7ff88c48a..3a26d0113 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, @@ -1572,15 +1573,16 @@ function registerExtensions( originalJsHandler: (m: NodeModule, filename: string) => any ) { const exts = new Set(extensions); - // Only way to transform .mts and .cts is via the .js extension. - // Can't register those extensions cuz would allow omitting file extension; node requires ext for .cjs and .mjs - if (exts.has('.mts') || exts.has('.cts')) exts.add('.js'); - // Filter extensions which should not be added to `require.extensions` - // They may still be handled via the `.js` extension handler. - exts.delete('.mts'); - exts.delete('.cts'); - exts.delete('.mjs'); - exts.delete('.cjs'); + // 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; then we *must* hook them or else our transformer + // will not be called. + for(const cannotAdd of ['.mts', '.cts', '.mjs', '.cjs']) { + // Other file exts can still be transformed via the .js extension. + if(exts.has(cannotAdd) && !hasOwnProperty(require.extensions, cannotAdd)) { + exts.add('.js'); + exts.delete(cannotAdd); + } + } // TODO do we care about overriding moduleType for mjs? No, I don't think so. // Could conditionally register `.mjs` extension when moduleType overrides are configured, diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 0a4ab9f9f..042374e94 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -204,10 +204,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']); From a865be3fe17ee11c52c188539152ea053d1368b9 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 11:52:43 -0400 Subject: [PATCH 25/37] lint-fix --- src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3a26d0113..b86fc6282 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1574,11 +1574,11 @@ function registerExtensions( ) { 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; then we *must* hook them or else our transformer - // will not be called. - for(const cannotAdd of ['.mts', '.cts', '.mjs', '.cjs']) { - // Other file exts can still be transformed via the .js extension. - if(exts.has(cannotAdd) && !hasOwnProperty(require.extensions, cannotAdd)) { + // 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); } From 293b5cdf9cab3c57df53fdf0c18058240f745df0 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 14:33:24 -0400 Subject: [PATCH 26/37] tricky types --- src/ts-compiler-types.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/ts-compiler-types.ts b/src/ts-compiler-types.ts index 3b23e1c88..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,10 +73,12 @@ export namespace TSCommon { _ts.ResolvedModuleWithFailedLookupLocations; export type FileReference = _ts.FileReference; export type SourceFile = _ts.SourceFile; - export namespace ModuleKind { - // Hack until we start building against TS >= 4.7.0 - export declare const Node16: 100 | undefined; - } + // 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; + }; } /** From 71066b2584b32623f6546d3ff5d7bb211ab31598 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 15:15:11 -0400 Subject: [PATCH 27/37] skip nodenext tests on older TS --- src/test/index.spec.ts | 22 +++++++++++++--------- src/test/resolver.spec.ts | 14 ++++++++++++-- src/test/testlib.ts | 19 +++++++++++++++++-- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 42bdf3b77..7350fdba4 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -7,6 +7,7 @@ import { BIN_PATH_JS, CMD_TS_NODE_WITH_PROJECT_TRANSPILE_ONLY_FLAG, nodeSupportsEsmHooks, + nodeSupportsSpawningChildProcess, ts, tsSupportsShowConfig, tsSupportsStableNodeNextNode16, @@ -209,15 +210,18 @@ test.suite('ts-node', (test) => { expect(stdout).toBe('hello world\n'); }); - test('should support mts when module = ESNext', 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.suite('should support mts when module = ESNext', (test) => { + test.runIf(nodeSupportsSpawningChildProcess); + 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 () => { diff --git a/src/test/resolver.spec.ts b/src/test/resolver.spec.ts index f638ff9b7..9a296c172 100755 --- a/src/test/resolver.spec.ts +++ b/src/test/resolver.spec.ts @@ -1,5 +1,11 @@ import { context, ExecutionContext, expect, TestInterface } from './testlib'; -import { ctxTsNode, isOneOf, resetNodeEnvironment, ts } from './helpers'; +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'; @@ -194,7 +200,11 @@ test.suite('Resolver hooks', (test) => { } }); -function declareProject(test: Test, project: Project) { +function declareProject(_test: Test, project: Project) { + const test = + project.useTsNodeNext && !tsSupportsStableNodeNextNode16 + ? _test.skip + : _test; test(`${project.identifier}`, async (t) => { t.teardown(() => { resetNodeEnvironment(); 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 ) { From 3c5a2db6701bab4a90c02080819b467a36b991a6 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 15:38:30 -0400 Subject: [PATCH 28/37] fix tests --- src/test/helpers.ts | 2 ++ src/test/index.spec.ts | 70 ++++++++++++++++++++++++++---------------- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 042374e94..0f648dab7 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -78,6 +78,8 @@ 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); diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 7350fdba4..261c6f689 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -9,6 +9,7 @@ import { nodeSupportsEsmHooks, nodeSupportsSpawningChildProcess, ts, + tsSupportsMtsCtsExtensions, tsSupportsShowConfig, tsSupportsStableNodeNextNode16, tsSupportsTsconfigInheritanceViaNodePackages, @@ -1012,12 +1013,12 @@ test.suite('ts-node', (test) => { }); test.suite('issue #1098', (test) => { - function testIgnored( + function testAllowedExtensions( t: ExecutionContext, compilerOptions: CreateOptions['compilerOptions'], - allowed: string[], - disallowed: string[] + allowed: string[] ) { + const disallowed = allExtensions.filter((ext) => !allowed.includes(ext)); const { ignored } = t.context.tsNodeUnderTest.create({ compilerOptions, skipProject: true, @@ -1032,37 +1033,52 @@ test.suite('ts-node', (test) => { } } + const allExtensions = [ + '.ts', + '.js', + '.d.ts', + '.mts', + '.cts', + '.mjs', + '.cjs', + '.tsx', + '.jsx', + '.xyz', + '', + ]; + const mtsCts = tsSupportsMtsCtsExtensions ? ['.mts', '.cts'] : []; + const mjsCjs = tsSupportsMtsCtsExtensions ? ['.mjs', '.cjs'] : []; + test('correctly filters file extensions from the compiler when allowJs=false and jsx=false', (t) => { - testIgnored( - t, - {}, - ['.ts', '.d.ts', '.mts', '.cts'], - ['.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) => { - testIgnored( - t, - { allowJs: true }, - ['.ts', '.js', '.d.ts', '.mts', '.cts', '.mjs', '.cjs'], - ['.tsx', '.jsx', '.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) => { - testIgnored( - t, - { allowJs: false, jsx: 'preserve' }, - ['.ts', '.tsx', '.d.ts', '.mts', '.cts'], - ['.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) => { - testIgnored( - t, - { allowJs: true, jsx: 'preserve' }, - ['.ts', '.tsx', '.js', '.jsx', '.d.ts', '.mts', '.cts', '.mjs', '.cjs'], - ['.xyz', ''] - ); + testAllowedExtensions(t, { allowJs: true, jsx: 'preserve' }, [ + '.ts', + '.tsx', + '.js', + '.jsx', + '.d.ts', + ...mtsCts, + ...mjsCjs, + ]); }); }); }); From 19f6442c77d9c1e8e34fcd45e1f9923bbfd3e5e2 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 19:54:35 -0400 Subject: [PATCH 29/37] fix bug and tests --- src/index.ts | 25 +++++++------------------ src/test/helpers.ts | 2 +- src/test/index.spec.ts | 4 +++- src/test/resolver.spec.ts | 13 +++++++------ 4 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/index.ts b/src/index.ts index b86fc6282..b4594ea89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1343,8 +1343,7 @@ export function createFromPreloadedConfig( transformers: transformers as _ts.CustomTransformers | undefined, }) : undefined; - return (code: string, _fileName: string): SourceOutput => { - let fileName = _fileName; + return (code: string, fileName: string): SourceOutput => { let result: _ts.TranspileOutput; if (customTranspiler) { result = customTranspiler.transpile(code, { @@ -1552,17 +1551,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. */ @@ -1584,10 +1572,6 @@ function registerExtensions( } } - // TODO do we care about overriding moduleType for mjs? No, I don't think so. - // Could conditionally register `.mjs` extension when moduleType overrides are configured, - // since that is the only situation where we want to avoid node throwing an error. - // Register new extensions. for (const ext of exts) { registerExtension(ext, service, originalJsHandler); @@ -1599,7 +1583,12 @@ function registerExtensions( ...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!); + } } } diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 0f648dab7..0a58c5a6b 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -289,7 +289,7 @@ function resetObject( continue; if (avoidSetterIfUnchanged.includes(key) && object[key] === value) continue; - object[key] = value; + state.descriptors[key].set?.call(object, value); } catch {} } // Reset descriptors diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 261c6f689..f480afd3a 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -212,7 +212,9 @@ test.suite('ts-node', (test) => { }); test.suite('should support mts when module = ESNext', (test) => { - test.runIf(nodeSupportsSpawningChildProcess); + test.runIf( + nodeSupportsSpawningChildProcess && tsSupportsMtsCtsExtensions + ); test('test', async () => { const { err, stdout } = await exec( [CMD_TS_NODE_WITHOUT_PROJECT_FLAG, './entrypoint.mjs'].join(' '), diff --git a/src/test/resolver.spec.ts b/src/test/resolver.spec.ts index 9a296c172..96d6cd8cf 100755 --- a/src/test/resolver.spec.ts +++ b/src/test/resolver.spec.ts @@ -165,13 +165,13 @@ 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 preferSrc of [false, true]) { + for (const typeModule of [false, true]) { + for (const allowJs of [false, true]) { for (const useTsNodeNext of [false, true]) { - for (const experimentalSpecifierResolutionNode 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]) { + // 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]) { + for (const experimentalSpecifierResolutionNode of [false, true]) { let identifier = `resolver-${projectSeq()}`; identifier += preferSrc ? '-preferSrc' : '-preferOut'; identifier += typeModule ? '-typeModule' : '-typeCjs---'; @@ -235,6 +235,7 @@ function declareProject(_test: Test, project: Project) { ? 'esnext' : 'commonjs', jsx: 'react', + target: 'esnext', }, }); From e3661c7b0bdf5590af0cfb30048577a507acebcb Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 21:24:16 -0400 Subject: [PATCH 30/37] fix windows --- src/index.ts | 6 ++---- src/node-module-type-classifier.ts | 10 ++++------ src/ts-transpile-module.ts | 2 +- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index b4594ea89..7badfebce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -750,6 +750,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(), @@ -1439,10 +1440,7 @@ export function createFromPreloadedConfig( [value, sourceMap] = getOutputForceESM(code, normalizedFileName); } else if (emitSkipped) { // Happens when ts compiler skips emit or in transpileOnly mode - const classification = classifyModule( - normalizedFileName, - isNodeModuleType - ); + const classification = classifyModule(fileName, isNodeModuleType); [value, sourceMap] = classification === 'nodecjs' ? getOutputForceNodeCommonJS(code, normalizedFileName) diff --git a/src/node-module-type-classifier.ts b/src/node-module-type-classifier.ts index 4f5e90051..19a9e8361 100644 --- a/src/node-module-type-classifier.ts +++ b/src/node-module-type-classifier.ts @@ -1,8 +1,6 @@ import { readPackageScope } from '../dist-raw/node-internal-modules-cjs-loader'; /** - * TODO https://github.com/microsoft/TypeScript/issues/46452#issuecomment-1073145723 - * * 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 @@ -16,12 +14,12 @@ import { readPackageScope } from '../dist-raw/node-internal-modules-cjs-loader'; * @internal */ export function classifyModule( - filename: string, + nativeFilename: string, isNodeModuleType: boolean ): 'nodecjs' | 'cjs' | 'esm' | 'nodeesm' | undefined { // [MUST_UPDATE_FOR_NEW_FILE_EXTENSIONS] - const lastDotIndex = filename.lastIndexOf('.'); - const ext = lastDotIndex >= 0 ? filename.slice(lastDotIndex) : ''; + const lastDotIndex = nativeFilename.lastIndexOf('.'); + const ext = lastDotIndex >= 0 ? nativeFilename.slice(lastDotIndex) : ''; switch (ext) { case '.cjs': case '.cts': @@ -31,7 +29,7 @@ export function classifyModule( return isNodeModuleType ? 'nodeesm' : 'esm'; } if (isNodeModuleType) { - const packageScope = readPackageScope(filename); + const packageScope = readPackageScope(nativeFilename); if (packageScope && packageScope.data.type === 'module') return 'nodeesm'; return 'nodecjs'; } diff --git a/src/ts-transpile-module.ts b/src/ts-transpile-module.ts index 518bb908f..0744dc4d3 100644 --- a/src/ts-transpile-module.ts +++ b/src/ts-transpile-module.ts @@ -88,7 +88,7 @@ export function createTsTranspileModule( } }, getDefaultLibFileName: () => 'lib.d.ts', - useCaseSensitiveFileNames: () => false, + useCaseSensitiveFileNames: () => true, getCanonicalFileName: (fileName) => fileName, getCurrentDirectory: () => '', getNewLine: () => newLine, From 41e93ec7dc62a6791699a5f578e536151ac7202a Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 20:03:48 -0400 Subject: [PATCH 31/37] turn off another test on ancient TS versions --- src/test/index.spec.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index f480afd3a..ac116b186 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -197,18 +197,21 @@ test.suite('ts-node', (test) => { expect(stdout).toBe('hello world\n'); }); - test('should support cts when module = CommonJS', async () => { - 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 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) => { From b308f2bb8841e08cff469445f74acf0833441dca Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 22:12:52 -0400 Subject: [PATCH 32/37] fix windows --- src/test/module-node.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/test/module-node.spec.ts b/src/test/module-node.spec.ts index 435432cf3..b182444ea 100644 --- a/src/test/module-node.spec.ts +++ b/src/test/module-node.spec.ts @@ -358,5 +358,9 @@ function createImportee( } function replaceExtension(path: string, ext: string) { - return Path.format({ ...Path.parse(path), ext: '.' + ext, base: undefined }); + return Path.posix.format({ + ...Path.parse(path), + ext: '.' + ext, + base: undefined, + }); } From 5de2b23164c8c444cbff6bcd13f45e382bb20303 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 22:55:38 -0400 Subject: [PATCH 33/37] fix allowing `moduleTypeOverrides` to override cts/cjs --- dist-raw/node-internal-modules-cjs-loader.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist-raw/node-internal-modules-cjs-loader.js b/dist-raw/node-internal-modules-cjs-loader.js index 1d78bc2be..e2ab5a2e7 100644 --- a/dist-raw/node-internal-modules-cjs-loader.js +++ b/dist-raw/node-internal-modules-cjs-loader.js @@ -573,7 +573,7 @@ function assertScriptCanLoadAsCJSImpl(service, module, filename) { const lastDotIndex = filename.lastIndexOf('.'); const ext = lastDotIndex >= 0 ? filename.slice(lastDotIndex) : ''; - if(ext === '.cts' || ext === '.cjs') return; + if((ext === '.cts' || ext === '.cjs') && tsNodeClassification.moduleType === 'auto') return; // Function require shouldn't be used in ES modules. if (ext === '.mts' || ext === '.mjs' || tsNodeClassification.moduleType === 'esm' || (pkg && pkg.data && pkg.data.type === 'module')) { From 5b1dcdc755238e512ec6a625673ca9549278ab9a Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 23:30:22 -0400 Subject: [PATCH 34/37] Update moduleTypes docs --- src/index.ts | 5 +++-- website/docs/module-type-overrides.md | 28 +++++++++++++++------------ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7badfebce..a0079bd24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -351,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. 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: From 00755d0ab42fef777feb1fc710d049f0dd954a56 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 23:40:35 -0400 Subject: [PATCH 35/37] add mts and cts awareness to internal/external classifier; add .d.cts/.d.mts extensions to tests --- src/resolver-functions.ts | 7 ++++++- src/test/index.spec.ts | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) 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/index.spec.ts b/src/test/index.spec.ts index ac116b186..ca4c2cf85 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -1044,6 +1044,8 @@ test.suite('ts-node', (test) => { '.d.ts', '.mts', '.cts', + '.d.mts', + '.d.cts', '.mjs', '.cjs', '.tsx', @@ -1051,7 +1053,9 @@ test.suite('ts-node', (test) => { '.xyz', '', ]; - const mtsCts = tsSupportsMtsCtsExtensions ? ['.mts', '.cts'] : []; + 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) => { From 02884682dbe2cc332e1bca2090e9f07a79840a04 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 17 May 2022 23:50:53 -0400 Subject: [PATCH 36/37] cleanup misc markdown files --- development-docs/README.md | 10 ++++++++++ TODOs.md => development-docs/nodenextNode16.md | 7 ++++++- notes2.md => development-docs/rootDirOutDirMapping.md | 2 ++ NOTES.md => development-docs/yarnPnpInterop.md | 4 +--- notes1.md | 9 --------- 5 files changed, 19 insertions(+), 13 deletions(-) create mode 100644 development-docs/README.md rename TODOs.md => development-docs/nodenextNode16.md (88%) rename notes2.md => development-docs/rootDirOutDirMapping.md (93%) rename NOTES.md => development-docs/yarnPnpInterop.md (96%) delete mode 100644 notes1.md 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/TODOs.md b/development-docs/nodenextNode16.md similarity index 88% rename from TODOs.md rename to development-docs/nodenextNode16.md index f24891cf4..89ce02c9a 100644 --- a/TODOs.md +++ b/development-docs/nodenextNode16.md @@ -1,4 +1,9 @@ -#TODOs +# 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 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/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" ? "($|/)" : "$"}`; From e5453949df1b3aaec86cf01539a709302f604ef9 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Wed, 18 May 2022 00:15:59 -0400 Subject: [PATCH 37/37] more markdown cleanup --- CAVEATS.md | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 CAVEATS.md diff --git a/CAVEATS.md b/CAVEATS.md deleted file mode 100644 index bda316ecc..000000000 --- a/CAVEATS.md +++ /dev/null @@ -1,10 +0,0 @@ -CAVEATS - -Node does not have require.extensions for mjs nor cjs -Thus they must be require()d including the extension. -Today, `ts-node` relies in extension omission to support CJS compilation. - -How do we support .cts and .mts? -Allow someone to import .cts / .mts directly? -Wait for our resolver hook to be implemented? -Merge file extension resolving PR? (#1361)