From 079206730fd75e6ff18f6998b15d08b9503cc000 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 5 Mar 2022 14:31:45 -0500 Subject: [PATCH] `ts-node-esm` / `--esm` to spawn a child process; decouple config loading from `create()`; fix pluggable dep resolution (#1655) * WIP * lint-fix * WIP * it works! * Update index.ts * Move `preferTsExts` from `RegisterOptions` to `CreateOptions` * fix * fix * fix tests * fix? * fix * fix? * fix? * fix! * fix * fix!! * fix * fix... * tweak test lib's suite delimiter to match ava's * fix * fix * docs, fixes * fix #1662 and add tests * lint-fix * test cleanup, remove or cleanup version checks, skip failing tsconfig "extends" tests on TS 2.7 * ensure tests are forced to install and use most recent ts-node tarball and cannot accidentally use project-root nor outdated tarball * fix absence of fs method on old node --- ava.config.cjs | 40 +++ ava.config.js | 14 - child-loader.mjs | 7 + dist-raw/node-primordials.js | 1 - package.json | 11 +- src/bin-esm.ts | 5 + src/bin.ts | 252 +++++++++++++++--- src/child/child-entrypoint.ts | 16 ++ src/child/child-loader.ts | 34 +++ src/child/child-require.ts | 27 ++ src/child/spawn-child.ts | 51 ++++ src/configuration.ts | 101 ++++++- src/esm.ts | 43 ++- src/index.ts | 124 ++++----- src/test/esm-loader.spec.ts | 84 +++++- src/test/exec-helpers.ts | 59 +++- src/test/helpers.ts | 41 ++- src/test/index.spec.ts | 133 +++++---- src/test/pluggable-dep-resolution.spec.ts | 98 +++++++ src/test/testlib.ts | 39 ++- src/transpilers/swc.ts | 7 +- src/transpilers/types.ts | 6 + tests/esm-child-process/via-flag/index.ts | 3 + tests/esm-child-process/via-flag/package.json | 3 + .../esm-child-process/via-flag/tsconfig.json | 9 + tests/esm-child-process/via-tsconfig/index.ts | 3 + .../via-tsconfig/package.json | 3 + tests/esm-child-process/via-tsconfig/sleep.ts | 13 + .../via-tsconfig/tsconfig.json | 10 + .../node_modules/@swc/core/index.js | 5 + .../node_modules/@swc/wasm/index.js | 5 + .../node_modules/custom-compiler/index.js | 9 + .../node_modules/custom-swc/index.js | 5 + .../node_modules/custom-transpiler/index.js | 10 + .../node_modules/@swc/core/index.js | 5 + .../node_modules/@swc/wasm/index.js | 5 + .../node_modules/custom-compiler/index.js | 9 + .../node_modules/custom-swc/index.js | 5 + .../node_modules/custom-transpiler/index.js | 10 + .../tsconfig-custom-compiler.json | 1 + .../tsconfig-custom-transpiler.json | 1 + .../shared-config/tsconfig-swc-core.json | 1 + .../tsconfig-swc-custom-backend.json | 1 + .../shared-config/tsconfig-swc-wasm.json | 1 + .../shared-config/tsconfig-swc.json | 1 + .../tsconfig-custom-compiler.json | 1 + .../tsconfig-custom-transpiler.json | 1 + .../tsconfig-extend-custom-compiler.json | 1 + .../tsconfig-extend-custom-transpiler.json | 1 + .../tsconfig-extend-swc-core.json | 1 + .../tsconfig-extend-swc-custom-backend.json | 1 + .../tsconfig-extend-swc-wasm.json | 1 + .../tsconfig-extend-swc.json | 1 + .../tsconfig-swc-core.json | 6 + .../tsconfig-swc-custom-backend.json | 6 + .../tsconfig-swc-wasm.json | 6 + .../tsconfig-swc.json | 1 + website/docs/imports.md | 28 +- website/docs/options.md | 1 + website/docs/usage.md | 3 + 60 files changed, 1139 insertions(+), 231 deletions(-) create mode 100644 ava.config.cjs delete mode 100644 ava.config.js create mode 100644 child-loader.mjs create mode 100644 src/bin-esm.ts create mode 100644 src/child/child-entrypoint.ts create mode 100644 src/child/child-loader.ts create mode 100644 src/child/child-require.ts create mode 100644 src/child/spawn-child.ts create mode 100644 src/test/pluggable-dep-resolution.spec.ts create mode 100644 tests/esm-child-process/via-flag/index.ts create mode 100644 tests/esm-child-process/via-flag/package.json create mode 100644 tests/esm-child-process/via-flag/tsconfig.json create mode 100644 tests/esm-child-process/via-tsconfig/index.ts create mode 100644 tests/esm-child-process/via-tsconfig/package.json create mode 100644 tests/esm-child-process/via-tsconfig/sleep.ts create mode 100644 tests/esm-child-process/via-tsconfig/tsconfig.json create mode 100644 tests/pluggable-dep-resolution/node_modules/@swc/core/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/@swc/wasm/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/custom-swc/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/custom-transpiler/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/core/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/wasm/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-swc/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-transpiler/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-compiler.json create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-transpiler.json create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-core.json create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-custom-backend.json create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-wasm.json create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-custom-compiler.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-custom-transpiler.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-custom-compiler.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-custom-transpiler.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-swc-core.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-swc-custom-backend.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-swc-wasm.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-swc.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-swc-core.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-swc-custom-backend.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-swc-wasm.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-swc.json diff --git a/ava.config.cjs b/ava.config.cjs new file mode 100644 index 000000000..aa04b33bf --- /dev/null +++ b/ava.config.cjs @@ -0,0 +1,40 @@ +const expect = require('expect'); +const { createRequire } = require('module'); + +module.exports = { + files: ['dist/test/**/*.spec.js'], + failWithoutAssertions: false, + environmentVariables: { + ts_node_install_lock: `id-${Math.floor(Math.random() * 10e9)}`, + // Force jest expect() errors to generate colorized strings, makes output more readable. + // Delete the env var within ava processes via `require` option below. + // This avoids passing it to spawned processes under test, which would negatively affect + // their behavior. + FORCE_COLOR: '3', + }, + require: ['./src/test/remove-env-var-force-color.js'], + timeout: '300s', + concurrency: 1, +}; + +{ + /* + * Tests *must* install and use our most recent ts-node tarball. + * We must prevent them from accidentally require-ing a different version of + * ts-node, from either node_modules or tests/node_modules + */ + + const { existsSync } = require('fs'); + const rimraf = require('rimraf'); + const { resolve } = require('path'); + + remove(resolve(__dirname, 'node_modules/ts-node')); + remove(resolve(__dirname, 'tests/node_modules/ts-node')); + + // Prove that we did it correctly + expect(() => {createRequire(resolve(__dirname, 'tests/foo.js')).resolve('ts-node')}).toThrow(); + + function remove(p) { + if(existsSync(p)) rimraf.sync(p, {recursive: true}) + } +} diff --git a/ava.config.js b/ava.config.js deleted file mode 100644 index 6181565d3..000000000 --- a/ava.config.js +++ /dev/null @@ -1,14 +0,0 @@ -export default { - files: ['dist/test/**/*.spec.js'], - failWithoutAssertions: false, - environmentVariables: { - ts_node_install_lock: `id-${Math.floor(Math.random() * 10e9)}`, - // Force jest expect() errors to generate colorized strings, makes output more readable. - // Delete the env var within ava processes via `require` option below. - // This avoids passing it to spawned processes under test, which would negatively affect - // their behavior. - FORCE_COLOR: '3', - }, - require: ['./src/test/remove-env-var-force-color.js'], - timeout: '300s', -}; diff --git a/child-loader.mjs b/child-loader.mjs new file mode 100644 index 000000000..3a96eeea4 --- /dev/null +++ b/child-loader.mjs @@ -0,0 +1,7 @@ +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; +const require = createRequire(fileURLToPath(import.meta.url)); + +/** @type {import('./dist/child-loader')} */ +const childLoader = require('./dist/child/child-loader'); +export const { resolve, load, getFormat, transformSource } = childLoader; diff --git a/dist-raw/node-primordials.js b/dist-raw/node-primordials.js index ae3b8b911..21d8cfd19 100644 --- a/dist-raw/node-primordials.js +++ b/dist-raw/node-primordials.js @@ -1,7 +1,6 @@ module.exports = { ArrayFrom: Array.from, ArrayIsArray: Array.isArray, - ArrayPrototypeJoin: (obj, separator) => Array.prototype.join.call(obj, separator), ArrayPrototypeShift: (obj) => Array.prototype.shift.call(obj), ArrayPrototypeForEach: (arr, ...rest) => Array.prototype.forEach.apply(arr, rest), ArrayPrototypeIncludes: (arr, ...rest) => Array.prototype.includes.apply(arr, rest), diff --git a/package.json b/package.json index 9ae86a3b9..84a5ad16d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "./dist/bin-script.js": "./dist/bin-script.js", "./dist/bin-cwd": "./dist/bin-cwd.js", "./dist/bin-cwd.js": "./dist/bin-cwd.js", + "./dist/bin-esm": "./dist/bin-esm.js", + "./dist/bin-esm.js": "./dist/bin-esm.js", "./register": "./register/index.js", "./register/files": "./register/files.js", "./register/transpile-only": "./register/transpile-only.js", @@ -23,6 +25,7 @@ "./esm.mjs": "./esm.mjs", "./esm/transpile-only": "./esm/transpile-only.mjs", "./esm/transpile-only.mjs": "./esm/transpile-only.mjs", + "./child-loader.mjs": "./child-loader.mjs", "./transpilers/swc": "./transpilers/swc.js", "./transpilers/swc-experimental": "./transpilers/swc-experimental.js", "./node10/tsconfig.json": "./node10/tsconfig.json", @@ -33,10 +36,11 @@ "types": "dist/index.d.ts", "bin": { "ts-node": "dist/bin.js", - "ts-script": "dist/bin-script-deprecated.js", - "ts-node-script": "dist/bin-script.js", "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-transpile-only": "dist/bin-transpile.js" + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" }, "files": [ "/transpilers/", @@ -46,6 +50,7 @@ "/register/", "/esm/", "/esm.mjs", + "/child-loader.mjs", "/LICENSE", "/tsconfig.schema.json", "/tsconfig.schemastore-schema.json", diff --git a/src/bin-esm.ts b/src/bin-esm.ts new file mode 100644 index 000000000..3bc6bbbd2 --- /dev/null +++ b/src/bin-esm.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { main } from './bin'; + +main(undefined, { '--esm': true }); diff --git a/src/bin.ts b/src/bin.ts index 13907f39e..3e972dd97 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -3,7 +3,7 @@ import { join, resolve, dirname, parse as parsePath, relative } from 'path'; import { inspect } from 'util'; import Module = require('module'); -import arg = require('arg'); +let arg: typeof import('arg'); import { parse, createRequire, hasOwnProperty } from './util'; import { EVAL_FILENAME, @@ -17,17 +17,76 @@ import { STDIN_NAME, REPL_FILENAME, } from './repl'; -import { VERSION, TSError, register, versionGteLt } from './index'; +import { + VERSION, + TSError, + register, + versionGteLt, + createEsmHooks, + createFromPreloadedConfig, + DEFAULTS, +} from './index'; import type { TSInternal } from './ts-compiler-types'; import { addBuiltinLibsToObject } from '../dist-raw/node-cjs-helpers'; +import { callInChild } from './child/spawn-child'; +import { findAndReadConfig } from './configuration'; /** * Main `bin` functionality. + * + * This file is split into a chain of functions (phases), each one adding to a shared state object. + * This is done so that the next function can either be invoked in-process or, if necessary, invoked in a child process. + * + * The functions are intentionally given uncreative names and left in the same order as the original code, to make a + * smaller git diff. */ export function main( argv: string[] = process.argv.slice(2), entrypointArgs: Record = {} ) { + const args = parseArgv(argv, entrypointArgs); + const state: BootstrapState = { + shouldUseChildProcess: false, + isInChildProcess: false, + entrypoint: __filename, + parseArgvResult: args, + }; + return bootstrap(state); +} + +/** + * @internal + * Describes state of CLI bootstrapping. + * Can be marshalled when necessary to resume bootstrapping in a child process. + */ +export interface BootstrapState { + isInChildProcess: boolean; + shouldUseChildProcess: boolean; + entrypoint: string; + parseArgvResult: ReturnType; + phase2Result?: ReturnType; + phase3Result?: ReturnType; +} + +/** @internal */ +export function bootstrap(state: BootstrapState) { + if (!state.phase2Result) { + state.phase2Result = phase2(state); + if (state.shouldUseChildProcess && !state.isInChildProcess) { + return callInChild(state); + } + } + if (!state.phase3Result) { + state.phase3Result = phase3(state); + if (state.shouldUseChildProcess && !state.isInChildProcess) { + return callInChild(state); + } + } + return phase4(state); +} + +function parseArgv(argv: string[], entrypointArgs: Record) { + arg ??= require('arg'); // HACK: technically, this function is not marked @internal so it's possible // that libraries in the wild are doing `require('ts-node/dist/bin').main({'--transpile-only': true})` // We can mark this function @internal in next major release. @@ -58,6 +117,7 @@ export function main( '--scriptMode': Boolean, '--version': arg.COUNT, '--showConfig': Boolean, + '--esm': Boolean, // Project options. '--cwd': String, @@ -156,7 +216,51 @@ export function main( '--scope': scope = undefined, '--scopeDir': scopeDir = undefined, '--noExperimentalReplAwait': noExperimentalReplAwait, + '--esm': esm, + _: restArgs, } = args; + return { + // Note: argv and restArgs may be overwritten by child process + argv: process.argv, + restArgs, + + cwdArg, + help, + scriptMode, + cwdMode, + version, + showConfig, + argsRequire, + code, + print, + interactive, + files, + compiler, + compilerOptions, + project, + ignoreDiagnostics, + ignore, + transpileOnly, + typeCheck, + transpiler, + swc, + compilerHost, + pretty, + skipProject, + skipIgnore, + preferTsExts, + logError, + emit, + scope, + scopeDir, + noExperimentalReplAwait, + esm, + }; +} + +function phase2(payload: BootstrapState) { + const { help, version, code, interactive, cwdArg, restArgs, esm } = + payload.parseArgvResult; if (help) { console.log(` @@ -169,13 +273,14 @@ Options: -r, --require [path] Require a node module before execution -i, --interactive Opens the REPL even if stdin does not appear to be a terminal + --esm Bootstrap with the ESM loader, enabling full ESM support + --swc Use the faster swc transpiler + -h, --help Print CLI usage - -v, --version Print module version information - --cwdMode Use current directory instead of for config resolution + -v, --version Print module version information. -vvv to print additional information --showConfig Print resolved configuration and exit -T, --transpileOnly Use TypeScript's faster \`transpileModule\` or a third-party transpiler - --swc Use the swc transpiler -H, --compilerHost Use TypeScript's compiler host API -I, --ignore [pattern] Override the path patterns to skip compilation -P, --project [path] Path to TypeScript JSON project file @@ -187,6 +292,7 @@ Options: --cwd Behave as if invoked within this working directory. --files Load \`files\`, \`include\` and \`exclude\` from \`tsconfig.json\` on startup --pretty Use pretty diagnostic formatter (usually enabled by default) + --cwdMode Use current directory instead of for config resolution --skipProject Skip reading \`tsconfig.json\` --skipIgnore Skip \`--ignore\` checks --emit Emit output files into \`.ts-node\` directory @@ -209,8 +315,8 @@ Options: // Figure out which we are executing: piped stdin, --eval, REPL, and/or entrypoint // This is complicated because node's behavior is complicated // `node -e code -i ./script.js` ignores -e - const executeEval = code != null && !(interactive && args._.length); - const executeEntrypoint = !executeEval && args._.length > 0; + const executeEval = code != null && !(interactive && restArgs.length); + const executeEntrypoint = !executeEval && restArgs.length > 0; const executeRepl = !executeEntrypoint && (interactive || (process.stdin.isTTY && !executeEval)); @@ -218,8 +324,90 @@ Options: const cwd = cwdArg || process.cwd(); /** Unresolved. May point to a symlink, not realpath. May be missing file extension */ - const scriptPath = executeEntrypoint ? resolve(cwd, args._[0]) : undefined; + const scriptPath = executeEntrypoint ? resolve(cwd, restArgs[0]) : undefined; + + if (esm) payload.shouldUseChildProcess = true; + return { + executeEval, + executeEntrypoint, + executeRepl, + executeStdin, + cwd, + scriptPath, + }; +} +function phase3(payload: BootstrapState) { + const { + emit, + files, + pretty, + transpileOnly, + transpiler, + noExperimentalReplAwait, + typeCheck, + swc, + compilerHost, + ignore, + preferTsExts, + logError, + scriptMode, + cwdMode, + project, + skipProject, + skipIgnore, + compiler, + ignoreDiagnostics, + compilerOptions, + argsRequire, + scope, + scopeDir, + } = payload.parseArgvResult; + const { cwd, scriptPath } = payload.phase2Result!; + + const preloadedConfig = findAndReadConfig({ + cwd, + emit, + files, + pretty, + transpileOnly: transpileOnly ?? transpiler != null ? true : undefined, + experimentalReplAwait: noExperimentalReplAwait ? false : undefined, + typeCheck, + transpiler, + swc, + compilerHost, + ignore, + logError, + projectSearchDir: getProjectSearchDir(cwd, scriptMode, cwdMode, scriptPath), + project, + skipProject, + skipIgnore, + compiler, + ignoreDiagnostics, + compilerOptions, + require: argsRequire, + scope, + scopeDir, + preferTsExts, + }); + + if (preloadedConfig.options.esm) payload.shouldUseChildProcess = true; + return { preloadedConfig }; +} + +function phase4(payload: BootstrapState) { + const { isInChildProcess, entrypoint } = payload; + const { version, showConfig, restArgs, code, print, argv } = + payload.parseArgvResult; + const { + executeEval, + cwd, + executeStdin, + executeRepl, + executeEntrypoint, + scriptPath, + } = payload.phase2Result!; + const { preloadedConfig } = payload.phase3Result!; /** * , [stdin], and [eval] are all essentially virtual files that do not exist on disc and are backed by a REPL * service to handle eval-ing of code. @@ -278,33 +466,22 @@ Options: } // Register the TypeScript compiler instance. - const service = register({ - cwd, - emit, - files, - pretty, - transpileOnly: transpileOnly ?? transpiler != null ? true : undefined, - experimentalReplAwait: noExperimentalReplAwait ? false : undefined, - typeCheck, - transpiler, - swc, - compilerHost, - ignore, - preferTsExts, - logError, - projectSearchDir: getProjectSearchDir(cwd, scriptMode, cwdMode, scriptPath), - project, - skipProject, - skipIgnore, - compiler, - ignoreDiagnostics, - compilerOptions, - require: argsRequire, - readFile: evalAwarePartialHost?.readFile ?? undefined, - fileExists: evalAwarePartialHost?.fileExists ?? undefined, - scope, - scopeDir, + const service = createFromPreloadedConfig({ + // Since this struct may have been marshalled across thread or process boundaries, we must restore + // un-marshall-able values. + ...preloadedConfig, + options: { + ...preloadedConfig.options, + readFile: evalAwarePartialHost?.readFile ?? undefined, + fileExists: evalAwarePartialHost?.fileExists ?? undefined, + tsTrace: DEFAULTS.tsTrace, + }, }); + register(service); + if (isInChildProcess) + ( + require('./child/child-loader') as typeof import('./child/child-loader') + ).lateBindHooks(createEsmHooks(service)); // Bind REPL service to ts-node compiler service (chicken-and-egg problem) replStuff?.repl.setService(service); @@ -377,12 +554,13 @@ Options: // Prepend `ts-node` arguments to CLI for child processes. process.execArgv.push( - __filename, - ...process.argv.slice(2, process.argv.length - args._.length) + entrypoint, + ...argv.slice(2, argv.length - restArgs.length) ); + // TODO this comes from BoostrapState process.argv = [process.argv[1]] .concat(executeEntrypoint ? ([scriptPath] as string[]) : []) - .concat(args._.slice(executeEntrypoint ? 1 : 0)); + .concat(restArgs.slice(executeEntrypoint ? 1 : 0)); // Execute the main contents (either eval, script or piped). if (executeEntrypoint) { diff --git a/src/child/child-entrypoint.ts b/src/child/child-entrypoint.ts new file mode 100644 index 000000000..03a02d2e9 --- /dev/null +++ b/src/child/child-entrypoint.ts @@ -0,0 +1,16 @@ +import { BootstrapState, bootstrap } from '../bin'; +import { brotliDecompressSync } from 'zlib'; + +const base64ConfigArg = process.argv[2]; +const argPrefix = '--brotli-base64-config='; +if (!base64ConfigArg.startsWith(argPrefix)) throw new Error('unexpected argv'); +const base64Payload = base64ConfigArg.slice(argPrefix.length); +const payload = JSON.parse( + brotliDecompressSync(Buffer.from(base64Payload, 'base64')).toString() +) as BootstrapState; +payload.isInChildProcess = true; +payload.entrypoint = __filename; +payload.parseArgvResult.argv = process.argv; +payload.parseArgvResult.restArgs = process.argv.slice(3); + +bootstrap(payload); diff --git a/src/child/child-loader.ts b/src/child/child-loader.ts new file mode 100644 index 000000000..0ac018132 --- /dev/null +++ b/src/child/child-loader.ts @@ -0,0 +1,34 @@ +// TODO same version check as ESM loader, but export stubs +// Also export a binder function that allows re-binding where the stubs +// delegate. + +import type { NodeLoaderHooksAPI1, NodeLoaderHooksAPI2 } from '..'; +import { filterHooksByAPIVersion } from '../esm'; + +let hooks: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2; + +/** @internal */ +export function lateBindHooks( + _hooks: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 +) { + hooks = _hooks as NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2; +} + +const proxy: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2 = { + resolve(...args: Parameters) { + return (hooks?.resolve ?? args[2])(...args); + }, + load(...args: Parameters) { + return (hooks?.load ?? args[2])(...args); + }, + getFormat(...args: Parameters) { + return (hooks?.getFormat ?? args[2])(...args); + }, + transformSource(...args: Parameters) { + return (hooks?.transformSource ?? args[2])(...args); + }, +}; + +/** @internal */ +export const { resolve, load, getFormat, transformSource } = + filterHooksByAPIVersion(proxy) as NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2; diff --git a/src/child/child-require.ts b/src/child/child-require.ts new file mode 100644 index 000000000..2ee155221 --- /dev/null +++ b/src/child/child-require.ts @@ -0,0 +1,27 @@ +interface EventEmitterInternals { + _events: Record>; +} +const _process = process as any as EventEmitterInternals; + +// Not shown here: Additional logic to correctly interact with process's events, either using this direct manipulation, or via the API + +let originalOnWarning: Function | undefined; +if (Array.isArray(_process._events.warning)) { + originalOnWarning = _process._events.warning[0]; + _process._events.warning[0] = onWarning; +} else { + originalOnWarning = _process._events.warning; + _process._events.warning = onWarning; +} + +const messageMatch = /--(?:experimental-)?loader\b/; +function onWarning(this: any, warning: Error, ...rest: any[]) { + // Suppress warning about how `--loader` is experimental + if ( + warning?.name === 'ExperimentalWarning' && + messageMatch.test(warning?.message) + ) + return; + // Will be undefined if `--no-warnings` + return originalOnWarning?.call(this, warning, ...rest); +} diff --git a/src/child/spawn-child.ts b/src/child/spawn-child.ts new file mode 100644 index 000000000..74bf4017c --- /dev/null +++ b/src/child/spawn-child.ts @@ -0,0 +1,51 @@ +import type { BootstrapState } from '../bin'; +import { spawn } from 'child_process'; +import { brotliCompressSync } from 'zlib'; +import { pathToFileURL } from 'url'; +import { versionGteLt } from '..'; + +const argPrefix = '--brotli-base64-config='; + +/** @internal */ +export function callInChild(state: BootstrapState) { + if (!versionGteLt(process.versions.node, '12.17.0')) { + throw new Error( + '`ts-node-esm` and `ts-node --esm` require node version 12.17.0 or newer.' + ); + } + const child = spawn( + process.execPath, + [ + '--require', + require.resolve('./child-require.js'), + '--loader', + // Node on Windows doesn't like `c:\` absolute paths here; must be `file:///c:/` + pathToFileURL(require.resolve('../../child-loader.mjs')).toString(), + require.resolve('./child-entrypoint.js'), + `${argPrefix}${brotliCompressSync( + Buffer.from(JSON.stringify(state), 'utf8') + ).toString('base64')}`, + ...state.parseArgvResult.restArgs, + ], + { + stdio: 'inherit', + argv0: process.argv0, + } + ); + child.on('error', (error) => { + console.error(error); + process.exit(1); + }); + child.on('exit', (code) => { + child.removeAllListeners(); + process.off('SIGINT', sendSignalToChild); + process.off('SIGTERM', sendSignalToChild); + process.exitCode = code === null ? 1 : code; + }); + // Ignore sigint and sigterm in parent; pass them to child + process.on('SIGINT', sendSignalToChild); + process.on('SIGTERM', sendSignalToChild); + function sendSignalToChild(signal: string) { + process.kill(child.pid, signal); + } +} diff --git a/src/configuration.ts b/src/configuration.ts index ff38ddd44..13b7ad28f 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -4,13 +4,19 @@ import { CreateOptions, DEFAULTS, OptionBasePaths, + RegisterOptions, TSCommon, TsConfigOptions, } from './index'; import type { TSInternal } from './ts-compiler-types'; import { createTsInternals } from './ts-internals'; import { getDefaultTsconfigJsonForNodeVersion } from './tsconfigs'; -import { assign, createProjectLocalResolveHelper } from './util'; +import { + assign, + attemptRequireWithV8CompileCache, + createProjectLocalResolveHelper, + getBasePathForProjectLocalDependencyResolution, +} from './util'; /** * TypeScript compiler option values required by `ts-node` which cannot be overridden. @@ -49,6 +55,68 @@ function fixConfig(ts: TSCommon, config: _ts.ParsedCommandLine) { return config; } +/** @internal */ +export function findAndReadConfig(rawOptions: CreateOptions) { + const cwd = resolve( + rawOptions.cwd ?? rawOptions.dir ?? DEFAULTS.cwd ?? process.cwd() + ); + const compilerName = rawOptions.compiler ?? DEFAULTS.compiler; + + // Compute minimum options to read the config file. + let projectLocalResolveDir = getBasePathForProjectLocalDependencyResolution( + undefined, + rawOptions.projectSearchDir, + rawOptions.project, + cwd + ); + let { compiler, ts } = resolveAndLoadCompiler( + compilerName, + projectLocalResolveDir + ); + + // Read config file and merge new options between env and CLI options. + const { configFilePath, config, tsNodeOptionsFromTsconfig, optionBasePaths } = + readConfig(cwd, ts, rawOptions); + + const options = assign( + {}, + DEFAULTS, + tsNodeOptionsFromTsconfig || {}, + { optionBasePaths }, + rawOptions + ); + options.require = [ + ...(tsNodeOptionsFromTsconfig.require || []), + ...(rawOptions.require || []), + ]; + + // Re-resolve the compiler in case it has changed. + // Compiler is loaded relative to tsconfig.json, so tsconfig discovery may cause us to load a + // different compiler than we did above, even if the name has not changed. + if (configFilePath) { + projectLocalResolveDir = getBasePathForProjectLocalDependencyResolution( + configFilePath, + rawOptions.projectSearchDir, + rawOptions.project, + cwd + ); + ({ compiler } = resolveCompiler( + options.compiler, + optionBasePaths.compiler ?? projectLocalResolveDir + )); + } + + return { + options, + config, + projectLocalResolveDir, + optionBasePaths, + configFilePath, + cwd, + compiler, + }; +} + /** * Load TypeScript configuration. Returns the parsed TypeScript config and * any `ts-node` options specified in the config file. @@ -193,6 +261,9 @@ export function readConfig( if (options.compiler != null) { optionBasePaths.compiler = basePath; } + if (options.swc != null) { + optionBasePaths.swc = basePath; + } assign(tsNodeOptionsFromTsconfig, options); } @@ -255,6 +326,32 @@ export function readConfig( }; } +/** + * Load the typescript compiler. It is required to load the tsconfig but might + * be changed by the tsconfig, so we have to do this twice. + * @internal + */ +export function resolveAndLoadCompiler( + name: string | undefined, + relativeToPath: string +) { + const { compiler } = resolveCompiler(name, relativeToPath); + const ts = loadCompiler(compiler); + return { compiler, ts }; +} + +function resolveCompiler(name: string | undefined, relativeToPath: string) { + const projectLocalResolveHelper = + createProjectLocalResolveHelper(relativeToPath); + const compiler = projectLocalResolveHelper(name || 'typescript', true); + return { compiler }; +} + +/** @internal */ +export function loadCompiler(compiler: string): TSCommon { + return attemptRequireWithV8CompileCache(require, compiler); +} + /** * Given the raw "ts-node" sub-object from a tsconfig, return an object with only the properties * recognized by "ts-node" @@ -286,6 +383,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): { experimentalReplAwait, swc, experimentalResolverFeatures, + esm, ...unrecognized } = jsonObject as TsConfigOptions; const filteredTsConfigOptions = { @@ -310,6 +408,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): { moduleTypes, swc, experimentalResolverFeatures, + esm, }; // Use the typechecker to make sure this implementation has the correct set of properties const catchExtraneousProps: keyof TsConfigOptions = diff --git a/src/esm.ts b/src/esm.ts index b42af64bd..38f4f0d58 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -95,6 +95,31 @@ export type NodeLoaderHooksFormat = | 'module' | 'wasm'; +export type NodeImportConditions = unknown; +export interface NodeImportAssertions { + type?: 'json'; +} + +// The hooks API changed in node version X so we need to check for backwards compatibility. +// TODO: When the new API is backported to v12, v14, update these version checks accordingly. +const newHooksAPI = + versionGteLt(process.versions.node, '17.0.0') || + versionGteLt(process.versions.node, '16.12.0', '17.0.0') || + versionGteLt(process.versions.node, '14.999.999', '15.0.0') || + versionGteLt(process.versions.node, '12.999.999', '13.0.0'); + +/** @internal */ +export function filterHooksByAPIVersion( + hooks: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2 +): NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 { + const { getFormat, load, resolve, transformSource } = hooks; + // Explicit return type to avoid TS's non-ideal inferred type + const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI + ? { resolve, load, getFormat: undefined, transformSource: undefined } + : { resolve, getFormat, transformSource, load: undefined }; + return hooksAPI; +} + /** @internal */ export function registerAndCreateEsmHooks(opts?: RegisterOptions) { // Automatically performs registration just like `-r ts-node/register` @@ -112,18 +137,12 @@ export function createEsmHooks(tsNodeService: Service) { preferTsExts: tsNodeService.options.preferTsExts, }); - // The hooks API changed in node version X so we need to check for backwards compatibility. - // TODO: When the new API is backported to v12, v14, update these version checks accordingly. - const newHooksAPI = - versionGteLt(process.versions.node, '17.0.0') || - versionGteLt(process.versions.node, '16.12.0', '17.0.0') || - versionGteLt(process.versions.node, '14.999.999', '15.0.0') || - versionGteLt(process.versions.node, '12.999.999', '13.0.0'); - - // Explicit return type to avoid TS's non-ideal inferred type - const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI - ? { resolve, load, getFormat: undefined, transformSource: undefined } - : { resolve, getFormat, transformSource, load: undefined }; + const hooksAPI = filterHooksByAPIVersion({ + resolve, + load, + getFormat, + transformSource, + }); function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) { // We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo` diff --git a/src/index.ts b/src/index.ts index 719a3e88d..996556a7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { relative, basename, extname, resolve, dirname, join } from 'path'; +import { relative, basename, extname, dirname, join } from 'path'; import { Module } from 'module'; import * as util from 'util'; import { fileURLToPath } from 'url'; @@ -9,18 +9,15 @@ import type * as _ts from 'typescript'; import type { Transpiler, TranspilerFactory } from './transpilers/types'; import { - assign, - attemptRequireWithV8CompileCache, cachedLookup, createProjectLocalResolveHelper, - getBasePathForProjectLocalDependencyResolution, normalizeSlashes, parse, ProjectLocalResolveHelper, split, yn, } from './util'; -import { readConfig } from './configuration'; +import { findAndReadConfig, loadCompiler } from './configuration'; import type { TSCommon, TSInternal } from './ts-compiler-types'; import { createModuleTypeClassifier, @@ -369,6 +366,18 @@ export interface CreateOptions { * @default console.log */ tsTrace?: (str: string) => void; + /** + * TODO DOCS YAY + */ + esm?: boolean; + /** + * Re-order file extensions so that TypeScript imports are preferred. + * + * For example, when both `index.js` and `index.ts` exist, enabling this option causes `require('./index')` to resolve to `index.ts` instead of `index.js` + * + * @default false + */ + preferTsExts?: boolean; } export type ModuleTypes = Record; @@ -378,21 +387,13 @@ export interface OptionBasePaths { moduleTypes?: string; transpiler?: string; compiler?: string; + swc?: string; } /** * Options for registering a TypeScript compiler instance globally. */ export interface RegisterOptions extends CreateOptions { - /** - * Re-order file extensions so that TypeScript imports are preferred. - * - * For example, when both `index.js` and `index.ts` exist, enabling this option causes `require('./index')` to resolve to `index.ts` instead of `index.js` - * - * @default false - */ - preferTsExts?: boolean; - /** * Enable experimental features that re-map imports and require calls to support: * `baseUrl`, `paths`, `rootDirs`, `.js` to `.ts` file extension mappings, @@ -592,63 +593,29 @@ export function register( * Create TypeScript compiler instance. */ export function create(rawOptions: CreateOptions = {}): Service { - const cwd = resolve( - rawOptions.cwd ?? rawOptions.dir ?? DEFAULTS.cwd ?? process.cwd() - ); - const compilerName = rawOptions.compiler ?? DEFAULTS.compiler; - - /** - * Load the typescript compiler. It is required to load the tsconfig but might - * be changed by the tsconfig, so we have to do this twice. - */ - function loadCompiler(name: string | undefined, relativeToPath: string) { - const projectLocalResolveHelper = - createProjectLocalResolveHelper(relativeToPath); - const compiler = projectLocalResolveHelper(name || 'typescript', true); - const ts: TSCommon = attemptRequireWithV8CompileCache(require, compiler); - return { compiler, ts, projectLocalResolveHelper }; - } + const foundConfigResult = findAndReadConfig(rawOptions); + return createFromPreloadedConfig(foundConfigResult); +} - // Compute minimum options to read the config file. - let { compiler, ts, projectLocalResolveHelper } = loadCompiler( - compilerName, - getBasePathForProjectLocalDependencyResolution( - undefined, - rawOptions.projectSearchDir, - rawOptions.project, - cwd - ) - ); +/** @internal */ +export function createFromPreloadedConfig( + foundConfigResult: ReturnType +): Service { + const { + configFilePath, + cwd, + options, + config, + compiler, + projectLocalResolveDir, + optionBasePaths, + } = foundConfigResult; - // Read config file and merge new options between env and CLI options. - const { configFilePath, config, tsNodeOptionsFromTsconfig, optionBasePaths } = - readConfig(cwd, ts, rawOptions); - const options = assign( - {}, - DEFAULTS, - tsNodeOptionsFromTsconfig || {}, - { optionBasePaths }, - rawOptions + const projectLocalResolveHelper = createProjectLocalResolveHelper( + projectLocalResolveDir ); - options.require = [ - ...(tsNodeOptionsFromTsconfig.require || []), - ...(rawOptions.require || []), - ]; - // Re-load the compiler in case it has changed. - // Compiler is loaded relative to tsconfig.json, so tsconfig discovery may cause us to load a - // different compiler than we did above, even if the name has not changed. - if (configFilePath) { - ({ compiler, ts, projectLocalResolveHelper } = loadCompiler( - options.compiler, - getBasePathForProjectLocalDependencyResolution( - configFilePath, - rawOptions.projectSearchDir, - rawOptions.project, - cwd - ) - )); - } + const ts = loadCompiler(compiler); // Experimental REPL await is not compatible targets lower than ES2018 const targetSupportsTla = config.options.target! >= ts.ScriptTarget.ES2018; @@ -692,11 +659,15 @@ export function create(rawOptions: CreateOptions = {}): Service { const transpileOnly = (options.transpileOnly === true || options.swc === true) && options.typeCheck !== true; - const transpiler = options.transpiler - ? options.transpiler - : options.swc - ? require.resolve('./transpilers/swc.js') - : undefined; + let transpiler: RegisterOptions['transpiler'] | undefined = undefined; + let transpilerBasePath: string | undefined = undefined; + if (options.transpiler) { + transpiler = options.transpiler; + transpilerBasePath = optionBasePaths.transpiler; + } else if (options.swc) { + transpiler = require.resolve('./transpilers/swc.js'); + transpilerBasePath = optionBasePaths.swc; + } const transformers = options.transformers || undefined; const diagnosticFilters: Array = [ { @@ -763,7 +734,13 @@ export function create(rawOptions: CreateOptions = {}): Service { typeof transpiler === 'string' ? transpiler : transpiler[0]; const transpilerOptions = typeof transpiler === 'string' ? {} : transpiler[1] ?? {}; - const transpilerPath = projectLocalResolveHelper(transpilerName, true); + const transpilerConfigLocalResolveHelper = transpilerBasePath + ? createProjectLocalResolveHelper(transpilerBasePath) + : projectLocalResolveHelper; + const transpilerPath = transpilerConfigLocalResolveHelper( + transpilerName, + true + ); const transpilerFactory = require(transpilerPath) .create as TranspilerFactory; createTranspiler = function (compilerOptions) { @@ -776,6 +753,7 @@ export function create(rawOptions: CreateOptions = {}): Service { }, projectLocalResolveHelper, }, + transpilerConfigLocalResolveHelper, ...transpilerOptions, }); }; diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts index 76cb320d5..cc673e544 100644 --- a/src/test/esm-loader.spec.ts +++ b/src/test/esm-loader.spec.ts @@ -5,18 +5,22 @@ import { context } from './testlib'; import semver = require('semver'); import { + BIN_ESM_PATH, BIN_PATH, + BIN_PATH_JS, CMD_ESM_LOADER_WITHOUT_PROJECT, CMD_TS_NODE_WITHOUT_PROJECT_FLAG, contextTsNodeUnderTest, + delay, EXPERIMENTAL_MODULES_FLAG, nodeSupportsEsmHooks, nodeSupportsImportAssertions, + nodeSupportsSpawningChildProcess, nodeUsesNewHooksApi, resetNodeEnvironment, TEST_DIR, } from './helpers'; -import { createExec } from './exec-helpers'; +import { createExec, createSpawn, ExecReturn } from './exec-helpers'; import { join, resolve } from 'path'; import * as expect from 'expect'; import type { NodeLoaderHooksAPI2 } from '../'; @@ -27,6 +31,9 @@ const test = context(contextTsNodeUnderTest); const exec = createExec({ cwd: TEST_DIR, }); +const spawn = createSpawn({ + cwd: TEST_DIR, +}); test.suite('esm', (test) => { test.suite('when node supports loader hooks', (test) => { @@ -322,6 +329,81 @@ test.suite('esm', (test) => { }); } ); + + test.suite('spawns child process', async (test) => { + test.runIf(nodeSupportsSpawningChildProcess); + + basic('ts-node-esm executable', () => + exec(`${BIN_ESM_PATH} ./esm-child-process/via-flag/index.ts foo bar`) + ); + basic('ts-node --esm flag', () => + exec(`${BIN_PATH} --esm ./esm-child-process/via-flag/index.ts foo bar`) + ); + basic('ts-node w/tsconfig esm:true', () => + exec( + `${BIN_PATH} --esm ./esm-child-process/via-tsconfig/index.ts foo bar` + ) + ); + + function basic(title: string, cb: () => ExecReturn) { + test(title, async (t) => { + const { err, stdout, stderr } = await cb(); + expect(err).toBe(null); + expect(stdout.trim()).toBe('CLI args: foo bar'); + expect(stderr).toBe(''); + }); + } + + test.suite('parent passes signals to child', (test) => { + test.runSerially(); + + signalTest('SIGINT'); + signalTest('SIGTERM'); + + function signalTest(signal: string) { + test(signal, async (t) => { + const childP = spawn([ + // exec lets us run the shims on windows; spawn does not + process.execPath, + BIN_PATH_JS, + `./esm-child-process/via-tsconfig/sleep.ts`, + ]); + let code: number | null | undefined = undefined; + childP.child.on('exit', (_code) => (code = _code)); + await delay(6e3); + const codeAfter6Seconds = code; + process.kill(childP.child.pid, signal); + await delay(2e3); + const codeAfter8Seconds = code; + const { stdoutP, stderrP } = await childP; + const stdout = await stdoutP; + const stderr = await stderrP; + t.log({ + stdout, + stderr, + codeAfter6Seconds, + codeAfter8Seconds, + code, + }); + expect(codeAfter6Seconds).toBeUndefined(); + if (process.platform === 'win32') { + // Windows doesn't have signals, and node attempts an imperfect facsimile. + // In Windows, SIGINT and SIGTERM kill the process immediately with exit + // code 1, and the process can't catch or prevent this. + expect(codeAfter8Seconds).toBe(1); + expect(code).toBe(1); + } else { + expect(codeAfter8Seconds).toBe(undefined); + expect(code).toBe(123); + expect(stdout.trim()).toBe( + `child registered signal handlers\nchild received signal: ${signal}\nchild exiting` + ); + } + expect(stderr).toBe(''); + }); + } + }); + }); }); test.suite('node >= 12.x.x', (test) => { diff --git a/src/test/exec-helpers.ts b/src/test/exec-helpers.ts index fc70f0e3f..bf0766475 100644 --- a/src/test/exec-helpers.ts +++ b/src/test/exec-helpers.ts @@ -1,5 +1,14 @@ -import type { ChildProcess, ExecException, ExecOptions } from 'child_process'; -import { exec as childProcessExec } from 'child_process'; +import type { + ChildProcess, + ExecException, + ExecOptions, + SpawnOptions, +} from 'child_process'; +import { + exec as childProcessExec, + spawn as childProcessSpawn, +} from 'child_process'; +import { getStream } from './helpers'; import { expect } from './testlib'; export type ExecReturn = Promise & { child: ChildProcess }; @@ -44,6 +53,52 @@ export function createExec>( }; } +export type SpawnReturn = Promise & { child: ChildProcess }; +export interface SpawnResult { + stdoutP: Promise; + stderrP: Promise; + code: number | null; + child: ChildProcess; +} + +export function createSpawn>( + preBoundOptions?: T +) { + /** + * Helper to spawn a child process. + * Returns a Promise and a reference to the child process to suite multiple situations. + * + * Should almost always avoid this helper, and instead use `createExec` / `exec`. `spawn` + * may be necessary if you need to avoid `exec`'s intermediate shell. + */ + return function spawn( + cmd: string[], + opts?: Pick> & + Partial> + ) { + let child!: ChildProcess; + return Object.assign( + new Promise((resolve, reject) => { + child = childProcessSpawn(cmd[0], cmd.slice(1), { + ...preBoundOptions, + ...opts, + }); + const stdoutP = getStream(child.stdout!); + const stderrP = getStream(child.stderr!); + child.on('exit', (code) => { + resolve({ stdoutP, stderrP, code, child }); + }); + child.on('error', (error) => { + reject(error); + }); + }), + { + child, + } + ); + }; +} + const defaultExec = createExec(); export interface ExecTesterOptions { diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 5327459be..cddf7575d 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -17,13 +17,7 @@ import semver = require('semver'); const createRequire: typeof _createRequire = require('create-require'); export { tsNodeTypes }; -export const nodeSupportsEsmHooks = semver.gte(process.version, '12.16.0'); -export const nodeUsesNewHooksApi = semver.gte(process.version, '16.12.0'); -export const nodeSupportsImportAssertions = semver.gte( - process.version, - '17.1.0' -); - +//#region Paths export const ROOT_DIR = resolve(__dirname, '../..'); export const DIST_DIR = resolve(__dirname, '..'); export const TEST_DIR = join(__dirname, '../../tests'); @@ -35,6 +29,10 @@ export const BIN_SCRIPT_PATH = join( 'node_modules/.bin/ts-node-script' ); export const BIN_CWD_PATH = join(TEST_DIR, 'node_modules/.bin/ts-node-cwd'); +export const BIN_ESM_PATH = join(TEST_DIR, 'node_modules/.bin/ts-node-esm'); +//#endregion + +//#region command lines /** Default `ts-node --project` invocation */ export const CMD_TS_NODE_WITH_PROJECT_FLAG = `"${BIN_PATH}" --project "${PROJECT}"`; /** Default `ts-node` invocation without `--project` */ @@ -43,12 +41,33 @@ export const EXPERIMENTAL_MODULES_FLAG = semver.gte(process.version, '12.17.0') ? '' : '--experimental-modules'; export const CMD_ESM_LOADER_WITHOUT_PROJECT = `node ${EXPERIMENTAL_MODULES_FLAG} --loader ts-node/esm`; +//#endregion // `createRequire` does not exist on older node versions export const testsDirRequire = createRequire(join(TEST_DIR, 'index.js')); export const ts = testsDirRequire('typescript'); +//#region version checks +export const nodeSupportsEsmHooks = semver.gte(process.version, '12.16.0'); +export const nodeSupportsSpawningChildProcess = semver.gte( + process.version, + '12.17.0' +); +export const nodeUsesNewHooksApi = semver.gte(process.version, '16.12.0'); +export const nodeSupportsImportAssertions = semver.gte( + process.version, + '17.1.0' +); +/** Supports tsconfig "extends" >= v3.2.0 */ +export const tsSupportsTsconfigInheritanceViaNodePackages = semver.gte( + ts.version, + '3.2.0' +); +/** Supports --showConfig: >= v3.2.0 */ +export const tsSupportsShowConfig = semver.gte(ts.version, '3.2.0'); +//#endregion + export const xfs = new NodeFS(fs); /** Pass to `test.context()` to get access to the ts-node API under test */ @@ -60,6 +79,7 @@ export const contextTsNodeUnderTest = once(async () => { }; }); +//#region install ts-node tarball const ts_node_install_lock = process.env.ts_node_install_lock as string; const lockPath = join(__dirname, ts_node_install_lock); @@ -128,6 +148,7 @@ async function lockedMemoizedOperation( releaseLock(); } } +//#endregion /** * Get a stream into a string. @@ -165,6 +186,8 @@ export function getStream(stream: Readable, waitForPattern?: string | RegExp) { } } +//#region Reset node environment + const defaultRequireExtensions = captureObjectState(require.extensions); const defaultProcess = captureObjectState(process); const defaultModule = captureObjectState(require('module')); @@ -224,3 +247,7 @@ function resetObject( // Reset descriptors Object.defineProperties(object, state.descriptors); } + +//#endregion + +export const delay = promisify(setTimeout); diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 5487a7b64..72faf86ea 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -3,7 +3,13 @@ import * as expect from 'expect'; import { join, resolve, sep as pathSep } from 'path'; import { tmpdir } from 'os'; import semver = require('semver'); -import { BIN_PATH_JS, nodeSupportsEsmHooks, ts } from './helpers'; +import { + BIN_PATH_JS, + nodeSupportsEsmHooks, + ts, + tsSupportsShowConfig, + tsSupportsTsconfigInheritanceViaNodePackages, +} from './helpers'; import { lstatSync, mkdtempSync } from 'fs'; import { npath } from '@yarnpkg/fslib'; import type _createRequire from 'create-require'; @@ -167,31 +173,29 @@ test.suite('ts-node', (test) => { expect(stdout).toBe('object\n'); }); - if (semver.gte(ts.version, '1.8.0')) { - test('should allow js', async () => { - const { err, stdout } = await exec( - [ - CMD_TS_NODE_WITH_PROJECT_FLAG, - '-O "{\\"allowJs\\":true}"', - '-pe "import { main } from \'./allow-js/run\';main()"', - ].join(' ') - ); - expect(err).toBe(null); - expect(stdout).toBe('hello world\n'); - }); + test('should allow js', async () => { + const { err, stdout } = await exec( + [ + CMD_TS_NODE_WITH_PROJECT_FLAG, + '-O "{\\"allowJs\\":true}"', + '-pe "import { main } from \'./allow-js/run\';main()"', + ].join(' ') + ); + expect(err).toBe(null); + expect(stdout).toBe('hello world\n'); + }); - test('should include jsx when `allow-js` true', async () => { - const { err, stdout } = await exec( - [ - CMD_TS_NODE_WITH_PROJECT_FLAG, - '-O "{\\"allowJs\\":true}"', - '-pe "import { Foo2 } from \'./allow-js/with-jsx\'; Foo2.sayHi()"', - ].join(' ') - ); - expect(err).toBe(null); - expect(stdout).toBe('hello world\n'); - }); - } + test('should include jsx when `allow-js` true', async () => { + const { err, stdout } = await exec( + [ + CMD_TS_NODE_WITH_PROJECT_FLAG, + '-O "{\\"allowJs\\":true}"', + '-pe "import { Foo2 } from \'./allow-js/with-jsx\'; Foo2.sayHi()"', + ].join(' ') + ); + expect(err).toBe(null); + expect(stdout).toBe('hello world\n'); + }); test('should eval code', async () => { const { err, stdout } = await exec( @@ -501,21 +505,16 @@ test.suite('ts-node', (test) => { }); test.suite('issue #884', (test) => { + // TODO disabled because it consistently fails on Windows on TS 2.7 + test.skipIf( + process.platform === 'win32' && semver.satisfies(ts.version, '2.7') + ); test('should compile', async (t) => { - // TODO disabled because it consistently fails on Windows on TS 2.7 - if ( - process.platform === 'win32' && - semver.satisfies(ts.version, '2.7') - ) { - t.log('Skipping'); - return; - } else { - const { err, stdout } = await exec( - `"${BIN_PATH}" --project issue-884/tsconfig.json issue-884` - ); - expect(err).toBe(null); - expect(stdout).toBe(''); - } + const { err, stdout } = await exec( + `"${BIN_PATH}" --project issue-884/tsconfig.json issue-884` + ); + expect(err).toBe(null); + expect(stdout).toBe(''); }); }); @@ -706,7 +705,7 @@ test.suite('ts-node', (test) => { ]); }); - if (semver.gte(ts.version, '3.2.0')) { + if (tsSupportsTsconfigInheritanceViaNodePackages) { test('should pull ts-node options from extended `tsconfig.json`', async () => { const { err, stdout } = await exec( `${BIN_PATH} --show-config --project ./tsconfig-extends/tsconfig.json` @@ -810,33 +809,33 @@ test.suite('ts-node', (test) => { } ); - if (semver.gte(ts.version, '3.2.0')) { - test.suite( - 'should bundle @tsconfig/bases to be used in your own tsconfigs', - (test) => { - const macro = test.macro((nodeVersion: string) => async (t) => { - const config = require(`@tsconfig/${nodeVersion}/tsconfig.json`); - const { err, stdout, stderr } = await exec( - `${BIN_PATH} --showConfig -e 10n`, - { - cwd: join(TEST_DIR, 'tsconfig-bases', nodeVersion), - } - ); - expect(err).toBe(null); - t.like(JSON.parse(stdout), { - compilerOptions: { - target: config.compilerOptions.target, - lib: config.compilerOptions.lib, - }, - }); + test.suite( + 'should bundle @tsconfig/bases to be used in your own tsconfigs', + (test) => { + test.runIf(tsSupportsTsconfigInheritanceViaNodePackages); + + const macro = test.macro((nodeVersion: string) => async (t) => { + const config = require(`@tsconfig/${nodeVersion}/tsconfig.json`); + const { err, stdout, stderr } = await exec( + `${BIN_PATH} --showConfig -e 10n`, + { + cwd: join(TEST_DIR, 'tsconfig-bases', nodeVersion), + } + ); + expect(err).toBe(null); + t.like(JSON.parse(stdout), { + compilerOptions: { + target: config.compilerOptions.target, + lib: config.compilerOptions.lib, + }, }); - test(`ts-node/node10/tsconfig.json`, macro, 'node10'); - test(`ts-node/node12/tsconfig.json`, macro, 'node12'); - test(`ts-node/node14/tsconfig.json`, macro, 'node14'); - test(`ts-node/node16/tsconfig.json`, macro, 'node16'); - } - ); - } + }); + test(`ts-node/node10/tsconfig.json`, macro, 'node10'); + test(`ts-node/node12/tsconfig.json`, macro, 'node12'); + test(`ts-node/node14/tsconfig.json`, macro, 'node14'); + test(`ts-node/node16/tsconfig.json`, macro, 'node16'); + } + ); test.suite('compiler host', (test) => { test('should execute cli', async () => { @@ -896,7 +895,7 @@ test.suite('ts-node', (test) => { }); }); - if (semver.gte(ts.version, '3.2.0')) { + if (tsSupportsShowConfig) { test('--showConfig should log resolved configuration', async (t) => { function native(path: string) { return path.replace(/\/|\\/g, pathSep); diff --git a/src/test/pluggable-dep-resolution.spec.ts b/src/test/pluggable-dep-resolution.spec.ts new file mode 100644 index 000000000..95504351b --- /dev/null +++ b/src/test/pluggable-dep-resolution.spec.ts @@ -0,0 +1,98 @@ +import { context } from './testlib'; +import { + contextTsNodeUnderTest, + resetNodeEnvironment, + tsSupportsTsconfigInheritanceViaNodePackages, +} from './helpers'; +import * as expect from 'expect'; +import { resolve } from 'path'; + +const test = context(contextTsNodeUnderTest); + +test.suite( + 'Pluggable dependency (compiler, transpiler, swc backend) is require()d relative to the tsconfig file that declares it', + (test) => { + test.runSerially(); + + // The use-case we want to support: + // + // User shares their tsconfig across multiple projects as an npm module named "shared-config", similar to @tsconfig/bases + // In their npm module + // They have tsconfig.json with `swc: true` or `compiler: "ts-patch"` or something like that + // The module declares a dependency on a known working version of @swc/core, or ts-patch, or something like that. + // They use this reusable config via `npm install shared-config` and `"extends": "shared-config/tsconfig.json"` + // + // ts-node should resolve ts-patch or @swc/core relative to the extended tsconfig + // to ensure we use the known working versions. + + const macro = _macro.bind(null, test); + + macro('tsconfig-custom-compiler.json', 'root custom compiler'); + macro('tsconfig-custom-transpiler.json', 'root custom transpiler'); + macro('tsconfig-swc-custom-backend.json', 'root custom swc backend'); + macro('tsconfig-swc-core.json', 'root @swc/core'); + macro('tsconfig-swc-wasm.json', 'root @swc/wasm'); + macro('tsconfig-swc.json', 'root @swc/core'); + + macro( + 'node_modules/shared-config/tsconfig-custom-compiler.json', + 'shared-config custom compiler' + ); + macro( + 'node_modules/shared-config/tsconfig-custom-transpiler.json', + 'shared-config custom transpiler' + ); + macro( + 'node_modules/shared-config/tsconfig-swc-custom-backend.json', + 'shared-config custom swc backend' + ); + macro( + 'node_modules/shared-config/tsconfig-swc-core.json', + 'shared-config @swc/core' + ); + macro( + 'node_modules/shared-config/tsconfig-swc-wasm.json', + 'shared-config @swc/wasm' + ); + macro( + 'node_modules/shared-config/tsconfig-swc.json', + 'shared-config @swc/core' + ); + + test.suite('"extends"', (test) => { + test.runIf(tsSupportsTsconfigInheritanceViaNodePackages); + + const macro = _macro.bind(null, test); + + macro( + 'tsconfig-extend-custom-compiler.json', + 'shared-config custom compiler' + ); + macro( + 'tsconfig-extend-custom-transpiler.json', + 'shared-config custom transpiler' + ); + macro( + 'tsconfig-extend-swc-custom-backend.json', + 'shared-config custom swc backend' + ); + macro('tsconfig-extend-swc-core.json', 'shared-config @swc/core'); + macro('tsconfig-extend-swc-wasm.json', 'shared-config @swc/wasm'); + macro('tsconfig-extend-swc.json', 'shared-config @swc/core'); + }); + + function _macro(_test: typeof test, config: string, expected: string) { + _test(`${config} uses ${expected}`, async (t) => { + t.teardown(resetNodeEnvironment); + + const output = t.context.tsNodeUnderTest + .create({ + project: resolve('tests/pluggable-dep-resolution', config), + }) + .compile('', 'index.ts'); + + expect(output).toContain(`emit from ${expected}\n`); + }); + } + } +); diff --git a/src/test/testlib.ts b/src/test/testlib.ts index 377d93ef3..6304164bb 100644 --- a/src/test/testlib.ts +++ b/src/test/testlib.ts @@ -19,6 +19,19 @@ export { ExecutionContext, expect }; // each .spec file in its own process, so actual concurrency is higher. const concurrencyLimiter = throat(16); +function errorPostprocessor(fn: T): T { + return async function (this: any) { + try { + return await fn.call(this, arguments); + } catch (error: any) { + delete error?.matcherResult; + // delete error?.matcherResult?.message; + if (error?.message) error.message = `\n${error.message}\n`; + throw error; + } + } as any; +} + function once(func: T): T { let run = false; let ret: any = undefined; @@ -35,7 +48,8 @@ export const test = createTestInterface({ mustDoSerial: false, automaticallyDoSerial: false, automaticallySkip: false, - separator: ' > ', + // The little right chevron used by ava + separator: ' \u203a ', titlePrefix: undefined, }); // In case someone wants to `const test = _test.context()` @@ -101,6 +115,8 @@ export interface TestInterface< skipUnless(conditional: boolean): void; /** If conditional is true, run tests, otherwise skip them */ runIf(conditional: boolean): void; + /** If conditional is false, skip tests */ + skipIf(conditional: boolean): void; // TODO add teardownEach } @@ -167,14 +183,16 @@ function createTestInterface(opts: { ) { const wrappedMacros = macros.map((macro) => { return async function (t: ExecutionContext, ...args: any[]) { - return concurrencyLimiter(async () => { - let i = 0; - for (const func of beforeEachFunctions) { - await func(t); - i++; - } - return macro(t, ...args); - }); + return concurrencyLimiter( + errorPostprocessor(async () => { + let i = 0; + for (const func of beforeEachFunctions) { + await func(t); + i++; + } + return macro(t, ...args); + }) + ); }; }); const computedTitle = computeTitle(title); @@ -270,5 +288,8 @@ function createTestInterface(opts: { assertOrderingForDeclaringSkipUnless(); automaticallySkip = automaticallySkip || !runIfTrue; }; + test.skipIf = function (skipIfTrue: boolean) { + test.runIf(!skipIfTrue); + }; return test as any; } diff --git a/src/transpilers/swc.ts b/src/transpilers/swc.ts index 23949595d..1d8d1c441 100644 --- a/src/transpilers/swc.ts +++ b/src/transpilers/swc.ts @@ -16,22 +16,23 @@ export function create(createOptions: SwcTranspilerOptions): Transpiler { const { swc, service: { config, projectLocalResolveHelper }, + transpilerConfigLocalResolveHelper, } = createOptions; // Load swc compiler let swcInstance: typeof swcWasm; if (typeof swc === 'string') { - swcInstance = require(projectLocalResolveHelper( + swcInstance = require(transpilerConfigLocalResolveHelper( swc, true )) as typeof swcWasm; } else if (swc == null) { let swcResolved; try { - swcResolved = projectLocalResolveHelper('@swc/core', true); + swcResolved = transpilerConfigLocalResolveHelper('@swc/core', true); } catch (e) { try { - swcResolved = projectLocalResolveHelper('@swc/wasm', true); + swcResolved = transpilerConfigLocalResolveHelper('@swc/wasm', true); } catch (e) { throw new Error( 'swc compiler requires either @swc/core or @swc/wasm to be installed as a dependency. See https://typestrong.org/ts-node/docs/transpilers' diff --git a/src/transpilers/types.ts b/src/transpilers/types.ts index ab524cbdc..f5eeff5bd 100644 --- a/src/transpilers/types.ts +++ b/src/transpilers/types.ts @@ -1,5 +1,6 @@ import type * as ts from 'typescript'; import type { Service } from '../index'; +import type { ProjectLocalResolveHelper } from '../util'; /** * Third-party transpilers are implemented as a CommonJS module with a @@ -21,6 +22,11 @@ export interface CreateTranspilerOptions { Service, Extract<'config' | 'options' | 'projectLocalResolveHelper', keyof Service> >; + /** + * If `"transpiler"` option is declared in an "extends" tsconfig, this path might be different than + * the `projectLocalResolveHelper` + */ + transpilerConfigLocalResolveHelper: ProjectLocalResolveHelper; } export interface Transpiler { // TODOs diff --git a/tests/esm-child-process/via-flag/index.ts b/tests/esm-child-process/via-flag/index.ts new file mode 100644 index 000000000..939272be8 --- /dev/null +++ b/tests/esm-child-process/via-flag/index.ts @@ -0,0 +1,3 @@ +import { strictEqual } from 'assert'; +strictEqual(import.meta.url.includes('index.ts'), true); +console.log(`CLI args: ${process.argv.slice(2).join(' ')}`); diff --git a/tests/esm-child-process/via-flag/package.json b/tests/esm-child-process/via-flag/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/esm-child-process/via-flag/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/esm-child-process/via-flag/tsconfig.json b/tests/esm-child-process/via-flag/tsconfig.json new file mode 100644 index 000000000..25a7642af --- /dev/null +++ b/tests/esm-child-process/via-flag/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "module": "ESNext", + "esModuleInterop": true + }, + "ts-node": { + "swc": true + } +} diff --git a/tests/esm-child-process/via-tsconfig/index.ts b/tests/esm-child-process/via-tsconfig/index.ts new file mode 100644 index 000000000..939272be8 --- /dev/null +++ b/tests/esm-child-process/via-tsconfig/index.ts @@ -0,0 +1,3 @@ +import { strictEqual } from 'assert'; +strictEqual(import.meta.url.includes('index.ts'), true); +console.log(`CLI args: ${process.argv.slice(2).join(' ')}`); diff --git a/tests/esm-child-process/via-tsconfig/package.json b/tests/esm-child-process/via-tsconfig/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/esm-child-process/via-tsconfig/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/esm-child-process/via-tsconfig/sleep.ts b/tests/esm-child-process/via-tsconfig/sleep.ts new file mode 100644 index 000000000..f45b9dadc --- /dev/null +++ b/tests/esm-child-process/via-tsconfig/sleep.ts @@ -0,0 +1,13 @@ +setTimeout(function () { + console.log('Slept 30 seconds'); +}, 30e3); +process.on('SIGTERM', onSignal); +process.on('SIGINT', onSignal); +console.log('child registered signal handlers'); +function onSignal(signal: string) { + console.log(`child received signal: ${signal}`); + setTimeout(() => { + console.log(`child exiting`); + process.exit(123); + }, 5e3); +} diff --git a/tests/esm-child-process/via-tsconfig/tsconfig.json b/tests/esm-child-process/via-tsconfig/tsconfig.json new file mode 100644 index 000000000..31f702b87 --- /dev/null +++ b/tests/esm-child-process/via-tsconfig/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "ESNext", + "esModuleInterop": true + }, + "ts-node": { + "esm": true, + "swc": true + } +} diff --git a/tests/pluggable-dep-resolution/node_modules/@swc/core/index.js b/tests/pluggable-dep-resolution/node_modules/@swc/core/index.js new file mode 100644 index 000000000..b9924d5f4 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/@swc/core/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from root @swc/core', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/@swc/wasm/index.js b/tests/pluggable-dep-resolution/node_modules/@swc/wasm/index.js new file mode 100644 index 000000000..f149018fb --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/@swc/wasm/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from root @swc/wasm', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js b/tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js new file mode 100644 index 000000000..806376ab1 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js @@ -0,0 +1,9 @@ +module.exports = { + ...require('typescript'), + transpileModule() { + return { + outputText: 'emit from root custom compiler', + sourceMapText: '{}', + }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/custom-swc/index.js b/tests/pluggable-dep-resolution/node_modules/custom-swc/index.js new file mode 100644 index 000000000..e23907430 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/custom-swc/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from root custom swc backend', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/custom-transpiler/index.js b/tests/pluggable-dep-resolution/node_modules/custom-transpiler/index.js new file mode 100644 index 000000000..ed3d1cb28 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/custom-transpiler/index.js @@ -0,0 +1,10 @@ +module.exports.create = function () { + return { + transpile() { + return { + outputText: 'emit from root custom transpiler', + sourceMapText: '{}', + }; + }, + }; + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/core/index.js b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/core/index.js new file mode 100644 index 000000000..ee65ccdd9 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/core/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from shared-config @swc/core', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/wasm/index.js b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/wasm/index.js new file mode 100644 index 000000000..7b4a479ea --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/wasm/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from shared-config @swc/wasm', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js new file mode 100644 index 000000000..b1a45e628 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js @@ -0,0 +1,9 @@ +module.exports = { + ...require('typescript'), + transpileModule() { + return { + outputText: 'emit from shared-config custom compiler', + sourceMapText: '{}', + }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-swc/index.js b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-swc/index.js new file mode 100644 index 000000000..9d69e702a --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-swc/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from shared-config custom swc backend', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-transpiler/index.js b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-transpiler/index.js new file mode 100644 index 000000000..d8ca0d3f6 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-transpiler/index.js @@ -0,0 +1,10 @@ +module.exports.create = function () { + return { + transpile() { + return { + outputText: 'emit from shared-config custom transpiler', + sourceMapText: '{}', + }; + }, + }; + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-compiler.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-compiler.json new file mode 100644 index 000000000..926d54985 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-compiler.json @@ -0,0 +1 @@ +{"ts-node":{"transpileOnly":true,"compiler":"custom-compiler"}} diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-transpiler.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-transpiler.json new file mode 100644 index 000000000..bb64bd1f2 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-transpiler.json @@ -0,0 +1 @@ +{"ts-node":{"transpileOnly":true,"transpiler":"custom-transpiler"}} diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-core.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-core.json new file mode 100644 index 000000000..c4191aec0 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-core.json @@ -0,0 +1 @@ +{"ts-node":{"transpileOnly":true,"transpiler":["ts-node/transpilers/swc",{"swc":"@swc/core"}]}} diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-custom-backend.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-custom-backend.json new file mode 100644 index 000000000..c23cd162e --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-custom-backend.json @@ -0,0 +1 @@ +{"ts-node":{"transpileOnly":true,"transpiler":["ts-node/transpilers/swc",{"swc":"custom-swc"}]}} diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-wasm.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-wasm.json new file mode 100644 index 000000000..94d91973a --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-wasm.json @@ -0,0 +1 @@ +{"ts-node":{"transpileOnly":true,"transpiler":["ts-node/transpilers/swc",{"swc":"@swc/wasm"}]}} diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc.json new file mode 100644 index 000000000..430482e84 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc.json @@ -0,0 +1 @@ +{"ts-node":{"swc":true}} diff --git a/tests/pluggable-dep-resolution/tsconfig-custom-compiler.json b/tests/pluggable-dep-resolution/tsconfig-custom-compiler.json new file mode 100644 index 000000000..12f1bfe6d --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-custom-compiler.json @@ -0,0 +1 @@ +{ "ts-node": { "transpileOnly": true, "compiler": "custom-compiler" } } diff --git a/tests/pluggable-dep-resolution/tsconfig-custom-transpiler.json b/tests/pluggable-dep-resolution/tsconfig-custom-transpiler.json new file mode 100644 index 000000000..c2339a1ea --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-custom-transpiler.json @@ -0,0 +1 @@ +{ "ts-node": { "transpileOnly": true, "transpiler": "custom-transpiler" } } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-custom-compiler.json b/tests/pluggable-dep-resolution/tsconfig-extend-custom-compiler.json new file mode 100644 index 000000000..674b908e2 --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-custom-compiler.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-custom-compiler.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-custom-transpiler.json b/tests/pluggable-dep-resolution/tsconfig-extend-custom-transpiler.json new file mode 100644 index 000000000..afe9b5d7e --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-custom-transpiler.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-custom-transpiler.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-swc-core.json b/tests/pluggable-dep-resolution/tsconfig-extend-swc-core.json new file mode 100644 index 000000000..4ad6e1a89 --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-swc-core.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-swc-core.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-swc-custom-backend.json b/tests/pluggable-dep-resolution/tsconfig-extend-swc-custom-backend.json new file mode 100644 index 000000000..c28b49a1a --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-swc-custom-backend.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-swc-custom-backend.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-swc-wasm.json b/tests/pluggable-dep-resolution/tsconfig-extend-swc-wasm.json new file mode 100644 index 000000000..8acee2395 --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-swc-wasm.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-swc-wasm.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-swc.json b/tests/pluggable-dep-resolution/tsconfig-extend-swc.json new file mode 100644 index 000000000..29827a78a --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-swc.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-swc.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-swc-core.json b/tests/pluggable-dep-resolution/tsconfig-swc-core.json new file mode 100644 index 000000000..8e33432ef --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-swc-core.json @@ -0,0 +1,6 @@ +{ + "ts-node": { + "transpileOnly": true, + "transpiler": ["ts-node/transpilers/swc", { "swc": "@swc/core" }] + } +} diff --git a/tests/pluggable-dep-resolution/tsconfig-swc-custom-backend.json b/tests/pluggable-dep-resolution/tsconfig-swc-custom-backend.json new file mode 100644 index 000000000..7a3d24429 --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-swc-custom-backend.json @@ -0,0 +1,6 @@ +{ + "ts-node": { + "transpileOnly": true, + "transpiler": ["ts-node/transpilers/swc", { "swc": "custom-swc" }] + } +} diff --git a/tests/pluggable-dep-resolution/tsconfig-swc-wasm.json b/tests/pluggable-dep-resolution/tsconfig-swc-wasm.json new file mode 100644 index 000000000..bfa5a0ebe --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-swc-wasm.json @@ -0,0 +1,6 @@ +{ + "ts-node": { + "transpileOnly": true, + "transpiler": ["ts-node/transpilers/swc", { "swc": "@swc/wasm" }] + } +} diff --git a/tests/pluggable-dep-resolution/tsconfig-swc.json b/tests/pluggable-dep-resolution/tsconfig-swc.json new file mode 100644 index 000000000..9f1295318 --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-swc.json @@ -0,0 +1 @@ +{ "ts-node": { "swc": true } } diff --git a/website/docs/imports.md b/website/docs/imports.md index 6b04f776f..4a0ea5c7b 100644 --- a/website/docs/imports.md +++ b/website/docs/imports.md @@ -11,7 +11,7 @@ Here is a brief comparison of the two. | Write native `import` syntax | Write native `import` syntax | | Transforms `import` into `require()` | Does not transform `import` | | Node executes scripts using the classic [CommonJS loader](https://nodejs.org/dist/latest-v16.x/docs/api/modules.html) | Node executes scripts using the new [ESM loader](https://nodejs.org/dist/latest-v16.x/docs/api/esm.html) | -| Use any of:
ts-node CLI
`node -r ts-node/register`
`NODE_OPTIONS="ts-node/register" node`
`require('ts-node').register({/* options */})` | Must use the ESM loader via:
`node --loader ts-node/esm`
`NODE_OPTIONS="--loader ts-node/esm" node` | +| Use any of:
`ts-node`
`node -r ts-node/register`
`NODE_OPTIONS="ts-node/register" node`
`require('ts-node').register({/* options */})` | Use any of:
`ts-node --esm`
`ts-node-esm`
Set `"esm": true` in `tsconfig.json`
`node --loader ts-node/esm`
`NODE_OPTIONS="--loader ts-node/esm" node` | ## CommonJS @@ -65,6 +65,32 @@ You must set [`"type": "module"`](https://nodejs.org/api/packages.html#packages_ { "compilerOptions": { "module": "ESNext" // or ES2015, ES2020 + }, + "ts-node": { + // Tell ts-node CLI to install the --loader automatically, explained below + "esm": true } } ``` + +You must also ensure node is passed `--loader`. The ts-node CLI will do this automatically with our `esm` option. + +> Note: `--esm` must spawn a child process to pass it `--loader`. This may change if node adds the ability to install loader hooks +into the current process. + +```shell +# pass the flag +ts-node --esm +# Use the convenience binary +ts-node-esm +# or add `"esm": true` to your tsconfig.json to make it automatic +ts-node +``` + +If you are not using our CLI, pass the loader flag to node. + +```shell +node --loader ts-node/esm ./index.ts +# Or via environment variable +NODE_OPTIONS="--loader ts-node/esm" node ./index.ts +``` diff --git a/website/docs/options.md b/website/docs/options.md index aadf36da9..96e49c1eb 100644 --- a/website/docs/options.md +++ b/website/docs/options.md @@ -15,6 +15,7 @@ _Environment variables, where available, are in `ALL_CAPS`_ - `-e, --eval` Evaluate code - `-p, --print` Print result of `--eval` - `-i, --interactive` Opens the REPL even if stdin does not appear to be a terminal +- `--esm` Bootstrap with the ESM loader, enabling full ESM support ## TSConfig diff --git a/website/docs/usage.md b/website/docs/usage.md index d988e606b..1e6e563e8 100644 --- a/website/docs/usage.md +++ b/website/docs/usage.md @@ -25,6 +25,9 @@ ts-node-transpile-only script.ts # Equivalent to ts-node --cwdMode ts-node-cwd script.ts + +# Equivalent to ts-node --esm +ts-node-esm script.ts ``` ## Shebang