Skip to content

Commit

Permalink
module: support require()ing synchronous ESM graphs
Browse files Browse the repository at this point in the history
This patch adds `require()` support for synchronous ESM graphs under
the flag `--experimental-require-module`

This is based on the the following design aspect of ESM:

- The resolution can be synchronous (up to the host)
- The evaluation of a synchronous graph (without top-level await) is
  also synchronous, and, by the time the module graph is instantiated
  (before evaluation starts), this is is already known.

If `--experimental-require-module` is enabled, and the ECMAScript
module being loaded by `require()` meets the following requirements:

- Explicitly marked as an ES module with a `"type": "module"` field in
  the closest package.json or a `.mjs` extension.
- Fully synchronous (contains no top-level `await`).

`require()` will load the requested module as an ES Module, and return
the module name space object.

```mjs
// point.mjs
export function distance(a, b) {
  return (b.x - a.x) ** 2 + (b.y - a.y) ** 2;
}
class Point {
  constructor(x, y) { this.x = x; this.y = y; }
}
export default Point;
`
```

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

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.

If `--experimental-print-required-tla` is enabled, instead of throwing
`ERR_REQUIRE_ASYNC_MODULE` before evaluation, Node.js will evaluate the
module, try to locate the top-level awaits, and print their location to
help users fix them.
  • Loading branch information
joyeecheung committed Mar 11, 2024
1 parent 604f6f8 commit 80678e1
Show file tree
Hide file tree
Showing 35 changed files with 1,011 additions and 160 deletions.
27 changes: 27 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,18 @@ added: v11.8.0

Use the specified file as a security policy.

### `--experimental-require-module`

<!-- YAML
added: REPLACEME
-->

> Stability: 1.1 - Active Developement
Supports loading a synchronous ES module graph in `require()`.

See [Loading ECMAScript modules using `require()`][].

### `--experimental-sea-config`

<!-- YAML
Expand Down Expand Up @@ -1578,6 +1590,18 @@ changes:

Identical to `-e` but prints the result.

### `--experimental-print-required-tla`

<!-- YAML
added: REPLACEME
-->

This flag is only useful when `--experimental-require-module` is enabled.

If the ES module being `require()`'d contains top-level await, this flag
allows Node.js to evaluate the module, try to locate the
top-level awaits, and print their location to help users fix them.

### `--prof`

<!-- YAML
Expand Down Expand Up @@ -2512,6 +2536,8 @@ Node.js options that are allowed are:
* `--experimental-network-imports`
* `--experimental-permission`
* `--experimental-policy`
* `--experimental-print-required-tla`
* `--experimental-require-module`
* `--experimental-shadow-realm`
* `--experimental-specifier-resolution`
* `--experimental-top-level-await`
Expand Down Expand Up @@ -3016,6 +3042,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[ExperimentalWarning: `vm.measureMemory` is an experimental feature]: vm.md#vmmeasurememoryoptions
[Fetch API]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
[File System Permissions]: permissions.md#file-system-permissions
[Loading ECMAScript modules using `require()`]: modules.md#loading-ecmascript-modules-using-require
[Module customization hooks]: module.md#customization-hooks
[Module customization hooks: enabling]: module.md#enabling
[Modules loaders]: packages.md#modules-loaders
Expand Down
16 changes: 15 additions & 1 deletion doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -2487,14 +2487,28 @@ Accessing `Object.prototype.__proto__` has been forbidden using
[`Object.setPrototypeOf`][] should be used to get and set the prototype of an
object.

<a id="ERR_REQUIRE_ESM"></a>
<a id="ERR_REQUIRE_ASYNC_MODULE"></a>

### `ERR_REQUIRE_ASYNC_MODULE`

> Stability: 1 - Experimental
When trying to `require()` a [ES Module][] under `--experimental-require-module`,
the module turns out to be asynchronous. That is, it contains top-level await.

To see where the top-level await is, use
`--experimental-print-required-tla` (this would execute the modules
before looking for the top-level awaits). <a id="ERR_REQUIRE_ESM"></a>

### `ERR_REQUIRE_ESM`

> Stability: 1 - Experimental
An attempt was made to `require()` an [ES Module][].

To enable `require()` for synchronous module graphs (without
top-level `await`), use `--experimental-require-module`.

<a id="ERR_SCRIPT_EXECUTION_INTERRUPTED"></a>

### `ERR_SCRIPT_EXECUTION_INTERRUPTED`
Expand Down
51 changes: 44 additions & 7 deletions doc/api/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,16 +167,50 @@ variable. Since the module lookups using `node_modules` folders are all
relative, and based on the real path of the files making the calls to
`require()`, the packages themselves can be anywhere.

## The `.mjs` extension
## Loading ECMAScript modules using `require()`

Due to the synchronous nature of `require()`, it is not possible to use it to
load ECMAScript module files. Attempting to do so will throw a
[`ERR_REQUIRE_ESM`][] error. Use [`import()`][] instead.

The `.mjs` extension is reserved for [ECMAScript Modules][] which cannot be
loaded via `require()`. See [Determining module system][] section for more info
Currently, if the flag `--experimental-require-module` is not used, loading
an ECMAScript module using `require()` will throw a [`ERR_REQUIRE_ESM`][]
error, and users need to use [`import()`][] instead. See
[Determining module system][] section for more info
regarding which files are parsed as ECMAScript modules.

If `--experimental-require-module` is enabled, and the ECMAScript module being
loaded by `require()` meets the following requirements:

* Explicitly marked as an ES module with a `"type": "module"` field in
the closest package.json or a `.mjs` extension.
* Fully synchronous (contains no top-level `await`).

`require()` will load the requested module as an ES Module, and return
the module name space object.

```mjs
// point.mjs
export function distance(a, b) { return (b.x - a.x) ** 2 + (b.y - a.y) ** 2; }
class Point {
constructor(x, y) { this.x = x; this.y = y; }
}
export default Point;
```

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

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.

If `--experimental-print-required-tla` is enabled, instead of throwing
`ERR_REQUIRE_ASYNC_MODULE` before evaluation, Node.js will evaluate the
module, try to locate the top-level awaits, and print their location to
help users fix them.

## All together

<!-- type=misc -->
Expand All @@ -187,6 +221,8 @@ the `require.resolve()` function.
Putting together all of the above, here is the high-level algorithm
in pseudocode of what `require()` does:

<!-- TODO(joyeecheung): update this for --experimental-require-module-->

<pre>
require(X) from module at path Y
1. If X is a core module,
Expand Down Expand Up @@ -1085,6 +1121,7 @@ This section was moved to
[GLOBAL_FOLDERS]: #loading-from-the-global-folders
[`"main"`]: packages.md#main
[`"type"`]: packages.md#type
[`ERR_REQUIRE_ASYNC_MODULE`]: errors.md#err_require_async_module
[`ERR_REQUIRE_ESM`]: errors.md#err_require_esm
[`ERR_UNSUPPORTED_DIR_IMPORT`]: errors.md#err_unsupported_dir_import
[`MODULE_NOT_FOUND`]: errors.md#module_not_found
Expand Down
2 changes: 1 addition & 1 deletion doc/api/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -1371,7 +1371,7 @@ This field defines [subpath imports][] for the current package.
[entry points]: #package-entry-points
[folders as modules]: modules.md#folders-as-modules
[import maps]: https://github.com/WICG/import-maps
[load ECMASCript modules from CommonJS modules]: modules.md#the-mjs-extension
[load ECMASCript modules from CommonJS modules]: modules.md#loading-ecmascript-modules-using-require
[loader hooks]: esm.md#loaders
[packages folder mapping]: https://github.com/WICG/import-maps#packages-via-trailing-slashes
[self-reference]: #self-referencing-a-package-using-its-name
Expand Down
7 changes: 7 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ class NodeAggregateError extends AggregateError {
}

const assert = require('internal/assert');
const { getOptionValue } = require('internal/options');

// Lazily loaded
let util;
Expand Down Expand Up @@ -1686,12 +1687,16 @@ E('ERR_PERFORMANCE_MEASURE_INVALID_OPTIONS', '%s', TypeError);
E('ERR_REQUIRE_ESM',
function(filename, hasEsmSyntax, parentPath = null, packageJsonPath = null) {
hideInternalStackFrames(this);
assert(!getOptionValue('--experimental-require-module'));
let msg = `require() of ES Module ${filename}${parentPath ? ` from ${
parentPath}` : ''} not supported.`;
const hint = '\nOr use --experimental-require-module if the module is synchronous ' +
'(contains no top-level await)';
if (!packageJsonPath) {
if (StringPrototypeEndsWith(filename, '.mjs'))
msg += `\nInstead change the require of ${filename} to a dynamic ` +
'import() which is available in all CommonJS modules.';
msg += hint;
return msg;
}
const path = require('path');
Expand All @@ -1700,6 +1705,7 @@ E('ERR_REQUIRE_ESM',
if (hasEsmSyntax) {
msg += `\nInstead change the require of ${basename} in ${parentPath} to` +
' a dynamic import() which is available in all CommonJS modules.';
msg += hint;
return msg;
}
msg += `\n${basename} is treated as an ES module file as it is a .js ` +
Expand All @@ -1710,6 +1716,7 @@ E('ERR_REQUIRE_ESM',
'modules, or change "type": "module" to "type": "commonjs" in ' +
`${packageJsonPath} to treat all .js files as CommonJS (using .mjs for ` +
'all ES modules instead).\n';
msg += hint;
return msg;
}, Error);
E('ERR_SCRIPT_EXECUTION_INTERRUPTED',
Expand Down
Loading

0 comments on commit 80678e1

Please sign in to comment.