Skip to content

Commit 5663357

Browse files
committed
module: add import map support
1 parent 33704c4 commit 5663357

28 files changed

+486
-28
lines changed

doc/api/errors.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1947,6 +1947,13 @@ for more information.
19471947

19481948
An invalid HTTP token was supplied.
19491949

1950+
<a id="ERR_INVALID_IMPORT_MAP"></a>
1951+
1952+
### `ERR_INVALID_IMPORT_MAP`
1953+
1954+
An invalid import map file was supplied. This error can throw for a variety
1955+
of conditions which will change the error message for added context.
1956+
19501957
<a id="ERR_INVALID_IP_ADDRESS"></a>
19511958

19521959
### `ERR_INVALID_IP_ADDRESS`

lib/internal/errors.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,7 @@ E('ERR_INVALID_FILE_URL_HOST',
14151415
E('ERR_INVALID_FILE_URL_PATH', 'File URL path %s', TypeError);
14161416
E('ERR_INVALID_HANDLE_TYPE', 'This handle type cannot be sent', TypeError);
14171417
E('ERR_INVALID_HTTP_TOKEN', '%s must be a valid HTTP token ["%s"]', TypeError);
1418+
E('ERR_INVALID_IMPORT_MAP', 'Invalid import map: %s', Error);
14181419
E('ERR_INVALID_IP_ADDRESS', 'Invalid IP address: %s', TypeError);
14191420
E('ERR_INVALID_MIME_SYNTAX', (production, str, invalidIndex) => {
14201421
const msg = invalidIndex !== -1 ? ` at ${invalidIndex}` : '';
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
'use strict';
2+
const { isURL, URL } = require('internal/url');
3+
const { ObjectEntries, ObjectKeys, SafeMap, ArrayIsArray } = primordials;
4+
const { codes: { ERR_INVALID_IMPORT_MAP } } = require('internal/errors');
5+
6+
class ImportMap {
7+
#baseURL;
8+
imports = new SafeMap();
9+
scopes = new SafeMap();
10+
11+
constructor(raw, baseURL) {
12+
this.#baseURL = baseURL;
13+
processImportMap(this, this.#baseURL, raw);
14+
}
15+
16+
get baseURL() {
17+
return this.#baseURL;
18+
}
19+
20+
resolve(specifier, parentURL = this.baseURL) {
21+
// Process scopes
22+
for (const { 0: prefix, 1: mapping } of this.scopes) {
23+
let mappedSpecifier = mapping.get(specifier);
24+
if (parentURL.pathname.startsWith(prefix.pathname) && mappedSpecifier) {
25+
if (!isURL(mappedSpecifier)) {
26+
mappedSpecifier = new URL(mappedSpecifier, this.baseURL);
27+
mapping.set(specifier, mappedSpecifier);
28+
}
29+
specifier = mappedSpecifier;
30+
break;
31+
}
32+
}
33+
34+
let spec = specifier;
35+
if (isURL(specifier)) {
36+
spec = specifier.pathname;
37+
}
38+
let importMapping = this.imports.get(spec);
39+
if (importMapping) {
40+
if (!isURL(importMapping)) {
41+
importMapping = new URL(importMapping, this.baseURL);
42+
this.imports.set(spec, importMapping);
43+
}
44+
return importMapping;
45+
}
46+
47+
return specifier;
48+
}
49+
}
50+
51+
function processImportMap(importMap, baseURL, raw) {
52+
// Validation and normalization
53+
if (typeof raw.imports !== 'object' || ArrayIsArray(raw.imports)) {
54+
throw new ERR_INVALID_IMPORT_MAP('top level key "imports" is required and must be a plain object');
55+
}
56+
if (typeof raw.scopes !== 'object' || ArrayIsArray(raw.scopes)) {
57+
throw new ERR_INVALID_IMPORT_MAP('top level key "scopes" is required and must be a plain object');
58+
}
59+
60+
// Normalize imports
61+
for (const { 0: specifier, 1: mapping } of ObjectEntries(raw.imports)) {
62+
if (!specifier || typeof specifier !== 'string') {
63+
throw new ERR_INVALID_IMPORT_MAP('module specifier keys must be non-empty strings');
64+
}
65+
if (!mapping || typeof mapping !== 'string') {
66+
throw new ERR_INVALID_IMPORT_MAP('module specifier values must be non-empty strings');
67+
}
68+
if (specifier.endsWith('/') && !mapping.endsWith('/')) {
69+
throw new ERR_INVALID_IMPORT_MAP('module specifier values for keys ending with / must also end with /');
70+
}
71+
72+
importMap.imports.set(specifier, mapping);
73+
}
74+
75+
// Normalize scopes
76+
// Sort the keys according to spec and add to the map in order
77+
// which preserves the sorted map requirement
78+
const sortedScopes = ObjectKeys(raw.scopes).sort().reverse();
79+
for (let scope of sortedScopes) {
80+
const _scopeMap = raw.scopes[scope];
81+
if (!scope || typeof scope !== 'string') {
82+
throw new ERR_INVALID_IMPORT_MAP('import map scopes keys must be non-empty strings');
83+
}
84+
if (!_scopeMap || typeof _scopeMap !== 'object') {
85+
throw new ERR_INVALID_IMPORT_MAP(`scope values must be plain objects (${scope} is ${typeof _scopeMap})`);
86+
}
87+
88+
// Normalize scope
89+
scope = new URL(scope, baseURL);
90+
91+
const scopeMap = new SafeMap();
92+
for (const { 0: specifier, 1: mapping } of ObjectEntries(_scopeMap)) {
93+
if (specifier.endsWith('/') && !mapping.endsWith('/')) {
94+
throw new ERR_INVALID_IMPORT_MAP('module specifier values for keys ending with / must also end with /');
95+
}
96+
scopeMap.set(specifier, mapping);
97+
}
98+
99+
importMap.scopes.set(scope, scopeMap);
100+
}
101+
102+
return importMap;
103+
}
104+
105+
module.exports = {
106+
ImportMap,
107+
};

lib/internal/modules/esm/loader.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ class ModuleLoader {
129129
*/
130130
#customizations;
131131

132+
/**
133+
* The loaders importMap instance
134+
*/
135+
importMap;
136+
132137
constructor(customizations) {
133138
if (getOptionValue('--experimental-network-imports')) {
134139
emitExperimentalWarning('Network Imports');
@@ -391,6 +396,7 @@ class ModuleLoader {
391396
conditions: this.#defaultConditions,
392397
importAttributes,
393398
parentURL,
399+
importMap: this.importMap,
394400
};
395401

396402
return defaultResolve(originalSpecifier, context);

lib/internal/modules/esm/resolve.js

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,35 @@ function throwIfInvalidParentURL(parentURL) {
10261026
}
10271027
}
10281028

1029+
/**
1030+
* Process policy
1031+
*/
1032+
function processPolicy(specifier, context) {
1033+
const { parentURL, conditions } = context;
1034+
const redirects = policy.manifest.getDependencyMapper(parentURL);
1035+
if (redirects) {
1036+
const { resolve, reaction } = redirects;
1037+
const destination = resolve(specifier, new SafeSet(conditions));
1038+
let missing = true;
1039+
if (destination === true) {
1040+
missing = false;
1041+
} else if (destination) {
1042+
const href = destination.href;
1043+
return { __proto__: null, url: href };
1044+
}
1045+
if (missing) {
1046+
// Prevent network requests from firing if resolution would be banned.
1047+
// Network requests can extract data by doing things like putting
1048+
// secrets in query params
1049+
reaction(new ERR_MANIFEST_DEPENDENCY_MISSING(
1050+
parentURL,
1051+
specifier,
1052+
ArrayPrototypeJoin([...conditions], ', ')),
1053+
);
1054+
}
1055+
}
1056+
}
1057+
10291058
/**
10301059
* Resolves the given specifier using the provided context, which includes the parent URL and conditions.
10311060
* 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) {
10371066
*/
10381067
function defaultResolve(specifier, context = {}) {
10391068
let { parentURL, conditions } = context;
1069+
const { importMap } = context;
10401070
throwIfInvalidParentURL(parentURL);
1041-
if (parentURL && policy?.manifest) {
1042-
const redirects = policy.manifest.getDependencyMapper(parentURL);
1043-
if (redirects) {
1044-
const { resolve, reaction } = redirects;
1045-
const destination = resolve(specifier, new SafeSet(conditions));
1046-
let missing = true;
1047-
if (destination === true) {
1048-
missing = false;
1049-
} else if (destination) {
1050-
const href = destination.href;
1051-
return { __proto__: null, url: href };
1052-
}
1053-
if (missing) {
1054-
// Prevent network requests from firing if resolution would be banned.
1055-
// Network requests can extract data by doing things like putting
1056-
// secrets in query params
1057-
reaction(new ERR_MANIFEST_DEPENDENCY_MISSING(
1058-
parentURL,
1059-
specifier,
1060-
ArrayPrototypeJoin([...conditions], ', ')),
1061-
);
1062-
}
1063-
}
1064-
}
10651071

10661072
let parsedParentURL;
10671073
if (parentURL) {
@@ -1079,8 +1085,19 @@ function defaultResolve(specifier, context = {}) {
10791085
} else {
10801086
parsed = new URL(specifier);
10811087
}
1088+
} catch {
1089+
// Ignore exception
1090+
}
10821091

1083-
// Avoid accessing the `protocol` property due to the lazy getters.
1092+
// Import maps are processed before policies and data/http handling
1093+
// so policies apply to the result of any mapping
1094+
if (importMap) {
1095+
// Intentionally mutating here as we don't think it is a problem
1096+
parsed = specifier = importMap.resolve(parsed || specifier, parsedParentURL);
1097+
}
1098+
1099+
// Avoid accessing the `protocol` property due to the lazy getters.
1100+
if (parsed) {
10841101
const protocol = parsed.protocol;
10851102
if (protocol === 'data:' ||
10861103
(experimentalNetworkImports &&
@@ -1092,8 +1109,13 @@ function defaultResolve(specifier, context = {}) {
10921109
) {
10931110
return { __proto__: null, url: parsed.href };
10941111
}
1095-
} catch {
1096-
// Ignore exception
1112+
}
1113+
1114+
if (parentURL && policy?.manifest) {
1115+
const policyResolution = processPolicy(specifier, context);
1116+
if (policyResolution) {
1117+
return policyResolution;
1118+
}
10971119
}
10981120

10991121
// There are multiple deep branches that can either throw or return; instead

lib/internal/modules/run_main.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ function resolveMainPath(main) {
5151
*/
5252
function shouldUseESMLoader(mainPath) {
5353
if (getOptionValue('--experimental-default-type') === 'module') { return true; }
54+
if (getOptionValue('--experimental-import-map')) { return true; }
5455

5556
/**
5657
* @type {string[]} userLoaders A list of custom loaders registered by the user
@@ -92,10 +93,24 @@ function shouldUseESMLoader(mainPath) {
9293
*/
9394
function runMainESM(mainPath) {
9495
const { loadESM } = require('internal/process/esm_loader');
95-
const { pathToFileURL } = require('internal/url');
96+
const { pathToFileURL, URL } = require('internal/url');
97+
const _importMapPath = getOptionValue('--experimental-import-map');
9698
const main = pathToFileURL(mainPath).href;
9799

98100
handleMainPromise(loadESM((esmLoader) => {
101+
// Load import map and throw validation errors
102+
if (_importMapPath) {
103+
const { ImportMap } = require('internal/modules/esm/import_map');
104+
const { getCWDURL } = require('internal/util');
105+
106+
const importMapPath = esmLoader.resolve(_importMapPath, getCWDURL(), { __proto__: null, type: 'json' });
107+
return esmLoader.import(importMapPath.url, getCWDURL(), { __proto__: null, type: 'json' })
108+
.then((importedMapFile) => {
109+
esmLoader.importMap = new ImportMap(importedMapFile.default, new URL(importMapPath.url));
110+
return esmLoader.import(main, undefined, { __proto__: null });
111+
});
112+
}
113+
99114
return esmLoader.import(main, undefined, { __proto__: null });
100115
}));
101116
}

src/node_options.cc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
546546
&EnvironmentOptions::prof_process);
547547
// Options after --prof-process are passed through to the prof processor.
548548
AddAlias("--prof-process", { "--prof-process", "--" });
549+
AddOption("--experimental-import-map",
550+
"set the path to an import map.json",
551+
&EnvironmentOptions::import_map_path,
552+
kAllowedInEnvvar);
549553
#if HAVE_INSPECTOR
550554
AddOption("--cpu-prof",
551555
"Start the V8 CPU profiler on start up, and write the CPU profile "

src/node_options.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ class EnvironmentOptions : public Options {
179179
bool extra_info_on_fatal_exception = true;
180180
std::string unhandled_rejections;
181181
std::vector<std::string> userland_loaders;
182+
std::string import_map_path;
182183
bool verify_base_objects =
183184
#ifdef DEBUG
184185
true;

0 commit comments

Comments
 (0)