Skip to content

Commit f9dc1ea

Browse files
joyeecheungmarco-ippolito
authored andcommitted
module: add __esModule to require()'d ESM
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. PR-URL: #52166 Backport-PR-URL: #56927 Refs: #52134 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Filip Skokan <panva.ip@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com> Reviewed-By: Guy Bedford <guybedford@gmail.com> Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com> Refs: #52697
1 parent 18593b7 commit f9dc1ea

30 files changed

+316
-55
lines changed

doc/api/modules.md

+27-9
Original file line numberDiff line numberDiff line change
@@ -195,33 +195,51 @@ loaded by `require()` meets the following requirements:
195195
`"type": "commonjs"`, and `--experimental-detect-module` is enabled.
196196

197197
`require()` will load the requested module as an ES Module, and return
198-
the module name space object. In this case it is similar to dynamic
198+
the module namespace object. In this case it is similar to dynamic
199199
`import()` but is run synchronously and returns the name space object
200200
directly.
201201

202+
With the following ES Modules:
203+
202204
```mjs
203-
// point.mjs
205+
// distance.mjs
204206
export function distance(a, b) { return (b.x - a.x) ** 2 + (b.y - a.y) ** 2; }
207+
```
208+
209+
```mjs
210+
// point.mjs
205211
class Point {
206212
constructor(x, y) { this.x = x; this.y = y; }
207213
}
208214
export default Point;
209215
```
210216

217+
A CommonJS module can load them with `require()` under `--experimental-detect-module`:
218+
211219
```cjs
212-
const required = require('./point.mjs');
220+
const distance = require('./distance.mjs');
221+
console.log(distance);
213222
// [Module: null prototype] {
214-
// default: [class Point],
215223
// distance: [Function: distance]
216224
// }
217-
console.log(required);
218225

219-
(async () => {
220-
const imported = await import('./point.mjs');
221-
console.log(imported === required); // true
222-
})();
226+
const point = require('./point.mjs');
227+
console.log(point);
228+
// [Module: null prototype] {
229+
// default: [class Point],
230+
// __esModule: true,
231+
// }
223232
```
224233

234+
For interoperability with existing tools that convert ES Modules into CommonJS,
235+
which could then load real ES Modules through `require()`, the returned namespace
236+
would contain a `__esModule: true` property if it has a `default` export so that
237+
consuming code generated by tools can recognize the default exports in real
238+
ES Modules. If the namespace already defines `__esModule`, this would not be added.
239+
This property is experimental and can change in the future. It should only be used
240+
by tools converting ES modules into CommonJS modules, following existing ecosystem
241+
conventions. Code authored directly in CommonJS should avoid depending on it.
242+
225243
If the module being `require()`'d contains top-level `await`, or the module
226244
graph it `import`s contains top-level `await`,
227245
[`ERR_REQUIRE_ASYNC_MODULE`][] will be thrown. In this case, users should

lib/internal/modules/cjs/loader.js

+51-4
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const {
4141
ObjectFreeze,
4242
ObjectGetOwnPropertyDescriptor,
4343
ObjectGetPrototypeOf,
44+
ObjectHasOwn,
4445
ObjectKeys,
4546
ObjectPrototype,
4647
ObjectPrototypeHasOwnProperty,
@@ -71,7 +72,7 @@ const {
7172
},
7273
} = internalBinding('util');
7374

74-
const { kEvaluated } = internalBinding('module_wrap');
75+
const { kEvaluated, createRequiredModuleFacade } = internalBinding('module_wrap');
7576

