Skip to content

module: add __esModule to require()'d ESM

Rodrigo Muino Tomonari requested to merge github/fork/joyeecheung/namespace into main

Before this PR, trying to load real ESM from transpiled ESM would throw errors like this with --experimental-require-module

// 'logger' package being loaded as real ESM
export default class Logger { log(val) { console.log(val); } }
export function log(logger, val) { logger.log(val) };
// Consuming code originally authored in ESM, but transpiled to CommonJS before being loaded by Node.js
import Logger, { log } from 'logger';
log(new Logger(), 'import both');
/Users/joyee/projects/node/test/fixtures/es-modules/transpiled-cjs-require-module/dist/import-both.cjs:27
(0, logger_1.log)(new logger_1.default(), 'import both');
                  ^

TypeError: logger_1.default is not a constructor
    at Object.<anonymous> (/Users/joyee/projects/node/test/fixtures/es-modules/transpiled-cjs-require-module/dist/import-both.cjs:27:19)
    at Module._compile (node:internal/modules/cjs/loader:1460:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1544:10)
    at Module.load (node:internal/modules/cjs/loader:1275:32)
    at Module._load (node:internal/modules/cjs/loader:1091:12)
    at wrapModuleLoad (node:internal/modules/cjs/loader:212:19)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:158:5)
    at node:internal/main/run_main_module:30:49

After this PR it logs 'import both'.

Tooling in the ecosystem have been using the __esModule property to recognize transpiled ESM in consuming code. For example, a 'log' package written in ESM:

export function log(val) { console.log(val); }

Can be transpiled as:

exports.__esModule = true;
exports.default = function log(val) { console.log(val); }

The consuming code may be written like this in ESM:

import log from 'log'

Which gets transpiled to:

const _mod = require('log');
const log = _mod.__esModule ? _mod.default : _mod;

So to allow transpiled consuming code to recognize require()'d real ESM as ESM and pick up the default exports, we add a __esModule property by building a source text module facade for any module that has a default export and add .__esModule = true to the exports. We don't do this to modules that don't have default exports to avoid the unnecessary overhead. This maintains the enumerability of the re-exported names and the live binding of the exports.

The source of the facade is defined as a constant per-isolate property required_module_facade_source_string, which looks like this

export * from 'original';
export { default } from 'original';
export const __esModule = true;

And the 'original' module request is always resolved by createRequiredModuleFacade() to wrap which is a ModuleWrap wrapping over the original module.

This PR originally used the same trick that Bun did (h/t @Jarred-Sumner) by putting the original module namespace in the prototype chain. Upon closer examination this now switched to use a SourceTextModule facade only when the exports contain default to 1) reduce the performance impact 2) ensure that the exported names are still enumerable and can be copied by tools.

Refs: https://github.com/nodejs/node/pull/51977#issuecomment-2002485317 Refs: https://github.com/nodejs/node/issues/52134

Merge request reports

Loading