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

esm: implement import.meta.resolve and make nodejs: scheme public #31032

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ Enable experimental Source Map V3 support for stack traces.
Currently, overriding `Error.prepareStackTrace` is ignored when the
`--enable-source-maps` flag is set.

### `--experimental-import-meta-resolve`
<!-- YAML
added: REPLACEME
-->

Enable experimental `import.meta.resolve()` support.

### `--experimental-json-modules`
<!-- YAML
added: v12.9.0
Expand Down Expand Up @@ -1073,6 +1080,7 @@ Node.js options that are allowed are:
<!-- node-options-node start -->
* `--enable-fips`
* `--enable-source-maps`
* `--experimental-import-meta-resolve`
* `--experimental-json-modules`
* `--experimental-loader`
* `--experimental-modules`
Expand Down
50 changes: 42 additions & 8 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,32 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
```

### No `require.resolve`

Former use cases relying on `require.resolve` to determine the resolved path
of a module can be supported via `import.meta.resolve`, which is experimental
and supported via the `--experimental-import-meta-resolve` flag:

```js
(async () => {
const dependencyAsset = await import.meta.resolve('component-lib/asset.css');
})();
```

`import.meta.resolve` also accepts a second argument which is the parent module
ljharb marked this conversation as resolved.
Show resolved Hide resolved
from which to resolve from:

```js
(async () => {
// Equivalent to import.meta.resolve('./dep')
await import.meta.resolve('./dep', import.meta.url);
})();
```

This function is asynchronous since the ES module resolver in Node.js is
asynchronous. With the introduction of [Top-Level Await][], these use cases
will be easier as they won't require an async function wrapper.

### No `require.extensions`

`require.extensions` is not used by `import`. The expectation is that loader
Expand Down Expand Up @@ -1350,13 +1376,14 @@ The resolver has the following properties:

The algorithm to load an ES module specifier is given through the
**ESM_RESOLVE** method below. It returns the resolved URL for a
module specifier relative to a parentURL, in addition to the unique module
format for that resolved URL given by the **ESM_FORMAT** routine.
module specifier relative to a parentURL.

The _"module"_ format is returned for an ECMAScript Module, while the
_"commonjs"_ format is used to indicate loading through the legacy
CommonJS loader. Additional formats such as _"addon"_ can be extended in future
updates.
The algorithm to determine the module format of a resolved URL is
provided by **ESM_FORMAT**, which returns the unique module
format for any file. The _"module"_ format is returned for an ECMAScript
Module, while the _"commonjs"_ format is used to indicate loading through the
legacy CommonJS loader. Additional formats such as _"addon"_ can be extended in
future updates.

In the following algorithms, all subroutine errors are propagated as errors
of these top-level routines unless stated otherwise.
Expand Down Expand Up @@ -1385,11 +1412,13 @@ _defaultEnv_ is the conditional environment name priority array,
> 1. If _resolvedURL_ contains any percent encodings of _"/"_ or _"\\"_ (_"%2f"_
> and _"%5C"_ respectively), then
> 1. Throw an _Invalid Specifier_ error.
> 1. If the file at _resolvedURL_ does not exist, then
> 1. If _resolvedURL_ does not end with a trailing _"/"_ and the file at
> _resolvedURL_ does not exist, then
> 1. Throw a _Module Not Found_ error.
> 1. Set _resolvedURL_ to the real path of _resolvedURL_.
> 1. Let _format_ be the result of **ESM_FORMAT**(_resolvedURL_).
> 1. Load _resolvedURL_ as module format, _format_.
> 1. Return _resolvedURL_.

**PACKAGE_RESOLVE**(_packageSpecifier_, _parentURL_)

Expand Down Expand Up @@ -1417,7 +1446,7 @@ _defaultEnv_ is the conditional environment name priority array,
> 1. If _selfUrl_ isn't empty, return _selfUrl_.
> 1. If _packageSubpath_ is _undefined_ and _packageName_ is a Node.js builtin
> module, then
> 1. Return the string _"node:"_ concatenated with _packageSpecifier_.
> 1. Return the string _"nodejs:"_ concatenated with _packageSpecifier_.
> 1. While _parentURL_ is not the file system root,
> 1. Let _packageURL_ be the URL resolution of _"node_modules/"_
> concatenated with _packageSpecifier_, relative to _parentURL_.
Expand All @@ -1426,6 +1455,8 @@ _defaultEnv_ is the conditional environment name priority array,
> 1. Set _parentURL_ to the parent URL path of _parentURL_.
> 1. Continue the next loop iteration.
> 1. Let _pjson_ be the result of **READ_PACKAGE_JSON**(_packageURL_).
> 1. If _packageSubpath_ is equal to _"./"_, then
> 1. Return _packageURL_ + _"/"_.
> 1. If _packageSubpath_ is _undefined__, then
> 1. Return the result of **PACKAGE_MAIN_RESOLVE**(_packageURL_,
> _pjson_).
Expand All @@ -1447,6 +1478,8 @@ _defaultEnv_ is the conditional environment name priority array,
> 1. If _pjson_ does not include an _"exports"_ property, then
> 1. Return **undefined**.
> 1. If _pjson.name_ is equal to _packageName_, then
> 1. If _packageSubpath_ is equal to _"./"_, then
> 1. Return _packageURL_ + _"/"_.
> 1. If _packageSubpath_ is _undefined_, then
> 1. Return the result of **PACKAGE_MAIN_RESOLVE**(_packageURL_, _pjson_).
> 1. Otherwise,
Expand Down Expand Up @@ -1625,3 +1658,4 @@ success!
[the official standard format]: https://tc39.github.io/ecma262/#sec-modules
[transpiler loader example]: #esm_transpiler_loader
[6.1.7 Array Index]: https://tc39.es/ecma262/#integer-index
[Top-Level Await]: https://github.com/tc39/proposal-top-level-await
3 changes: 3 additions & 0 deletions doc/node.1
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ Requires Node.js to be built with
.It Fl -enable-source-maps
Enable experimental Source Map V3 support for stack traces.
.
.It Fl -experimental-import-meta-resolve
Enable experimental ES modules support for import.meta.resolve().
.
.It Fl -experimental-json-modules
Enable experimental JSON interop support for the ES Module loader.
.
Expand Down
4 changes: 2 additions & 2 deletions lib/internal/modules/esm/get_format.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use strict';

const { NativeModule } = require('internal/bootstrap/loaders');
const { extname } = require('path');
const { getOptionValue } = require('internal/options');

Expand Down Expand Up @@ -39,7 +38,7 @@ if (experimentalJsonModules)
extensionFormatMap['.json'] = legacyExtensionFormatMap['.json'] = 'json';

function defaultGetFormat(url, context, defaultGetFormat) {
if (NativeModule.canBeRequiredByUsers(url)) {
if (url.startsWith('nodejs:')) {
return { format: 'builtin' };
}
const parsed = new URL(url);
Expand Down Expand Up @@ -73,5 +72,6 @@ function defaultGetFormat(url, context, defaultGetFormat) {
}
return { format: format || null };
}
return { format: null };
}
exports.defaultGetFormat = defaultGetFormat;
10 changes: 7 additions & 3 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ class Loader {
throw new ERR_INVALID_RETURN_PROPERTY_VALUE(
'string', 'loader resolve', 'url', url);
}
return url;
}

async getFormat(url) {
const getFormatResponse = await this._getFormat(
url, {}, defaultGetFormat);
if (typeof getFormatResponse !== 'object') {
Expand All @@ -109,7 +112,7 @@ class Loader {
}

if (format === 'builtin') {
return { url: `node:${url}`, format };
return format;
}

if (this._resolve !== defaultResolve) {
Expand All @@ -132,7 +135,7 @@ class Loader {
);
}

return { url, format };
return format;
}

async eval(
Expand Down Expand Up @@ -185,7 +188,8 @@ class Loader {
}

async getModuleJob(specifier, parentURL) {
const { url, format } = await this.resolve(specifier, parentURL);
const url = await this.resolve(specifier, parentURL);
const format = await this.getFormat(url);
let job = this.moduleMap.get(url);
// CommonJS will set functions for lazy job evaluation.
if (typeof job === 'function')
Expand Down
10 changes: 7 additions & 3 deletions lib/internal/modules/esm/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const internalFS = require('internal/fs/utils');
const { NativeModule } = require('internal/bootstrap/loaders');
const { realpathSync } = require('fs');
const { getOptionValue } = require('internal/options');
const { sep } = require('path');

const preserveSymlinks = getOptionValue('--preserve-symlinks');
const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main');
Expand All @@ -29,11 +30,13 @@ function defaultResolve(specifier, { parentURL } = {}, defaultResolve) {
};
}
} catch {}
if (parsed && parsed.protocol === 'nodejs:')
return { url: specifier };
if (parsed && parsed.protocol !== 'file:' && parsed.protocol !== 'data:')
throw new ERR_UNSUPPORTED_ESM_URL_SCHEME();
if (NativeModule.canBeRequiredByUsers(specifier)) {
return {
url: specifier
url: 'nodejs:' + specifier
};
}
if (parentURL && parentURL.startsWith('data:')) {
Expand All @@ -58,11 +61,12 @@ function defaultResolve(specifier, { parentURL } = {}, defaultResolve) {
let url = moduleWrapResolve(specifier, parentURL);

if (isMain ? !preserveSymlinksMain : !preserveSymlinks) {
const real = realpathSync(fileURLToPath(url), {
const urlPath = fileURLToPath(url);
const real = realpathSync(urlPath, {
[internalFS.realpathCacheKey]: realpathCache
});
const old = url;
url = pathToFileURL(real);
url = pathToFileURL(real + (urlPath.endsWith(sep) ? '/' : ''));
url.search = old.search;
url.hash = old.hash;
}
Expand Down
33 changes: 24 additions & 9 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const { ERR_UNKNOWN_BUILTIN_MODULE } = require('internal/errors').codes;
const { maybeCacheSourceMap } = require('internal/source_map/source_map_cache');
const moduleWrap = internalBinding('module_wrap');
const { ModuleWrap } = moduleWrap;
const { getOptionValue } = require('internal/options');
const experimentalImportMetaResolve =
getOptionValue('--experimental-import-meta-resolve');

const debug = debuglog('esm');

Expand All @@ -42,16 +45,28 @@ function errPath(url) {
return url;
}

function initializeImportMeta(meta, { url }) {
meta.url = url;
}

let esmLoader;
async function importModuleDynamically(specifier, { url }) {
if (!esmLoader) {
esmLoader = require('internal/process/esm_loader');
esmLoader = require('internal/process/esm_loader').ESMLoader;
}
return esmLoader.ESMLoader.import(specifier, url);
return esmLoader.import(specifier, url);
}

function createImportMetaResolve(defaultParentUrl) {
return async function resolve(specifier, parentUrl = defaultParentUrl) {
if (!esmLoader) {
esmLoader = require('internal/process/esm_loader').ESMLoader;
}
return esmLoader.resolve(specifier, parentUrl);
};
}

function initializeImportMeta(meta, { url }) {
// Alphabetical
if (experimentalImportMetaResolve)
meta.resolve = createImportMetaResolve(url);
meta.url = url;
}

// Strategy for loading a standard JavaScript module
Expand Down Expand Up @@ -104,10 +119,10 @@ translators.set('commonjs', function commonjsStrategy(url, isMain) {
// through normal resolution
translators.set('builtin', async function builtinStrategy(url) {
debug(`Translating BuiltinModule ${url}`);
// Slice 'node:' scheme
const id = url.slice(5);
// Slice 'nodejs:' scheme
const id = url.slice(7);
const module = loadNativeModule(id, url, true);
if (!module) {
if (!url.startsWith('nodejs:') || !module) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(id);
}
debug(`Loading BuiltinModule ${url}`);
Expand Down
15 changes: 11 additions & 4 deletions src/module_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,10 @@ Maybe<URL> FinalizeResolution(Environment* env,
return Nothing<URL>();
}

if (resolved.path().back() == '/') {
return Just(resolved);
}

const std::string& path = resolved.ToFilePath();
if (CheckDescriptorAtPath(path) != FILE) {
std::string msg = "Cannot find module " +
Expand Down Expand Up @@ -1221,7 +1225,9 @@ Maybe<URL> ResolveSelf(Environment* env,
}
if (!found_pjson || pcfg->name != pkg_name) return Nothing<URL>();
if (pcfg->exports.IsEmpty()) return Nothing<URL>();
if (!pkg_subpath.length()) {
if (pkg_subpath == "./") {
return Just(URL("./", pjson_url));
} else if (!pkg_subpath.length()) {
return PackageMainResolve(env, pjson_url, *pcfg, base);
} else {
return PackageExportsResolve(env, pjson_url, pkg_subpath, *pcfg, base);
Expand Down Expand Up @@ -1265,8 +1271,7 @@ Maybe<URL> PackageResolve(Environment* env,
return Nothing<URL>();
}
std::string pkg_subpath;
if ((sep_index == std::string::npos ||
sep_index == specifier.length() - 1)) {
if (sep_index == std::string::npos) {
pkg_subpath = "";
} else {
pkg_subpath = "." + specifier.substr(sep_index);
Expand Down Expand Up @@ -1297,7 +1302,9 @@ Maybe<URL> PackageResolve(Environment* env,
Maybe<const PackageConfig*> pcfg = GetPackageConfig(env, pjson_path, base);
// Invalid package configuration error.
if (pcfg.IsNothing()) return Nothing<URL>();
if (!pkg_subpath.length()) {
if (pkg_subpath == "./") {
return Just(URL("./", pjson_url));
} else if (!pkg_subpath.length()) {
return PackageMainResolve(env, pjson_url, *pcfg.FromJust(), base);
} else {
if (!pcfg.FromJust()->exports.IsEmpty()) {
Expand Down
4 changes: 4 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
"experimental ES Module support for webassembly modules",
&EnvironmentOptions::experimental_wasm_modules,
kAllowedInEnvironment);
AddOption("--experimental-import-meta-resolve",
"experimental ES Module import.meta.resolve() support",
&EnvironmentOptions::experimental_import_meta_resolve,
kAllowedInEnvironment);
AddOption("--experimental-policy",
"use the specified file as a "
"security policy",
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class EnvironmentOptions : public Options {
std::string experimental_specifier_resolution;
std::string es_module_specifier_resolution;
bool experimental_wasm_modules = false;
bool experimental_import_meta_resolve = false;
std::string module_type;
std::string experimental_policy;
std::string experimental_policy_integrity;
Expand Down
5 changes: 3 additions & 2 deletions test/es-module/test-esm-dynamic-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,12 @@ function expectFsNamespace(result) {
expectFsNamespace(import('fs'));
expectFsNamespace(eval('import("fs")'));
expectFsNamespace(eval('import("fs")'));
expectFsNamespace(import('nodejs:fs'));

expectModuleError(import('nodejs:unknown'),
'ERR_UNKNOWN_BUILTIN_MODULE');
expectModuleError(import('./not-an-existing-module.mjs'),
'ERR_MODULE_NOT_FOUND');
expectModuleError(import('node:fs'),
'ERR_UNSUPPORTED_ESM_URL_SCHEME');
expectModuleError(import('http://example.com/foo.js'),
'ERR_UNSUPPORTED_ESM_URL_SCHEME');
})();
24 changes: 24 additions & 0 deletions test/es-module/test-esm-import-meta-resolve.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Flags: --experimental-import-meta-resolve
import '../common/index.mjs';
import assert from 'assert';

const dirname = import.meta.url.slice(0, import.meta.url.lastIndexOf('/') + 1);
const fixtures = dirname.slice(0, dirname.lastIndexOf('/', dirname.length - 2) +
1) + 'fixtures/';

(async () => {
assert.strictEqual(await import.meta.resolve('./test-esm-import-meta.mjs'),
dirname + 'test-esm-import-meta.mjs');
try {
await import.meta.resolve('./notfound.mjs');
assert.fail();
} catch (e) {
assert.strictEqual(e.code, 'ERR_MODULE_NOT_FOUND');
}
assert.strictEqual(
await import.meta.resolve('../fixtures/empty-with-bom.txt'),
fixtures + 'empty-with-bom.txt');
assert.strictEqual(await import.meta.resolve('../fixtures/'), fixtures);
assert.strictEqual(await import.meta.resolve('baz/', fixtures),
fixtures + 'node_modules/baz/');
})();
Loading