Skip to content

Commit

Permalink
module: add --experimental-enable-transformation for strip-types
Browse files Browse the repository at this point in the history
  • Loading branch information
marco-ippolito committed Aug 9, 2024
1 parent 3cbeed8 commit b67230f
Show file tree
Hide file tree
Showing 14 changed files with 232 additions and 24 deletions.
10 changes: 10 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,15 @@ files with no extension will be treated as WebAssembly if they begin with the
WebAssembly magic number (`\0asm`); otherwise they will be treated as ES module
JavaScript.

### `--experimental-enable-transformation`

<!-- YAML
added: REPLACEME
-->

Enables the transformation of TypeScript-only syntax into JavaScript code.
Implies `--experimental-strip-types`.

### `--experimental-eventsource`

<!-- YAML
Expand Down Expand Up @@ -2911,6 +2920,7 @@ one is included in the list below.
* `--experimental-async-context-frame`
* `--experimental-default-type`
* `--experimental-detect-module`
* `--experimental-enable-transformation`
* `--experimental-eventsource`
* `--experimental-import-meta-resolve`
* `--experimental-json-modules`
Expand Down
3 changes: 3 additions & 0 deletions doc/node.1
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ Enable snapshot testing in the test runner.
.It Fl -experimental-strip-types
Enable experimental type-stripping for TypeScript files.
.
.It Fl -experimental-enable-transformation
Enable transformation of TypeScript-only syntax into JavaScript code.
.
.It Fl -experimental-eventsource
Enable experimental support for the EventSource Web API.
.
Expand Down
4 changes: 2 additions & 2 deletions lib/internal/main/eval_string.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const {
markBootstrapComplete,
} = require('internal/process/pre_execution');
const { evalModuleEntryPoint, evalScript } = require('internal/process/execution');
const { addBuiltinLibsToObject, tsParse } = require('internal/modules/helpers');
const { addBuiltinLibsToObject, stripTypes } = require('internal/modules/helpers');

const { getOptionValue } = require('internal/options');

Expand All @@ -24,7 +24,7 @@ markBootstrapComplete();

const code = getOptionValue('--eval');
const source = getOptionValue('--experimental-strip-types') ?
tsParse(code) :
stripTypes(code) :
code;

const print = getOptionValue('--print');
Expand Down
16 changes: 8 additions & 8 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -1362,8 +1362,8 @@ function loadESMFromCJS(mod, filename) {
if (isUnderNodeModules(filename)) {
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
}
const { tsParse } = require('internal/modules/helpers');
source = tsParse(source);
const { stripTypes } = require('internal/modules/helpers');
source = stripTypes(source, filename);
}
const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
const isMain = mod[kIsMainSymbol];
Expand Down Expand Up @@ -1576,9 +1576,9 @@ function loadCTS(module, filename) {
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
}
const source = getMaybeCachedSource(module, filename);
const { tsParse } = require('internal/modules/helpers');
const content = tsParse(source);
module._compile(content, filename, 'commonjs');
const { stripTypes } = require('internal/modules/helpers');
const code = stripTypes(source, filename);
module._compile(code, filename, 'commonjs');
}

