Repository: nodejs/node
Pull request: module: refator ESM loader for adding future synchronous hooks

This lays the foundation for supporting synchronous hooks proposed in https://github.com/nodejs/loaders/pull/198 for ESM.

- Corrects and adds several JSDoc comments for internal functions of the ESM loader, as well as explaining how require() for import CJS work in the special resolve/load paths. This doesn't consolidate it with import in require(esm) yet due to caching differences, which is left as a TODO.
- The moduleProvider passed into ModuleJob is replaced as moduleOrModulePromise, we call the translators directly in the ESM loader and verify it right after loading for clarity.
- Reuse a few refactored out helpers for require(esm) in getModuleJobForRequire().

<!--
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/loader.js
patch: @@ -206,13 +206,11 @@ class ModuleLoader {
   }
 
   async eval(source, url, isEntryPoint = false) {
-    const evalInstance = (url) => {
-      return compileSourceTextModule(url, source, this);
-    };
     const { ModuleJob } = require('internal/modules/esm/module_job');
+    const wrap = compileSourceTextModule(url, source, this);
     const module = await onImport.tracePromise(async () => {
       const job = new ModuleJob(
-        this, url, undefined, evalInstance, false, false);
+        this, url, undefined, wrap, false, false);
       this.loadCache.set(url, undefined, job);
       const { module } = await job.run(isEntryPoint);
       return module;
@@ -230,40 +228,49 @@ class ModuleLoader {
   }
 
   /**
-   * Get a (possibly still pending) module job from the cache,
-   * or create one and return its Promise.
-   * @param {string} specifier The string after `from` in an `import` statement,
-   *                           or the first parameter of an `import()`
-   *                           expression
-   * @param {string | undefined} parentURL The URL of the module importing this
-   *                                     one, unless this is the Node.js entry
-   *                                     point.
-   * @param {Record<string, string>} importAttributes Validations for the
-   *                                                  module import.
-   * @returns {Promise<ModuleJob>} The (possibly pending) module job
+   * Get a (possibly not yet fully linked) module job from the cache, or create one and return its Promise.
+   * @param {string} specifier The module request of the module to be resolved. Typically, what's
+   *                           requested by `import '<specifier>'` or `import('<specifier>')`.
+   * @param {string} [parentURL] The URL of the module where the module request is initiated.
+   *                             It's undefined if it's from the root module.
+   * @param {ImportAttributes} importAttributes Attributes from the import statement or expression.
+   * @returns {Promise<ModuleJobBase}
    */
-  async getModuleJob(specifier, parentURL, importAttributes) {
+  async getModuleJobForImport(specifier, parentURL, importAttributes) {
     const resolveResult = await this.resolve(specifier, parentURL, importAttributes);
-    return this.getJobFromResolveResult(resolveResult, parentURL, importAttributes);
+    return this.#getJobFromResolveResult(resolveResult, parentURL, importAttributes, false);
   }
 
-  getModuleJobSync(specifier, parentURL, importAttributes) {
+  /**
+   * Similar to {@link getModuleJobForImport} but it's used for `require()` resolved by the ESM loader
+   * in imported CJS modules. This runs synchronously and when it returns, the module job's module
+   * requests are all linked.
+   * @param {string} specifier See {@link getModuleJobForImport}
+   * @param {string} [parentURL] See {@link getModuleJobForImport}
+   * @param {ImportAttributes} importAttributes See {@link getModuleJobForImport}
+   * @returns {Promise<ModuleJobBase}
+   */
+  getModuleJobForRequireInImportedCJS(specifier, parentURL, importAttributes) {
     const resolveResult = this.resolveSync(specifier, parentURL, importAttributes);
-    return this.getJobFromResolveResult(resolveResult, parentURL, importAttributes, true);
+    return this.#getJobFromResolveResult(resolveResult, parentURL, importAttributes, true);
   }
 
-  getJobFromResolveResult(resolveResult, parentURL, importAttributes, sync) {
+  /**
+   * Given a resolved module request, obtain a ModuleJobBase from it - if it's already cached,
+   * return the cached ModuleJobBase. Otherwise, load its source and translate it into a ModuleWrap first.
+   * @param {{ format: string, url: string }} resolveResult Resolved module request.
+   * @param {string} [parentURL] See {@link getModuleJobForImport}
+   * @param {ImportAttributes} importAttributes See {@link getModuleJobForImport}
+   * @param {boolean} isForRequireInImportedCJS Whether this is done for require() in imported CJS.
+   * @returns {ModuleJobBase}
+   */
+  #getJobFromResolveResult(resolveResult, parentURL, importAttributes, isForRequireInImportedCJS = false) {
     const { url, format } = resolveResult;
     const resolvedImportAttributes = resolveResult.importAttributes ?? importAttributes;
     let job = this.loadCache.get(url, resolvedImportAttributes.type);
 
-    // CommonJS will set functions for lazy job evaluation.
-    if (typeof job === 'function') {
-      this.loadCache.set(url, undefined, job = job());
-    }
-
     if (job === undefined) {
-      job = this.#createModuleJob(url, resolvedImportAttributes, parentURL, format, sync);
+      job = this.#createModuleJob(url, resolvedImportAttributes, parentURL, format, isForRequireInImportedCJS);
     }
 
     return job;
@@ -336,13 +343,9 @@ class ModuleLoader {
       assert(protocol === 'file:' || protocol === 'node:' || protocol === 'data:');
     }
 
-    const requestKey = this.#resolveCache.serializeKey(specifier, importAttributes);
-    let resolveResult = this.#resolveCache.get(requestKey, parentURL);
-    if (resolveResult == null) {
-      resolveResult = this.defaultResolve(specifier, parentURL, importAttributes);
-      this.#resolveCache.set(requestKey, parentURL, resolveResult);
-    }
-
+    // TODO(joyeecheung): consolidate cache behavior and use resolveSync() and
+    // loadSync() here.
+    const resolveResult = this.#cachedDefaultResolve(specifier, parentURL, importAttributes);
     const { url, format } = resolveResult;
     if (!getOptionValue('--experimental-require-module')) {
       throw new ERR_REQUIRE_ESM(url, true);
@@ -371,23 +374,16 @@ class ModuleLoader {
     const loadResult = defaultLoadSync(url, { format, importAttributes });
     const {
       format: finalFormat,
-      responseURL,
       source,
     } = loadResult;
 
-    this.validateLoadResult(url, finalFormat);
     if (finalFormat === 'wasm') {
       assert.fail('WASM is currently unsupported by require(esm)');
     }
 
-    const translator = getTranslators().get(finalFormat);
-    if (!translator) {
-      throw new ERR_UNKNOWN_MODULE_FORMAT(finalFormat, responseURL);
-    }
-
     const isMain = (parentURL === undefined);
-    const wrap = FunctionPrototypeCall(translator, this, responseURL, source, isMain);
-    assert(wrap instanceof ModuleWrap);  // No asynchronous translators should be called.
+    const wrap = this.#translate(url, finalFormat, source, isMain);
+    assert(wrap instanceof ModuleWrap, `Translator used for require(${url}) should not be async`);
 
     if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) {
       process.send({ 'watch:import': [url] });
@@ -416,33 +412,96 @@ class ModuleLoader {
   }
 
   /**
-   * Create and cache an object representing a loaded module.
-   * @param {string} url The absolute URL that was resolved for this module
-   * @param {Record<string, string>} importAttributes Validations for the
-   *                                                  module import.
-   * @param {string} [parentURL] The absolute URL of the module importing this
-   *                             one, unless this is the Node.js entry point
-   * @param {string} [format] The format hint possibly returned by the
-   *                          `resolve` hook
-   * @returns {Promise<ModuleJob>} The (possibly pending) module job
+   * Translate a loaded module source into a ModuleWrap. This is run synchronously,
+   * but the translator may return the ModuleWrap in a Promise.
+   * @param {stirng} url URL of the module to be translated.
+   * @param {string} format Format of the module to be translated. This is used to find
+   *                        matching translators.
+   * @param {ModuleSource} source Source of the module to be translated.
+   * @param {boolean} isMain Whether the module to be translated is the entry point.
+   * @returns {ModuleWrap | Promise<ModuleWrap>}
+   */
+  #translate(url, format, source, isMain) {
+    this.validateLoadResult(url, format);
+    const translator = getTranslators().get(format);
+
+    if (!translator) {
+      throw new ERR_UNKNOWN_MODULE_FORMAT(format, url);
+    }
+
+    return FunctionPrototypeCall(translator, this, url, source, isMain);
+  }
+
+  /**
+   * Load a module and translate it into a ModuleWrap for require() in imported CJS.
+   * This is run synchronously, and the translator always return a ModuleWrap synchronously.
+   * @param {string} url URL of the module to be translated.
+   * @param {object} loadContext See {@link load}
+   * @param {boolean} isMain Whether the module to be translated is the entry point.
+   * @returns {ModuleWrap}
    */
-  #createModuleJob(url, importAttributes, parentURL, format, sync) {
-    const callTranslator = ({ format: finalFormat, responseURL, source }, isMain) => {
-      const translator = getTranslators().get(finalFormat);
+  loadAndTranslateForRequireInImportedCJS(url, loadContext, isMain) {
+    const { format: formatFromLoad, source } = this.#loadSync(url, loadContext);
+
+    if (formatFromLoad === 'wasm') {  // require(wasm) is not supported.
+      throw new ERR_UNKNOWN_MODULE_FORMAT(formatFromLoad, url);
+    }
 
-      if (!translator) {
-        throw new ERR_UNKNOWN_MODULE_FORMAT(finalFormat, responseURL);
+    if (formatFromLoad === 'module' || formatFromLoad === 'module-typescript') {
+      if (!getOptionValue('--experimental-require-module')) {
+        throw new ERR_REQUIRE_ESM(url, true);
       }
+    }
 
-      return FunctionPrototypeCall(translator, this, responseURL, source, isMain);
-    };
-    const context = { format, importAttributes };
+    let finalFormat = formatFromLoad;
+    if (formatFromLoad === 'commonjs') {
+      finalFormat = 'require-commonjs';
+    }
+    if (formatFromLoad === 'commonjs-typescript') {
+      finalFormat = 'require-commonjs-typescript';
+    }
 
-    const moduleProvider = sync ?
-      (url, isMain) => callTranslator(this.loadSync(url, context), isMain) :
-      async (url, isMain) => callTranslator(await this.load(url, context), isMain);
+    const wrap = this.#translate(url, finalFormat, source, isMain);
+    assert(wrap instanceof ModuleWrap, `Translator used for require(${url}) should not be async`);
+    return wrap;
+  }
+
+  /**
+   * Load a module and translate it into a ModuleWrap for ordinary imported ESM.
+   * This is run asynchronously.
+   * @param {string} url URL of the module to be translated.
+   * @param {object} loadContext See {@link load}
+   * @param {boolean} isMain Whether the module to be translated is the entry point.
+   * @returns {Promise<ModuleWrap>}
+   */
+  async loadAndTranslate(url, loadContext, isMain) {
+    const { format, source } = await this.load(url, loadContext);
+    return this.#translate(url, format, source, isMain);
+  }
+
+  /**
+   * Load a module and translate it into a ModuleWrap, and create a ModuleJob from it.
+   * This runs synchronously. If isForRequireInImportedCJS is true, the module should be linked
+   * by the time this returns. Otherwise it may still have pending module requests.
+   * @param {string} url The URL that was resolved for this module.
+   * @param {ImportAttributes} importAttributes See {@link getModuleJobForImport}
+   * @param {string} [parentURL] See {@link getModuleJobForImport}
+   * @param {string} [format] The format hint possibly returned by the `resolve` hook
+   * @param {boolean} isForRequireInImportedCJS Whether this module job is created for require()
+   *                                            in imported CJS.
+   * @returns {ModuleJobBase} The (possibly pending) module job
+   */
+  #createModuleJob(url, importAttributes, parentURL, format, isForRequireInImportedCJS) {
+    const context = { format, importAttributes };
 
     const isMain = parentURL === undefined;
+    let moduleOrModulePromise;
+    if (isForRequireInImportedCJS) {
+      moduleOrModulePromise = this.loadAndTranslateForRequireInImportedCJS(url, context, isMain);
+    } else {
+      moduleOrModulePromise = this.loadAndTranslate(url, context, isMain);
+    }
+
     const inspectBrk = (
       isMain &&
       getOptionValue('--inspect-brk')
@@ -457,10 +516,10 @@ class ModuleLoader {
       this,
       url,
       importAttributes,
-      moduleProvider,
+      moduleOrModulePromise,
       isMain,
       inspectBrk,
-      sync,
+      isForRequireInImportedCJS,
     );
 
     this.loadCache.set(url, importAttributes.type, job);
@@ -479,7 +538,7 @@ class ModuleLoader {
    */
   async import(specifier, parentURL, importAttributes, isEntryPoint = false) {
     return onImport.tracePromise(async () => {
-      const moduleJob = await this.getModuleJob(specifier, parentURL, importAttributes);
+      const moduleJob = await this.getModuleJobForImport(specifier, parentURL, importAttributes);
       const { module } = await moduleJob.run(isEntryPoint);
       return module.getNamespace();
     }, {
@@ -504,39 +563,72 @@ class ModuleLoader {
   }
 
   /**
-   * Resolve the location of the module.
-   * @param {string} originalSpecifier The specified URL path of the module to
-   *                                   be resolved.
-   * @param {string} [parentURL] The URL path of the module's parent.
-   * @param {ImportAttributes} importAttributes Attributes from the import
-   *                                            statement or expression.
-   * @returns {{ format: string, url: URL['href'] }}
+   * Resolve a module request to a URL identifying the location of the module. Handles customization hooks,
+   * if any.
+   * @param {string|URL} specifier The module request of the module to be resolved. Typically, what's
+   *                               requested by `import specifier`, `import(specifier)` or
+   *                               `import.meta.resolve(specifier)`.
+   * @param {string} [parentURL] The URL of the module where the module request is initiated.
+   *                             It's undefined if it's from the root module.
+   * @param {ImportAttributes} importAttributes Attributes from the import statement or expression.
+   * @returns {Promise<{format: string, url: string}>}
    */
-  resolve(originalSpecifier, parentURL, importAttributes) {
-    originalSpecifier = `${originalSpecifier}`;
-    if (this.#customizations) {
-      return this.#customizations.resolve(originalSpecifier, parentURL, importAttributes);
+  resolve(specifier, parentURL, importAttributes) {
+    specifier = `${specifier}`;
+    if (this.#customizations) {  // Only has module.register hooks.
+      return this.#customizations.resolve(specifier, parentURL, importAttributes);
     }
-    const requestKey = this.#resolveCache.serializeKey(originalSpecifier, importAttributes);
+    return this.#cachedDefaultResolve(specifier, parentURL, importAttributes);
+  }
+
+  /**
+   * Either return a cached resolution, or perform the default resolution which is synchronous, and
+   * cache the result.
+   * @param {string} specifier See {@link resolve}.
+   * @param {string} [parentURL] See {@link resolve}.
+   * @param {ImportAttributes} importAttributes See {@link resolve}.
+   * @returns {{ format: string, url: string }}
+   */
+  #cachedDefaultResolve(specifier, parentURL, importAttributes) {
+    const requestKey = this.#resolveCache.serializeKey(specifier, importAttributes);
     const cachedResult = this.#resolveCache.get(requestKey, parentURL);
     if (cachedResult != null) {
       return cachedResult;
     }
-    const result = this.defaultResolve(originalSpecifier, parentURL, importAttributes);
+    const result = this.defaultResolve(specifier, parentURL, importAttributes);
     this.#resolveCache.set(requestKey, parentURL, result);
     return result;
   }
 
   /**
-   * Just like `resolve` except synchronous. This is here specifically to support
-   * `import.meta.resolve` which must happen synchronously.
+   * This is the default resolve step for future synchronous hooks, which incorporates asynchronous hooks
+   * from module.register() which are run in a blocking fashion for it to be synchronous.
+   * @param {string|URL} specifier See {@link resolveSync}.
+   * @param {{ parentURL?: string, importAttributes: ImportAttributes}} context See {@link resolveSync}.
+   * @returns {{ format: string, url: string }}
    */
-  resolveSync(originalSpecifier, parentURL, importAttributes) {
-    originalSpecifier = `${originalSpecifier}`;
+  #resolveAndMaybeBlockOnLoaderThread(specifier, context) {
     if (this.#customizations) {
-      return this.#customizations.resolveSync(originalSpecifier, parentURL, importAttributes);
+      return this.#customizations.resolveSync(specifier, context.parentURL, context.importAttributes);
     }
-    return this.defaultResolve(originalSpecifier, parentURL, importAttributes);
+    return this.#cachedDefaultResolve(specifier, context.parentURL, context.importAttributes);
+  }
+
+  /**
+   * Similar to {@link resolve}, but the results are always synchronously returned. If there are any
+   * asynchronous resolve hooks from module.register(), it will block until the results are returned
+   * from the loader thread for this to be synchornous.
+   * This is here to support `import.meta.resolve()`, `require()` in imported CJS, and
+   * future synchronous hooks.
+   *
+   * TODO(joyeecheung): consolidate the cache behavior and use this in require(esm).
+   * @param {string|URL} specifier See {@link resolve}.
+   * @param {string} [parentURL] See {@link resolve}.
+   * @param {ImportAttributes} [importAttributes] See {@link resolve}.
+   * @returns {{ format: string, url: string }}
+   */
+  resolveSync(specifier, parentURL, importAttributes = { __proto__: null }) {
+    return this.#resolveAndMaybeBlockOnLoaderThread(`${specifier}`, { parentURL, importAttributes });
   }
 
   /**
@@ -558,41 +650,49 @@ class ModuleLoader {
   }
 
   /**
-   * Provide source that is understood by one of Node's translators.
-   * @param {URL['href']} url The URL/path of the module to be loaded
-   * @param {object} [context] Metadata about the module
+   * Provide source that is understood by one of Node's translators. Handles customization hooks,
+   * if any.
+   * @param {string} url The URL of the module to be loaded.
+   * @param {object} context Metadata about the module
    * @returns {Promise<{ format: ModuleFormat, source: ModuleSource }>}
    */
   async load(url, context) {
+    if (this.#customizations) {
+      return this.#customizations.load(url, context);
+    }
+
     defaultLoad ??= require('internal/modules/esm/load').defaultLoad;
-    const result = this.#customizations ?
-      await this.#customizations.load(url, context) :
-      await defaultLoad(url, context);
-    this.validateLoadResult(url, result?.format);
-    return result;
+    return defaultLoad(url, context);
   }
 
-  loadSync(url, context) {
-    defaultLoadSync ??= require('internal/modules/esm/load').defaultLoadSync;
-
-    let result = this.#customizations ?
-      this.#customizations.loadSync(url, context) :
-      defaultLoadSync(url, context);
-    let format = result?.format;
-    if (format === 'module' || format === 'module-typescript') {
-      throw new ERR_REQUIRE_ESM(url, true);
-    }
-    if (format === 'commonjs') {
-      format = 'require-commonjs';
-      result = { __proto__: result, format };
-    }
-    if (format === 'commonjs-typescript') {
-      format = 'require-commonjs-typescript';
-      result = { __proto__: result, format };
+  /**
+   * This is the default load step for future synchronous hooks, which incorporates asynchronous hooks
+   * from module.register() which are run in a blocking fashion for it to be synchronous.
+   * @param {string} url See {@link load}
+   * @param {object} context See {@link load}
+   * @returns {{ format: ModuleFormat, source: ModuleSource }}
+   */
+  #loadAndMaybeBlockOnLoaderThread(url, context) {
+    if (this.#customizations) {
+      return this.#customizations.loadSync(url, context);
     }
+    defaultLoadSync ??= require('internal/modules/esm/load').defaultLoadSync;
+    return defaultLoadSync(url, context);
+  }
 
-    this.validateLoadResult(url, format);
-    return result;
+  /**
+   * Similar to {@link load} but this is always run synchronously. If there are asynchronous hooks
+   * from module.register(), this blocks on the loader thread for it to return synchronously.
+   *
+   * This is here to support `require()` in imported CJS and future synchronous hooks.
+   *
+   * TODO(joyeecheung): consolidate the cache behavior and use this in require(esm).
+   * @param {string} url See {@link load}
+   * @param {object} [context] See {@link load}
+   * @returns {{ format: ModuleFormat, source: ModuleSource }}
+   */
+  #loadSync(url, context) {
+    return this.#loadAndMaybeBlockOnLoaderThread(url, context);
   }
 
   validateLoadResult(url, format) {

filename: lib/internal/modules/esm/module_job.js
patch: @@ -8,7 +8,6 @@ const {
   ObjectSetPrototypeOf,
   PromisePrototypeThen,
   PromiseResolve,
-  ReflectApply,
   RegExpPrototypeExec,
   RegExpPrototypeSymbolReplace,
   SafePromiseAllReturnArrayLike,
@@ -56,35 +55,42 @@ const isCommonJSGlobalLikeNotDefinedError = (errorMessage) =>
   );
 
 class ModuleJobBase {
-  constructor(url, importAttributes, moduleWrapMaybePromise, isMain, inspectBrk) {
+  constructor(url, importAttributes, isMain, inspectBrk) {
     this.importAttributes = importAttributes;
     this.isMain = isMain;
     this.inspectBrk = inspectBrk;
 
     this.url = url;
-    this.module = moduleWrapMaybePromise;
   }
 }
 
 /* A ModuleJob tracks the loading of a single Module, and the ModuleJobs of
  * its dependencies, over time. */
 class ModuleJob extends ModuleJobBase {
   #loader = null;
-  // `loader` is the Loader instance used for loading dependencies.
+
+  /**
+   * @param {ModuleLoader} loader The ESM loader.
+   * @param {string} url URL of the module to be wrapped in ModuleJob.
+   * @param {ImportAttributes} importAttributes Import attributes from the import statement.
+   * @param {ModuleWrap|Promise<ModuleWrap>} moduleOrModulePromise Translated ModuleWrap for the module.
+   * @param {boolean} isMain Whether the module is the entry point.
+   * @param {boolean} inspectBrk Whether this module should be evaluated with the
+   *                             first line paused in the debugger (because --inspect-brk is passed).
+   * @param {boolean} isForRequireInImportedCJS Whether this is created for require() in imported CJS.
+   */
   constructor(loader, url, importAttributes = { __proto__: null },
-              moduleProvider, isMain, inspectBrk, sync = false) {
-    const modulePromise = ReflectApply(moduleProvider, loader, [url, isMain]);
-    super(url, importAttributes, modulePromise, isMain, inspectBrk);
+              moduleOrModulePromise, isMain, inspectBrk, isForRequireInImportedCJS = false) {
+    super(url, importAttributes, isMain, inspectBrk);
     this.#loader = loader;
-    // Expose the promise to the ModuleWrap directly for linking below.
-    // `this.module` is also filled in below.
-    this.modulePromise = modulePromise;
 
-    if (sync) {
-      this.module = this.modulePromise;
+    // Expose the promise to the ModuleWrap directly for linking below.
+    if (isForRequireInImportedCJS) {
+      this.module = moduleOrModulePromise;
+      assert(this.module instanceof ModuleWrap);
       this.modulePromise = PromiseResolve(this.module);
     } else {
-      this.modulePromise = PromiseResolve(this.modulePromise);
+      this.modulePromise = moduleOrModulePromise;
     }
 
     // Promise for the list of all dependencyJobs.
@@ -123,7 +129,7 @@ class ModuleJob extends ModuleJobBase {
     for (let idx = 0; idx < moduleRequests.length; idx++) {
       const { specifier, attributes } = moduleRequests[idx];
 
-      const dependencyJobPromise = this.#loader.getModuleJob(
+      const dependencyJobPromise = this.#loader.getModuleJobForImport(
         specifier, this.url, attributes,
       );
       const modulePromise = PromisePrototypeThen(dependencyJobPromise, (job) => {
@@ -288,14 +294,33 @@ class ModuleJob extends ModuleJobBase {
   }
 }
 
-// This is a fully synchronous job and does not spawn additional threads in any way.
-// All the steps are ensured to be synchronous and it throws on instantiating
-// an asynchronous graph.
+/**
+ * This is a fully synchronous job and does not spawn additional threads in any way.
+ * All the steps are ensured to be synchronous and it throws on instantiating
+ * an asynchronous graph. It also disallows CJS <-> ESM cycles.
+ *
+ * This is used for ES modules loaded via require(esm). Modules loaded by require() in
+ * imported CJS are handled by ModuleJob with the isForRequireInImportedCJS set to true instead.
+ * The two currently have different caching behaviors.
+ * TODO(joyeecheung): consolidate this with the isForRequireInImportedCJS variant of ModuleJob.
+ */
 class ModuleJobSync extends ModuleJobBase {
   #loader = null;
+
+  /**
+   * @param {ModuleLoader} loader The ESM loader.
+   * @param {string} url URL of the module to be wrapped in ModuleJob.
+   * @param {ImportAttributes} importAttributes Import attributes from the import statement.
+   * @param {ModuleWrap} moduleWrap Translated ModuleWrap for the module.
+   * @param {boolean} isMain Whether the module is the entry point.
+   * @param {boolean} inspectBrk Whether this module should be evaluated with the
+   *                             first line paused in the debugger (because --inspect-brk is passed).
+   */
   constructor(loader, url, importAttributes, moduleWrap, isMain, inspectBrk) {
-    super(url, importAttributes, moduleWrap, isMain, inspectBrk, true);
+    super(url, importAttributes, isMain, inspectBrk, true);
+
     this.#loader = loader;
+    this.module = moduleWrap;
 
     assert(this.module instanceof ModuleWrap);
     // Store itself into the cache first before linking in case there are circular

filename: lib/internal/modules/esm/translators.js
patch: @@ -68,28 +68,11 @@ function getSource(url) {
 /** @type {import('deps/cjs-module-lexer/lexer.js').parse} */
 let cjsParse;
 /**
- * Initializes the CommonJS module lexer parser.
- * If WebAssembly is available, it uses the optimized version from the dist folder.
- * Otherwise, it falls back to the JavaScript version from the lexer folder.
+ * Initializes the CommonJS module lexer parser using the JavaScript version.
+ * TODO(joyeecheung): Use `require('internal/deps/cjs-module-lexer/dist/lexer').initSync()`
+ * when cjs-module-lexer 1.4.0 is rolled in.
  */
-async function initCJSParse() {
-  if (typeof WebAssembly === 'undefined') {
-    initCJSParseSync();
-  } else {
-    const { parse, init } =
-        require('internal/deps/cjs-module-lexer/dist/lexer');
-    try {
-      await init();
-      cjsParse = parse;
-    } catch {
-      initCJSParseSync();
-    }
-  }
-}
-
 function initCJSParseSync() {
-  // TODO(joyeecheung): implement a binding that directly compiles using
-  // v8::WasmModuleObject::Compile() synchronously.
   if (cjsParse === undefined) {
     cjsParse = require('internal/deps/cjs-module-lexer/lexer').parse;
   }
@@ -159,7 +142,7 @@ function loadCJSModule(module, source, url, filename, isMain) {
       }
       specifier = `${pathToFileURL(path)}`;
     }
-    const job = cascadedLoader.getModuleJobSync(specifier, url, importAttributes);
+    const job = cascadedLoader.getModuleJobForRequireInImportedCJS(specifier, url, importAttributes);
     job.runSync();
     return cjsCache.get(job.url).exports;
   };
@@ -250,6 +233,7 @@ translators.set('commonjs-sync', function requireCommonJS(url, source, isMain) {
 // Handle CommonJS modules referenced by `require` calls.
 // This translator function must be sync, as `require` is sync.
 translators.set('require-commonjs', (url, source, isMain) => {
+  initCJSParseSync();
   assert(cjsParse);
 
   return createCJSModuleWrap(url, source);
@@ -266,10 +250,9 @@ translators.set('require-commonjs-typescript', (url, source, isMain) => {
 
 // Handle CommonJS modules referenced by `import` statements or expressions,
 // or as the initial entry point when the ESM loader handles a CommonJS entry.
-translators.set('commonjs', async function commonjsStrategy(url, source,
-                                                            isMain) {
+translators.set('commonjs', function commonjsStrategy(url, source, isMain) {
   if (!cjsParse) {
-    await initCJSParse();
+    initCJSParseSync();
   }
 
   // For backward-compatibility, it's possible to return a nullish value for
@@ -287,7 +270,6 @@ translators.set('commonjs', async function commonjsStrategy(url, source,
     // Continue regardless of error.
   }
   return createCJSModuleWrap(url, source, isMain, cjsLoader);
-
 });
 
 /**
@@ -448,8 +430,9 @@ translators.set('wasm', async function(url, source) {
 
   let compiled;
   try {
-    // TODO(joyeecheung): implement a binding that directly compiles using
-    // v8::WasmModuleObject::Compile() synchronously.
+    // TODO(joyeecheung): implement a translator that just uses
+    // compiled = new WebAssembly.Module(source) to compile it
+    // synchronously.
     compiled = await WebAssembly.compile(source);
   } catch (err) {
     err.message = errPath(url) + ': ' + err.message;