From 9ab643fac98d3ab86d0216f47de7d10d846b2839 Mon Sep 17 00:00:00 2001 From: Marco Ippolito Date: Sat, 5 Oct 2024 18:11:07 +0200 Subject: [PATCH] vm: add vm.stripTypeScriptTypes(code, options) --- doc/api/vm.md | 61 ++++++++++++++++++++ lib/internal/modules/helpers.js | 51 +++++++---------- lib/vm.js | 32 +++++++++++ test/parallel/test-vm-strip-types.js | 86 ++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 31 deletions(-) create mode 100644 test/parallel/test-vm-strip-types.js diff --git a/doc/api/vm.md b/doc/api/vm.md index 01317b5843ad34..3e1ee876106756 100644 --- a/doc/api/vm.md +++ b/doc/api/vm.md @@ -1564,6 +1564,65 @@ local scope, so the value `localVar` is changed. In this way `vm.runInThisContext()` is much like an [indirect `eval()` call][], e.g. `(0,eval)('code')`. +## `vm.stripTypeScriptTypes(code[, options])` + + + +> Stability: 1.0 - Early development + +* `code` {string} The code to strip type annotations from. +* `options` {Object} + * `mode` {string} **Default:** `'strip-only'`. Possible values are: + * `'strip-only'` Only strip type annotations without performing the transformation of TypeScript features. + * `'transform'` Strip type annotations and transform TypeScript features to JavaScript. + * `sourceMap` {boolean} **Default:** `false`. Only when `mode` is `'transform'`, if `true`, a source map + will be generated for the transformed code. + * `filename` {string} Only when `mode` is `'transform'`, specifies the filename used in the source map. +* Returns: {string} The code with type annotations stripped. + +`vm.stripTypeScriptTypes()` removes type annotations from TypeScript code. It +can be used to strip type annotations from TypeScript code before running it +with `vm.runInContext()` or `vm.compileFunction()`. +By default, it will throw an error if the code contains TypeScript features +that require transformation such as `Enums`, +see [type-stripping][] for more information. +When mode is `'transform'`, it also transforms TypeScript features to JavaScript, +see [transform TypeScript features][] for more information. +When mode is `'strip-only'`, source maps are not generated, because locations are preserved. +If `sourceMap` or `filename` is provided, when mode is `'strip-only'`, an error will be thrown. + +```js +const vm = require('node:vm'); + +const code = `const a: number = 1;`; +const strippedCode = vm.stripTypeScriptTypes(code); +console.log(strippedCode); +// Prints: const a = 1; +``` + +When `mode` is `'transform'`, the code is transformed to JavaScript: + +```js +const vm = require('node:vm'); + +const code = ` + namespace MathUtil { + export const add = (a: number, b: number) => a + b; + }`; +const strippedCode = vm.stripTypeScriptTypes(code, { mode: 'transform', sourceMap: true }); +console.log(strippedCode); + +// Prints: +// var MathUtil; +// (function(MathUtil) { +// MathUtil.add = (a, b)=>a + b; +// })(MathUtil || (MathUtil = {})); + +//# sourceMappingURL=data:application/json;base64, ... +``` + ## Example: Running an HTTP server within a VM When using either [`script.runInThisContext()`][] or @@ -1982,3 +2041,5 @@ const { Script, SyntheticModule } = require('node:vm'); [global object]: https://es5.github.io/#x15.1 [indirect `eval()` call]: https://es5.github.io/#x10.4.2 [origin]: https://developer.mozilla.org/en-US/docs/Glossary/Origin +[transform TypeScript features]: typescript.md#typescript-features +[type-stripping]: typescript.md#type-stripping diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js index ef28151da79741..ef08d97436e19f 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -313,44 +313,33 @@ function getBuiltinModule(id) { return normalizedId ? require(normalizedId) : undefined; } -/** - * TypeScript parsing function, by default Amaro.transformSync. - * @type {Function} - */ -let typeScriptParser; /** * The TypeScript parsing mode, either 'strip-only' or 'transform'. * @type {string} */ -let typeScriptParsingMode; -/** - * Whether source maps are enabled for TypeScript parsing. - * @type {boolean} - */ -let sourceMapEnabled; +const getTypeScriptParsingMode = getLazy(() => + (getOptionValue('--experimental-transform-types') ? 'transform' : 'strip-only'), +); /** * Load the TypeScript parser. - * @param {Function} parser - A function that takes a string of TypeScript code * and returns an object with a `code` property. * @returns {Function} The TypeScript parser function. */ -function loadTypeScriptParser(parser) { - if (typeScriptParser) { - return typeScriptParser; - } +const loadTypeScriptParser = getLazy(() => { + const amaro = require('internal/deps/amaro/dist/index'); + return amaro.transformSync; +}); - if (parser) { - typeScriptParser = parser; - } else { - const amaro = require('internal/deps/amaro/dist/index'); - // Default option for Amaro is to perform Type Stripping only. - typeScriptParsingMode = getOptionValue('--experimental-transform-types') ? 'transform' : 'strip-only'; - sourceMapEnabled = getOptionValue('--enable-source-maps'); - // Curry the transformSync function with the default options. - typeScriptParser = amaro.transformSync; - } - return typeScriptParser; +/** + * + * @param {string} source the source code + * @param {object} options the options to pass to the parser + * @returns {TransformOutput} an object with a `code` property. + */ +function parseTypeScript(source, options) { + const parse = loadTypeScriptParser(); + return parse(source, options); } /** @@ -365,14 +354,13 @@ function loadTypeScriptParser(parser) { */ function stripTypeScriptTypes(source, filename) { assert(typeof source === 'string'); - const parse = loadTypeScriptParser(); const options = { __proto__: null, - mode: typeScriptParsingMode, - sourceMap: sourceMapEnabled, + mode: getTypeScriptParsingMode(), + sourceMap: getOptionValue('--enable-source-maps'), filename, }; - const { code, map } = parse(source, options); + const { code, map } = parseTypeScript(source, options); if (map) { // TODO(@marco-ippolito) When Buffer.transcode supports utf8 to // base64 transformation, we should change this line. @@ -488,6 +476,7 @@ module.exports = { loadBuiltinModule, makeRequireFunction, normalizeReferrerURL, + parseTypeScript, stripTypeScriptTypes, stringify, stripBOM, diff --git a/lib/vm.js b/lib/vm.js index 3eea66b3f07437..334e0c4756328e 100644 --- a/lib/vm.js +++ b/lib/vm.js @@ -67,6 +67,8 @@ const { vm_dynamic_import_main_context_default, vm_context_no_contextify, } = internalBinding('symbols'); +const { parseTypeScript } = require('internal/modules/helpers'); +const { Buffer } = require('buffer'); const kParsingContext = Symbol('script parsing context'); /** @@ -400,6 +402,35 @@ const vmConstants = { ObjectFreeze(vmConstants); +function stripTypeScriptTypes(code, options = kEmptyObject) { + emitExperimentalWarning('vm.stripTypeScriptTypes'); + validateObject(options, 'options'); + const { mode = 'strip-only', sourceMap = false, filename = '' } = options; + validateOneOf(mode, 'options.mode', ['strip-only', 'transform']); + if (mode === 'strip-only') { + validateOneOf(sourceMap, 'options.sourceMap', [false, undefined]); + validateOneOf(filename, 'options.filename', ['', undefined]); + } + validateBoolean(sourceMap, 'options.sourceMap'); + validateString(filename, 'options.filename'); + + const transformOptions = { + __proto__: null, + mode, + sourceMap, + filename, + }; + + const { code: transformed, map } = parseTypeScript(code, transformOptions); + if (map) { + // TODO(@marco-ippolito) When Buffer.transcode supports utf8 to + // base64 transformation, we should change this line. + const base64SourceMap = Buffer.from(map).toString('base64'); + return `${transformed}\n\n//# sourceMappingURL=data:application/json;base64,${base64SourceMap}`; + } + return transformed; +} + module.exports = { Script, createContext, @@ -411,6 +442,7 @@ module.exports = { compileFunction, measureMemory, constants: vmConstants, + stripTypeScriptTypes, }; // The vm module is patched to include vm.Module, vm.SourceTextModule diff --git a/test/parallel/test-vm-strip-types.js b/test/parallel/test-vm-strip-types.js new file mode 100644 index 00000000000000..860f1adcf024db --- /dev/null +++ b/test/parallel/test-vm-strip-types.js @@ -0,0 +1,86 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const vm = require('vm'); +const { test } = require('node:test'); + +common.expectWarning( + 'ExperimentalWarning', + 'vm.stripTypeScriptTypes is an experimental feature and might change at any time', +); + +test('vm.stripTypeScriptTypes', () => { + const source = 'const x: number = 1;'; + const result = vm.stripTypeScriptTypes(source); + assert.strictEqual(result, 'const x = 1;'); +}); + +test('vm.stripTypeScriptTypes explicit', () => { + const source = 'const x: number = 1;'; + const result = vm.stripTypeScriptTypes(source, { mode: 'strip-only' }); + assert.strictEqual(result, 'const x = 1;'); +}); + +test('vm.stripTypeScriptTypes invalid mode', () => { + const source = 'const x: number = 1;'; + assert.throws(() => vm.stripTypeScriptTypes(source, { mode: 'invalid' }), { code: 'ERR_INVALID_ARG_VALUE' }); +}); + +test('vm.stripTypeScriptTypes sourceMap throws when mode is strip-only', () => { + const source = 'const x: number = 1;'; + assert.throws(() => vm.stripTypeScriptTypes(source, + { mode: 'strip-only', sourceMap: true }), + { code: 'ERR_INVALID_ARG_VALUE' }); +}); + +test('vm.stripTypeScriptTypes filename throws when mode is strip-only', () => { + const source = 'const x: number = 1;'; + assert.throws(() => vm.stripTypeScriptTypes(source, + { mode: 'strip-only', filename: 'foo.ts' }), + { code: 'ERR_INVALID_ARG_VALUE' }); +}); + +test('vm.stripTypeScriptTypes source map when mode is transform', () => { + const source = ` + namespace MathUtil { + export const add = (a: number, b: number) => a + b; + }`; + const result = vm.stripTypeScriptTypes(source, { mode: 'transform', sourceMap: true }); + const script = new vm.Script(result); + const sourceMap = + { + version: 3, + sources: [ + '', + ], + sourcesContent: [ + '\n namespace MathUtil {\n export const add = (a: number, b: number) => a + b;\n }', + ], + names: [], + mappings: ';UACY;aACK,MAAM,CAAC,GAAW,IAAc,IAAI;AACnD,GAFU,aAAA' + }; + assert(script.sourceMapURL, `sourceMappingURL=data:application/json;base64,${JSON.stringify(sourceMap)}`); +}); + +test('vm.stripTypeScriptTypes source map when mode is transform and filename', () => { + const source = ` + namespace MathUtil { + export const add = (a: number, b: number) => a + b; + }`; + const result = vm.stripTypeScriptTypes(source, { mode: 'transform', sourceMap: true, filename: 'test.ts' }); + const script = new vm.Script(result); + const sourceMap = + { + version: 3, + sources: [ + 'test.ts', + ], + sourcesContent: [ + '\n namespace MathUtil {\n export const add = (a: number, b: number) => a + b;\n }', + ], + names: [], + mappings: ';UACY;aACK,MAAM,CAAC,GAAW,IAAc,IAAI;AACnD,GAFU,aAAA' + }; + assert(script.sourceMapURL, `sourceMappingURL=data:application/json;base64,${JSON.stringify(sourceMap)}`); +});