Skip to content

Commit 878d49c

Browse files
module: support eval with ts syntax detection
1 parent a85ef6a commit 878d49c

File tree

8 files changed

+324
-35
lines changed

8 files changed

+324
-35
lines changed

doc/api/cli.md

+18-2
Original file line numberDiff line numberDiff line change
@@ -1370,8 +1370,23 @@ added: v12.0.0
13701370
-->
13711371

13721372
This configures Node.js to interpret `--eval` or `STDIN` input as CommonJS or
1373-
as an ES module. Valid values are `"commonjs"` or `"module"`. The default is
1374-
`"commonjs"`.
1373+
as an ES module. Valid values are `"commonjs"`, `"module"`, `"module-typescript"` and `"commonjs-typescript"`.
1374+
The `"-typescript"` values are available only in combination with the flag `--experimental-strip-types`.
1375+
The default is `"commonjs"`.
1376+
1377+
If `--experimental-strip-types` is enabled and `--input-type` is not provided,
1378+
Node.js will try to detect the syntax with the following steps:
1379+
1380+
1. Run the input as CommonJS.
1381+
2. If step 1 fails, run the input as an ES module.
1382+
3. If step 2 fails with a SyntaxError, strip the types.
1383+
4. If step 3 fails with an error code [`ERR_INVALID_TYPESCRIPT_SYNTAX`][],
1384+
throw the error from step 2, including the TypeScript error in the message,
1385+
else run as CommonJS.
1386+
5. If step 4 fails, run the input as an ES module.
1387+
1388+
To avoid the delay of multiple syntax detection passes, the `--input-type=type` flag can be used to specify
1389+
how the `--eval` input should be interpreted.
13751390

