Repository: nodejs/node
Pull request: esm: fallback to `readFileSync` when `source` is nullish

When using the Modules Customization Hooks API to load CommonJS modules, we want to support the returned value of `defaultLoad` which must be nullish to preserve backward compatibility. This can be achieved by fetching the source from the translator.

Fixes: https://github.com/nodejs/node/issues/50435

<!--
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/esm/load.js
patch: @@ -261,5 +261,6 @@ function throwUnknownModuleFormat(url, format) {
 module.exports = {
   defaultLoad,
   defaultLoadSync,
+  getSourceSync,
   throwUnknownModuleFormat,
 };

filename: lib/internal/modules/esm/translators.js
patch: @@ -31,6 +31,7 @@ function lazyTypes() {
 }
 
 const { containsModuleSyntax } = internalBinding('contextify');
+const { BuiltinModule } = require('internal/bootstrap/realm');
 const assert = require('internal/assert');
 const { readFileSync } = require('fs');
 const { dirname, extname, isAbsolute } = require('path');
@@ -58,6 +59,17 @@ const asyncESM = require('internal/process/esm_loader');
 const { emitWarningSync } = require('internal/process/warning');
 const { internalCompileFunction } = require('internal/vm');
 
+// Lazy-loading to avoid circular dependencies.
+let getSourceSync;
+/**
+ * @param {Parameters<typeof import('./load').getSourceSync>[0]} url
+ * @returns {ReturnType<typeof import('./load').getSourceSync>}
+ */
+function getSource(url) {
+  getSourceSync ??= require('internal/modules/esm/load').getSourceSync;
+  return getSourceSync(url);
+}
+
 /** @type {import('deps/cjs-module-lexer/lexer.js').parse} */
 let cjsParse;
 /**
@@ -225,21 +237,19 @@ function loadCJSModule(module, source, url, filename) {
   // eslint-disable-next-line func-name-matching,func-style
   const requireFn = function require(specifier) {
     let importAttributes = kEmptyObject;
-    if (!StringPrototypeStartsWith(specifier, 'node:')) {
+    if (!StringPrototypeStartsWith(specifier, 'node:') && !BuiltinModule.normalizeRequirableId(specifier)) {
       // TODO: do not depend on the monkey-patchable CJS loader here.
       const path = CJSModule._resolveFilename(specifier, module);
-      if (specifier !== path) {
-        switch (extname(path)) {
-          case '.json':
-            importAttributes = { __proto__: null, type: 'json' };
-            break;
-          case '.node':
-            return CJSModule._load(specifier, module);
-          default:
+      switch (extname(path)) {
+        case '.json':
+          importAttributes = { __proto__: null, type: 'json' };
+          break;
+        case '.node':
+          return CJSModule._load(specifier, module);
+        default:
             // fall through
-        }
-        specifier = `${pathToFileURL(path)}`;
       }
+      specifier = `${pathToFileURL(path)}`;
     }
     const job = asyncESM.esmLoader.getModuleJobSync(specifier, url, importAttributes);
     job.runSync();
@@ -276,7 +286,8 @@ function createCJSModuleWrap(url, source, isMain, loadCJS = loadCJSModule) {
   debug(`Translating CJSModule ${url}`);
 
   const filename = StringPrototypeStartsWith(url, 'file://') ? fileURLToPath(url) : url;
-  source = stringify(source);
+  // In case the source was not provided by the `load` step, we need fetch it now.
+  source = stringify(source ?? getSource(new URL(url)).source);
 
   const { exportNames, module } = cjsPreparseModuleExports(filename, source);
   cjsCache.set(url, module);

filename: test/es-module/test-esm-loader-hooks.mjs
patch: @@ -746,4 +746,39 @@ describe('Loader hooks', { concurrency: true }, () => {
     assert.strictEqual(code, 0);
     assert.strictEqual(signal, null);
   });
+
+  it('should handle mixed of opt-in modules and non-opt-in ones', async () => {
+    const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
+      '--no-warnings',
+      '--experimental-loader',
+      `data:text/javascript,const fixtures=${JSON.stringify(fixtures.path('empty.js'))};export ${
+        encodeURIComponent(function resolve(s, c, n) {
+          if (s.endsWith('entry-point')) {
+            return {
+              shortCircuit: true,
+              url: 'file:///c:/virtual-entry-point',
+              format: 'commonjs',
+            };
+          }
+          return n(s, c);
+        })
+      }export ${
+        encodeURIComponent(async function load(u, c, n) {
+          if (u === 'file:///c:/virtual-entry-point') {
+            return {
+              shortCircuit: true,
+              source: `"use strict";require(${JSON.stringify(fixtures)});console.log("Hello");`,
+              format: 'commonjs',
+            };
+          }
+          return n(u, c);
+        })}`,
+      'entry-point',
+    ]);
+
+    assert.strictEqual(stderr, '');
+    assert.strictEqual(stdout, 'Hello\n');
+    assert.strictEqual(code, 0);
+    assert.strictEqual(signal, null);
+  });
 });