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..5dc1aaebf55993
--- /dev/null
+++ b/lib/internal/modules/esm/import_map.js
@@ -0,0 +1,177 @@
+'use strict';
+const { isURL, URL } = require('internal/url');
+const {
+ ObjectEntries,
+ ObjectKeys,
+ SafeMap,
+ ArrayIsArray,
+ StringPrototypeStartsWith,
+ StringPrototypeEndsWith,
+ StringPrototypeSlice,
+ ArrayPrototypeReverse,
+ ArrayPrototypeSort,
+} = primordials;
+const { codes: { ERR_INVALID_IMPORT_MAP } } = require('internal/errors');
+const { shouldBeTreatedAsRelativeOrAbsolutePath } = require('internal/modules/helpers');
+
+class ImportMap {
+ #baseURL;
+ #imports = new SafeMap();
+ #scopes = new SafeMap();
+ #specifiers = new SafeMap()
+
+ constructor(raw, baseURL) {
+ this.#baseURL = baseURL;
+ this.process(raw);
+ }
+
+ // These are convinenince methods mostly for tests
+ get baseURL() {
+ return this.#baseURL;
+ }
+
+ get imports() {
+ return this.#imports;
+ }
+
+ get scopes() {
+ return this.#scopes;
+ }
+
+ #getMappedSpecifier(_mappedSpecifier) {
+ let mappedSpecifier = this.#specifiers.get(_mappedSpecifier);
+
+ // Specifiers are processed and cached in this.#specifiers
+ if (!mappedSpecifier) {
+ // Try processing as a url, fall back for bare specifiers
+ try {
+ if (shouldBeTreatedAsRelativeOrAbsolutePath(_mappedSpecifier)) {
+ mappedSpecifier = new URL(_mappedSpecifier, this.#baseURL);
+ } else {
+ mappedSpecifier = new URL(_mappedSpecifier);
+ }
+ } catch {
+ // Ignore exception
+ mappedSpecifier = _mappedSpecifier;
+ }
+ this.#specifiers.set(_mappedSpecifier, mappedSpecifier);
+ }
+ return mappedSpecifier;
+ }
+
+ resolve(specifier, parentURL = this.#baseURL) {
+ // When using the customized loader the parent
+ // will be a string (for transferring to the worker)
+ // so just handle that here
+ if (!isURL(parentURL)) {
+ parentURL = new URL(parentURL);
+ }
+
+ // Process scopes
+ for (const { 0: prefix, 1: mapping } of this.#scopes) {
+ const _mappedSpecifier = mapping.get(specifier);
+ if (StringPrototypeStartsWith(parentURL.pathname, prefix.pathname) && _mappedSpecifier) {
+ const mappedSpecifier = this.#getMappedSpecifier(_mappedSpecifier);
+ if (mappedSpecifier !== _mappedSpecifier) {
+ mapping.set(specifier, mappedSpecifier);
+ }
+ specifier = mappedSpecifier;
+ break;
+ }
+ }
+
+ // Handle bare specifiers with sub paths
+ let spec = specifier;
+ let hasSlash = (typeof specifier === 'string' && specifier.indexOf('/')) || -1;
+ let subSpec;
+ let bareSpec;
+ if (isURL(spec)) {
+ spec = spec.href;
+ } else if (hasSlash !== -1) {
+ hasSlash += 1;
+ subSpec = StringPrototypeSlice(spec, hasSlash);
+ bareSpec = StringPrototypeSlice(spec, 0, hasSlash);
+ }
+
+ let _mappedSpecifier = this.#imports.get(bareSpec) || this.#imports.get(spec);
+ if (_mappedSpecifier) {
+ // Re-assemble sub spec
+ if (_mappedSpecifier === spec && subSpec) {
+ _mappedSpecifier += subSpec;
+ }
+ const mappedSpecifier = this.#getMappedSpecifier(_mappedSpecifier);
+
+ if (mappedSpecifier !== _mappedSpecifier) {
+ this.imports.set(specifier, mappedSpecifier);
+ }
+ specifier = mappedSpecifier;
+ }
+
+ return specifier;
+ }
+
+ process(raw) {
+ if (!raw) {
+ throw new ERR_INVALID_IMPORT_MAP('top level must be a plain object');
+ }
+
+ // Validation and normalization
+ if (raw.imports === null || 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 (raw.scopes === null || 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
+ const importsEntries = ObjectEntries(raw.imports);
+ for (let i = 0; i < importsEntries.length; i++) {
+ const { 0: specifier, 1: mapping } = importsEntries[i];
+ 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 (StringPrototypeEndsWith(specifier, '/') && !StringPrototypeEndsWith(mapping, '/')) {
+ throw new ERR_INVALID_IMPORT_MAP('module specifier keys ending with "/" must have values that end with "/"');
+ }
+
+ this.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 = ArrayPrototypeReverse(ArrayPrototypeSort(ObjectKeys(raw.scopes)));
+ for (let i = 0; i < sortedScopes.length; i++) {
+ let scope = sortedScopes[i];
+ 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, this.#baseURL);
+
+ const scopeMap = new SafeMap();
+ const scopeEntries = ObjectEntries(_scopeMap);
+ for (let i = 0; i < scopeEntries.length; i++) {
+ const { 0: specifier, 1: mapping } = scopeEntries[i];
+ if (StringPrototypeEndsWith(specifier, '/') && !StringPrototypeEndsWith(mapping, '/')) {
+ throw new ERR_INVALID_IMPORT_MAP('module specifier keys ending with "/" must have values that end with "/"');
+ }
+ scopeMap.set(specifier, mapping);
+ }
+
+ this.scopes.set(scope, scopeMap);
+ }
+ }
+}
+
+module.exports = {
+ ImportMap,
+};
diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js
index 6044765c3709f5..b6af1e7399c632 100644
--- a/lib/internal/modules/esm/loader.js
+++ b/lib/internal/modules/esm/loader.js
@@ -129,6 +129,14 @@ class ModuleLoader {
*/
#customizations;
+ /**
+ * The loaders importMap instance
+ *
+ * Note: private to ensure you must call setImportMap to ensure
+ * this is properly passed down to the customized loader
+ */
+ #importMap;
+
constructor(customizations) {
if (getOptionValue('--experimental-network-imports')) {
emitExperimentalWarning('Network Imports');
@@ -188,11 +196,22 @@ class ModuleLoader {
this.#customizations = customizations;
if (customizations) {
this.allowImportMetaResolve = customizations.allowImportMetaResolve;
+ if (this.#importMap) {
+ this.#customizations.importMap = this.#importMap;
+ }
} else {
this.allowImportMetaResolve = true;
}
}
+ setImportMap(importMap) {
+ if (this.#customizations) {
+ this.#customizations.importMap = importMap;
+ } else {
+ this.#importMap = importMap;
+ }
+ }
+
async eval(
source,
url = pathToFileURL(`${process.cwd()}/[eval${++this.evalIndex}]`).href,
@@ -391,6 +410,7 @@ class ModuleLoader {
conditions: this.#defaultConditions,
importAttributes,
parentURL,
+ importMap: this.#importMap,
};
return defaultResolve(originalSpecifier, context);
@@ -455,6 +475,8 @@ ObjectSetPrototypeOf(ModuleLoader.prototype, null);
class CustomizedModuleLoader {
+ importMap;
+
allowImportMetaResolve = true;
/**
@@ -489,7 +511,16 @@ class CustomizedModuleLoader {
* @returns {{ format: string, url: URL['href'] }}
*/
resolve(originalSpecifier, parentURL, importAttributes) {
- return hooksProxy.makeAsyncRequest('resolve', undefined, originalSpecifier, parentURL, importAttributes);
+ // Resolve with import map before passing to loader.
+ let spec = originalSpecifier;
+ if (this.importMap) {
+ spec = this.importMap.resolve(spec, parentURL);
+ if (spec && isURL(spec)) {
+ spec = spec.href;
+ }
+ }
+
+ return hooksProxy.makeAsyncRequest('resolve', undefined, spec, parentURL, importAttributes);
}
resolveSync(originalSpecifier, parentURL, importAttributes) {
diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js
index 06a34c11254a2f..6ff7add57042a5 100644
--- a/lib/internal/modules/esm/resolve.js
+++ b/lib/internal/modules/esm/resolve.js
@@ -59,6 +59,7 @@ const { getPackageScopeConfig } = require('internal/modules/esm/package_config')
const { getConditionsSet } = require('internal/modules/esm/utils');
const packageJsonReader = require('internal/modules/package_json_reader');
const { internalModuleStat } = internalBinding('fs');
+const { shouldBeTreatedAsRelativeOrAbsolutePath, isRelativeSpecifier } = require('internal/modules/helpers');
/**
* @typedef {import('internal/modules/esm/package_config.js').PackageConfig} PackageConfig
@@ -861,30 +862,6 @@ function isBareSpecifier(specifier) {
return specifier[0] && specifier[0] !== '/' && specifier[0] !== '.';
}
-/**
- * Determines whether a specifier is a relative path.
- * @param {string} specifier - The specifier to check.
- */
-function isRelativeSpecifier(specifier) {
- if (specifier[0] === '.') {
- if (specifier.length === 1 || specifier[1] === '/') { return true; }
- if (specifier[1] === '.') {
- if (specifier.length === 2 || specifier[2] === '/') { return true; }
- }
- }
- return false;
-}
-
-/**
- * Determines whether a specifier should be treated as a relative or absolute path.
- * @param {string} specifier - The specifier to check.
- */
-function shouldBeTreatedAsRelativeOrAbsolutePath(specifier) {
- if (specifier === '') { return false; }
- if (specifier[0] === '/') { return true; }
- return isRelativeSpecifier(specifier);
-}
-
/**
* Resolves a module specifier to a URL.
* @param {string} specifier - The module specifier to resolve.
@@ -1026,6 +1003,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 +1043,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 +1062,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 +1086,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/helpers.js b/lib/internal/modules/helpers.js
index 6b30a1d8c76d4b..c5c7d579b63c3c 100644
--- a/lib/internal/modules/helpers.js
+++ b/lib/internal/modules/helpers.js
@@ -298,6 +298,30 @@ function normalizeReferrerURL(referrer) {
return new URL(referrer).href;
}
+/**
+ * Determines whether a specifier is a relative path.
+ * @param {string} specifier - The specifier to check.
+ */
+function isRelativeSpecifier(specifier) {
+ if (specifier[0] === '.') {
+ if (specifier.length === 1 || specifier[1] === '/') { return true; }
+ if (specifier[1] === '.') {
+ if (specifier.length === 2 || specifier[2] === '/') { return true; }
+ }
+ }
+ return false;
+}
+
+/**
+ * Determines whether a specifier should be treated as a relative or absolute path.
+ * @param {string} specifier - The specifier to check.
+ */
+function shouldBeTreatedAsRelativeOrAbsolutePath(specifier) {
+ if (specifier === '') { return false; }
+ if (specifier[0] === '/') { return true; }
+ return isRelativeSpecifier(specifier);
+}
+
module.exports = {
addBuiltinLibsToObject,
getCjsConditions,
@@ -307,4 +331,6 @@ module.exports = {
normalizeReferrerURL,
stripBOM,
toRealPath,
+ isRelativeSpecifier,
+ shouldBeTreatedAsRelativeOrAbsolutePath,
};
diff --git a/lib/internal/modules/run_main.js b/lib/internal/modules/run_main.js
index 1f03c313121db0..59770c8d2fb164 100644
--- a/lib/internal/modules/run_main.js
+++ b/lib/internal/modules/run_main.js
@@ -2,6 +2,7 @@
const {
StringPrototypeEndsWith,
+ StringPrototypeStartsWith,
} = primordials;
const { containsModuleSyntax } = internalBinding('contextify');
@@ -51,6 +52,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 +94,31 @@ 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 _importMapSpecifier = getOptionValue('--experimental-import-map');
const main = pathToFileURL(mainPath).href;
- handleMainPromise(loadESM((esmLoader) => {
+ handleMainPromise(loadESM(async (esmLoader) => {
+ // Load import map and throw validation errors
+ if (_importMapSpecifier) {
+ const { ImportMap } = require('internal/modules/esm/import_map');
+ const { getCWDURL } = require('internal/util');
+ const parentURL = getCWDURL();
+ const importAttributes = { __proto__: null, type: 'json' };
+
+ const importMapSpecifier = await esmLoader.resolve(_importMapSpecifier, parentURL.href, importAttributes);
+ const job = await esmLoader.getJobFromResolveResult(importMapSpecifier, parentURL.href, importAttributes);
+ const { module: importMapModule } = await job.run();
+
+ // For data urls, use cwd as the parent url
+ const importMapURL = StringPrototypeStartsWith(importMapSpecifier.url, 'data:') ?
+ parentURL :
+ new URL(importMapSpecifier.url, parentURL);
+
+ // Set method ensures this is passed to the customized loader if necessary
+ esmLoader.setImportMap(new ImportMap(importMapModule.getNamespace().default, importMapURL));
+ }
+
return esmLoader.import(main, undefined, { __proto__: null });
}));
}
diff --git a/src/node_options.cc b/src/node_options.cc
index 29cb7fc6b29b89..5085f45c42446e 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",
+ "load an import map for module resolution",
+ &EnvironmentOptions::import_map_specifier,
+ 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..9ee0bdf5c67339 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_specifier;
bool verify_base_objects =
#ifdef DEBUG
true;
diff --git a/test/es-module/test-import-map.mjs b/test/es-module/test-import-map.mjs
new file mode 100644
index 00000000000000..038ed9d760e2d2
--- /dev/null
+++ b/test/es-module/test-import-map.mjs
@@ -0,0 +1,262 @@
+// 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, ObjectEntries } } = binding;
+
+const importMapFixture = (...p) => fixtures.path('es-module-loaders', 'import-maps', ...p);
+const getImportMapPathURL = (name, filename = 'importmap.json') => {
+ return pathToFileURL(importMapFixture(name, filename));
+};
+const readImportMapFile = async (name, filename) => {
+ const url = getImportMapPathURL(name, filename);
+ return [url, (await import(url, { with: { type: 'json' } })).default];
+};
+const getImportMap = async (name, filename) => {
+ const [url, rawMap] = await readImportMapFile(name, filename);
+ return new ImportMap(rawMap, url);
+};
+const spawnPromisifiedWtihImportMap = async (name) => {
+ const entryPoint = importMapFixture(name, 'index.mjs');
+ const importMapPath = getImportMapPathURL(name).pathname;
+ const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
+ '--no-warnings',
+ '--experimental-import-map', importMapPath,
+ entryPoint,
+ ], {
+ cwd: importMapFixture(name),
+ });
+ assert.strictEqual(code, 0, [stderr, stdout]);
+ assert.strictEqual(signal, null);
+ return stdout;
+}
+
+describe('Import Maps', { concurrency: true }, () => {
+ it('processImportMap - simple import map', async () => {
+ const importMap = await getImportMap('simple');
+ assert.deepStrictEqual(importMap.imports, new SafeMap(Object.entries({
+ foo: './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 import map', async () => {
+ await assert.rejects(
+ getImportMap('invalid'),
+ {
+ code: 'ERR_INVALID_IMPORT_MAP'
+ }
+ );
+ await assert.rejects(
+ getImportMap('invalid', 'missing-scopes.json'),
+ {
+ code: 'ERR_INVALID_IMPORT_MAP'
+ }
+ );
+ await assert.rejects(
+ getImportMap('invalid', 'array-imports.json'),
+ {
+ code: 'ERR_INVALID_IMPORT_MAP'
+ }
+ );
+ });
+
+ it('resolve - empty import map', async () => {
+ const importMap = await getImportMap('empty');
+ const spec = importMap.resolve('test');
+ assert.strictEqual(spec, 'test');
+ });
+
+ it('resolve - simple import map', async () => {
+ const importMap = await getImportMap('simple');
+ const entryPoint = new URL('index.mjs', importMap.baseURL);
+ assert.strictEqual(
+ importMap.resolve('foo').pathname,
+ new URL('foo/index.mjs', entryPoint).pathname
+ );
+ assert.strictEqual(
+ importMap.resolve('bar', new URL('foo/index.mjs', entryPoint)).pathname,
+ new URL('baz.mjs', entryPoint).pathname
+ );
+ assert.strictEqual(importMap.resolve('bar'), 'bar');
+ });
+
+ it('resolve - scope with correct precedence', async () => {
+ const importMap = await getImportMap('scope-order');
+ const entryPoint = new URL('index.mjs', importMap.baseURL);
+ 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('data-uri');
+ assert.strictEqual(
+ importMap.resolve('foo').href,
+ 'data:text/javascript,export default () => \'data foo\''
+ );
+ assert.strictEqual(
+ importMap.resolve('foo/bar').href,
+ 'data:text/javascript,export default () => \'data bar\''
+ );
+ assert.strictEqual(
+ importMap.resolve('baz', new URL('./index.mjs', importMap.baseURL)).href,
+ 'data:text/javascript,export default () => \'data baz\''
+ );
+ assert.strictEqual(
+ importMap.resolve('data:text/javascript,export default () => \'bad\'', new URL('./index.mjs', importMap.baseURL)).href,
+ 'data:text/javascript,export default () => \'data qux\''
+ );
+ });
+
+ it('should throw on startup on invalid import map', async () => {
+ await assert.rejects(
+ spawnPromisifiedWtihImportMap('invalid'),
+ /Error \[ERR_INVALID_IMPORT_MAP\]: Invalid import map: top level key "imports" is required and must be a plain object/
+ );
+ });
+
+ it('should pass --experimental-import-map', async () => {
+ const stdout = await spawnPromisifiedWtihImportMap('simple');
+ assert.strictEqual(stdout, 'baz\n');
+ });
+
+ it('should handle import maps with data uris', async () => {
+ const stdout = await spawnPromisifiedWtihImportMap('data-uri');
+ assert.strictEqual(stdout, 'data foo\ndata bar\ndata baz\ndata qux\n');
+ });
+
+ it('should handle import maps with bare specifiers', async () => {
+ const stdout = await spawnPromisifiedWtihImportMap('bare');
+ assert.strictEqual(stdout, 'zed\n');
+ });
+
+ it('should handle import maps with absolute paths', async () => {
+ tmpdir.refresh();
+ const entryPoint = importMapFixture('simple', 'index.mjs');
+ const importMapPath = path.resolve(tmpdir.path, 'absolute.json');
+
+ // Read simple import map and convert to absolute file paths
+ const [, simple] = await readImportMapFile('simple');
+ const importsEntries = ObjectEntries(simple.imports);
+ for (const { 0: key, 1: val } of importsEntries) {
+ simple.imports[key] = importMapFixture('simple', val);
+ }
+ const scopesEntries = ObjectEntries(simple.scopes);
+ for (const { 0: scope, 1: map } of scopesEntries) {
+ const mapEntries = ObjectEntries(map);
+ const absScope = importMapFixture('simple', scope);
+ simple.scopes[absScope] = {};
+ for (const { 0: key, 1: val } of mapEntries) {
+ simple.scopes[absScope][key] = importMapFixture('simple', val);
+ }
+ delete simple.scopes[scope];
+ }
+
+ await writeFile(importMapPath, JSONStringify(simple));
+ const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
+ '--no-warnings',
+ '--experimental-import-map', importMapPath,
+ entryPoint,
+ ], {
+ cwd: importMapFixture('simple'),
+ });
+
+ assert.strictEqual(code, 0, [stderr, stdout]);
+ assert.strictEqual(signal, null);
+ assert.strictEqual(stdout, 'baz\n');
+ });
+
+ it('should handle import maps as data uris', async () => {
+ const entryPoint = importMapFixture('data-uri', 'index.mjs');
+ const [, importMap] = await readImportMapFile('data-uri');
+ const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
+ // '--inspect', '--inspect-brk',
+ '--experimental-import-map', `data:application/json,${JSONStringify(importMap)}`,
+ entryPoint,
+ ], {
+ cwd: importMapFixture('data-uri'),
+ });
+
+ assert.strictEqual(code, 0, [stderr, stdout]);
+ assert.strictEqual(signal, null);
+ assert.strictEqual(stdout, 'data foo\ndata bar\ndata baz\ndata qux\n');
+ });
+
+ it('should handle http imports', async () => {
+ const entryPoint = importMapFixture('simple', 'index.mjs');
+ 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();
+
+ tmpdir.refresh();
+ 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,
+ ], {
+ cwd: importMapFixture('simple'),
+ });
+
+ server.close();
+ assert.strictEqual(code, 0, stderr);
+ assert.strictEqual(signal, null);
+ assert.strictEqual(stdout, 'http\n');
+ });
+
+ it('should work with other loaders', async () => {
+ const entryPoint = importMapFixture('simple', 'index.mjs');
+ const importMapPath = getImportMapPathURL('simple').pathname;
+ const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
+ '--no-warnings',
+ '--experimental-import-map', importMapPath,
+ '--loader', fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs'),
+ entryPoint,
+ ], {
+ cwd: importMapFixture(),
+ });
+
+ assert.strictEqual(code, 0, [stderr, stdout].join('\n'));
+ assert.strictEqual(signal, null);
+ assert.strictEqual(stdout, 'baz\n');
+ });
+});
diff --git a/test/fixtures/es-module-loaders/import-maps/bare/importmap.json b/test/fixtures/es-module-loaders/import-maps/bare/importmap.json
new file mode 100644
index 00000000000000..6e190c13b58099
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/bare/importmap.json
@@ -0,0 +1,10 @@
+{
+ "imports": {
+ "foo": "bar"
+ },
+ "scopes": {
+ "node_modules/bar": {
+ "baz": "zed"
+ }
+ }
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/bare/index.mjs b/test/fixtures/es-module-loaders/import-maps/bare/index.mjs
new file mode 100644
index 00000000000000..6e57ae8261907b
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/bare/index.mjs
@@ -0,0 +1,2 @@
+import foo from 'foo';
+console.log(foo());
diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/index.mjs b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/index.mjs
new file mode 100644
index 00000000000000..f50f1f6ada0c10
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/index.mjs
@@ -0,0 +1,4 @@
+import baz from 'baz';
+export default () => {
+ return baz();
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/package.json b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/package.json
new file mode 100644
index 00000000000000..27210b7a5b4293
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/package.json
@@ -0,0 +1,5 @@
+{
+ "exports": {
+ ".": "./index.mjs"
+ }
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/index.mjs b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/index.mjs
new file mode 100644
index 00000000000000..fd8809d269b73b
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/index.mjs
@@ -0,0 +1,3 @@
+export default () => {
+ throw new Error('baz should have been remapped to zed');
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/package.json b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/package.json
new file mode 100644
index 00000000000000..27210b7a5b4293
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/package.json
@@ -0,0 +1,5 @@
+{
+ "exports": {
+ ".": "./index.mjs"
+ }
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/index.mjs b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/index.mjs
new file mode 100644
index 00000000000000..aee26b7d61bdad
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/index.mjs
@@ -0,0 +1,3 @@
+export default () => {
+ throw new Error('foo should have been remapped to bar');
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/package.json b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/package.json
new file mode 100644
index 00000000000000..27210b7a5b4293
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/package.json
@@ -0,0 +1,5 @@
+{
+ "exports": {
+ ".": "./index.mjs"
+ }
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/index.mjs b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/index.mjs
new file mode 100644
index 00000000000000..a6628b588d5a75
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/index.mjs
@@ -0,0 +1,3 @@
+export default () => {
+ return 'zed';
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/package.json b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/package.json
new file mode 100644
index 00000000000000..27210b7a5b4293
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/package.json
@@ -0,0 +1,5 @@
+{
+ "exports": {
+ ".": "./index.mjs"
+ }
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/data-uri/baz.mjs b/test/fixtures/es-module-loaders/import-maps/data-uri/baz.mjs
new file mode 100644
index 00000000000000..e9ba458c37dcd6
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/data-uri/baz.mjs
@@ -0,0 +1 @@
+throw new Error('baz.mjs should be remapped to a data uri');
diff --git a/test/fixtures/es-module-loaders/import-maps/data-uri/importmap.json b/test/fixtures/es-module-loaders/import-maps/data-uri/importmap.json
new file mode 100644
index 00000000000000..07e61fdd68c69f
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/data-uri/importmap.json
@@ -0,0 +1,12 @@
+{
+ "imports": {
+ "foo": "data:text/javascript,export default () => 'data foo'",
+ "foo/bar": "data:text/javascript,export default () => 'data bar'",
+ "data:text/javascript,export default () => 'bad'": "data:text/javascript,export default () => 'data qux'"
+ },
+ "scopes": {
+ "./index.mjs": {
+ "baz": "data:text/javascript,export default () => 'data baz'"
+ }
+ }
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/data-uri/index.mjs b/test/fixtures/es-module-loaders/import-maps/data-uri/index.mjs
new file mode 100644
index 00000000000000..4c7fece9f0ac5d
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/data-uri/index.mjs
@@ -0,0 +1,11 @@
+import foo from 'foo';
+console.log(foo());
+
+import bar from 'foo/bar';
+console.log(bar());
+
+import baz from 'baz';
+console.log(baz());
+
+import qux from 'data:text/javascript,export default () => \'bad\'';
+console.log(qux());
diff --git a/test/fixtures/es-module-loaders/import-maps/empty/importmap.json b/test/fixtures/es-module-loaders/import-maps/empty/importmap.json
new file mode 100644
index 00000000000000..9cac6b3e8a3b11
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/empty/importmap.json
@@ -0,0 +1,4 @@
+{
+ "imports": {},
+ "scopes": {}
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/invalid/array-imports.json b/test/fixtures/es-module-loaders/import-maps/invalid/array-imports.json
new file mode 100644
index 00000000000000..355fafaacdc141
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/invalid/array-imports.json
@@ -0,0 +1,3 @@
+{
+ "imports": []
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/invalid/importmap.json b/test/fixtures/es-module-loaders/import-maps/invalid/importmap.json
new file mode 100644
index 00000000000000..20f606f9935ea5
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/invalid/importmap.json
@@ -0,0 +1,4 @@
+{
+ "missing": "the required keys",
+ "scopes": []
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/invalid/missing-scopes.json b/test/fixtures/es-module-loaders/import-maps/invalid/missing-scopes.json
new file mode 100644
index 00000000000000..959a501196db82
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/invalid/missing-scopes.json
@@ -0,0 +1,4 @@
+{
+ "imports": {},
+ "missing": "the scopes key"
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/scope-order/importmap.json b/test/fixtures/es-module-loaders/import-maps/scope-order/importmap.json
new file mode 100644
index 00000000000000..c7a1c2caacd1f6
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/scope-order/importmap.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"
+ }
+ }
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/simple/baz.mjs b/test/fixtures/es-module-loaders/import-maps/simple/baz.mjs
new file mode 100644
index 00000000000000..eecc080ab601de
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/simple/baz.mjs
@@ -0,0 +1,3 @@
+export default () => {
+ return 'baz';
+};
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/import-maps/simple/foo/index.mjs b/test/fixtures/es-module-loaders/import-maps/simple/foo/index.mjs
new file mode 100644
index 00000000000000..683543f39a735c
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/simple/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/import-maps/simple/foo/package.json b/test/fixtures/es-module-loaders/import-maps/simple/foo/package.json
new file mode 100644
index 00000000000000..94d250b64f9ac8
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/simple/foo/package.json
@@ -0,0 +1,3 @@
+{
+ "main": "index.mjs"
+}
\ No newline at end of file
diff --git a/test/fixtures/es-module-loaders/import-maps/simple/importmap.json b/test/fixtures/es-module-loaders/import-maps/simple/importmap.json
new file mode 100644
index 00000000000000..4cd4dc21ad3138
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/simple/importmap.json
@@ -0,0 +1,10 @@
+{
+ "imports": {
+ "foo": "./foo/index.mjs"
+ },
+ "scopes": {
+ "foo": {
+ "bar": "./baz.mjs"
+ }
+ }
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/simple/index.mjs b/test/fixtures/es-module-loaders/import-maps/simple/index.mjs
new file mode 100644
index 00000000000000..591640d8bb7a2d
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/simple/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/import-maps/simple/node_modules/foo/index.mjs b/test/fixtures/es-module-loaders/import-maps/simple/node_modules/foo/index.mjs
new file mode 100644
index 00000000000000..5092a2a87fa621
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/simple/node_modules/foo/index.mjs
@@ -0,0 +1,3 @@
+export default () => {
+ throw new Error('foo should have been remapped to ./foo');
+}
diff --git a/test/fixtures/es-module-loaders/import-maps/simple/node_modules/foo/package.json b/test/fixtures/es-module-loaders/import-maps/simple/node_modules/foo/package.json
new file mode 100644
index 00000000000000..94d250b64f9ac8
--- /dev/null
+++ b/test/fixtures/es-module-loaders/import-maps/simple/node_modules/foo/package.json
@@ -0,0 +1,3 @@
+{
+ "main": "index.mjs"
+}
\ No newline at end of file