13761391
The REPL does not support this option. Usage of `--input-type=module` with
13771392
[`--print`][] will throw an error, as `--print` does not support ES module
@@ -3628,6 +3643,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
36283643
[`AsyncLocalStorage`]: async_context.md#class-asynclocalstorage
36293644
[`Buffer`]: buffer.md#class-buffer
36303645
[`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html
3646+
[`ERR_INVALID_TYPESCRIPT_SYNTAX`]: errors.md#err_invalid_typescript_syntax
36313647
[`NODE_OPTIONS`]: #node_optionsoptions
36323648
[`NO_COLOR`]: https://no-color.org
36333649
[`SlowBuffer`]: buffer.md#class-slowbuffer

lib/internal/main/eval_string.js

+34-15
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,34 @@ const {
1313
prepareMainThreadExecution,
1414
markBootstrapComplete,
1515
} = require('internal/process/pre_execution');
16-
const { evalModuleEntryPoint, evalScript } = require('internal/process/execution');
16+
const {
17+
evalModuleEntryPoint,
18+
evalTypeScript,
19+
parseAndEvalCommonjsTypeScript,
20+
parseAndEvalModuleTypeScript,
21+
evalScript,
22+
} = require('internal/process/execution');
1723
const { addBuiltinLibsToObject } = require('internal/modules/helpers');
18-
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
1924
const { getOptionValue } = require('internal/options');
2025

2126
prepareMainThreadExecution();
2227
addBuiltinLibsToObject(globalThis, '<eval>');
2328
markBootstrapComplete();
2429

2530
const code = getOptionValue('--eval');
26-
const source = getOptionValue('--experimental-strip-types') ?
27-
stripTypeScriptModuleTypes(code) :
28-
code;
2931

3032
const print = getOptionValue('--print');
3133
const shouldLoadESM = getOptionValue('--import').length > 0 || getOptionValue('--experimental-loader').length > 0;
32-
if (getOptionValue('--input-type') === 'module') {
33-
evalModuleEntryPoint(source, print);
34+
const inputType = getOptionValue('--input-type');
35+
const tsEnabled = getOptionValue('--experimental-strip-types');
36+
if (inputType === 'module') {
37+
evalModuleEntryPoint(code, print);
38+
} else if (inputType === 'module-typescript' && tsEnabled) {
39+
parseAndEvalModuleTypeScript(code, print);
3440
} else {
3541
// For backward compatibility, we want the identifier crypto to be the
3642
// `node:crypto` module rather than WebCrypto.
37-
const isUsingCryptoIdentifier = RegExpPrototypeExec(/\bcrypto\b/, source) !== null;
43+
const isUsingCryptoIdentifier = RegExpPrototypeExec(/\bcrypto\b/, code) !== null;
3844
const shouldDefineCrypto = isUsingCryptoIdentifier && internalBinding('config').hasOpenSSL;
3945

4046
if (isUsingCryptoIdentifier && !shouldDefineCrypto) {
@@ -49,11 +55,24 @@ if (getOptionValue('--input-type') === 'module') {
4955
};
5056
ObjectDefineProperty(object, name, { __proto__: null, set: setReal });
5157
}
52-
evalScript('[eval]',
53-
shouldDefineCrypto ? (
54-
print ? `let crypto=require("node:crypto");{${source}}` : `(crypto=>{{${source}}})(require('node:crypto'))`
55-
) : source,
56-
getOptionValue('--inspect-brk'),
57-
print,
58-
shouldLoadESM);
58+
59+
let evalFunction;
60+
if (inputType === 'commonjs') {
61+
evalFunction = evalScript;
62+
} else if (inputType === 'commonjs-typescript' && tsEnabled) {
63+
evalFunction = parseAndEvalCommonjsTypeScript;
64+
} else if (tsEnabled) {
65+
evalFunction = evalTypeScript;
66+
} else {
67+
// Default to commonjs.
68+
evalFunction = evalScript;
69+
}
70+
71+
evalFunction('[eval]',
72+
shouldDefineCrypto ? (
73+
print ? `let crypto=require("node:crypto");{${code}}` : `(crypto=>{{${code}}})(require('node:crypto'))`
74+
) : code,
75+
getOptionValue('--inspect-brk'),
76+
print,
77+
shouldLoadESM);
5978
}

lib/internal/modules/cjs/loader.js

-1
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,6 @@ function initializeCJS() {
449449

450450
const tsEnabled = getOptionValue('--experimental-strip-types');
451451
if (tsEnabled) {
452-
emitExperimentalWarning('Type Stripping');
453452
Module._extensions['.cts'] = loadCTS;
454453
Module._extensions['.ts'] = loadTS;
455454
}

lib/internal/modules/esm/translators.js

-3
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,6 @@ translators.set('require-commonjs', (url, source, isMain) => {
250250
// Handle CommonJS modules referenced by `require` calls.
251251
// This translator function must be sync, as `require` is sync.
252252
translators.set('require-commonjs-typescript', (url, source, isMain) => {
253-
emitExperimentalWarning('Type Stripping');
254253
assert(cjsParse);
255254
const code = stripTypeScriptModuleTypes(stringify(source), url);
256255
return createCJSModuleWrap(url, code, isMain, 'commonjs-typescript');
@@ -464,7 +463,6 @@ translators.set('wasm', async function(url, source) {
464463

465464
// Strategy for loading a commonjs TypeScript module
466465
translators.set('commonjs-typescript', function(url, source) {
467-
emitExperimentalWarning('Type Stripping');
468466
assertBufferSource(source, true, 'load');
469467
const code = stripTypeScriptModuleTypes(stringify(source), url);
470468
debug(`Translating TypeScript ${url}`);
@@ -473,7 +471,6 @@ translators.set('commonjs-typescript', function(url, source) {
473471

474472
// Strategy for loading an esm TypeScript module
475473
translators.set('module-typescript', function(url, source) {
476-
emitExperimentalWarning('Type Stripping');
477474
assertBufferSource(source, true, 'load');
478475
const code = stripTypeScriptModuleTypes(stringify(source), url);
479476
debug(`Translating TypeScript ${url}`);

lib/internal/modules/typescript.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,13 @@ function processTypeScriptCode(code, options) {
112112
* It is used by internal loaders.
113113
* @param {string} source TypeScript code to parse.
114114
* @param {string} filename The filename of the source code.
115+
* @param {boolean} emitWarning Whether to emit a warning.
115116
* @returns {TransformOutput} The stripped TypeScript code.
116117
*/
117-
function stripTypeScriptModuleTypes(source, filename) {
118+
function stripTypeScriptModuleTypes(source, filename, emitWarning = true) {
119+
if (emitWarning) {
120+
emitExperimentalWarning('Type Stripping');
121+
}
118122
assert(typeof source === 'string');
119123
if (isUnderNodeModules(filename)) {
120124
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);

lib/internal/process/execution.js

+153
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
const {
44
RegExpPrototypeExec,
5+
StringPrototypeIndexOf,
6+
StringPrototypeSlice,
57
Symbol,
68
globalThis,
79
} = primordials;
@@ -17,6 +19,7 @@ const {
1719
} = require('internal/errors');
1820
const { pathToFileURL } = require('internal/url');
1921
const { exitCodes: { kGenericUserError } } = internalBinding('errors');
22+
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
2023

2124
const {
2225
executionAsyncId,
@@ -32,6 +35,7 @@ const { getOptionValue } = require('internal/options');
3235
const {
3336
makeContextifyScript, runScriptInThisContext,
3437
} = require('internal/vm');
38+
const { emitExperimentalWarning, isError } = require('internal/util');
3539
// shouldAbortOnUncaughtToggle is a typed array for faster
3640
// communication with JS.
3741
const { shouldAbortOnUncaughtToggle } = internalBinding('util');
@@ -84,6 +88,9 @@ function evalScript(name, body, breakFirstLine, print, shouldLoadESM = false) {
8488
if (getOptionValue('--experimental-detect-module') &&
8589
getOptionValue('--input-type') === '' &&
8690
containsModuleSyntax(body, name, null, 'no CJS variables')) {
91+
if (getOptionValue('--experimental-strip-types')) {
92+
return evalTypeScriptModuleEntryPoint(body, print);
93+
}
8794
return evalModuleEntryPoint(body, print);
8895
}
8996

@@ -238,10 +245,156 @@ function readStdin(callback) {
238245
});
239246
}
240247

248+
/**
249+
* Adds the TS message to the error stack.
250+
*
251+
* At the 3rd line of the stack, the message is added.
252+
* @param {string} originalStack The stack to decorate
253+
* @param {string} newMessage the message to add to the error stack
254+
* @returns {void}
255+
*/
256+
function decorateCJSErrorWithTSMessage(originalStack, newMessage) {
257+
let index;
258+
for (let i = 0; i < 3; i++) {
259+
index = StringPrototypeIndexOf(originalStack, '\n', index + 1);
260+
}
261+
return StringPrototypeSlice(originalStack, 0, index) +
262+
'\n' + newMessage +
263+
StringPrototypeSlice(originalStack, index);
264+
}
265+
266+
/**
267+
*
268+
* Wrapper of evalScript
269+
*
270+
* This function wraps the evaluation of the source code in a try-catch block.
271+
* If the source code fails to be evaluated, it will retry evaluating the source code
272+
* with the TypeScript parser.
273+
*
274+
* If the source code fails to be evaluated with the TypeScript parser,
275+
* it will rethrow the original error, adding the TypeScript error message to the stack.
276+
*
277+
* This way we don't change the behavior of the code, but we provide a better error message
278+
* in case of a typescript error.
279+
* @param {string} name The name of the file
280+
* @param {string} source The source code to evaluate
281+
* @param {boolean} breakFirstLine Whether to break on the first line
282+
* @param {boolean} print If the result should be printed
283+
* @param {boolean} shouldLoadESM If the code should be loaded as an ESM module
284+
* @returns {void}
285+
*/
286+
function evalTypeScript(name, source, breakFirstLine, print, shouldLoadESM = false) {
287+
try {
288+
evalScript(name, source, breakFirstLine, print, shouldLoadESM);
289+
} catch (originalError) {
290+
// If it's not a SyntaxError, rethrow it.
291+
if (!isError(originalError) || originalError.name !== 'SyntaxError') {
292+
throw originalError;
293+
}
294+
try {
295+
const strippedSource = stripTypeScriptModuleTypes(source, name, false);
296+
evalScript(name, strippedSource, breakFirstLine, print, shouldLoadESM);
297+
// Emit the experimental warning after the code was successfully evaluated.
298+
emitExperimentalWarning('Type Stripping');
299+
} catch (tsError) {
300+
// If its not an error, or it's not an invalid typescript syntax error, rethrow it.
301+
if (!isError(tsError) || tsError?.code !== 'ERR_INVALID_TYPESCRIPT_SYNTAX') {
302+
throw tsError;
303+
}
304+
305+
try {
306+
originalError.stack = decorateCJSErrorWithTSMessage(originalError.stack, tsError.message);
307+
} catch { /* Ignore potential errors coming from `stack` getter/setter */ }
308+
throw originalError;
309+
}
310+
}
311+
}
312+
313+
/**
314+
* Wrapper of evalModuleEntryPoint
315+
*
316+
* This function wraps the evaluation of the source code in a try-catch block.
317+
* If the source code fails to be evaluated, it will retry evaluating the source code
318+
* with the TypeScript parser.
319+
* @param {string} source The source code to evaluate
320+
* @param {boolean} print If the result should be printed
321+
* @returns {Promise} The module evaluation promise
322+
*/
323+
function evalTypeScriptModuleEntryPoint(source, print) {
324+
if (print) {
325+
throw new ERR_EVAL_ESM_CANNOT_PRINT();
326+
}
327+
328+
RegExpPrototypeExec(/^/, ''); // Necessary to reset RegExp statics before user code runs.
329+
330+
return require('internal/modules/run_main').runEntryPointWithESMLoader(
331+
async (loader) => {
332+
try {
333+
// Await here to catch the error and rethrow it with the typescript error message.
334+
return await loader.eval(source, getEvalModuleUrl(), true);
335+
} catch (originalError) {
336+
// If it's not a SyntaxError, rethrow it.
337+
if (!isError(originalError) || originalError.name !== 'SyntaxError') {
338+
throw originalError;
339+
}
340+
341+
try {
342+
const url = getEvalModuleUrl();
343+
const strippedSource = stripTypeScriptModuleTypes(source, url, false);
344+
const result = await loader.eval(strippedSource, url, true);
345+
// Emit the experimental warning after the code was successfully evaluated.
346+
emitExperimentalWarning('Type Stripping');
347+
return result;
348+
} catch (tsError) {
349+
// If its not an error, or it's not an invalid typescript syntax error, rethrow it.
350+
if (!isError(tsError) || tsError?.code !== 'ERR_INVALID_TYPESCRIPT_SYNTAX') {
351+
throw tsError;
352+
}
353+
354+
try {
355+
originalError.stack = `${tsError.message}\n\n${originalError.stack}`;
356+
} catch { /* Ignore potential errors coming from `stack` getter/setter */ }
357+
throw originalError;
358+
}
359+
}
360+
},
361+
);
362+
};
363+
364+
/**
365+
*
366+
* Function used to shortcut when `--input-type=module-typescript` is set.
367+
* @param {string} source
368+
* @param {boolean} print
369+
*/
370+
function parseAndEvalModuleTypeScript(source, print) {
371+
// We know its a TypeScript module, we can safely emit the experimental warning.
372+
const strippedSource = stripTypeScriptModuleTypes(source, getEvalModuleUrl());
373+
evalModuleEntryPoint(strippedSource, print);
374+
}
375+
376+
/**
377+
* Function used to shortcut when `--input-type=commonjs-typescript` is set
378+
* @param {string} name The name of the file
379+
* @param {string} source The source code to evaluate
380+
* @param {boolean} breakFirstLine Whether to break on the first line
381+
* @param {boolean} print If the result should be printed
382+
* @param {boolean} shouldLoadESM If the code should be loaded as an ESM module
383+
* @returns {void}
384+
*/
385+
function parseAndEvalCommonjsTypeScript(name, source, breakFirstLine, print, shouldLoadESM = false) {
386+
// We know its a TypeScript module, we can safely emit the experimental warning.
387+
const strippedSource = stripTypeScriptModuleTypes(source, getEvalModuleUrl());
388+
evalScript(name, strippedSource, breakFirstLine, print, shouldLoadESM);
389+
}
390+
241391
module.exports = {
392+
parseAndEvalCommonjsTypeScript,
393+
parseAndEvalModuleTypeScript,
242394
readStdin,
243395
tryGetCwd,
244396
evalModuleEntryPoint,
397+
evalTypeScript,
245398
evalScript,
246399
onGlobalUncaughtException: createOnGlobalUncaughtException(),
247400
setUncaughtExceptionCaptureCallback,

src/node_options.cc

+6-2
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,12 @@ void PerIsolateOptions::CheckOptions(std::vector<std::string>* errors,
108108
void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors,
109109
std::vector<std::string>* argv) {
110110
if (!input_type.empty()) {
111-
if (input_type != "commonjs" && input_type != "module") {
112-
errors->push_back("--input-type must be \"module\" or \"commonjs\"");
111+
if (input_type != "commonjs" && input_type != "module" &&
112+
input_type != "commonjs-typescript" &&
113+
input_type != "module-typescript") {
114+
errors->push_back(
115+
"--input-type must be \"module\","
116+
"\"commonjs\", \"module-typescript\" or \"commonjs-typescript\"");
113117
}
114118
}
115119

0 commit comments

Comments
 (0)