/**
Expand All @@ -1592,8 +1592,8 @@ function loadTS(module, filename) {
}
// If already analyzed the source, then it will be cached.
const source = getMaybeCachedSource(module, filename);
const { tsParse } = require('internal/modules/helpers');
const content = tsParse(source);
const { stripTypes } = require('internal/modules/helpers');
const content = stripTypes(source, filename);
let format;
const pkg = packageJsonReader.getNearestParentPackageJSON(filename);
// Function require shouldn't be used in ES modules.
Expand All @@ -1613,7 +1613,7 @@ function loadTS(module, filename) {
if (Module._cache[parentPath]) {
let parentSource;
try {
parentSource = tsParse(fs.readFileSync(parentPath, 'utf8'));
parentSource = stripTypes(fs.readFileSync(parentPath, 'utf8'), parentPath);
} catch {
// Continue regardless of error.
}
Expand Down
6 changes: 3 additions & 3 deletions lib/internal/modules/esm/get_format.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,9 @@ function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreE
default: { // The user did not pass `--experimental-default-type`.
// `source` is undefined when this is called from `defaultResolve`;
// but this gets called again from `defaultLoad`/`defaultLoadSync`.
const { tsParse } = require('internal/modules/helpers');
const parsedSource = tsParse(source);
const detectedFormat = detectModuleFormat(parsedSource, url);
const { stripTypes } = require('internal/modules/helpers');
const code = stripTypes(source, url);
const detectedFormat = detectModuleFormat(code, url);
// When source is undefined, default to module-typescript.
const format = detectedFormat ? `${detectedFormat}-typescript` : 'module-typescript';
if (format === 'module-typescript' && foundPackageJson) {
Expand Down
8 changes: 4 additions & 4 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const { readFileSync } = require('fs');
const { dirname, extname, isAbsolute } = require('path');
const {
loadBuiltinModule,
tsParse,
stripTypes,
stripBOM,
urlToFilename,
} = require('internal/modules/helpers');
Expand Down Expand Up @@ -309,7 +309,7 @@ translators.set('require-commonjs', (url, source, isMain) => {
translators.set('require-commonjs-typescript', (url, source, isMain) => {
emitExperimentalWarning('Type Stripping');
assert(cjsParse);
const code = tsParse(stringify(source));
const code = stripTypes(stringify(source), url);
return createCJSModuleWrap(url, code);
});

Expand Down Expand Up @@ -526,7 +526,7 @@ translators.set('wasm', async function(url, source) {
translators.set('commonjs-typescript', function(url, source) {
emitExperimentalWarning('Type Stripping');
assertBufferSource(source, false, 'load');
const code = tsParse(stringify(source));
const code = stripTypes(stringify(source), url);
debug(`Translating TypeScript ${url}`);
return FunctionPrototypeCall(translators.get('commonjs'), this, url, code, false);
});
Expand All @@ -535,7 +535,7 @@ translators.set('commonjs-typescript', function(url, source) {
translators.set('module-typescript', function(url, source) {
emitExperimentalWarning('Type Stripping');
assertBufferSource(source, false, 'load');
const code = tsParse(stringify(source));
const code = stripTypes(stringify(source), url);
debug(`Translating TypeScript ${url}`);
return FunctionPrototypeCall(translators.get('module'), this, url, code, false);
});
39 changes: 32 additions & 7 deletions lib/internal/modules/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const internalFS = require('internal/fs/utils');
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 { setOwnProperty } = require('internal/util');
const { inspect } = require('internal/util/inspect');
Expand Down Expand Up @@ -300,7 +300,21 @@ 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;

/**
* Load the TypeScript parser.
Expand All @@ -318,23 +332,34 @@ function loadTypeScriptParser(parser) {
} else {
const amaro = require('internal/deps/amaro/dist/index');
// Default option for Amaro is to perform Type Stripping only.
const defaultOptions = { __proto__: null, mode: 'strip-only' };
typeScriptParsingMode = getOptionValue('--experimental-enable-transformation') ? 'transform' : 'strip-only';
sourceMapEnabled = getOptionValue('--enable-source-maps');
// Curry the transformSync function with the default options.
typeScriptParser = (source) => amaro.transformSync(source, defaultOptions);
typeScriptParser = amaro.transformSync;
}
return typeScriptParser;
}

/**
* @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.
* @returns {string} JavaScript code.
* @param {string} filename The filename of the source code.
* @returns {TransformOutput} The stripped TypeScript code.
*/
function tsParse(source) {
function stripTypes(source, filename) {
// TODO(@marco-ippolito) Checking empty string or non string input should be handled in Amaro.
if (!source || typeof source !== 'string') { return ''; }
const parse = loadTypeScriptParser();
const { code } = parse(source);
const options = { __proto__: null, mode: typeScriptParsingMode, sourceMap: sourceMapEnabled, filename };
const { code, map } = parse(source, options);
if (map) {
const base64SourceMap = Buffer.from(map).toString('base64');
return `${code}\n\n//# sourceMappingURL=data:application/json;base64,${base64SourceMap}`;
}
return code;
}

Expand All @@ -354,7 +379,7 @@ module.exports = {
loadBuiltinModule,
makeRequireFunction,
normalizeReferrerURL,
tsParse,
stripTypes,
stripBOM,
toRealPath,
hasStartedUserCJSExecution() {
Expand Down
6 changes: 6 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,12 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
"Experimental type-stripping for TypeScript files.",
&EnvironmentOptions::experimental_strip_types,
kAllowedInEnvvar);
AddOption("--experimental-enable-transformation",
"enable transformation of TypeScript-only"
"syntax in JavaScript code",
&EnvironmentOptions::experimental_enable_transformation,
kAllowedInEnvvar);
Implies("--experimental-enable-transformation", "--experimental-strip-types");
AddOption("--interactive",
"always enter the REPL even if stdin does not appear "
"to be a terminal",
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ class EnvironmentOptions : public Options {
std::vector<std::string> preload_esm_modules;

bool experimental_strip_types = false;
bool experimental_enable_transformation = false;

std::vector<std::string> user_argv;

Expand Down
85 changes: 85 additions & 0 deletions test/es-module/test-typescript-transform.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { skip, spawnPromisified } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import { match, strictEqual } from 'node:assert';
import { test } from 'node:test';

if (!process.config.variables.node_use_amaro) skip('Requires Amaro');

test('execute a TypeScript file with transformation enabled', async () => {
const result = await spawnPromisified(process.execPath, [
'--experimental-enable-transformation',
'--no-warnings',
fixtures.path('typescript/ts/transformation/test-enum.ts'),
]);

strictEqual(result.stderr, '');
match(result.stdout, /Hello, TypeScript!/);
strictEqual(result.code, 0);
});

test('execute a TypeScript file with transformation enabled and sourcemaps', async () => {
const result = await spawnPromisified(process.execPath, [
'--experimental-enable-transformation',
'--enable-source-maps',
'--no-warnings',
fixtures.path('typescript/ts/transformation/test-enum.ts'),
]);

strictEqual(result.stderr, '');
match(result.stdout, /Hello, TypeScript!/);
strictEqual(result.code, 0);
});

test('reconstruct error of a TypeScript file with transformation enabled and sourcemaps', async () => {
const result = await spawnPromisified(process.execPath, [
'--experimental-enable-transformation',
'--enable-source-maps',
'--no-warnings',
fixtures.path('typescript/ts/transformation/test-enum-stacktrace.ts'),
]);

match(result.stderr, /test-enum-stacktrace\.ts:4:7/);
strictEqual(result.stdout, '');
strictEqual(result.code, 1);
});

test('reconstruct error of a complex TypeScript file with transformation enabled and sourcemaps', async () => {
const result = await spawnPromisified(process.execPath, [
'--experimental-enable-transformation',
'--enable-source-maps',
'--no-warnings',
fixtures.path('typescript/ts/transformation/test-complex-stacktrace.ts'),
]);

match(result.stderr, /Calculation failed: Division by zero!/);
match(result.stderr, /test-complex-stacktrace\.ts:64/);
match(result.stderr, /test-complex-stacktrace\.ts:64:19/);
strictEqual(result.stdout, '');
strictEqual(result.code, 1);
});

test('reconstruct error of a complex TypeScript file with transformation enabled without sourcemaps', async () => {
const result = await spawnPromisified(process.execPath, [
'--experimental-enable-transformation',
'--no-warnings',
fixtures.path('typescript/ts/transformation/test-complex-stacktrace.ts'),
]);
// The stack trace is not reconstructed without sourcemaps.
match(result.stderr, /Calculation failed: Division by zero!/);
match(result.stderr, /test-complex-stacktrace\.ts:50/);
match(result.stderr, /test-complex-stacktrace\.ts:50:19/);
strictEqual(result.stdout, '');
strictEqual(result.code, 1);
});

// TODO (@marco-ippolito) this should fail
test('elide unused imports', async () => {
const result = await spawnPromisified(process.execPath, [
'--experimental-enable-transformation',
'--no-warnings',
fixtures.path('typescript/ts/transformation/test-unused-import.ts'),
]);
strictEqual(result.stderr, '');
strictEqual(result.stdout, '');
strictEqual(result.code, 0);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Namespace
namespace Mathematics {
// Enum
export enum Operation {
Add,
Subtract,
Multiply,
Divide
}

// Interface
export interface MathOperation {
perform(a: number, b: number): number;
}

// Generic function
export function executeOperation<T extends MathOperation>(op: T, x: number, y: number): number {
return op.perform(x, y);
}

// Class implementing interface
export class Calculator implements MathOperation {
constructor(private op: Operation) { }

perform(a: number, b: number): number {
switch (this.op) {
case Operation.Add: return a + b;
case Operation.Subtract: return a - b;
case Operation.Multiply: return a * b;
case Operation.Divide:
if (b === 0) throw new Error("Division by zero!");
return a / b;
default:
throw new Error("Unknown operation");
}
}
}
}

// Using the namespace
const calc = new Mathematics.Calculator(Mathematics.Operation.Divide);

// Union type
type Result = number | string;

// Function with rest parameters and arrow syntax
const processResults = (...results: Result[]): string => {
return results.map(r => r.toString()).join(", ");
};

// Async function with await
async function performAsyncCalculation(a: number, b: number): Promise<number> {
const result = await Promise.resolve(Mathematics.executeOperation(calc, a, b));
return result;
}

// IIFE to create a block scope
(async () => {
try {
const result = await performAsyncCalculation(10, 0);
} catch (error) {
if (error instanceof Error) {
// This line will throw an error
throw new Error("Calculation failed: " + error.message);
}
}
})();
Loading

0 comments on commit b67230f

Please sign in to comment.