7677
// Internal properties for Module instances.
7778
/**
@@ -1340,9 +1341,55 @@ function loadESMFromCJS(mod, filename) {
13401341
// ESM won't be accessible via process.mainModule.
13411342
setOwnProperty(process, 'mainModule', undefined);
13421343
} else {
1343-
// TODO(joyeecheung): we may want to invent optional special handling for default exports here.
1344-
// For now, it's good enough to be identical to what `import()` returns.
1345-
mod.exports = cascadedLoader.importSyncForRequire(mod, filename, source, isMain, mod[kModuleParent]);
1344+
const {
1345+
wrap,
1346+
namespace,
1347+
} = cascadedLoader.importSyncForRequire(mod, filename, source, isMain, mod[kModuleParent]);
1348+
// Tooling in the ecosystem have been using the __esModule property to recognize
1349+
// transpiled ESM in consuming code. For example, a 'log' package written in ESM:
1350+
//
1351+
// export default function log(val) { console.log(val); }
1352+
//
1353+
// Can be transpiled as:
1354+
//
1355+
// exports.__esModule = true;
1356+
// exports.default = function log(val) { console.log(val); }
1357+
//
1358+
// The consuming code may be written like this in ESM:
1359+
//
1360+
// import log from 'log'
1361+
//
1362+
// Which gets transpiled to:
1363+
//
1364+
// const _mod = require('log');
1365+
// const log = _mod.__esModule ? _mod.default : _mod;
1366+
//
1367+
// So to allow transpiled consuming code to recognize require()'d real ESM
1368+
// as ESM and pick up the default exports, we add a __esModule property by
1369+
// building a source text module facade for any module that has a default
1370+
// export and add .__esModule = true to the exports. This maintains the
1371+
// enumerability of the re-exported names and the live binding of the exports,
1372+
// without incurring a non-trivial per-access overhead on the exports.
1373+
//
1374+
// The source of the facade is defined as a constant per-isolate property
1375+
// required_module_default_facade_source_string, which looks like this
1376+
//
1377+
// export * from 'original';
1378+
// export { default } from 'original';
1379+
// export const __esModule = true;
1380+
//
1381+
// And the 'original' module request is always resolved by
1382+
// createRequiredModuleFacade() to `wrap` which is a ModuleWrap wrapping
1383+
// over the original module.
1384+
1385+
// We don't do this to modules that don't have default exports to avoid
1386+
// the unnecessary overhead. If __esModule is already defined, we will
1387+
// also skip the extension to allow users to override it.
1388+
if (!ObjectHasOwn(namespace, 'default') || ObjectHasOwn(namespace, '__esModule')) {
1389+
mod.exports = namespace;
1390+
} else {
1391+
mod.exports = createRequiredModuleFacade(wrap);
1392+
}
13461393
}
13471394
}
13481395

lib/internal/modules/esm/loader.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ class ModuleLoader {
269269
* @param {string} source Source code. TODO(joyeecheung): pass the raw buffer.
270270
* @param {string} isMain Whether this module is a main module.
271271
* @param {CJSModule|undefined} parent Parent module, if any.
272-
* @returns {{ModuleWrap}}
272+
* @returns {{wrap: ModuleWrap, namespace: ModuleNamespaceObject}}
273273
*/
274274
importSyncForRequire(mod, filename, source, isMain, parent) {
275275
const url = pathToFileURL(filename).href;
@@ -294,7 +294,7 @@ class ModuleLoader {
294294
}
295295
throw new ERR_REQUIRE_CYCLE_MODULE(message);
296296
}
297-
return job.module.getNamespaceSync();
297+
return { wrap: job.module, namespace: job.module.getNamespaceSync() };
298298
}
299299
// TODO(joyeecheung): refactor this so that we pre-parse in C++ and hit the
300300
// cache here, or use a carrier object to carry the compiled module script
@@ -306,7 +306,7 @@ class ModuleLoader {
306306
job = new ModuleJobSync(this, url, kEmptyObject, wrap, isMain, inspectBrk);
307307
this.loadCache.set(url, kImplicitAssertType, job);
308308
mod[kRequiredModuleSymbol] = job.module;
309-
return job.runSync().namespace;
309+
return { wrap: job.module, namespace: job.runSync().namespace };
310310
}
311311

