From 1bc470d4e50118de4ccebf3e2dca00d80d1cae91 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Fri, 9 Jul 2021 15:36:10 -0400 Subject: [PATCH] Add moduleType option to override module type on certain files. (#1371) * Add moduleType option to override module type on certain files. Also refactor resolverFunctions into their own file; should break this into 2x PRs later. * lint fix * add test * remove unnecessary exports from ts-internals; mark exports as @internal * add docs * strip optionBasePaths from showConfig output * proper path normalization to support windows * lint-fix * use es2015 instead of es2020 in test tsconfig to support ts2.7 * add missing path normalization when calling classifyModule * Test coverage: moduleType overrides during ts-node loader usage (#1376) * Test coverage: add test case to confirm that moduleType overrides are applied for ts-node in loader mode * Ensure that a default export exists for the esm-exception module * Re-order tsconfig.json glob rules, and use implicit globbing * lint fixup: apply prettier * Add 'experimental-specifier-resolution' flag to NPM options in ESM module override test * Ensure that a default export exists for the cjs-subdir module * Revert "Ensure that a default export exists for the cjs-subdir module" This reverts commit c64cf928787b476911dc86657dde7d4289a277a4. * Revert "Add 'experimental-specifier-resolution' flag to NPM options in ESM module override test" This reverts commit 1093df80dcf7782910a00c8c292f3bd2a6a8a5a7. * Specify tsconfig project using TS_NODE_PROJECT environment variable * Use full file paths in preference to directory-default module imports This avoids ERR_UNSUPPORTED_DIR_IMPORT, without having to provide the 'experimental-specifier-resolution' flag to ts-node * Update index.ts * Update index.ts * Update tsconfig.json * Extract execModuleTypeOverride function * Add expected failure cases for Node 12.15, 14.13 Co-authored-by: Andrew Bradley * Update tests * fix * fix * fix for TS2.7 * fix * fix * reword * update docs * address todos * fix Co-authored-by: James Addison --- dist-raw/node-cjs-loader-utils.js | 21 +- src/bin.ts | 1 + src/configuration.ts | 26 +- src/esm.ts | 23 +- src/index.ts | 286 ++++++--------- src/module-type-classifier.ts | 96 +++++ src/resolver-functions.ts | 172 +++++++++ src/test/index.spec.ts | 66 +++- src/ts-internals.ts | 344 ++++++++++++++++-- .../module-types/override-to-cjs/package.json | 3 + .../src/cjs-subdir/esm-exception.ts | 2 + .../override-to-cjs/src/cjs-subdir/index.ts | 2 + .../override-to-cjs/src/should-be-esm.ts | 3 + .../override-to-cjs/test-webpack-config.cjs | 3 + tests/module-types/override-to-cjs/test.cjs | 25 ++ tests/module-types/override-to-cjs/test.mjs | 13 + .../override-to-cjs/tsconfig.json | 14 + .../override-to-cjs/webpack.config.ts | 1 + .../module-types/override-to-esm/package.json | 1 + .../src/esm-subdir/cjs-exception.ts | 2 + .../override-to-esm/src/esm-subdir/index.ts | 2 + .../override-to-esm/src/should-be-cjs.ts | 3 + tests/module-types/override-to-esm/test.cjs | 18 + tests/module-types/override-to-esm/test.mjs | 11 + .../override-to-esm/tsconfig.json | 13 + website/docs/module-type-overrides.md | 48 +++ website/docs/options.md | 1 + website/sidebars.js | 3 +- 28 files changed, 980 insertions(+), 223 deletions(-) create mode 100644 src/module-type-classifier.ts create mode 100644 src/resolver-functions.ts create mode 100644 tests/module-types/override-to-cjs/package.json create mode 100644 tests/module-types/override-to-cjs/src/cjs-subdir/esm-exception.ts create mode 100644 tests/module-types/override-to-cjs/src/cjs-subdir/index.ts create mode 100644 tests/module-types/override-to-cjs/src/should-be-esm.ts create mode 100644 tests/module-types/override-to-cjs/test-webpack-config.cjs create mode 100644 tests/module-types/override-to-cjs/test.cjs create mode 100644 tests/module-types/override-to-cjs/test.mjs create mode 100644 tests/module-types/override-to-cjs/tsconfig.json create mode 100644 tests/module-types/override-to-cjs/webpack.config.ts create mode 100644 tests/module-types/override-to-esm/package.json create mode 100644 tests/module-types/override-to-esm/src/esm-subdir/cjs-exception.ts create mode 100644 tests/module-types/override-to-esm/src/esm-subdir/index.ts create mode 100644 tests/module-types/override-to-esm/src/should-be-cjs.ts create mode 100644 tests/module-types/override-to-esm/test.cjs create mode 100644 tests/module-types/override-to-esm/test.mjs create mode 100644 tests/module-types/override-to-esm/tsconfig.json create mode 100644 website/docs/module-type-overrides.md diff --git a/dist-raw/node-cjs-loader-utils.js b/dist-raw/node-cjs-loader-utils.js index 029cf5f77..b7ec0d531 100644 --- a/dist-raw/node-cjs-loader-utils.js +++ b/dist-raw/node-cjs-loader-utils.js @@ -5,17 +5,28 @@ const path = require('path'); const packageJsonReader = require('./node-package-json-reader'); const {JSONParse} = require('./node-primordials'); +const {normalizeSlashes} = require('../dist/util'); module.exports.assertScriptCanLoadAsCJSImpl = assertScriptCanLoadAsCJSImpl; -// copied from Module._extensions['.js'] -// https://github.com/nodejs/node/blob/v15.3.0/lib/internal/modules/cjs/loader.js#L1113-L1120 -function assertScriptCanLoadAsCJSImpl(filename) { +/** + * copied from Module._extensions['.js'] + * https://github.com/nodejs/node/blob/v15.3.0/lib/internal/modules/cjs/loader.js#L1113-L1120 + * @param {import('../src/index').Service} service + * @param {NodeJS.Module} module + * @param {string} filename + */ +function assertScriptCanLoadAsCJSImpl(service, module, filename) { const pkg = readPackageScope(filename); + + // ts-node modification: allow our configuration to override + const tsNodeClassification = service.moduleTypeClassifier.classifyModule(normalizeSlashes(filename)); + if(tsNodeClassification.moduleType === 'cjs') return; + // Function require shouldn't be used in ES modules. - if (pkg && pkg.data && pkg.data.type === 'module') { + if (tsNodeClassification.moduleType === 'esm' || (pkg && pkg.data && pkg.data.type === 'module')) { const parentPath = module.parent && module.parent.filename; - const packageJsonPath = path.resolve(pkg.path, 'package.json'); + const packageJsonPath = pkg ? path.resolve(pkg.path, 'package.json') : null; throw createErrRequireEsm(filename, parentPath, packageJsonPath); } } diff --git a/src/bin.ts b/src/bin.ts index 3bd35921f..4a4957d80 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -296,6 +296,7 @@ export function main( const json = { ['ts-node']: { ...service.options, + optionBasePaths: undefined, experimentalEsmLoader: undefined, compilerOptions: undefined, project: service.configFilePath ?? service.options.project, diff --git a/src/configuration.ts b/src/configuration.ts index 87babb561..51126e1be 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,6 +1,12 @@ import { resolve, dirname } from 'path'; import type * as _ts from 'typescript'; -import { CreateOptions, DEFAULTS, TSCommon, TsConfigOptions } from './index'; +import { + CreateOptions, + DEFAULTS, + OptionBasePaths, + TSCommon, + TsConfigOptions, +} from './index'; import type { TSInternal } from './ts-compiler-types'; import { createTsInternals } from './ts-internals'; import { getDefaultTsconfigJsonForNodeVersion } from './tsconfigs'; @@ -70,6 +76,7 @@ export function readConfig( * this function. */ tsNodeOptionsFromTsconfig: TsConfigOptions; + optionBasePaths: OptionBasePaths; } { // Ordered [a, b, c] where config a extends b extends c const configChain: Array<{ @@ -110,6 +117,7 @@ export function readConfig( configFilePath, config: { errors: [result.error], fileNames: [], options: {} }, tsNodeOptionsFromTsconfig: {}, + optionBasePaths: {}, }; } @@ -140,6 +148,7 @@ export function readConfig( configFilePath, config: { errors, fileNames: [], options: {} }, tsNodeOptionsFromTsconfig: {}, + optionBasePaths: {}, }; } if (resolvedExtendedConfigPath == null) break; @@ -152,6 +161,7 @@ export function readConfig( // Merge and fix ts-node options that come from tsconfig.json(s) const tsNodeOptionsFromTsconfig: TsConfigOptions = {}; + const optionBasePaths: OptionBasePaths = {}; for (let i = configChain.length - 1; i >= 0; i--) { const { config, basePath, configPath } = configChain[i]; const options = filterRecognizedTsConfigTsNodeOptions(config['ts-node']) @@ -169,6 +179,11 @@ export function readConfig( options.scopeDir = resolve(basePath, options.scopeDir!); } + // Downstream code uses the basePath; we do not do that here. + if (options.moduleTypes) { + optionBasePaths.moduleTypes = basePath; + } + assign(tsNodeOptionsFromTsconfig, options); } @@ -222,7 +237,12 @@ export function readConfig( ) ); - return { configFilePath, config: fixedConfig, tsNodeOptionsFromTsconfig }; + return { + configFilePath, + config: fixedConfig, + tsNodeOptionsFromTsconfig, + optionBasePaths, + }; } /** @@ -251,6 +271,7 @@ function filterRecognizedTsConfigTsNodeOptions( transpiler, scope, scopeDir, + moduleTypes, ...unrecognized } = jsonObject as TsConfigOptions; const filteredTsConfigOptions = { @@ -271,6 +292,7 @@ function filterRecognizedTsConfigTsNodeOptions( transpiler, scope, scopeDir, + moduleTypes, }; // Use the typechecker to make sure this implementation has the correct set of properties const catchExtraneousProps: keyof TsConfigOptions = (null as any) as keyof typeof filteredTsConfigOptions; diff --git a/src/esm.ts b/src/esm.ts index 6cc64e86c..53e14fd71 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -8,6 +8,7 @@ import { } from 'url'; import { extname } from 'path'; import * as assert from 'assert'; +import { normalizeSlashes } from './util'; const { createResolve, } = require('../dist-raw/node-esm-resolve-implementation'); @@ -96,11 +97,27 @@ export function registerAndCreateEsmHooks(opts?: RegisterOptions) { // If file has .ts, .tsx, or .jsx extension, then ask node how it would treat this file if it were .js const ext = extname(nativePath); + let nodeSays: { format: Format }; if (ext !== '.js' && !tsNodeInstance.ignored(nativePath)) { - return defer(formatUrl(pathToFileURL(nativePath + '.js'))); + nodeSays = await defer(formatUrl(pathToFileURL(nativePath + '.js'))); + } else { + nodeSays = await defer(); } - - return defer(); + // For files compiled by ts-node that node believes are either CJS or ESM, check if we should override that classification + if ( + !tsNodeInstance.ignored(nativePath) && + (nodeSays.format === 'commonjs' || nodeSays.format === 'module') + ) { + const { moduleType } = tsNodeInstance.moduleTypeClassifier.classifyModule( + normalizeSlashes(nativePath) + ); + if (moduleType === 'cjs') { + return { format: 'commonjs' }; + } else if (moduleType === 'esm') { + return { format: 'module' }; + } + } + return nodeSays; } async function transformSource( diff --git a/src/index.ts b/src/index.ts index 82313aad1..f770eceb3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,11 @@ import { } from './util'; import { readConfig } from './configuration'; import type { TSCommon, TSInternal } from './ts-compiler-types'; +import { + createModuleTypeClassifier, + ModuleTypeClassifier, +} from './module-type-classifier'; +import { createResolverFunctions } from './resolver-functions'; export { TSCommon }; export { createRepl, CreateReplOptions, ReplService } from './repl'; @@ -43,7 +48,9 @@ const engineSupportsPackageTypeField = * * Loaded conditionally so we don't need to support older node versions */ -const assertScriptCanLoadAsCJS: ( +let assertScriptCanLoadAsCJS: ( + service: Service, + module: NodeJS.Module, filename: string ) => void = engineSupportsPackageTypeField ? require('../dist-raw/node-cjs-loader-utils').assertScriptCanLoadAsCJSImpl @@ -273,6 +280,34 @@ export interface CreateOptions { * @internal */ experimentalEsmLoader?: 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; + * it achieves the same effect. + * + * Each key is a glob pattern following the same rules as tsconfig's "include" array. + * When multiple patterns match the same file, the last pattern takes precedence. + * + * `cjs` overrides matches files to compile and execute as CommonJS. + * `esm` overrides matches files to compile and execute as native ECMAScript modules. + * `package` overrides either of the above to default behavior, which obeys package.json "type" and + * tsconfig.json "module" options. + */ + moduleTypes?: Record; + /** + * @internal + * Set by our configuration loader whenever a config file contains options that + * are relative to the config file they came from, *and* when other logic needs + * to know this. Some options can be eagerly resolved to absolute paths by + * the configuration loader, so it is *not* necessary for their source to be set here. + */ + optionBasePaths?: OptionBasePaths; +} + +/** @internal */ +export interface OptionBasePaths { + moduleTypes?: string; } /** @@ -304,6 +339,7 @@ export interface TsConfigOptions | 'cwd' | 'projectSearchDir' | 'experimentalEsmLoader' + | 'optionBasePaths' > {} /** @@ -372,6 +408,8 @@ export interface Service { getTypeInfo(code: string, fileName: string, position: number): TypeInfo; /** @internal */ configFilePath: string | undefined; + /** @internal */ + moduleTypeClassifier: ModuleTypeClassifier; } /** @@ -447,15 +485,17 @@ export function create(rawOptions: CreateOptions = {}): Service { ); // Read config file and merge new options between env and CLI options. - const { configFilePath, config, tsNodeOptionsFromTsconfig } = readConfig( - cwd, - ts, - rawOptions - ); + const { + configFilePath, + config, + tsNodeOptionsFromTsconfig, + optionBasePaths, + } = readConfig(cwd, ts, rawOptions); const options = assign( {}, DEFAULTS, tsNodeOptionsFromTsconfig || {}, + { optionBasePaths }, rawOptions ); options.require = [ @@ -600,10 +640,11 @@ export function create(rawOptions: CreateOptions = {}): Service { ? (path: string) => (/\.[tj]sx$/.test(path) ? '.jsx' : '.js') : (_: string) => '.js'; + type GetOutputFunction = (code: string, fileName: string) => SourceOutput; /** * Create the basic required function using transpile mode. */ - let getOutput: (code: string, fileName: string) => SourceOutput; + let getOutput: GetOutputFunction; let getTypeInfo: ( _code: string, _fileName: string, @@ -614,161 +655,10 @@ export function create(rawOptions: CreateOptions = {}): Service { ts.sys.useCaseSensitiveFileNames ); - // In a factory because these are shared across both CompilerHost and LanguageService codepaths - function createResolverFunctions(serviceHost: _ts.ModuleResolutionHost) { - const moduleResolutionCache = ts.createModuleResolutionCache( - cwd, - getCanonicalFileName, - config.options - ); - const knownInternalFilenames = new Set(); - /** "Buckets" (module directories) whose contents should be marked "internal" */ - const internalBuckets = new Set(); - - // Get bucket for a source filename. Bucket is the containing `./node_modules/*/` directory - // For '/project/node_modules/foo/node_modules/bar/lib/index.js' bucket is '/project/node_modules/foo/node_modules/bar/' - // For '/project/node_modules/foo/node_modules/@scope/bar/lib/index.js' bucket is '/project/node_modules/foo/node_modules/@scope/bar/' - const moduleBucketRe = /.*\/node_modules\/(?:@[^\/]+\/)?[^\/]+\//; - function getModuleBucket(filename: string) { - const find = moduleBucketRe.exec(filename); - if (find) return find[0]; - return ''; - } - - // Mark that this file and all siblings in its bucket should be "internal" - function markBucketOfFilenameInternal(filename: string) { - internalBuckets.add(getModuleBucket(filename)); - } - - function isFileInInternalBucket(filename: string) { - return internalBuckets.has(getModuleBucket(filename)); - } - - function isFileKnownToBeInternal(filename: string) { - return knownInternalFilenames.has(filename); - } - - /** - * If we need to emit JS for a file, force TS to consider it non-external - */ - const fixupResolvedModule = ( - resolvedModule: _ts.ResolvedModule | _ts.ResolvedTypeReferenceDirective - ) => { - const { resolvedFileName } = resolvedModule; - if (resolvedFileName === undefined) return; - // .ts is always switched to internal - // .js is switched on-demand - if ( - resolvedModule.isExternalLibraryImport && - ((resolvedFileName.endsWith('.ts') && - !resolvedFileName.endsWith('.d.ts')) || - isFileKnownToBeInternal(resolvedFileName) || - isFileInInternalBucket(resolvedFileName)) - ) { - resolvedModule.isExternalLibraryImport = false; - } - if (!resolvedModule.isExternalLibraryImport) { - knownInternalFilenames.add(resolvedFileName); - } - }; - /* - * NOTE: - * Older ts versions do not pass `redirectedReference` nor `options`. - * We must pass `redirectedReference` to newer ts versions, but cannot rely on `options`, hence the weird argument name - */ - const resolveModuleNames: _ts.LanguageServiceHost['resolveModuleNames'] = ( - moduleNames: string[], - containingFile: string, - reusedNames: string[] | undefined, - redirectedReference: _ts.ResolvedProjectReference | undefined, - optionsOnlyWithNewerTsVersions: _ts.CompilerOptions - ): (_ts.ResolvedModule | undefined)[] => { - return moduleNames.map((moduleName) => { - const { resolvedModule } = ts.resolveModuleName( - moduleName, - containingFile, - config.options, - serviceHost, - moduleResolutionCache, - redirectedReference - ); - if (resolvedModule) { - fixupResolvedModule(resolvedModule); - } - return resolvedModule; - }); - }; - - // language service never calls this, but TS docs recommend that we implement it - const getResolvedModuleWithFailedLookupLocationsFromCache: _ts.LanguageServiceHost['getResolvedModuleWithFailedLookupLocationsFromCache'] = ( - moduleName, - containingFile - ): _ts.ResolvedModuleWithFailedLookupLocations | undefined => { - const ret = ts.resolveModuleNameFromCache( - moduleName, - containingFile, - moduleResolutionCache - ); - if (ret && ret.resolvedModule) { - fixupResolvedModule(ret.resolvedModule); - } - return ret; - }; - - const resolveTypeReferenceDirectives: _ts.LanguageServiceHost['resolveTypeReferenceDirectives'] = ( - typeDirectiveNames: string[], - containingFile: string, - redirectedReference: _ts.ResolvedProjectReference | undefined, - options: _ts.CompilerOptions - ): (_ts.ResolvedTypeReferenceDirective | undefined)[] => { - // Note: seems to be called with empty typeDirectiveNames array for all files. - return typeDirectiveNames.map((typeDirectiveName) => { - let { - resolvedTypeReferenceDirective, - } = ts.resolveTypeReferenceDirective( - typeDirectiveName, - containingFile, - config.options, - serviceHost, - redirectedReference - ); - if (typeDirectiveName === 'node' && !resolvedTypeReferenceDirective) { - // Resolve @types/node relative to project first, then __dirname (copy logic from elsewhere / refactor into reusable function) - const typesNodePackageJsonPath = require.resolve( - '@types/node/package.json', - { - paths: [configFilePath ?? cwd, __dirname], - } - ); - const typeRoots = [resolve(typesNodePackageJsonPath, '../..')]; - ({ - resolvedTypeReferenceDirective, - } = ts.resolveTypeReferenceDirective( - typeDirectiveName, - containingFile, - { - ...config.options, - typeRoots, - }, - serviceHost, - redirectedReference - )); - } - if (resolvedTypeReferenceDirective) { - fixupResolvedModule(resolvedTypeReferenceDirective); - } - return resolvedTypeReferenceDirective; - }); - }; - - return { - resolveModuleNames, - getResolvedModuleWithFailedLookupLocationsFromCache, - resolveTypeReferenceDirectives, - isFileKnownToBeInternal, - markBucketOfFilenameInternal, - }; - } + const moduleTypeClassifier = createModuleTypeClassifier({ + basePath: options.optionBasePaths?.moduleTypes, + patterns: options.moduleTypes, + }); // Use full language services when the fast option is disabled. if (!transpileOnly) { @@ -842,7 +732,14 @@ export function create(rawOptions: CreateOptions = {}): Service { resolveTypeReferenceDirectives, isFileKnownToBeInternal, markBucketOfFilenameInternal, - } = createResolverFunctions(serviceHost); + } = createResolverFunctions({ + serviceHost, + getCanonicalFileName, + ts, + cwd, + config, + configFilePath, + }); serviceHost.resolveModuleNames = resolveModuleNames; serviceHost.getResolvedModuleWithFailedLookupLocationsFromCache = getResolvedModuleWithFailedLookupLocationsFromCache; serviceHost.resolveTypeReferenceDirectives = resolveTypeReferenceDirectives; @@ -986,7 +883,14 @@ export function create(rawOptions: CreateOptions = {}): Service { resolveTypeReferenceDirectives, isFileKnownToBeInternal, markBucketOfFilenameInternal, - } = createResolverFunctions(host); + } = createResolverFunctions({ + serviceHost: host, + cwd, + configFilePath, + config, + ts, + getCanonicalFileName, + }); host.resolveModuleNames = resolveModuleNames; host.resolveTypeReferenceDirectives = resolveTypeReferenceDirectives; @@ -1143,7 +1047,22 @@ export function create(rawOptions: CreateOptions = {}): Service { } } } else { - getOutput = (code: string, fileName: string): SourceOutput => { + getOutput = createTranspileOnlyGetOutputFunction(); + + getTypeInfo = () => { + throw new TypeError( + 'Type information is unavailable in "--transpile-only"' + ); + }; + } + + function createTranspileOnlyGetOutputFunction( + overrideModuleType?: _ts.ModuleKind + ): GetOutputFunction { + const compilerOptions = { ...config.options }; + if (overrideModuleType !== undefined) + compilerOptions.module = overrideModuleType; + return (code: string, fileName: string): SourceOutput => { let result: _ts.TranspileOutput; if (customTranspiler) { result = customTranspiler.transpile(code, { @@ -1152,7 +1071,7 @@ export function create(rawOptions: CreateOptions = {}): Service { } else { result = ts.transpileModule(code, { fileName, - compilerOptions: config.options, + compilerOptions, reportDiagnostics: true, transformers: transformers as _ts.CustomTransformers | undefined, }); @@ -1166,18 +1085,36 @@ export function create(rawOptions: CreateOptions = {}): Service { return [result.outputText, result.sourceMapText as string]; }; - - getTypeInfo = () => { - throw new TypeError( - 'Type information is unavailable in "--transpile-only"' - ); - }; } + // When either is undefined, it means normal `getOutput` should be used + const getOutputForceCommonJS = + config.options.module === ts.ModuleKind.CommonJS + ? undefined + : createTranspileOnlyGetOutputFunction(ts.ModuleKind.CommonJS); + const getOutputForceESM = + config.options.module === ts.ModuleKind.ES2015 || + config.options.module === ts.ModuleKind.ES2020 || + config.options.module === ts.ModuleKind.ESNext + ? undefined + : createTranspileOnlyGetOutputFunction( + ts.ModuleKind.ES2020 || ts.ModuleKind.ES2015 + ); + // Create a simple TypeScript compiler proxy. function compile(code: string, fileName: string, lineOffset = 0) { const normalizedFileName = normalizeSlashes(fileName); - const [value, sourceMap] = getOutput(code, normalizedFileName); + const classification = moduleTypeClassifier.classifyModule( + normalizedFileName + ); + // Must always call normal getOutput to throw typechecking errors + let [value, sourceMap] = getOutput(code, normalizedFileName); + // If module classification contradicts the above, call the relevant transpiler + if (classification.moduleType === 'cjs' && getOutputForceCommonJS) { + [value, sourceMap] = getOutputForceCommonJS(code, normalizedFileName); + } else if (classification.moduleType === 'esm' && getOutputForceESM) { + [value, sourceMap] = getOutputForceESM(code, normalizedFileName); + } const output = updateOutput( value, normalizedFileName, @@ -1213,6 +1150,7 @@ export function create(rawOptions: CreateOptions = {}): Service { enabled, options, configFilePath, + moduleTypeClassifier, }; } @@ -1276,7 +1214,7 @@ function registerExtension( require.extensions[ext] = function (m: any, filename) { if (service.ignored(filename)) return old(m, filename); - assertScriptCanLoadAsCJS(filename); + assertScriptCanLoadAsCJS(service, m, filename); const _compile = m._compile; diff --git a/src/module-type-classifier.ts b/src/module-type-classifier.ts new file mode 100644 index 000000000..dfe153289 --- /dev/null +++ b/src/module-type-classifier.ts @@ -0,0 +1,96 @@ +import { dirname } from 'path'; +import { getPatternFromSpec } from './ts-internals'; +import { cachedLookup, normalizeSlashes } from './util'; + +// Logic to support out `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'; +/** @internal */ +export interface ModuleTypeClassification { + moduleType: ModuleType; +} +/** @internal */ +export interface ModuleTypeClassifierOptions { + basePath?: string; + patterns?: Record; +} +/** @internal */ +export type ModuleTypeClassifier = ReturnType< + typeof createModuleTypeClassifier +>; +/** + * @internal + * May receive non-normalized options -- basePath and patterns -- and will normalize them + * internally. + * However, calls to `classifyModule` must pass pre-normalized paths! + */ +export function createModuleTypeClassifier( + options: ModuleTypeClassifierOptions +) { + const { patterns, basePath: _basePath } = options; + const basePath = + _basePath !== undefined + ? normalizeSlashes(_basePath).replace(/\/$/, '') + : undefined; + + const patternTypePairs = Object.entries(patterns ?? []).map( + ([_pattern, type]) => { + const pattern = normalizeSlashes(_pattern); + return { pattern: parsePattern(basePath!, pattern), type }; + } + ); + + const classifications: Record = { + package: { + moduleType: 'package', + }, + cjs: { + moduleType: 'cjs', + }, + esm: { + moduleType: 'esm', + }, + }; + const auto = classifications.package; + + // Passed path must be normalized! + function classifyModuleNonCached(path: string): ModuleTypeClassification { + const matched = matchPatterns(patternTypePairs, (_) => _.pattern, path); + if (matched) return classifications[matched.type]; + return auto; + } + + const classifyModule = cachedLookup(classifyModuleNonCached); + + function classifyModuleAuto(path: String) { + return auto; + } + + return { + classifyModule: patternTypePairs.length + ? classifyModule + : classifyModuleAuto, + }; +} + +function parsePattern(basePath: string, patternString: string): RegExp { + const pattern = getPatternFromSpec(patternString, basePath); + return pattern !== undefined ? new RegExp(pattern) : /(?:)/; +} + +function matchPatterns( + objects: T[], + getPattern: (t: T) => RegExp, + candidate: string +): T | undefined { + for (let i = objects.length - 1; i >= 0; i--) { + const object = objects[i]; + const pattern = getPattern(object); + + if (pattern?.test(candidate)) { + return object; + } + } +} diff --git a/src/resolver-functions.ts b/src/resolver-functions.ts new file mode 100644 index 000000000..d52f09851 --- /dev/null +++ b/src/resolver-functions.ts @@ -0,0 +1,172 @@ +import { resolve } from 'path'; +import type * as _ts from 'typescript'; + +/** + * @internal + * In a factory because these are shared across both CompilerHost and LanguageService codepaths + */ +export function createResolverFunctions(kwargs: { + ts: typeof _ts; + serviceHost: _ts.ModuleResolutionHost; + cwd: string; + getCanonicalFileName: (filename: string) => string; + config: _ts.ParsedCommandLine; + configFilePath: string | undefined; +}) { + const { + serviceHost, + ts, + config, + cwd, + getCanonicalFileName, + configFilePath, + } = kwargs; + const moduleResolutionCache = ts.createModuleResolutionCache( + cwd, + getCanonicalFileName, + config.options + ); + const knownInternalFilenames = new Set(); + /** "Buckets" (module directories) whose contents should be marked "internal" */ + const internalBuckets = new Set(); + + // Get bucket for a source filename. Bucket is the containing `./node_modules/*/` directory + // For '/project/node_modules/foo/node_modules/bar/lib/index.js' bucket is '/project/node_modules/foo/node_modules/bar/' + // For '/project/node_modules/foo/node_modules/@scope/bar/lib/index.js' bucket is '/project/node_modules/foo/node_modules/@scope/bar/' + const moduleBucketRe = /.*\/node_modules\/(?:@[^\/]+\/)?[^\/]+\//; + function getModuleBucket(filename: string) { + const find = moduleBucketRe.exec(filename); + if (find) return find[0]; + return ''; + } + + // Mark that this file and all siblings in its bucket should be "internal" + function markBucketOfFilenameInternal(filename: string) { + internalBuckets.add(getModuleBucket(filename)); + } + + function isFileInInternalBucket(filename: string) { + return internalBuckets.has(getModuleBucket(filename)); + } + + function isFileKnownToBeInternal(filename: string) { + return knownInternalFilenames.has(filename); + } + + /** + * If we need to emit JS for a file, force TS to consider it non-external + */ + const fixupResolvedModule = ( + resolvedModule: _ts.ResolvedModule | _ts.ResolvedTypeReferenceDirective + ) => { + const { resolvedFileName } = resolvedModule; + if (resolvedFileName === undefined) return; + // .ts is always switched to internal + // .js is switched on-demand + if ( + resolvedModule.isExternalLibraryImport && + ((resolvedFileName.endsWith('.ts') && + !resolvedFileName.endsWith('.d.ts')) || + isFileKnownToBeInternal(resolvedFileName) || + isFileInInternalBucket(resolvedFileName)) + ) { + resolvedModule.isExternalLibraryImport = false; + } + if (!resolvedModule.isExternalLibraryImport) { + knownInternalFilenames.add(resolvedFileName); + } + }; + /* + * NOTE: + * Older ts versions do not pass `redirectedReference` nor `options`. + * We must pass `redirectedReference` to newer ts versions, but cannot rely on `options`, hence the weird argument name + */ + const resolveModuleNames: _ts.LanguageServiceHost['resolveModuleNames'] = ( + moduleNames: string[], + containingFile: string, + reusedNames: string[] | undefined, + redirectedReference: _ts.ResolvedProjectReference | undefined, + optionsOnlyWithNewerTsVersions: _ts.CompilerOptions + ): (_ts.ResolvedModule | undefined)[] => { + return moduleNames.map((moduleName) => { + const { resolvedModule } = ts.resolveModuleName( + moduleName, + containingFile, + config.options, + serviceHost, + moduleResolutionCache, + redirectedReference + ); + if (resolvedModule) { + fixupResolvedModule(resolvedModule); + } + return resolvedModule; + }); + }; + + // language service never calls this, but TS docs recommend that we implement it + const getResolvedModuleWithFailedLookupLocationsFromCache: _ts.LanguageServiceHost['getResolvedModuleWithFailedLookupLocationsFromCache'] = ( + moduleName, + containingFile + ): _ts.ResolvedModuleWithFailedLookupLocations | undefined => { + const ret = ts.resolveModuleNameFromCache( + moduleName, + containingFile, + moduleResolutionCache + ); + if (ret && ret.resolvedModule) { + fixupResolvedModule(ret.resolvedModule); + } + return ret; + }; + + const resolveTypeReferenceDirectives: _ts.LanguageServiceHost['resolveTypeReferenceDirectives'] = ( + typeDirectiveNames: string[], + containingFile: string, + redirectedReference: _ts.ResolvedProjectReference | undefined, + options: _ts.CompilerOptions + ): (_ts.ResolvedTypeReferenceDirective | undefined)[] => { + // Note: seems to be called with empty typeDirectiveNames array for all files. + return typeDirectiveNames.map((typeDirectiveName) => { + let { resolvedTypeReferenceDirective } = ts.resolveTypeReferenceDirective( + typeDirectiveName, + containingFile, + config.options, + serviceHost, + redirectedReference + ); + if (typeDirectiveName === 'node' && !resolvedTypeReferenceDirective) { + // Resolve @types/node relative to project first, then __dirname (copy logic from elsewhere / refactor into reusable function) + const typesNodePackageJsonPath = require.resolve( + '@types/node/package.json', + { + paths: [configFilePath ?? cwd, __dirname], + } + ); + const typeRoots = [resolve(typesNodePackageJsonPath, '../..')]; + ({ resolvedTypeReferenceDirective } = ts.resolveTypeReferenceDirective( + typeDirectiveName, + containingFile, + { + ...config.options, + typeRoots, + }, + serviceHost, + redirectedReference + )); + } + if (resolvedTypeReferenceDirective) { + fixupResolvedModule(resolvedTypeReferenceDirective); + } + return resolvedTypeReferenceDirective; + }); + }; + + return { + resolveModuleNames, + getResolvedModuleWithFailedLookupLocationsFromCache, + resolveTypeReferenceDirectives, + isFileKnownToBeInternal, + markBucketOfFilenameInternal, + }; +} diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 98b32a6dc..c8c8a7e4b 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -1459,7 +1459,7 @@ test.suite('ts-node', (test) => { test('should support compiler scope specified via tsconfig.json', async (t) => { const { err, stderr, stdout } = await exec( - `${cmd} --project ./scope/c/config/tsconfig.json ./scope/c/index.js` + `${cmdNoProject} --project ./scope/c/config/tsconfig.json ./scope/c/index.js` ); expect(err).to.equal(null); expect(stdout).to.equal(`value\nFailures: 0\n`); @@ -1737,18 +1737,18 @@ test.suite('ts-node', (test) => { const experimentalModulesFlag = semver.gte(process.version, '12.17.0') ? '' : '--experimental-modules'; - const cmd = `node ${experimentalModulesFlag} --loader ts-node/esm`; + const esmCmd = `node ${experimentalModulesFlag} --loader ts-node/esm`; if (semver.gte(process.version, '12.16.0')) { test('should compile and execute as ESM', async () => { - const { err, stdout } = await exec(`${cmd} index.ts`, { + const { err, stdout } = await exec(`${esmCmd} index.ts`, { cwd: join(TEST_DIR, './esm'), }); expect(err).to.equal(null); expect(stdout).to.equal('foo bar baz biff libfoo\n'); }); test('should use source maps', async () => { - const { err, stdout } = await exec(`${cmd} "throw error.ts"`, { + const { err, stdout } = await exec(`${esmCmd} "throw error.ts"`, { cwd: join(TEST_DIR, './esm'), }); expect(err).not.to.equal(null); @@ -1770,7 +1770,7 @@ test.suite('ts-node', (test) => { err, stdout, } = await exec( - `${cmd} --experimental-specifier-resolution=node index.ts`, + `${esmCmd} --experimental-specifier-resolution=node index.ts`, { cwd: join(TEST_DIR, './esm-node-resolver') } ); expect(err).to.equal(null); @@ -1781,14 +1781,14 @@ test.suite('ts-node', (test) => { err, stdout, } = await exec( - `${cmd} --experimental-modules --es-module-specifier-resolution=node index.ts`, + `${esmCmd} --experimental-modules --es-module-specifier-resolution=node index.ts`, { cwd: join(TEST_DIR, './esm-node-resolver') } ); expect(err).to.equal(null); expect(stdout).to.equal('foo bar baz biff libfoo\n'); }); test('via NODE_OPTIONS', async () => { - const { err, stdout } = await exec(`${cmd} index.ts`, { + const { err, stdout } = await exec(`${esmCmd} index.ts`, { cwd: join(TEST_DIR, './esm-node-resolver'), env: { ...process.env, @@ -1801,7 +1801,7 @@ test.suite('ts-node', (test) => { }); test('throws ERR_REQUIRE_ESM when attempting to require() an ESM script when ESM loader is enabled', async () => { - const { err, stderr } = await exec(`${cmd} ./index.js`, { + const { err, stderr } = await exec(`${esmCmd} ./index.js`, { cwd: join(TEST_DIR, './esm-err-require-esm'), }); expect(err).to.not.equal(null); @@ -1811,7 +1811,7 @@ test.suite('ts-node', (test) => { }); test('defers to fallback loaders when URL should not be handled by ts-node', async () => { - const { err, stdout, stderr } = await exec(`${cmd} index.mjs`, { + const { err, stdout, stderr } = await exec(`${esmCmd} index.mjs`, { cwd: join(TEST_DIR, './esm-import-http-url'), }); expect(err).to.not.equal(null); @@ -1822,7 +1822,7 @@ test.suite('ts-node', (test) => { }); test('should bypass import cache when changing search params', async () => { - const { err, stdout } = await exec(`${cmd} index.ts`, { + const { err, stdout } = await exec(`${esmCmd} index.ts`, { cwd: join(TEST_DIR, './esm-import-cache'), }); expect(err).to.equal(null); @@ -1830,14 +1830,17 @@ test.suite('ts-node', (test) => { }); test('should support transpile only mode via dedicated loader entrypoint', async () => { - const { err, stdout } = await exec(`${cmd}/transpile-only index.ts`, { - cwd: join(TEST_DIR, './esm-transpile-only'), - }); + const { err, stdout } = await exec( + `${esmCmd}/transpile-only index.ts`, + { + cwd: join(TEST_DIR, './esm-transpile-only'), + } + ); expect(err).to.equal(null); expect(stdout).to.equal(''); }); test('should throw type errors without transpile-only enabled', async () => { - const { err, stdout } = await exec(`${cmd} index.ts`, { + const { err, stdout } = await exec(`${esmCmd} index.ts`, { cwd: join(TEST_DIR, './esm-transpile-only'), }); if (err === null) { @@ -1857,6 +1860,41 @@ test.suite('ts-node', (test) => { ); expect(stdout).to.equal(''); }); + + async function runModuleTypeTest(project: string, ext: string) { + const { err, stderr, stdout } = await exec( + `${esmCmd} ./module-types/${project}/test.${ext}`, + { + env: { + ...process.env, + TS_NODE_PROJECT: `./module-types/${project}/tsconfig.json`, + }, + } + ); + expect(err).to.equal(null); + expect(stdout).to.equal(`Failures: 0\n`); + } + + test('moduleTypes should allow importing CJS in an otherwise ESM project', async (t) => { + // A notable case where you can use ts-node's CommonJS loader, not the ESM loader, in an ESM project: + // when loading a webpack.config.ts or similar config + const { err, stderr, stdout } = await exec( + `${cmdNoProject} --project ./module-types/override-to-cjs/tsconfig.json ./module-types/override-to-cjs/test-webpack-config.cjs` + ); + expect(err).to.equal(null); + expect(stdout).to.equal(``); + + await runModuleTypeTest('override-to-cjs', 'cjs'); + if (semver.gte(process.version, '14.13.1')) + await runModuleTypeTest('override-to-cjs', 'mjs'); + }); + + test('moduleTypes should allow importing ESM in an otherwise CJS project', async (t) => { + await runModuleTypeTest('override-to-esm', 'cjs'); + // Node 14.13.0 has a bug(?) where it checks for ESM-only syntax *before* we transform the code. + if (semver.gte(process.version, '14.13.1')) + await runModuleTypeTest('override-to-esm', 'mjs'); + }); } if (semver.gte(process.version, '12.0.0')) { diff --git a/src/ts-internals.ts b/src/ts-internals.ts index 47a75adae..e438c812c 100644 --- a/src/ts-internals.ts +++ b/src/ts-internals.ts @@ -3,11 +3,16 @@ import { cachedLookup, normalizeSlashes } from './util'; import type * as _ts from 'typescript'; import type { TSCommon, TSInternal } from './ts-compiler-types'; +/** @internal */ export const createTsInternals = cachedLookup(createTsInternalsUncached); /** * Given a reference to the TS compiler, return some TS internal functions that we * could not or did not want to grab off the `ts` object. * These have been copy-pasted from TS's source and tweaked as necessary. + * + * NOTE: This factory returns *only* functions which need a reference to the TS + * compiler. Other functions do not need a reference to the TS compiler so are + * exported directly from this file. */ function createTsInternalsUncached(_ts: TSCommon) { const ts = _ts as TSCommon & TSInternal; @@ -68,36 +73,327 @@ function createTsInternalsUncached(_ts: TSCommon) { return undefined; } - function startsWith(str: string, prefix: string): boolean { - return str.lastIndexOf(prefix, 0) === 0; + return { getExtendsConfigPath }; +} + +// These functions have alternative implementation to avoid copying too much from TS +function isRootedDiskPath(path: string) { + return isAbsolute(path); +} +function combinePaths(path: string, ...paths: (string | undefined)[]): string { + return normalizeSlashes( + resolve(path, ...(paths.filter((path) => path) as string[])) + ); +} +function getNormalizedAbsolutePath( + fileName: string, + currentDirectory: string | undefined +) { + return normalizeSlashes( + currentDirectory != null + ? resolve(currentDirectory!, fileName) + : resolve(fileName) + ); +} + +function startsWith(str: string, prefix: string): boolean { + return str.lastIndexOf(prefix, 0) === 0; +} + +function endsWith(str: string, suffix: string): boolean { + const expectedPos = str.length - suffix.length; + return expectedPos >= 0 && str.indexOf(suffix, expectedPos) === expectedPos; +} +// Reserved characters, forces escaping of any non-word (or digit), non-whitespace character. +// It may be inefficient (we could just match (/[-[\]{}()*+?.,\\^$|#\s]/g), but this is future +// proof. +const reservedCharacterPattern = /[^\w\s\/]/g; + +/** + * @internal + * See also: getRegularExpressionForWildcard, which seems to do almost the same thing + */ +export function getPatternFromSpec(spec: string, basePath: string) { + const pattern = spec && getSubPatternFromSpec(spec, basePath, excludeMatcher); + return pattern && `^(${pattern})${'($|/)'}`; +} +function getSubPatternFromSpec( + spec: string, + basePath: string, + { + singleAsteriskRegexFragment, + doubleAsteriskRegexFragment, + replaceWildcardCharacter, + }: WildcardMatcher +): string { + let subpattern = ''; + let hasWrittenComponent = false; + const components = getNormalizedPathComponents(spec, basePath); + const lastComponent = last(components); + + // getNormalizedPathComponents includes the separator for the root component. + // We need to remove to create our regex correctly. + components[0] = removeTrailingDirectorySeparator(components[0]); + + if (isImplicitGlob(lastComponent)) { + components.push('**', '*'); } - function endsWith(str: string, suffix: string): boolean { - const expectedPos = str.length - suffix.length; - return expectedPos >= 0 && str.indexOf(suffix, expectedPos) === expectedPos; + + let optionalCount = 0; + for (let component of components) { + if (component === '**') { + subpattern += doubleAsteriskRegexFragment; + } else { + if (hasWrittenComponent) { + subpattern += directorySeparator; + } + subpattern += component.replace( + reservedCharacterPattern, + replaceWildcardCharacter + ); + } + + hasWrittenComponent = true; } - // These functions have alternative implementation to avoid copying too much from TS - function isRootedDiskPath(path: string) { - return isAbsolute(path); + while (optionalCount > 0) { + subpattern += ')?'; + optionalCount--; } - function combinePaths( - path: string, - ...paths: (string | undefined)[] - ): string { - return normalizeSlashes( - resolve(path, ...(paths.filter((path) => path) as string[])) - ); + + return subpattern; +} +interface WildcardMatcher { + singleAsteriskRegexFragment: string; + doubleAsteriskRegexFragment: string; + replaceWildcardCharacter: (match: string) => string; +} +const directoriesMatcher: WildcardMatcher = { + singleAsteriskRegexFragment: '[^/]*', + /** + * Regex for the ** wildcard. Matches any num of subdirectories. When used for including + * files or directories, does not match subdirectories that start with a . character + */ + doubleAsteriskRegexFragment: `(/[^/.][^/]*)*?`, + replaceWildcardCharacter: (match) => + replaceWildcardCharacter( + match, + directoriesMatcher.singleAsteriskRegexFragment + ), +}; +const excludeMatcher: WildcardMatcher = { + singleAsteriskRegexFragment: '[^/]*', + doubleAsteriskRegexFragment: '(/.+?)?', + replaceWildcardCharacter: (match) => + replaceWildcardCharacter(match, excludeMatcher.singleAsteriskRegexFragment), +}; +function getNormalizedPathComponents( + path: string, + currentDirectory: string | undefined +) { + return reducePathComponents(getPathComponents(path, currentDirectory)); +} +function getPathComponents(path: string, currentDirectory = '') { + path = combinePaths(currentDirectory, path); + return pathComponents(path, getRootLength(path)); +} +function reducePathComponents(components: readonly string[]) { + if (!some(components)) return []; + const reduced = [components[0]]; + for (let i = 1; i < components.length; i++) { + const component = components[i]; + if (!component) continue; + if (component === '.') continue; + if (component === '..') { + if (reduced.length > 1) { + if (reduced[reduced.length - 1] !== '..') { + reduced.pop(); + continue; + } + } else if (reduced[0]) continue; + } + reduced.push(component); } - function getNormalizedAbsolutePath( - fileName: string, - currentDirectory: string | undefined - ) { - return normalizeSlashes( - currentDirectory != null - ? resolve(currentDirectory!, fileName) - : resolve(fileName) + return reduced; +} +function getRootLength(path: string) { + const rootLength = getEncodedRootLength(path); + return rootLength < 0 ? ~rootLength : rootLength; +} +function getEncodedRootLength(path: string): number { + if (!path) return 0; + const ch0 = path.charCodeAt(0); + + // POSIX or UNC + if (ch0 === CharacterCodes.slash || ch0 === CharacterCodes.backslash) { + if (path.charCodeAt(1) !== ch0) return 1; // POSIX: "/" (or non-normalized "\") + + const p1 = path.indexOf( + ch0 === CharacterCodes.slash ? directorySeparator : altDirectorySeparator, + 2 ); + if (p1 < 0) return path.length; // UNC: "//server" or "\\server" + + return p1 + 1; // UNC: "//server/" or "\\server\" } - return { getExtendsConfigPath }; + // DOS + if (isVolumeCharacter(ch0) && path.charCodeAt(1) === CharacterCodes.colon) { + const ch2 = path.charCodeAt(2); + if (ch2 === CharacterCodes.slash || ch2 === CharacterCodes.backslash) + return 3; // DOS: "c:/" or "c:\" + if (path.length === 2) return 2; // DOS: "c:" (but not "c:d") + } + + // URL + const schemeEnd = path.indexOf(urlSchemeSeparator); + if (schemeEnd !== -1) { + const authorityStart = schemeEnd + urlSchemeSeparator.length; + const authorityEnd = path.indexOf(directorySeparator, authorityStart); + if (authorityEnd !== -1) { + // URL: "file:///", "file://server/", "file://server/path" + // For local "file" URLs, include the leading DOS volume (if present). + // Per https://www.ietf.org/rfc/rfc1738.txt, a host of "" or "localhost" is a + // special case interpreted as "the machine from which the URL is being interpreted". + const scheme = path.slice(0, schemeEnd); + const authority = path.slice(authorityStart, authorityEnd); + if ( + scheme === 'file' && + (authority === '' || authority === 'localhost') && + isVolumeCharacter(path.charCodeAt(authorityEnd + 1)) + ) { + const volumeSeparatorEnd = getFileUrlVolumeSeparatorEnd( + path, + authorityEnd + 2 + ); + if (volumeSeparatorEnd !== -1) { + if (path.charCodeAt(volumeSeparatorEnd) === CharacterCodes.slash) { + // URL: "file:///c:/", "file://localhost/c:/", "file:///c%3a/", "file://localhost/c%3a/" + return ~(volumeSeparatorEnd + 1); + } + if (volumeSeparatorEnd === path.length) { + // URL: "file:///c:", "file://localhost/c:", "file:///c$3a", "file://localhost/c%3a" + // but not "file:///c:d" or "file:///c%3ad" + return ~volumeSeparatorEnd; + } + } + } + return ~(authorityEnd + 1); // URL: "file://server/", "http://server/" + } + return ~path.length; // URL: "file://server", "http://server" + } + + // relative + return 0; +} +function ensureTrailingDirectorySeparator(path: string) { + if (!hasTrailingDirectorySeparator(path)) { + return path + directorySeparator; + } + + return path; +} +function hasTrailingDirectorySeparator(path: string) { + return ( + path.length > 0 && isAnyDirectorySeparator(path.charCodeAt(path.length - 1)) + ); +} +function isAnyDirectorySeparator(charCode: number): boolean { + return ( + charCode === CharacterCodes.slash || charCode === CharacterCodes.backslash + ); +} +function removeTrailingDirectorySeparator(path: string) { + if (hasTrailingDirectorySeparator(path)) { + return path.substr(0, path.length - 1); + } + + return path; +} +const directorySeparator = '/'; +const altDirectorySeparator = '\\'; +const urlSchemeSeparator = '://'; +function isVolumeCharacter(charCode: number) { + return ( + (charCode >= CharacterCodes.a && charCode <= CharacterCodes.z) || + (charCode >= CharacterCodes.A && charCode <= CharacterCodes.Z) + ); +} +function getFileUrlVolumeSeparatorEnd(url: string, start: number) { + const ch0 = url.charCodeAt(start); + if (ch0 === CharacterCodes.colon) return start + 1; + if ( + ch0 === CharacterCodes.percent && + url.charCodeAt(start + 1) === CharacterCodes._3 + ) { + const ch2 = url.charCodeAt(start + 2); + if (ch2 === CharacterCodes.a || ch2 === CharacterCodes.A) return start + 3; + } + return -1; +} +function some(array: readonly T[] | undefined): array is readonly T[]; +function some( + array: readonly T[] | undefined, + predicate: (value: T) => boolean +): boolean; +function some( + array: readonly T[] | undefined, + predicate?: (value: T) => boolean +): boolean { + if (array) { + if (predicate) { + for (const v of array) { + if (predicate(v)) { + return true; + } + } + } else { + return array.length > 0; + } + } + return false; +} +/* @internal */ +const enum CharacterCodes { + _3 = 0x33, + a = 0x61, + z = 0x7a, + A = 0x41, + Z = 0x5a, + asterisk = 0x2a, // * + backslash = 0x5c, // \ + colon = 0x3a, // : + percent = 0x25, // % + question = 0x3f, // ? + slash = 0x2f, // / +} +function pathComponents(path: string, rootLength: number) { + const root = path.substring(0, rootLength); + const rest = path.substring(rootLength).split(directorySeparator); + if (rest.length && !lastOrUndefined(rest)) rest.pop(); + return [root, ...rest]; +} +function lastOrUndefined(array: readonly T[]): T | undefined { + return array.length === 0 ? undefined : array[array.length - 1]; +} +function last(array: readonly T[]): T { + // Debug.assert(array.length !== 0); + return array[array.length - 1]; +} +function replaceWildcardCharacter( + match: string, + singleAsteriskRegexFragment: string +) { + return match === '*' + ? singleAsteriskRegexFragment + : match === '?' + ? '[^/]' + : '\\' + match; +} +/** + * An "includes" path "foo" is implicitly a glob "foo/** /*" (without the space) if its last component has no extension, + * and does not contain any glob characters itself. + */ +function isImplicitGlob(lastPathComponent: string): boolean { + return !/[.*?]/.test(lastPathComponent); } diff --git a/tests/module-types/override-to-cjs/package.json b/tests/module-types/override-to-cjs/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/module-types/override-to-cjs/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/module-types/override-to-cjs/src/cjs-subdir/esm-exception.ts b/tests/module-types/override-to-cjs/src/cjs-subdir/esm-exception.ts new file mode 100644 index 000000000..f93bf43df --- /dev/null +++ b/tests/module-types/override-to-cjs/src/cjs-subdir/esm-exception.ts @@ -0,0 +1,2 @@ +declare const require: any; +export const requireType = typeof require; diff --git a/tests/module-types/override-to-cjs/src/cjs-subdir/index.ts b/tests/module-types/override-to-cjs/src/cjs-subdir/index.ts new file mode 100644 index 000000000..f93bf43df --- /dev/null +++ b/tests/module-types/override-to-cjs/src/cjs-subdir/index.ts @@ -0,0 +1,2 @@ +declare const require: any; +export const requireType = typeof require; diff --git a/tests/module-types/override-to-cjs/src/should-be-esm.ts b/tests/module-types/override-to-cjs/src/should-be-esm.ts new file mode 100644 index 000000000..866503d04 --- /dev/null +++ b/tests/module-types/override-to-cjs/src/should-be-esm.ts @@ -0,0 +1,3 @@ +export const a: string = 'b'; +declare const require: any; +export const requireType = typeof require; diff --git a/tests/module-types/override-to-cjs/test-webpack-config.cjs b/tests/module-types/override-to-cjs/test-webpack-config.cjs new file mode 100644 index 000000000..b14389f1c --- /dev/null +++ b/tests/module-types/override-to-cjs/test-webpack-config.cjs @@ -0,0 +1,3 @@ +const assert = require('assert'); + +assert(require('./webpack.config').hello === 'world'); diff --git a/tests/module-types/override-to-cjs/test.cjs b/tests/module-types/override-to-cjs/test.cjs new file mode 100644 index 000000000..d8c2328a4 --- /dev/null +++ b/tests/module-types/override-to-cjs/test.cjs @@ -0,0 +1,25 @@ +const assert = require('assert'); + +const wpc = require('./webpack.config.ts'); +assert(wpc.hello === 'world'); + +let failures = 0; + +try { + require('./src/should-be-esm.ts'); + failures++; +} catch (e) { + // good +} + +const cjsSubdir = require('./src/cjs-subdir'); +assert(cjsSubdir.requireType === 'function'); + +try { + require('./src/cjs-subdir/esm-exception.ts'); + failures++; +} catch (e) { + // good +} + +console.log(`Failures: ${failures}`); diff --git a/tests/module-types/override-to-cjs/test.mjs b/tests/module-types/override-to-cjs/test.mjs new file mode 100644 index 000000000..b5183df06 --- /dev/null +++ b/tests/module-types/override-to-cjs/test.mjs @@ -0,0 +1,13 @@ +import assert from 'assert'; + +import webpackConfig from './webpack.config.ts'; +import * as shouldBeEsm from './src/should-be-esm.ts'; +import subdirCjs from './src/cjs-subdir/index.ts'; +import * as subdirEsm from './src/cjs-subdir/esm-exception.ts'; + +assert(webpackConfig.hello === 'world'); +assert(shouldBeEsm.requireType === 'undefined'); +assert(subdirCjs.requireType === 'function'); +assert(subdirEsm.requireType === 'undefined'); + +console.log(`Failures: 0`); diff --git a/tests/module-types/override-to-cjs/tsconfig.json b/tests/module-types/override-to-cjs/tsconfig.json new file mode 100644 index 000000000..f8d81b073 --- /dev/null +++ b/tests/module-types/override-to-cjs/tsconfig.json @@ -0,0 +1,14 @@ +{ + "ts-node": { + "moduleTypes": { + "webpack.config.ts": "cjs", + // Test that subsequent patterns override earlier ones + "src/cjs-subdir/**/*": "esm", + "src/cjs-subdir": "cjs", + "src/cjs-subdir/esm-exception.ts": "esm" + } + }, + "compilerOptions": { + "module": "ES2015" + } +} diff --git a/tests/module-types/override-to-cjs/webpack.config.ts b/tests/module-types/override-to-cjs/webpack.config.ts new file mode 100644 index 000000000..afa980a1c --- /dev/null +++ b/tests/module-types/override-to-cjs/webpack.config.ts @@ -0,0 +1 @@ +export const hello: string = 'world'; diff --git a/tests/module-types/override-to-esm/package.json b/tests/module-types/override-to-esm/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/tests/module-types/override-to-esm/package.json @@ -0,0 +1 @@ +{} diff --git a/tests/module-types/override-to-esm/src/esm-subdir/cjs-exception.ts b/tests/module-types/override-to-esm/src/esm-subdir/cjs-exception.ts new file mode 100644 index 000000000..f93bf43df --- /dev/null +++ b/tests/module-types/override-to-esm/src/esm-subdir/cjs-exception.ts @@ -0,0 +1,2 @@ +declare const require: any; +export const requireType = typeof require; diff --git a/tests/module-types/override-to-esm/src/esm-subdir/index.ts b/tests/module-types/override-to-esm/src/esm-subdir/index.ts new file mode 100644 index 000000000..f93bf43df --- /dev/null +++ b/tests/module-types/override-to-esm/src/esm-subdir/index.ts @@ -0,0 +1,2 @@ +declare const require: any; +export const requireType = typeof require; diff --git a/tests/module-types/override-to-esm/src/should-be-cjs.ts b/tests/module-types/override-to-esm/src/should-be-cjs.ts new file mode 100644 index 000000000..866503d04 --- /dev/null +++ b/tests/module-types/override-to-esm/src/should-be-cjs.ts @@ -0,0 +1,3 @@ +export const a: string = 'b'; +declare const require: any; +export const requireType = typeof require; diff --git a/tests/module-types/override-to-esm/test.cjs b/tests/module-types/override-to-esm/test.cjs new file mode 100644 index 000000000..aa321d258 --- /dev/null +++ b/tests/module-types/override-to-esm/test.cjs @@ -0,0 +1,18 @@ +const assert = require('assert'); + +let failures = 0; + +const shouldBeCjs = require('./src/should-be-cjs.ts'); +assert(shouldBeCjs.requireType === 'function'); + +try { + require('./src/esm-subdir'); + failures++; +} catch (e) { + // good +} + +const cjsException = require('./src/esm-subdir/cjs-exception.ts'); +assert(cjsException.requireType === 'function'); + +console.log(`Failures: ${failures}`); diff --git a/tests/module-types/override-to-esm/test.mjs b/tests/module-types/override-to-esm/test.mjs new file mode 100644 index 000000000..c1773964d --- /dev/null +++ b/tests/module-types/override-to-esm/test.mjs @@ -0,0 +1,11 @@ +import assert from 'assert'; + +import shouldBeCjs from './src/should-be-cjs.ts'; +import * as subdirEsm from './src/esm-subdir/index.ts'; +import subdirCjs from './src/esm-subdir/cjs-exception.ts'; + +assert(shouldBeCjs.requireType === 'function'); +assert(subdirEsm.requireType === 'undefined'); +assert(subdirCjs.requireType === 'function'); + +console.log(`Failures: 0`); diff --git a/tests/module-types/override-to-esm/tsconfig.json b/tests/module-types/override-to-esm/tsconfig.json new file mode 100644 index 000000000..ae239f1f8 --- /dev/null +++ b/tests/module-types/override-to-esm/tsconfig.json @@ -0,0 +1,13 @@ +{ + "ts-node": { + "moduleTypes": { + // Test that subsequent patterns override earlier ones + "src/esm-subdir/**/*": "cjs", + "src/esm-subdir": "esm", + "src/esm-subdir/cjs-exception.ts": "cjs" + } + }, + "compilerOptions": { + "module": "CommonJS" + } +} diff --git a/website/docs/module-type-overrides.md b/website/docs/module-type-overrides.md new file mode 100644 index 000000000..0cfd8e6a5 --- /dev/null +++ b/website/docs/module-type-overrides.md @@ -0,0 +1,48 @@ +--- +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. + +The following example tells `ts-node` to execute a webpack config as CommonJS: + +```json title=tsconfig.json +{ + "ts-node": { + "transpileOnly": true, + "moduleTypes": { + "webpack.config.ts": "cjs", + // Globs are also supported with the same behavior as tsconfig "include" + "webpack-config-scripts/**/*": "cjs" + } + }, + "compilerOptions": { + "module": "es2020", + "target": "es2020" + } +} +``` + +Each key is a glob pattern with the same syntax as tsconfig's `"include"` array. +When multiple patterns match the same file, the last pattern takes precedence. + +* `cjs` overrides matches files to compile and execute as CommonJS. +* `esm` overrides matches files to compile and execute as native ECMAScript modules. +* `package` resets either of the above to default behavior, which obeys `package.json` `"type"` and `tsconfig.json` `"module"` options. + +## Caveats + +Files with an overridden module type are transformed with the same limitations as [`isolatedModules`](https://www.typescriptlang.org/tsconfig#isolatedModules). This will only affect rare cases such as using `const enum`s with [`preserveConstEnums`](https://www.typescriptlang.org/tsconfig#preserveConstEnums) disabled. + +This feature is meant to faciliate scenarios where normal `compilerOptions` and `package.json` configuration is not possible. For example, a `webpack.config.ts` cannot be given its own `package.json` to override `"type"`. Wherever possible you should favor using traditional `package.json` and `tsconfig.json` configurations. diff --git a/website/docs/options.md b/website/docs/options.md index 7548ac47a..8570528e3 100644 --- a/website/docs/options.md +++ b/website/docs/options.md @@ -51,6 +51,7 @@ _Environment variables, where available, are in `ALL_CAPS`_ - `--emit` Emit output files into `.ts-node` directory
*Default:* `false`
*Environment:* `TS_NODE_EMIT` - `--scope` Scope compiler to files within `scopeDir`. Anything outside this directory is ignored.
*Default: `false`
*Environment:* `TS_NODE_SCOPE` - `--scopeDir` Directory within which compiler is limited when `scope` is enabled.
*Default:* First of: `tsconfig.json` "rootDir" if specified, directory containing `tsconfig.json`, or cwd if no `tsconfig.json` is loaded.
*Environment:* `TS_NODE_SCOPE_DIR` +- `moduleType` Override the module type of certain files, ignoring the `package.json` `"type"` field. See [Module type overrides](./module-type-overrides.md) for details.
*Default:* obeys `package.json` `"type"` and `tsconfig.json` `"module"`
*Can only be specified via `tsconfig.json` or API.* - `TS_NODE_HISTORY` Path to history file for REPL
*Default:* `~/.ts_node_repl_history`
## API diff --git a/website/sidebars.js b/website/sidebars.js index cda797347..4f1d70e01 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -22,7 +22,8 @@ module.exports = { 'paths', 'types', 'compilers', - 'transpilers' + 'transpilers', + 'module-type-overrides' ], }, { type: 'category',