From 5c4e3657022a98b8abe58d4ae00f01dc2b1c1182 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 13 Apr 2020 01:45:41 -0400 Subject: [PATCH 01/16] Initial commit --- dist-raw/node-esm-resolve-implementation.js | 728 ++++++++++++++++++++ esm-usage-example/README.md | 7 + esm-usage-example/bar.ts | 1 + esm-usage-example/foo.ts | 3 + esm-usage-example/index.js | 5 + esm-usage-example/package.json | 3 + esm-usage-example/tsconfig.json | 5 + esm.mjs | 5 + package.json | 5 + src/esm.ts | 107 +++ src/index.ts | 101 ++- 11 files changed, 940 insertions(+), 30 deletions(-) create mode 100644 dist-raw/node-esm-resolve-implementation.js create mode 100644 esm-usage-example/README.md create mode 100644 esm-usage-example/bar.ts create mode 100644 esm-usage-example/foo.ts create mode 100644 esm-usage-example/index.js create mode 100644 esm-usage-example/package.json create mode 100644 esm-usage-example/tsconfig.json create mode 100644 esm.mjs create mode 100644 src/esm.ts diff --git a/dist-raw/node-esm-resolve-implementation.js b/dist-raw/node-esm-resolve-implementation.js new file mode 100644 index 000000000..b9217cb9b --- /dev/null +++ b/dist-raw/node-esm-resolve-implementation.js @@ -0,0 +1,728 @@ +// Copied from https://raw.githubusercontent.com/nodejs/node/v13.12.0/lib/internal/modules/esm/resolve.js +// Then modified to suite our needs. +// Formatting is intentionally bad to keep the diff as small as possible, to make it easier to merge +// upstream changes and understand our modifications. +'use strict'; + +const { + ArrayIsArray, + JSONParse, + JSONStringify, + ObjectGetOwnPropertyNames, + ObjectPrototypeHasOwnProperty, + SafeMap, + StringPrototypeEndsWith, + StringPrototypeIncludes, + StringPrototypeIndexOf, + StringPrototypeSlice, + StringPrototypeStartsWith, + StringPrototypeSubstr, +} = { + ArrayIsArray: Array.isArray, + JSONParse: JSON.parse, + JSONStringify: JSON.stringify, + ObjectGetOwnPropertyNames: Object.getOwnPropertyNames, + ObjectPrototypeHasOwnProperty: (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop), + SafeMap: Map, + StringPrototypeEndsWith: (str, ...rest) => String.prototype.endsWith.apply(str, rest), + StringPrototypeIncludes: (str, ...rest) => String.prototype.includes.apply(str, rest), + StringPrototypeIndexOf: (str, ...rest) => String.prototype.indexOf.apply(str, rest), + StringPrototypeSlice: (str, ...rest) => String.prototype.slice.apply(str, rest), + StringPrototypeStartsWith: (str, ...rest) => String.prototype.startsWith.apply(str, rest), + StringPrototypeSubstr: (str, ...rest) => String.prototype.substr.apply(str, rest), +} // node pulls from `primordials` object + +// const internalFS = require('internal/fs/utils'); +// const { NativeModule } = require('internal/bootstrap/loaders'); +const Module = require('module') +const NativeModule = { + canBeRequiredByUsers(specifier) { + return Module.builtinModules.includes(specifier) + } +} +const { + closeSync, + fstatSync, + openSync, + readFileSync, + realpathSync, + statSync, + Stats, +} = require('fs'); +// const { getOptionValue } = require('internal/options'); +const { getOptionValue } = { + getOptionValue: (opt) => { + return ({ + '--preserve-symlinks': false, + '--preserve-symlinks-main': false, + '--input-type': undefined, + '--experimental-specifier-resolution': 'explicit' + })[opt] + } +} +const { sep } = require('path'); + +const preserveSymlinks = getOptionValue('--preserve-symlinks'); +const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main'); +const typeFlag = getOptionValue('--input-type'); +// const { URL, pathToFileURL, fileURLToPath } = require('internal/url'); +const { URL, pathToFileURL, fileURLToPath } = require('url'); +const { + ERR_INPUT_TYPE_NOT_ALLOWED, + ERR_INVALID_MODULE_SPECIFIER, + ERR_INVALID_PACKAGE_CONFIG, + ERR_INVALID_PACKAGE_TARGET, + ERR_MODULE_NOT_FOUND, + ERR_PACKAGE_PATH_NOT_EXPORTED, + ERR_UNSUPPORTED_ESM_URL_SCHEME, +// } = require('internal/errors').codes; +} = { + ERR_INPUT_TYPE_NOT_ALLOWED: createErrorCtor('ERR_INPUT_TYPE_NOT_ALLOWED'), + ERR_INVALID_MODULE_SPECIFIER: createErrorCtor('ERR_INVALID_MODULE_SPECIFIER'), + ERR_INVALID_PACKAGE_CONFIG: createErrorCtor('ERR_INVALID_PACKAGE_CONFIG'), + ERR_INVALID_PACKAGE_TARGET: createErrorCtor('ERR_INVALID_PACKAGE_TARGET'), + ERR_MODULE_NOT_FOUND: createErrorCtor('ERR_MODULE_NOT_FOUND'), + ERR_PACKAGE_PATH_NOT_EXPORTED: createErrorCtor('ERR_PACKAGE_PATH_NOT_EXPORTED'), + ERR_UNSUPPORTED_ESM_URL_SCHEME: createErrorCtor('ERR_UNSUPPORTED_ESM_URL_SCHEME'), +} +function createErrorCtor(name) { + return class CustomError extends Error { + constructor(...args) { + super([name, ...args].join(' ')) + } + } +} + +function createResolve(opts) { +const {tsExtensions, jsExtensions, preferTsExts} = opts; + +const realpathCache = new SafeMap(); +const packageJSONCache = new SafeMap(); /* string -> PackageConfig */ + +function tryStatSync(path) { + try { + return statSync(path); + } catch { + return new Stats(); + } +} + +function readIfFile(path) { + let fd; + try { + fd = openSync(path, 'r'); + } catch { + return undefined; + } + try { + if (!fstatSync(fd).isFile()) return undefined; + return readFileSync(fd, 'utf8'); + } finally { + closeSync(fd); + } +} + +function getPackageConfig(path, base) { + const existing = packageJSONCache.get(path); + if (existing !== undefined) { + if (!existing.isValid) { + throw new ERR_INVALID_PACKAGE_CONFIG(path, fileURLToPath(base), false); + } + return existing; + } + + const source = readIfFile(path); + if (source === undefined) { + const packageConfig = { + exists: false, + main: undefined, + name: undefined, + isValid: true, + type: 'none', + exports: undefined + }; + packageJSONCache.set(path, packageConfig); + return packageConfig; + } + + let packageJSON; + try { + packageJSON = JSONParse(source); + } catch { + const packageConfig = { + exists: true, + main: undefined, + name: undefined, + isValid: false, + type: 'none', + exports: undefined + }; + packageJSONCache.set(path, packageConfig); + return packageConfig; + } + + let { main, name, type } = packageJSON; + const { exports } = packageJSON; + if (typeof main !== 'string') main = undefined; + if (typeof name !== 'string') name = undefined; + // Ignore unknown types for forwards compatibility + if (type !== 'module' && type !== 'commonjs') type = 'none'; + + const packageConfig = { + exists: true, + main, + name, + isValid: true, + type, + exports + }; + packageJSONCache.set(path, packageConfig); + return packageConfig; +} + +function getPackageScopeConfig(resolved, base) { + let packageJSONUrl = new URL('./package.json', resolved); + while (true) { + const packageJSONPath = packageJSONUrl.pathname; + if (StringPrototypeEndsWith(packageJSONPath, 'node_modules/package.json')) + break; + const packageConfig = getPackageConfig(fileURLToPath(packageJSONUrl), base); + if (packageConfig.exists) return packageConfig; + + const lastPackageJSONUrl = packageJSONUrl; + packageJSONUrl = new URL('../package.json', packageJSONUrl); + + // Terminates at root where ../package.json equals ../../package.json + // (can't just check "/package.json" for Windows support). + if (packageJSONUrl.pathname === lastPackageJSONUrl.pathname) break; + } + const packageConfig = { + exists: false, + main: undefined, + name: undefined, + isValid: true, + type: 'none', + exports: undefined + }; + packageJSONCache.set(fileURLToPath(packageJSONUrl), packageConfig); + return packageConfig; +} + +/* + * Legacy CommonJS main resolution: + * 1. let M = pkg_url + (json main field) + * 2. TRY(M, M.js, M.json, M.node) + * 3. TRY(M/index.js, M/index.json, M/index.node) + * 4. TRY(pkg_url/index.js, pkg_url/index.json, pkg_url/index.node) + * 5. NOT_FOUND + */ +function fileExists(url) { + return tryStatSync(fileURLToPath(url)).isFile(); +} + +function legacyMainResolve(packageJSONUrl, packageConfig) { + let guess; + if (packageConfig.main !== undefined) { + // Note: fs check redundances will be handled by Descriptor cache here. + if (fileExists(guess = new URL(`./${packageConfig.main}`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}.js`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}.json`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}.node`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}/index.js`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}/index.json`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}/index.node`, + packageJSONUrl))) { + return guess; + } + // Fallthrough. + } + if (fileExists(guess = new URL('./index.js', packageJSONUrl))) { + return guess; + } + // So fs. + if (fileExists(guess = new URL('./index.json', packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL('./index.node', packageJSONUrl))) { + return guess; + } + // Not found. + return undefined; +} + +function resolveExtensionsWithTryExactName(search) { + if (fileExists(search)) return search; + return resolveExtensions(search); +} + +const extensions = Array.from(new Set([ + ...(preferTsExts ? tsExtensions : []), + ...jsExtensions, + '.json', '.node', '.mjs', + ...tsExtensions +])); +function resolveExtensions(search) { + for (let i = 0; i < extensions.length; i++) { + const extension = extensions[i]; + const guess = new URL(`${search.pathname}${extension}`, search); + if (fileExists(guess)) return guess; + } + return undefined; +} + +// function resolveIndex(search) { +// return resolveExtensions(new URL('index', search)); +// } + +function finalizeResolution(resolved, base) { + // if (getOptionValue('--experimental-specifier-resolution') === 'node') { + // let file = resolveExtensionsWithTryExactName(resolved); + // if (file !== undefined) return file; + // if (!StringPrototypeEndsWith(resolved.pathname, '/')) { + // file = resolveIndex(new URL(`${resolved.pathname}/`, base)); + // } else { + // file = resolveIndex(resolved); + // } + // if (file !== undefined) return file; + // throw new ERR_MODULE_NOT_FOUND( + // resolved.pathname, fileURLToPath(base), 'module'); + // } + + let file = resolveExtensionsWithTryExactName(resolved); + if (file !== undefined) return file; + + if (StringPrototypeEndsWith(resolved.pathname, '/')) return resolved; + const path = fileURLToPath(resolved); + + if (!tryStatSync(path).isFile()) { + throw new ERR_MODULE_NOT_FOUND( + path || resolved.pathname, fileURLToPath(base), 'module'); + } + + return resolved; +} + +function throwExportsNotFound(subpath, packageJSONUrl, base) { + throw new ERR_PACKAGE_PATH_NOT_EXPORTED( + fileURLToPath(packageJSONUrl), subpath, fileURLToPath(base)); +} + +function throwSubpathInvalid(subpath, packageJSONUrl, base) { + throw new ERR_INVALID_MODULE_SPECIFIER( + fileURLToPath(packageJSONUrl), subpath, fileURLToPath(base)); +} + +function throwExportsInvalid( + subpath, target, packageJSONUrl, base) { + if (typeof target === 'object' && target !== null) { + target = JSONStringify(target, null, ''); + } else if (ArrayIsArray(target)) { + target = `[${target}]`; + } else { + target = `${target}`; + } + throw new ERR_INVALID_PACKAGE_TARGET( + fileURLToPath(packageJSONUrl), null, subpath, target, fileURLToPath(base)); +} + +function resolveExportsTargetString( + target, subpath, match, packageJSONUrl, base) { + if (target[0] !== '.' || target[1] !== '/' || + (subpath !== '' && target[target.length - 1] !== '/')) { + throwExportsInvalid(match, target, packageJSONUrl, base); + } + + const resolved = new URL(target, packageJSONUrl); + const resolvedPath = resolved.pathname; + const packagePath = new URL('.', packageJSONUrl).pathname; + + if (!StringPrototypeStartsWith(resolvedPath, packagePath) || + StringPrototypeIncludes( + resolvedPath, '/node_modules/', packagePath.length - 1)) { + throwExportsInvalid(match, target, packageJSONUrl, base); + } + + if (subpath === '') return resolved; + const subpathResolved = new URL(subpath, resolved); + const subpathResolvedPath = subpathResolved.pathname; + if (!StringPrototypeStartsWith(subpathResolvedPath, resolvedPath) || + StringPrototypeIncludes(subpathResolvedPath, + '/node_modules/', packagePath.length - 1)) { + throwSubpathInvalid(match + subpath, packageJSONUrl, base); + } + return subpathResolved; +} + +function isArrayIndex(key /* string */) { /* -> boolean */ + const keyNum = +key; + if (`${keyNum}` !== key) return false; + return keyNum >= 0 && keyNum < 0xFFFF_FFFF; +} + +function resolveExportsTarget( + packageJSONUrl, target, subpath, packageSubpath, base) { + if (typeof target === 'string') { + const resolved = resolveExportsTargetString( + target, subpath, packageSubpath, packageJSONUrl, base); + return finalizeResolution(resolved, base); + } else if (ArrayIsArray(target)) { + if (target.length === 0) + throwExportsInvalid(packageSubpath, target, packageJSONUrl, base); + + let lastException; + for (let i = 0; i < target.length; i++) { + const targetItem = target[i]; + let resolved; + try { + resolved = resolveExportsTarget( + packageJSONUrl, targetItem, subpath, packageSubpath, base); + } catch (e) { + lastException = e; + if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' || + e.code === 'ERR_INVALID_PACKAGE_TARGET') { + continue; + } + throw e; + } + + return finalizeResolution(resolved, base); + } + throw lastException; + } else if (typeof target === 'object' && target !== null) { + const keys = ObjectGetOwnPropertyNames(target); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (isArrayIndex(key)) { + throw new ERR_INVALID_PACKAGE_CONFIG( + fileURLToPath(packageJSONUrl), + '"exports" cannot contain numeric property keys'); + } + } + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key === 'node' || key === 'import' || key === 'default') { + const conditionalTarget = target[key]; + try { + return resolveExportsTarget( + packageJSONUrl, conditionalTarget, subpath, packageSubpath, base); + } catch (e) { + if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') continue; + throw e; + } + } + } + throwExportsNotFound(packageSubpath, packageJSONUrl, base); + } + throwExportsInvalid(packageSubpath, target, packageJSONUrl, base); +} + +function isConditionalExportsMainSugar(exports, packageJSONUrl, base) { + if (typeof exports === 'string' || ArrayIsArray(exports)) return true; + if (typeof exports !== 'object' || exports === null) return false; + + const keys = ObjectGetOwnPropertyNames(exports); + let isConditionalSugar = false; + let i = 0; + for (let j = 0; j < keys.length; j++) { + const key = keys[j]; + const curIsConditionalSugar = key === '' || key[0] !== '.'; + if (i++ === 0) { + isConditionalSugar = curIsConditionalSugar; + } else if (isConditionalSugar !== curIsConditionalSugar) { + throw new ERR_INVALID_PACKAGE_CONFIG( + fileURLToPath(packageJSONUrl), + '"exports" cannot contain some keys starting with \'.\' and some not.' + + ' The exports object must either be an object of package subpath keys' + + ' or an object of main entry condition name keys only.'); + } + } + return isConditionalSugar; +} + + +function packageMainResolve(packageJSONUrl, packageConfig, base) { + if (packageConfig.exists) { + const exports = packageConfig.exports; + if (exports !== undefined) { + if (isConditionalExportsMainSugar(exports, packageJSONUrl, base)) { + return resolveExportsTarget(packageJSONUrl, exports, '', '', base); + } else if (typeof exports === 'object' && exports !== null) { + const target = exports['.']; + if (target !== undefined) + return resolveExportsTarget(packageJSONUrl, target, '', '', base); + } + + throw new ERR_PACKAGE_PATH_NOT_EXPORTED(packageJSONUrl, '.'); + } + if (packageConfig.main !== undefined) { + const resolved = new URL(packageConfig.main, packageJSONUrl); + const path = fileURLToPath(resolved); + if (tryStatSync(path).isFile()) return resolved; + } + // if (getOptionValue('--experimental-specifier-resolution') === 'node') { + // if (packageConfig.main !== undefined) { + // return finalizeResolution( + // new URL(packageConfig.main, packageJSONUrl), base); + // } else { + // return finalizeResolution( + // new URL('index', packageJSONUrl), base); + // } + // } + if (packageConfig.type !== 'module') { + return legacyMainResolve(packageJSONUrl, packageConfig); + } + } + + throw new ERR_MODULE_NOT_FOUND( + fileURLToPath(new URL('.', packageJSONUrl)), fileURLToPath(base)); +} + + +function packageExportsResolve( + packageJSONUrl, packageSubpath, packageConfig, base) /* -> URL */ { + const exports = packageConfig.exports; + if (exports === undefined || + isConditionalExportsMainSugar(exports, packageJSONUrl, base)) { + throwExportsNotFound(packageSubpath, packageJSONUrl, base); + } + + + if (ObjectPrototypeHasOwnProperty(exports, packageSubpath)) { + const target = exports[packageSubpath]; + const resolved = resolveExportsTarget( + packageJSONUrl, target, '', packageSubpath, base); + return finalizeResolution(resolved, base); + } + + let bestMatch = ''; + const keys = ObjectGetOwnPropertyNames(exports); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key[key.length - 1] !== '/') continue; + if (StringPrototypeStartsWith(packageSubpath, key) && + key.length > bestMatch.length) { + bestMatch = key; + } + } + + if (bestMatch) { + const target = exports[bestMatch]; + const subpath = StringPrototypeSubstr(packageSubpath, bestMatch.length); + const resolved = resolveExportsTarget( + packageJSONUrl, target, subpath, packageSubpath, base); + return finalizeResolution(resolved, base); + } + + throwExportsNotFound(packageSubpath, packageJSONUrl, base); +} + +function getPackageType(url) { + const packageConfig = getPackageScopeConfig(url, url); + return packageConfig.type; +} + +function packageResolve(specifier /* string */, base /* URL */) { /* -> URL */ + let separatorIndex = StringPrototypeIndexOf(specifier, '/'); + let validPackageName = true; + let isScoped = false; + if (specifier[0] === '@') { + isScoped = true; + if (separatorIndex === -1 || specifier.length === 0) { + validPackageName = false; + } else { + separatorIndex = StringPrototypeIndexOf( + specifier, '/', separatorIndex + 1); + } + } + + const packageName = separatorIndex === -1 ? + specifier : StringPrototypeSlice(specifier, 0, separatorIndex); + + // Package name cannot have leading . and cannot have percent-encoding or + // separators. + for (let i = 0; i < packageName.length; i++) { + if (packageName[i] === '%' || packageName[i] === '\\') { + validPackageName = false; + break; + } + } + + if (!validPackageName) { + throw new ERR_INVALID_MODULE_SPECIFIER( + specifier, undefined, fileURLToPath(base)); + } + + const packageSubpath = separatorIndex === -1 ? + '' : '.' + StringPrototypeSlice(specifier, separatorIndex); + + // ResolveSelf + const packageConfig = getPackageScopeConfig(base, base); + if (packageConfig.exists) { + // TODO(jkrems): Find a way to forward the pair/iterator already generated + // while executing GetPackageScopeConfig + let packageJSONUrl; + for (const [ filename, packageConfigCandidate ] of packageJSONCache) { + if (packageConfig === packageConfigCandidate) { + packageJSONUrl = pathToFileURL(filename); + break; + } + } + if (packageJSONUrl !== undefined && + packageConfig.name === packageName && + packageConfig.exports !== undefined) { + if (packageSubpath === './') { + return new URL('./', packageJSONUrl); + } else if (packageSubpath === '') { + return packageMainResolve(packageJSONUrl, packageConfig, base); + } else { + return packageExportsResolve( + packageJSONUrl, packageSubpath, packageConfig, base); + } + } + } + + let packageJSONUrl = + new URL('./node_modules/' + packageName + '/package.json', base); + let packageJSONPath = fileURLToPath(packageJSONUrl); + let lastPath; + do { + const stat = tryStatSync( + StringPrototypeSlice(packageJSONPath, 0, packageJSONPath.length - 13)); + if (!stat.isDirectory()) { + lastPath = packageJSONPath; + packageJSONUrl = new URL((isScoped ? + '../../../../node_modules/' : '../../../node_modules/') + + packageName + '/package.json', packageJSONUrl); + packageJSONPath = fileURLToPath(packageJSONUrl); + continue; + } + + // Package match. + const packageConfig = getPackageConfig(packageJSONPath, base); + if (packageSubpath === './') { + return new URL('./', packageJSONUrl); + } else if (packageSubpath === '') { + return packageMainResolve(packageJSONUrl, packageConfig, base); + } else if (packageConfig.exports !== undefined) { + return packageExportsResolve( + packageJSONUrl, packageSubpath, packageConfig, base); + } else { + return finalizeResolution( + new URL(packageSubpath, packageJSONUrl), base); + } + // Cross-platform root check. + } while (packageJSONPath.length !== lastPath.length); + + // eslint can't handle the above code. + // eslint-disable-next-line no-unreachable + throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base)); +} + +function shouldBeTreatedAsRelativeOrAbsolutePath(specifier) { + if (specifier === '') return false; + if (specifier[0] === '/') return true; + if (specifier[0] === '.') { + if (specifier.length === 1 || specifier[1] === '/') return true; + if (specifier[1] === '.') { + if (specifier.length === 2 || specifier[2] === '/') return true; + } + } + return false; +} + +function moduleResolve(specifier /* string */, base /* URL */) { /* -> URL */ + // Order swapped from spec for minor perf gain. + // Ok since relative URLs cannot parse as URLs. + let resolved; + if (shouldBeTreatedAsRelativeOrAbsolutePath(specifier)) { + resolved = new URL(specifier, base); + } else { + try { + resolved = new URL(specifier); + } catch { + return packageResolve(specifier, base); + } + } + return finalizeResolution(resolved, base); +} + +function defaultResolve(specifier, { parentURL } = {}, defaultResolveUnused) { + let parsed; + try { + parsed = new URL(specifier); + if (parsed.protocol === 'data:') { + return { + url: specifier + }; + } + } catch {} + if (parsed && parsed.protocol === 'nodejs:') + return { url: specifier }; + if (parsed && parsed.protocol !== 'file:' && parsed.protocol !== 'data:') + throw new ERR_UNSUPPORTED_ESM_URL_SCHEME(); + if (NativeModule.canBeRequiredByUsers(specifier)) { + return { + url: 'nodejs:' + specifier + }; + } + if (parentURL && StringPrototypeStartsWith(parentURL, 'data:')) { + // This is gonna blow up, we want the error + new URL(specifier, parentURL); + } + + const isMain = parentURL === undefined; + if (isMain) { + parentURL = pathToFileURL(`${process.cwd()}/`).href; + + // This is the initial entry point to the program, and --input-type has + // been passed as an option; but --input-type can only be used with + // --eval, --print or STDIN string input. It is not allowed with file + // input, to avoid user confusion over how expansive the effect of the + // flag should be (i.e. entry point only, package scope surrounding the + // entry point, etc.). + if (typeFlag) + throw new ERR_INPUT_TYPE_NOT_ALLOWED(); + } + + let url = moduleResolve(specifier, new URL(parentURL)); + + if (isMain ? !preserveSymlinksMain : !preserveSymlinks) { + const urlPath = fileURLToPath(url); + const real = realpathSync(urlPath, { + // [internalFS.realpathCacheKey]: realpathCache + }); + const old = url; + url = pathToFileURL(real + (urlPath.endsWith(sep) ? '/' : '')); + url.search = old.search; + url.hash = old.hash; + } + + return { url: `${url}` }; +} + +return { + defaultResolve, + getPackageType +}; +} +module.exports = { + createResolve +} diff --git a/esm-usage-example/README.md b/esm-usage-example/README.md new file mode 100644 index 000000000..229db41fa --- /dev/null +++ b/esm-usage-example/README.md @@ -0,0 +1,7 @@ +To run the experiment: + +``` +cd ./esm-usage-example # Must be in this directory +node -v # Must be using node v13 +node --loader ../esm.mjs ./index +``` diff --git a/esm-usage-example/bar.ts b/esm-usage-example/bar.ts new file mode 100644 index 000000000..45dd9d249 --- /dev/null +++ b/esm-usage-example/bar.ts @@ -0,0 +1 @@ +export const bar = 123; diff --git a/esm-usage-example/foo.ts b/esm-usage-example/foo.ts new file mode 100644 index 000000000..185a3dbf3 --- /dev/null +++ b/esm-usage-example/foo.ts @@ -0,0 +1,3 @@ +export const foo = 123; +export {bar} from './bar'; + diff --git a/esm-usage-example/index.js b/esm-usage-example/index.js new file mode 100644 index 000000000..1902166b8 --- /dev/null +++ b/esm-usage-example/index.js @@ -0,0 +1,5 @@ +async function main() { + const fooModule = await import('./foo.ts'); + console.dir({foo: fooModule}) +} +main() diff --git a/esm-usage-example/package.json b/esm-usage-example/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/esm-usage-example/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/esm-usage-example/tsconfig.json b/esm-usage-example/tsconfig.json new file mode 100644 index 000000000..1ac61592b --- /dev/null +++ b/esm-usage-example/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "module": "ESNext" + } +} diff --git a/esm.mjs b/esm.mjs new file mode 100644 index 000000000..bc44b8df0 --- /dev/null +++ b/esm.mjs @@ -0,0 +1,5 @@ +import {fileURLToPath} from 'url' +import {createRequire} from 'module' +const require = createRequire(fileURLToPath(import.meta.url)) + +export const {resolve, getFormat, transformSource} = require('./dist/esm').registerAndCreateEsmHooks() diff --git a/package.json b/package.json index 17c40d3c4..5b5416e5b 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "version": "8.8.2", "description": "TypeScript execution environment and REPL for node.js, with source map support", "main": "dist/index.js", + "exports": { + ".": "./dist/index.js", + "./esm": "./esm.js" + }, "types": "dist/index.d.ts", "bin": { "ts-node": "dist/bin.js", @@ -12,6 +16,7 @@ }, "files": [ "dist/", + "dist-raw/", "register/", "LICENSE", "tsconfig.schema.json", diff --git a/src/esm.ts b/src/esm.ts new file mode 100644 index 000000000..0aa7a7532 --- /dev/null +++ b/src/esm.ts @@ -0,0 +1,107 @@ +import { register, getExtensions } from './index' +import { parse as parseUrl, format as formatUrl, UrlWithStringQuery } from 'url' +import { posix as posixPath } from 'path' +import * as assert from 'assert' +const { createResolve } = require('../dist-raw/node-esm-resolve-implementation') + +export function registerAndCreateEsmHooks () { + // Automatically performs registration just like `-r ts-node/register` + const tsNodeInstance = register() + + // Custom implementation that considers additional file extensions and automatically adds file extensions + const nodeResolveImplementation = createResolve({ + ...getExtensions(tsNodeInstance.config), + preferTsExts: tsNodeInstance.options.preferTsExts + }) + + return { resolve, getFormat, transformSource } + + function isFileUrlOrNodeStyleSpecifier (parsed: UrlWithStringQuery) { + // We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo` + const { protocol } = parsed + return protocol === null || protocol === 'file:' + } + + async function resolve (specifier: string, context: {parentURL: string}, defaultResolve: typeof resolve): Promise<{url: string}> { + const defer = async () => { + const r = await defaultResolve(specifier, context, defaultResolve) + return r + } + + const parsed = parseUrl(specifier) + const { pathname, protocol, hostname } = parsed + + if (!isFileUrlOrNodeStyleSpecifier(parsed)) { + return defer() + } + + if (protocol !== null && protocol !== 'file:') { + return defer() + } + + // Malformed file:// URL? We should always see `null` or `''` + if (hostname) { + // TODO file://./foo sets `hostname` to `'.'`. Perhaps we should special-case this. + return defer() + } + + // pathname is the path to be resolved + + return nodeResolveImplementation.defaultResolve(specifier, context, defaultResolve) + } + + type Format = 'builtin' | 'commonjs' | 'dynamic' | 'json' | 'module' | 'wasm' + async function getFormat (url: string, context: {}, defaultGetFormat: typeof getFormat): Promise<{format: Format}> { + const defer = (overrideUrl: string = url) => defaultGetFormat(overrideUrl, context, defaultGetFormat) + + const parsed = parseUrl(url) + + if (!isFileUrlOrNodeStyleSpecifier(parsed)) { + return defer() + } + + const { pathname } = parsed + // TODO test how Windows behaves. What kind of path do we receive? Should we use a simpler regexp to match the end of the string? + assert(pathname !== null, 'ESM getFormat() hook: URL should never have null pathname') + + // If file has .ts or .tsx extension, then ask node how it would treat this file if it were .js + const ext = posixPath.extname(pathname!) + if (ext === '.ts' || ext === '.tsx') { + return defer(formatUrl({ + ...parsed, + pathname: pathname + '.js' + })) + } + + return defer() + } + + async function transformSource (source: string | Buffer, context: {url: string, format: Format}, defaultTransformSource: typeof transformSource): Promise<{source: string | Buffer}> { + const defer = () => defaultTransformSource(source, context, defaultTransformSource) + + const sourceAsString = typeof source === 'string' ? source : source.toString('utf8') + + const { url } = context + const parsed = parseUrl(url) + + if (!isFileUrlOrNodeStyleSpecifier(parsed)) { + return defer() + } + const { pathname } = parsed + if (pathname === null || !posixPath.isAbsolute(pathname)) { + // If we are meant to handle this URL, then it has already been resolved to an absolute path by our resolver hook + return defer() + } + + // Assigning to a new variable so it's clear that we have stopped thinking of it as a URL, and started using it like a native FS path + const fileName = pathname + + if (tsNodeInstance.ignored(fileName)) { + return defer() + } + + const emittedJs = tsNodeInstance.compileEsm(sourceAsString, fileName) + + return { source: emittedJs } + } +} diff --git a/src/index.ts b/src/index.ts index e3d293a53..0b769f574 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import * as ynModule from 'yn' import { BaseError } from 'make-error' import * as util from 'util' import * as _ts from 'typescript' +import * as assert from 'assert' /** * Registered `ts-node` instance information. @@ -42,14 +43,14 @@ const debug = shouldDebug ? (...args: any) => console.log(`[ts-node ${new Date().toISOString()}]`, ...args) : () => undefined const debugFn = shouldDebug ? - (key: string, fn: (arg: T) => U) => { + (key: string, fn: (arg: T) => U) => { let i = 0 return (x: T) => { debug(key, x, ++i) return fn(x) } } : - (_: string, fn: (arg: T) => U) => fn + (_: string, fn: (arg: T) => U) => fn /** * Common TypeScript interfaces between versions. @@ -202,12 +203,12 @@ export interface TsConfigOptions extends Omit {} + > { } /** * Like `Object.assign`, but ignores `undefined` properties. */ -function assign (initialValue: T, ...sources: Array): T { +function assign (initialValue: T, ...sources: Array): T { for (const source of sources) { for (const key of Object.keys(source)) { const value = (source as any)[key] @@ -310,13 +311,14 @@ export interface Register { enabled (enabled?: boolean): boolean ignored (fileName: string): boolean compile (code: string, fileName: string, lineOffset?: number): string + compileEsm (code: string, fileName: string, lineOffset?: number): string getTypeInfo (code: string, fileName: string, position: number): TypeInfo } /** * Cached fs operation wrapper. */ -function cachedLookup (fn: (arg: string) => T): (arg: string) => T { +function cachedLookup (fn: (arg: string) => T): (arg: string) => T { const cache = new Map() return (arg: string): T => { @@ -328,18 +330,26 @@ function cachedLookup (fn: (arg: string) => T): (arg: string) => T { } } +/** @internal */ +export function getExtensions (config: _ts.ParsedCommandLine) { + const tsExtensions = ['.ts'] + const jsExtensions = [] + + // 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') + return { tsExtensions, jsExtensions } +} + /** * Register TypeScript compiler instance onto node.js */ export function register (opts: RegisterOptions = {}): Register { const originalJsHandler = require.extensions['.js'] // tslint:disable-line const service = create(opts) - const extensions = ['.ts'] - - // Enable additional extensions when JSX or `allowJs` is enabled. - if (service.config.options.jsx) extensions.push('.tsx') - if (service.config.options.allowJs) extensions.push('.js') - if (service.config.options.jsx && service.config.options.allowJs) extensions.push('.jsx') + const { tsExtensions, jsExtensions } = getExtensions(service.config) + const extensions = [...tsExtensions, ...jsExtensions] // Expose registered instance globally. process[REGISTER_INSTANCE] = service @@ -392,7 +402,10 @@ export function create (rawOptions: CreateOptions = {}): Register { ].map(Number) const configDiagnosticList = filterDiagnostics(config.errors, ignoreDiagnostics) - const outputCache = new Map() + const outputCache = new Map() const isScoped = options.scope ? (relname: string) => relname.charAt(0) !== '.' : () => true const shouldIgnore = createIgnore(options.skipIgnore ? [] : ( @@ -409,7 +422,7 @@ export function create (rawOptions: CreateOptions = {}): Register { sourceMapSupport.install({ environment: 'node', retrieveFile (path: string) { - return outputCache.get(path) || '' + return outputCache.get(path)?.content || '' } }) @@ -447,9 +460,22 @@ export function create (rawOptions: CreateOptions = {}): Register { /** * Create the basic required function using transpile mode. */ - let getOutput: (code: string, fileName: string, lineOffset: number) => SourceOutput + let getOutput: (code: string, fileName: string) => SourceOutput let getTypeInfo: (_code: string, _fileName: string, _position: number) => TypeInfo + const getOutputTranspileOnly = (code: string, fileName: string, overrideCompilerOptions?: Partial<_ts.CompilerOptions>): SourceOutput => { + const result = ts.transpileModule(code, { + fileName, + compilerOptions: overrideCompilerOptions ? { ...config.options, ...overrideCompilerOptions } : config.options, + reportDiagnostics: true + }) + + const diagnosticList = filterDiagnostics(result.diagnostics || [], ignoreDiagnostics) + if (diagnosticList.length) reportTSError(diagnosticList) + + return [result.outputText, result.sourceMapText as string] + } + // Use full language services when the fast option is disabled. if (!transpileOnly) { const fileContents = new Map() @@ -735,30 +761,41 @@ export function create (rawOptions: CreateOptions = {}): Register { throw new TypeError('Transformers function is unavailable in "--transpile-only"') } - getOutput = (code: string, fileName: string): SourceOutput => { - const result = ts.transpileModule(code, { - fileName, - transformers, - compilerOptions: config.options, - reportDiagnostics: true - }) - - const diagnosticList = filterDiagnostics(result.diagnostics || [], ignoreDiagnostics) - if (diagnosticList.length) reportTSError(diagnosticList) - - return [result.outputText, result.sourceMapText as string] - } + getOutput = getOutputTranspileOnly getTypeInfo = () => { throw new TypeError('Type information is unavailable in "--transpile-only"') } } + const cannotCompileViaBothCodepathsErrorMessage = 'Cannot compile the same file via both `require()` and ESM hooks codepaths. ' + + 'This breaks source-map-support, which cannot tell the difference between the two sourcemaps. ' + + 'To avoid this problem, load each .ts file as only ESM or only CommonJS.' // Create a simple TypeScript compiler proxy. function compile (code: string, fileName: string, lineOffset = 0) { - const [value, sourceMap] = getOutput(code, fileName, lineOffset) + // Avoid sourcemap issues + assert(outputCache.get(fileName)?.createdBy !== 'compileEsm', cannotCompileViaBothCodepathsErrorMessage) + const [value, sourceMap] = getOutput(code, fileName) + const output = updateOutput(value, fileName, sourceMap, getExtension) + outputCache.set(fileName, { createdBy: 'compile', content: output }) + return output + } + + function compileEsm (code: string, fileName: string) { + if (config.options.module === ts.ModuleKind.ESNext || config.options.module === ts.ModuleKind.ES2015) { + // We can use our regular `compile` implementation, since it emits ESM + return compile(code, fileName) + } + // Else we must do an alternative emit for ESM + + // Avoid sourcemap issues + assert(outputCache.get(fileName)?.createdBy !== 'compile', cannotCompileViaBothCodepathsErrorMessage) + + // TODO for now, we use a very simple transpileModule + // This does not typecheck, so we'll need to integrate with our main codepath in the future. + const [value, sourceMap] = getOutputTranspileOnly(code, fileName, { module: ts.ModuleKind.ESNext }) const output = updateOutput(value, fileName, sourceMap, getExtension) - outputCache.set(fileName, output) + outputCache.set(fileName, { createdBy: 'compileEsm', content: output }) return output } @@ -767,10 +804,14 @@ export function create (rawOptions: CreateOptions = {}): Register { const ignored = (fileName: string) => { if (!active) return true const relname = relative(cwd, fileName) + if (!config.options.allowJs) { + const ext = extname(fileName) + if (ext === '.js' || ext === '.jsx') return true + } return !isScoped(relname) || shouldIgnore(relname) } - return { ts, config, compile, getTypeInfo, ignored, enabled, options } + return { ts, config, compile, compileEsm, getTypeInfo, ignored, enabled, options } } /** From 5ce5f9771052c15161da7dd045006f88e9bb31c3 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 13 Apr 2020 02:36:36 -0400 Subject: [PATCH 02/16] fix exports declaration in package.json; update example readme --- esm-usage-example/README.md | 7 +++++++ esm-usage-example/package.json | 6 +++++- package.json | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/esm-usage-example/README.md b/esm-usage-example/README.md index 229db41fa..f75113aaf 100644 --- a/esm-usage-example/README.md +++ b/esm-usage-example/README.md @@ -3,5 +3,12 @@ To run the experiment: ``` cd ./esm-usage-example # Must be in this directory node -v # Must be using node v13 + +# Install the github branch via npm +npm install +node --loader ts-node/esm ./index.js + +# Or if you're hacking locally node --loader ../esm.mjs ./index + ``` diff --git a/esm-usage-example/package.json b/esm-usage-example/package.json index 3dbc1ca59..e9974a780 100644 --- a/esm-usage-example/package.json +++ b/esm-usage-example/package.json @@ -1,3 +1,7 @@ { - "type": "module" + "type": "module", + "dependencies": { + "ts-node": "github:TypeStrong/ts-node#ab/esm-support", + "typescript": "^3.8.3" + } } diff --git a/package.json b/package.json index 5b5416e5b..3fd500107 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "exports": { ".": "./dist/index.js", - "./esm": "./esm.js" + "./esm": "./esm.mjs" }, "types": "dist/index.d.ts", "bin": { From bba682ab7aa917664dfc7ab246be69e6658c053e Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Thu, 23 Apr 2020 11:57:40 -0400 Subject: [PATCH 03/16] add missing file to package files array --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 3fd500107..9f12dbbbc 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dist/", "dist-raw/", "register/", + "esm.mjs", "LICENSE", "tsconfig.schema.json", "tsconfig.schemastore-schema.json" From a726af1d759271a937d08827507f68ce02d0cd2e Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Fri, 24 Apr 2020 12:15:09 -0400 Subject: [PATCH 04/16] Add missing .js extension to ESM resolver --- dist-raw/node-esm-resolve-implementation.js | 1 + 1 file changed, 1 insertion(+) diff --git a/dist-raw/node-esm-resolve-implementation.js b/dist-raw/node-esm-resolve-implementation.js index b9217cb9b..0ae9c3788 100644 --- a/dist-raw/node-esm-resolve-implementation.js +++ b/dist-raw/node-esm-resolve-implementation.js @@ -275,6 +275,7 @@ function resolveExtensionsWithTryExactName(search) { const extensions = Array.from(new Set([ ...(preferTsExts ? tsExtensions : []), + '.js', ...jsExtensions, '.json', '.node', '.mjs', ...tsExtensions From e8ae493c4fba58fb57ec7881734f18235e084cfa Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Wed, 29 Apr 2020 12:28:06 -0400 Subject: [PATCH 05/16] WIP to support EmitFlavor, which I'm going to undo since actually we don't need to support 2x emit flavors --- src/index.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3bfb02afe..a7f71f906 100644 --- a/src/index.ts +++ b/src/index.ts @@ -402,8 +402,9 @@ export function create (rawOptions: CreateOptions = {}): Register { ].map(Number) const configDiagnosticList = filterDiagnostics(config.errors, ignoreDiagnostics) + type EmitFlavor = 'compileEsm' | 'compile'; const outputCache = new Map() @@ -460,7 +461,7 @@ export function create (rawOptions: CreateOptions = {}): Register { /** * Create the basic required function using transpile mode. */ - let getOutput: (code: string, fileName: string) => SourceOutput + let getOutput: (code: string, fileName: string, emitFlavor: EmitFlavor) => SourceOutput let getTypeInfo: (_code: string, _fileName: string, _position: number) => TypeInfo const getOutputTranspileOnly = (code: string, fileName: string, overrideCompilerOptions?: Partial<_ts.CompilerOptions>): SourceOutput => { @@ -553,7 +554,7 @@ export function create (rawOptions: CreateOptions = {}): Register { let previousProgram: _ts.Program | undefined = undefined - getOutput = (code: string, fileName: string) => { + getOutput = (code: string, fileName: string, emitFlavor: EmitFlavor) => { updateMemoryCache(code, fileName) const programBefore = service.getProgram() @@ -561,6 +562,10 @@ export function create (rawOptions: CreateOptions = {}): Register { debug(`compiler rebuilt Program instance when getting output for ${fileName}`) } + config.options.module === _ts.ModuleKind. + if(config.options.module === _ts.ModuleKind.CommonJS && emitFlavor === 'compile') { + + } const output = service.getEmitOutput(fileName) // Get the relevant diagnostics - this is 3x faster than `getPreEmitDiagnostics`. From 7f69cc65684e4a0868655426b67a619068362ffe Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Thu, 30 Apr 2020 11:14:56 -0400 Subject: [PATCH 06/16] Add foo.js -> foo.ts resolution, remove compileEsm codepath, add support for experimental-specifier-resolution=node --- dist-raw/node-esm-resolve-implementation.js | 121 ++++++++++++++------ src/esm.ts | 5 +- src/index.ts | 39 +------ 3 files changed, 95 insertions(+), 70 deletions(-) diff --git a/dist-raw/node-esm-resolve-implementation.js b/dist-raw/node-esm-resolve-implementation.js index 0ae9c3788..a71bd1d39 100644 --- a/dist-raw/node-esm-resolve-implementation.js +++ b/dist-raw/node-esm-resolve-implementation.js @@ -50,16 +50,37 @@ const { Stats, } = require('fs'); // const { getOptionValue } = require('internal/options'); -const { getOptionValue } = { - getOptionValue: (opt) => { - return ({ +const { getOptionValue } = (() => { + let options; + function parseOptions() { + if (!options) { + options = { '--preserve-symlinks': false, '--preserve-symlinks-main': false, '--input-type': undefined, - '--experimental-specifier-resolution': 'explicit' - })[opt] + '--experimental-specifier-resolution': 'explicit', + ...parseExecArgv() + } + } + }; + function parseExecArgv () { + return require('arg')({ + '--preserve-symlinks': Boolean, + '--preserve-symlinks-main': Boolean, + '--input-type': String, + '--experimental-specifier-resolution': String + }, { + argv: process.execArgv, + permissive: true + }); } -} + return { + getOptionValue: (opt) => { + parseOptions(); + return options[opt]; + } + }; +})(); const { sep } = require('path'); const preserveSymlinks = getOptionValue('--preserve-symlinks'); @@ -94,6 +115,7 @@ function createErrorCtor(name) { } function createResolve(opts) { +// TODO receive cached fs implementations here const {tsExtensions, jsExtensions, preferTsExts} = opts; const realpathCache = new SafeMap(); @@ -270,6 +292,8 @@ function legacyMainResolve(packageJSONUrl, packageConfig) { function resolveExtensionsWithTryExactName(search) { if (fileExists(search)) return search; + const resolvedReplacementExtension = getReplacementExtensionCandidates(search); + if(resolvedReplacementExtension) return resolvedReplacementExtension; return resolveExtensions(search); } @@ -280,6 +304,7 @@ const extensions = Array.from(new Set([ '.json', '.node', '.mjs', ...tsExtensions ])); + function resolveExtensions(search) { for (let i = 0; i < extensions.length; i++) { const extension = extensions[i]; @@ -289,36 +314,62 @@ function resolveExtensions(search) { return undefined; } -// function resolveIndex(search) { -// return resolveExtensions(new URL('index', search)); -// } +function resolveReplacementExtensionsWithTryExactName(search) { + if (fileExists(search)) return search; + return getReplacementExtensionCandidates(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)); + +function getReplacementExtensionCandidates(search) { + if (search.pathname.match(/\.js$/)) { + const pathnameWithoutExtension = search.pathname.slice(0, search.pathname.length - 3); + return replacementExtensions.map(new URL(`${pathnameWithoutExtension}${extension}`, search)); + } + return [search]; +} + +// TODO re-merge with above function +function resolveCandidates(guesses) { + for (let i = 0; i < guesses.length; i++) { + const guess = guesses[i]; + if (fileExists(guess)) return guess; + } + return undefined; +} + +function resolveIndex(search) { + return resolveExtensions(new URL('index', search)); +} function finalizeResolution(resolved, base) { - // if (getOptionValue('--experimental-specifier-resolution') === 'node') { - // let file = resolveExtensionsWithTryExactName(resolved); - // if (file !== undefined) return file; - // if (!StringPrototypeEndsWith(resolved.pathname, '/')) { - // file = resolveIndex(new URL(`${resolved.pathname}/`, base)); - // } else { - // file = resolveIndex(resolved); - // } - // if (file !== undefined) return file; - // throw new ERR_MODULE_NOT_FOUND( - // resolved.pathname, fileURLToPath(base), 'module'); - // } - - let file = resolveExtensionsWithTryExactName(resolved); - if (file !== undefined) return file; + if (getOptionValue('--experimental-specifier-resolution') === 'node') { + let file = resolveExtensionsWithTryExactName(resolved); + if (file !== undefined) return file; + if (!StringPrototypeEndsWith(resolved.pathname, '/')) { + file = resolveIndex(new URL(`${resolved.pathname}/`, base)); + } else { + file = resolveIndex(resolved); + } + if (file !== undefined) return file; + throw new ERR_MODULE_NOT_FOUND( + resolved.pathname, fileURLToPath(base), 'module'); + } if (StringPrototypeEndsWith(resolved.pathname, '/')) return resolved; const path = fileURLToPath(resolved); - if (!tryStatSync(path).isFile()) { + const file = resolveCandidates(getReplacementExtensionCandidates(resolved)); + if (!file) { throw new ERR_MODULE_NOT_FOUND( path || resolved.pathname, fileURLToPath(base), 'module'); } - return resolved; + return file; } function throwExportsNotFound(subpath, packageJSONUrl, base) { @@ -478,15 +529,15 @@ function packageMainResolve(packageJSONUrl, packageConfig, base) { const path = fileURLToPath(resolved); if (tryStatSync(path).isFile()) return resolved; } - // if (getOptionValue('--experimental-specifier-resolution') === 'node') { - // if (packageConfig.main !== undefined) { - // return finalizeResolution( - // new URL(packageConfig.main, packageJSONUrl), base); - // } else { - // return finalizeResolution( - // new URL('index', packageJSONUrl), base); - // } - // } + if (getOptionValue('--experimental-specifier-resolution') === 'node') { + if (packageConfig.main !== undefined) { + return finalizeResolution( + new URL(packageConfig.main, packageJSONUrl), base); + } else { + return finalizeResolution( + new URL('index', packageJSONUrl), base); + } + } if (packageConfig.type !== 'module') { return legacyMainResolve(packageJSONUrl, packageConfig); } diff --git a/src/esm.ts b/src/esm.ts index 0aa7a7532..b1d79225b 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -4,6 +4,8 @@ import { posix as posixPath } from 'path' import * as assert from 'assert' const { createResolve } = require('../dist-raw/node-esm-resolve-implementation') +// Note: On Windows, URLs look like this: file:///D:/dev/@TypeStrong/ts-node-examples/foo.ts + export function registerAndCreateEsmHooks () { // Automatically performs registration just like `-r ts-node/register` const tsNodeInstance = register() @@ -61,7 +63,6 @@ export function registerAndCreateEsmHooks () { } const { pathname } = parsed - // TODO test how Windows behaves. What kind of path do we receive? Should we use a simpler regexp to match the end of the string? assert(pathname !== null, 'ESM getFormat() hook: URL should never have null pathname') // If file has .ts or .tsx extension, then ask node how it would treat this file if it were .js @@ -100,7 +101,7 @@ export function registerAndCreateEsmHooks () { return defer() } - const emittedJs = tsNodeInstance.compileEsm(sourceAsString, fileName) + const emittedJs = tsNodeInstance.compile(sourceAsString, fileName) return { source: emittedJs } } diff --git a/src/index.ts b/src/index.ts index a7f71f906..3fd3378ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,7 +39,8 @@ function yn (value: string | undefined) { * Debugging `ts-node`. */ const shouldDebug = yn(process.env.TS_NODE_DEBUG) -const debug = shouldDebug ? +/** @internal */ +export const debug = shouldDebug ? (...args: any) => console.log(`[ts-node ${new Date().toISOString()}]`, ...args) : () => undefined const debugFn = shouldDebug ? @@ -311,7 +312,6 @@ export interface Register { enabled (enabled?: boolean): boolean ignored (fileName: string): boolean compile (code: string, fileName: string, lineOffset?: number): string - compileEsm (code: string, fileName: string, lineOffset?: number): string getTypeInfo (code: string, fileName: string, position: number): TypeInfo } @@ -402,9 +402,7 @@ export function create (rawOptions: CreateOptions = {}): Register { ].map(Number) const configDiagnosticList = filterDiagnostics(config.errors, ignoreDiagnostics) - type EmitFlavor = 'compileEsm' | 'compile'; const outputCache = new Map() @@ -461,7 +459,7 @@ export function create (rawOptions: CreateOptions = {}): Register { /** * Create the basic required function using transpile mode. */ - let getOutput: (code: string, fileName: string, emitFlavor: EmitFlavor) => SourceOutput + let getOutput: (code: string, fileName: string) => SourceOutput let getTypeInfo: (_code: string, _fileName: string, _position: number) => TypeInfo const getOutputTranspileOnly = (code: string, fileName: string, overrideCompilerOptions?: Partial<_ts.CompilerOptions>): SourceOutput => { @@ -554,7 +552,7 @@ export function create (rawOptions: CreateOptions = {}): Register { let previousProgram: _ts.Program | undefined = undefined - getOutput = (code: string, fileName: string, emitFlavor: EmitFlavor) => { + getOutput = (code: string, fileName: string) => { updateMemoryCache(code, fileName) const programBefore = service.getProgram() @@ -562,10 +560,6 @@ export function create (rawOptions: CreateOptions = {}): Register { debug(`compiler rebuilt Program instance when getting output for ${fileName}`) } - config.options.module === _ts.ModuleKind. - if(config.options.module === _ts.ModuleKind.CommonJS && emitFlavor === 'compile') { - - } const output = service.getEmitOutput(fileName) // Get the relevant diagnostics - this is 3x faster than `getPreEmitDiagnostics`. @@ -779,30 +773,9 @@ export function create (rawOptions: CreateOptions = {}): Register { // Create a simple TypeScript compiler proxy. function compile (code: string, fileName: string, lineOffset = 0) { const normalizedFileName = normalizeSlashes(fileName) - // Avoid sourcemap issues - assert(outputCache.get(normalizedFileName)?.createdBy !== 'compileEsm', cannotCompileViaBothCodepathsErrorMessage) const [value, sourceMap] = getOutput(code, normalizedFileName) const output = updateOutput(value, normalizedFileName, sourceMap, getExtension) - outputCache.set(normalizedFileName, { createdBy: 'compile', content: output }) - return output - } - - function compileEsm (code: string, fileName: string) { - const normalizedFileName = normalizeSlashes(fileName) - if (config.options.module === ts.ModuleKind.ESNext || config.options.module === ts.ModuleKind.ES2015) { - // We can use our regular `compile` implementation, since it emits ESM - return compile(code, normalizedFileName) - } - // Else we must do an alternative emit for ESM - - // Avoid sourcemap issues - assert(outputCache.get(fileName)?.createdBy !== 'compile', cannotCompileViaBothCodepathsErrorMessage) - - // TODO for now, we use a very simple transpileModule - // This does not typecheck, so we'll need to integrate with our main codepath in the future. - const [value, sourceMap] = getOutputTranspileOnly(code, normalizedFileName, { module: ts.ModuleKind.ESNext }) - const output = updateOutput(value, normalizedFileName, sourceMap, getExtension) - outputCache.set(normalizedFileName, { createdBy: 'compileEsm', content: output }) + outputCache.set(normalizedFileName, { content: output }) return output } @@ -818,7 +791,7 @@ export function create (rawOptions: CreateOptions = {}): Register { return !isScoped(relname) || shouldIgnore(relname) } - return { ts, config, compile, compileEsm, getTypeInfo, ignored, enabled, options } + return { ts, config, compile, getTypeInfo, ignored, enabled, options } } /** From 946dffe0a60dcc5ed4a2faea2e2311260e5d833c Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 2 May 2020 18:11:10 -0400 Subject: [PATCH 07/16] Add testing on node 14 to CI --- .github/workflows/continuous-integration.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index b988e82b4..3895aeee2 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - flavor: [1, 2, 3, 4] + flavor: [1, 2, 3, 4, 5, 6, 7] include: - flavor: 1 node: 6 @@ -21,6 +21,15 @@ jobs: - flavor: 4 node: 13 typescript: typescript@next + - flavor: 5 + node: 14 + typescript: typescript@latest + - flavor: 6 + node: 14 + typescript: typescript@2.7 + - flavor: 7 + node: 14 + typescript: typescript@next steps: # checkout code - uses: actions/checkout@v2 From 54e1835c1543d0934b13ee5a97f9c2fc3ea36a69 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 2 May 2020 18:23:48 -0400 Subject: [PATCH 08/16] Add ESM header to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 6e296fed4..2bf4d7174 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ > TypeScript execution and REPL for node.js, with source map support. **Works with `typescript@>=2.7`**. +### *Experimental ESM support* + +Native ESM support is currently experimental. For usage, limitations, and to provide feedback, see [#1007](https://github.com/TypeStrong/ts-node/issues/1007). + ## Installation ```sh From 5d103c7c17591fe820948e303d38b2c08efc9611 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 2 May 2020 18:38:48 -0400 Subject: [PATCH 09/16] Add ESM test --- src/index.spec.ts | 17 +++++++++++++++++ src/index.ts | 1 - tests/esm/bar.ts | 3 +++ tests/esm/baz.js | 3 +++ tests/esm/foo.ts | 3 +++ tests/esm/index.ts | 7 +++++++ tests/esm/package.json | 3 +++ tests/esm/tsconfig.json | 6 ++++++ 8 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/esm/bar.ts create mode 100644 tests/esm/baz.js create mode 100644 tests/esm/foo.ts create mode 100644 tests/esm/index.ts create mode 100644 tests/esm/package.json create mode 100644 tests/esm/tsconfig.json diff --git a/src/index.spec.ts b/src/index.spec.ts index ce3ba1639..a642ae695 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -634,4 +634,21 @@ describe('ts-node', function () { expect(output).to.contain('var x = 10;') }) }) + + describe('esm', () => { + this.slow(1000) + + // `ts-node/` prefix required to import from ourselves + const cmd = `node --loader ts-node/esm` + + it('should compile and execute as ESM', (done) => { + exec(`${cmd} index.ts`, {cwd: join(__dirname, '../tests/esm')}, function (err, stdout) { + expect(err).to.equal(null) + expect(stdout).to.equal('foo bar baz\n') + + return done() + }) + + }) + }) }) diff --git a/src/index.ts b/src/index.ts index 3fd3378ba..e37442a90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,6 @@ import * as ynModule from 'yn' import { BaseError } from 'make-error' import * as util from 'util' import * as _ts from 'typescript' -import * as assert from 'assert' /** * Registered `ts-node` instance information. diff --git a/tests/esm/bar.ts b/tests/esm/bar.ts new file mode 100644 index 000000000..2282d175d --- /dev/null +++ b/tests/esm/bar.ts @@ -0,0 +1,3 @@ +export const bar = 'bar' as const + +if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm/baz.js b/tests/esm/baz.js new file mode 100644 index 000000000..673d2fc83 --- /dev/null +++ b/tests/esm/baz.js @@ -0,0 +1,3 @@ +export const baz = 'baz' as const + +if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm/foo.ts b/tests/esm/foo.ts new file mode 100644 index 000000000..229ccafaf --- /dev/null +++ b/tests/esm/foo.ts @@ -0,0 +1,3 @@ +export const foo = 'foo' as const + +if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm/index.ts b/tests/esm/index.ts new file mode 100644 index 000000000..7af68220b --- /dev/null +++ b/tests/esm/index.ts @@ -0,0 +1,7 @@ +import {foo} from './foo.js' +import {bar} from './bar.js' +import {baz} from './baz.js' + +if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') + +console.log(`${foo} ${bar} ${baz}`) diff --git a/tests/esm/package.json b/tests/esm/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/esm/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/esm/tsconfig.json b/tests/esm/tsconfig.json new file mode 100644 index 000000000..aa85ff006 --- /dev/null +++ b/tests/esm/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "module": "ESNext", + "allowJs": true + } +} From 49fc2ec94a3c9afb48587eb0373b76506c996478 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 2 May 2020 18:43:02 -0400 Subject: [PATCH 10/16] ESM loader factory accepts RegisterOptions --- src/esm.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/esm.ts b/src/esm.ts index b1d79225b..896fb73c7 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -1,4 +1,4 @@ -import { register, getExtensions } from './index' +import { register, getExtensions, RegisterOptions } from './index' import { parse as parseUrl, format as formatUrl, UrlWithStringQuery } from 'url' import { posix as posixPath } from 'path' import * as assert from 'assert' @@ -6,9 +6,9 @@ const { createResolve } = require('../dist-raw/node-esm-resolve-implementation') // Note: On Windows, URLs look like this: file:///D:/dev/@TypeStrong/ts-node-examples/foo.ts -export function registerAndCreateEsmHooks () { +export function registerAndCreateEsmHooks (opts?: RegisterOptions) { // Automatically performs registration just like `-r ts-node/register` - const tsNodeInstance = register() + const tsNodeInstance = register(opts) // Custom implementation that considers additional file extensions and automatically adds file extensions const nodeResolveImplementation = createResolve({ From d553d297ba6d395b788f2fa71b0f1a554d817ef9 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 2 May 2020 18:44:16 -0400 Subject: [PATCH 11/16] Add copy of node's ESM loader to make diffing easier. --- raw/node-esm-resolve-implementation.js | 663 +++++++++++++++++++++++++ 1 file changed, 663 insertions(+) create mode 100644 raw/node-esm-resolve-implementation.js diff --git a/raw/node-esm-resolve-implementation.js b/raw/node-esm-resolve-implementation.js new file mode 100644 index 000000000..730c815b8 --- /dev/null +++ b/raw/node-esm-resolve-implementation.js @@ -0,0 +1,663 @@ +'use strict'; + +const { + ArrayIsArray, + JSONParse, + JSONStringify, + ObjectGetOwnPropertyNames, + ObjectPrototypeHasOwnProperty, + SafeMap, + StringPrototypeEndsWith, + StringPrototypeIncludes, + StringPrototypeIndexOf, + StringPrototypeSlice, + StringPrototypeStartsWith, + StringPrototypeSubstr, +} = primordials; + +const internalFS = require('internal/fs/utils'); +const { NativeModule } = require('internal/bootstrap/loaders'); +const { + closeSync, + fstatSync, + openSync, + readFileSync, + realpathSync, + statSync, + Stats, +} = require('fs'); +const { getOptionValue } = require('internal/options'); +const { sep } = require('path'); + +const preserveSymlinks = getOptionValue('--preserve-symlinks'); +const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main'); +const typeFlag = getOptionValue('--input-type'); +const { URL, pathToFileURL, fileURLToPath } = require('internal/url'); +const { + ERR_INPUT_TYPE_NOT_ALLOWED, + ERR_INVALID_MODULE_SPECIFIER, + ERR_INVALID_PACKAGE_CONFIG, + ERR_INVALID_PACKAGE_TARGET, + ERR_MODULE_NOT_FOUND, + ERR_PACKAGE_PATH_NOT_EXPORTED, + ERR_UNSUPPORTED_ESM_URL_SCHEME, +} = require('internal/errors').codes; + +const realpathCache = new SafeMap(); +const packageJSONCache = new SafeMap(); /* string -> PackageConfig */ + +function tryStatSync(path) { + try { + return statSync(path); + } catch { + return new Stats(); + } +} + +function readIfFile(path) { + let fd; + try { + fd = openSync(path, 'r'); + } catch { + return undefined; + } + try { + if (!fstatSync(fd).isFile()) return undefined; + return readFileSync(fd, 'utf8'); + } finally { + closeSync(fd); + } +} + +function getPackageConfig(path, base) { + const existing = packageJSONCache.get(path); + if (existing !== undefined) { + if (!existing.isValid) { + throw new ERR_INVALID_PACKAGE_CONFIG(path, fileURLToPath(base), false); + } + return existing; + } + + const source = readIfFile(path); + if (source === undefined) { + const packageConfig = { + exists: false, + main: undefined, + name: undefined, + isValid: true, + type: 'none', + exports: undefined + }; + packageJSONCache.set(path, packageConfig); + return packageConfig; + } + + let packageJSON; + try { + packageJSON = JSONParse(source); + } catch { + const packageConfig = { + exists: true, + main: undefined, + name: undefined, + isValid: false, + type: 'none', + exports: undefined + }; + packageJSONCache.set(path, packageConfig); + return packageConfig; + } + + let { main, name, type } = packageJSON; + const { exports } = packageJSON; + if (typeof main !== 'string') main = undefined; + if (typeof name !== 'string') name = undefined; + // Ignore unknown types for forwards compatibility + if (type !== 'module' && type !== 'commonjs') type = 'none'; + + const packageConfig = { + exists: true, + main, + name, + isValid: true, + type, + exports + }; + packageJSONCache.set(path, packageConfig); + return packageConfig; +} + +function getPackageScopeConfig(resolved, base) { + let packageJSONUrl = new URL('./package.json', resolved); + while (true) { + const packageJSONPath = packageJSONUrl.pathname; + if (StringPrototypeEndsWith(packageJSONPath, 'node_modules/package.json')) + break; + const packageConfig = getPackageConfig(fileURLToPath(packageJSONUrl), base); + if (packageConfig.exists) return packageConfig; + + const lastPackageJSONUrl = packageJSONUrl; + packageJSONUrl = new URL('../package.json', packageJSONUrl); + + // Terminates at root where ../package.json equals ../../package.json + // (can't just check "/package.json" for Windows support). + if (packageJSONUrl.pathname === lastPackageJSONUrl.pathname) break; + } + const packageConfig = { + exists: false, + main: undefined, + name: undefined, + isValid: true, + type: 'none', + exports: undefined + }; + packageJSONCache.set(fileURLToPath(packageJSONUrl), packageConfig); + return packageConfig; +} + +/* + * Legacy CommonJS main resolution: + * 1. let M = pkg_url + (json main field) + * 2. TRY(M, M.js, M.json, M.node) + * 3. TRY(M/index.js, M/index.json, M/index.node) + * 4. TRY(pkg_url/index.js, pkg_url/index.json, pkg_url/index.node) + * 5. NOT_FOUND + */ +function fileExists(url) { + return tryStatSync(fileURLToPath(url)).isFile(); +} + +function legacyMainResolve(packageJSONUrl, packageConfig) { + let guess; + if (packageConfig.main !== undefined) { + // Note: fs check redundances will be handled by Descriptor cache here. + if (fileExists(guess = new URL(`./${packageConfig.main}`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}.js`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}.json`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}.node`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}/index.js`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}/index.json`, + packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL(`./${packageConfig.main}/index.node`, + packageJSONUrl))) { + return guess; + } + // Fallthrough. + } + if (fileExists(guess = new URL('./index.js', packageJSONUrl))) { + return guess; + } + // So fs. + if (fileExists(guess = new URL('./index.json', packageJSONUrl))) { + return guess; + } + if (fileExists(guess = new URL('./index.node', packageJSONUrl))) { + return guess; + } + // Not found. + return undefined; +} + +function resolveExtensionsWithTryExactName(search) { + if (fileExists(search)) return search; + return resolveExtensions(search); +} + +const extensions = ['.js', '.json', '.node', '.mjs']; +function resolveExtensions(search) { + for (let i = 0; i < extensions.length; i++) { + const extension = extensions[i]; + const guess = new URL(`${search.pathname}${extension}`, search); + if (fileExists(guess)) return guess; + } + return undefined; +} + +function resolveIndex(search) { + return resolveExtensions(new URL('index', search)); +} + +function finalizeResolution(resolved, base) { + if (getOptionValue('--experimental-specifier-resolution') === 'node') { + let file = resolveExtensionsWithTryExactName(resolved); + if (file !== undefined) return file; + if (!StringPrototypeEndsWith(resolved.pathname, '/')) { + file = resolveIndex(new URL(`${resolved.pathname}/`, base)); + } else { + file = resolveIndex(resolved); + } + if (file !== undefined) return file; + throw new ERR_MODULE_NOT_FOUND( + resolved.pathname, fileURLToPath(base), 'module'); + } + + if (StringPrototypeEndsWith(resolved.pathname, '/')) return resolved; + const path = fileURLToPath(resolved); + + if (!tryStatSync(path).isFile()) { + throw new ERR_MODULE_NOT_FOUND( + path || resolved.pathname, fileURLToPath(base), 'module'); + } + + return resolved; +} + +function throwExportsNotFound(subpath, packageJSONUrl, base) { + throw new ERR_PACKAGE_PATH_NOT_EXPORTED( + fileURLToPath(packageJSONUrl), subpath, fileURLToPath(base)); +} + +function throwSubpathInvalid(subpath, packageJSONUrl, base) { + throw new ERR_INVALID_MODULE_SPECIFIER( + fileURLToPath(packageJSONUrl), subpath, fileURLToPath(base)); +} + +function throwExportsInvalid( + subpath, target, packageJSONUrl, base) { + if (typeof target === 'object' && target !== null) { + target = JSONStringify(target, null, ''); + } else if (ArrayIsArray(target)) { + target = `[${target}]`; + } else { + target = `${target}`; + } + throw new ERR_INVALID_PACKAGE_TARGET( + fileURLToPath(packageJSONUrl), null, subpath, target, fileURLToPath(base)); +} + +function resolveExportsTargetString( + target, subpath, match, packageJSONUrl, base) { + if (target[0] !== '.' || target[1] !== '/' || + (subpath !== '' && target[target.length - 1] !== '/')) { + throwExportsInvalid(match, target, packageJSONUrl, base); + } + + const resolved = new URL(target, packageJSONUrl); + const resolvedPath = resolved.pathname; + const packagePath = new URL('.', packageJSONUrl).pathname; + + if (!StringPrototypeStartsWith(resolvedPath, packagePath) || + StringPrototypeIncludes( + resolvedPath, '/node_modules/', packagePath.length - 1)) { + throwExportsInvalid(match, target, packageJSONUrl, base); + } + + if (subpath === '') return resolved; + const subpathResolved = new URL(subpath, resolved); + const subpathResolvedPath = subpathResolved.pathname; + if (!StringPrototypeStartsWith(subpathResolvedPath, resolvedPath) || + StringPrototypeIncludes(subpathResolvedPath, + '/node_modules/', packagePath.length - 1)) { + throwSubpathInvalid(match + subpath, packageJSONUrl, base); + } + return subpathResolved; +} + +function isArrayIndex(key /* string */) { /* -> boolean */ + const keyNum = +key; + if (`${keyNum}` !== key) return false; + return keyNum >= 0 && keyNum < 0xFFFF_FFFF; +} + +function resolveExportsTarget( + packageJSONUrl, target, subpath, packageSubpath, base) { + if (typeof target === 'string') { + const resolved = resolveExportsTargetString( + target, subpath, packageSubpath, packageJSONUrl, base); + return finalizeResolution(resolved, base); + } else if (ArrayIsArray(target)) { + if (target.length === 0) + throwExportsInvalid(packageSubpath, target, packageJSONUrl, base); + + let lastException; + for (let i = 0; i < target.length; i++) { + const targetItem = target[i]; + let resolved; + try { + resolved = resolveExportsTarget( + packageJSONUrl, targetItem, subpath, packageSubpath, base); + } catch (e) { + lastException = e; + if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' || + e.code === 'ERR_INVALID_PACKAGE_TARGET') { + continue; + } + throw e; + } + + return finalizeResolution(resolved, base); + } + throw lastException; + } else if (typeof target === 'object' && target !== null) { + const keys = ObjectGetOwnPropertyNames(target); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (isArrayIndex(key)) { + throw new ERR_INVALID_PACKAGE_CONFIG( + fileURLToPath(packageJSONUrl), + '"exports" cannot contain numeric property keys'); + } + } + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key === 'node' || key === 'import' || key === 'default') { + const conditionalTarget = target[key]; + try { + return resolveExportsTarget( + packageJSONUrl, conditionalTarget, subpath, packageSubpath, base); + } catch (e) { + if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') continue; + throw e; + } + } + } + throwExportsNotFound(packageSubpath, packageJSONUrl, base); + } + throwExportsInvalid(packageSubpath, target, packageJSONUrl, base); +} + +function isConditionalExportsMainSugar(exports, packageJSONUrl, base) { + if (typeof exports === 'string' || ArrayIsArray(exports)) return true; + if (typeof exports !== 'object' || exports === null) return false; + + const keys = ObjectGetOwnPropertyNames(exports); + let isConditionalSugar = false; + let i = 0; + for (let j = 0; j < keys.length; j++) { + const key = keys[j]; + const curIsConditionalSugar = key === '' || key[0] !== '.'; + if (i++ === 0) { + isConditionalSugar = curIsConditionalSugar; + } else if (isConditionalSugar !== curIsConditionalSugar) { + throw new ERR_INVALID_PACKAGE_CONFIG( + fileURLToPath(packageJSONUrl), + '"exports" cannot contain some keys starting with \'.\' and some not.' + + ' The exports object must either be an object of package subpath keys' + + ' or an object of main entry condition name keys only.'); + } + } + return isConditionalSugar; +} + + +function packageMainResolve(packageJSONUrl, packageConfig, base) { + if (packageConfig.exists) { + const exports = packageConfig.exports; + if (exports !== undefined) { + if (isConditionalExportsMainSugar(exports, packageJSONUrl, base)) { + return resolveExportsTarget(packageJSONUrl, exports, '', '', base); + } else if (typeof exports === 'object' && exports !== null) { + const target = exports['.']; + if (target !== undefined) + return resolveExportsTarget(packageJSONUrl, target, '', '', base); + } + + throw new ERR_PACKAGE_PATH_NOT_EXPORTED(packageJSONUrl, '.'); + } + if (packageConfig.main !== undefined) { + const resolved = new URL(packageConfig.main, packageJSONUrl); + const path = fileURLToPath(resolved); + if (tryStatSync(path).isFile()) return resolved; + } + if (getOptionValue('--experimental-specifier-resolution') === 'node') { + if (packageConfig.main !== undefined) { + return finalizeResolution( + new URL(packageConfig.main, packageJSONUrl), base); + } else { + return finalizeResolution( + new URL('index', packageJSONUrl), base); + } + } + if (packageConfig.type !== 'module') { + return legacyMainResolve(packageJSONUrl, packageConfig); + } + } + + throw new ERR_MODULE_NOT_FOUND( + fileURLToPath(new URL('.', packageJSONUrl)), fileURLToPath(base)); +} + + +function packageExportsResolve( + packageJSONUrl, packageSubpath, packageConfig, base) /* -> URL */ { + const exports = packageConfig.exports; + if (exports === undefined || + isConditionalExportsMainSugar(exports, packageJSONUrl, base)) { + throwExportsNotFound(packageSubpath, packageJSONUrl, base); + } + + + if (ObjectPrototypeHasOwnProperty(exports, packageSubpath)) { + const target = exports[packageSubpath]; + const resolved = resolveExportsTarget( + packageJSONUrl, target, '', packageSubpath, base); + return finalizeResolution(resolved, base); + } + + let bestMatch = ''; + const keys = ObjectGetOwnPropertyNames(exports); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key[key.length - 1] !== '/') continue; + if (StringPrototypeStartsWith(packageSubpath, key) && + key.length > bestMatch.length) { + bestMatch = key; + } + } + + if (bestMatch) { + const target = exports[bestMatch]; + const subpath = StringPrototypeSubstr(packageSubpath, bestMatch.length); + const resolved = resolveExportsTarget( + packageJSONUrl, target, subpath, packageSubpath, base); + return finalizeResolution(resolved, base); + } + + throwExportsNotFound(packageSubpath, packageJSONUrl, base); +} + +function getPackageType(url) { + const packageConfig = getPackageScopeConfig(url, url); + return packageConfig.type; +} + +function packageResolve(specifier /* string */, base /* URL */) { /* -> URL */ + let separatorIndex = StringPrototypeIndexOf(specifier, '/'); + let validPackageName = true; + let isScoped = false; + if (specifier[0] === '@') { + isScoped = true; + if (separatorIndex === -1 || specifier.length === 0) { + validPackageName = false; + } else { + separatorIndex = StringPrototypeIndexOf( + specifier, '/', separatorIndex + 1); + } + } + + const packageName = separatorIndex === -1 ? + specifier : StringPrototypeSlice(specifier, 0, separatorIndex); + + // Package name cannot have leading . and cannot have percent-encoding or + // separators. + for (let i = 0; i < packageName.length; i++) { + if (packageName[i] === '%' || packageName[i] === '\\') { + validPackageName = false; + break; + } + } + + if (!validPackageName) { + throw new ERR_INVALID_MODULE_SPECIFIER( + specifier, undefined, fileURLToPath(base)); + } + + const packageSubpath = separatorIndex === -1 ? + '' : '.' + StringPrototypeSlice(specifier, separatorIndex); + + // ResolveSelf + const packageConfig = getPackageScopeConfig(base, base); + if (packageConfig.exists) { + // TODO(jkrems): Find a way to forward the pair/iterator already generated + // while executing GetPackageScopeConfig + let packageJSONUrl; + for (const [ filename, packageConfigCandidate ] of packageJSONCache) { + if (packageConfig === packageConfigCandidate) { + packageJSONUrl = pathToFileURL(filename); + break; + } + } + if (packageJSONUrl !== undefined && + packageConfig.name === packageName && + packageConfig.exports !== undefined) { + if (packageSubpath === './') { + return new URL('./', packageJSONUrl); + } else if (packageSubpath === '') { + return packageMainResolve(packageJSONUrl, packageConfig, base); + } else { + return packageExportsResolve( + packageJSONUrl, packageSubpath, packageConfig, base); + } + } + } + + let packageJSONUrl = + new URL('./node_modules/' + packageName + '/package.json', base); + let packageJSONPath = fileURLToPath(packageJSONUrl); + let lastPath; + do { + const stat = tryStatSync( + StringPrototypeSlice(packageJSONPath, 0, packageJSONPath.length - 13)); + if (!stat.isDirectory()) { + lastPath = packageJSONPath; + packageJSONUrl = new URL((isScoped ? + '../../../../node_modules/' : '../../../node_modules/') + + packageName + '/package.json', packageJSONUrl); + packageJSONPath = fileURLToPath(packageJSONUrl); + continue; + } + + // Package match. + const packageConfig = getPackageConfig(packageJSONPath, base); + if (packageSubpath === './') { + return new URL('./', packageJSONUrl); + } else if (packageSubpath === '') { + return packageMainResolve(packageJSONUrl, packageConfig, base); + } else if (packageConfig.exports !== undefined) { + return packageExportsResolve( + packageJSONUrl, packageSubpath, packageConfig, base); + } else { + return finalizeResolution( + new URL(packageSubpath, packageJSONUrl), base); + } + // Cross-platform root check. + } while (packageJSONPath.length !== lastPath.length); + + // eslint can't handle the above code. + // eslint-disable-next-line no-unreachable + throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base)); +} + +function shouldBeTreatedAsRelativeOrAbsolutePath(specifier) { + if (specifier === '') return false; + if (specifier[0] === '/') return true; + if (specifier[0] === '.') { + if (specifier.length === 1 || specifier[1] === '/') return true; + if (specifier[1] === '.') { + if (specifier.length === 2 || specifier[2] === '/') return true; + } + } + return false; +} + +function moduleResolve(specifier /* string */, base /* URL */) { /* -> URL */ + // Order swapped from spec for minor perf gain. + // Ok since relative URLs cannot parse as URLs. + let resolved; + if (shouldBeTreatedAsRelativeOrAbsolutePath(specifier)) { + resolved = new URL(specifier, base); + } else { + try { + resolved = new URL(specifier); + } catch { + return packageResolve(specifier, base); + } + } + return finalizeResolution(resolved, base); +} + +function defaultResolve(specifier, { parentURL } = {}, defaultResolveUnused) { + let parsed; + try { + parsed = new URL(specifier); + if (parsed.protocol === 'data:') { + return { + url: specifier + }; + } + } catch {} + if (parsed && parsed.protocol === 'nodejs:') + return { url: specifier }; + if (parsed && parsed.protocol !== 'file:' && parsed.protocol !== 'data:') + throw new ERR_UNSUPPORTED_ESM_URL_SCHEME(); + if (NativeModule.canBeRequiredByUsers(specifier)) { + return { + url: 'nodejs:' + specifier + }; + } + if (parentURL && StringPrototypeStartsWith(parentURL, 'data:')) { + // This is gonna blow up, we want the error + new URL(specifier, parentURL); + } + + const isMain = parentURL === undefined; + if (isMain) { + parentURL = pathToFileURL(`${process.cwd()}/`).href; + + // This is the initial entry point to the program, and --input-type has + // been passed as an option; but --input-type can only be used with + // --eval, --print or STDIN string input. It is not allowed with file + // input, to avoid user confusion over how expansive the effect of the + // flag should be (i.e. entry point only, package scope surrounding the + // entry point, etc.). + if (typeFlag) + throw new ERR_INPUT_TYPE_NOT_ALLOWED(); + } + + let url = moduleResolve(specifier, new URL(parentURL)); + + if (isMain ? !preserveSymlinksMain : !preserveSymlinks) { + const urlPath = fileURLToPath(url); + const real = realpathSync(urlPath, { + [internalFS.realpathCacheKey]: realpathCache + }); + const old = url; + url = pathToFileURL(real + (urlPath.endsWith(sep) ? '/' : '')); + url.search = old.search; + url.hash = old.hash; + } + + return { url: `${url}` }; +} + +module.exports = { + defaultResolve, + getPackageType +}; From e41ec3384a2764dc8a1afb642a94364d855740a5 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 2 May 2020 19:44:23 -0400 Subject: [PATCH 12/16] Fix tests --- dist-raw/node-esm-resolve-implementation.js | 36 ++++++++------------- src/index.spec.ts | 13 ++++++-- tests/esm-node-resolver/bar/index.ts | 3 ++ tests/esm-node-resolver/baz.js | 3 ++ tests/esm-node-resolver/biff.jsx | 8 +++++ tests/esm-node-resolver/foo.ts | 3 ++ tests/esm-node-resolver/index.ts | 8 +++++ tests/esm-node-resolver/package.json | 3 ++ tests/esm-node-resolver/tsconfig.json | 8 +++++ tests/esm/baz.js | 2 +- tests/esm/biff.jsx | 8 +++++ tests/esm/index.ts | 3 +- tests/esm/tsconfig.json | 3 +- 13 files changed, 73 insertions(+), 28 deletions(-) create mode 100644 tests/esm-node-resolver/bar/index.ts create mode 100644 tests/esm-node-resolver/baz.js create mode 100644 tests/esm-node-resolver/biff.jsx create mode 100644 tests/esm-node-resolver/foo.ts create mode 100644 tests/esm-node-resolver/index.ts create mode 100644 tests/esm-node-resolver/package.json create mode 100644 tests/esm-node-resolver/tsconfig.json create mode 100644 tests/esm/biff.jsx diff --git a/dist-raw/node-esm-resolve-implementation.js b/dist-raw/node-esm-resolve-implementation.js index a71bd1d39..1a5846673 100644 --- a/dist-raw/node-esm-resolve-implementation.js +++ b/dist-raw/node-esm-resolve-implementation.js @@ -292,7 +292,7 @@ function legacyMainResolve(packageJSONUrl, packageConfig) { function resolveExtensionsWithTryExactName(search) { if (fileExists(search)) return search; - const resolvedReplacementExtension = getReplacementExtensionCandidates(search); + const resolvedReplacementExtension = resolveReplacementExtensions(search); if(resolvedReplacementExtension) return resolvedReplacementExtension; return resolveExtensions(search); } @@ -314,30 +314,20 @@ function resolveExtensions(search) { return undefined; } -function resolveReplacementExtensionsWithTryExactName(search) { - if (fileExists(search)) return search; - return getReplacementExtensionCandidates(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)); -function getReplacementExtensionCandidates(search) { +function resolveReplacementExtensions(search) { if (search.pathname.match(/\.js$/)) { const pathnameWithoutExtension = search.pathname.slice(0, search.pathname.length - 3); - return replacementExtensions.map(new URL(`${pathnameWithoutExtension}${extension}`, search)); - } - return [search]; -} - -// TODO re-merge with above function -function resolveCandidates(guesses) { - for (let i = 0; i < guesses.length; i++) { - const guess = guesses[i]; - if (fileExists(guess)) return guess; + for (let i = 0; i < replacementExtensions.length; i++) { + const extension = replacementExtensions[i]; + const guess = new URL(`${pathnameWithoutExtension}${extension}`, search); + if (fileExists(guess)) return guess; + } } return undefined; } @@ -361,12 +351,14 @@ function finalizeResolution(resolved, base) { } if (StringPrototypeEndsWith(resolved.pathname, '/')) return resolved; - const path = fileURLToPath(resolved); - const file = resolveCandidates(getReplacementExtensionCandidates(resolved)); - if (!file) { - throw new ERR_MODULE_NOT_FOUND( - path || resolved.pathname, fileURLToPath(base), 'module'); + const file = resolveReplacementExtensions(resolved) || resolved; + + const path = fileURLToPath(file); + + if (!tryStatSync(path).isFile()) { + throw new ERR_MODULE_NOT_FOUND( + path || resolved.pathname, fileURLToPath(base), 'module'); } return file; diff --git a/src/index.spec.ts b/src/index.spec.ts index a642ae695..6417a954d 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -638,11 +638,18 @@ describe('ts-node', function () { describe('esm', () => { this.slow(1000) - // `ts-node/` prefix required to import from ourselves - const cmd = `node --loader ts-node/esm` + const cmd = `node --loader ../../esm.mjs` it('should compile and execute as ESM', (done) => { - exec(`${cmd} index.ts`, {cwd: join(__dirname, '../tests/esm')}, function (err, stdout) { + exec(`${cmd} index.ts`, { cwd: join(__dirname, '../tests/esm') }, function (err, stdout) { + expect(err).to.equal(null) + expect(stdout).to.equal('foo bar baz\n') + + return done() + }) + }) + it('supports --experimental-specifier-resolution=node', (done) => { + exec(`${cmd} --experimental-specifier-resolution=node index.ts`, { cwd: join(__dirname, '../tests/esm-node-resolver') }, function (err, stdout) { expect(err).to.equal(null) expect(stdout).to.equal('foo bar baz\n') diff --git a/tests/esm-node-resolver/bar/index.ts b/tests/esm-node-resolver/bar/index.ts new file mode 100644 index 000000000..2282d175d --- /dev/null +++ b/tests/esm-node-resolver/bar/index.ts @@ -0,0 +1,3 @@ +export const bar = 'bar' as const + +if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm-node-resolver/baz.js b/tests/esm-node-resolver/baz.js new file mode 100644 index 000000000..51474b54f --- /dev/null +++ b/tests/esm-node-resolver/baz.js @@ -0,0 +1,3 @@ +export const baz = 'baz' + +if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm-node-resolver/biff.jsx b/tests/esm-node-resolver/biff.jsx new file mode 100644 index 000000000..e397d5217 --- /dev/null +++ b/tests/esm-node-resolver/biff.jsx @@ -0,0 +1,8 @@ +export const biff = 'biff' + +const React = { + createElement() {} +} +const div =
+ +if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm-node-resolver/foo.ts b/tests/esm-node-resolver/foo.ts new file mode 100644 index 000000000..229ccafaf --- /dev/null +++ b/tests/esm-node-resolver/foo.ts @@ -0,0 +1,3 @@ +export const foo = 'foo' as const + +if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm-node-resolver/index.ts b/tests/esm-node-resolver/index.ts new file mode 100644 index 000000000..88b9bc868 --- /dev/null +++ b/tests/esm-node-resolver/index.ts @@ -0,0 +1,8 @@ +import {foo} from './foo' +import {bar} from './bar' +import {baz} from './baz' +import {biff} from './biff' + +if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') + +console.log(`${foo} ${bar} ${baz} ${biff}`) diff --git a/tests/esm-node-resolver/package.json b/tests/esm-node-resolver/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/esm-node-resolver/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/esm-node-resolver/tsconfig.json b/tests/esm-node-resolver/tsconfig.json new file mode 100644 index 000000000..635b5b872 --- /dev/null +++ b/tests/esm-node-resolver/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "ESNext", + "allowJs": true, + "jsx": "react", + "moduleResolution": "node" + } +} diff --git a/tests/esm/baz.js b/tests/esm/baz.js index 673d2fc83..51474b54f 100644 --- a/tests/esm/baz.js +++ b/tests/esm/baz.js @@ -1,3 +1,3 @@ -export const baz = 'baz' as const +export const baz = 'baz' if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm/biff.jsx b/tests/esm/biff.jsx new file mode 100644 index 000000000..e397d5217 --- /dev/null +++ b/tests/esm/biff.jsx @@ -0,0 +1,8 @@ +export const biff = 'biff' + +const React = { + createElement() {} +} +const div =
+ +if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm/index.ts b/tests/esm/index.ts index 7af68220b..3b955e28b 100644 --- a/tests/esm/index.ts +++ b/tests/esm/index.ts @@ -1,7 +1,8 @@ import {foo} from './foo.js' import {bar} from './bar.js' import {baz} from './baz.js' +import {biff} from './biff.js' if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') -console.log(`${foo} ${bar} ${baz}`) +console.log(`${foo} ${bar} ${baz} ${biff}`) diff --git a/tests/esm/tsconfig.json b/tests/esm/tsconfig.json index aa85ff006..03e0c3c5d 100644 --- a/tests/esm/tsconfig.json +++ b/tests/esm/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "module": "ESNext", - "allowJs": true + "allowJs": true, + "jsx": "react" } } From a6d1c0e4d97ebd49423ebf83a9e1beddb91ec5ec Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 2 May 2020 19:57:45 -0400 Subject: [PATCH 13/16] Fix tests --- src/esm.ts | 4 ++-- src/index.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/esm.ts b/src/esm.ts index 896fb73c7..ed1e6003c 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -65,9 +65,9 @@ export function registerAndCreateEsmHooks (opts?: RegisterOptions) { const { pathname } = parsed assert(pathname !== null, 'ESM getFormat() hook: URL should never have null pathname') - // If file has .ts or .tsx extension, then ask node how it would treat this file if it were .js + // If file has .ts, .tsx, or .jsx extension, then ask node how it would treat this file if it were .js const ext = posixPath.extname(pathname!) - if (ext === '.ts' || ext === '.tsx') { + if (ext === '.ts' || ext === '.tsx' || ext === '.jsx') { return defer(formatUrl({ ...parsed, pathname: pathname + '.js' diff --git a/src/index.spec.ts b/src/index.spec.ts index 6417a954d..2f7b78337 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -643,7 +643,7 @@ describe('ts-node', function () { it('should compile and execute as ESM', (done) => { exec(`${cmd} index.ts`, { cwd: join(__dirname, '../tests/esm') }, function (err, stdout) { expect(err).to.equal(null) - expect(stdout).to.equal('foo bar baz\n') + expect(stdout).to.equal('foo bar baz biff\n') return done() }) @@ -651,7 +651,7 @@ describe('ts-node', function () { it('supports --experimental-specifier-resolution=node', (done) => { exec(`${cmd} --experimental-specifier-resolution=node index.ts`, { cwd: join(__dirname, '../tests/esm-node-resolver') }, function (err, stdout) { expect(err).to.equal(null) - expect(stdout).to.equal('foo bar baz\n') + expect(stdout).to.equal('foo bar baz biff\n') return done() }) From 023c5d12c4edf1c5a398f93e340c021da6a96f96 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 2 May 2020 19:58:02 -0400 Subject: [PATCH 14/16] type annotation in esm.mjs --- esm.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esm.mjs b/esm.mjs index bc44b8df0..873ff9768 100644 --- a/esm.mjs +++ b/esm.mjs @@ -2,4 +2,6 @@ import {fileURLToPath} from 'url' import {createRequire} from 'module' const require = createRequire(fileURLToPath(import.meta.url)) -export const {resolve, getFormat, transformSource} = require('./dist/esm').registerAndCreateEsmHooks() +/** @type {import('./dist/esm')} */ +const esm = require('./dist/esm') +export const {resolve, getFormat, transformSource} = esm.registerAndCreateEsmHooks() From 0c315168f78a7034d8c2122576004a6325312f45 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 2 May 2020 20:07:31 -0400 Subject: [PATCH 15/16] fix tests on ts 2.7 --- tests/esm-node-resolver/bar/index.ts | 2 +- tests/esm-node-resolver/foo.ts | 2 +- tests/esm/bar.ts | 2 +- tests/esm/foo.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/esm-node-resolver/bar/index.ts b/tests/esm-node-resolver/bar/index.ts index 2282d175d..4bfad1a30 100644 --- a/tests/esm-node-resolver/bar/index.ts +++ b/tests/esm-node-resolver/bar/index.ts @@ -1,3 +1,3 @@ -export const bar = 'bar' as const +export const bar: string = 'bar' if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm-node-resolver/foo.ts b/tests/esm-node-resolver/foo.ts index 229ccafaf..501c0021d 100644 --- a/tests/esm-node-resolver/foo.ts +++ b/tests/esm-node-resolver/foo.ts @@ -1,3 +1,3 @@ -export const foo = 'foo' as const +export const foo: string = 'foo' if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm/bar.ts b/tests/esm/bar.ts index 2282d175d..4bfad1a30 100644 --- a/tests/esm/bar.ts +++ b/tests/esm/bar.ts @@ -1,3 +1,3 @@ -export const bar = 'bar' as const +export const bar: string = 'bar' if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') diff --git a/tests/esm/foo.ts b/tests/esm/foo.ts index 229ccafaf..501c0021d 100644 --- a/tests/esm/foo.ts +++ b/tests/esm/foo.ts @@ -1,3 +1,3 @@ -export const foo = 'foo' as const +export const foo: string = 'foo' if(typeof module !== 'undefined') throw new Error('module should not exist in ESM') From 0ea60074b2746c8e705244169e0375f28caf875f Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 2 May 2020 20:13:23 -0400 Subject: [PATCH 16/16] Conditionally run esm tests on node >= 13 --- src/index.spec.ts | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 2f7b78337..51f4d2b61 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -635,27 +635,29 @@ describe('ts-node', function () { }) }) - describe('esm', () => { - this.slow(1000) + if (semver.gte(process.version, '13.0.0')) { + describe('esm', () => { + this.slow(1000) - const cmd = `node --loader ../../esm.mjs` + const cmd = `node --loader ../../esm.mjs` - it('should compile and execute as ESM', (done) => { - exec(`${cmd} index.ts`, { cwd: join(__dirname, '../tests/esm') }, function (err, stdout) { - expect(err).to.equal(null) - expect(stdout).to.equal('foo bar baz biff\n') + it('should compile and execute as ESM', (done) => { + exec(`${cmd} index.ts`, { cwd: join(__dirname, '../tests/esm') }, function (err, stdout) { + expect(err).to.equal(null) + expect(stdout).to.equal('foo bar baz biff\n') - return done() + return done() + }) }) - }) - it('supports --experimental-specifier-resolution=node', (done) => { - exec(`${cmd} --experimental-specifier-resolution=node index.ts`, { cwd: join(__dirname, '../tests/esm-node-resolver') }, function (err, stdout) { - expect(err).to.equal(null) - expect(stdout).to.equal('foo bar baz biff\n') + it('supports --experimental-specifier-resolution=node', (done) => { + exec(`${cmd} --experimental-specifier-resolution=node index.ts`, { cwd: join(__dirname, '../tests/esm-node-resolver') }, function (err, stdout) { + expect(err).to.equal(null) + expect(stdout).to.equal('foo bar baz biff\n') - return done() - }) + return done() + }) + }) }) - }) + } })