diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..b249da6d --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,3 @@ +# Prettier formatting +9d05cb684fc3a6e492832100a125ea07d1cc98c5 + diff --git a/README.md b/README.md index a08d3648..63625d5e 100644 --- a/README.md +++ b/README.md @@ -1057,7 +1057,9 @@ These tricks will make ts-node faster. ## Skip typechecking -It is often better to use `tsc --noEmit` to typecheck as part of your tests or linting. In these cases, ts-node can skip typechecking. +It is often better to typecheck as part of your tests or linting. You can run `tsc --noEmit` to do this. In these cases, ts-node can skip typechecking, making it much faster. + +To skip typechecking in ts-node, do one of the following: * Enable [swc](#swc) * This is by far the fastest option @@ -1065,6 +1067,8 @@ It is often better to use `tsc --noEmit` to typecheck as part of your tests or l ## With typechecking +If you absolutely must typecheck in ts-node: + * Avoid dynamic `require()` which may trigger repeated typechecking; prefer `import` * Try with and without `--files`; one may be faster depending on your project * Check `tsc --showConfig`; make sure all executed files are included diff --git a/api-extractor/ts-node.api.md b/api-extractor/ts-node.api.md index 9e16ce1d..6d42f4a4 100644 --- a/api-extractor/ts-node.api.md +++ b/api-extractor/ts-node.api.md @@ -29,6 +29,7 @@ export interface CreateOptions { esm?: boolean; experimentalReplAwait?: boolean; experimentalSpecifierResolution?: 'node' | 'explicit'; + experimentalTsImportSpecifiers?: boolean; // (undocumented) fileExists?: (path: string) => boolean; files?: boolean; diff --git a/package-lock.json b/package-lock.json index 237b2d06..90ef1939 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "ts-node", - "version": "10.8.2", + "version": "10.9.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -508,21 +508,6 @@ } } }, - "@napi-rs/triples": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@napi-rs/triples/-/triples-1.0.3.tgz", - "integrity": "sha512-jDJTpta+P4p1NZTFVLHJ/TLFVYVcOqv6l8xwOeBKNPMgY/zDYH/YH7SJbvrr/h1RcS9GzbPcLKGzpuK9cV56UA==", - "dev": true - }, - "@node-rs/helper": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@node-rs/helper/-/helper-1.2.1.tgz", - "integrity": "sha512-R5wEmm8nbuQU0YGGmYVjEc0OHtYsuXdpRG+Ut/3wZ9XAvQWyThN08bTh2cBJgoZxHQUPtvRfeQuxcAgLuiBISg==", - "dev": true, - "requires": { - "@napi-rs/triples": "^1.0.3" - } - }, "@nodelib/fs.scandir": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz", @@ -644,114 +629,121 @@ "dev": true }, "@swc/core": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.2.106.tgz", - "integrity": "sha512-9uw8gqU+lsk7KROAcSNhsrnBgNiC5H4MIaps5LlnnEevJmKu/o1ws22tXc2qjJg+F4/V1ynUbh8E0rYlmo1XGw==", - "dev": true, - "requires": { - "@node-rs/helper": "^1.0.0", - "@swc/core-android-arm64": "^1.2.106", - "@swc/core-darwin-arm64": "^1.2.106", - "@swc/core-darwin-x64": "^1.2.106", - "@swc/core-freebsd-x64": "^1.2.106", - "@swc/core-linux-arm-gnueabihf": "^1.2.106", - "@swc/core-linux-arm64-gnu": "^1.2.106", - "@swc/core-linux-arm64-musl": "^1.2.106", - "@swc/core-linux-x64-gnu": "^1.2.106", - "@swc/core-linux-x64-musl": "^1.2.106", - "@swc/core-win32-arm64-msvc": "^1.2.106", - "@swc/core-win32-ia32-msvc": "^1.2.106", - "@swc/core-win32-x64-msvc": "^1.2.106" - } + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.2.205.tgz", + "integrity": "sha512-evq0/tFyYdYgOhKb//+G93fxe9zwFxtme7NL7wSiEF8+4/ON4Y5AI9eHLoqddXqs3W8Y0HQi+rJmlrkCibrseA==", + "dev": true, + "requires": { + "@swc/core-android-arm-eabi": "1.2.205", + "@swc/core-android-arm64": "1.2.205", + "@swc/core-darwin-arm64": "1.2.205", + "@swc/core-darwin-x64": "1.2.205", + "@swc/core-freebsd-x64": "1.2.205", + "@swc/core-linux-arm-gnueabihf": "1.2.205", + "@swc/core-linux-arm64-gnu": "1.2.205", + "@swc/core-linux-arm64-musl": "1.2.205", + "@swc/core-linux-x64-gnu": "1.2.205", + "@swc/core-linux-x64-musl": "1.2.205", + "@swc/core-win32-arm64-msvc": "1.2.205", + "@swc/core-win32-ia32-msvc": "1.2.205", + "@swc/core-win32-x64-msvc": "1.2.205" + } + }, + "@swc/core-android-arm-eabi": { + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-android-arm-eabi/-/core-android-arm-eabi-1.2.205.tgz", + "integrity": "sha512-HfiuVA1JDHMSRQ8nE1DcemUgZ1PKaPwit4i7q3xin0NVbVHY1xkJyQFuLVh3VxTvGKKkF3hi8GJMVQgOXWL6kg==", + "dev": true, + "optional": true }, "@swc/core-android-arm64": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-android-arm64/-/core-android-arm64-1.2.106.tgz", - "integrity": "sha512-F5T6kP3yV9S0/oXyco305QaAyE6rLNsNSdR0QI4CtACwKadiPwTOptwNIDCiTNLNgWlWLQmIRkPpxg+G4doT6Q==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-android-arm64/-/core-android-arm64-1.2.205.tgz", + "integrity": "sha512-sRGZBV2dOnmh8gWWFo9HVOHdKa33zIsF8/8oYEGtq+2/s96UlAKltO2AA7HH9RaO/fT1tzBZStp+fEMUhDk/FA==", "dev": true, "optional": true }, "@swc/core-darwin-arm64": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.2.106.tgz", - "integrity": "sha512-bgKzzYLFnc+mv2mDS/DLwzBvx5DCC9ZCKYY46b4dAnBfasr+SMHj+v/WI84HtilbjLBMUfYZ2hgYKls3CebIIQ==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.2.205.tgz", + "integrity": "sha512-JwVDfKS7vp7zzOQXWNwwcF41h4r3DWEpK6DQjz18WJyS1VVOcpVQGyuE7kSPjcnG01ZxBL9JPwwT353i/8IwDg==", "dev": true, "optional": true }, "@swc/core-darwin-x64": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.2.106.tgz", - "integrity": "sha512-I5Uhit5RqbXaMIV2+v9jL+MIQeR3lT1DqVwzxZs1bTARclAheFZQpTmg+h6QmichjCiUT74SXQb6Apc/vqYKog==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.2.205.tgz", + "integrity": "sha512-malz2I+w6xFF1QyTmPGt0Y0NEMbUcrvfr5gUfZDGjxMhPPlS7k6fXucuZxVr9VVaM+JGq1SidVODmZ84jb1qHg==", "dev": true, "optional": true }, "@swc/core-freebsd-x64": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-freebsd-x64/-/core-freebsd-x64-1.2.106.tgz", - "integrity": "sha512-ZSK3vgzbA2Pkpw2LgHlAkUdx4okIpdXXTbLXuc5jkZMw1KhRWpeQaDlwbrN7XVynAYjkj2qgGQ7wv1tD43vQig==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-freebsd-x64/-/core-freebsd-x64-1.2.205.tgz", + "integrity": "sha512-/nZrG1z0T7h97AsOb/wOtYlnh4WEuNppv3XKQIMPj32YNQdMBVgpybVTVRIs1GQGZMd1/7jAy5BVQcwQjUbrLg==", "dev": true, "optional": true }, "@swc/core-linux-arm-gnueabihf": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.2.106.tgz", - "integrity": "sha512-WZh6XV8cQ9Fh3IQNX9z87Tv68+sLtfnT51ghMQxceRhfvc5gIaYW+PCppezDDdlPJnWXhybGWNPAl5SHppWb2g==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.2.205.tgz", + "integrity": "sha512-mTA3vETMdBmpecUyI9waZYsp7FABhew4e81psspmFpDyfty0SLISWZDnvPAn0pSnb2fWhzKwDC5kdXHKUmLJuA==", "dev": true, "optional": true }, "@swc/core-linux-arm64-gnu": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.2.106.tgz", - "integrity": "sha512-OSI9VUWPsRrCbUlRQ4KdYqdwV63VYBC5ahSNq+72DXhtRwVbLSFuF7MNsnXgUSMHidxbc0No3/bPPamshqHdsQ==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.2.205.tgz", + "integrity": "sha512-qGzFGryeQE+O5SFK7Nn2ESqCEnv00rnzhf11WZF9V71EZ15amIhmbcwHqvFpoRSDw8hZnqoGqfPRfoJbouptnA==", "dev": true, "optional": true }, "@swc/core-linux-arm64-musl": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.2.106.tgz", - "integrity": "sha512-de8AAUOP8D2/tZIpQ399xw+pGGKlR1+l5Jmy4lW7ixarEI4xKkBSF4bS9eXtC1jckmenzrLPiK/5sSbQSf6BWQ==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.2.205.tgz", + "integrity": "sha512-uLJoX9L/4Xg3sLMjAbIhzbTe5gD/MBA8VETBeEkLtgb7a0ys1kvn9xQ6qLw6A71amEPlI+VABnoTRdUEaBSV9Q==", "dev": true, "optional": true }, "@swc/core-linux-x64-gnu": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.2.106.tgz", - "integrity": "sha512-QzFC7+lBSuVBmX5tS2pdM+74voiJcGgIMJ+x9pcjUu3GkDl3ow6WC6ta2WUzlgGopCGNp6IdZaFemKRzjLr3lw==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.2.205.tgz", + "integrity": "sha512-gQsjcYlkWKP1kceQIsoHGrOrG7ygW3ojNsSnYoZ5DG5PipRA4eeUfO9YIfrmoa29LiVNjmRPfUJa8O1UHDG5ew==", "dev": true, "optional": true }, "@swc/core-linux-x64-musl": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.2.106.tgz", - "integrity": "sha512-QZ1gFqNiCJefkNMihbmYc7nr5stERyjoQpWgAIN6dzrgMUzRHXHGDRl/p1qsXW2VKos+okSdLwPFEmRT94H+1A==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.2.205.tgz", + "integrity": "sha512-LR5ukqBltQc++2eX3qEj/H8KtOt0V3CmtgXNOiNCUxvPDT8mYz/8MJhYOrofonND0RKfXyyPW7dRxg62ceTLSQ==", "dev": true, "optional": true }, "@swc/core-win32-arm64-msvc": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.2.106.tgz", - "integrity": "sha512-MbuQwk+s43bfBNnAZTKnoQlfo4UPSOsy6t9F15yU4P3rVUuFtcxdZg6CpDnUqNPbojILXujp8z4SSigRYh5cgg==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.2.205.tgz", + "integrity": "sha512-NjcLWm4mOy78LAEt7pqFl+SLcCyqnSlUP729XRd1uRvKwt1Cwch5SQRdoaFqwf1DaEQy4H4iuGPynkfarlb1kQ==", "dev": true, "optional": true }, "@swc/core-win32-ia32-msvc": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.2.106.tgz", - "integrity": "sha512-BFxWpcPxsG2LLQZ+8K8ma45rbTckjpPbnvOOhybQ0hEhLgoVzMVPp3RIUGmC+RMZe6DkGSaEQf/Rjn2cbMdQhw==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.2.205.tgz", + "integrity": "sha512-+6byrRxIXgZ0zmLL6ZeX1HBBrAqvCy8MR5Yz0SO26jR8OPZXJCgZXL9BTsZO+YEG4f32ZOlZh3nnHCl6Dcb4GA==", "dev": true, "optional": true }, "@swc/core-win32-x64-msvc": { - "version": "1.2.106", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.2.106.tgz", - "integrity": "sha512-Emn5akqApGXzPsA7ntSXEohL0AH0WjQMHy6mT3MS9Yil42yTJ96dJGf68ejKVptxwg7Iz798mT+J9r1JbAFBgg==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.2.205.tgz", + "integrity": "sha512-RRSkyAol0c7sU9gejtrpF8TLmdYdBjLutcmQHtLKbWTm74ZLidZpF28G0J2tD7HNmzQnMpLzyoT1jW9JgLwzVg==", "dev": true, "optional": true }, "@swc/wasm": { - "version": "1.2.58", - "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.2.58.tgz", - "integrity": "sha512-3PAMVT+clB2xZsaVtQK2WjgeCftCxDO/0q8JCwW5g3CrLL/WBOCHyCKME3CzGz7V9zfw84QZZMpZY26gohhF/w==", + "version": "1.2.205", + "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.2.205.tgz", + "integrity": "sha512-xRI7Lrg/v18EnJIHRDflD09bif4Ivc2W0dhRGtTmjdsSrCjJFrArDGaGttD2Fwuv7q1S4/uAWMLFHncwwSyO3Q==", "dev": true }, "@szmarczak/http-timer": { @@ -3583,6 +3575,12 @@ } } }, + "outdent": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", + "integrity": "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==", + "dev": true + }, "p-cancelable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", @@ -4670,9 +4668,9 @@ } }, "typescript": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.2.tgz", - "integrity": "sha512-Mamb1iX2FDUpcTRzltPxgWMKy3fhg0TN378ylbktPGPK/99KbDtMQ4W1hwgsbPAsG3a0xKa1vmw4VKZQbkvz5A==", + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "dev": true }, "typescript-json-schema": { diff --git a/package.json b/package.json index 7fe9bad6..a6842cf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ts-node", - "version": "10.8.2", + "version": "10.9.0", "description": "TypeScript execution environment and REPL for node.js, with source map support", "main": "dist/index.js", "exports": { @@ -112,8 +112,8 @@ "homepage": "https://typestrong.org/ts-node", "devDependencies": { "@microsoft/api-extractor": "^7.19.4", - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", + "@swc/core": ">=1.2.205", + "@swc/wasm": ">=1.2.205", "@types/diff": "^4.0.2", "@types/lodash": "^4.14.151", "@types/node": "13.13.5", @@ -131,6 +131,7 @@ "lodash": "^4.17.15", "ntypescript": "^1.201507091536.1", "nyc": "^15.0.1", + "outdent": "^0.8.0", "proper-lockfile": "^4.1.2", "proxyquire": "^2.0.0", "react": "^16.14.0", @@ -138,7 +139,7 @@ "semver": "^7.1.3", "throat": "^6.0.1", "typedoc": "^0.22.10", - "typescript": "4.7.2", + "typescript": "4.7.4", "typescript-json-schema": "^0.53.0", "util.promisify": "^1.0.1" }, diff --git a/src/bin.ts b/src/bin.ts index 8b5f9176..fb3208c4 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -48,7 +48,8 @@ export function main( const state: BootstrapState = { shouldUseChildProcess: false, isInChildProcess: false, - entrypoint: __filename, + isCli: true, + tsNodeScript: __filename, parseArgvResult: args, }; return bootstrap(state); @@ -62,7 +63,12 @@ export function main( export interface BootstrapState { isInChildProcess: boolean; shouldUseChildProcess: boolean; - entrypoint: string; + /** + * True if bootstrapping the ts-node CLI process or the direct child necessitated by `--esm`. + * false if bootstrapping a subsequently `fork()`ed child. + */ + isCli: boolean; + tsNodeScript: string; parseArgvResult: ReturnType; phase2Result?: ReturnType; phase3Result?: ReturnType; @@ -73,12 +79,16 @@ export function bootstrap(state: BootstrapState) { if (!state.phase2Result) { state.phase2Result = phase2(state); if (state.shouldUseChildProcess && !state.isInChildProcess) { + // Note: When transitioning into the child-process after `phase2`, + // the updated working directory needs to be preserved. return callInChild(state); } } if (!state.phase3Result) { state.phase3Result = phase3(state); if (state.shouldUseChildProcess && !state.isInChildProcess) { + // Note: When transitioning into the child-process after `phase2`, + // the updated working directory needs to be preserved. return callInChild(state); } } @@ -264,8 +274,7 @@ function parseArgv(argv: string[], entrypointArgs: Record) { } function phase2(payload: BootstrapState) { - const { help, version, code, interactive, cwdArg, restArgs, esm } = - payload.parseArgvResult; + const { help, version, cwdArg, esm } = payload.parseArgvResult; if (help) { console.log(` @@ -319,28 +328,14 @@ Options: process.exit(0); } - // 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 && restArgs.length); - const executeEntrypoint = !executeEval && restArgs.length > 0; - const executeRepl = - !executeEntrypoint && - (interactive || (process.stdin.isTTY && !executeEval)); - const executeStdin = !executeEval && !executeRepl && !executeEntrypoint; - - const cwd = cwdArg || process.cwd(); - /** Unresolved. May point to a symlink, not realpath. May be missing file extension */ - const scriptPath = executeEntrypoint ? resolve(cwd, restArgs[0]) : undefined; + const cwd = cwdArg ? resolve(cwdArg) : process.cwd(); + // If ESM is explicitly enabled through the flag, stage3 should be run in a child process + // with the ESM loaders configured. if (esm) payload.shouldUseChildProcess = true; + return { - executeEval, - executeEntrypoint, - executeRepl, - executeStdin, cwd, - scriptPath, }; } @@ -372,7 +367,15 @@ function phase3(payload: BootstrapState) { esm, experimentalSpecifierResolution, } = payload.parseArgvResult; - const { cwd, scriptPath } = payload.phase2Result!; + const { cwd } = payload.phase2Result!; + + // NOTE: When we transition to a child process for ESM, the entry-point script determined + // here might not be the one used later in `phase4`. This can happen when we execute the + // original entry-point but then the process forks itself using e.g. `child_process.fork`. + // We will always use the original TS project in forked processes anyway, so it is + // expected and acceptable to retrieve the entry-point information here in `phase2`. + // See: https://github.com/TypeStrong/ts-node/issues/1812. + const { entryPointPath } = getEntryPointInfo(payload); const preloadedConfig = findAndReadConfig({ cwd, @@ -387,7 +390,12 @@ function phase3(payload: BootstrapState) { compilerHost, ignore, logError, - projectSearchDir: getProjectSearchDir(cwd, scriptMode, cwdMode, scriptPath), + projectSearchDir: getProjectSearchDir( + cwd, + scriptMode, + cwdMode, + entryPointPath + ), project, skipProject, skipIgnore, @@ -403,23 +411,77 @@ function phase3(payload: BootstrapState) { experimentalSpecifierResolution as ExperimentalSpecifierResolution, }); + // If ESM is enabled through the parsed tsconfig, stage4 should be run in a child + // process with the ESM loaders configured. if (preloadedConfig.options.esm) payload.shouldUseChildProcess = true; + return { preloadedConfig }; } +/** + * Determines the entry-point information from the argv and phase2 result. This + * method will be invoked in two places: + * + * 1. In phase 3 to be able to find a project from the potential entry-point script. + * 2. In phase 4 to determine the actual entry-point script. + * + * Note that we need to explicitly re-resolve the entry-point information in the final + * stage because the previous stage information could be modified when the bootstrap + * invocation transitioned into a child process for ESM. + * + * Stages before (phase 4) can and will be cached by the child process through the Brotli + * configuration and entry-point information is only reliable in the final phase. More + * details can be found in here: https://github.com/TypeStrong/ts-node/issues/1812. + */ +function getEntryPointInfo(state: BootstrapState) { + const { code, interactive, restArgs } = state.parseArgvResult!; + const { cwd } = state.phase2Result!; + const { isCli } = state; + + // 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 && restArgs.length); + const executeEntrypoint = !executeEval && restArgs.length > 0; + const executeRepl = + !executeEntrypoint && + (interactive || (process.stdin.isTTY && !executeEval)); + const executeStdin = !executeEval && !executeRepl && !executeEntrypoint; + + /** + * Unresolved. May point to a symlink, not realpath. May be missing file extension + * NOTE: resolution relative to cwd option (not `process.cwd()`) is legacy backwards-compat; should be changed in next major: https://github.com/TypeStrong/ts-node/issues/1834 + */ + const entryPointPath = executeEntrypoint + ? isCli + ? resolve(cwd, restArgs[0]) + : resolve(restArgs[0]) + : undefined; + + return { + executeEval, + executeEntrypoint, + executeRepl, + executeStdin, + entryPointPath, + }; +} + function phase4(payload: BootstrapState) { - const { isInChildProcess, entrypoint } = payload; + const { isInChildProcess, tsNodeScript } = payload; const { version, showConfig, restArgs, code, print, argv } = payload.parseArgvResult; + const { cwd } = payload.phase2Result!; + const { preloadedConfig } = payload.phase3Result!; + const { + entryPointPath, + executeEntrypoint, executeEval, - cwd, - executeStdin, executeRepl, - executeEntrypoint, - scriptPath, - } = payload.phase2Result!; - const { preloadedConfig } = payload.phase3Result!; + executeStdin, + } = getEntryPointInfo(payload); + /** * , [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. @@ -566,12 +628,13 @@ function phase4(payload: BootstrapState) { // Prepend `ts-node` arguments to CLI for child processes. process.execArgv.push( - entrypoint, + tsNodeScript, ...argv.slice(2, argv.length - restArgs.length) ); - // TODO this comes from BoostrapState + + // TODO this comes from BootstrapState process.argv = [process.argv[1]] - .concat(executeEntrypoint ? ([scriptPath] as string[]) : []) + .concat(executeEntrypoint ? ([entryPointPath] as string[]) : []) .concat(restArgs.slice(executeEntrypoint ? 1 : 0)); // Execute the main contents (either eval, script or piped). diff --git a/src/child/argv-payload.ts b/src/child/argv-payload.ts new file mode 100644 index 00000000..abe6da9d --- /dev/null +++ b/src/child/argv-payload.ts @@ -0,0 +1,18 @@ +import { brotliCompressSync, brotliDecompressSync, constants } from 'zlib'; + +/** @internal */ +export const argPrefix = '--brotli-base64-config='; + +/** @internal */ +export function compress(object: any) { + return brotliCompressSync(Buffer.from(JSON.stringify(object), 'utf8'), { + [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MIN_QUALITY, + }).toString('base64'); +} + +/** @internal */ +export function decompress(str: string) { + return JSON.parse( + brotliDecompressSync(Buffer.from(str, 'base64')).toString() + ); +} diff --git a/src/child/child-entrypoint.ts b/src/child/child-entrypoint.ts index 03a02d2e..0550170b 100644 --- a/src/child/child-entrypoint.ts +++ b/src/child/child-entrypoint.ts @@ -1,16 +1,24 @@ import { BootstrapState, bootstrap } from '../bin'; -import { brotliDecompressSync } from 'zlib'; +import { argPrefix, compress, decompress } from './argv-payload'; 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); +const state = decompress(base64Payload) as BootstrapState; -bootstrap(payload); +state.isInChildProcess = true; +state.tsNodeScript = __filename; +state.parseArgvResult.argv = process.argv; +state.parseArgvResult.restArgs = process.argv.slice(3); + +// Modify and re-compress the payload delivered to subsequent child processes. +// This logic may be refactored into bin.ts by https://github.com/TypeStrong/ts-node/issues/1831 +if (state.isCli) { + const stateForChildren: BootstrapState = { + ...state, + isCli: false, + }; + state.parseArgvResult.argv[2] = `${argPrefix}${compress(stateForChildren)}`; +} + +bootstrap(state); diff --git a/src/child/spawn-child.ts b/src/child/spawn-child.ts index 12368fce..618b8190 100644 --- a/src/child/spawn-child.ts +++ b/src/child/spawn-child.ts @@ -1,12 +1,15 @@ import type { BootstrapState } from '../bin'; import { spawn } from 'child_process'; -import { brotliCompressSync } from 'zlib'; import { pathToFileURL } from 'url'; import { versionGteLt } from '../util'; +import { argPrefix, compress } from './argv-payload'; -const argPrefix = '--brotli-base64-config='; - -/** @internal */ +/** + * @internal + * @param state Bootstrap state to be transferred into the child process. + * @param targetCwd Working directory to be preserved when transitioning to + * the child process. + */ export function callInChild(state: BootstrapState) { if (!versionGteLt(process.versions.node, '12.17.0')) { throw new Error( @@ -22,9 +25,7 @@ export function callInChild(state: BootstrapState) { // 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')}`, + `${argPrefix}${compress(state)}`, ...state.parseArgvResult.restArgs, ], { diff --git a/src/configuration.ts b/src/configuration.ts index 5142a358..4ab0a7cc 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,4 +1,4 @@ -import { resolve, dirname } from 'path'; +import { resolve, dirname, join } from 'path'; import type * as _ts from 'typescript'; import { CreateOptions, @@ -167,9 +167,13 @@ export function readConfig( // Read project configuration when available. if (!skipProject) { - configFilePath = project - ? resolve(cwd, project) - : ts.findConfigFile(projectSearchDir, fileExists); + if (project) { + const resolved = resolve(cwd, project); + const nested = join(resolved, 'tsconfig.json'); + configFilePath = fileExists(nested) ? nested : resolved; + } else { + configFilePath = ts.findConfigFile(projectSearchDir, fileExists); + } if (configFilePath) { let pathToNextConfigInChain = configFilePath; @@ -383,6 +387,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): { experimentalResolver, esm, experimentalSpecifierResolution, + experimentalTsImportSpecifiers, ...unrecognized } = jsonObject as TsConfigOptions; const filteredTsConfigOptions = { @@ -409,6 +414,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): { experimentalResolver, esm, experimentalSpecifierResolution, + experimentalTsImportSpecifiers, }; // Use the typechecker to make sure this implementation has the correct set of properties const catchExtraneousProps: keyof TsConfigOptions = diff --git a/src/file-extensions.ts b/src/file-extensions.ts index 87e8be1c..b5fd0355 100644 --- a/src/file-extensions.ts +++ b/src/file-extensions.ts @@ -19,6 +19,13 @@ const nodeEquivalents = new Map([ ['.cts', '.cjs'], ]); +const tsResolverEquivalents = new Map([ + ['.ts', ['.js']], + ['.tsx', ['.js', '.jsx']], + ['.mts', ['.mjs']], + ['.cts', ['.cjs']], +]); + // All extensions understood by vanilla node const vanillaNodeExtensions: readonly string[] = [ '.js', @@ -129,6 +136,19 @@ export function getExtensions( * as far as getFormat is concerned. */ nodeEquivalents, + /** + * Mapping from extensions rejected by TSC in import specifiers, to the + * possible alternatives that TS's resolver will accept. + * + * When we allow users to opt-in to .ts extensions in import specifiers, TS's + * resolver requires us to replace the .ts extensions with .js alternatives. + * Otherwise, resolution fails. + * + * Note TS's resolver is only used by, and only required for, typechecking. + * This is separate from node's resolver, which we hook separately and which + * does not require this mapping. + */ + tsResolverEquivalents, /** * Extensions that we can support if the user upgrades their typescript version. * Used when raising hints. diff --git a/src/index.ts b/src/index.ts index 607d5976..cf9d7eef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -373,6 +373,17 @@ export interface CreateOptions { * For details, see https://nodejs.org/dist/latest-v18.x/docs/api/esm.html#customizing-esm-specifier-resolution-algorithm */ experimentalSpecifierResolution?: 'node' | 'explicit'; + /** + * Allow using voluntary `.ts` file extension in import specifiers. + * + * Typically, in ESM projects, import specifiers must have an emit extension, `.js`, `.cjs`, or `.mjs`, + * and we automatically map to the corresponding `.ts`, `.cts`, or `.mts` source file. This is the + * recommended approach. + * + * However, if you really want to use `.ts` in import specifiers, and are aware that this may + * break tooling, you can enable this flag. + */ + experimentalTsImportSpecifiers?: boolean; } export type ModuleTypes = Record; @@ -693,6 +704,11 @@ export function createFromPreloadedConfig( 6059, // "'rootDir' is expected to contain all source files." 18002, // "The 'files' list in config file is empty." 18003, // "No inputs were found in config file." + ...(options.experimentalTsImportSpecifiers + ? [ + 2691, // "An import path cannot end with a '.ts' extension. Consider importing '' instead." + ] + : []), ...(options.ignoreDiagnostics || []), ].map(Number), }, @@ -905,6 +921,8 @@ export function createFromPreloadedConfig( patterns: options.moduleTypes, }); + const extensions = getExtensions(config, options, ts.version); + // Use full language services when the fast option is disabled. if (!transpileOnly) { const fileContents = new Map(); @@ -985,6 +1003,8 @@ export function createFromPreloadedConfig( cwd, config, projectLocalResolveHelper, + options, + extensions, }); serviceHost.resolveModuleNames = resolveModuleNames; serviceHost.getResolvedModuleWithFailedLookupLocationsFromCache = @@ -1143,6 +1163,8 @@ export function createFromPreloadedConfig( ts, getCanonicalFileName, projectLocalResolveHelper, + options, + extensions, }); host.resolveModuleNames = resolveModuleNames; host.resolveTypeReferenceDirectives = resolveTypeReferenceDirectives; @@ -1448,7 +1470,6 @@ export function createFromPreloadedConfig( let active = true; const enabled = (enabled?: boolean) => enabled === undefined ? active : (active = !!enabled); - const extensions = getExtensions(config, options, ts.version); const ignored = (fileName: string) => { if (!active) return true; const ext = extname(fileName); diff --git a/src/repl.ts b/src/repl.ts index eed95a0d..3137daa4 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -207,6 +207,7 @@ export function createRepl(options: CreateReplOptions = {}) { state, input: code, context, + overrideIsCompletion: false, }); assert(result.containsTopLevelAwait === false); return result.value; @@ -512,6 +513,12 @@ function appendCompileAndEvalInput(options: { /** Enable top-level await but only if the TSNode service allows it. */ enableTopLevelAwait?: boolean; context: Context | undefined; + /** + * Added so that `evalCode` can be guaranteed *not* to trigger the `isCompletion` + * codepath. However, the `isCompletion` logic is ancient and maybe should be removed entirely. + * Nobody's looked at it in a long time. + */ + overrideIsCompletion?: boolean; }): AppendCompileAndEvalInputResult { const { service, @@ -519,6 +526,7 @@ function appendCompileAndEvalInput(options: { wrappedErr, enableTopLevelAwait = false, context, + overrideIsCompletion, } = options; let { input } = options; @@ -533,7 +541,7 @@ function appendCompileAndEvalInput(options: { } const lines = state.lines; - const isCompletion = !/\n$/.test(input); + const isCompletion = overrideIsCompletion ?? !/\n$/.test(input); const undo = appendToEvalState(state, input); let output: string; diff --git a/src/resolver-functions.ts b/src/resolver-functions.ts index afe13b46..83568669 100644 --- a/src/resolver-functions.ts +++ b/src/resolver-functions.ts @@ -1,4 +1,6 @@ import { resolve } from 'path'; +import type { CreateOptions } from '.'; +import type { Extensions } from './file-extensions'; import type { TSCommon, TSInternal } from './ts-compiler-types'; import type { ProjectLocalResolveHelper } from './util'; @@ -13,6 +15,8 @@ export function createResolverFunctions(kwargs: { getCanonicalFileName: (filename: string) => string; config: TSCommon.ParsedCommandLine; projectLocalResolveHelper: ProjectLocalResolveHelper; + options: CreateOptions; + extensions: Extensions; }) { const { host, @@ -21,6 +25,8 @@ export function createResolverFunctions(kwargs: { cwd, getCanonicalFileName, projectLocalResolveHelper, + options, + extensions, } = kwargs; const moduleResolutionCache = ts.createModuleResolutionCache( cwd, @@ -105,7 +111,7 @@ export function createResolverFunctions(kwargs: { i ) : undefined; - const { resolvedModule } = ts.resolveModuleName( + let { resolvedModule } = ts.resolveModuleName( moduleName, containingFile, config.options, @@ -114,6 +120,25 @@ export function createResolverFunctions(kwargs: { redirectedReference, mode ); + if (!resolvedModule && options.experimentalTsImportSpecifiers) { + const lastDotIndex = moduleName.lastIndexOf('.'); + const ext = lastDotIndex >= 0 ? moduleName.slice(lastDotIndex) : ''; + if (ext) { + const replacements = extensions.tsResolverEquivalents.get(ext); + for (const replacementExt of replacements ?? []) { + ({ resolvedModule } = ts.resolveModuleName( + moduleName.slice(0, -ext.length) + replacementExt, + containingFile, + config.options, + host, + moduleResolutionCache, + redirectedReference, + mode + )); + if (resolvedModule) break; + } + } + } if (resolvedModule) { fixupResolvedModule(resolvedModule); } diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts index 41c421fd..375012a7 100644 --- a/src/test/esm-loader.spec.ts +++ b/src/test/esm-loader.spec.ts @@ -22,6 +22,7 @@ import { TEST_DIR, tsSupportsImportAssertions, tsSupportsResolveJsonModule, + tsSupportsStableNodeNextNode16, } from './helpers'; import { createExec, createSpawn, ExecReturn } from './exec-helpers'; import { join, resolve } from 'path'; @@ -358,6 +359,53 @@ test.suite('esm', (test) => { }); } + test.suite('esm child process working directory', (test) => { + test('should have the correct working directory in the user entry-point', async () => { + const { err, stdout, stderr } = await exec( + `${BIN_PATH} --esm --cwd ./esm/ index.ts`, + { + cwd: resolve(TEST_DIR, 'working-dir'), + } + ); + + expect(err).toBe(null); + expect(stdout.trim()).toBe('Passing'); + expect(stderr).toBe(''); + }); + }); + + test.suite('esm child process and forking', (test) => { + test('should be able to fork vanilla NodeJS script', async () => { + const { err, stdout, stderr } = await exec( + `${BIN_PATH} --esm --cwd ./esm-child-process/ ./process-forking-js/index.ts` + ); + + expect(err).toBe(null); + expect(stdout.trim()).toBe('Passing: from main'); + expect(stderr).toBe(''); + }); + + test('should be able to fork TypeScript script', async () => { + const { err, stdout, stderr } = await exec( + `${BIN_PATH} --esm --cwd ./esm-child-process/ ./process-forking-ts/index.ts` + ); + + expect(err).toBe(null); + expect(stdout.trim()).toBe('Passing: from main'); + expect(stderr).toBe(''); + }); + + test('should be able to fork TypeScript script by absolute path', async () => { + const { err, stdout, stderr } = await exec( + `${BIN_PATH} --esm --cwd ./esm-child-process/ ./process-forking-ts-abs/index.ts` + ); + + expect(err).toBe(null); + expect(stdout.trim()).toBe('Passing: from main'); + expect(stderr).toBe(''); + }); + }); + test.suite('parent passes signals to child', (test) => { test.runSerially(); diff --git a/src/test/helpers.ts b/src/test/helpers.ts index c4ea9e8a..da86bddc 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -99,6 +99,8 @@ export const tsSupportsStableNodeNextNode16 = // TS 4.5 is first version to understand .cts, .mts, .cjs, and .mjs extensions export const tsSupportsMtsCtsExtensions = semver.gte(ts.version, '4.5.0'); export const tsSupportsImportAssertions = semver.gte(ts.version, '4.5.0'); +// TS 4.1 added jsx=react-jsx and react-jsxdev: https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/#react-17-jsx-factories +export const tsSupportsReact17JsxFactories = semver.gte(ts.version, '4.1.0'); //#endregion export const xfs = new NodeFS(fs); diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index ca4c2cf8..f085a363 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -617,6 +617,33 @@ test.suite('ts-node', (test) => { } }); + test('should have the correct working directory in the user entry-point', async () => { + const { err, stdout, stderr } = await exec( + `${BIN_PATH} --cwd ./cjs index.ts`, + { + cwd: resolve(TEST_DIR, 'working-dir'), + } + ); + + expect(err).toBe(null); + expect(stdout.trim()).toBe('Passing'); + expect(stderr).toBe(''); + }); + + // Disabled due to bug: + // --cwd is passed to forked children when not using --esm, erroneously affects their entrypoint resolution. + // tracked/fixed by either https://github.com/TypeStrong/ts-node/issues/1834 + // or https://github.com/TypeStrong/ts-node/issues/1831 + test.skip('should be able to fork into a nested TypeScript script with a modified working directory', async () => { + const { err, stdout, stderr } = await exec( + `${BIN_PATH} --cwd ./working-dir/forking/ index.ts` + ); + + expect(err).toBe(null); + expect(stdout.trim()).toBe('Passing: from main'); + expect(stderr).toBe(''); + }); + test.suite('should read ts-node options from tsconfig.json', (test) => { const BIN_EXEC = `"${BIN_PATH}" --project tsconfig-options/tsconfig.json`; diff --git a/src/test/transpilers.spec.ts b/src/test/transpilers.spec.ts index 57174f5e..1d64186a 100644 --- a/src/test/transpilers.spec.ts +++ b/src/test/transpilers.spec.ts @@ -3,8 +3,15 @@ // Should consolidate them here. import { context } from './testlib'; -import { ctxTsNode, testsDirRequire } from './helpers'; +import { + ctxTsNode, + testsDirRequire, + tsSupportsImportAssertions, + tsSupportsReact17JsxFactories, +} from './helpers'; +import { createSwcOptions } from '../transpilers/swc'; import * as expect from 'expect'; +import { outdent } from 'outdent'; const test = context(ctxTsNode); @@ -44,4 +51,119 @@ test.suite('swc', (test) => { expect([...swcTranspiler.targetMapping.values()]).toContain(target); } }); + + test.suite('converts TS config to swc config', (test) => { + test.suite('jsx', (test) => { + const macro = test.macro( + (jsx: string, runtime?: string, development?: boolean) => [ + () => `jsx=${jsx}`, + async (t) => { + const tsNode = t.context.tsNodeUnderTest.create({ + compilerOptions: { + jsx, + }, + }); + const swcOptions = createSwcOptions( + tsNode.config.options, + undefined, + require('@swc/core'), + '@swc/core' + ); + expect(swcOptions.tsxOptions.jsc?.transform?.react).toBeDefined(); + expect( + swcOptions.tsxOptions.jsc?.transform?.react?.development + ).toBe(development); + expect(swcOptions.tsxOptions.jsc?.transform?.react?.runtime).toBe( + runtime + ); + }, + ] + ); + + test(macro, 'react', undefined, undefined); + test.suite('react 17 jsx factories', (test) => { + test.runIf(tsSupportsReact17JsxFactories); + test(macro, 'react-jsx', 'automatic', undefined); + test(macro, 'react-jsxdev', 'automatic', true); + }); + }); + }); + + const compileMacro = test.macro( + (compilerOptions: object, input: string, expectedOutput: string) => [ + (title?: string) => title ?? `${JSON.stringify(compilerOptions)}`, + async (t) => { + const code = t.context.tsNodeUnderTest + .create({ + swc: true, + skipProject: true, + compilerOptions: { + module: 'esnext', + ...compilerOptions, + }, + }) + .compile(input, 'input.tsx'); + expect(code.replace(/\/\/# sourceMappingURL.*/, '').trim()).toBe( + expectedOutput + ); + }, + ] + ); + + test.suite('transforms various forms of jsx', (test) => { + const input = outdent` + const div =
; + `; + + test( + compileMacro, + { jsx: 'react' }, + input, + `const div = /*#__PURE__*/ React.createElement("div", null);` + ); + test.suite('react 17 jsx factories', (test) => { + test.runIf(tsSupportsReact17JsxFactories); + test( + compileMacro, + { jsx: 'react-jsx' }, + input, + outdent` + import { jsx as _jsx } from "react/jsx-runtime"; + const div = /*#__PURE__*/ _jsx("div", {}); + ` + ); + test( + compileMacro, + { jsx: 'react-jsxdev' }, + input, + outdent` + import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime"; + const div = /*#__PURE__*/ _jsxDEV("div", {}, void 0, false, { + fileName: "input.tsx", + lineNumber: 1, + columnNumber: 13 + }, this); + ` + ); + }); + }); + + test.suite('preserves import assertions for json imports', (test) => { + test.runIf(tsSupportsImportAssertions); + test( + 'basic json import', + compileMacro, + { module: 'esnext' }, + outdent` + import document from './document.json' assert {type: 'json'}; + document; + `, + outdent` + import document from './document.json' assert { + type: 'json' + }; + document; + ` + ); + }); }); diff --git a/src/test/ts-import-specifiers.spec.ts b/src/test/ts-import-specifiers.spec.ts new file mode 100644 index 00000000..39c4cc29 --- /dev/null +++ b/src/test/ts-import-specifiers.spec.ts @@ -0,0 +1,22 @@ +import { context } from './testlib'; +import * as expect from 'expect'; +import { createExec } from './exec-helpers'; +import { + TEST_DIR, + ctxTsNode, + CMD_TS_NODE_WITHOUT_PROJECT_FLAG, +} from './helpers'; + +const exec = createExec({ + cwd: TEST_DIR, +}); + +const test = context(ctxTsNode); + +test('Supports .ts extensions in import specifiers with typechecking, even though vanilla TS checker does not', async () => { + const { err, stdout } = await exec( + `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} ts-import-specifiers/index.ts` + ); + expect(err).toBe(null); + expect(stdout.trim()).toBe('{ foo: true, bar: true }'); +}); diff --git a/src/transpilers/swc.ts b/src/transpilers/swc.ts index 246b70f4..89e9bd49 100644 --- a/src/transpilers/swc.ts +++ b/src/transpilers/swc.ts @@ -2,7 +2,9 @@ import type * as ts from 'typescript'; import type * as swcWasm from '@swc/wasm'; import type * as swcTypes from '@swc/core'; import type { CreateTranspilerOptions, Transpiler } from './types'; +import type { NodeModuleEmitKind } from '..'; +type SwcInstance = typeof swcWasm; export interface SwcTranspilerOptions extends CreateTranspilerOptions { /** * swc compiler to use for compilation @@ -21,8 +23,11 @@ export function create(createOptions: SwcTranspilerOptions): Transpiler { } = createOptions; // Load swc compiler - let swcInstance: typeof swcWasm; + let swcInstance: SwcInstance; + // Used later in diagnostics; merely needs to be human-readable. + let swcDepName: string = 'swc'; if (typeof swc === 'string') { + swcDepName = swc; swcInstance = require(transpilerConfigLocalResolveHelper( swc, true @@ -30,10 +35,12 @@ export function create(createOptions: SwcTranspilerOptions): Transpiler { } else if (swc == null) { let swcResolved; try { - swcResolved = transpilerConfigLocalResolveHelper('@swc/core', true); + swcDepName = '@swc/core'; + swcResolved = transpilerConfigLocalResolveHelper(swcDepName, true); } catch (e) { try { - swcResolved = transpilerConfigLocalResolveHelper('@swc/wasm', true); + swcDepName = '@swc/wasm'; + swcResolved = transpilerConfigLocalResolveHelper(swcDepName, 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' @@ -46,107 +53,12 @@ export function create(createOptions: SwcTranspilerOptions): Transpiler { } // Prepare SWC options derived from typescript compiler options - const compilerOptions = config.options; - const { - esModuleInterop, - sourceMap, - importHelpers, - experimentalDecorators, - emitDecoratorMetadata, - target, - module, - jsxFactory, - jsxFragmentFactory, - strict, - alwaysStrict, - noImplicitUseStrict, - } = compilerOptions; - const nonTsxOptions = createSwcOptions(false); - const tsxOptions = createSwcOptions(true); - function createSwcOptions(isTsx: boolean): swcTypes.Options { - let swcTarget = targetMapping.get(target!) ?? 'es3'; - // Downgrade to lower target if swc does not support the selected target. - // Perhaps project has an older version of swc. - // TODO cache the results of this; slightly faster - let swcTargetIndex = swcTargets.indexOf(swcTarget); - for (; swcTargetIndex >= 0; swcTargetIndex--) { - try { - swcInstance.transformSync('', { - jsc: { target: swcTargets[swcTargetIndex] }, - }); - break; - } catch (e) {} - } - swcTarget = swcTargets[swcTargetIndex]; - const keepClassNames = target! >= /* ts.ScriptTarget.ES2016 */ 3; - const isNodeModuleKind = - module === ModuleKind.Node12 || module === ModuleKind.NodeNext; - // swc only supports these 4x module options [MUST_UPDATE_FOR_NEW_MODULEKIND] - const moduleType = - module === ModuleKind.CommonJS - ? 'commonjs' - : module === ModuleKind.AMD - ? 'amd' - : module === ModuleKind.UMD - ? 'umd' - : isNodeModuleKind && nodeModuleEmitKind === 'nodecjs' - ? 'commonjs' - : isNodeModuleKind && nodeModuleEmitKind === 'nodeesm' - ? 'es6' - : 'es6'; - // In swc: - // strictMode means `"use strict"` is *always* emitted for non-ES module, *never* for ES module where it is assumed it can be omitted. - // (this assumption is invalid, but that's the way swc behaves) - // tsc is a bit more complex: - // alwaysStrict will force emitting it always unless `import`/`export` syntax is emitted which implies it per the JS spec. - // if not alwaysStrict, will emit implicitly whenever module target is non-ES *and* transformed module syntax is emitted. - // For node, best option is to assume that all scripts are modules (commonjs or esm) and thus should get tsc's implicit strict behavior. - - // Always set strictMode, *unless* alwaysStrict is disabled and noImplicitUseStrict is enabled - const strictMode = - // if `alwaysStrict` is disabled, remembering that `strict` defaults `alwaysStrict` to true - (alwaysStrict === false || (alwaysStrict !== true && strict !== true)) && - // if noImplicitUseStrict is enabled - noImplicitUseStrict === true - ? false - : true; - return { - sourceMaps: sourceMap, - // isModule: true, - module: moduleType - ? ({ - noInterop: !esModuleInterop, - type: moduleType, - strictMode, - // For NodeNext and Node12, emit as CJS but do not transform dynamic imports - ignoreDynamic: nodeModuleEmitKind === 'nodecjs', - } as swcTypes.ModuleConfig) - : undefined, - swcrc: false, - jsc: { - externalHelpers: importHelpers, - parser: { - syntax: 'typescript', - tsx: isTsx, - decorators: experimentalDecorators, - dynamicImport: true, - }, - target: swcTarget, - transform: { - decoratorMetadata: emitDecoratorMetadata, - legacyDecorator: true, - react: { - throwIfNamespace: false, - development: false, - useBuiltins: false, - pragma: jsxFactory!, - pragmaFrag: jsxFragmentFactory!, - } as swcTypes.ReactConfig, - }, - keepClassNames, - } as swcTypes.JscConfig, - }; - } + const { nonTsxOptions, tsxOptions } = createSwcOptions( + config.options, + nodeModuleEmitKind, + swcInstance, + swcDepName + ); const transpile: Transpiler['transpile'] = (input, transpileOptions) => { const { fileName } = transpileOptions; @@ -207,6 +119,155 @@ const ModuleKind = { ES2015: 5, ES2020: 6, ESNext: 99, - Node12: 100, + Node16: 100, NodeNext: 199, } as const; + +const JsxEmit = { + ReactJSX: /* ts.JsxEmit.ReactJSX */ 4, + ReactJSXDev: /* ts.JsxEmit.ReactJSXDev */ 5, +} as const; + +/** + * Prepare SWC options derived from typescript compiler options. + * @internal exported for testing + */ +export function createSwcOptions( + compilerOptions: ts.CompilerOptions, + nodeModuleEmitKind: NodeModuleEmitKind | undefined, + swcInstance: SwcInstance, + swcDepName: string +) { + const { + esModuleInterop, + sourceMap, + importHelpers, + experimentalDecorators, + emitDecoratorMetadata, + target, + module, + jsx, + jsxFactory, + jsxFragmentFactory, + strict, + alwaysStrict, + noImplicitUseStrict, + } = compilerOptions; + + let swcTarget = targetMapping.get(target!) ?? 'es3'; + // Downgrade to lower target if swc does not support the selected target. + // Perhaps project has an older version of swc. + // TODO cache the results of this; slightly faster + let swcTargetIndex = swcTargets.indexOf(swcTarget); + for (; swcTargetIndex >= 0; swcTargetIndex--) { + try { + swcInstance.transformSync('', { + jsc: { target: swcTargets[swcTargetIndex] }, + }); + break; + } catch (e) {} + } + swcTarget = swcTargets[swcTargetIndex]; + const keepClassNames = target! >= /* ts.ScriptTarget.ES2016 */ 3; + const isNodeModuleKind = + module === ModuleKind.Node16 || module === ModuleKind.NodeNext; + // swc only supports these 4x module options [MUST_UPDATE_FOR_NEW_MODULEKIND] + const moduleType = + module === ModuleKind.CommonJS + ? 'commonjs' + : module === ModuleKind.AMD + ? 'amd' + : module === ModuleKind.UMD + ? 'umd' + : isNodeModuleKind && nodeModuleEmitKind === 'nodecjs' + ? 'commonjs' + : isNodeModuleKind && nodeModuleEmitKind === 'nodeesm' + ? 'es6' + : 'es6'; + // In swc: + // strictMode means `"use strict"` is *always* emitted for non-ES module, *never* for ES module where it is assumed it can be omitted. + // (this assumption is invalid, but that's the way swc behaves) + // tsc is a bit more complex: + // alwaysStrict will force emitting it always unless `import`/`export` syntax is emitted which implies it per the JS spec. + // if not alwaysStrict, will emit implicitly whenever module target is non-ES *and* transformed module syntax is emitted. + // For node, best option is to assume that all scripts are modules (commonjs or esm) and thus should get tsc's implicit strict behavior. + + // Always set strictMode, *unless* alwaysStrict is disabled and noImplicitUseStrict is enabled + const strictMode = + // if `alwaysStrict` is disabled, remembering that `strict` defaults `alwaysStrict` to true + (alwaysStrict === false || (alwaysStrict !== true && strict !== true)) && + // if noImplicitUseStrict is enabled + noImplicitUseStrict === true + ? false + : true; + + const jsxRuntime: swcTypes.ReactConfig['runtime'] = + jsx === JsxEmit.ReactJSX || jsx === JsxEmit.ReactJSXDev + ? 'automatic' + : undefined; + const jsxDevelopment: swcTypes.ReactConfig['development'] = + jsx === JsxEmit.ReactJSXDev ? true : undefined; + + const nonTsxOptions = createVariant(false); + const tsxOptions = createVariant(true); + return { nonTsxOptions, tsxOptions }; + + function createVariant(isTsx: boolean): swcTypes.Options { + const swcOptions: swcTypes.Options = { + sourceMaps: sourceMap, + // isModule: true, + module: moduleType + ? ({ + noInterop: !esModuleInterop, + type: moduleType, + strictMode, + // For NodeNext and Node12, emit as CJS but do not transform dynamic imports + ignoreDynamic: nodeModuleEmitKind === 'nodecjs', + } as swcTypes.ModuleConfig) + : undefined, + swcrc: false, + jsc: { + externalHelpers: importHelpers, + parser: { + syntax: 'typescript', + tsx: isTsx, + decorators: experimentalDecorators, + dynamicImport: true, + importAssertions: true, + }, + target: swcTarget, + transform: { + decoratorMetadata: emitDecoratorMetadata, + legacyDecorator: true, + react: { + throwIfNamespace: false, + development: jsxDevelopment, + useBuiltins: false, + pragma: jsxFactory!, + pragmaFrag: jsxFragmentFactory!, + runtime: jsxRuntime, + } as swcTypes.ReactConfig, + }, + keepClassNames, + experimental: { + keepImportAssertions: true, + }, + } as swcTypes.JscConfig, + }; + + // Throw a helpful error if swc version is old, for example, if it rejects `ignoreDynamic` + try { + swcInstance.transformSync('', swcOptions); + } catch (e) { + throw new Error( + `${swcDepName} threw an error when attempting to validate swc compiler options.\n` + + 'You may be using an old version of swc which does not support the options used by ts-node.\n' + + 'Try upgrading to the latest version of swc.\n' + + 'Error message from swc:\n' + + (e as Error)?.message + ); + } + + return swcOptions; + } +} diff --git a/tests/esm-child-process/process-forking-js/index.ts b/tests/esm-child-process/process-forking-js/index.ts new file mode 100644 index 00000000..88a3bd61 --- /dev/null +++ b/tests/esm-child-process/process-forking-js/index.ts @@ -0,0 +1,24 @@ +import { fork } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +// Initially set the exit code to non-zero. We only set it to `0` when the +// worker process finishes properly with the expected stdout message. +process.exitCode = 1; + +process.chdir(dirname(fileURLToPath(import.meta.url))); + +const workerProcess = fork('./worker.js', [], { + stdio: 'pipe', +}); + +let stdout = ''; + +workerProcess.stdout.on('data', (chunk) => (stdout += chunk.toString('utf8'))); +workerProcess.on('error', () => (process.exitCode = 1)); +workerProcess.on('close', (status, signal) => { + if (status === 0 && signal === null && stdout.trim() === 'Works') { + console.log('Passing: from main'); + process.exitCode = 0; + } +}); diff --git a/tests/esm-child-process/process-forking-js/package.json b/tests/esm-child-process/process-forking-js/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/tests/esm-child-process/process-forking-js/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/esm-child-process/process-forking-js/tsconfig.json b/tests/esm-child-process/process-forking-js/tsconfig.json new file mode 100644 index 00000000..04e93e5c --- /dev/null +++ b/tests/esm-child-process/process-forking-js/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "ESNext" + }, + "ts-node": { + "swc": true + } +} diff --git a/tests/esm-child-process/process-forking-js/worker.js b/tests/esm-child-process/process-forking-js/worker.js new file mode 100644 index 00000000..820d10b2 --- /dev/null +++ b/tests/esm-child-process/process-forking-js/worker.js @@ -0,0 +1 @@ +console.log('Works'); diff --git a/tests/esm-child-process/process-forking-ts-abs/index.ts b/tests/esm-child-process/process-forking-ts-abs/index.ts new file mode 100644 index 00000000..ec94e846 --- /dev/null +++ b/tests/esm-child-process/process-forking-ts-abs/index.ts @@ -0,0 +1,26 @@ +import { fork } from 'child_process'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +// Initially set the exit code to non-zero. We only set it to `0` when the +// worker process finishes properly with the expected stdout message. +process.exitCode = 1; + +const workerProcess = fork( + join(dirname(fileURLToPath(import.meta.url)), 'subfolder/worker.ts'), + [], + { + stdio: 'pipe', + } +); + +let stdout = ''; + +workerProcess.stdout.on('data', (chunk) => (stdout += chunk.toString('utf8'))); +workerProcess.on('error', () => (process.exitCode = 1)); +workerProcess.on('close', (status, signal) => { + if (status === 0 && signal === null && stdout.trim() === 'Works') { + console.log('Passing: from main'); + process.exitCode = 0; + } +}); diff --git a/tests/esm-child-process/process-forking-ts-abs/package.json b/tests/esm-child-process/process-forking-ts-abs/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/tests/esm-child-process/process-forking-ts-abs/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/esm-child-process/process-forking-ts-abs/subfolder/worker.ts b/tests/esm-child-process/process-forking-ts-abs/subfolder/worker.ts new file mode 100644 index 00000000..4114d5ab --- /dev/null +++ b/tests/esm-child-process/process-forking-ts-abs/subfolder/worker.ts @@ -0,0 +1,3 @@ +const message: string = 'Works'; + +console.log(message); diff --git a/tests/esm-child-process/process-forking-ts-abs/tsconfig.json b/tests/esm-child-process/process-forking-ts-abs/tsconfig.json new file mode 100644 index 00000000..04e93e5c --- /dev/null +++ b/tests/esm-child-process/process-forking-ts-abs/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "ESNext" + }, + "ts-node": { + "swc": true + } +} diff --git a/tests/esm-child-process/process-forking-ts/index.ts b/tests/esm-child-process/process-forking-ts/index.ts new file mode 100644 index 00000000..2d59e0aa --- /dev/null +++ b/tests/esm-child-process/process-forking-ts/index.ts @@ -0,0 +1,24 @@ +import { fork } from 'child_process'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +// Initially set the exit code to non-zero. We only set it to `0` when the +// worker process finishes properly with the expected stdout message. +process.exitCode = 1; + +process.chdir(join(dirname(fileURLToPath(import.meta.url)), 'subfolder')); + +const workerProcess = fork('./worker.ts', [], { + stdio: 'pipe', +}); + +let stdout = ''; + +workerProcess.stdout.on('data', (chunk) => (stdout += chunk.toString('utf8'))); +workerProcess.on('error', () => (process.exitCode = 1)); +workerProcess.on('close', (status, signal) => { + if (status === 0 && signal === null && stdout.trim() === 'Works') { + console.log('Passing: from main'); + process.exitCode = 0; + } +}); diff --git a/tests/esm-child-process/process-forking-ts/package.json b/tests/esm-child-process/process-forking-ts/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/tests/esm-child-process/process-forking-ts/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/esm-child-process/process-forking-ts/subfolder/worker.ts b/tests/esm-child-process/process-forking-ts/subfolder/worker.ts new file mode 100644 index 00000000..4114d5ab --- /dev/null +++ b/tests/esm-child-process/process-forking-ts/subfolder/worker.ts @@ -0,0 +1,3 @@ +const message: string = 'Works'; + +console.log(message); diff --git a/tests/esm-child-process/process-forking-ts/tsconfig.json b/tests/esm-child-process/process-forking-ts/tsconfig.json new file mode 100644 index 00000000..04e93e5c --- /dev/null +++ b/tests/esm-child-process/process-forking-ts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "ESNext" + }, + "ts-node": { + "swc": true + } +} diff --git a/tests/ts-import-specifiers/bar.tsx b/tests/ts-import-specifiers/bar.tsx new file mode 100644 index 00000000..3a850c17 --- /dev/null +++ b/tests/ts-import-specifiers/bar.tsx @@ -0,0 +1 @@ +export const bar = true; diff --git a/tests/ts-import-specifiers/foo.ts b/tests/ts-import-specifiers/foo.ts new file mode 100644 index 00000000..62d968e8 --- /dev/null +++ b/tests/ts-import-specifiers/foo.ts @@ -0,0 +1 @@ +export const foo = true; diff --git a/tests/ts-import-specifiers/index.ts b/tests/ts-import-specifiers/index.ts new file mode 100644 index 00000000..2f1444fb --- /dev/null +++ b/tests/ts-import-specifiers/index.ts @@ -0,0 +1,3 @@ +import { foo } from './foo.ts'; +import { bar } from './bar.jsx'; +console.log({ foo, bar }); diff --git a/tests/ts-import-specifiers/tsconfig.json b/tests/ts-import-specifiers/tsconfig.json new file mode 100644 index 00000000..098594e5 --- /dev/null +++ b/tests/ts-import-specifiers/tsconfig.json @@ -0,0 +1,10 @@ +{ + "ts-node": { + // Can eventually make this a stable feature. For now, `experimental` flag allows me to iterate quickly + "experimentalTsImportSpecifiers": true, + "experimentalResolver": true + }, + "compilerOptions": { + "jsx": "react" + } +} diff --git a/tests/working-dir/cjs/index.ts b/tests/working-dir/cjs/index.ts new file mode 100644 index 00000000..f3ba1b30 --- /dev/null +++ b/tests/working-dir/cjs/index.ts @@ -0,0 +1,7 @@ +import { strictEqual } from 'assert'; +import { normalize, dirname } from 'path'; + +// Expect the working directory to be the parent directory. +strictEqual(normalize(process.cwd()), normalize(dirname(__dirname))); + +console.log('Passing'); diff --git a/tests/working-dir/esm/index.ts b/tests/working-dir/esm/index.ts new file mode 100644 index 00000000..21230f9d --- /dev/null +++ b/tests/working-dir/esm/index.ts @@ -0,0 +1,11 @@ +import { strictEqual } from 'assert'; +import { normalize, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +// Expect the working directory to be the parent directory. +strictEqual( + normalize(process.cwd()), + normalize(dirname(dirname(fileURLToPath(import.meta.url)))) +); + +console.log('Passing'); diff --git a/tests/working-dir/esm/package.json b/tests/working-dir/esm/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/tests/working-dir/esm/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/working-dir/esm/tsconfig.json b/tests/working-dir/esm/tsconfig.json new file mode 100644 index 00000000..04e93e5c --- /dev/null +++ b/tests/working-dir/esm/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "ESNext" + }, + "ts-node": { + "swc": true + } +} diff --git a/tests/working-dir/forking/index.ts b/tests/working-dir/forking/index.ts new file mode 100644 index 00000000..45ff8afd --- /dev/null +++ b/tests/working-dir/forking/index.ts @@ -0,0 +1,22 @@ +import { fork } from 'child_process'; +import { join } from 'path'; + +// Initially set the exit code to non-zero. We only set it to `0` when the +// worker process finishes properly with the expected stdout message. +process.exitCode = 1; + +const workerProcess = fork('./worker.ts', [], { + stdio: 'pipe', + cwd: join(__dirname, 'subfolder'), +}); + +let stdout = ''; + +workerProcess.stdout!.on('data', (chunk) => (stdout += chunk.toString('utf8'))); +workerProcess.on('error', () => (process.exitCode = 1)); +workerProcess.on('close', (status, signal) => { + if (status === 0 && signal === null && stdout.trim() === 'Works') { + console.log('Passing: from main'); + process.exitCode = 0; + } +}); diff --git a/tests/working-dir/forking/subfolder/worker.ts b/tests/working-dir/forking/subfolder/worker.ts new file mode 100644 index 00000000..4114d5ab --- /dev/null +++ b/tests/working-dir/forking/subfolder/worker.ts @@ -0,0 +1,3 @@ +const message: string = 'Works'; + +console.log(message); diff --git a/tests/working-dir/tsconfig.json b/tests/working-dir/tsconfig.json new file mode 100644 index 00000000..484405d0 --- /dev/null +++ b/tests/working-dir/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "ts-node": { + "transpileOnly": true + } +} diff --git a/website/docs/performance.md b/website/docs/performance.md index 21704761..e5940e6a 100644 --- a/website/docs/performance.md +++ b/website/docs/performance.md @@ -6,7 +6,9 @@ These tricks will make ts-node faster. ## Skip typechecking -It is often better to use `tsc --noEmit` to typecheck as part of your tests or linting. In these cases, ts-node can skip typechecking. +It is often better to typecheck as part of your tests or linting. You can run `tsc --noEmit` to do this. In these cases, ts-node can skip typechecking, making it much faster. + +To skip typechecking in ts-node, do one of the following: * Enable [swc](./swc.md) * This is by far the fastest option @@ -14,6 +16,8 @@ It is often better to use `tsc --noEmit` to typecheck as part of your tests or l ## With typechecking +If you absolutely must typecheck in ts-node: + * Avoid dynamic `require()` which may trigger repeated typechecking; prefer `import` * Try with and without `--files`; one may be faster depending on your project * Check `tsc --showConfig`; make sure all executed files are included