diff --git a/doc/api/cli.md b/doc/api/cli.md index 9efcf3953ffddf..56322f8524cd9f 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1370,8 +1370,23 @@ added: v12.0.0 --> This configures Node.js to interpret `--eval` or `STDIN` input as CommonJS or -as an ES module. Valid values are `"commonjs"` or `"module"`. The default is -`"commonjs"`. +as an ES module. Valid values are `"commonjs"`, `"module"`, `"module-typescript"` and `"commonjs-typescript"`. +The `"-typescript"` values are available only in combination with the flag `--experimental-strip-types`. +The default is `"commonjs"`. + +If `--experimental-strip-types` is enabled and `--input-type` is not provided, +Node.js will try to detect the syntax with the following steps: + +1. Run the input as commonjs. +2. If step 1 fails, run the input as an ES module. +3. If step 2 fails fails with a Syntax Error, strip the types. +4. If step 3 fails with an error code [`ERR_INVALID_TYPESCRIPT_SYNTAX`][], +throw the error from step 2, including the TypeScript error in the message, +else run as commonjs. +5. If step 4 fails, run the input as an ES module. + +To avoid the delay of multiple syntax detection passes, the `--input-type=type` flag can be used to specify +how the `--eval` input should be interpreted. The REPL does not support this option. Usage of `--input-type=module` with [`--print`][] will throw an error, as `--print` does not support ES module @@ -3625,6 +3640,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [`AsyncLocalStorage`]: async_context.md#class-asynclocalstorage [`Buffer`]: buffer.md#class-buffer [`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html +[`ERR_INVALID_TYPESCRIPT_SYNTAX`]: errors.md#ERR_INVALID_TYPESCRIPT_SYNTAX [`NODE_OPTIONS`]: #node_optionsoptions [`NO_COLOR`]: https://no-color.org [`SlowBuffer`]: buffer.md#class-slowbuffer diff --git a/lib/internal/main/eval_string.js b/lib/internal/main/eval_string.js index 6518c93430a28e..ee402f50fbdd2b 100644 --- a/lib/internal/main/eval_string.js +++ b/lib/internal/main/eval_string.js @@ -13,9 +13,14 @@ const { prepareMainThreadExecution, markBootstrapComplete, } = require('internal/process/pre_execution'); -const { evalModuleEntryPoint, evalScript } = require('internal/process/execution'); +const { + evalModuleEntryPoint, + evalTypeScript, + parseAndEvalCommonjsTypeScript, + parseAndEvalModuleTypeScript, + evalScript, +} = require('internal/process/execution'); const { addBuiltinLibsToObject } = require('internal/modules/helpers'); -const { stripTypeScriptModuleTypes } = require('internal/modules/typescript'); const { getOptionValue } = require('internal/options'); prepareMainThreadExecution(); @@ -23,18 +28,19 @@ addBuiltinLibsToObject(globalThis, ''); markBootstrapComplete(); const code = getOptionValue('--eval'); -const source = getOptionValue('--experimental-strip-types') ? - stripTypeScriptModuleTypes(code) : - code; const print = getOptionValue('--print'); const shouldLoadESM = getOptionValue('--import').length > 0 || getOptionValue('--experimental-loader').length > 0; -if (getOptionValue('--input-type') === 'module') { - evalModuleEntryPoint(source, print); +const inputType = getOptionValue('--input-type'); +const tsEnabled = getOptionValue('--experimental-strip-types'); +if (inputType === 'module') { + evalModuleEntryPoint(code, print); +} else if (inputType === 'module-typescript' && tsEnabled) { + parseAndEvalModuleTypeScript(code, print); } else { // For backward compatibility, we want the identifier crypto to be the // `node:crypto` module rather than WebCrypto. - const isUsingCryptoIdentifier = RegExpPrototypeExec(/\bcrypto\b/, source) !== null; + const isUsingCryptoIdentifier = RegExpPrototypeExec(/\bcrypto\b/, code) !== null; const shouldDefineCrypto = isUsingCryptoIdentifier && internalBinding('config').hasOpenSSL; if (isUsingCryptoIdentifier && !shouldDefineCrypto) { @@ -49,11 +55,24 @@ if (getOptionValue('--input-type') === 'module') { }; ObjectDefineProperty(object, name, { __proto__: null, set: setReal }); } - evalScript('[eval]', - shouldDefineCrypto ? ( - print ? `let crypto=require("node:crypto");{${source}}` : `(crypto=>{{${source}}})(require('node:crypto'))` - ) : source, - getOptionValue('--inspect-brk'), - print, - shouldLoadESM); + + let evalFunction; + if (inputType === 'commonjs') { + evalFunction = evalScript; + } else if (inputType === 'commonjs-typescript' && tsEnabled) { + evalFunction = parseAndEvalCommonjsTypeScript; + } else if (tsEnabled) { + evalFunction = evalTypeScript; + } else { + // Default to commonjs. + evalFunction = evalScript; + } + + evalFunction('[eval]', + shouldDefineCrypto ? ( + print ? `let crypto=require("node:crypto");{${code}}` : `(crypto=>{{${code}}})(require('node:crypto'))` + ) : code, + getOptionValue('--inspect-brk'), + print, + shouldLoadESM); } diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 0779190e1c9070..c453f2b403e89d 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -449,7 +449,6 @@ function initializeCJS() { const tsEnabled = getOptionValue('--experimental-strip-types'); if (tsEnabled) { - emitExperimentalWarning('Type Stripping'); Module._extensions['.cts'] = loadCTS; Module._extensions['.ts'] = loadTS; } diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index 5b2a865582e5cd..b24e20cb491a93 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -250,7 +250,6 @@ translators.set('require-commonjs', (url, source, isMain) => { // Handle CommonJS modules referenced by `require` calls. // This translator function must be sync, as `require` is sync. translators.set('require-commonjs-typescript', (url, source, isMain) => { - emitExperimentalWarning('Type Stripping'); assert(cjsParse); const code = stripTypeScriptModuleTypes(stringify(source), url); return createCJSModuleWrap(url, code, isMain, 'commonjs-typescript'); @@ -464,7 +463,6 @@ translators.set('wasm', async function(url, source) { // Strategy for loading a commonjs TypeScript module translators.set('commonjs-typescript', function(url, source) { - emitExperimentalWarning('Type Stripping'); assertBufferSource(source, true, 'load'); const code = stripTypeScriptModuleTypes(stringify(source), url); debug(`Translating TypeScript ${url}`); @@ -473,7 +471,6 @@ translators.set('commonjs-typescript', function(url, source) { // Strategy for loading an esm TypeScript module translators.set('module-typescript', function(url, source) { - emitExperimentalWarning('Type Stripping'); assertBufferSource(source, true, 'load'); const code = stripTypeScriptModuleTypes(stringify(source), url); debug(`Translating TypeScript ${url}`); diff --git a/lib/internal/modules/typescript.js b/lib/internal/modules/typescript.js index d1b58e86c72ee7..fa55b82b7e110d 100644 --- a/lib/internal/modules/typescript.js +++ b/lib/internal/modules/typescript.js @@ -112,9 +112,13 @@ function processTypeScriptCode(code, options) { * It is used by internal loaders. * @param {string} source TypeScript code to parse. * @param {string} filename The filename of the source code. + * @param {boolean} emitWarning Whether to emit a warning. * @returns {TransformOutput} The stripped TypeScript code. */ -function stripTypeScriptModuleTypes(source, filename) { +function stripTypeScriptModuleTypes(source, filename, emitWarning = true) { + if (emitWarning) { + emitExperimentalWarning('Type Stripping'); + } assert(typeof source === 'string'); if (isUnderNodeModules(filename)) { throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename); diff --git a/lib/internal/process/execution.js b/lib/internal/process/execution.js index a5afd44ca9ca06..1c74d156442aad 100644 --- a/lib/internal/process/execution.js +++ b/lib/internal/process/execution.js @@ -2,6 +2,8 @@ const { RegExpPrototypeExec, + StringPrototypeIndexOf, + StringPrototypeSlice, Symbol, globalThis, } = primordials; @@ -17,6 +19,7 @@ const { } = require('internal/errors'); const { pathToFileURL } = require('internal/url'); const { exitCodes: { kGenericUserError } } = internalBinding('errors'); +const { stripTypeScriptModuleTypes } = require('internal/modules/typescript'); const { executionAsyncId, @@ -32,6 +35,7 @@ const { getOptionValue } = require('internal/options'); const { makeContextifyScript, runScriptInThisContext, } = require('internal/vm'); +const { emitExperimentalWarning, isError } = require('internal/util'); // shouldAbortOnUncaughtToggle is a typed array for faster // communication with JS. const { shouldAbortOnUncaughtToggle } = internalBinding('util'); @@ -84,6 +88,9 @@ function evalScript(name, body, breakFirstLine, print, shouldLoadESM = false) { if (getOptionValue('--experimental-detect-module') && getOptionValue('--input-type') === '' && containsModuleSyntax(body, name, null, 'no CJS variables')) { + if (getOptionValue('--experimental-strip-types')) { + return evalTypeScriptModuleEntryPoint(body, print); + } return evalModuleEntryPoint(body, print); } @@ -238,10 +245,155 @@ function readStdin(callback) { }); } +/** + * Adds the TS message to the error stack. + * + * At the 3rd line of the stack, the message is added. + * @param {string} originalStack The stack to decorate + * @param {string} newMessage the message to add to the error stack + * @returns {void} + */ +function decorateCJSErrorWithTSMessage(originalStack, newMessage) { + let index; + for (let i = 0; i < 3; i++) { + index = StringPrototypeIndexOf(originalStack, '\n', index + 1); + } + return StringPrototypeSlice(originalStack, 0, index) + + '\n' + newMessage + + StringPrototypeSlice(originalStack, index); +} + +/** + * + * Wrapper of evalScript + * + * This function wraps the evaluation of the source code in a try-catch block. + * If the source code fails to be evaluated, it will retry evaluating the source code + * with the TypeScript parser. + * + * If the source code fails to be evaluated with the TypeScript parser, + * it will rethrow the original error, adding the TypeScript error message to the stack. + * + * This way we don't change the behavior of the code, but we provide a better error message + * in case of a typescript error. + * @param {string} name The name of the file + * @param {string} source The source code to evaluate + * @param {boolean} breakFirstLine Whether to break on the first line + * @param {boolean} print If the result should be printed + * @param {boolean} shouldLoadESM If the code should be loaded as an ESM module + * @returns {void} + */ +function evalTypeScript(name, source, breakFirstLine, print, shouldLoadESM = false) { + try { + evalScript(name, source, breakFirstLine, print, shouldLoadESM); + } catch (originalError) { + // If it's not a SyntaxError, rethrow it. + if (!isError(originalError) || originalError.name !== 'SyntaxError') { + throw originalError; + } + try { + const strippedSource = stripTypeScriptModuleTypes(source, name, false); + evalScript(name, strippedSource, breakFirstLine, print, shouldLoadESM); + // Emit the experimental warning after the code was successfully evaluated. + emitExperimentalWarning('Type Stripping'); + } catch (tsError) { + // If it's due to invalid syntax, we can rethrow the original error with the TS message. + if (isError(tsError) && tsError?.code === 'ERR_INVALID_TYPESCRIPT_SYNTAX') { + try { + originalError.stack = decorateCJSErrorWithTSMessage(originalError.stack, tsError.message); + } catch { /* Ignore potential errors coming from `stack` getter/setter */ } + throw originalError; + } + throw tsError; + } + } +} + +/** + * Wrapper of evalModuleEntryPoint + * + * This function wraps the evaluation of the source code in a try-catch block. + * If the source code fails to be evaluated, it will retry evaluating the source code + * with the TypeScript parser. + * @param {string} source The source code to evaluate + * @param {boolean} print If the result should be printed + * @returns {Promise} The module evaluation promise + */ +function evalTypeScriptModuleEntryPoint(source, print) { + if (print) { + throw new ERR_EVAL_ESM_CANNOT_PRINT(); + } + + RegExpPrototypeExec(/^/, ''); // Necessary to reset RegExp statics before user code runs. + + return require('internal/modules/run_main').runEntryPointWithESMLoader( + async (loader) => { + try { + // Await here to catch the error and rethrow it with the typescript error message. + return await loader.eval(source, getEvalModuleUrl(), true); + } catch (originalError) { + // If it's not a SyntaxError, rethrow it. + if (!isError(originalError) || originalError.name !== 'SyntaxError') { + throw originalError; + } + + try { + const url = getEvalModuleUrl(); + const strippedSource = stripTypeScriptModuleTypes(source, url, false); + const result = await loader.eval(strippedSource, url, true); + // Emit the experimental warning after the code was successfully evaluated. + emitExperimentalWarning('Type Stripping'); + return result; + } catch (tsError) { + // If its not an error, or it's not an invalid typescript syntax error, rethrow it. + if (!isError(tsError) || tsError?.code !== 'ERR_INVALID_TYPESCRIPT_SYNTAX') { + throw tsError; + } + + try { + originalError.stack = decorateCJSErrorWithTSMessage(originalError.stack, tsError.message); + } catch { /* Ignore potential errors coming from `stack` getter/setter */ } + throw originalError; + } + } + }, + ); +}; + +/** + * + * Function used to shortcut when `--input-type=module-typescript` is set. + * @param {string} source + * @param {boolean} print + */ +function parseAndEvalModuleTypeScript(source, print) { + // We know its a TypeScript module, we can safely emit the experimental warning. + const strippedSource = stripTypeScriptModuleTypes(source, getEvalModuleUrl()); + evalModuleEntryPoint(strippedSource, print); +} + +/** + * Function used to shortcut when `--input-type=commonjs-typescript` is set + * @param {string} name The name of the file + * @param {string} source The source code to evaluate + * @param {boolean} breakFirstLine Whether to break on the first line + * @param {boolean} print If the result should be printed + * @param {boolean} shouldLoadESM If the code should be loaded as an ESM module + * @returns {void} + */ +function parseAndEvalCommonjsTypeScript(name, source, breakFirstLine, print, shouldLoadESM = false) { + // We know its a TypeScript module, we can safely emit the experimental warning. + const strippedSource = stripTypeScriptModuleTypes(source, getEvalModuleUrl()); + evalScript(name, strippedSource, breakFirstLine, print, shouldLoadESM); +} + module.exports = { + parseAndEvalCommonjsTypeScript, + parseAndEvalModuleTypeScript, readStdin, tryGetCwd, evalModuleEntryPoint, + evalTypeScript, evalScript, onGlobalUncaughtException: createOnGlobalUncaughtException(), setUncaughtExceptionCaptureCallback, diff --git a/src/node_options.cc b/src/node_options.cc index cccb621aa3e7e6..02f64fd97f4a39 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -108,8 +108,12 @@ void PerIsolateOptions::CheckOptions(std::vector* errors, void EnvironmentOptions::CheckOptions(std::vector* errors, std::vector* argv) { if (!input_type.empty()) { - if (input_type != "commonjs" && input_type != "module") { - errors->push_back("--input-type must be \"module\" or \"commonjs\""); + if (input_type != "commonjs" && input_type != "module" && + input_type != "commonjs-typescript" && + input_type != "module-typescript") { + errors->push_back( + "--input-type must be \"module\"," + "\"commonjs\", \"module-typescript\" or \"commonjs-typescript\""); } } diff --git a/test/es-module/test-typescript-eval.mjs b/test/es-module/test-typescript-eval.mjs index e6d841ffa07f7e..c741ac586ba35e 100644 --- a/test/es-module/test-typescript-eval.mjs +++ b/test/es-module/test-typescript-eval.mjs @@ -1,5 +1,5 @@ import { skip, spawnPromisified } from '../common/index.mjs'; -import { match, strictEqual } from 'node:assert'; +import { doesNotMatch, match, strictEqual } from 'node:assert'; import { test } from 'node:test'; if (!process.config.variables.node_use_amaro) skip('Requires Amaro'); @@ -20,7 +20,7 @@ test('eval TypeScript ESM syntax', async () => { test('eval TypeScript ESM syntax with input-type module', async () => { const result = await spawnPromisified(process.execPath, [ '--experimental-strip-types', - '--input-type=module', + '--input-type=module-typescript', '--eval', `import util from 'node:util' const text: string = 'Hello, TypeScript!' @@ -37,17 +37,16 @@ test('eval TypeScript CommonJS syntax', async () => { '--eval', `const util = require('node:util'); const text: string = 'Hello, TypeScript!' - console.log(util.styleText('red', text));`, - '--no-warnings']); + console.log(util.styleText('red', text));`]); match(result.stdout, /Hello, TypeScript!/); - strictEqual(result.stderr, ''); + match(result.stderr, /ExperimentalWarning: Type Stripping is an experimental/); strictEqual(result.code, 0); }); -test('eval TypeScript CommonJS syntax with input-type commonjs', async () => { +test('eval TypeScript CommonJS syntax with input-type commonjs-typescript', async () => { const result = await spawnPromisified(process.execPath, [ '--experimental-strip-types', - '--input-type=commonjs', + '--input-type=commonjs-typescript', '--eval', `const util = require('node:util'); const text: string = 'Hello, TypeScript!' @@ -84,10 +83,10 @@ test('TypeScript ESM syntax not specified', async () => { strictEqual(result.code, 0); }); -test('expect fail eval TypeScript CommonJS syntax with input-type module', async () => { +test('expect fail eval TypeScript CommonJS syntax with input-type module-typescript', async () => { const result = await spawnPromisified(process.execPath, [ '--experimental-strip-types', - '--input-type=module', + '--input-type=module-typescript', '--eval', `const util = require('node:util'); const text: string = 'Hello, TypeScript!' @@ -98,10 +97,10 @@ test('expect fail eval TypeScript CommonJS syntax with input-type module', async strictEqual(result.code, 1); }); -test('expect fail eval TypeScript ESM syntax with input-type commonjs', async () => { +test('expect fail eval TypeScript ESM syntax with input-type commonjs-typescript', async () => { const result = await spawnPromisified(process.execPath, [ '--experimental-strip-types', - '--input-type=commonjs', + '--input-type=commonjs-typescript', '--eval', `import util from 'node:util' const text: string = 'Hello, TypeScript!' @@ -117,6 +116,104 @@ test('check syntax error is thrown when passing invalid syntax', async () => { '--eval', 'enum Foo { A, B, C }']); strictEqual(result.stdout, ''); + match(result.stderr, /SyntaxError/); + doesNotMatch(result.stderr, /ERR_INVALID_TYPESCRIPT_SYNTAX/); + strictEqual(result.code, 1); +}); + +test('check syntax error is thrown when passing invalid syntax with --input-type=module-typescript', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-strip-types', + '--input-type=module-typescript', + '--eval', + 'enum Foo { A, B, C }']); + strictEqual(result.stdout, ''); match(result.stderr, /ERR_INVALID_TYPESCRIPT_SYNTAX/); strictEqual(result.code, 1); }); + +test('check syntax error is thrown when passing invalid syntax with --input-type=commonjs-typescript', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-strip-types', + '--input-type=commonjs-typescript', + '--eval', + 'enum Foo { A, B, C }']); + strictEqual(result.stdout, ''); + match(result.stderr, /ERR_INVALID_TYPESCRIPT_SYNTAX/); + strictEqual(result.code, 1); +}); + +test('should not parse TypeScript with --type-module=commonjs', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-strip-types', + '--input-type=commonjs', + '--eval', + `enum Foo {}`]); + + strictEqual(result.stdout, ''); + match(result.stderr, /SyntaxError/); + doesNotMatch(result.stderr, /ERR_INVALID_TYPESCRIPT_SYNTAX/); + strictEqual(result.code, 1); +}); + +test('should not parse TypeScript with --type-module=module', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-strip-types', + '--input-type=module', + '--eval', + `enum Foo {}`]); + + strictEqual(result.stdout, ''); + match(result.stderr, /SyntaxError/); + doesNotMatch(result.stderr, /ERR_INVALID_TYPESCRIPT_SYNTAX/); + strictEqual(result.code, 1); +}); + +test('check warning is emitted when eval TypeScript CommonJS syntax', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-strip-types', + '--eval', + `const util = require('node:util'); + const text: string = 'Hello, TypeScript!' + console.log(util.styleText('red', text));`]); + match(result.stderr, /ExperimentalWarning: Type Stripping is an experimental/); + match(result.stdout, /Hello, TypeScript!/); + strictEqual(result.code, 0); +}); + +test('code is throwing a non Error', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-strip-types', + '--eval', + `throw null;`]); + // TODO(marco-ippolito) fix the stack trace of non errors + // being re-thrown + // the stack trace is wrong because it is rethrown + // but it's not an Error object + match(result.stderr, /node:internal\/process\/execution/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 1); +}); + +test('code is throwing an error with customized accessors', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-strip-types', + '--eval', + `throw Object.defineProperty(new Error, "stack", { set() {throw this} });`]); + + match(result.stderr, /Error/); + match(result.stderr, /at \[eval\]:1:29/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 1); +}); + +test('typescript code is throwing an error', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-strip-types', + '--eval', + `const foo: string = 'Hello, TypeScript!'; throw new Error(foo);`]); + + match(result.stderr, /Hello, TypeScript!/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 1); +});