312312
/**

src/env.h

+2
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,8 @@ class Environment : public MemoryRetainer {
10611061
std::vector<std::string> supported_hash_algorithms;
10621062
#endif // HAVE_OPENSSL
10631063

1064+
v8::Global<v8::Module> temporary_required_module_facade_original;
1065+
10641066
private:
10651067
// V8 has changed the constructor of exceptions, support both APIs before Node
10661068
// updates to V8 12.1.

src/env_properties.h

+6
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@
252252
V(openssl_error_stack, "opensslErrorStack") \
253253
V(options_string, "options") \
254254
V(order_string, "order") \
255+
V(original_string, "original") \
255256
V(output_string, "output") \
256257
V(overlapped_string, "overlapped") \
257258
V(parse_error_string, "Parse Error") \
@@ -285,6 +286,11 @@
285286
V(regexp_string, "regexp") \
286287
V(rename_string, "rename") \
287288
V(replacement_string, "replacement") \
289+
V(required_module_facade_url_string, \
290+
"node:internal/require_module_default_facade") \
291+
V(required_module_facade_source_string, \
292+
"export * from 'original'; export { default } from 'original'; export " \
293+
"const __esModule = true;") \
288294
V(require_string, "require") \
289295
V(resource_string, "resource") \
290296
V(retry_string, "retry") \

src/module_wrap.cc

+70
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,70 @@ void ModuleWrap::CreateCachedData(const FunctionCallbackInfo<Value>& args) {
966966
}
967967
}
968968

969+
// This v8::Module::ResolveModuleCallback simply links `import 'original'`
970+
// to the env->temporary_required_module_facade_original() which is stashed
971+
// right before this callback is called and will be restored as soon as
972+
// v8::Module::Instantiate() returns.
973+
MaybeLocal<Module> LinkRequireFacadeWithOriginal(
974+
Local<Context> context,
975+
Local<String> specifier,
976+
Local<FixedArray> import_attributes,
977+
Local<Module> referrer) {
978+
Environment* env = Environment::GetCurrent(context);
979+
Isolate* isolate = context->GetIsolate();
980+
CHECK(specifier->Equals(context, env->original_string()).ToChecked());
981+
CHECK(!env->temporary_required_module_facade_original.IsEmpty());
982+
return env->temporary_required_module_facade_original.Get(isolate);
983+
}
984+
985+
// Wraps an existing source text module with a facade that adds
986+
// .__esModule = true to the exports.
987+
// See env->required_module_facade_source_string() for the source.
988+
void ModuleWrap::CreateRequiredModuleFacade(
989+
const FunctionCallbackInfo<Value>& args) {
990+
Isolate* isolate = args.GetIsolate();
991+
Local<Context> context = isolate->GetCurrentContext();
992+
Environment* env = Environment::GetCurrent(context);
993+
CHECK(args[0]->IsObject()); // original module
994+
Local<Object> wrap = args[0].As<Object>();
995+
ModuleWrap* original;
996+
ASSIGN_OR_RETURN_UNWRAP(&original, wrap);
997+
998+
// Use the same facade source and URL to hit the compilation cache.
999+
ScriptOrigin origin(isolate,
1000+
env->required_module_facade_url_string(),
1001+
0, // line offset
1002+
0, // column offset
1003+
true, // is cross origin
1004+
-1, // script id
1005+
Local<Value>(), // source map URL
1006+
false, // is opaque (?)
1007+
false, // is WASM
1008+
true); // is ES Module
1009+
ScriptCompiler::Source source(env->required_module_facade_source_string(),
1010+
origin);
1011+
1012+
// The module facade instantiation simply links `import 'original'` in the
1013+
// facade with the original module and should never fail.
1014+
Local<Module> facade =
1015+
ScriptCompiler::CompileModule(isolate, &source).ToLocalChecked();
1016+
// Stash the original module in temporary_required_module_facade_original
1017+
// for the LinkRequireFacadeWithOriginal() callback to pick it up.
1018+
CHECK(env->temporary_required_module_facade_original.IsEmpty());
1019+
env->temporary_required_module_facade_original.Reset(
1020+
isolate, original->module_.Get(isolate));
1021+
CHECK(facade->InstantiateModule(context, LinkRequireFacadeWithOriginal)
1022+
.IsJust());
1023+
env->temporary_required_module_facade_original.Reset();
1024+
1025+
// The evaluation of the facade is synchronous.
1026+
Local<Value> evaluated = facade->Evaluate(context).ToLocalChecked();
1027+
CHECK(evaluated->IsPromise());
1028+
CHECK_EQ(evaluated.As<Promise>()->State(), Promise::PromiseState::kFulfilled);
1029+
1030+
args.GetReturnValue().Set(facade->GetModuleNamespace());
1031+
}
1032+
9691033
void ModuleWrap::CreatePerIsolateProperties(IsolateData* isolate_data,
9701034
Local<ObjectTemplate> target) {
9711035
Isolate* isolate = isolate_data->isolate();
@@ -998,6 +1062,10 @@ void ModuleWrap::CreatePerIsolateProperties(IsolateData* isolate_data,
9981062
target,
9991063
"setInitializeImportMetaObjectCallback",
10001064
SetInitializeImportMetaObjectCallback);
1065+
SetMethod(isolate,
1066+
target,
1067+
"createRequiredModuleFacade",
1068+
CreateRequiredModuleFacade);
10011069
}
10021070

10031071
void ModuleWrap::CreatePerContextProperties(Local<Object> target,
@@ -1038,6 +1106,8 @@ void ModuleWrap::RegisterExternalReferences(
10381106
registry->Register(GetStatus);
10391107
registry->Register(GetError);
10401108

1109+
registry->Register(CreateRequiredModuleFacade);
1110+
10411111
registry->Register(SetImportModuleDynamicallyCallback);
10421112
registry->Register(SetInitializeImportMetaObjectCallback);
10431113
}

src/module_wrap.h

+3
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ class ModuleWrap : public BaseObject {
8787
std::optional<v8::ScriptCompiler::CachedData*> user_cached_data,
8888
bool* cache_rejected);
8989

90+
static void CreateRequiredModuleFacade(
91+
const v8::FunctionCallbackInfo<v8::Value>& args);
92+
9093
private:
9194
ModuleWrap(Realm* realm,
9295
v8::Local<v8::Object> object,

test/common/index.js

+7-2
Original file line numberDiff line numberDiff line change
@@ -927,9 +927,14 @@ function getPrintedStackTrace(stderr) {
927927
* @param {object} mod result returned by require()
928928
* @param {object} expectation shape of expected namespace.
929929
*/
930-
function expectRequiredModule(mod, expectation) {
930+
function expectRequiredModule(mod, expectation, checkESModule = true) {
931+
const clone = { ...mod };
932+
if (Object.hasOwn(mod, 'default') && checkESModule) {
933+
assert.strictEqual(mod.__esModule, true);
934+
delete clone.__esModule;
935+
}
931936
assert(isModuleNamespaceObject(mod));
932-
assert.deepStrictEqual({ ...mod }, { ...expectation });
937+
assert.deepStrictEqual(clone, { ...expectation });
933938
}
934939

935940
const common = {

test/es-module/test-require-module-default-extension.js

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
// Flags: --experimental-require-module
22
'use strict';
33

4-
require('../common');
4+
const { expectRequiredModule } = require('../common');
55
const assert = require('assert');
6-
const { isModuleNamespaceObject } = require('util/types');
76

87
const mod = require('../fixtures/es-modules/package-default-extension/index.mjs');
9-
assert.deepStrictEqual({ ...mod }, { entry: 'mjs' });
10-
assert(isModuleNamespaceObject(mod));
8+
expectRequiredModule(mod, { entry: 'mjs' });
119

1210
assert.throws(() => {
1311
const mod = require('../fixtures/es-modules/package-default-extension');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Flags: --experimental-require-module
2+
'use strict';
3+
const common = require('../common');
4+
5+
// If an ESM already defines __esModule to be something else,
6+
// require(esm) should allow the user override.
7+
{
8+
const mod = require('../fixtures/es-modules/export-es-module.mjs');
9+
common.expectRequiredModule(
10+
mod,
11+
{ default: { hello: 'world' }, __esModule: 'test' },
12+
false,
13+
);
14+
}
15+
16+
{
17+
const mod = require('../fixtures/es-modules/export-es-module-2.mjs');
18+
common.expectRequiredModule(
19+
mod,
20+
{ default: { hello: 'world' }, __esModule: false },
21+
false,
22+
);
23+
}

test/es-module/test-require-module-dynamic-import-1.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ const { pathToFileURL } = require('url');
2121
const url = pathToFileURL(path.resolve(__dirname, id));
2222
const imported = await import(url);
2323
const required = require(id);
24-
assert.strictEqual(imported, required,
25-
`import()'ed and require()'ed result of ${id} was not reference equal`);
24+
common.expectRequiredModule(required, imported);
2625
}
2726

2827
const id = '../fixtures/es-modules/data-import.mjs';

test/es-module/test-require-module-dynamic-import-2.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ const path = require('path');
2121
const url = pathToFileURL(path.resolve(__dirname, id));
2222
const required = require(id);
2323
const imported = await import(url);
24-
assert.strictEqual(imported, required,
25-
`import()'ed and require()'ed result of ${id} was not reference equal`);
24+
common.expectRequiredModule(required, imported);
2625
}
2726

2827
const id = '../fixtures/es-modules/data-import.mjs';

test/es-module/test-require-module-dynamic-import-3.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
// be loaded by dynamic import().
66

77
const common = require('../common');
8-
const assert = require('assert');
98

109
(async () => {
1110
const required = require('../fixtures/es-modules/require-and-import/load.cjs');
1211
const imported = await import('../fixtures/es-modules/require-and-import/load.mjs');
13-
assert.deepStrictEqual({ ...required }, { ...imported });
12+
common.expectRequiredModule(required, imported);
1413
})().then(common.mustCall());

test/es-module/test-require-module-dynamic-import-4.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
// be loaded by require().
66

77
const common = require('../common');
8-
const assert = require('assert');
98

109
(async () => {
1110
const imported = await import('../fixtures/es-modules/require-and-import/load.mjs');
1211
const required = require('../fixtures/es-modules/require-and-import/load.cjs');
13-
assert.deepStrictEqual({ ...required }, { ...imported });
12+
common.expectRequiredModule(required, imported);
1413
})().then(common.mustCall());

0 commit comments

Comments
 (0)