module: add module.stripTypeScriptTypes

PR-URL: https://github.com/nodejs/node/pull/55282
Fixes: https://github.com/nodejs/node/issues/54300
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Joyee Cheung <joyeec9h3@gmail.com>
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Richard Lau <rlau@redhat.com>
This commit is contained in:
Marco Ippolito 2024-10-24 20:27:58 +02:00 committed by GitHub
parent cbb72ebfa7
commit 53b1050e6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 358 additions and 88 deletions

View File

@ -270,6 +270,105 @@ changes:
Register a module that exports [hooks][] that customize Node.js module
resolution and loading behavior. See [Customization hooks][].
## `module.stripTypeScriptTypes(code[, options])`
<!-- YAML
added: REPLACEME
-->
> Stability: 1.0 - Early development
* `code` {string} The code to strip type annotations from.
* `options` {Object}
* `mode` {string} **Default:** `'strip'`. Possible values are:
* `'strip'` 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.
* `sourceUrl` {string} Specifies the source url used in the source map.
* Returns: {string} The code with type annotations stripped.
`module.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'`, source maps are not generated, because locations are preserved.
If `sourceMap` is provided, when mode is `'strip'`, an error will be thrown.
_WARNING_: The output of this function should not be considered stable across Node.js versions,
due to changes in the TypeScript parser.
```mjs
import { stripTypeScriptTypes } from 'node:module';
const code = 'const a: number = 1;';
const strippedCode = stripTypeScriptTypes(code);
console.log(strippedCode);
// Prints: const a = 1;
```
```cjs
const { stripTypeScriptTypes } = require('node:module');
const code = 'const a: number = 1;';
const strippedCode = stripTypeScriptTypes(code);
console.log(strippedCode);
// Prints: const a = 1;
```
If `sourceUrl` is provided, it will be used appended as a comment at the end of the output:
```mjs
import { stripTypeScriptTypes } from 'node:module';
const code = 'const a: number = 1;';
const strippedCode = stripTypeScriptTypes(code, { mode: 'strip', sourceUrl: 'source.ts' });
console.log(strippedCode);
// Prints: const a = 1\n\n//# sourceURL=source.ts;
```
```cjs
const { stripTypeScriptTypes } = require('node:module');
const code = 'const a: number = 1;';
const strippedCode = stripTypeScriptTypes(code, { mode: 'strip', sourceUrl: 'source.ts' });
console.log(strippedCode);
// Prints: const a = 1\n\n//# sourceURL=source.ts;
```
When `mode` is `'transform'`, the code is transformed to JavaScript:
```mjs
import { stripTypeScriptTypes } from 'node:module';
const code = `
namespace MathUtil {
export const add = (a: number, b: number) => a + b;
}`;
const strippedCode = 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, ...
```
```cjs
const { stripTypeScriptTypes } = require('node:module');
const code = `
namespace MathUtil {
export const add = (a: number, b: number) => a + b;
}`;
const strippedCode = 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, ...
```
### `module.syncBuiltinESMExports()`
<!-- YAML
@ -1252,3 +1351,5 @@ returned object contains the following keys:
[realm]: https://tc39.es/ecma262/#realm
[source map include directives]: https://sourcemaps.info/spec.html#h.lmz475t4mvbx
[transferable objects]: worker_threads.md#portpostmessagevalue-transferlist
[transform TypeScript features]: typescript.md#typescript-features
[type-stripping]: typescript.md#type-stripping

View File

@ -14,8 +14,8 @@ const {
markBootstrapComplete,
} = require('internal/process/pre_execution');
const { evalModuleEntryPoint, evalScript } = require('internal/process/execution');
const { addBuiltinLibsToObject, stripTypeScriptTypes } = require('internal/modules/helpers');
const { addBuiltinLibsToObject } = require('internal/modules/helpers');
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
const { getOptionValue } = require('internal/options');
prepareMainThreadExecution();
@ -24,7 +24,7 @@ markBootstrapComplete();
const code = getOptionValue('--eval');
const source = getOptionValue('--experimental-strip-types') ?
stripTypeScriptTypes(code) :
stripTypeScriptModuleTypes(code) :
code;
const print = getOptionValue('--print');

View File

