Skip to content

Commit

Permalink
module: support eval with ts syntax detection
Browse files Browse the repository at this point in the history
  • Loading branch information
marco-ippolito committed Dec 19, 2024
1 parent b171afe commit b1e837e
Show file tree
Hide file tree
Showing 8 changed files with 323 additions and 35 deletions.
20 changes: 18 additions & 2 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 34 additions & 15 deletions lib/internal/main/eval_string.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,34 @@ 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();
addBuiltinLibsToObject(globalThis, '<eval>');
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) {
Expand All @@ -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);
}
1 change: 0 additions & 1 deletion lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 0 additions & 3 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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}`);
Expand All @@ -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}`);
Expand Down
6 changes: 5 additions & 1 deletion lib/internal/modules/typescript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
152 changes: 152 additions & 0 deletions lib/internal/process/execution.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

const {
RegExpPrototypeExec,
StringPrototypeIndexOf,
StringPrototypeSlice,
Symbol,
globalThis,
} = primordials;
Expand All @@ -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,
Expand All @@ -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');
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,12 @@ void PerIsolateOptions::CheckOptions(std::vector<std::string>* errors,
void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors,
std::vector<std::string>* 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\"");
}
}

Expand Down
Loading

0 comments on commit b1e837e

Please sign in to comment.