From f6cd5d4d696e152428ce7d7825dcd6f055edb953 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 3 May 2020 01:01:53 -0400 Subject: [PATCH] Experimental ESM support for node >=13 (#1010) * Initial commit * fix exports declaration in package.json; update example readme * add missing file to package files array * Add missing .js extension to ESM resolver * WIP to support EmitFlavor, which I'm going to undo since actually we don't need to support 2x emit flavors * Add foo.js -> foo.ts resolution, remove compileEsm codepath, add support for experimental-specifier-resolution=node * Add testing on node 14 to CI * Add ESM header to README * Add ESM test * ESM loader factory accepts RegisterOptions * Add copy of node's ESM loader to make diffing easier. * Fix tests * Fix tests * type annotation in esm.mjs * fix tests on ts 2.7 * Conditionally run esm tests on node >= 13 --- .github/workflows/continuous-integration.yml | 11 +- README.md | 4 + dist-raw/node-esm-resolve-implementation.js | 772 +++++++++++++++++++ esm-usage-example/README.md | 14 + esm-usage-example/bar.ts | 1 + esm-usage-example/foo.ts | 3 + esm-usage-example/index.js | 5 + esm-usage-example/package.json | 7 + esm-usage-example/tsconfig.json | 5 + esm.mjs | 7 + package.json | 6 + raw/node-esm-resolve-implementation.js | 663 ++++++++++++++++ src/esm.ts | 108 +++ src/index.spec.ts | 26 + src/index.ts | 79 +- 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/bar.ts | 3 + tests/esm/baz.js | 3 + tests/esm/biff.jsx | 8 + tests/esm/foo.ts | 3 + tests/esm/index.ts | 8 + tests/esm/package.json | 3 + tests/esm/tsconfig.json | 7 + 29 files changed, 1751 insertions(+), 31 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 raw/node-esm-resolve-implementation.js create mode 100644 src/esm.ts 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/bar.ts create mode 100644 tests/esm/baz.js create mode 100644 tests/esm/biff.jsx 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/.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 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 diff --git a/dist-raw/node-esm-resolve-implementation.js b/dist-raw/node-esm-resolve-implementation.js new file mode 100644 index 000000000..1a5846673 --- /dev/null +++ b/dist-raw/node-esm-resolve-implementation.js @@ -0,0 +1,772 @@ +// 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 } = (() => { + let options; + function parseOptions() { + if (!options) { + options = { + '--preserve-symlinks': false, + '--preserve-symlinks-main': false, + '--input-type': undefined, + '--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'); +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) { +// TODO receive cached fs implementations here +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; + const resolvedReplacementExtension = resolveReplacementExtensions(search); + if(resolvedReplacementExtension) return resolvedReplacementExtension; + return resolveExtensions(search); +} + +const extensions = Array.from(new Set([ + ...(preferTsExts ? tsExtensions : []), + '.js', + ...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; +} + +/** + * 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 resolveReplacementExtensions(search) { + if (search.pathname.match(/\.js$/)) { + const pathnameWithoutExtension = search.pathname.slice(0, search.pathname.length - 3); + 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; +} + +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 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; +} + +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..f75113aaf --- /dev/null +++ b/esm-usage-example/README.md @@ -0,0 +1,14 @@ +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/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..e9974a780 --- /dev/null +++ b/esm-usage-example/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "dependencies": { + "ts-node": "github:TypeStrong/ts-node#ab/esm-support", + "typescript": "^3.8.3" + } +} 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..873ff9768 --- /dev/null +++ b/esm.mjs @@ -0,0 +1,7 @@ +import {fileURLToPath} from 'url' +import {createRequire} from 'module' +const require = createRequire(fileURLToPath(import.meta.url)) + +/** @type {import('./dist/esm')} */ +const esm = require('./dist/esm') +export const {resolve, getFormat, transformSource} = esm.registerAndCreateEsmHooks() diff --git a/package.json b/package.json index dffaa1327..af860b502 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "version": "8.9.1", "description": "TypeScript execution environment and REPL for node.js, with source map support", "main": "dist/index.js", + "exports": { + ".": "./dist/index.js", + "./esm": "./esm.mjs" + }, "types": "dist/index.d.ts", "bin": { "ts-node": "dist/bin.js", @@ -12,7 +16,9 @@ }, "files": [ "dist/", + "dist-raw/", "register/", + "esm.mjs", "LICENSE", "tsconfig.schema.json", "tsconfig.schemastore-schema.json" 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 +}; diff --git a/src/esm.ts b/src/esm.ts new file mode 100644 index 000000000..ed1e6003c --- /dev/null +++ b/src/esm.ts @@ -0,0 +1,108 @@ +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' +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 (opts?: RegisterOptions) { + // Automatically performs registration just like `-r ts-node/register` + const tsNodeInstance = register(opts) + + // 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 + assert(pathname !== null, 'ESM getFormat() hook: URL should never have null pathname') + + // 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' || ext === '.jsx') { + 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.compile(sourceAsString, fileName) + + return { source: emittedJs } + } +} diff --git a/src/index.spec.ts b/src/index.spec.ts index 8e7007175..355cfcdf3 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -649,4 +649,30 @@ describe('ts-node', function () { expect(output).to.contain('var x = 10;') }) }) + + if (semver.gte(process.version, '13.0.0')) { + describe('esm', () => { + this.slow(1000) + + 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') + + 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') + + return done() + }) + + }) + }) + } }) diff --git a/src/index.ts b/src/index.ts index 50059bff8..e37442a90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,18 +38,19 @@ 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 ? - (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] @@ -316,7 +317,7 @@ export interface Register { /** * 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 +329,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 +401,9 @@ 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 +420,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 +458,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,31 +759,22 @@ 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 normalizedFileName = normalizeSlashes(fileName) - const [value, sourceMap] = getOutput(code, normalizedFileName, lineOffset) + const [value, sourceMap] = getOutput(code, normalizedFileName) const output = updateOutput(value, normalizedFileName, sourceMap, getExtension) - outputCache.set(fileName, output) + outputCache.set(normalizedFileName, { content: output }) return output } @@ -768,6 +783,10 @@ 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) } diff --git a/tests/esm-node-resolver/bar/index.ts b/tests/esm-node-resolver/bar/index.ts new file mode 100644 index 000000000..4bfad1a30 --- /dev/null +++ b/tests/esm-node-resolver/bar/index.ts @@ -0,0 +1,3 @@ +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/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..501c0021d --- /dev/null +++ b/tests/esm-node-resolver/foo.ts @@ -0,0 +1,3 @@ +export const foo: string = 'foo' + +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/bar.ts b/tests/esm/bar.ts new file mode 100644 index 000000000..4bfad1a30 --- /dev/null +++ b/tests/esm/bar.ts @@ -0,0 +1,3 @@ +export const bar: string = 'bar' + +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..51474b54f --- /dev/null +++ b/tests/esm/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/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/foo.ts b/tests/esm/foo.ts new file mode 100644 index 000000000..501c0021d --- /dev/null +++ b/tests/esm/foo.ts @@ -0,0 +1,3 @@ +export const foo: string = 'foo' + +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..3b955e28b --- /dev/null +++ b/tests/esm/index.ts @@ -0,0 +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} ${biff}`) 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..03e0c3c5d --- /dev/null +++ b/tests/esm/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "ESNext", + "allowJs": true, + "jsx": "react" + } +}