Skip to content

Commit

Permalink
module: add __esModule to require()'d ESM
Browse files Browse the repository at this point in the history
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: nodejs#52166
Refs: nodejs#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>
  • Loading branch information
joyeecheung authored and nodejs-github-bot committed Jul 11, 2024
1 parent 9f6dbfe commit e77aac2
Show file tree
Hide file tree
Showing 30 changed files with 315 additions and 55 deletions.
36 changes: 27 additions & 9 deletions doc/api/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,33 +188,51 @@ loaded by `require()` meets the following requirements:
`"type": "commonjs"`, and `--experimental-detect-module` is enabled.

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

With the following ES Modules:

```mjs
// point.mjs
// distance.mjs
export function distance(a, b) { return (b.x - a.x) ** 2 + (b.y - a.y) ** 2; }
```

```mjs
// point.mjs
class Point {
constructor(x, y) { this.x = x; this.y = y; }
}
export default Point;
```

A CommonJS module can load them with `require()` under `--experimental-detect-module`:

```cjs
const required = require('./point.mjs');
const distance = require('./distance.mjs');
console.log(distance);
// [Module: null prototype] {
// default: [class Point],
// distance: [Function: distance]
// }
console.log(required);

(async () => {
const imported = await import('./point.mjs');
console.log(imported === required); // true
})();
const point = require('./point.mjs');
console.log(point);
// [Module: null prototype] {
// default: [class Point],
// __esModule: true,
// }
```

For interoperability with existing tools that convert ES Modules into CommonJS,
which could then load real ES Modules through `require()`, the returned namespace
would contain a `__esModule: true` property if it has a `default` export so that
consuming code generated by tools can recognize the default exports in real
ES Modules. If the namespace already defines `__esModule`, this would not be added.
This property is experimental and can change in the future. It should only be used
by tools converting ES modules into CommonJS modules, following existing ecosystem
conventions. Code authored directly in CommonJS should avoid depending on it.

If the module being `require()`'d contains top-level `await`, or the module
graph it `import`s contains top-level `await`,
[`ERR_REQUIRE_ASYNC_MODULE`][] will be thrown. In this case, users should
Expand Down
55 changes: 51 additions & 4 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const {
ObjectFreeze,
ObjectGetOwnPropertyDescriptor,
ObjectGetPrototypeOf,
ObjectHasOwn,
ObjectKeys,
ObjectPrototype,
ObjectPrototypeHasOwnProperty,
Expand Down Expand Up @@ -71,7 +72,7 @@ const {
},
} = internalBinding('util');

const { kEvaluated } = internalBinding('module_wrap');
const { kEvaluated, createRequiredModuleFacade } = internalBinding('module_wrap');

