module: unify TypeScript and .mjs handling in CommonJS

This refactors the CommonJS loading a bit to create a center point
that handles source loading (`loadSource`) and make format detection
more consistent to pave the way for future synchronous hooks.

- Handle .mjs in the .js handler, similar to how .cjs has been handled.
- Generate the legacy ERR_REQUIRE_ESM in a getRequireESMError() for
  both .mts and require(esm) handling (when it's disabled).

PR-URL: https://github.com/nodejs/node/pull/55590
Refs: https://github.com/nodejs/loaders/pull/198
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
Reviewed-By: Juan José Arboleda <soyjuanarbol@gmail.com>
This commit is contained in:
Joyee Cheung 2024-10-31 16:43:57 +01:00 committed by GitHub
parent 4379dfb1fd
commit d080f0db1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -100,6 +100,9 @@ const kIsMainSymbol = Symbol('kIsMainSymbol');
const kIsCachedByESMLoader = Symbol('kIsCachedByESMLoader'); const kIsCachedByESMLoader = Symbol('kIsCachedByESMLoader');
const kRequiredModuleSymbol = Symbol('kRequiredModuleSymbol'); const kRequiredModuleSymbol = Symbol('kRequiredModuleSymbol');
const kIsExecuting = Symbol('kIsExecuting'); const kIsExecuting = Symbol('kIsExecuting');
const kFormat = Symbol('kFormat');
// Set first due to cycle with ESM loader functions. // Set first due to cycle with ESM loader functions.
module.exports = { module.exports = {
kModuleSource, kModuleSource,
@ -436,9 +439,8 @@ function initializeCJS() {
Module._extensions['.ts'] = loadTS; Module._extensions['.ts'] = loadTS;
} }
if (getOptionValue('--experimental-require-module')) { if (getOptionValue('--experimental-require-module')) {
Module._extensions['.mjs'] = loadESMFromCJS;
if (tsEnabled) { if (tsEnabled) {
Module._extensions['.mts'] = loadESMFromCJS; Module._extensions['.mts'] = loadMTS;
} }
} }
} }
@ -653,8 +655,6 @@ function getDefaultExtensions() {
if (tsEnabled) { if (tsEnabled) {
// remove .ts and .cts from the default extensions // remove .ts and .cts from the default extensions
// to avoid extensionless require of .ts and .cts files. // to avoid extensionless require of .ts and .cts files.
// it behaves similarly to how .mjs is handled when --experimental-require-module
// is enabled.
extensions = ArrayPrototypeFilter(extensions, (ext) => extensions = ArrayPrototypeFilter(extensions, (ext) =>
(ext !== '.ts' || Module._extensions['.ts'] !== loadTS) && (ext !== '.ts' || Module._extensions['.ts'] !== loadTS) &&
(ext !== '.cts' || Module._extensions['.cts'] !== loadCTS), (ext !== '.cts' || Module._extensions['.cts'] !== loadCTS),
@ -667,14 +667,10 @@ function getDefaultExtensions() {
if (tsEnabled) { if (tsEnabled) {
extensions = ArrayPrototypeFilter(extensions, (ext) => extensions = ArrayPrototypeFilter(extensions, (ext) =>
ext !== '.mts' || Module._extensions['.mts'] !== loadESMFromCJS, ext !== '.mts' || Module._extensions['.mts'] !== loadMTS,
); );
} }
// If the .mjs extension is added by --experimental-require-module, return extensions;
// remove it from the supported default extensions to maintain
// compatibility.
// TODO(joyeecheung): allow both .mjs and .cjs?
return ArrayPrototypeFilter(extensions, (ext) => ext !== '.mjs' || Module._extensions['.mjs'] !== loadESMFromCJS);
} }
/** /**
@ -1301,10 +1297,6 @@ Module.prototype.load = function(filename) {
this.paths = Module._nodeModulePaths(path.dirname(filename)); this.paths = Module._nodeModulePaths(path.dirname(filename));
const extension = findLongestRegisteredExtension(filename); const extension = findLongestRegisteredExtension(filename);
// allow .mjs to be overridden
if (StringPrototypeEndsWith(filename, '.mjs') && !Module._extensions['.mjs']) {
throw new ERR_REQUIRE_ESM(filename, true);
}
if (getOptionValue('--experimental-strip-types')) { if (getOptionValue('--experimental-strip-types')) {
if (StringPrototypeEndsWith(filename, '.mts') && !Module._extensions['.mts']) { if (StringPrototypeEndsWith(filename, '.mts') && !Module._extensions['.mts']) {
@ -1353,12 +1345,10 @@ let hasPausedEntry = false;
* Resolve and evaluate it synchronously as ESM if it's ESM. * Resolve and evaluate it synchronously as ESM if it's ESM.
* @param {Module} mod CJS module instance * @param {Module} mod CJS module instance
* @param {string} filename Absolute path of the file. * @param {string} filename Absolute path of the file.
* @param {string} format Format of the module. If it had types, this would be what it is after type-stripping.
* @param {string} source Source the module. If it had types, this would have the type stripped.
*/ */
function loadESMFromCJS(mod, filename) { function loadESMFromCJS(mod, filename, format, source) {
let source = getMaybeCachedSource(mod, filename);
if (getOptionValue('--experimental-strip-types') && path.extname(filename) === '.mts') {
source = stripTypeScriptModuleTypes(source, filename);
}
const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
const isMain = mod[kIsMainSymbol]; const isMain = mod[kIsMainSymbol];
if (isMain) { if (isMain) {
@ -1512,9 +1502,30 @@ function wrapSafe(filename, content, cjsModuleInstance, format) {
* `exports`) to the file. Returns exception, if any. * `exports`) to the file. Returns exception, if any.
* @param {string} content The source code of the module * @param {string} content The source code of the module
* @param {string} filename The file path of the module * @param {string} filename The file path of the module
* @param {'module'|'commonjs'|undefined} format Intended format of the module. * @param {
* 'module'|'commonjs'|'commonjs-typescript'|'module-typescript'
* } format Intended format of the module.
*/ */
Module.prototype._compile = function(content, filename, format) { Module.prototype._compile = function(content, filename, format) {
if (format === 'commonjs-typescript' || format === 'module-typescript' || format === 'typescript') {
content = stripTypeScriptModuleTypes(content, filename);
switch (format) {
case 'commonjs-typescript': {
format = 'commonjs';
break;
}
case 'module-typescript': {
format = 'module';
break;
}
// If the format is still unknown i.e. 'typescript', detect it in
// wrapSafe using the type-stripped source.
default:
format = undefined;
break;
}
}
let redirects; let redirects;
let compiledWrapper; let compiledWrapper;
@ -1527,9 +1538,7 @@ Module.prototype._compile = function(content, filename, format) {
} }
if (format === 'module') { if (format === 'module') {
// Pass the source into the .mjs extension handler indirectly through the cache. loadESMFromCJS(this, filename, format, content);
this[kModuleSource] = content;
loadESMFromCJS(this, filename);
return; return;
} }
@ -1582,72 +1591,76 @@ Module.prototype._compile = function(content, filename, format) {
/** /**
* Get the source code of a module, using cached ones if it's cached. * Get the source code of a module, using cached ones if it's cached.
* After this returns, mod[kFormat], mod[kModuleSource] and mod[kURL] will be set.
* @param {Module} mod Module instance whose source is potentially already cached. * @param {Module} mod Module instance whose source is potentially already cached.
* @param {string} filename Absolute path to the file of the module. * @param {string} filename Absolute path to the file of the module.
* @returns {string} * @returns {{source: string, format?: string}}
*/ */
function getMaybeCachedSource(mod, filename) { function loadSource(mod, filename, formatFromNode) {
// If already analyzed the source, then it will be cached. if (formatFromNode !== undefined) {
let content; mod[kFormat] = formatFromNode;
if (mod[kModuleSource] !== undefined) { }
content = mod[kModuleSource]; const format = mod[kFormat];
let source = mod[kModuleSource];
if (source !== undefined) {
mod[kModuleSource] = undefined; mod[kModuleSource] = undefined;
} else { } else {
// TODO(joyeecheung): we can read a buffer instead to speed up // TODO(joyeecheung): we can read a buffer instead to speed up
// compilation. // compilation.
content = fs.readFileSync(filename, 'utf8'); source = fs.readFileSync(filename, 'utf8');
} }
return content; return { source, format };
} }
/**
* Built-in handler for `.mts` files.
* @param {Module} mod CJS module instance
* @param {string} filename The file path of the module
*/
function loadMTS(mod, filename) {
const loadResult = loadSource(mod, filename, 'module-typescript');
mod._compile(loadResult.source, filename, loadResult.format);
}
/**
* Built-in handler for `.cts` files.
* @param {Module} module CJS module instance
* @param {string} filename The file path of the module
*/
function loadCTS(module, filename) { function loadCTS(module, filename) {
const source = getMaybeCachedSource(module, filename); const loadResult = loadSource(module, filename, 'commonjs-typescript');
const code = stripTypeScriptModuleTypes(source, filename); module._compile(loadResult.source, filename, loadResult.format);
module._compile(code, filename, 'commonjs');
} }
/** /**
* Built-in handler for `.ts` files. * Built-in handler for `.ts` files.
* @param {Module} module The module to compile * @param {Module} module CJS module instance
* @param {string} filename The file path of the module * @param {string} filename The file path of the module
*/ */
function loadTS(module, filename) { function loadTS(module, filename) {
// If already analyzed the source, then it will be cached.
const source = getMaybeCachedSource(module, filename);
const content = stripTypeScriptModuleTypes(source, filename);
let format;
const pkg = packageJsonReader.getNearestParentPackageJSON(filename); const pkg = packageJsonReader.getNearestParentPackageJSON(filename);
// Function require shouldn't be used in ES modules. const typeFromPjson = pkg?.data.type;
if (pkg?.data.type === 'module') {
if (getOptionValue('--experimental-require-module')) {
module._compile(content, filename, 'module');
return;
}
const parent = module[kModuleParent]; let format;
const parentPath = parent?.filename; if (typeFromPjson === 'module') {
const packageJsonPath = pkg.path; format = 'module-typescript';
const usesEsm = containsModuleSyntax(content, filename); } else if (typeFromPjson === 'commonjs') {
const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath, format = 'commonjs-typescript';
packageJsonPath); } else {
// Attempt to reconstruct the parent require frame. format = 'typescript';
if (Module._cache[parentPath]) { }
let parentSource; const loadResult = loadSource(module, filename, format);
try {
parentSource = stripTypeScriptModuleTypes(fs.readFileSync(parentPath, 'utf8'), parentPath); // Function require shouldn't be used in ES modules when require(esm) is disabled.
} catch { if (typeFromPjson === 'module' && !getOptionValue('--experimental-require-module')) {
// Continue regardless of error. const err = getRequireESMError(module, pkg, loadResult.source, filename);
}
if (parentSource) {
reconstructErrorStack(err, parentPath, parentSource);
}
}
throw err; throw err;
} else if (pkg?.data.type === 'commonjs') {
format = 'commonjs';
} }
module._compile(content, filename, format); module[kFormat] = loadResult.format;
module._compile(loadResult.source, filename, loadResult.format);
}; };
function reconstructErrorStack(err, parentPath, parentSource) { function reconstructErrorStack(err, parentPath, parentSource) {
@ -1663,53 +1676,64 @@ function reconstructErrorStack(err, parentPath, parentSource) {
} }
} }
/**
* Generate the legacy ERR_REQUIRE_ESM for the cases where require(esm) is disabled.
* @param {Module} mod The module being required.
* @param {undefined|object} pkg Data of the nearest package.json of the module.
* @param {string} content Source code of the module.
* @param {string} filename Filename of the module
* @returns {Error}
*/
function getRequireESMError(mod, pkg, content, filename) {
// This is an error path because `require` of a `.js` file in a `"type": "module"` scope is not allowed.
const parent = mod[kModuleParent];
const parentPath = parent?.filename;
const packageJsonPath = pkg?.path;
const usesEsm = containsModuleSyntax(content, filename);
const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath,
packageJsonPath);
// Attempt to reconstruct the parent require frame.
const parentModule = Module._cache[parentPath];
if (parentModule) {
let parentSource;
try {
({ source: parentSource } = loadSource(parentModule, parentPath));
} catch {
// Continue regardless of error.
}
if (parentSource) {
// TODO(joyeecheung): trim off internal frames from the stack.
reconstructErrorStack(err, parentPath, parentSource);
}
}
return err;
}
/** /**
* Built-in handler for `.js` files. * Built-in handler for `.js` files.
* @param {Module} module The module to compile * @param {Module} module The module to compile
* @param {string} filename The file path of the module * @param {string} filename The file path of the module
*/ */
Module._extensions['.js'] = function(module, filename) { Module._extensions['.js'] = function(module, filename) {
// If already analyzed the source, then it will be cached. let format, pkg;
const content = getMaybeCachedSource(module, filename); if (StringPrototypeEndsWith(filename, '.cjs')) {
let format;
if (StringPrototypeEndsWith(filename, '.js')) {
const pkg = packageJsonReader.getNearestParentPackageJSON(filename);
// Function require shouldn't be used in ES modules.
if (pkg?.data.type === 'module') {
if (getOptionValue('--experimental-require-module')) {
module._compile(content, filename, 'module');
return;
}
// This is an error path because `require` of a `.js` file in a `"type": "module"` scope is not allowed.
const parent = module[kModuleParent];
const parentPath = parent?.filename;
const packageJsonPath = pkg.path;
const usesEsm = containsModuleSyntax(content, filename);
const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath,
packageJsonPath);
// Attempt to reconstruct the parent require frame.
if (Module._cache[parentPath]) {
let parentSource;
try {
parentSource = fs.readFileSync(parentPath, 'utf8');
} catch {
// Continue regardless of error.
}
if (parentSource) {
reconstructErrorStack(err, parentPath, parentSource);
}
}
throw err;
} else if (pkg?.data.type === 'commonjs') {
format = 'commonjs';
}
} else if (StringPrototypeEndsWith(filename, '.cjs')) {
format = 'commonjs'; format = 'commonjs';
} else if (StringPrototypeEndsWith(filename, '.mjs')) {
format = 'module';
} else if (StringPrototypeEndsWith(filename, '.js')) {
pkg = packageJsonReader.getNearestParentPackageJSON(filename);
const typeFromPjson = pkg?.data.type;
if (typeFromPjson === 'module' || typeFromPjson === 'commonjs' || !typeFromPjson) {
format = typeFromPjson;
}
} }
const { source, format: loadedFormat } = loadSource(module, filename, format);
module._compile(content, filename, format); // Function require shouldn't be used in ES modules when require(esm) is disabled.
if (loadedFormat === 'module' && !getOptionValue('--experimental-require-module')) {
const err = getRequireESMError(module, pkg, source, filename);
throw err;
}
module._compile(source, filename, loadedFormat);
}; };
/** /**