Skip to content

Commit 5b6df57

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

35 files changed

+686
-53
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: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
'use strict';
2+
const { isURL, URL } = require('internal/url');
3+
const {
4+
ObjectEntries,
5+
ObjectKeys,
6+
SafeMap,
7+
ArrayIsArray,
8+
StringPrototypeStartsWith,
9+
StringPrototypeEndsWith,
10+
StringPrototypeSlice,
11+
ArrayPrototypeReverse,
12+
ArrayPrototypeSort,
13+
} = primordials;
14+
const { codes: { ERR_INVALID_IMPORT_MAP } } = require('internal/errors');
15+
const { shouldBeTreatedAsRelativeOrAbsolutePath } = require('internal/modules/helpers');
16+
17+
class ImportMap {
18+
#baseURL;
19+
#imports = new SafeMap();
20+
#scopes = new SafeMap();
21+
#specifiers = new SafeMap()
22+
23+
constructor(raw, baseURL) {
24+
this.#baseURL = baseURL;
25+
this.process(raw, this.#baseURL);
26+
}
27+
28+
// These are convinenince methods mostly for tests
29+
get baseURL() {
30+
return this.#baseURL;
31+
}
32+
33+
get imports() {
34+
return this.#imports;
35+
}
36+
37+
get scopes() {
38+
return this.#scopes;
39+
}
40+
41+
#getMappedSpecifier(_mappedSpecifier) {
42+
let mappedSpecifier = this.#specifiers.get(_mappedSpecifier);
43+
44+
// Specifiers are processed and cached in this.#specifiers
45+
if (!mappedSpecifier) {
46+
// Try processing as a url, fall back for bare specifiers
47+
try {
48+
if (shouldBeTreatedAsRelativeOrAbsolutePath(_mappedSpecifier)) {
49+
mappedSpecifier = new URL(_mappedSpecifier, this.#baseURL);
50+
} else {
51+
mappedSpecifier = new URL(_mappedSpecifier);
52+
}
53+
} catch {
54+
// Ignore exception
55+
mappedSpecifier = _mappedSpecifier;
56+
}
57+
this.#specifiers.set(_mappedSpecifier, mappedSpecifier);
58+
}
59+
return mappedSpecifier;
60+
}
61+
62+
resolve(specifier, parentURL = this.#baseURL) {
63+
debugger;
64+
// Process scopes
65+
for (const { 0: prefix, 1: mapping } of this.#scopes) {
66+
const _mappedSpecifier = mapping.get(specifier);
67+
if (StringPrototypeStartsWith(parentURL.pathname, prefix.pathname) && _mappedSpecifier) {
68+
const mappedSpecifier = this.#getMappedSpecifier(_mappedSpecifier);
69+
if (mappedSpecifier !== _mappedSpecifier) {
70+
mapping.set(specifier, mappedSpecifier);
71+
}
72+
specifier = mappedSpecifier;
73+
break;
74+
}
75+
}
76+
77+
78+
// Handle bare specifiers with sub paths
79+
let spec = specifier;
80+
let hasSlash = (typeof specifier === 'string' && specifier.indexOf('/')) || -1;
81+
let subSpec;
82+
let bareSpec;
83+
if (isURL(spec)) {
84+
spec = spec.href;
85+
} else if (hasSlash !== -1) {
86+
hasSlash += 1;
87+
subSpec = StringPrototypeSlice(spec, hasSlash);
88+
bareSpec = StringPrototypeSlice(spec, 0, hasSlash);
89+
}
90+
91+
let _mappedSpecifier = this.#imports.get(bareSpec) || this.#imports.get(spec);
92+
if (_mappedSpecifier) {
93+
// Re-assemble sub spec
94+
if (_mappedSpecifier === spec && subSpec) {
95+
_mappedSpecifier += subSpec;
96+
}
97+
const mappedSpecifier = this.#getMappedSpecifier(_mappedSpecifier);
98+
99+
if (mappedSpecifier !== _mappedSpecifier) {
100+
this.imports.set(specifier, mappedSpecifier);
101+
}
102+
specifier = mappedSpecifier;
103+
}
104+
105+
return specifier;
106+
}
107+
108+
process(raw) {
109+
if (!raw) {
110+
throw new ERR_INVALID_IMPORT_MAP('top level must be a plain object');
111+
}
112+
113+
// Validation and normalization
114+
if (raw.imports === null || typeof raw.imports !== 'object' || ArrayIsArray(raw.imports)) {
115+
throw new ERR_INVALID_IMPORT_MAP('top level key "imports" is required and must be a plain object');
116+
}
117+
if (raw.scopes === null || typeof raw.scopes !== 'object' || ArrayIsArray(raw.scopes)) {
118+
throw new ERR_INVALID_IMPORT_MAP('top level key "scopes" is required and must be a plain object');
119+
}
120+
121+
// Normalize imports
122+
const importsEntries = ObjectEntries(raw.imports);
123+
for (let i = 0; i < importsEntries.length; i++) {
124+
const { 0: specifier, 1: mapping } = importsEntries[i];
125+
if (!specifier || typeof specifier !== 'string') {
126+
throw new ERR_INVALID_IMPORT_MAP('module specifier keys must be non-empty strings');
127+
}
128+
if (!mapping || typeof mapping !== 'string') {
129+
throw new ERR_INVALID_IMPORT_MAP('module specifier values must be non-empty strings');
130+
}
131+
if (StringPrototypeEndsWith(specifier, '/') && !StringPrototypeEndsWith(mapping, '/')) {
132+
throw new ERR_INVALID_IMPORT_MAP('module specifier keys ending with "/" must have values that end with "/"');
133+
}
134+
135+
this.imports.set(specifier, mapping);
136+
}
137+
138+
// Normalize scopes
139+
// Sort the keys according to spec and add to the map in order
140+
// which preserves the sorted map requirement
141+
const sortedScopes = ArrayPrototypeReverse(ArrayPrototypeSort(ObjectKeys(raw.scopes)));
142+
for (let i = 0; i < sortedScopes.length; i++) {
143+
let scope = sortedScopes[i];
144+
const _scopeMap = raw.scopes[scope];
145+
if (!scope || typeof scope !== 'string') {
146+
throw new ERR_INVALID_IMPORT_MAP('import map scopes keys must be non-empty strings');
147+
}
148+
if (!_scopeMap || typeof _scopeMap !== 'object') {
149+
throw new ERR_INVALID_IMPORT_MAP(`scope values must be plain objects (${scope} is ${typeof _scopeMap})`);
150+
}
151+
152+
// Normalize scope
153+
debugger
154+
scope = new URL(scope, this.#baseURL);
155+
156+
const scopeMap = new SafeMap();
157+
const scopeEntries = ObjectEntries(_scopeMap);
158+
for (let i = 0; i < scopeEntries.length; i++) {
159+
const { 0: specifier, 1: mapping } = scopeEntries[i];
160+
if (StringPrototypeEndsWith(specifier, '/') && !StringPrototypeEndsWith(mapping, '/')) {
161+
throw new ERR_INVALID_IMPORT_MAP('module specifier keys ending with "/" must have values that end with "/"');
162+
}
163+
scopeMap.set(specifier, mapping);
164+
}
165+
166+
this.scopes.set(scope, scopeMap);
167+
}
168+
}
169+
}
170+
171+
module.exports = {
172+
ImportMap,
173+
};

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: 50 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const { getPackageScopeConfig } = require('internal/modules/esm/package_config')
5959
const { getConditionsSet } = require('internal/modules/esm/utils');
6060
const packageJsonReader = require('internal/modules/package_json_reader');
6161
const { internalModuleStat } = internalBinding('fs');
62+
const { shouldBeTreatedAsRelativeOrAbsolutePath, isRelativeSpecifier } = require('internal/modules/helpers');
6263

6364
/**
6465
* @typedef {import('internal/modules/esm/package_config.js').PackageConfig} PackageConfig
@@ -861,30 +862,6 @@ function isBareSpecifier(specifier) {
861862
return specifier[0] && specifier[0] !== '/' && specifier[0] !== '.';
862863
}
863864

864-
/**
865-
* Determines whether a specifier is a relative path.
866-
* @param {string} specifier - The specifier to check.
867-
*/
868-
function isRelativeSpecifier(specifier) {
869-
if (specifier[0] === '.') {
870-
if (specifier.length === 1 || specifier[1] === '/') { return true; }
871-
if (specifier[1] === '.') {
872-
if (specifier.length === 2 || specifier[2] === '/') { return true; }
873-
}
874-
}
875-
return false;
876-
}
877-
878-
/**
879-
* Determines whether a specifier should be treated as a relative or absolute path.
880-
* @param {string} specifier - The specifier to check.
881-
*/
882-
function shouldBeTreatedAsRelativeOrAbsolutePath(specifier) {
883-
if (specifier === '') { return false; }
884-
if (specifier[0] === '/') { return true; }
885-
return isRelativeSpecifier(specifier);
886-
}
887-
888865
/**
889866
* Resolves a module specifier to a URL.
890867
* @param {string} specifier - The module specifier to resolve.
@@ -1026,6 +1003,35 @@ function throwIfInvalidParentURL(parentURL) {
10261003
}
10271004
}
10281005

1006+
/**
1007+
* Process policy
1008+
*/
1009+
function processPolicy(specifier, context) {
1010+
const { parentURL, conditions } = context;
1011+
const redirects = policy.manifest.getDependencyMapper(parentURL);
1012+
if (redirects) {
1013+
const { resolve, reaction } = redirects;
1014+
const destination = resolve(specifier, new SafeSet(conditions));
1015+
let missing = true;
1016+
if (destination === true) {
1017+
missing = false;
1018+
} else if (destination) {
1019+
const href = destination.href;
1020+
return { __proto__: null, url: href };
1021+
}
1022+
if (missing) {
1023+
// Prevent network requests from firing if resolution would be banned.
1024+
// Network requests can extract data by doing things like putting
1025+
// secrets in query params
1026+
reaction(new ERR_MANIFEST_DEPENDENCY_MISSING(
1027+
parentURL,
1028+
specifier,
1029+
ArrayPrototypeJoin([...conditions], ', ')),
1030+
);
1031+
}
1032+
}
1033+
}
1034+
10291035
/**
10301036
* Resolves the given specifier using the provided context, which includes the parent URL and conditions.
10311037
* 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) {
10371043
*/
10381044
function defaultResolve(specifier, context = {}) {
10391045
let { parentURL, conditions } = context;
1046+
const { importMap } = context;
10401047
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-
}
10651048

10661049
let parsedParentURL;
10671050
if (parentURL) {
@@ -1079,8 +1062,19 @@ function defaultResolve(specifier, context = {}) {
10791062
} else {
10801063
parsed = new URL(specifier);
10811064
}
1065+
} catch {
1066+
// Ignore exception
1067+
}
10821068

1083-
// Avoid accessing the `protocol` property due to the lazy getters.
1069+
// Import maps are processed before policies and data/http handling
1070+
// so policies apply to the result of any mapping
1071+
if (importMap) {
1072+
// Intentionally mutating here as we don't think it is a problem
1073+
parsed = specifier = importMap.resolve(parsed || specifier, parsedParentURL);
1074+
}
1075+
1076+
// Avoid accessing the `protocol` property due to the lazy getters.
1077+
if (parsed) {
10841078
const protocol = parsed.protocol;
10851079
if (protocol === 'data:' ||
10861080
(experimentalNetworkImports &&
@@ -1092,8 +1086,13 @@ function defaultResolve(specifier, context = {}) {
10921086
) {
10931087
return { __proto__: null, url: parsed.href };
10941088
}
1095-
} catch {
1096-
// Ignore exception
1089+
}
1090+
1091+
if (parentURL && policy?.manifest) {
1092+
const policyResolution = processPolicy(specifier, context);
1093+
if (policyResolution) {
1094+
return policyResolution;
1095+
}
10971096
}
10981097

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

lib/internal/modules/helpers.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,30 @@ function normalizeReferrerURL(referrer) {
298298
return new URL(referrer).href;
299299
}
300300

301+
/**
302+
* Determines whether a specifier is a relative path.
303+
* @param {string} specifier - The specifier to check.
304+
*/
305+
function isRelativeSpecifier(specifier) {
306+
if (specifier[0] === '.') {
307+
if (specifier.length === 1 || specifier[1] === '/') { return true; }
308+
if (specifier[1] === '.') {
309+
if (specifier.length === 2 || specifier[2] === '/') { return true; }
310+
}
311+
}
312+
return false;
313+
}
314+
315+
/**
316+
* Determines whether a specifier should be treated as a relative or absolute path.
317+
* @param {string} specifier - The specifier to check.
318+
*/
319+
function shouldBeTreatedAsRelativeOrAbsolutePath(specifier) {
320+
if (specifier === '') { return false; }
321+
if (specifier[0] === '/') { return true; }
322+
return isRelativeSpecifier(specifier);
323+
}
324+
301325
module.exports = {
302326
addBuiltinLibsToObject,
303327
getCjsConditions,
@@ -307,4 +331,6 @@ module.exports = {
307331
normalizeReferrerURL,
308332
stripBOM,
309333
toRealPath,
334+
isRelativeSpecifier,
335+
shouldBeTreatedAsRelativeOrAbsolutePath,
310336
};

0 commit comments

Comments
 (0)