Repository: nodejs/node
Pull request: 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).

Refs: https://github.com/nodejs/loaders/pull/198

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: lib/internal/modules/cjs/loader.js
patch: @@ -100,6 +100,9 @@ const kIsMainSymbol = Symbol('kIsMainSymbol');
 const kIsCachedByESMLoader = Symbol('kIsCachedByESMLoader');
 const kRequiredModuleSymbol = Symbol('kRequiredModuleSymbol');
 const kIsExecuting = Symbol('kIsExecuting');
+
+const kFormat = Symbol('kFormat');
+
 // Set first due to cycle with ESM loader functions.
 module.exports = {
   kModuleSource,
@@ -436,9 +439,8 @@ function initializeCJS() {
     Module._extensions['.ts'] = loadTS;
   }
   if (getOptionValue('--experimental-require-module')) {
-    Module._extensions['.mjs'] = loadESMFromCJS;
     if (tsEnabled) {
-      Module._extensions['.mts'] = loadESMFromCJS;
+      Module._extensions['.mts'] = loadMTS;
     }
   }
 }
@@ -653,8 +655,6 @@ function getDefaultExtensions() {
   if (tsEnabled) {
     // remove .ts and .cts from the default extensions
     // 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) =>
       (ext !== '.ts' || Module._extensions['.ts'] !== loadTS) &&
       (ext !== '.cts' || Module._extensions['.cts'] !== loadCTS),
@@ -667,14 +667,10 @@ function getDefaultExtensions() {
 
   if (tsEnabled) {
     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,
-  // 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);
+  return extensions;
 }
 
 /**
@@ -1301,10 +1297,6 @@ Module.prototype.load = function(filename) {
   this.paths = Module._nodeModulePaths(path.dirname(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 (StringPrototypeEndsWith(filename, '.mts') && !Module._extensions['.mts']) {
@@ -1353,12 +1345,10 @@ let hasPausedEntry = false;
  * Resolve and evaluate it synchronously as ESM if it's ESM.
  * @param {Module} mod CJS module instance
  * @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) {
-  let source = getMaybeCachedSource(mod, filename);
-  if (getOptionValue('--experimental-strip-types') && path.extname(filename) === '.mts') {
-    source = stripTypeScriptModuleTypes(source, filename);
-  }
+function loadESMFromCJS(mod, filename, format, source) {
   const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
   const isMain = mod[kIsMainSymbol];
   if (isMain) {
@@ -1512,9 +1502,30 @@ function wrapSafe(filename, content, cjsModuleInstance, format) {
  * `exports`) to the file. Returns exception, if any.
  * @param {string} content The source code 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) {
+  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 compiledWrapper;
@@ -1527,9 +1538,7 @@ Module.prototype._compile = function(content, filename, format) {
   }
 
   if (format === 'module') {
-    // Pass the source into the .mjs extension handler indirectly through the cache.
-    this[kModuleSource] = content;
-    loadESMFromCJS(this, filename);
+    loadESMFromCJS(this, filename, format, content);
     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.
+ * 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 {string} filename Absolute path to the file of the module.
- * @returns {string}
+ * @returns {{source: string, format?: string}}
  */
-function getMaybeCachedSource(mod, filename) {
-  // If already analyzed the source, then it will be cached.
-  let content;
-  if (mod[kModuleSource] !== undefined) {
-    content = mod[kModuleSource];
+function loadSource(mod, filename, formatFromNode) {
+  if (formatFromNode !== undefined) {
+    mod[kFormat] = formatFromNode;
+  }
+  const format = mod[kFormat];
+
+  let source = mod[kModuleSource];
+  if (source !== undefined) {
     mod[kModuleSource] = undefined;
   } else {
     // TODO(joyeecheung): we can read a buffer instead to speed up
     // 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) {
-  const source = getMaybeCachedSource(module, filename);
-  const code = stripTypeScriptModuleTypes(source, filename);
-  module._compile(code, filename, 'commonjs');
+  const loadResult = loadSource(module, filename, 'commonjs-typescript');
+  module._compile(loadResult.source, filename, loadResult.format);
 }
 
 /**
  * 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
  */
 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);
-  // 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;
-    }
+  const typeFromPjson = pkg?.data.type;
 
-    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 = stripTypeScriptModuleTypes(fs.readFileSync(parentPath, 'utf8'), parentPath);
-      } catch {
-        // Continue regardless of error.
-      }
-      if (parentSource) {
-        reconstructErrorStack(err, parentPath, parentSource);
-      }
-    }
+  let format;
+  if (typeFromPjson === 'module') {
+    format = 'module-typescript';
+  } else if (typeFromPjson === 'commonjs') {
+    format = 'commonjs-typescript';
+  } else {
+    format = 'typescript';
+  }
+  const loadResult = loadSource(module, filename, format);
+
+  // Function require shouldn't be used in ES modules when require(esm) is disabled.
+  if (typeFromPjson === 'module' && !getOptionValue('--experimental-require-module')) {
+    const err = getRequireESMError(module, pkg, loadResult.source, filename);
     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) {
@@ -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.
  * @param {Module} module The module to compile
  * @param {string} filename The file path of the module
  */
 Module._extensions['.js'] = function(module, filename) {
-  // If already analyzed the source, then it will be cached.
-  const content = getMaybeCachedSource(module, filename);
-
-  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')) {
+  let format, pkg;
+  if (StringPrototypeEndsWith(filename, '.cjs')) {
     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;
+    }
   }
-
-  module._compile(content, filename, format);
+  const { source, format: loadedFormat } = loadSource(module, 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);
 };
 
 /**