@ -151,8 +151,8 @@ const {
setHasStartedUserCJSExecution,
stripBOM,
toRealPath,
stripTypeScriptTypes,
} = require('internal/modules/helpers');
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
const packageJsonReader = require('internal/modules/package_json_reader');
const { getOptionValue, getEmbedderOptions } = require('internal/options');
const shouldReportRequiredModules = getLazy(() => process.env.WATCH_REPORT_DEPENDENCIES);
@ -1357,7 +1357,7 @@ let hasPausedEntry = false;
function loadESMFromCJS(mod, filename) {
let source = getMaybeCachedSource(mod, filename);
if (getOptionValue('--experimental-strip-types') && path.extname(filename) === '.mts') {
source = stripTypeScriptTypes(source, filename);
source = stripTypeScriptModuleTypes(source, filename);
}
const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
const isMain = mod[kIsMainSymbol];
@ -1599,7 +1599,7 @@ function getMaybeCachedSource(mod, filename) {
function loadCTS(module, filename) {
const source = getMaybeCachedSource(module, filename);
const code = stripTypeScriptTypes(source, filename);
const code = stripTypeScriptModuleTypes(source, filename);
module._compile(code, filename, 'commonjs');
}
@ -1611,7 +1611,7 @@ function loadCTS(module, filename) {
function loadTS(module, filename) {
// If already analyzed the source, then it will be cached.
const source = getMaybeCachedSource(module, filename);
const content = stripTypeScriptTypes(source, filename);
const content = stripTypeScriptModuleTypes(source, filename);
let format;
const pkg = packageJsonReader.getNearestParentPackageJSON(filename);
// Function require shouldn't be used in ES modules.
@ -1631,7 +1631,7 @@ function loadTS(module, filename) {
if (Module._cache[parentPath]) {
let parentSource;
try {
parentSource = stripTypeScriptTypes(fs.readFileSync(parentPath, 'utf8'), parentPath);
parentSource = stripTypeScriptModuleTypes(fs.readFileSync(parentPath, 'utf8'), parentPath);
} catch {
// Continue regardless of error.
}

View File

@ -164,9 +164,10 @@ function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreE
// Since experimental-strip-types depends on detect-module, we always return null
// if source is undefined.
if (!source) { return null; }
const { stripTypeScriptTypes, stringify } = require('internal/modules/helpers');
const { stringify } = require('internal/modules/helpers');
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
const stringifiedSource = stringify(source);
const parsedSource = stripTypeScriptTypes(stringifiedSource, fileURLToPath(url));
const parsedSource = stripTypeScriptModuleTypes(stringifiedSource, fileURLToPath(url));
const detectedFormat = detectModuleFormat(parsedSource, url);
const format = `${detectedFormat}-typescript`;
if (format === 'module-typescript' && foundPackageJson) {

View File

@ -31,10 +31,10 @@ const {
assertBufferSource,
loadBuiltinModule,
stringify,
stripTypeScriptTypes,
stripBOM,
urlToFilename,
} = require('internal/modules/helpers');
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
const {
kIsCachedByESMLoader,
Module: CJSModule,
@ -248,7 +248,7 @@ translators.set('require-commonjs', (url, source, isMain) => {
translators.set('require-commonjs-typescript', (url, source, isMain) => {
emitExperimentalWarning('Type Stripping');
assert(cjsParse);
const code = stripTypeScriptTypes(stringify(source), url);
const code = stripTypeScriptModuleTypes(stringify(source), url);
return createCJSModuleWrap(url, code);
});
@ -463,7 +463,7 @@ translators.set('wasm', async function(url, source) {
translators.set('commonjs-typescript', function(url, source) {
emitExperimentalWarning('Type Stripping');
assertBufferSource(source, true, 'load');
const code = stripTypeScriptTypes(stringify(source), url);
const code = stripTypeScriptModuleTypes(stringify(source), url);
debug(`Translating TypeScript ${url}`);
return FunctionPrototypeCall(translators.get('commonjs'), this, url, code, false);
});
@ -472,7 +472,7 @@ translators.set('commonjs-typescript', function(url, source) {
translators.set('module-typescript', function(url, source) {
emitExperimentalWarning('Type Stripping');
assertBufferSource(source, true, 'load');
const code = stripTypeScriptTypes(stringify(source), url);
const code = stripTypeScriptModuleTypes(stringify(source), url);
debug(`Translating TypeScript ${url}`);
return FunctionPrototypeCall(translators.get('module'), this, url, code, false);
});

View File

@ -15,8 +15,6 @@ const {
const {
ERR_INVALID_ARG_TYPE,
ERR_INVALID_RETURN_PROPERTY_VALUE,
ERR_INVALID_TYPESCRIPT_SYNTAX,
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING,
} = require('internal/errors').codes;
const { BuiltinModule } = require('internal/bootstrap/realm');
@ -27,9 +25,8 @@ const path = require('path');
const { pathToFileURL, fileURLToPath } = require('internal/url');
const assert = require('internal/assert');
const { Buffer } = require('buffer');
const { getOptionValue } = require('internal/options');
const { assertTypeScript, setOwnProperty, getLazy, isUnderNodeModules } = require('internal/util');
const { setOwnProperty, getLazy } = require('internal/util');
const { inspect } = require('internal/util/inspect');
const lazyTmpdir = getLazy(() => require('os').tmpdir());
@ -314,74 +311,6 @@ function getBuiltinModule(id) {
return normalizedId ? require(normalizedId) : undefined;
}
/**
* The TypeScript parsing mode, either 'strip-only' or 'transform'.
* @type {string}
*/
const getTypeScriptParsingMode = getLazy(() =>
(getOptionValue('--experimental-transform-types') ? 'transform' : 'strip-only'),
);
/**
* Load the TypeScript parser.
* and returns an object with a `code` property.
* @returns {Function} The TypeScript parser function.
*/
const loadTypeScriptParser = getLazy(() => {
assertTypeScript();
const amaro = require('internal/deps/amaro/dist/index');
return amaro.transformSync;
});
/**
*
* @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();
try {
return parse(source, options);
} catch (error) {
throw new ERR_INVALID_TYPESCRIPT_SYNTAX(error);
}
}
/**
* @typedef {object} TransformOutput
* @property {string} code The compiled code.
* @property {string} [map] The source maps (optional).
*
* Performs type-stripping to TypeScript source code.
* @param {string} source TypeScript code to parse.
* @param {string} filename The filename of the source code.
* @returns {TransformOutput} The stripped TypeScript code.
*/
function stripTypeScriptTypes(source, filename) {
if (isUnderNodeModules(filename)) {
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
}
assert(typeof source === 'string');
const options = {
__proto__: null,
mode: getTypeScriptParsingMode(),
sourceMap: getOptionValue('--enable-source-maps'),
filename,
};
const { code, map } = parseTypeScript(source, options);
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 `${code}\n\n//# sourceMappingURL=data:application/json;base64,${base64SourceMap}`;
}
// Source map is not necessary in strip-only mode. However, to map the source
// file in debuggers to the original TypeScript source, add a sourceURL magic
// comment to hint that it is a generated source.
return `${code}\n\n//# sourceURL=${filename}`;
}
/** @type {import('internal/util/types')} */
let _TYPES = null;
/**
@ -485,7 +414,6 @@ module.exports = {
loadBuiltinModule,
makeRequireFunction,
normalizeReferrerURL,
stripTypeScriptTypes,
stringify,
stripBOM,
toRealPath,

View File

@ -0,0 +1,146 @@
'use strict';
const {
validateBoolean,
validateOneOf,
validateObject,
validateString,
} = require('internal/validators');
const { assertTypeScript,
emitExperimentalWarning,
getLazy,
isUnderNodeModules,
kEmptyObject } = require('internal/util');
const {
ERR_INVALID_TYPESCRIPT_SYNTAX,
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING,
} = require('internal/errors').codes;
const { getOptionValue } = require('internal/options');
const assert = require('internal/assert');
/**
* The TypeScript parsing mode, either 'strip-only' or 'transform'.
* @type {string}
*/
const getTypeScriptParsingMode = getLazy(() =>
(getOptionValue('--experimental-transform-types') ? 'transform' : 'strip-only'),
);
/**
* Load the TypeScript parser.
* and returns an object with a `code` property.
* @returns {Function} The TypeScript parser function.
*/
const loadTypeScriptParser = getLazy(() => {
assertTypeScript();
const amaro = require('internal/deps/amaro/dist/index');
return amaro.transformSync;
});
/**
*
* @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();
try {
return parse(source, options);
} catch (error) {
throw new ERR_INVALID_TYPESCRIPT_SYNTAX(error);
}
}
/**
* Performs type-stripping to TypeScript source code.
* @param {string} code TypeScript code to parse.
* @param {TransformOptions} options The configuration for type stripping.
* @returns {string} The stripped TypeScript code.
*/
function stripTypeScriptTypes(code, options = kEmptyObject) {
emitExperimentalWarning('stripTypeScriptTypes');
validateString(code, 'code');
validateObject(options, 'options');
const {
sourceMap = false,
sourceUrl = '',
} = options;
let { mode = 'strip' } = options;
validateOneOf(mode, 'options.mode', ['strip', 'transform']);
validateBoolean(sourceMap, 'options.sourceMap');
validateString(sourceUrl, 'options.sourceUrl');
if (mode === 'strip') {
validateOneOf(sourceMap, 'options.sourceMap', [false, undefined]);
// Rename mode from 'strip' to 'strip-only'.
// The reason is to match `process.features.typescript` which returns `strip`,
// but the parser expects `strip-only`.
mode = 'strip-only';
}
return processTypeScriptCode(code, {
mode,
sourceMap,
filename: sourceUrl,
});
}
/**
* Processes TypeScript code by stripping types or transforming.
* Handles source maps if needed.
* @param {string} code TypeScript code to process.
* @param {object} options The configuration object.
* @returns {string} The processed code.
*/
function processTypeScriptCode(code, options) {
const { code: transformedCode, map } = parseTypeScript(code, options);
if (map) {
return addSourceMap(transformedCode, map);
}
if (options.filename) {
return `${transformedCode}\n\n//# sourceURL=${options.filename}`;
}
return transformedCode;
}
/**
* Performs type-stripping to TypeScript source code internally.
* It is used by internal loaders.
* @param {string} source TypeScript code to parse.
* @param {string} filename The filename of the source code.
* @returns {TransformOutput} The stripped TypeScript code.
*/
function stripTypeScriptModuleTypes(source, filename) {
assert(typeof source === 'string');
if (isUnderNodeModules(filename)) {
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
}
const options = {
mode: getTypeScriptParsingMode(),
sourceMap: getOptionValue('--enable-source-maps'),
filename,
};
return processTypeScriptCode(source, options);
}
/**
*
* @param {string} code The compiled code.
* @param {string} sourceMap The source map.
* @returns {string} The code with the source map attached.
*/
function addSourceMap(code, sourceMap) {
// TODO(@marco-ippolito) When Buffer.transcode supports utf8 to
// base64 transformation, we should change this line.
const base64SourceMap = internalBinding('buffer').btoa(sourceMap);
return `${code}\n\n//# sourceMappingURL=data:application/json;base64,${base64SourceMap}`;
}
module.exports = {
stripTypeScriptModuleTypes,
stripTypeScriptTypes,
};

View File

@ -10,6 +10,7 @@ const {
flushCompileCache,
getCompileCacheDir,
} = require('internal/modules/helpers');
const { stripTypeScriptTypes } = require('internal/modules/typescript');
Module.findSourceMap = findSourceMap;
Module.register = register;
@ -17,6 +18,6 @@ Module.SourceMap = SourceMap;
Module.constants = constants;
Module.enableCompileCache = enableCompileCache;
Module.flushCompileCache = flushCompileCache;
Module.stripTypeScriptTypes = stripTypeScriptTypes;
Module.getCompileCacheDir = getCompileCacheDir;
module.exports = Module;

View File

@ -104,6 +104,7 @@ expected.beforePreExec = new Set([
'NativeModule diagnostics_channel',
'Internal Binding wasm_web_api',
'NativeModule internal/events/abort_listener',
'NativeModule internal/modules/typescript',
]);
expected.atRunTime = new Set([

View File

@ -0,0 +1,92 @@
'use strict';
const common = require('../common');
if (!process.config.variables.node_use_amaro) common.skip('Requires Amaro');
const assert = require('assert');
const vm = require('node:vm');
const { stripTypeScriptTypes } = require('node:module');
const { test } = require('node:test');
common.expectWarning(
'ExperimentalWarning',
'stripTypeScriptTypes is an experimental feature and might change at any time',
);
test('stripTypeScriptTypes', () => {
const source = 'const x: number = 1;';
const result = stripTypeScriptTypes(source);
assert.strictEqual(result, 'const x = 1;');
});
test('stripTypeScriptTypes explicit', () => {
const source = 'const x: number = 1;';
const result = stripTypeScriptTypes(source, { mode: 'strip' });
assert.strictEqual(result, 'const x = 1;');
});
test('stripTypeScriptTypes code is not a string', () => {
assert.throws(() => stripTypeScriptTypes({}),
{ code: 'ERR_INVALID_ARG_TYPE' });
});
test('stripTypeScriptTypes invalid mode', () => {
const source = 'const x: number = 1;';
assert.throws(() => stripTypeScriptTypes(source, { mode: 'invalid' }), { code: 'ERR_INVALID_ARG_VALUE' });
});
test('stripTypeScriptTypes sourceMap throws when mode is strip', () => {
const source = 'const x: number = 1;';
assert.throws(() => stripTypeScriptTypes(source,
{ mode: 'strip', sourceMap: true }),
{ code: 'ERR_INVALID_ARG_VALUE' });
});
test('stripTypeScriptTypes sourceUrl throws when mode is strip', () => {
const source = 'const x: number = 1;';
const result = stripTypeScriptTypes(source, { mode: 'strip', sourceUrl: 'foo.ts' });
assert.strictEqual(result, 'const x = 1;\n\n//# sourceURL=foo.ts');
});
test('stripTypeScriptTypes source map when mode is transform', () => {
const source = `
namespace MathUtil {
export const add = (a: number, b: number) => a + b;
}`;
const result = stripTypeScriptTypes(source, { mode: 'transform', sourceMap: true });
const script = new vm.Script(result);
const sourceMap =
{
version: 3,
sources: [
'<anon>',
],
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('stripTypeScriptTypes source map when mode is transform and sourceUrl', () => {
const source = `
namespace MathUtil {
export const add = (a: number, b: number) => a + b;
}`;
const result = stripTypeScriptTypes(source, { mode: 'transform', sourceMap: true, sourceUrl: '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)}`);
});