diff --git a/lib/internal/bootstrap/node.js b/lib/internal/bootstrap/node.js index bde4cb2be84b2d..fe377bb57649f1 100644 --- a/lib/internal/bootstrap/node.js +++ b/lib/internal/bootstrap/node.js @@ -334,20 +334,16 @@ ObjectDefineProperty(process, 'features', { const { emitWarning, emitWarningSync } = require('internal/process/warning'); const { getOptionValue } = require('internal/options'); -let kTypeStrippingMode = process.config.variables.node_use_amaro ? null : false; // This must be a getter, as getOptionValue does not work // before bootstrapping. ObjectDefineProperty(process.features, 'typescript', { __proto__: null, get() { - if (kTypeStrippingMode === null) { - if (getOptionValue('--strip-types')) { - kTypeStrippingMode = 'strip'; - } else { - kTypeStrippingMode = false; - } + const { isTypeScriptEnabled, currentConfig } = require('internal/modules/typescript'); + if (!isTypeScriptEnabled()) { + return false; } - return kTypeStrippingMode; + return currentConfig.mode; }, configurable: true, enumerable: true, diff --git a/lib/internal/main/eval_stdin.js b/lib/internal/main/eval_stdin.js index 7a972aeb9b4d00..f8733e09ebc824 100644 --- a/lib/internal/main/eval_stdin.js +++ b/lib/internal/main/eval_stdin.js @@ -29,7 +29,8 @@ readStdin((code) => { const print = getOptionValue('--print'); const shouldLoadESM = getOptionValue('--import').length > 0; const inputType = getOptionValue('--input-type'); - const tsEnabled = getOptionValue('--strip-types'); + const { isTypeScriptEnabled } = require('internal/modules/typescript'); + const tsEnabled = isTypeScriptEnabled(); if (inputType === 'module') { evalModuleEntryPoint(code, print); } else if (inputType === 'module-typescript' && tsEnabled) { diff --git a/lib/internal/main/eval_string.js b/lib/internal/main/eval_string.js index 998c3fb4ada52d..b48b48c3aa36a9 100644 --- a/lib/internal/main/eval_string.js +++ b/lib/internal/main/eval_string.js @@ -32,7 +32,8 @@ const code = getOptionValue('--eval'); const print = getOptionValue('--print'); const shouldLoadESM = getOptionValue('--import').length > 0 || getOptionValue('--experimental-loader').length > 0; const inputType = getOptionValue('--input-type'); -const tsEnabled = getOptionValue('--strip-types'); +const { isTypeScriptEnabled } = require('internal/modules/typescript'); +const tsEnabled = isTypeScriptEnabled(); if (inputType === 'module') { evalModuleEntryPoint(code, print); } else if (inputType === 'module-typescript' && tsEnabled) { diff --git a/lib/internal/main/worker_thread.js b/lib/internal/main/worker_thread.js index 8a58dc0cba0f49..06264266279c16 100644 --- a/lib/internal/main/worker_thread.js +++ b/lib/internal/main/worker_thread.js @@ -166,7 +166,8 @@ port.on('message', (message) => { value: filename, }); ArrayPrototypeSplice(process.argv, 1, 0, name); - const tsEnabled = getOptionValue('--strip-types'); + const { isTypeScriptEnabled } = require('internal/modules/typescript'); + const tsEnabled = isTypeScriptEnabled(); const inputType = getOptionValue('--input-type'); if (inputType === 'module-typescript' && tsEnabled) { diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index bb466d0b68d5cb..d90adb58b28996 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -180,7 +180,7 @@ const { resolveWithHooks, validateLoadStrict, } = require('internal/modules/customization_hooks'); -const { stripTypeScriptModuleTypes } = require('internal/modules/typescript'); +const { isTypeScriptEnabled, 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); @@ -2032,7 +2032,7 @@ function getRequireESMError(mod, pkg, content, filename) { */ Module._extensions['.js'] = function(module, filename) { let format, pkg; - const tsEnabled = getOptionValue('--strip-types'); + const tsEnabled = isTypeScriptEnabled(); if (StringPrototypeEndsWith(filename, '.cjs')) { format = 'commonjs'; } else if (StringPrototypeEndsWith(filename, '.mjs')) { diff --git a/lib/internal/modules/esm/get_format.js b/lib/internal/modules/esm/get_format.js index 7bc69b3eb5c358..b7fdc2be90fe2b 100644 --- a/lib/internal/modules/esm/get_format.js +++ b/lib/internal/modules/esm/get_format.js @@ -27,7 +27,8 @@ function initializeExtensionFormatMap() { extensionFormatMap['.node'] = 'addon'; } - if (getOptionValue('--strip-types')) { + const { isTypeScriptEnabled } = require('internal/modules/typescript'); + if (isTypeScriptEnabled()) { extensionFormatMap['.ts'] = 'module-typescript'; extensionFormatMap['.mts'] = 'module-typescript'; extensionFormatMap['.cts'] = 'commonjs-typescript'; @@ -190,7 +191,8 @@ function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreE } return format; } - if (ext === '.ts' && getOptionValue('--strip-types')) { + const { isTypeScriptEnabled } = require('internal/modules/typescript'); + if (ext === '.ts' && isTypeScriptEnabled()) { const { type: packageType, pjsonPath, exists: foundPackageJson } = getPackageScopeConfig(url); if (packageType !== 'none') { return `${packageType}-typescript`; diff --git a/lib/internal/modules/run_main.js b/lib/internal/modules/run_main.js index 2974459755ec25..9925d32ea5feb7 100644 --- a/lib/internal/modules/run_main.js +++ b/lib/internal/modules/run_main.js @@ -67,7 +67,8 @@ function shouldUseESMLoader(mainPath) { if (mainPath && StringPrototypeEndsWith(mainPath, '.wasm')) { return true; } if (!mainPath || StringPrototypeEndsWith(mainPath, '.cjs')) { return false; } - if (getOptionValue('--strip-types')) { + const { isTypeScriptEnabled } = require('internal/modules/typescript'); + if (isTypeScriptEnabled()) { if (!mainPath || StringPrototypeEndsWith(mainPath, '.cts')) { return false; } // This will likely change in the future to start with commonjs loader by default if (mainPath && StringPrototypeEndsWith(mainPath, '.mts')) { return true; } diff --git a/lib/internal/modules/typescript.js b/lib/internal/modules/typescript.js index 43bc57d977ec27..4b6c9aeb927368 100644 --- a/lib/internal/modules/typescript.js +++ b/lib/internal/modules/typescript.js @@ -4,10 +4,12 @@ const { ObjectPrototypeHasOwnProperty, } = primordials; const { + validateBoolean, validateOneOf, validateObject, validateString, } = require('internal/validators'); +const { getOptionValue } = require('internal/options'); const { assertTypeScript, emitExperimentalWarning, getLazy, @@ -22,8 +24,66 @@ const assert = require('internal/assert'); const { getCompileCacheEntry, saveCompileCacheEntry, - cachedCodeTypes: { kStrippedTypeScript }, + cachedCodeTypes: { + kStrippedTypeScript, + kTransformedTypeScript, + kTransformedTypeScriptWithSourceMaps, + }, } = internalBinding('modules'); +const currentConfig = { + mode: 'strip', + nodeModules: false, + sourceMaps: undefined, +}; +let typescriptEnabledViaAPI = false; + +function isTypeScriptEnabled() { + return typescriptEnabledViaAPI || getOptionValue('--strip-types'); +} + +function configureTypeScript(options) { + if (options === undefined) { + return { ...currentConfig }; + } + validateObject(options, 'options'); + const newConfig = { ...currentConfig }; + if (options.mode !== undefined) { + validateOneOf(options.mode, 'options.mode', ['strip', 'transform']); + newConfig.mode = options.mode; + } + if (options.nodeModules !== undefined) { + validateBoolean(options.nodeModules, 'options.nodeModules'); + newConfig.nodeModules = options.nodeModules; + } + if (options.sourceMaps !== undefined) { + validateBoolean(options.sourceMaps, 'options.sourceMaps'); + newConfig.sourceMaps = options.sourceMaps; + } + const previousConfig = { ...currentConfig }; + + // Mutate currentConfig in-place to preserve references for CJS exports + currentConfig.mode = newConfig.mode; + currentConfig.nodeModules = newConfig.nodeModules; + currentConfig.sourceMaps = newConfig.sourceMaps; + + typescriptEnabledViaAPI = true; + + // Dynamically map the TypeScript extensions in ESM get_format + const { extensionFormatMap } = require('internal/modules/esm/get_format'); + extensionFormatMap['.ts'] = 'module-typescript'; + extensionFormatMap['.mts'] = 'module-typescript'; + extensionFormatMap['.cts'] = 'commonjs-typescript'; + + return previousConfig; +} + +function getCachedCodeType(mode, sourceMap) { + if (mode === 'transform') { + if (sourceMap) { return kTransformedTypeScriptWithSourceMaps; } + return kTransformedTypeScript; + } + return kStrippedTypeScript; +} /** @@ -93,23 +153,29 @@ function stripTypeScriptTypes(code, options = kEmptyObject) { validateString(code, 'code'); validateObject(options, 'options'); + let { + mode = 'strip', + } = options; const { sourceMap = false, sourceUrl = '', } = options; - let { mode = 'strip' } = options; - validateOneOf(mode, 'options.mode', ['strip']); + validateOneOf(mode, 'options.mode', ['strip', 'transform']); 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'; + } else { + validateBoolean(sourceMap, 'options.sourceMap'); } return processTypeScriptCode(code, { mode, + sourceMap, filename: sourceUrl, }); } @@ -151,21 +217,27 @@ function stripTypeScriptTypesForCoverage(code) { */ function stripTypeScriptModuleTypes(source, filename, sourceURL) { assert(typeof source === 'string'); - if (isUnderNodeModules(filename)) { + if (!currentConfig.nodeModules && isUnderNodeModules(filename)) { throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename); } + + const mode = currentConfig.mode; + const sourceMap = mode === 'transform' ? (currentConfig.sourceMaps ?? true) : false; + const type = getCachedCodeType(mode, sourceMap); + // Get a compile cache entry into the native compile cache store, // keyed by the filename. If the cache can already be loaded on disk, // cached.transpiled contains the cached string. Otherwise we should do // the transpilation and save it in the native store later using // saveCompileCacheEntry(). - const cached = (filename ? getCompileCacheEntry(source, filename, kStrippedTypeScript) : undefined); + const cached = (filename ? getCompileCacheEntry(source, filename, type) : undefined); if (cached?.transpiled) { // TODO(joyeecheung): return Buffer here. return cached.transpiled; } const options = { - mode: 'strip-only', + mode: mode === 'strip' ? 'strip-only' : mode, + sourceMap, filename: sourceURL ?? filename, }; @@ -182,6 +254,9 @@ function stripTypeScriptModuleTypes(source, filename, sourceURL) { } module.exports = { + configureTypeScript, + isTypeScriptEnabled, + currentConfig, stripTypeScriptModuleTypes, stripTypeScriptTypes, stripTypeScriptTypesForCoverage, diff --git a/lib/internal/process/execution.js b/lib/internal/process/execution.js index 4a2b57790bd7e6..95f1aa60e4305f 100644 --- a/lib/internal/process/execution.js +++ b/lib/internal/process/execution.js @@ -25,7 +25,7 @@ const { kSourcePhase, kEvaluationPhase, } = internalBinding('module_wrap'); -const { stripTypeScriptModuleTypes } = require('internal/modules/typescript'); +const { isTypeScriptEnabled, stripTypeScriptModuleTypes } = require('internal/modules/typescript'); const { executionAsyncId, @@ -86,7 +86,7 @@ function evalScript(name, body, breakFirstLine, print, shouldLoadESM = false) { const baseUrl = pathToFileURL(module.filename).href; if (shouldUseModuleEntryPoint(name, body)) { - return getOptionValue('--strip-types') ? + return isTypeScriptEnabled() ? evalTypeScriptModuleEntryPoint(body, print) : evalModuleEntryPoint(body, print); } diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index 9937d9592a2967..5246119cb9ec55 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -70,7 +70,8 @@ const kMaxRandomSeed = 0xFFFF_FFFF; const kPatterns = ['test', 'test/**/*', 'test-*', '*[._-]test']; const kFileExtensions = ['js', 'mjs', 'cjs']; -if (getOptionValue('--strip-types')) { +const { isTypeScriptEnabled } = require('internal/modules/typescript'); +if (isTypeScriptEnabled()) { ArrayPrototypePush(kFileExtensions, 'ts', 'mts', 'cts'); } const kDefaultPattern = `**/{${ArrayPrototypeJoin(kPatterns, ',')}}.{${ArrayPrototypeJoin(kFileExtensions, ',')}}`; diff --git a/lib/module.js b/lib/module.js index 1217172afb3ccb..a946ba0a072259 100644 --- a/lib/module.js +++ b/lib/module.js @@ -19,7 +19,7 @@ const { const { findPackageJSON, } = require('internal/modules/package_json_reader'); -const { stripTypeScriptTypes } = require('internal/modules/typescript'); +const { stripTypeScriptTypes, configureTypeScript } = require('internal/modules/typescript'); Module.register = register; Module.constants = constants; @@ -28,6 +28,7 @@ Module.findPackageJSON = findPackageJSON; Module.flushCompileCache = flushCompileCache; Module.getCompileCacheDir = getCompileCacheDir; Module.stripTypeScriptTypes = stripTypeScriptTypes; +Module.configureTypeScript = configureTypeScript; // SourceMap APIs Module.findSourceMap = findSourceMap; diff --git a/src/compile_cache.cc b/src/compile_cache.cc index dd097acd86f8e4..6b054db1ccc2e8 100644 --- a/src/compile_cache.cc +++ b/src/compile_cache.cc @@ -96,6 +96,10 @@ const char* CompileCacheEntry::type_name() const { return "ESM"; case CachedCodeType::kStrippedTypeScript: return "StrippedTypeScript"; + case CachedCodeType::kTransformedTypeScript: + return "TransformedTypeScript"; + case CachedCodeType::kTransformedTypeScriptWithSourceMaps: + return "TransformedTypeScriptWithSourceMaps"; default: UNREACHABLE(); } @@ -349,7 +353,9 @@ void CompileCacheHandler::MaybeSave(CompileCacheEntry* entry, void CompileCacheHandler::MaybeSave(CompileCacheEntry* entry, std::string_view transpiled) { - CHECK(entry->type == CachedCodeType::kStrippedTypeScript); + CHECK(entry->type == CachedCodeType::kStrippedTypeScript || + entry->type == CachedCodeType::kTransformedTypeScript || + entry->type == CachedCodeType::kTransformedTypeScriptWithSourceMaps); Debug("[compile cache] saving transpilation cache for %s %s\n", entry->type_name(), entry->source_filename); diff --git a/src/compile_cache.h b/src/compile_cache.h index 62934332103661..36d40c68974895 100644 --- a/src/compile_cache.h +++ b/src/compile_cache.h @@ -16,7 +16,9 @@ class Environment; #define CACHED_CODE_TYPES(V) \ V(kCommonJS, 0) \ V(kESM, 1) \ - V(kStrippedTypeScript, 2) + V(kStrippedTypeScript, 2) \ + V(kTransformedTypeScript, 3) \ + V(kTransformedTypeScriptWithSourceMaps, 4) enum class CachedCodeType : uint8_t { #define V(type, value) type = value, diff --git a/test/parallel/test-module-configure-typescript.js b/test/parallel/test-module-configure-typescript.js new file mode 100644 index 00000000000000..dfd05c27a46347 --- /dev/null +++ b/test/parallel/test-module-configure-typescript.js @@ -0,0 +1,169 @@ +'use strict'; + +const common = require('../common'); +if (!process.config.variables.node_use_amaro) { + common.skip('Requires Amaro'); +} + +const assert = require('assert'); +const { configureTypeScript, stripTypeScriptTypes } = require('node:module'); +const { test } = require('node:test'); +const tmpdir = require('../common/tmpdir'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +test('configureTypeScript export and basic getters', () => { + assert.strictEqual(typeof configureTypeScript, 'function'); + + // Calling with no arguments returns current configuration + const current = configureTypeScript(); + assert.strictEqual(typeof current, 'object'); + assert.strictEqual(typeof current.mode, 'string'); + assert.strictEqual(typeof current.nodeModules, 'boolean'); + + // Returns previous configuration + const original = configureTypeScript({ mode: 'transform' }); + assert.deepStrictEqual(original, current); + + const updated = configureTypeScript({ mode: 'strip' }); + assert.strictEqual(updated.mode, 'transform'); + + // Restore original + configureTypeScript(original); +}); + +test('configureTypeScript input validation', () => { + assert.throws(() => configureTypeScript('invalid'), { + code: 'ERR_INVALID_ARG_TYPE' + }); + assert.throws(() => configureTypeScript({ mode: 'invalid' }), { + code: 'ERR_INVALID_ARG_VALUE' + }); + assert.throws(() => configureTypeScript({ nodeModules: 'invalid' }), { + code: 'ERR_INVALID_ARG_TYPE' + }); + assert.throws(() => configureTypeScript({ sourceMaps: 'invalid' }), { + code: 'ERR_INVALID_ARG_TYPE' + }); +}); + +test('stripTypeScriptTypes transform mode', () => { + const source = 'enum MyEnum { A, B }'; + // Default mode or strip mode throws on enum syntax (unsupported) + assert.throws(() => stripTypeScriptTypes(source, { mode: 'strip' }), { + code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX' + }); + + const transformed = stripTypeScriptTypes(source, { mode: 'transform' }); + assert.match(transformed, /MyEnum/); +}); + +test('process.features.typescript reflects API changes', () => { + const original = configureTypeScript(); + + configureTypeScript({ mode: 'transform' }); + assert.strictEqual(process.features.typescript, 'transform'); + + configureTypeScript({ mode: 'strip' }); + assert.strictEqual(process.features.typescript, 'strip'); + + configureTypeScript(original); +}); + +// Test nodeModules option and dynamic loading in a spawned process +test('nodeModules option and dynamic loading behavior', () => { + tmpdir.refresh(); + + const nodeModulesDir = path.join(tmpdir.path, 'node_modules'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + + const pkgDir = path.join(nodeModulesDir, 'my-pkg'); + fs.mkdirSync(pkgDir, { recursive: true }); + + const tsFile = path.join(pkgDir, 'index.ts'); + fs.writeFileSync(tsFile, 'export const x: number = 42;'); + + const pjsonFile = path.join(pkgDir, 'package.json'); + fs.writeFileSync(pjsonFile, JSON.stringify({ type: 'module', main: './index.ts' })); + + // Script that tries to import the TypeScript package under node_modules + const scriptFile = path.join(tmpdir.path, 'test.js'); + fs.writeFileSync(scriptFile, ` + import assert from 'assert'; + import { configureTypeScript } from 'node:module'; + + // 1. By default, importing a TS file under node_modules should fail + configureTypeScript({ mode: 'strip', nodeModules: false }); + try { + await import('my-pkg'); + assert.fail('Should have failed to import TS under node_modules'); + } catch (err) { + assert.strictEqual(err.code, 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'); + } + + // 2. Setting nodeModules to true should succeed + configureTypeScript({ nodeModules: true }); + const { x } = await import('my-pkg'); + assert.strictEqual(x, 42); + + console.log('OK'); + `); + + const child = spawnSync(process.execPath, [scriptFile], { encoding: 'utf8' }); + if (child.status !== 0) { + console.error('nodeModules test child failed!'); + console.error('stdout:', child.stdout); + console.error('stderr:', child.stderr); + } + assert.strictEqual(child.status, 0); + assert.match(child.stdout, /OK/); +}); + +// Test compile cache key selection based on mode and sourceMaps +test('compile cache key selection and transform mode in runtime CJS loading', () => { + tmpdir.refresh(); + + const scriptFile = path.join(tmpdir.path, 'script.cts'); + fs.writeFileSync(scriptFile, ` + const assert = require('assert'); + const { configureTypeScript } = require('node:module'); + + configureTypeScript({ mode: 'transform', sourceMaps: true }); + + // This file uses TypeScript enums (requires transform) + const other = require('./other.cts'); + assert.strictEqual(other.MyEnum.Val, 100); + console.log('OK-CJS'); + `); + + const otherFile = path.join(tmpdir.path, 'other.cts'); + fs.writeFileSync(otherFile, ` + enum MyEnum { + Val = 100 + } + module.exports = { MyEnum }; + `); + + const cacheDir = path.join(tmpdir.path, 'cache'); + + const child = spawnSync(process.execPath, [scriptFile], { + env: { + ...process.env, + NODE_DEBUG_NATIVE: 'COMPILE_CACHE', + NODE_COMPILE_CACHE: cacheDir + }, + encoding: 'utf8' + }); + + if (child.status !== 0) { + console.error('compile cache test child failed!'); + console.error('stdout:', child.stdout); + console.error('stderr:', child.stderr); + } + assert.strictEqual(child.status, 0); + assert.match(child.stdout, /OK-CJS/); + + // Assert compile cache logs show correct types were saved/written + assert.match(child.stderr, /writing cache for TransformedTypeScriptWithSourceMaps/); +});