diff --git a/doc/api/errors.md b/doc/api/errors.md
index 50e9f658fcbf3a..f9ed5a82058345 100644
--- a/doc/api/errors.md
+++ b/doc/api/errors.md
@@ -1947,6 +1947,13 @@ for more information.
An invalid HTTP token was supplied.
+
+
+### `ERR_INVALID_IMPORT_MAP`
+
+An invalid import map file was supplied. This error can throw for a variety
+of conditions which will change the error message for added context.
+
### `ERR_INVALID_IP_ADDRESS`
diff --git a/lib/internal/errors.js b/lib/internal/errors.js
index 363b9d3bb8fe8b..c7fbd4aa7e680c 100644
--- a/lib/internal/errors.js
+++ b/lib/internal/errors.js
@@ -1415,6 +1415,7 @@ E('ERR_INVALID_FILE_URL_HOST',
E('ERR_INVALID_FILE_URL_PATH', 'File URL path %s', TypeError);
E('ERR_INVALID_HANDLE_TYPE', 'This handle type cannot be sent', TypeError);
E('ERR_INVALID_HTTP_TOKEN', '%s must be a valid HTTP token ["%s"]', TypeError);
+E('ERR_INVALID_IMPORT_MAP', 'Invalid import map: %s', Error);
E('ERR_INVALID_IP_ADDRESS', 'Invalid IP address: %s', TypeError);
E('ERR_INVALID_MIME_SYNTAX', (production, str, invalidIndex) => {
const msg = invalidIndex !== -1 ? ` at ${invalidIndex}` : '';
diff --git a/lib/internal/modules/esm/import_map.js b/lib/internal/modules/esm/import_map.js
new file mode 100644
index 00000000000000..e8829f25db60de
--- /dev/null
+++ b/lib/internal/modules/esm/import_map.js
@@ -0,0 +1,107 @@
+'use strict';
+const { isURL, URL } = require('internal/url');
+const { ObjectEntries, ObjectKeys, SafeMap, ArrayIsArray } = primordials;
+const { codes: { ERR_INVALID_IMPORT_MAP } } = require('internal/errors');
+
+class ImportMap {
+ #baseURL;
+ imports = new SafeMap();
+ scopes = new SafeMap();
+
+ constructor(raw, baseURL) {
+ this.#baseURL = baseURL;
+ processImportMap(this, this.#baseURL, raw);
+ }
+
+ get baseURL() {
+ return this.#baseURL;
+ }
+
+ resolve(specifier, parentURL = this.baseURL) {
+ // Process scopes
+ for (const { 0: prefix, 1: mapping } of this.scopes) {
+ let mappedSpecifier = mapping.get(specifier);
+ if (parentURL.pathname.startsWith(prefix.pathname) && mappedSpecifier) {
+ if (!isURL(mappedSpecifier)) {
+ mappedSpecifier = new URL(mappedSpecifier, this.baseURL);
+ mapping.set(specifier, mappedSpecifier);
+ }
+ specifier = mappedSpecifier;
+ break;
+ }
+ }
+
+ let spec = specifier;
+ if (isURL(specifier)) {
+ spec = specifier.pathname;
+ }
+ let importMapping = this.imports.get(spec);
+ if (importMapping) {
+ if (!isURL(importMapping)) {
+ importMapping = new URL(importMapping, this.baseURL);
+ this.imports.set(spec, importMapping);
+ }
+ return importMapping;
+ }
+
+ return specifier;
+ }
+}
+
+function processImportMap(importMap, baseURL, raw) {
+ // Validation and normalization
+ if (typeof raw.imports !== 'object' || ArrayIsArray(raw.imports)) {
+ throw new ERR_INVALID_IMPORT_MAP('top level key "imports" is required and must be a plain object');
+ }
+ if (typeof raw.scopes !== 'object' || ArrayIsArray(raw.scopes)) {
+ throw new ERR_INVALID_IMPORT_MAP('top level key "scopes" is required and must be a plain object');
+ }
+
+ // Normalize imports
+ for (const { 0: specifier, 1: mapping } of ObjectEntries(raw.imports)) {
+ if (!specifier || typeof specifier !== 'string') {
+ throw new ERR_INVALID_IMPORT_MAP('module specifier keys must be non-empty strings');
+ }
+ if (!mapping || typeof mapping !== 'string') {
+ throw new ERR_INVALID_IMPORT_MAP('module specifier values must be non-empty strings');
+ }
+ if (specifier.endsWith('/') && !mapping.endsWith('/')) {
+ throw new ERR_INVALID_IMPORT_MAP('module specifier values for keys ending with / must also end with /');
+ }
+
+ importMap.imports.set(specifier, mapping);
+ }
+
+ // Normalize scopes
+ // Sort the keys according to spec and add to the map in order
+ // which preserves the sorted map requirement
+ const sortedScopes = ObjectKeys(raw.scopes).sort().reverse();
+ for (let scope of sortedScopes) {
+ const _scopeMap = raw.scopes[scope];
+ if (!scope || typeof scope !== 'string') {
+ throw new ERR_INVALID_IMPORT_MAP('import map scopes keys must be non-empty strings');
+ }
+ if (!_scopeMap || typeof _scopeMap !== 'object') {
+ throw new ERR_INVALID_IMPORT_MAP(`scope values must be plain objects (${scope} is ${typeof _scopeMap})`);
+ }
+
+ // Normalize scope
+ scope = new URL(scope, baseURL);
+
+ const scopeMap = new SafeMap();
+ for (const { 0: specifier, 1: mapping } of ObjectEntries(_scopeMap)) {
+ if (specifier.endsWith('/') && !mapping.endsWith('/')) {
+ throw new ERR_INVALID_IMPORT_MAP('module specifier values for keys ending with / must also end with /');
+ }
+ scopeMap.set(specifier, mapping);
+ }
+
+ importMap.scopes.set(scope, scopeMap);
+ }
+
+ return importMap;
+}
+
+module.exports = {
+ ImportMap,
+};
diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js
index 6044765c3709f5..3cc384066ca944 100644
--- a/lib/internal/modules/esm/loader.js
+++ b/lib/internal/modules/esm/loader.js
@@ -129,6 +129,11 @@ class ModuleLoader {
*/
#customizations;
+ /**
+ * The loaders importMap instance
+ */
+ importMap;
+
constructor(customizations) {
if (getOptionValue('--experimental-network-imports')) {
emitExperimentalWarning('Network Imports');
@@ -391,6 +396,7 @@ class ModuleLoader {
conditions: this.#defaultConditions,
importAttributes,
parentURL,
+ importMap: this.importMap,
};
return defaultResolve(originalSpecifier, context);
diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js
index 06a34c11254a2f..7e06e8714d2aa8 100644
--- a/lib/internal/modules/esm/resolve.js
+++ b/lib/internal/modules/esm/resolve.js
@@ -1026,6 +1026,35 @@ function throwIfInvalidParentURL(parentURL) {
}
}
+/**
+ * Process policy
+ */
+function processPolicy(specifier, context) {
+ const { parentURL, conditions } = context;
+ const redirects = policy.manifest.getDependencyMapper(parentURL);
+ if (redirects) {
+ const { resolve, reaction } = redirects;
+ const destination = resolve(specifier, new SafeSet(conditions));
+ let missing = true;
+ if (destination === true) {
+ missing = false;
+ } else if (destination) {
+ const href = destination.href;
+ return { __proto__: null, url: href };
+ }
+ if (missing) {
+ // Prevent network requests from firing if resolution would be banned.
+ // Network requests can extract data by doing things like putting
+ // secrets in query params
+ reaction(new ERR_MANIFEST_DEPENDENCY_MISSING(
+ parentURL,
+ specifier,
+ ArrayPrototypeJoin([...conditions], ', ')),
+ );
+ }
+ }
+}
+
/**
* Resolves the given specifier using the provided context, which includes the parent URL and conditions.
* Throws an error if the parent URL is invalid or if the resolution is disallowed by the policy manifest.
@@ -1037,31 +1066,8 @@ function throwIfInvalidParentURL(parentURL) {
*/
function defaultResolve(specifier, context = {}) {
let { parentURL, conditions } = context;
+ const { importMap } = context;
throwIfInvalidParentURL(parentURL);
- if (parentURL && policy?.manifest) {
- const redirects = policy.manifest.getDependencyMapper(parentURL);
- if (redirects) {
- const { resolve, reaction } = redirects;
- const destination = resolve(specifier, new SafeSet(conditions));
- let missing = true;
- if (destination === true) {
- missing = false;
- } else if (destination) {
- const href = destination.href;
- return { __proto__: null, url: href };
- }
- if (missing) {
- // Prevent network requests from firing if resolution would be banned.
- // Network requests can extract data by doing things like putting
- // secrets in query params
- reaction(new ERR_MANIFEST_DEPENDENCY_MISSING(
- parentURL,
- specifier,
- ArrayPrototypeJoin([...conditions], ', ')),
- );
- }
- }
- }
let parsedParentURL;
if (parentURL) {
@@ -1079,8 +1085,19 @@ function defaultResolve(specifier, context = {}) {
} else {
parsed = new URL(specifier);
}
+ } catch {
+ // Ignore exception
+ }
- // Avoid accessing the `protocol` property due to the lazy getters.
+ // Import maps are processed before policies and data/http handling
+ // so policies apply to the result of any mapping
+ if (importMap) {
+ // Intentionally mutating here as we don't think it is a problem
+ parsed = specifier = importMap.resolve(parsed || specifier, parsedParentURL);
+ }
+
+ // Avoid accessing the `protocol` property due to the lazy getters.
+ if (parsed) {
const protocol = parsed.protocol;
if (protocol === 'data:' ||
(experimentalNetworkImports &&
@@ -1092,8 +1109,13 @@ function defaultResolve(specifier, context = {}) {
) {
return { __proto__: null, url: parsed.href };
}
- } catch {
- // Ignore exception
+ }
+
+ if (parentURL && policy?.manifest) {
+ const policyResolution = processPolicy(specifier, context);
+ if (policyResolution) {
+ return policyResolution;
+ }
}
// There are multiple deep branches that can either throw or return; instead
diff --git a/lib/internal/modules/run_main.js b/lib/internal/modules/run_main.js
index 1f03c313121db0..60653600106283 100644
--- a/lib/internal/modules/run_main.js
+++ b/lib/internal/modules/run_main.js
@@ -51,6 +51,7 @@ function resolveMainPath(main) {
*/
function shouldUseESMLoader(mainPath) {
if (getOptionValue('--experimental-default-type') === 'module') { return true; }
+ if (getOptionValue('--experimental-import-map')) { return true; }
/**
* @type {string[]} userLoaders A list of custom loaders registered by the user
@@ -92,10 +93,24 @@ function shouldUseESMLoader(mainPath) {
*/
function runMainESM(mainPath) {
const { loadESM } = require('internal/process/esm_loader');
- const { pathToFileURL } = require('internal/url');
+ const { pathToFileURL, URL } = require('internal/url');
+ const _importMapPath = getOptionValue('--experimental-import-map');
const main = pathToFileURL(mainPath).href;
handleMainPromise(loadESM((esmLoader) => {
+ // Load import map and throw validation errors
+ if (_importMapPath) {
+ const { ImportMap } = require('internal/modules/esm/import_map');
+ const { getCWDURL } = require('internal/util');
+
+ const importMapPath = esmLoader.resolve(_importMapPath, getCWDURL(), { __proto__: null, type: 'json' });
+ return esmLoader.import(importMapPath.url, getCWDURL(), { __proto__: null, type: 'json' })
+ .then((importedMapFile) => {
+ esmLoader.importMap = new ImportMap(importedMapFile.default, new URL(importMapPath.url));
+ return esmLoader.import(main, undefined, { __proto__: null });
+ });
+ }
+
return esmLoader.import(main, undefined, { __proto__: null });
}));
}
diff --git a/src/node_options.cc b/src/node_options.cc
index 29cb7fc6b29b89..461c66e49ea3d0 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -546,6 +546,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
&EnvironmentOptions::prof_process);
// Options after --prof-process are passed through to the prof processor.
AddAlias("--prof-process", { "--prof-process", "--" });
+ AddOption("--experimental-import-map",
+ "set the path to an import map.json",
+ &EnvironmentOptions::import_map_path,
+ kAllowedInEnvvar);
#if HAVE_INSPECTOR
AddOption("--cpu-prof",
"Start the V8 CPU profiler on start up, and write the CPU profile "
diff --git a/src/node_options.h b/src/node_options.h
index 30955c779714ce..32082b1a15af4d 100644
--- a/src/node_options.h
+++ b/src/node_options.h
@@ -179,6 +179,7 @@ class EnvironmentOptions : public Options {
bool extra_info_on_fatal_exception = true;
std::string unhandled_rejections;
std::vector userland_loaders;
+ std::string import_map_path;
bool verify_base_objects =
#ifdef DEBUG
true;
diff --git a/test/es-module/test-importmap.mjs b/test/es-module/test-importmap.mjs
new file mode 100644
index 00000000000000..9241b43651ccbc
--- /dev/null
+++ b/test/es-module/test-importmap.mjs
@@ -0,0 +1,203 @@
+// Flags: --expose-internals
+
+import { spawnPromisified } from '../common/index.mjs';
+import fixtures from '../common/fixtures.js';
+import tmpdir from '../common/tmpdir.js';
+import { describe, it } from 'node:test';
+import assert from 'node:assert';
+import path from 'node:path';
+import { execPath } from 'node:process';
+import { pathToFileURL } from 'node:url';
+import { writeFile } from 'node:fs/promises';
+import http from 'node:http';
+import import_map from 'internal/modules/esm/import_map';
+const { ImportMap } = import_map;
+import binding from 'internal/test/binding';
+const { primordials: { SafeMap, JSONStringify } } = binding;
+
+const importMapFixtureRoot = fixtures.path('es-module-loaders', 'importmaps');
+const entryPoint = pathToFileURL(path.resolve(importMapFixtureRoot, 'index.mjs'));
+const getImportMapPathURL = (name) => {
+ return pathToFileURL(path.resolve(importMapFixtureRoot, name + '.json'));
+};
+const getImportMap = async (name) => {
+ const url = getImportMapPathURL(name);
+ const rawMap = await import(url, { with: { type: 'json' } });
+ return new ImportMap(rawMap.default, url);
+};
+
+describe('Import Maps', { concurrency: true }, () => {
+ tmpdir.refresh();
+ it('processImportMap - simple importmap', async () => {
+ const importMap = await getImportMap('simple');
+ assert.deepStrictEqual(importMap.imports, new SafeMap(Object.entries({
+ foo: './node_modules/foo/index.mjs'
+ })));
+ const expectedScopes = new SafeMap();
+ const fooScopeKey = new URL(importMap.baseURL, pathToFileURL('node_modules/foo'));
+ const fooScopeMap = new SafeMap(Object.entries({
+ bar: './baz.mjs'
+ }));
+ expectedScopes.set(fooScopeKey, fooScopeMap);
+ assert.deepStrictEqual(importMap.scopes, expectedScopes);
+ });
+
+ it('processImportMap - invalid importmap', async () => {
+ assert.rejects(
+ getImportMap('invalid'),
+ /^Error \[ERR_INVALID_IMPORT_MAP\]: Invalid import map: top level key "imports" is required and must be a plain object$/
+ );
+ assert.rejects(
+ getImportMap('missing-scopes'),
+ /^Error \[ERR_INVALID_IMPORT_MAP\]: Invalid import map: top level key "scopes" is required and must be a plain object$/
+ );
+ assert.rejects(
+ getImportMap('array-imports'),
+ /^Error \[ERR_INVALID_IMPORT_MAP\]: Invalid import map: top level key "imports" is required and must be a plain object$/
+ );
+ });
+
+ it('resolve - empty importmap', async () => {
+ const importMap = await getImportMap('empty');
+ const spec = importMap.resolve('test');
+ assert.strictEqual(spec, 'test');
+ });
+
+ it('resolve - simple importmap', async () => {
+ const importMap = await getImportMap('simple');
+ assert.strictEqual(
+ importMap.resolve('foo').pathname,
+ new URL('node_modules/foo/index.mjs', entryPoint).pathname
+ );
+ assert.strictEqual(
+ importMap.resolve('bar', new URL('node_modules/foo/index.mjs', entryPoint)).pathname,
+ new URL('baz.mjs', entryPoint).pathname
+ );
+ assert.strictEqual(importMap.resolve('bar'), 'bar');
+ });
+
+ it('resolve - nested scopes', async () => {
+ const importMap = await getImportMap('unordered-scopes');
+ assert.strictEqual(
+ importMap.resolve('zed', new URL('node_modules/bar', entryPoint)).pathname,
+ new URL('node_modules/bar/node_modules/zed/index.mjs', entryPoint).pathname
+ );
+ assert.strictEqual(
+ importMap.resolve('zed', new URL('node_modules/bar/node_modules/zed', entryPoint)).pathname,
+ new URL('baz.mjs', entryPoint).pathname
+ );
+ });
+
+ it('resolve - data url', async () => {
+ const importMap = await getImportMap('dataurl');
+ assert.strictEqual(
+ importMap.resolve('foo').href,
+ 'data:text/javascript,export default () => \'data\''
+ );
+ });
+
+ it('should pass --experimental-import-map', async () => {
+ const importMapPath = fixtures.path('es-module-loaders/importmaps/simple.json');
+ const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
+ '--experimental-import-map', importMapPath,
+ entryPoint.pathname,
+ ], {
+ cwd: fixtures.path('es-module-loaders/importmaps'),
+ });
+
+ assert.strictEqual(code, 0, stderr);
+ assert.strictEqual(stdout, 'baz\n');
+ assert.strictEqual(signal, null);
+ });
+
+ it('should throw on startup on invalid import map', async () => {
+ const importMapPath = fixtures.path('es-module-loaders/importmaps/invalid.json');
+ const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
+ '--experimental-import-map', importMapPath,
+ entryPoint.pathname,
+ ], {
+ cwd: fixtures.path('es-module-loaders/importmaps'),
+ });
+
+ assert.strictEqual(code, 1);
+ assert.strictEqual(signal, null);
+ assert.strictEqual(stdout, '');
+ assert(stderr.includes('Invalid import map: top level key "imports" is required'));
+ });
+
+ it('should handle import maps with absolute paths', async () => {
+ const importMapPath = path.resolve(tmpdir.path, 'absolute.json');
+ await writeFile(importMapPath, JSONStringify({
+ imports: {
+ foo: fixtures.path('es-module-loaders/importmaps/node_modules/foo/index.mjs'),
+ [fixtures.path('es-module-loaders/importmaps/baz.mjs')]: fixtures.path('es-module-loaders/importmaps/qux.mjs'),
+ },
+ scopes: {
+ [fixtures.path('es-module-loaders/importmaps/node_modules/foo')]: {
+ bar: fixtures.path('es-module-loaders/importmaps/baz.mjs'),
+ }
+ }
+ }));
+
+ const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
+ '--experimental-import-map', importMapPath,
+ entryPoint.pathname,
+ ], {
+ cwd: fixtures.path('es-module-loaders/importmaps'),
+ });
+
+ assert.strictEqual(code, 0, stderr);
+ assert.strictEqual(signal, null);
+ assert.strictEqual(stdout, 'qux\n');
+ });
+
+ it('should handle import maps with data urls', async () => {
+ const importMapPath = getImportMapPathURL('dataurl').pathname;
+ const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
+ '--experimental-import-map', importMapPath,
+ entryPoint.pathname,
+ ], {
+ cwd: importMapFixtureRoot,
+ });
+
+ assert.strictEqual(code, 0, stderr);
+ assert.strictEqual(signal, null);
+ assert.strictEqual(stdout, 'data\n');
+ });
+
+ it('should handle http imports', async () => {
+ const server = http.createServer((req, res) => {
+ res
+ .writeHead(200, { 'Content-Type': 'application/javascript' })
+ .end('export default () => \'http\'');
+ });
+ await (new Promise((resolve, reject) => {
+ server.listen((err) => {
+ if (err) return reject(err);
+ resolve();
+ });
+ }));
+ const { port } = server.address();
+
+ const importMapPath = path.resolve(tmpdir.path, 'http.json');
+ await writeFile(importMapPath, JSONStringify({
+ imports: {
+ foo: `http://localhost:${port}`
+ },
+ scopes: {}
+ }));
+
+ const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
+ '--experimental-network-imports',
+ '--experimental-import-map', importMapPath,
+ entryPoint.pathname,
+ ], {
+ cwd: importMapFixtureRoot,
+ });
+
+ server.close();
+ assert.strictEqual(code, 0, stderr);
+ assert.strictEqual(signal, null);
+ assert.strictEqual(stdout, 'http\n');
+ });
+});
diff --git a/test/fixtures/es-module-loaders/importmaps/array-imports.json b/test/fixtures/es-module-loaders/importmaps/array-imports.json
new file mode 100644
index 00000000000000..b7c16376f5c26a
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/array-imports.json
@@ -0,0 +1,3 @@
+{
+ "imports": []
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/baz.mjs b/test/fixtures/es-module-loaders/importmaps/baz.mjs
new file mode 100644
index 00000000000000..eecc080ab601de
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/baz.mjs
@@ -0,0 +1,3 @@
+export default () => {
+ return 'baz';
+};
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/dataurl.json b/test/fixtures/es-module-loaders/importmaps/dataurl.json
new file mode 100644
index 00000000000000..6e2be85035f4e5
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/dataurl.json
@@ -0,0 +1,6 @@
+{
+ "imports": {
+ "foo": "data:text/javascript,export default () => 'data'"
+ },
+ "scopes": {}
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/empty.json b/test/fixtures/es-module-loaders/importmaps/empty.json
new file mode 100644
index 00000000000000..85f87be068855b
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/empty.json
@@ -0,0 +1,4 @@
+{
+ "imports": {},
+ "scopes": {}
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/index.mjs b/test/fixtures/es-module-loaders/importmaps/index.mjs
new file mode 100644
index 00000000000000..591640d8bb7a2d
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/index.mjs
@@ -0,0 +1,2 @@
+import foo from 'foo';
+console.log(foo());
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/invalid.json b/test/fixtures/es-module-loaders/importmaps/invalid.json
new file mode 100644
index 00000000000000..5d148e2cb573d1
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/invalid.json
@@ -0,0 +1,4 @@
+{
+ "missing": "the required keys",
+ "scopes": []
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/missing-scopes.json b/test/fixtures/es-module-loaders/importmaps/missing-scopes.json
new file mode 100644
index 00000000000000..99df1f906a4ae9
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/missing-scopes.json
@@ -0,0 +1,4 @@
+{
+ "imports": {},
+ "missing": "the scopes key"
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/node_modules/bar/index.mjs b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/index.mjs
new file mode 100644
index 00000000000000..b47e8304a8897f
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/index.mjs
@@ -0,0 +1,3 @@
+export default () => {
+ return 'bar';
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/node_modules/bar/node_modules/zed/index.mjs b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/node_modules/zed/index.mjs
new file mode 100644
index 00000000000000..564f4b94f08714
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/node_modules/zed/index.mjs
@@ -0,0 +1,3 @@
+export default () => {
+ return 'zed';
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/node_modules/bar/node_modules/zed/package.json b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/node_modules/zed/package.json
new file mode 100644
index 00000000000000..94d250b64f9ac8
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/node_modules/zed/package.json
@@ -0,0 +1,3 @@
+{
+ "main": "index.mjs"
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/node_modules/bar/node_modules/zed/zed.mjs b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/node_modules/zed/zed.mjs
new file mode 100644
index 00000000000000..0f33a3c44118c4
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/node_modules/zed/zed.mjs
@@ -0,0 +1,4 @@
+import baz from 'zed';
+export default () => {
+ return baz();
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/node_modules/bar/package.json b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/package.json
new file mode 100644
index 00000000000000..94d250b64f9ac8
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/package.json
@@ -0,0 +1,3 @@
+{
+ "main": "index.mjs"
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/node_modules/bar/zed.mjs b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/zed.mjs
new file mode 100644
index 00000000000000..0ffb0273859feb
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/node_modules/bar/zed.mjs
@@ -0,0 +1,4 @@
+import zed from 'zed';
+export default () => {
+ return zed();
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/node_modules/foo/index.mjs b/test/fixtures/es-module-loaders/importmaps/node_modules/foo/index.mjs
new file mode 100644
index 00000000000000..683543f39a735c
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/node_modules/foo/index.mjs
@@ -0,0 +1,4 @@
+import bar from 'bar';
+export default () => {
+ return bar();
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/node_modules/foo/package.json b/test/fixtures/es-module-loaders/importmaps/node_modules/foo/package.json
new file mode 100644
index 00000000000000..94d250b64f9ac8
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/node_modules/foo/package.json
@@ -0,0 +1,3 @@
+{
+ "main": "index.mjs"
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/node_modules/importmap.json b/test/fixtures/es-module-loaders/importmaps/node_modules/importmap.json
new file mode 100644
index 00000000000000..90973e70f22af1
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/node_modules/importmap.json
@@ -0,0 +1,10 @@
+{
+ "imports": {
+ "foo": "node_modules/foo"
+ },
+ "scopes": {
+ "node_modules/foo": {
+ "bar": "baz"
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/qux.mjs b/test/fixtures/es-module-loaders/importmaps/qux.mjs
new file mode 100644
index 00000000000000..b0ee0222e13397
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/qux.mjs
@@ -0,0 +1,3 @@
+export default () => {
+ return 'qux';
+};
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/simple.json b/test/fixtures/es-module-loaders/importmaps/simple.json
new file mode 100644
index 00000000000000..01460066610040
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/simple.json
@@ -0,0 +1,10 @@
+{
+ "imports": {
+ "foo": "./node_modules/foo/index.mjs"
+ },
+ "scopes": {
+ "node_modules/foo": {
+ "bar": "./baz.mjs"
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/importmaps/unordered-scopes.json b/test/fixtures/es-module-loaders/importmaps/unordered-scopes.json
new file mode 100644
index 00000000000000..043b8ad682714e
--- /dev/null
+++ b/test/fixtures/es-module-loaders/importmaps/unordered-scopes.json
@@ -0,0 +1,16 @@
+{
+ "imports": {
+ "foo": "node_modules/foo/index.mjs"
+ },
+ "scopes": {
+ "node_modules/foo": {
+ "bar": "node_modules/bar/zed.mjs"
+ },
+ "node_modules/bar": {
+ "zed": "node_modules/bar/node_modules/zed/index.mjs"
+ },
+ "node_modules/bar/node_modules/zed": {
+ "zed": "baz.mjs"
+ }
+ }
+}
\ No newline at end of file