From dc0fed275a47557ecc74f12d3e7f3bf19495d75c Mon Sep 17 00:00:00 2001 From: ejose19 <8742215+ejose19@users.noreply.github.com> Date: Sun, 8 Aug 2021 18:36:30 -0300 Subject: [PATCH] feat: add REPL top level await support (#1383) * feat: add repl top level await support * refactor: add more files as dist-raw, adjust primordial imports * refactor: add flag support for experimental repl await * refactor: add support for Typescript input in experimental repl await * refactor: conditionally await eval result * chore: update node-repl-await file * refactor: dynamically exclude TLA diagnostics when --experimental-repl-await is set * refactor: exclude sourceMap when transpiling for experimentalReplAwait * refactor: add acorn & acorn-walk as dependencies and remove them from dist-raw * refactor: allow setting experimentalReplAwait via env & tsconfig * refactor: adjust evalCode signature to avoid being a breaking change * refactor: improve top level await mechanism * refactor: adjust ignored diagnostic codes related to top level await/for await * refactor: use evalCodeInternal and revert evalCode to previous signature * refactor: await evalAndExitOnTsError result during bin `main` * refactor: adjust node-primordials * chore: remove unused require * refactor: revert to previous implementation of node-primordials, add missing methods * refactor: adjust node-repl-await to use compatible syntax up to node 12 * fix: typo in node-repl-await * refactor: add unhandledRejection listener * chore: remove node-primordials dts * fix: typo in node version comparison * test: add top level await tests * test: fix tla test * test: add upstream test suite of tla * feat: allow REPL to be configured on start * refactor: return repl server on `start` * refactor: use a different context when useGlobal = false * test: adjust upstream tests to latest changes * refactor: adjust new line placement on top level await processing * refactor: adjust ignored codes related to TLA * test: adjust TLA tests * refactor: override target when experimental repl await is set * test: adjust tla test * test: adjust tla tests * test: adjust tla tests * test: move tla upstream tests to a separate file * refactor: lazy load processTopLevelAwait * refactor: correctly handle errors for async eval result * refactor: throw error if target is not compatible with experimental repl await * refactor: adjust main call in bin * refactor: don't exclude tla diagnostic codes when mode is "entrypoint" * refactor: move new repl start implementation to startInternal * fix: typo in config * test: adjust tla tests * test: normalize object usage in commandline * test: move upstream tla deps from testlib to its file * test: fix formatObjectCommandLine * refactor: adjust type assertion * refactor: adjust _eval to iterate changes using a for loop * refactor: adjust execution implementation in nodeEval * test: add tla test * refactor: fix processTopLevelAwait return type * refactor: restore `main` to sync, implement callback mechanism * refactor: small adjustments in repl * refactor: remove TLA support from [stdin] & [eval] * refactor: adjust tla tests * refactor: improve code reuse in repl tests * Add raw/node-repl-await.js for easier diffing in the future * Rename flag to --no-experimental-repl-await to match node; enable by default when target is high enough; avoid `process.exit()` in `create()`; change `await` detection heuristic to match node's * Minimize changes to bin, since the only async possibility is in REPL, so bin.ts does not need to handle its errors * Integrate with latest `main` branch * fix test * fix tests * fix * fix * fix tests * fix test; make test-local fix linting errors instead of blocking on them * fix * normalize paths passed to diagnosticFilters to hopefully fix windows tests * force repl's virtual file to be a module, which is safe as long as node's runtime effectively does the same thing * remove extraneous prop deletion in show-config output * remove some todos * Update src/repl.ts Co-authored-by: ejose19 <8742215+ejose19@users.noreply.github.com> * refactor: move forceToBeModule from createRepl to startRepl * test: adjust tests * test: set static target for TLA tests * refactor: show hint regarding tla errors when shouldReplAwait=false * test: small adjustments * Final cleanup * increase wait time in repl test to reduce flakiness Co-authored-by: Andrew Bradley --- dist-raw/node-primordials.js | 10 + dist-raw/node-repl-await.js | 254 ++++++++++++++++++ package-lock.json | 30 +-- package.json | 4 +- raw/node-repl-await.js | 252 ++++++++++++++++++ src/bin.ts | 12 +- src/configuration.ts | 2 + src/index.ts | 58 ++++- src/repl.ts | 490 ++++++++++++++++++++++++++--------- src/test/index.spec.ts | 274 +++++++++++++++++--- src/test/node-repl-tla.ts | 336 ++++++++++++++++++++++++ tests/repl/tla-import.ts | 1 + website/docs/options.md | 1 + 13 files changed, 1546 insertions(+), 178 deletions(-) create mode 100644 dist-raw/node-repl-await.js create mode 100644 raw/node-repl-await.js create mode 100644 src/test/node-repl-tla.ts create mode 100644 tests/repl/tla-import.ts diff --git a/dist-raw/node-primordials.js b/dist-raw/node-primordials.js index d92b499ac..ec8083460 100644 --- a/dist-raw/node-primordials.js +++ b/dist-raw/node-primordials.js @@ -1,24 +1,34 @@ 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), + ArrayPrototypeJoin: (arr, ...rest) => Array.prototype.join.apply(arr, rest), + ArrayPrototypePop: (arr, ...rest) => Array.prototype.pop.apply(arr, rest), + ArrayPrototypePush: (arr, ...rest) => Array.prototype.push.apply(arr, rest), + FunctionPrototype: Function.prototype, JSONParse: JSON.parse, JSONStringify: JSON.stringify, ObjectFreeze: Object.freeze, + ObjectKeys: Object.keys, ObjectGetOwnPropertyNames: Object.getOwnPropertyNames, ObjectDefineProperty: Object.defineProperty, ObjectPrototypeHasOwnProperty: (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop), RegExpPrototypeTest: (obj, string) => RegExp.prototype.test.call(obj, string), + RegExpPrototypeSymbolReplace: (obj, ...rest) => RegExp.prototype[Symbol.replace].apply(obj, rest), SafeMap: Map, SafeSet: Set, StringPrototypeEndsWith: (str, ...rest) => String.prototype.endsWith.apply(str, rest), StringPrototypeIncludes: (str, ...rest) => String.prototype.includes.apply(str, rest), StringPrototypeLastIndexOf: (str, ...rest) => String.prototype.lastIndexOf.apply(str, rest), StringPrototypeIndexOf: (str, ...rest) => String.prototype.indexOf.apply(str, rest), + StringPrototypeRepeat: (str, ...rest) => String.prototype.repeat.apply(str, rest), StringPrototypeReplace: (str, ...rest) => String.prototype.replace.apply(str, rest), StringPrototypeSlice: (str, ...rest) => String.prototype.slice.apply(str, rest), StringPrototypeSplit: (str, ...rest) => String.prototype.split.apply(str, rest), StringPrototypeStartsWith: (str, ...rest) => String.prototype.startsWith.apply(str, rest), StringPrototypeSubstr: (str, ...rest) => String.prototype.substr.apply(str, rest), + SyntaxError: SyntaxError }; diff --git a/dist-raw/node-repl-await.js b/dist-raw/node-repl-await.js new file mode 100644 index 000000000..85bff2de1 --- /dev/null +++ b/dist-raw/node-repl-await.js @@ -0,0 +1,254 @@ +// copied from https://github.com/nodejs/node/blob/88799930794045795e8abac874730f9eba7e2300/lib/internal/repl/await.js +'use strict'; + +const { + ArrayFrom, + ArrayPrototypeForEach, + ArrayPrototypeIncludes, + ArrayPrototypeJoin, + ArrayPrototypePop, + ArrayPrototypePush, + FunctionPrototype, + ObjectKeys, + RegExpPrototypeSymbolReplace, + StringPrototypeEndsWith, + StringPrototypeIncludes, + StringPrototypeIndexOf, + StringPrototypeRepeat, + StringPrototypeSplit, + StringPrototypeStartsWith, + SyntaxError, +} = require('./node-primordials'); + +const parser = require('acorn').Parser; +const walk = require('acorn-walk'); +const { Recoverable } = require('repl'); + +function isTopLevelDeclaration(state) { + return state.ancestors[state.ancestors.length - 2] === state.body; +} + +const noop = FunctionPrototype; +const visitorsWithoutAncestors = { + ClassDeclaration(node, state, c) { + if (isTopLevelDeclaration(state)) { + state.prepend(node, `${node.id.name}=`); + ArrayPrototypePush( + state.hoistedDeclarationStatements, + `let ${node.id.name}; ` + ); + } + + walk.base.ClassDeclaration(node, state, c); + }, + ForOfStatement(node, state, c) { + if (node.await === true) { + state.containsAwait = true; + } + walk.base.ForOfStatement(node, state, c); + }, + FunctionDeclaration(node, state, c) { + state.prepend(node, `${node.id.name}=`); + ArrayPrototypePush( + state.hoistedDeclarationStatements, + `var ${node.id.name}; ` + ); + }, + FunctionExpression: noop, + ArrowFunctionExpression: noop, + MethodDefinition: noop, + AwaitExpression(node, state, c) { + state.containsAwait = true; + walk.base.AwaitExpression(node, state, c); + }, + ReturnStatement(node, state, c) { + state.containsReturn = true; + walk.base.ReturnStatement(node, state, c); + }, + VariableDeclaration(node, state, c) { + const variableKind = node.kind; + const isIterableForDeclaration = ArrayPrototypeIncludes( + ['ForOfStatement', 'ForInStatement'], + state.ancestors[state.ancestors.length - 2].type + ); + + if (variableKind === 'var' || isTopLevelDeclaration(state)) { + state.replace( + node.start, + node.start + variableKind.length + (isIterableForDeclaration ? 1 : 0), + variableKind === 'var' && isIterableForDeclaration ? + '' : + 'void' + (node.declarations.length === 1 ? '' : ' (') + ); + + if (!isIterableForDeclaration) { + ArrayPrototypeForEach(node.declarations, (decl) => { + state.prepend(decl, '('); + state.append(decl, decl.init ? ')' : '=undefined)'); + }); + + if (node.declarations.length !== 1) { + state.append(node.declarations[node.declarations.length - 1], ')'); + } + } + + const variableIdentifiersToHoist = [ + ['var', []], + ['let', []], + ]; + function registerVariableDeclarationIdentifiers(node) { + switch (node.type) { + case 'Identifier': + ArrayPrototypePush( + variableIdentifiersToHoist[variableKind === 'var' ? 0 : 1][1], + node.name + ); + break; + case 'ObjectPattern': + ArrayPrototypeForEach(node.properties, (property) => { + registerVariableDeclarationIdentifiers(property.value); + }); + break; + case 'ArrayPattern': + ArrayPrototypeForEach(node.elements, (element) => { + registerVariableDeclarationIdentifiers(element); + }); + break; + } + } + + ArrayPrototypeForEach(node.declarations, (decl) => { + registerVariableDeclarationIdentifiers(decl.id); + }); + + ArrayPrototypeForEach( + variableIdentifiersToHoist, + ({ 0: kind, 1: identifiers }) => { + if (identifiers.length > 0) { + ArrayPrototypePush( + state.hoistedDeclarationStatements, + `${kind} ${ArrayPrototypeJoin(identifiers, ', ')}; ` + ); + } + } + ); + } + + walk.base.VariableDeclaration(node, state, c); + } +}; + +const visitors = {}; +for (const nodeType of ObjectKeys(walk.base)) { + const callback = visitorsWithoutAncestors[nodeType] || walk.base[nodeType]; + visitors[nodeType] = (node, state, c) => { + const isNew = node !== state.ancestors[state.ancestors.length - 1]; + if (isNew) { + ArrayPrototypePush(state.ancestors, node); + } + callback(node, state, c); + if (isNew) { + ArrayPrototypePop(state.ancestors); + } + }; +} + +function processTopLevelAwait(src) { + const wrapPrefix = '(async () => { '; + const wrapped = `${wrapPrefix}${src} })()`; + const wrappedArray = ArrayFrom(wrapped); + let root; + try { + root = parser.parse(wrapped, { ecmaVersion: 'latest' }); + } catch (e) { + if (StringPrototypeStartsWith(e.message, 'Unterminated ')) + throw new Recoverable(e); + // If the parse error is before the first "await", then use the execution + // error. Otherwise we must emit this parse error, making it look like a + // proper syntax error. + const awaitPos = StringPrototypeIndexOf(src, 'await'); + const errPos = e.pos - wrapPrefix.length; + if (awaitPos > errPos) + return null; + // Convert keyword parse errors on await into their original errors when + // possible. + if (errPos === awaitPos + 6 && + StringPrototypeIncludes(e.message, 'Expecting Unicode escape sequence')) + return null; + if (errPos === awaitPos + 7 && + StringPrototypeIncludes(e.message, 'Unexpected token')) + return null; + const line = e.loc.line; + const column = line === 1 ? e.loc.column - wrapPrefix.length : e.loc.column; + let message = '\n' + StringPrototypeSplit(src, '\n')[line - 1] + '\n' + + StringPrototypeRepeat(' ', column) + + '^\n\n' + RegExpPrototypeSymbolReplace(/ \([^)]+\)/, e.message, ''); + // V8 unexpected token errors include the token string. + if (StringPrototypeEndsWith(message, 'Unexpected token')) + message += " '" + + // Wrapper end may cause acorn to report error position after the source + ((src.length - 1) >= (e.pos - wrapPrefix.length) + ? src[e.pos - wrapPrefix.length] + : src[src.length - 1]) + + "'"; + // eslint-disable-next-line no-restricted-syntax + throw new SyntaxError(message); + } + const body = root.body[0].expression.callee.body; + const state = { + body, + ancestors: [], + hoistedDeclarationStatements: [], + replace(from, to, str) { + for (let i = from; i < to; i++) { + wrappedArray[i] = ''; + } + if (from === to) str += wrappedArray[from]; + wrappedArray[from] = str; + }, + prepend(node, str) { + wrappedArray[node.start] = str + wrappedArray[node.start]; + }, + append(node, str) { + wrappedArray[node.end - 1] += str; + }, + containsAwait: false, + containsReturn: false + }; + + walk.recursive(body, state, visitors); + + // Do not transform if + // 1. False alarm: there isn't actually an await expression. + // 2. There is a top-level return, which is not allowed. + if (!state.containsAwait || state.containsReturn) { + return null; + } + + const last = body.body[body.body.length - 1]; + if (last.type === 'ExpressionStatement') { + // For an expression statement of the form + // ( expr ) ; + // ^^^^^^^^^^ // last + // ^^^^ // last.expression + // + // We do not want the left parenthesis before the `return` keyword; + // therefore we prepend the `return (` to `last`. + // + // On the other hand, we do not want the right parenthesis after the + // semicolon. Since there can only be more right parentheses between + // last.expression.end and the semicolon, appending one more to + // last.expression should be fine. + state.prepend(last, 'return ('); + state.append(last.expression, ')'); + } + + return ( + ArrayPrototypeJoin(state.hoistedDeclarationStatements, '') + + ArrayPrototypeJoin(wrappedArray, '') + ); +} + +module.exports = { + processTopLevelAwait +}; diff --git a/package-lock.json b/package-lock.json index 3942e3aa3..677c3ca9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", @@ -1270,10 +1272,9 @@ } }, "node_modules/acorn": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.0.5.tgz", - "integrity": "sha512-v+DieK/HJkJOpFBETDJioequtc3PfxsWMaxIdIwujtF7FEV/MAyDQLlm6/zPvr7Mix07mLh6ccVwIsloceodlg==", - "dev": true, + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.4.1.tgz", + "integrity": "sha512-asabaBSkEKosYKMITunzX177CXxQ4Q8BSSzMTKD+FefUhipQC70gfW5SiUDhYQ3vk8G+81HqQk7Fv9OXwwn9KA==", "bin": { "acorn": "bin/acorn" }, @@ -1282,10 +1283,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.0.2.tgz", - "integrity": "sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A==", - "dev": true, + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.1.1.tgz", + "integrity": "sha512-FbJdceMlPHEAWJOILDk1fXD8lnTlEIWFkqtfk+MvmL5q/qlHfN7GEHcsFZWt/Tea9jRNPWUZG4G976nqAAmU9w==", "engines": { "node": ">=0.4.0" } @@ -7433,16 +7433,14 @@ } }, "acorn": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.0.5.tgz", - "integrity": "sha512-v+DieK/HJkJOpFBETDJioequtc3PfxsWMaxIdIwujtF7FEV/MAyDQLlm6/zPvr7Mix07mLh6ccVwIsloceodlg==", - "dev": true + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.4.1.tgz", + "integrity": "sha512-asabaBSkEKosYKMITunzX177CXxQ4Q8BSSzMTKD+FefUhipQC70gfW5SiUDhYQ3vk8G+81HqQk7Fv9OXwwn9KA==" }, "acorn-walk": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.0.2.tgz", - "integrity": "sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A==", - "dev": true + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.1.1.tgz", + "integrity": "sha512-FbJdceMlPHEAWJOILDk1fXD8lnTlEIWFkqtfk+MvmL5q/qlHfN7GEHcsFZWt/Tea9jRNPWUZG4G976nqAAmU9w==" }, "aggregate-error": { "version": "3.0.1", diff --git a/package.json b/package.json index f3a5da37f..a29518832 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "test-spec": "ava", "test-cov": "nyc ava", "test": "npm run build && npm run lint && npm run test-cov --", - "test-local": "npm run lint && npm run build-tsc && npm run build-pack && npm run test-spec --", + "test-local": "npm run lint-fix && npm run build-tsc && npm run build-pack && npm run test-spec --", "coverage-report": "nyc report --reporter=lcov", "prepare": "npm run clean && npm run build-nopack", "api-extractor": "api-extractor run --local --verbose" @@ -162,6 +162,8 @@ "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", diff --git a/raw/node-repl-await.js b/raw/node-repl-await.js new file mode 100644 index 000000000..b8b9153f4 --- /dev/null +++ b/raw/node-repl-await.js @@ -0,0 +1,252 @@ +// downloaded from https://github.com/nodejs/node/blob/88799930794045795e8abac874730f9eba7e2300/lib/internal/repl/await.js +'use strict'; + +const { + ArrayFrom, + ArrayPrototypeForEach, + ArrayPrototypeIncludes, + ArrayPrototypeJoin, + ArrayPrototypePop, + ArrayPrototypePush, + FunctionPrototype, + ObjectKeys, + RegExpPrototypeSymbolReplace, + StringPrototypeEndsWith, + StringPrototypeIncludes, + StringPrototypeIndexOf, + StringPrototypeRepeat, + StringPrototypeSplit, + StringPrototypeStartsWith, + SyntaxError, +} = primordials; + +const parser = require('internal/deps/acorn/acorn/dist/acorn').Parser; +const walk = require('internal/deps/acorn/acorn-walk/dist/walk'); +const { Recoverable } = require('internal/repl'); + +function isTopLevelDeclaration(state) { + return state.ancestors[state.ancestors.length - 2] === state.body; +} + +const noop = FunctionPrototype; +const visitorsWithoutAncestors = { + ClassDeclaration(node, state, c) { + if (isTopLevelDeclaration(state)) { + state.prepend(node, `${node.id.name}=`); + ArrayPrototypePush( + state.hoistedDeclarationStatements, + `let ${node.id.name}; ` + ); + } + + walk.base.ClassDeclaration(node, state, c); + }, + ForOfStatement(node, state, c) { + if (node.await === true) { + state.containsAwait = true; + } + walk.base.ForOfStatement(node, state, c); + }, + FunctionDeclaration(node, state, c) { + state.prepend(node, `${node.id.name}=`); + ArrayPrototypePush( + state.hoistedDeclarationStatements, + `var ${node.id.name}; ` + ); + }, + FunctionExpression: noop, + ArrowFunctionExpression: noop, + MethodDefinition: noop, + AwaitExpression(node, state, c) { + state.containsAwait = true; + walk.base.AwaitExpression(node, state, c); + }, + ReturnStatement(node, state, c) { + state.containsReturn = true; + walk.base.ReturnStatement(node, state, c); + }, + VariableDeclaration(node, state, c) { + const variableKind = node.kind; + const isIterableForDeclaration = ArrayPrototypeIncludes( + ['ForOfStatement', 'ForInStatement'], + state.ancestors[state.ancestors.length - 2].type + ); + + if (variableKind === 'var' || isTopLevelDeclaration(state)) { + state.replace( + node.start, + node.start + variableKind.length + (isIterableForDeclaration ? 1 : 0), + variableKind === 'var' && isIterableForDeclaration ? + '' : + 'void' + (node.declarations.length === 1 ? '' : ' (') + ); + + if (!isIterableForDeclaration) { + ArrayPrototypeForEach(node.declarations, (decl) => { + state.prepend(decl, '('); + state.append(decl, decl.init ? ')' : '=undefined)'); + }); + + if (node.declarations.length !== 1) { + state.append(node.declarations[node.declarations.length - 1], ')'); + } + } + + const variableIdentifiersToHoist = [ + ['var', []], + ['let', []], + ]; + function registerVariableDeclarationIdentifiers(node) { + switch (node.type) { + case 'Identifier': + ArrayPrototypePush( + variableIdentifiersToHoist[variableKind === 'var' ? 0 : 1][1], + node.name + ); + break; + case 'ObjectPattern': + ArrayPrototypeForEach(node.properties, (property) => { + registerVariableDeclarationIdentifiers(property.value); + }); + break; + case 'ArrayPattern': + ArrayPrototypeForEach(node.elements, (element) => { + registerVariableDeclarationIdentifiers(element); + }); + break; + } + } + + ArrayPrototypeForEach(node.declarations, (decl) => { + registerVariableDeclarationIdentifiers(decl.id); + }); + + ArrayPrototypeForEach( + variableIdentifiersToHoist, + ({ 0: kind, 1: identifiers }) => { + if (identifiers.length > 0) { + ArrayPrototypePush( + state.hoistedDeclarationStatements, + `${kind} ${ArrayPrototypeJoin(identifiers, ', ')}; ` + ); + } + } + ); + } + + walk.base.VariableDeclaration(node, state, c); + } +}; + +const visitors = {}; +for (const nodeType of ObjectKeys(walk.base)) { + const callback = visitorsWithoutAncestors[nodeType] || walk.base[nodeType]; + visitors[nodeType] = (node, state, c) => { + const isNew = node !== state.ancestors[state.ancestors.length - 1]; + if (isNew) { + ArrayPrototypePush(state.ancestors, node); + } + callback(node, state, c); + if (isNew) { + ArrayPrototypePop(state.ancestors); + } + }; +} + +function processTopLevelAwait(src) { + const wrapPrefix = '(async () => { '; + const wrapped = `${wrapPrefix}${src} })()`; + const wrappedArray = ArrayFrom(wrapped); + let root; + try { + root = parser.parse(wrapped, { ecmaVersion: 'latest' }); + } catch (e) { + if (StringPrototypeStartsWith(e.message, 'Unterminated ')) + throw new Recoverable(e); + // If the parse error is before the first "await", then use the execution + // error. Otherwise we must emit this parse error, making it look like a + // proper syntax error. + const awaitPos = StringPrototypeIndexOf(src, 'await'); + const errPos = e.pos - wrapPrefix.length; + if (awaitPos > errPos) + return null; + // Convert keyword parse errors on await into their original errors when + // possible. + if (errPos === awaitPos + 6 && + StringPrototypeIncludes(e.message, 'Expecting Unicode escape sequence')) + return null; + if (errPos === awaitPos + 7 && + StringPrototypeIncludes(e.message, 'Unexpected token')) + return null; + const line = e.loc.line; + const column = line === 1 ? e.loc.column - wrapPrefix.length : e.loc.column; + let message = '\n' + StringPrototypeSplit(src, '\n')[line - 1] + '\n' + + StringPrototypeRepeat(' ', column) + + '^\n\n' + RegExpPrototypeSymbolReplace(/ \([^)]+\)/, e.message, ''); + // V8 unexpected token errors include the token string. + if (StringPrototypeEndsWith(message, 'Unexpected token')) + message += " '" + + // Wrapper end may cause acorn to report error position after the source + (src[e.pos - wrapPrefix.length] ?? src[src.length - 1]) + + "'"; + // eslint-disable-next-line no-restricted-syntax + throw new SyntaxError(message); + } + const body = root.body[0].expression.callee.body; + const state = { + body, + ancestors: [], + hoistedDeclarationStatements: [], + replace(from, to, str) { + for (let i = from; i < to; i++) { + wrappedArray[i] = ''; + } + if (from === to) str += wrappedArray[from]; + wrappedArray[from] = str; + }, + prepend(node, str) { + wrappedArray[node.start] = str + wrappedArray[node.start]; + }, + append(node, str) { + wrappedArray[node.end - 1] += str; + }, + containsAwait: false, + containsReturn: false + }; + + walk.recursive(body, state, visitors); + + // Do not transform if + // 1. False alarm: there isn't actually an await expression. + // 2. There is a top-level return, which is not allowed. + if (!state.containsAwait || state.containsReturn) { + return null; + } + + const last = body.body[body.body.length - 1]; + if (last.type === 'ExpressionStatement') { + // For an expression statement of the form + // ( expr ) ; + // ^^^^^^^^^^ // last + // ^^^^ // last.expression + // + // We do not want the left parenthesis before the `return` keyword; + // therefore we prepend the `return (` to `last`. + // + // On the other hand, we do not want the right parenthesis after the + // semicolon. Since there can only be more right parentheses between + // last.expression.end and the semicolon, appending one more to + // last.expression should be fine. + state.prepend(last, 'return ('); + state.append(last.expression, ')'); + } + + return ( + ArrayPrototypeJoin(state.hoistedDeclarationStatements, '') + + ArrayPrototypeJoin(wrappedArray, '') + ); +} + +module.exports = { + processTopLevelAwait +}; diff --git a/src/bin.ts b/src/bin.ts index 71a9c506b..43974b046 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -10,7 +10,7 @@ import { EvalState, createRepl, ReplService, - setContext, + setupContext, STDIN_FILENAME, EvalAwarePartialHost, EVAL_NAME, @@ -65,6 +65,7 @@ export function main( '--emit': Boolean, '--scope': Boolean, '--scope-dir': String, + '--no-experimental-repl-await': Boolean, // Aliases. '-e': '--eval', @@ -124,6 +125,7 @@ export function main( '--emit': emit, '--scope': scope = undefined, '--scope-dir': scopeDir = undefined, + '--no-experimental-repl-await': noExperimentalReplAwait, } = args; if (help) { @@ -160,6 +162,7 @@ export function main( --scope-dir Directory for \`--scope\` --prefer-ts-exts Prefer importing TypeScript files over JavaScript files --log-error Logs TypeScript errors to stderr instead of throwing exceptions + --no-experimental-repl-await Disable top-level await in REPL. Equivalent to node's --no-experimental-repl-await `); process.exit(0); @@ -249,6 +252,7 @@ export function main( files, pretty, transpileOnly: transpileOnly ?? transpiler != null ? true : undefined, + experimentalReplAwait: noExperimentalReplAwait ? false : undefined, typeCheck, transpiler, compilerHost, @@ -326,6 +330,8 @@ export function main( if (executeEntrypoint) { Module.runMain(); } else { + // Note: eval and repl may both run, but never with stdin. + // If stdin runs, eval and repl will not. if (executeEval) { addBuiltinLibsToObject(global); evalAndExitOnTsError( @@ -336,9 +342,11 @@ export function main( 'eval' ); } + if (executeRepl) { replStuff!.repl.start(); } + if (executeStdin) { let buffer = code || ''; process.stdin.on('data', (chunk: Buffer) => (buffer += chunk)); @@ -448,7 +456,7 @@ function evalAndExitOnTsError( filenameAndDirname: 'eval' | 'stdin' ) { let result: any; - setContext(global, module, filenameAndDirname); + setupContext(global, module, filenameAndDirname); try { result = replService.evalCode(code); diff --git a/src/configuration.ts b/src/configuration.ts index 51126e1be..6bc8e1113 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -272,6 +272,7 @@ function filterRecognizedTsConfigTsNodeOptions( scope, scopeDir, moduleTypes, + experimentalReplAwait, ...unrecognized } = jsonObject as TsConfigOptions; const filteredTsConfigOptions = { @@ -279,6 +280,7 @@ function filterRecognizedTsConfigTsNodeOptions( compilerHost, compilerOptions, emit, + experimentalReplAwait, files, ignore, ignoreDiagnostics, diff --git a/src/index.ts b/src/index.ts index 5360a86a0..acbc892a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { ModuleTypeClassifier, } from './module-type-classifier'; import { createResolverFunctions } from './resolver-functions'; +import { ScriptTarget } from 'typescript'; export { TSCommon }; export { createRepl, CreateReplOptions, ReplService } from './repl'; @@ -42,6 +43,20 @@ export type { const engineSupportsPackageTypeField = parseInt(process.versions.node.split('.')[0], 10) >= 12; +function versionGte(version: string, requirement: string) { + const [major, minor, patch, extra] = version + .split(/[\.-]/) + .map((s) => parseInt(s, 10)); + const [reqMajor, reqMinor, reqPatch] = requirement + .split('.') + .map((s) => parseInt(s, 10)); + return ( + major > reqMajor || + (major === reqMajor && + (minor > reqMinor || (minor === reqMinor && patch >= reqPatch))) + ); +} + /** * Assert that script can be loaded as CommonJS when we attempt to require it. * If it should be loaded as ESM, throw ERR_REQUIRE_ESM like node does. @@ -104,6 +119,7 @@ export interface ProcessEnv { TS_NODE_COMPILER_HOST?: string; TS_NODE_LOG_ERROR?: string; TS_NODE_HISTORY?: string; + TS_NODE_EXPERIMENTAL_REPL_AWAIT?: string; NODE_NO_READLINE?: string; } @@ -280,6 +296,17 @@ export interface CreateOptions { * @internal */ experimentalEsmLoader?: boolean; + /** + * Allows the usage of top level await in REPL. + * + * Uses node's implementation which accomplishes this with an AST syntax transformation. + * + * Enabled by default when tsconfig target is es2018 or above. Set to false to disable. + * + * **Note**: setting to `true` when tsconfig target is too low will throw an Error. Leave as `undefined` + * to get default, automatic behavior. + */ + experimentalReplAwait?: boolean; /** * Override certain paths to be compiled and executed as CommonJS or ECMAScript modules. * When overridden, the tsconfig "module" and package.json "type" fields are overridden. @@ -375,6 +402,7 @@ export const DEFAULTS: RegisterOptions = { compilerHost: yn(env.TS_NODE_COMPILER_HOST), logError: yn(env.TS_NODE_LOG_ERROR), experimentalEsmLoader: false, + experimentalReplAwait: yn(env.TS_NODE_EXPERIMENTAL_REPL_AWAIT) ?? undefined, }; /** @@ -411,6 +439,8 @@ export interface Service { /** @internal */ moduleTypeClassifier: ModuleTypeClassifier; /** @internal */ + readonly shouldReplAwait: boolean; + /** @internal */ addDiagnosticFilter(filter: DiagnosticFilter): void; } @@ -515,6 +545,26 @@ export function create(rawOptions: CreateOptions = {}): Service { ...(rawOptions.require || []), ]; + // Experimental REPL await is not compatible targets lower than ES2018 + const targetSupportsTla = config.options.target! >= ScriptTarget.ES2018; + if (options.experimentalReplAwait === true && !targetSupportsTla) { + throw new Error( + 'Experimental REPL await is not compatible with targets lower than ES2018' + ); + } + // Top-level await was added in TS 3.8 + const tsVersionSupportsTla = versionGte(ts.version, '3.8.0'); + if (options.experimentalReplAwait === true && !tsVersionSupportsTla) { + throw new Error( + 'Experimental REPL await is not compatible with TypeScript versions older than 3.8' + ); + } + + const shouldReplAwait = + options.experimentalReplAwait !== false && + tsVersionSupportsTla && + targetSupportsTla; + // 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. @@ -1160,7 +1210,12 @@ export function create(rawOptions: CreateOptions = {}): Service { }; function addDiagnosticFilter(filter: DiagnosticFilter) { - diagnosticFilters.push(filter); + diagnosticFilters.push({ + ...filter, + filenamesAbsolute: filter.filenamesAbsolute.map((f) => + normalizeSlashes(f) + ), + }); } return { @@ -1173,6 +1228,7 @@ export function create(rawOptions: CreateOptions = {}): Service { options, configFilePath, moduleTypeClassifier, + shouldReplAwait, addDiagnosticFilter, }; } diff --git a/src/repl.ts b/src/repl.ts index 8537e58dd..49a0c6c44 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -1,13 +1,30 @@ import { diffLines } from 'diff'; import { homedir } from 'os'; -import { dirname, join } from 'path'; -import { Recoverable, start } from 'repl'; -import { Script } from 'vm'; +import { join } from 'path'; +import { + Recoverable, + ReplOptions, + REPLServer, + start as nodeReplStart, +} from 'repl'; +import { Context, createContext, Script } from 'vm'; import { Service, CreateOptions, TSError, env } from './index'; import { readFileSync, statSync } from 'fs'; import { Console } from 'console'; +import * as assert from 'assert'; import type * as tty from 'tty'; -import Module = require('module'); +import type * as Module from 'module'; + +// Lazy-loaded. +let _processTopLevelAwait: (src: string) => string | null; +function getProcessTopLevelAwait() { + if (_processTopLevelAwait === undefined) { + ({ + processTopLevelAwait: _processTopLevelAwait, + } = require('../dist-raw/node-repl-await')); + } + return _processTopLevelAwait; +} /** @internal */ export const EVAL_FILENAME = `[eval].ts`; @@ -28,19 +45,57 @@ export interface ReplService { * Bind this REPL to a ts-node compiler service. A compiler service must be bound before `eval`-ing code or starting the REPL */ setService(service: Service): void; - evalCode(code: string): void; + /** + * Append code to the virtual source file, compile it to JavaScript, throw semantic errors if the typechecker is enabled, + * and execute it. + * + * Note: typically, you will want to call `start()` instead of using this method. + * + * @param code string of TypeScript. + */ + evalCode(code: string): any; + /** @internal */ + evalCodeInternal(opts: { + code: string; + enableTopLevelAwait?: boolean; + context?: Context; + }): + | { + containsTopLevelAwait: true; + valuePromise: Promise; + } + | { + containsTopLevelAwait: false; + value: any; + }; /** * `eval` implementation compatible with node's REPL API + * + * Can be used in advanced scenarios if you want to manually create your own + * node REPL instance and delegate eval to this `ReplService`. + * + * Example: + * + * import {start} from 'repl'; + * const replService: tsNode.ReplService = ...; // assuming you have already created a ts-node ReplService + * const nodeRepl = start({eval: replService.eval}); */ nodeEval( code: string, - _context: any, + context: Context, _filename: string, callback: (err: Error | null, result?: any) => any ): void; evalAwarePartialHost: EvalAwarePartialHost; /** Start a node REPL */ - start(code?: string): void; + start(): void; + /** + * Start a node REPL, evaling a string of TypeScript before it starts. + * @deprecated + */ + start(code: string): void; + /** @internal */ + startInternal(opts?: ReplOptions): REPLServer; /** @internal */ readonly stdin: NodeJS.ReadableStream; /** @internal */ @@ -59,6 +114,7 @@ export interface CreateReplOptions { stderr?: NodeJS.WritableStream; /** @internal */ composeWithEvalAwarePartialHost?: EvalAwarePartialHost; + // TODO collapse both of the following two flags into a single `isInteractive` or `isLineByLine` flag. /** * @internal * Ignore diagnostics that are annoying when interactively entering input line-by-line. @@ -69,15 +125,24 @@ export interface CreateReplOptions { /** * Create a ts-node REPL instance. * + * Pay close attention to the example below. Today, the API requires a few lines + * of boilerplate to correctly bind the `ReplService` to the ts-node `Service` and + * vice-versa. + * * Usage example: * - * const repl = tsNode.createRepl() - * const service = tsNode.create({...repl.evalAwarePartialHost}) - * repl.setService(service) - * repl.start() + * const repl = tsNode.createRepl(); + * const service = tsNode.create({...repl.evalAwarePartialHost}); + * repl.setService(service); + * repl.start(); */ export function createRepl(options: CreateReplOptions = {}) { + const { ignoreDiagnosticsThatAreAnnoyingInInteractiveRepl = true } = options; let service = options.service; + let nodeReplServer: REPLServer; + // If `useGlobal` is not true, then REPL creates a context when started. + // This stores a reference to it or to `global`, whichever is used, after REPL has started. + let context: Context | undefined; const state = options.state ?? new EvalState(join(process.cwd(), REPL_FILENAME)); const evalAwarePartialHost = createEvalAwarePartialHost( @@ -91,20 +156,22 @@ export function createRepl(options: CreateReplOptions = {}) { stdout === process.stdout && stderr === process.stderr ? console : new Console(stdout, stderr); - const { ignoreDiagnosticsThatAreAnnoyingInInteractiveRepl = true } = options; const replService: ReplService = { state: options.state ?? new EvalState(join(process.cwd(), EVAL_FILENAME)), setService, evalCode, + evalCodeInternal, nodeEval, evalAwarePartialHost, start, + startInternal, stdin, stdout, stderr, console: _console, }; + return replService; function setService(_service: Service) { @@ -117,51 +184,219 @@ export function createRepl(options: CreateReplOptions = {}) { 2393, // Duplicate function implementation: https://github.com/TypeStrong/ts-node/issues/729 6133, // is declared but its value is never read. https://github.com/TypeStrong/ts-node/issues/850 7027, // Unreachable code detected. https://github.com/TypeStrong/ts-node/issues/469 + ...(service.shouldReplAwait ? topLevelAwaitDiagnosticCodes : []), ], }); } } function evalCode(code: string) { - return _eval(service!, state, code); + const result = appendCompileAndEvalInput({ + service: service!, + state, + input: code, + context, + }); + assert(result.containsTopLevelAwait === false); + return result.value; + } + + function evalCodeInternal(options: { + code: string; + enableTopLevelAwait?: boolean; + context: Context; + }) { + const { code, enableTopLevelAwait, context } = options; + return appendCompileAndEvalInput({ + service: service!, + state, + input: code, + enableTopLevelAwait, + context, + }); } function nodeEval( code: string, - _context: any, + context: Context, _filename: string, callback: (err: Error | null, result?: any) => any ) { - let err: Error | null = null; - let result: any; - // TODO: Figure out how to handle completion here. if (code === '.scope') { - callback(err); + callback(null); return; } try { - result = evalCode(code); + const evalResult = evalCodeInternal({ + code, + enableTopLevelAwait: true, + context, + }); + + if (evalResult.containsTopLevelAwait) { + (async () => { + try { + callback(null, await evalResult.valuePromise); + } catch (promiseError) { + handleError(promiseError); + } + })(); + } else { + callback(null, evalResult.value); + } } catch (error) { + handleError(error); + } + + // Log TSErrors, check if they're recoverable, log helpful hints for certain + // well-known errors, and invoke `callback()` + // TODO should evalCode API get the same error-handling benefits? + function handleError(error: unknown) { + // Don't show TLA hint if the user explicitly disabled repl top level await + const canLogTopLevelAwaitHint = + service!.options.experimentalReplAwait !== false && + !service!.shouldReplAwait; if (error instanceof TSError) { // Support recoverable compilations using >= node 6. if (Recoverable && isRecoverable(error)) { - err = new Recoverable(error); + callback(new Recoverable(error)); + return; } else { _console.error(error); + + if ( + canLogTopLevelAwaitHint && + error.diagnosticCodes.some((dC) => + topLevelAwaitDiagnosticCodes.includes(dC) + ) + ) { + _console.error(getTopLevelAwaitHint()); + } + callback(null); } } else { - err = error; + let _error = error as Error | undefined; + if ( + canLogTopLevelAwaitHint && + _error instanceof SyntaxError && + _error.message?.includes('await is only valid') + ) { + try { + // Only way I know to make our hint appear after the error + _error.message += `\n\n${getTopLevelAwaitHint()}`; + _error.stack = _error.stack?.replace( + /(SyntaxError:.*)/, + (_, $1) => `${$1}\n\n${getTopLevelAwaitHint()}` + ); + } catch {} + } + callback(_error as Error); } } - - return callback(err, result); + function getTopLevelAwaitHint() { + return `Hint: REPL top-level await requires TypeScript version 3.8 or higher and target ES2018 or higher. You are using TypeScript ${ + service!.ts.version + } and target ${ + service!.ts.ScriptTarget[service!.config.options.target!] + }.`; + } } + // Note: `code` argument is deprecated function start(code?: string) { - // TODO assert that service is set; remove all ! postfixes - return startRepl(replService, service!, state, code); + startInternal({ code }); + } + + // Note: `code` argument is deprecated + function startInternal( + options?: ReplOptions & { code?: string; forceToBeModule?: boolean } + ) { + const { code, forceToBeModule = true, ...optionsOverride } = options ?? {}; + // TODO assert that `service` is set; remove all `service!` non-null assertions + + // Eval incoming code before the REPL starts. + // Note: deprecated + if (code) { + try { + evalCode(`${code}\n`); + } catch (err) { + _console.error(err); + // Note: should not be killing the process here, but this codepath is deprecated anyway + process.exit(1); + } + } + + const repl = nodeReplStart({ + prompt: '> ', + input: replService.stdin, + output: replService.stdout, + // Mimicking node's REPL implementation: https://github.com/nodejs/node/blob/168b22ba073ee1cbf8d0bcb4ded7ff3099335d04/lib/internal/repl.js#L28-L30 + terminal: + (stdout as tty.WriteStream).isTTY && + !parseInt(env.NODE_NO_READLINE!, 10), + eval: nodeEval, + useGlobal: true, + ...optionsOverride, + }); + + nodeReplServer = repl; + context = repl.context; + + // Bookmark the point where we should reset the REPL state. + const resetEval = appendToEvalState(state, ''); + + function reset() { + resetEval(); + + // Hard fix for TypeScript forcing `Object.defineProperty(exports, ...)`. + runInContext('exports = module.exports', state.path, context); + if (forceToBeModule) { + state.input += 'export {};void 0;\n'; + } + } + + reset(); + repl.on('reset', reset); + + repl.defineCommand('type', { + help: 'Check the type of a TypeScript identifier', + action: function (identifier: string) { + if (!identifier) { + repl.displayPrompt(); + return; + } + + const undo = appendToEvalState(state, identifier); + const { name, comment } = service!.getTypeInfo( + state.input, + state.path, + state.input.length + ); + + undo(); + + if (name) repl.outputStream.write(`${name}\n`); + if (comment) repl.outputStream.write(`${comment}\n`); + repl.displayPrompt(); + }, + }); + + // Set up REPL history when available natively via node.js >= 11. + if (repl.setupHistory) { + const historyPath = + env.TS_NODE_HISTORY || join(homedir(), '.ts_node_repl_history'); + + repl.setupHistory(historyPath, (err) => { + if (!err) return; + + _console.error(err); + process.exit(1); + }); + } + + return repl; } } @@ -184,7 +419,7 @@ export class EvalState { } /** - * Filesystem host functions which are aware of the "virtual" [eval].ts file used to compile REPL inputs. + * Filesystem host functions which are aware of the "virtual" `[eval].ts`, ``, or `[stdin].ts` file used to compile REPL inputs. * Must be passed to `create()` to create a ts-node compiler service which can compile REPL inputs. */ export type EvalAwarePartialHost = Pick< @@ -222,13 +457,33 @@ export function createEvalAwarePartialHost( return { readFile, fileExists }; } +type AppendCompileAndEvalInputResult = + | { containsTopLevelAwait: true; valuePromise: Promise } + | { containsTopLevelAwait: false; value: any }; /** * Evaluate the code snippet. + * + * Append it to virtual .ts file, compile, handle compiler errors, compute a diff of the JS, and eval any code that + * appears as "added" in the diff. */ -function _eval(service: Service, state: EvalState, input: string) { +function appendCompileAndEvalInput(options: { + service: Service; + state: EvalState; + input: string; + /** Enable top-level await but only if the TSNode service allows it. */ + enableTopLevelAwait?: boolean; + context: Context | undefined; +}): AppendCompileAndEvalInputResult { + const { + service, + state, + input, + enableTopLevelAwait = false, + context, + } = options; const lines = state.lines; const isCompletion = !/\n$/.test(input); - const undo = appendEval(state, input); + const undo = appendToEvalState(state, input); let output: string; // Based on https://github.com/nodejs/node/blob/92573721c7cff104ccb82b6ed3e8aa69c4b27510/lib/repl.js#L457-L461 @@ -256,105 +511,75 @@ function _eval(service: Service, state: EvalState, input: string) { state.output = output; } - return changes.reduce((result, change) => { - return change.added ? exec(change.value, state.path) : result; - }, undefined); -} - -/** - * Execute some code. - */ -function exec(code: string, filename: string) { - const script = new Script(code, { filename }); - - return script.runInThisContext(); -} - -/** - * Start a CLI REPL. - */ -function startRepl( - replService: ReplService, - service: Service, - state: EvalState, - code?: string -) { - // Eval incoming code before the REPL starts. - if (code) { - try { - replService.evalCode(`${code}\n`); - } catch (err) { - replService.console.error(err); - process.exit(1); + let commands: Array<{ mustAwait?: true; execCommand: () => any }> = []; + let containsTopLevelAwait = false; + + // Build a list of "commands": bits of JS code in the diff that must be executed. + for (const change of changes) { + if (change.added) { + if ( + enableTopLevelAwait && + service.shouldReplAwait && + change.value.indexOf('await') > -1 + ) { + const processTopLevelAwait = getProcessTopLevelAwait(); + + // Newline prevents comments to mess with wrapper + const wrappedResult = processTopLevelAwait(change.value + '\n'); + if (wrappedResult !== null) { + containsTopLevelAwait = true; + commands.push({ + mustAwait: true, + execCommand: () => runInContext(wrappedResult, state.path, context), + }); + continue; + } + } + commands.push({ + execCommand: () => runInContext(change.value, state.path, context), + }); } } - const repl = start({ - prompt: '> ', - input: replService.stdin, - output: replService.stdout, - // Mimicking node's REPL implementation: https://github.com/nodejs/node/blob/168b22ba073ee1cbf8d0bcb4ded7ff3099335d04/lib/internal/repl.js#L28-L30 - terminal: - (replService.stdout as tty.WriteStream).isTTY && - !parseInt(env.NODE_NO_READLINE!, 10), - eval: replService.nodeEval, - useGlobal: true, - }); - - // Bookmark the point where we should reset the REPL state. - const resetEval = appendEval(state, ''); - - function reset() { - resetEval(); - - // Hard fix for TypeScript forcing `Object.defineProperty(exports, ...)`. - exec('exports = module.exports', state.path); + // Execute all commands asynchronously if necessary, returning the result or a + // promise of the result. + if (containsTopLevelAwait) { + return { + containsTopLevelAwait, + valuePromise: (async () => { + let value; + for (const command of commands) { + const r = command.execCommand(); + value = command.mustAwait ? await r : r; + } + return value; + })(), + }; + } else { + return { + containsTopLevelAwait: false, + value: commands.reduce((_, c) => c.execCommand(), undefined), + }; } +} - reset(); - repl.on('reset', reset); - - repl.defineCommand('type', { - help: 'Check the type of a TypeScript identifier', - action: function (identifier: string) { - if (!identifier) { - repl.displayPrompt(); - return; - } - - const undo = appendEval(state, identifier); - const { name, comment } = service.getTypeInfo( - state.input, - state.path, - state.input.length - ); - - undo(); - - if (name) repl.outputStream.write(`${name}\n`); - if (comment) repl.outputStream.write(`${comment}\n`); - repl.displayPrompt(); - }, - }); - - // Set up REPL history when available natively via node.js >= 11. - if (repl.setupHistory) { - const historyPath = - env.TS_NODE_HISTORY || join(homedir(), '.ts_node_repl_history'); - - repl.setupHistory(historyPath, (err) => { - if (!err) return; +/** + * Low-level execution of JS code in context + */ +function runInContext(code: string, filename: string, context?: Context) { + const script = new Script(code, { filename }); - replService.console.error(err); - process.exit(1); - }); + if (context === undefined || context === global) { + return script.runInThisContext(); + } else { + return script.runInContext(context); } } /** * Append to the eval instance and return an undo function. */ -function appendEval(state: EvalState, input: string) { +function appendToEvalState(state: EvalState, input: string) { const undoInput = state.input; const undoVersion = state.version; const undoOutput = state.output; @@ -396,6 +621,10 @@ function lineCount(value: string) { return count; } +/** + * TS diagnostic codes which are recoverable, meaning that the user likely entered and incomplete line of code + * and should be prompted for the next. For example, starting a multi-line for() loop and not finishing it. + */ const RECOVERY_CODES: Set = new Set([ 1003, // "Identifier expected." 1005, // "')' expected." @@ -406,6 +635,18 @@ const RECOVERY_CODES: Set = new Set([ 2355, // "A function whose declared type is neither 'void' nor 'any' must return a value." ]); +/** + * Diagnostic codes raised when using top-level await. + * These are suppressed when top-level await is enabled. + * When it is *not* enabled, these trigger a helpful hint about enabling top-level await. + */ +const topLevelAwaitDiagnosticCodes = [ + 1375, // 'await' expressions are only allowed at the top level of a file when that file is a module, but this file has no imports or exports. Consider adding an empty 'export {}' to make this file a module. + 1378, // Top-level 'await' expressions are only allowed when the 'module' option is set to 'esnext' or 'system', and the 'target' option is set to 'es2017' or higher. + 1431, // 'for await' loops are only allowed at the top level of a file when that file is a module, but this file has no imports or exports. Consider adding an empty 'export {}' to make this file a module. + 1432, // Top-level 'for await' loops are only allowed when the 'module' option is set to 'esnext' or 'system', and the 'target' option is set to 'es2017' or higher. +]; + /** * Check if a function can recover gracefully. */ @@ -413,8 +654,11 @@ function isRecoverable(error: TSError) { return error.diagnosticCodes.every((code) => RECOVERY_CODES.has(code)); } -/** @internal */ -export function setContext( +/** + * @internal + * Set properties on `context` before eval-ing [stdin] or [eval] input. + */ +export function setupContext( context: any, module: Module, filenameAndDirname: 'eval' | 'stdin' | null @@ -427,15 +671,3 @@ export function setContext( context.exports = module.exports; context.require = module.require.bind(module); } - -/** @internal */ -export function createNodeModuleForContext( - type: 'eval' | 'stdin', - cwd: string -) { - // Create a local module instance based on `cwd`. - const module = new Module(`[${type}]`); - module.filename = join(cwd, module.id) + '.ts'; - module.paths = (Module as any)._nodeModulePaths(cwd); - return module; -} diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 927d2db1d..6b3c337eb 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -1,4 +1,4 @@ -import { test, TestInterface } from './testlib'; +import { test } from './testlib'; import { expect } from 'chai'; import * as exp from 'expect'; import { @@ -14,15 +14,7 @@ import ts = require('typescript'); import proxyquire = require('proxyquire'); import type * as tsNodeTypes from '../index'; import * as fs from 'fs'; -import { - unlinkSync, - existsSync, - lstatSync, - mkdtempSync, - fstat, - copyFileSync, - writeFileSync, -} from 'fs'; +import { unlinkSync, existsSync, lstatSync, mkdtempSync } from 'fs'; import { NodeFS, npath } from '@yarnpkg/fslib'; import * as promisify from 'util.promisify'; import { sync as rimrafSync } from 'rimraf'; @@ -33,10 +25,80 @@ import type * as Module from 'module'; import { PassThrough } from 'stream'; import * as getStream from 'get-stream'; import { once } from 'lodash'; +import { upstreamTopLevelAwaitTests } from './node-repl-tla'; import { createMacrosAndHelpers, ExecMacroAssertionCallback } from './macros'; const xfs = new NodeFS(fs); +async function settled(fn: () => Promise | T) { + try { + return { + status: 'fulfilled', + value: await fn(), + }; + } catch (reason) { + return { + status: 'rejected', + reason, + }; + } +} + +interface CreateReplViaApiOptions { + createReplOpts?: Partial; + createServiceOpts?: Partial; +} +function createReplViaApi({ + createReplOpts, + createServiceOpts, +}: CreateReplViaApiOptions = {}) { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const replService = createRepl({ + stdin, + stdout, + stderr, + ...createReplOpts, + }); + const service = create({ + ...replService.evalAwarePartialHost, + project: `${TEST_DIR}/tsconfig.json`, + ...createServiceOpts, + }); + replService.setService(service); + return { stdin, stdout, stderr, replService, service }; +} + +// Todo combine with replApiMacro +async function executeInRepl( + input: string, + { + waitMs = 1e3, + startOptions, + ...rest + }: CreateReplViaApiOptions & { + waitMs?: number; + startOptions?: Parameters[0]; + } = {} +) { + const { stdin, stdout, stderr, replService } = createReplViaApi(rest); + + replService.startInternal(startOptions); + + stdin.write(input); + stdin.end(); + await promisify(setTimeout)(waitMs); + stdout.end(); + stderr.end(); + + return { + stdin, + stdout: await getStream(stdout), + stderr: await getStream(stderr), + }; +} + const ROOT_DIR = resolve(__dirname, '../..'); const DIST_DIR = resolve(__dirname, '..'); const TEST_DIR = join(__dirname, '../../tests'); @@ -407,23 +469,6 @@ test.suite('ts-node', (test) => { ); }); - function createReplViaApi() { - const stdin = new PassThrough(); - const stdout = new PassThrough(); - const stderr = new PassThrough(); - const replService = createRepl({ - stdin, - stdout, - stderr, - }); - const service = create({ - ...replService.evalAwarePartialHost, - project: `${TEST_DIR}/tsconfig.json`, - }); - replService.setService(service); - return { stdin, stdout, stderr, replService, service }; - } - const execMacro = createExecMacro({ cmd, cwd: TEST_DIR, @@ -467,6 +512,42 @@ test.suite('ts-node', (test) => { } ); + // Serial because it's timing-sensitive + test.serial('REPL can be configured on `start`', async () => { + const prompt = '#> '; + + const { stdout, stderr } = await executeInRepl('const x = 3', { + startOptions: { + prompt, + ignoreUndefined: true, + }, + }); + + expect(stderr).to.equal(''); + expect(stdout).to.equal(`${prompt}${prompt}`); + }); + + // Serial because it's timing-sensitive + test.serial( + 'REPL uses a different context when `useGlobal` is false', + async () => { + const { stdout, stderr } = await executeInRepl( + // No error when re-declaring x + 'const x = 3\n' + + // console.log ouput will end up in the stream and not in test output + 'console.log(1)\n', + { + startOptions: { + useGlobal: false, + }, + } + ); + + expect(stderr).to.equal(''); + expect(stdout).to.equal(`> undefined\n> 1\nundefined\n> `); + } + ); + test.suite( '[eval], , and [stdin] execute with correct globals', (test) => { @@ -633,7 +714,7 @@ test.suite('ts-node', (test) => { exportsTest: true, // Note: vanilla node uses different name. See #1360 stackTest: exp.stringContaining( - ` at ${join(TEST_DIR, '.ts')}:1:` + ` at ${join(TEST_DIR, '.ts')}:2:` ), moduleAccessorsTest: true, argv: [tsNodeExe], @@ -806,7 +887,7 @@ test.suite('ts-node', (test) => { exportsTest: true, // Note: vanilla node uses different name. See #1360 stackTest: exp.stringContaining( - ` at ${join(TEST_DIR, '.ts')}:1:` + ` at ${join(TEST_DIR, '.ts')}:2:` ), moduleAccessorsTest: true, argv: [tsNodeExe], @@ -1988,4 +2069,139 @@ test.suite('ts-node', (test) => { }); } }); + + test.suite('top level await', (test) => { + const compilerOptions = { + target: 'es2018', + }; + function executeInTlaRepl(input: string, waitMs = 1000) { + return executeInRepl( + input + .split('\n') + .map((line) => line.trim()) + // Restore newline once https://github.com/nodejs/node/pull/39392 is merged + .join(''), + { + waitMs, + createServiceOpts: { + experimentalReplAwait: true, + compilerOptions, + }, + startOptions: { useGlobal: false }, + } + ); + } + + if (semver.gte(ts.version, '3.8.0')) { + // Serial because it's timing-sensitive + test.serial('should allow evaluating top level await', async () => { + const script = ` + const x: number = await new Promise((r) => r(1)); + for await (const x of [1,2,3]) { console.log(x) }; + for (const x of ['a', 'b']) { await x; console.log(x) }; + class Foo {}; await 1; + function Bar() {}; await 2; + const {y} = await ({y: 2}); + const [z] = await [3]; + x + y + z; + `; + + const { stdout, stderr } = await executeInTlaRepl(script); + expect(stderr).to.equal(''); + expect(stdout).to.equal('> 1\n2\n3\na\nb\n6\n> '); + }); + + // Serial because it's timing-sensitive + test.serial( + 'should wait until promise is settled when awaiting at top level', + async () => { + const awaitMs = 500; + const script = ` + const startTime = new Date().getTime(); + await new Promise((r) => setTimeout(() => r(1), ${awaitMs})); + const endTime = new Date().getTime(); + endTime - startTime; + `; + const { stdout, stderr } = await executeInTlaRepl(script, 6000); + + expect(stderr).to.equal(''); + + const elapsedTime = Number( + stdout.split('\n')[0].replace('> ', '').trim() + ); + expect(elapsedTime).to.be.gte(awaitMs - 50); + expect(elapsedTime).to.be.lte(awaitMs + 100); + } + ); + + // Serial because it's timing-sensitive + test.serial( + 'should not wait until promise is settled when not using await at top level', + async () => { + const script = ` + const startTime = new Date().getTime(); + (async () => await new Promise((r) => setTimeout(() => r(1), ${1000})))(); + const endTime = new Date().getTime(); + endTime - startTime; + `; + const { stdout, stderr } = await executeInTlaRepl(script); + + expect(stderr).to.equal(''); + + const ellapsedTime = Number( + stdout.split('\n')[0].replace('> ', '').trim() + ); + expect(ellapsedTime).to.be.gte(0); + expect(ellapsedTime).to.be.lte(10); + } + ); + + // Serial because it's timing-sensitive + test.serial( + 'should error with typing information when awaited result has type mismatch', + async () => { + const { stdout, stderr } = await executeInTlaRepl( + 'const x: string = await 1' + ); + + expect(stdout).to.equal('> > '); + expect(stderr.replace(/\r\n/g, '\n')).to.equal( + '.ts(2,7): error TS2322: ' + + (semver.gte(ts.version, '4.0.0') + ? `Type 'number' is not assignable to type 'string'.\n` + : `Type '1' is not assignable to type 'string'.\n`) + + '\n' + ); + } + ); + + // Serial because it's timing-sensitive + test.serial( + 'should error with typing information when importing a file with type errors', + async () => { + const { stdout, stderr } = await executeInTlaRepl( + `const {foo} = await import('./tests/repl/tla-import');` + ); + + expect(stdout).to.equal('> > '); + expect(stderr.replace(/\r\n/g, '\n')).to.equal( + 'tests/repl/tla-import.ts(1,14): error TS2322: ' + + (semver.gte(ts.version, '4.0.0') + ? `Type 'number' is not assignable to type 'string'.\n` + : `Type '1' is not assignable to type 'string'.\n`) + + '\n' + ); + } + ); + + test('should pass upstream test cases', async () => + upstreamTopLevelAwaitTests({ TEST_DIR, create, createRepl })); + } else { + test('should throw error when attempting to use top level await on TS < 3.8', async () => { + exp(executeInTlaRepl('', 1000)).rejects.toThrow( + 'Experimental REPL await is not compatible with TypeScript versions older than 3.8' + ); + }); + } + }); }); diff --git a/src/test/node-repl-tla.ts b/src/test/node-repl-tla.ts new file mode 100644 index 000000000..c981892d1 --- /dev/null +++ b/src/test/node-repl-tla.ts @@ -0,0 +1,336 @@ +import { expect } from 'chai'; +import type { Key } from 'readline'; +import { Stream } from 'stream'; +import type * as tsNodeTypes from '../index'; +import semver = require('semver'); +import ts = require('typescript'); + +interface SharedObjects + extends Pick { + TEST_DIR: string; +} + +// Based on https://github.com/nodejs/node/blob/88799930794045795e8abac874730f9eba7e2300/test/parallel/test-repl-top-level-await.js +export async function upstreamTopLevelAwaitTests({ + TEST_DIR, + create, + createRepl, +}: SharedObjects) { + const PROMPT = 'await repl > '; + + const putIn = new REPLStream(); + const replService = createRepl({ + // @ts-ignore + stdin: putIn, + // @ts-ignore + stdout: putIn, + // @ts-ignore + stderr: putIn, + }); + const service = create({ + ...replService.evalAwarePartialHost, + project: `${TEST_DIR}/tsconfig.json`, + experimentalReplAwait: true, + transpileOnly: true, + compilerOptions: { + target: semver.gte(ts.version, '3.0.1') + ? 'es2018' + : // TS 2.7 is using polyfill for async interator even though they + // were added in es2018 + 'esnext', + }, + }); + replService.setService(service); + (replService.stdout as NodeJS.WritableStream & { + isTTY: boolean; + }).isTTY = true; + const replServer = replService.startInternal({ + prompt: PROMPT, + terminal: true, + useColors: true, + useGlobal: false, + }); + + function runAndWait(cmds: Array) { + const promise = putIn.wait(); + for (const cmd of cmds) { + if (typeof cmd === 'string') { + putIn.run([cmd]); + } else { + replServer.write('', cmd); + } + } + return promise; + } + + runAndWait([ + 'function foo(x) { return x; }', + 'function koo() { return Promise.resolve(4); }', + ]); + + const testCases = [ + ['await Promise.resolve(0)', '0'], + + // issue: { a: await Promise.resolve(1) } is being interpreted as a block + // remove surrounding parenthesis once issue is fixed + ['({ a: await Promise.resolve(1) })', '{ a: 1 }'], + + ['_', '{ a: 1 }'], + ['let { aa, bb } = await Promise.resolve({ aa: 1, bb: 2 }), f = 5;'], + ['aa', '1'], + ['bb', '2'], + ['f', '5'], + ['let cc = await Promise.resolve(2)'], + ['cc', '2'], + ['let dd;'], + ['dd'], + ['let [ii, { abc: { kk } }] = [0, { abc: { kk: 1 } }];'], + ['ii', '0'], + ['kk', '1'], + ['var ll = await Promise.resolve(2);'], + ['ll', '2'], + ['foo(await koo())', '4'], + ['_', '4'], + ['const m = foo(await koo());'], + ['m', '4'], + + // issue: REPL doesn't recognize end of input + // compile is returning TS1005 after second line even though + // it's valid syntax + // [ + // 'const n = foo(await\nkoo());', + // ['const n = foo(await\r', '... koo());\r', 'undefined'], + // ], + + [ + '`status: ${(await Promise.resolve({ status: 200 })).status}`', + "'status: 200'", + ], + ['for (let i = 0; i < 2; ++i) await i'], + ['for (let i = 0; i < 2; ++i) { await i }'], + ['await 0', '0'], + ['await 0; function foo() {}'], + ['foo', '[Function: foo]'], + ['class Foo {}; await 1;', '1'], + + [ + 'Foo', + // Adjusted since ts-node supports older versions of node + semver.gte(process.version, '12.18.0') + ? '[class Foo]' + : '[Function: Foo]', + ], + ['if (await true) { function fooz() {}; }'], + ['fooz', '[Function: fooz]'], + ['if (await true) { class Bar {}; }'], + + [ + 'Bar', + // Adjusted since ts-node supports older versions of node + semver.gte(process.version, '12.16.0') + ? 'Uncaught ReferenceError: Bar is not defined' + : 'ReferenceError: Bar is not defined', + // Line increased due to TS added lines + { + line: semver.gte(process.version, '12.16.0') ? 4 : 5, + }, + ], + + ['await 0; function* gen(){}'], + ['for (var i = 0; i < 10; ++i) { await i; }'], + ['i', '10'], + ['for (let j = 0; j < 5; ++j) { await j; }'], + + [ + 'j', + // Adjusted since ts-node supports older versions of node + semver.gte(process.version, '12.16.0') + ? 'Uncaught ReferenceError: j is not defined' + : 'ReferenceError: j is not defined', + // Line increased due to TS added lines + { + line: semver.gte(process.version, '12.16.0') ? 4 : 5, + }, + ], + + ['gen', '[GeneratorFunction: gen]'], + + [ + 'return 42; await 5;', + // Adjusted since ts-node supports older versions of node + semver.gte(process.version, '12.16.0') + ? 'Uncaught SyntaxError: Illegal return statement' + : 'SyntaxError: Illegal return statement', + // Line increased due to TS added lines + { + line: semver.gte(process.version, '12.16.0') ? 4 : 5, + }, + ], + + ['let o = await 1, p'], + ['p'], + ['let q = 1, s = await 2'], + ['s', '2'], + [ + 'for await (let i of [1,2,3]) console.log(i)', + [ + 'for await (let i of [1,2,3]) console.log(i)\r', + '1', + '2', + '3', + 'undefined', + ], + ], + + // issue: REPL is expecting more input to finish execution + // compiler is returning TS1003 error + // [ + // 'await Promise..resolve()', + // [ + // 'await Promise..resolve()\r', + // 'Uncaught SyntaxError: ', + // 'await Promise..resolve()', + // ' ^', + // '', + // "Unexpected token '.'", + // ], + // ], + + [ + 'for (const x of [1,2,3]) {\nawait x\n}', + ['for (const x of [1,2,3]) {\r', '... await x\r', '... }\r', 'undefined'], + ], + [ + 'for (const x of [1,2,3]) {\nawait x;\n}', + [ + 'for (const x of [1,2,3]) {\r', + '... await x;\r', + '... }\r', + 'undefined', + ], + ], + [ + 'for await (const x of [1,2,3]) {\nconsole.log(x)\n}', + [ + 'for await (const x of [1,2,3]) {\r', + '... console.log(x)\r', + '... }\r', + '1', + '2', + '3', + 'undefined', + ], + ], + [ + 'for await (const x of [1,2,3]) {\nconsole.log(x);\n}', + [ + 'for await (const x of [1,2,3]) {\r', + '... console.log(x);\r', + '... }\r', + '1', + '2', + '3', + 'undefined', + ], + ], + ] as const; + + for (const [ + input, + expected = [`${input}\r`], + options = {} as { line?: number }, + ] of testCases) { + const toBeRun = input.split('\n'); + const lines = await runAndWait(toBeRun); + if (Array.isArray(expected)) { + if (expected.length === 1) expected.push('undefined'); + if (lines[0] === input) lines.shift(); + expect(lines).to.eqls([...expected, PROMPT]); + } else if ('line' in options) { + expect(lines[toBeRun.length + options.line!]).to.eqls(expected); + } else { + const echoed = toBeRun.map((a, i) => `${i > 0 ? '... ' : ''}${a}\r`); + expect(lines).to.eqls([...echoed, expected, PROMPT]); + } + } +} + +// copied from https://github.com/nodejs/node/blob/88799930794045795e8abac874730f9eba7e2300/lib/internal/util/inspect.js#L220-L227 +// Regex used for ansi escape code splitting +// Adopted from https://github.com/chalk/ansi-regex/blob/HEAD/index.js +// License: MIT, authors: @sindresorhus, Qix-, arjunmehta and LitoMore +// Matches all ansi escape code sequences in a string +const ansiPattern = + '[\\u001B\\u009B][[\\]()#;?]*' + + '(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)' + + '|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))'; +const ansi = new RegExp(ansiPattern, 'g'); + +// copied from https://github.com/nodejs/node/blob/88799930794045795e8abac874730f9eba7e2300/lib/internal/util/inspect.js#L2112-L2117 +/** + * Remove all VT control characters. Use to estimate displayed string width. + */ +function stripVTControlCharacters(str: string) { + return str.replace(ansi, ''); +} + +// copied from https://github.com/nodejs/node/blob/88799930794045795e8abac874730f9eba7e2300/test/parallel/test-repl-top-level-await.js +class ArrayStream extends Stream { + readable = true; + writable = true; + + run(data: string[]) { + data.forEach((line) => { + this.emit('data', `${line}\n`); + }); + } + + pause() {} + resume() {} + write(_chunk: Buffer | string, _encoding: string, _callback: () => {}) {} +} + +export class REPLStream extends ArrayStream { + waitingForResponse = false; + lines = ['']; + + constructor() { + super(); + } + + write(chunk: Buffer | string, encoding: string, callback: () => void) { + if (Buffer.isBuffer(chunk)) { + chunk = chunk.toString(encoding); + } + const chunkLines = stripVTControlCharacters(chunk).split('\n'); + this.lines[this.lines.length - 1] += chunkLines[0]; + if (chunkLines.length > 1) { + this.lines.push(...chunkLines.slice(1)); + } + this.emit('line'); + if (callback) callback(); + return true; + } + + wait(): Promise { + if (this.waitingForResponse) { + throw new Error('Currently waiting for response to another command'); + } + this.lines = ['']; + return new Promise((resolve, reject) => { + const onError = (err: any) => { + this.removeListener('line', onLine); + reject(err); + }; + const onLine = () => { + if (this.lines[this.lines.length - 1].includes('> ')) { + this.removeListener('error', onError); + this.removeListener('line', onLine); + resolve(this.lines); + } + }; + this.once('error', onError); + this.on('line', onLine); + }); + } +} diff --git a/tests/repl/tla-import.ts b/tests/repl/tla-import.ts new file mode 100644 index 000000000..b40ce69cc --- /dev/null +++ b/tests/repl/tla-import.ts @@ -0,0 +1 @@ +export const foo: string = 1; diff --git a/website/docs/options.md b/website/docs/options.md index 8570528e3..9139ea85c 100644 --- a/website/docs/options.md +++ b/website/docs/options.md @@ -53,6 +53,7 @@ _Environment variables, where available, are in `ALL_CAPS`_ - `--scopeDir` Directory within which compiler is limited when `scope` is enabled.
*Default:* First of: `tsconfig.json` "rootDir" if specified, directory containing `tsconfig.json`, or cwd if no `tsconfig.json` is loaded.
*Environment:* `TS_NODE_SCOPE_DIR` - `moduleType` Override the module type of certain files, ignoring the `package.json` `"type"` field. See [Module type overrides](./module-type-overrides.md) for details.
*Default:* obeys `package.json` `"type"` and `tsconfig.json` `"module"`
*Can only be specified via `tsconfig.json` or API.* - `TS_NODE_HISTORY` Path to history file for REPL
*Default:* `~/.ts_node_repl_history`
+- `--no-experimental-repl-await` Disable top-level await in REPL. Equivalent to node's [`--no-experimental-repl-await`](https://nodejs.org/api/cli.html#cli_no_experimental_repl_await)
*Default:* Enabled if TypeScript version is 3.8 or higher and target is ES2018 or higher.
*Environment:* `TS_NODE_EXPERIMENTAL_REPL_AWAIT` set `false` to disable ## API