// Internal properties for Module instances.
/**
Expand Down Expand Up @@ -1333,9 +1334,55 @@ function loadESMFromCJS(mod, filename) {
// ESM won't be accessible via process.mainModule.
setOwnProperty(process, 'mainModule', undefined);
} else {
// TODO(joyeecheung): we may want to invent optional special handling for default exports here.
// For now, it's good enough to be identical to what `import()` returns.
mod.exports = cascadedLoader.importSyncForRequire(mod, filename, source, isMain, mod[kModuleParent]);
const {
wrap,
namespace,
} = cascadedLoader.importSyncForRequire(mod, filename, source, isMain, mod[kModuleParent]);
// 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 default 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. This maintains the
// enumerability of the re-exported names and the live binding of the exports,
// without incurring a non-trivial per-access overhead on the exports.
//
// The source of the facade is defined as a constant per-isolate property
// required_module_default_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.

// We don't do this to modules that don't have default exports to avoid
// the unnecessary overhead. If __esModule is already defined, we will
// also skip the extension to allow users to override it.
if (!ObjectHasOwn(namespace, 'default') || ObjectHasOwn(namespace, '__esModule')) {
mod.exports = namespace;
} else {
mod.exports = createRequiredModuleFacade(wrap);
}
}
}

Expand Down
6 changes: 3 additions & 3 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ class ModuleLoader {
* @param {string} source Source code. TODO(joyeecheung): pass the raw buffer.
* @param {string} isMain Whether this module is a main module.
* @param {CJSModule|undefined} parent Parent module, if any.
* @returns {{ModuleWrap}}
* @returns {{wrap: ModuleWrap, namespace: ModuleNamespaceObject}}
*/
importSyncForRequire(mod, filename, source, isMain, parent) {
const url = pathToFileURL(filename).href;
Expand All @@ -305,7 +305,7 @@ class ModuleLoader {
}
throw new ERR_REQUIRE_CYCLE_MODULE(message);
}
return job.module.getNamespaceSync();
return { wrap: job.module, namespace: job.module.getNamespaceSync() };
}
// TODO(joyeecheung): refactor this so that we pre-parse in C++ and hit the
// cache here, or use a carrier object to carry the compiled module script
Expand All @@ -317,7 +317,7 @@ class ModuleLoader {
job = new ModuleJobSync(this, url, kEmptyObject, wrap, isMain, inspectBrk);
this.loadCache.set(url, kImplicitTypeAttribute, job);
mod[kRequiredModuleSymbol] = job.module;
return job.runSync().namespace;
return { wrap: job.module, namespace: job.runSync().namespace };
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/env.h
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,8 @@ class Environment : public MemoryRetainer {
std::vector<std::string> supported_hash_algorithms;
#endif // HAVE_OPENSSL

v8::Global<v8::Module> temporary_required_module_facade_original;

private:
inline void ThrowError(v8::Local<v8::Value> (*fun)(v8::Local<v8::String>,
v8::Local<v8::Value>),
Expand Down
6 changes: 6 additions & 0 deletions src/env_properties.h
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@
V(openssl_error_stack, "opensslErrorStack") \
V(options_string, "options") \
V(order_string, "order") \
V(original_string, "original") \
V(output_string, "output") \
V(overlapped_string, "overlapped") \
V(parse_error_string, "Parse Error") \
Expand Down Expand Up @@ -289,6 +290,11 @@
V(regexp_string, "regexp") \
V(rename_string, "rename") \
V(replacement_string, "replacement") \
V(required_module_facade_url_string, \
"node:internal/require_module_default_facade") \
V(required_module_facade_source_string, \
"export * from 'original'; export { default } from 'original'; export " \
"const __esModule = true;") \
V(require_string, "require") \
V(resource_string, "resource") \
V(retry_string, "retry") \
Expand Down
69 changes: 69 additions & 0 deletions src/module_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,69 @@ void ModuleWrap::CreateCachedData(const FunctionCallbackInfo<Value>& args) {
}
}

// This v8::Module::ResolveModuleCallback simply links `import 'original'`
// to the env->temporary_required_module_facade_original() which is stashed
// right before this callback is called and will be restored as soon as
// v8::Module::Instantiate() returns.
MaybeLocal<Module> LinkRequireFacadeWithOriginal(
Local<Context> context,
Local<String> specifier,
Local<FixedArray> import_attributes,
Local<Module> referrer) {
Environment* env = Environment::GetCurrent(context);
Isolate* isolate = context->GetIsolate();
CHECK(specifier->Equals(context, env->original_string()).ToChecked());
CHECK(!env->temporary_required_module_facade_original.IsEmpty());
return env->temporary_required_module_facade_original.Get(isolate);
}

// Wraps an existing source text module with a facade that adds
// .__esModule = true to the exports.
// See env->required_module_facade_source_string() for the source.
void ModuleWrap::CreateRequiredModuleFacade(
const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
Environment* env = Environment::GetCurrent(context);
CHECK(args[0]->IsObject()); // original module
Local<Object> wrap = args[0].As<Object>();
ModuleWrap* original;
ASSIGN_OR_RETURN_UNWRAP(&original, wrap);

// Use the same facade source and URL to hit the compilation cache.
ScriptOrigin origin(env->required_module_facade_url_string(),
0, // line offset
0, // column offset
true, // is cross origin
-1, // script id
Local<Value>(), // source map URL
false, // is opaque (?)
false, // is WASM
true); // is ES Module
ScriptCompiler::Source source(env->required_module_facade_source_string(),
origin);

// The module facade instantiation simply links `import 'original'` in the
// facade with the original module and should never fail.
Local<Module> facade =
ScriptCompiler::CompileModule(isolate, &source).ToLocalChecked();
// Stash the original module in temporary_required_module_facade_original
// for the LinkRequireFacadeWithOriginal() callback to pick it up.
CHECK(env->temporary_required_module_facade_original.IsEmpty());
env->temporary_required_module_facade_original.Reset(
isolate, original->module_.Get(isolate));
CHECK(facade->InstantiateModule(context, LinkRequireFacadeWithOriginal)
.IsJust());
env->temporary_required_module_facade_original.Reset();

// The evaluation of the facade is synchronous.
Local<Value> evaluated = facade->Evaluate(context).ToLocalChecked();
CHECK(evaluated->IsPromise());
CHECK_EQ(evaluated.As<Promise>()->State(), Promise::PromiseState::kFulfilled);

args.GetReturnValue().Set(facade->GetModuleNamespace());
}

void ModuleWrap::CreatePerIsolateProperties(IsolateData* isolate_data,
Local<ObjectTemplate> target) {
Isolate* isolate = isolate_data->isolate();
Expand Down Expand Up @@ -1051,6 +1114,10 @@ void ModuleWrap::CreatePerIsolateProperties(IsolateData* isolate_data,
target,
"setInitializeImportMetaObjectCallback",
SetInitializeImportMetaObjectCallback);
SetMethod(isolate,
target,
"createRequiredModuleFacade",
CreateRequiredModuleFacade);
}

void ModuleWrap::CreatePerContextProperties(Local<Object> target,
Expand Down Expand Up @@ -1091,6 +1158,8 @@ void ModuleWrap::RegisterExternalReferences(
registry->Register(GetStatus);
registry->Register(GetError);

registry->Register(CreateRequiredModuleFacade);

registry->Register(SetImportModuleDynamicallyCallback);
registry->Register(SetInitializeImportMetaObjectCallback);
}
Expand Down
3 changes: 3 additions & 0 deletions src/module_wrap.h
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ class ModuleWrap : public BaseObject {
std::optional<v8::ScriptCompiler::CachedData*> user_cached_data,
bool* cache_rejected);

static void CreateRequiredModuleFacade(
const v8::FunctionCallbackInfo<v8::Value>& args);

private:
ModuleWrap(Realm* realm,
v8::Local<v8::Object> object,
Expand Down
9 changes: 7 additions & 2 deletions test/common/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -964,9 +964,14 @@ function getPrintedStackTrace(stderr) {
* @param {object} mod result returned by require()
* @param {object} expectation shape of expected namespace.
*/
function expectRequiredModule(mod, expectation) {
function expectRequiredModule(mod, expectation, checkESModule = true) {
const clone = { ...mod };
if (Object.hasOwn(mod, 'default') && checkESModule) {
assert.strictEqual(mod.__esModule, true);
delete clone.__esModule;
}
assert(isModuleNamespaceObject(mod));
assert.deepStrictEqual({ ...mod }, { ...expectation });
assert.deepStrictEqual(clone, { ...expectation });
}

const common = {
Expand Down
6 changes: 2 additions & 4 deletions test/es-module/test-require-module-default-extension.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
// Flags: --experimental-require-module
'use strict';

require('../common');
const { expectRequiredModule } = require('../common');
const assert = require('assert');
const { isModuleNamespaceObject } = require('util/types');

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

assert.throws(() => {
const mod = require('../fixtures/es-modules/package-default-extension');
Expand Down
23 changes: 23 additions & 0 deletions test/es-module/test-require-module-defined-esmodule.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Flags: --experimental-require-module
'use strict';
const common = require('../common');

// If an ESM already defines __esModule to be something else,
// require(esm) should allow the user override.
{
const mod = require('../fixtures/es-modules/export-es-module.mjs');
common.expectRequiredModule(
mod,
{ default: { hello: 'world' }, __esModule: 'test' },
false,
);
}

{
const mod = require('../fixtures/es-modules/export-es-module-2.mjs');
common.expectRequiredModule(
mod,
{ default: { hello: 'world' }, __esModule: false },
false,
);
}
3 changes: 1 addition & 2 deletions test/es-module/test-require-module-dynamic-import-1.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ const { pathToFileURL } = require('url');
const url = pathToFileURL(path.resolve(__dirname, id));
const imported = await import(url);
const required = require(id);
assert.strictEqual(imported, required,
`import()'ed and require()'ed result of ${id} was not reference equal`);
common.expectRequiredModule(required, imported);
}

const id = '../fixtures/es-modules/data-import.mjs';
Expand Down
3 changes: 1 addition & 2 deletions test/es-module/test-require-module-dynamic-import-2.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ const path = require('path');
const url = pathToFileURL(path.resolve(__dirname, id));
const required = require(id);
const imported = await import(url);
assert.strictEqual(imported, required,
`import()'ed and require()'ed result of ${id} was not reference equal`);
common.expectRequiredModule(required, imported);
}

const id = '../fixtures/es-modules/data-import.mjs';
Expand Down
3 changes: 1 addition & 2 deletions test/es-module/test-require-module-dynamic-import-3.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
// be loaded by dynamic import().

const common = require('../common');
const assert = require('assert');

(async () => {
const required = require('../fixtures/es-modules/require-and-import/load.cjs');
const imported = await import('../fixtures/es-modules/require-and-import/load.mjs');
assert.deepStrictEqual({ ...required }, { ...imported });
common.expectRequiredModule(required, imported);
})().then(common.mustCall());
3 changes: 1 addition & 2 deletions test/es-module/test-require-module-dynamic-import-4.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
// be loaded by require().

const common = require('../common');
const assert = require('assert');

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

0 comments on commit e77aac2

Please sign in to comment.