Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

module: add __esModule to require()'d ESM #52166

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions benchmark/esm/require-esm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use strict';

const common = require('../common');
const fs = require('fs');
const tmpdir = require('../../test/common/tmpdir');
const path = require('path');
const assert = require('assert');

const bench = common.createBenchmark(main, {
type: ['all', 'access', 'load'],
exports: ['default', 'named'],
n: [1000],
}, {
flags: ['--experimental-require-module', '--no-warnings'],
});

function prepare(count, useDefault) {
tmpdir.refresh();
const dir = tmpdir.resolve('modules');
fs.mkdirSync(dir, { recursive: true });
let mainSource = '';
let useSource = 'exports.access = function() { return 0';
for (let i = 0; i < count; ++i) {
let modSource = `const value${i} = 1;\n`;
if (useDefault) {
modSource += `export default { value${i} }\n`;
} else {
modSource += `export { value${i} };\n`;
}
const filename = `mod${i}.mjs`;
fs.writeFileSync(
path.resolve(dir, filename),
modSource,
'utf8',
);
mainSource += `const mod${i} = require('./modules/${filename}');\n`;
if (useDefault) {
useSource += ` + mod${i}.default.value${i}`;
} else {
useSource += ` + mod${i}.value${i}`;
}
}
useSource += '; };\n';
const script = tmpdir.resolve('main.js');
fs.writeFileSync(script, mainSource + useSource, 'utf8');
return script;
}

function main({ n, exports, type }) {
const script = prepare(n, exports === 'default');
switch (type) {
case 'all': {
bench.start();
const result = require(script).access();
bench.end(n);
assert.strictEqual(result, n);
break;
}
case 'access': {
const { access } = require(script);
bench.start();
let result = access();
for (let i = 0; i < 99; ++i) {
result = access();
}
bench.end(n * 100);
assert.strictEqual(result, n);
break;
}
case 'load': {
bench.start();
const { access } = require(script);
bench.end(n);
const result = access();
assert.strictEqual(result, n);
break;
}
}
}
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
joyeecheung marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -1055,6 +1055,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
Loading
Loading