Skip to content

Commit a32030d

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

File tree

35 files changed

+738
-54
lines changed

35 files changed

+738
-54
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: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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+
/**
24+
* Process a raw import map object
25+
* @param {object} raw The plain import map object read from JSON file
26+
* @param {URL} baseURL The url to resolve relative to
27+
*/
28+
constructor(raw, baseURL) {
29+
this.#baseURL = baseURL;
30+
this.process(raw);
31+
}
32+
33+
// These are convenience methods mostly for tests
34+
get baseURL() {
35+
return this.#baseURL;
36+
}
37+
38+
get imports() {
39+
return this.#imports;
40+
}
41+
42+
get scopes() {
43+
return this.#scopes;
44+
}
45+
46+
/**
47+
* Cache for mapped specifiers
48+
* @param {string | URL} originalSpecifier The original specifier being mapped
49+
*/
50+
#getMappedSpecifier(originalSpecifier) {
51+
let mappedSpecifier = this.#specifiers.get(originalSpecifier);
52+
53+
// Specifiers are processed and cached in this.#specifiers
54+
if (!mappedSpecifier) {
55+
// Try processing as a url, fall back for bare specifiers
56+
try {
57+
if (shouldBeTreatedAsRelativeOrAbsolutePath(originalSpecifier)) {
58+
mappedSpecifier = new URL(originalSpecifier, this.#baseURL);
59+
} else {
60+
mappedSpecifier = new URL(originalSpecifier);
61+
}
62+
} catch {
63+
// Ignore exception
64+
mappedSpecifier = originalSpecifier;
65+
}
66+
this.#specifiers.set(originalSpecifier, mappedSpecifier);
67+
}
68+
return mappedSpecifier;
69+
}
70+
71+
/**
72+
* Resolve the module according to the import map.
73+
* @param {string | URL} specifier The specified URL of the module to be resolved.
74+
* @param {string | URL} [parentURL] The URL path of the module's parent.
75+
* @returns {string | URL} The resolved module specifier
76+
*/
77+
resolve(specifier, parentURL = this.#baseURL) {
78+
// When using the customized loader the parent
79+
// will be a string (for transferring to the worker)
80+
// so just handle that here
81+
if (!isURL(parentURL)) {
82+
parentURL = new URL(parentURL);
83+
}
84+
85+
// Process scopes
86+
for (const { 0: prefix, 1: mapping } of this.#scopes) {
87+
const _mappedSpecifier = mapping.get(specifier);
88+
if (StringPrototypeStartsWith(parentURL.pathname, prefix.pathname) && _mappedSpecifier) {
89+
const mappedSpecifier = this.#getMappedSpecifier(_mappedSpecifier);
90+
if (mappedSpecifier !== _mappedSpecifier) {
91+
mapping.set(specifier, mappedSpecifier);
92+
}
93+
specifier = mappedSpecifier;
94+
break;
95+
}
96+
}
97+
98+
// Handle bare specifiers with sub paths
99+
let spec = specifier;
100+
let slashIndex = (typeof specifier === 'string' && specifier.indexOf('/')) || -1;
101+
let subSpec;
102+
let bareSpec;
103+
if (isURL(spec)) {
104+
spec = spec.href;
105+
} else if (slashIndex !== -1) {
106+
slashIndex += 1;
107+
subSpec = StringPrototypeSlice(spec, slashIndex);
108+
bareSpec = StringPrototypeSlice(spec, 0, slashIndex);
109+
}
110+
111+
let _mappedSpecifier = this.#imports.get(bareSpec) || this.#imports.get(spec);
112+
if (_mappedSpecifier) {
113+
// Re-assemble sub spec
114+
if (_mappedSpecifier === spec && subSpec) {
115+
_mappedSpecifier += subSpec;
116+
}
117+
const mappedSpecifier = this.#getMappedSpecifier(_mappedSpecifier);
118+
119+
if (mappedSpecifier !== _mappedSpecifier) {
120+
this.imports.set(specifier, mappedSpecifier);
121+
}
122+
specifier = mappedSpecifier;
123+
}
124+
125+
return specifier;
126+
}
127+
128+
/**
129+
* Process a raw import map object
130+
* @param {object} raw The plain import map object read from JSON file
131+
*/
132+
process(raw) {
133+
if (!raw) {
134+
throw new ERR_INVALID_IMPORT_MAP('top level must be a plain object');
135+
}
136+
137+
// Validation and normalization
138+
if (raw.imports === null || typeof raw.imports !== 'object' || ArrayIsArray(raw.imports)) {
139+
throw new ERR_INVALID_IMPORT_MAP('top level key "imports" is required and must be a plain object');
140+
}
141+
if (raw.scopes === null || typeof raw.scopes !== 'object' || ArrayIsArray(raw.scopes)) {
142+
throw new ERR_INVALID_IMPORT_MAP('top level key "scopes" is required and must be a plain object');
143+
}
144+
145+
// Normalize imports
146+
const importsEntries = ObjectEntries(raw.imports);
147+
for (let i = 0; i < importsEntries.length; i++) {
148+
const { 0: specifier, 1: mapping } = importsEntries[i];
149+
if (!specifier || typeof specifier !== 'string') {
150+
throw new ERR_INVALID_IMPORT_MAP('module specifier keys must be non-empty strings');
151+
}
152+
if (!mapping || typeof mapping !== 'string') {
153+
throw new ERR_INVALID_IMPORT_MAP('module specifier values must be non-empty strings');
154+
}
155+
if (StringPrototypeEndsWith(specifier, '/') && !StringPrototypeEndsWith(mapping, '/')) {
156+
throw new ERR_INVALID_IMPORT_MAP('module specifier keys ending with "/" must have values that end with "/"');
157+
}
158+
159+
this.imports.set(specifier, mapping);
160+
}
161+
162+
// Normalize scopes
163+
// Sort the keys according to spec and add to the map in order
164+
// which preserves the sorted map requirement
165+
const sortedScopes = ArrayPrototypeReverse(ArrayPrototypeSort(ObjectKeys(raw.scopes)));
166+
for (let i = 0; i < sortedScopes.length; i++) {
167+
let scope = sortedScopes[i];
168+
const _scopeMap = raw.scopes[scope];
169+
if (!scope || typeof scope !== 'string') {
170+
throw new ERR_INVALID_IMPORT_MAP('import map scopes keys must be non-empty strings');
171+
}
172+
if (!_scopeMap || typeof _scopeMap !== 'object') {
173+
throw new ERR_INVALID_IMPORT_MAP(`scope values must be plain objects (${scope} is ${typeof _scopeMap})`);
174+
}
175+
176+
// Normalize scope
177+
scope = new URL(scope, this.#baseURL);
178+
179+
const scopeMap = new SafeMap();
180+
const scopeEntries = ObjectEntries(_scopeMap);
181+
for (let i = 0; i < scopeEntries.length; i++) {
182+
const { 0: specifier, 1: mapping } = scopeEntries[i];
183+
if (StringPrototypeEndsWith(specifier, '/') && !StringPrototypeEndsWith(mapping, '/')) {
184+
throw new ERR_INVALID_IMPORT_MAP('module specifier keys ending with "/" must have values that end with "/"');
185+
}
186+
scopeMap.set(specifier, mapping);
187+
}
188+
189+
this.scopes.set(scope, scopeMap);
190+
}
191+
}
192+
}
193+
194+
module.exports = {
195+
ImportMap,
196+
};

lib/internal/modules/esm/loader.js

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ class ModuleLoader {
129129
*/
130130
#customizations;
131131

132+
/**
133+
* The loaders importMap instance
134+
*
135+
* Note that this is private to ensure you must call setImportMap
136+
* to ensure this is properly passed down to the customized loader
137+
* @see {ModuleLoader.setImportMap}
138+
*/
139+
#importMap;
140+
132141
constructor(customizations) {
133142
if (getOptionValue('--experimental-network-imports')) {
134143
emitExperimentalWarning('Network Imports');
@@ -188,11 +197,27 @@ class ModuleLoader {
188197
this.#customizations = customizations;
189198
if (customizations) {
190199
this.allowImportMetaResolve = customizations.allowImportMetaResolve;
200+
if (this.#importMap) {
201+
this.#customizations.importMap = this.#importMap;
202+
}
191203
} else {
192204
this.allowImportMetaResolve = true;
193205
}
194206
}
195207

208+
/**
209+
* Set the import map instance for use when resolving
210+
*
211+
* @param {object} importMap
212+
*/
213+
setImportMap(importMap) {
214+
if (this.#customizations) {
215+
this.#customizations.importMap = importMap;
216+
} else {
217+
this.#importMap = importMap;
218+
}
219+
}
220+
196221
async eval(
197222
source,
198223
url = pathToFileURL(`${process.cwd()}/[eval${++this.evalIndex}]`).href,
@@ -391,6 +416,7 @@ class ModuleLoader {
391416
conditions: this.#defaultConditions,
392417
importAttributes,
393418
parentURL,
419+
importMap: this.#importMap,
394420
};
395421

396422
return defaultResolve(originalSpecifier, context);
@@ -455,6 +481,8 @@ ObjectSetPrototypeOf(ModuleLoader.prototype, null);
455481

456482
class CustomizedModuleLoader {
457483

484+
importMap;
485+
458486
allowImportMetaResolve = true;
459487

460488
/**
@@ -489,7 +517,16 @@ class CustomizedModuleLoader {
489517
* @returns {{ format: string, url: URL['href'] }}
490518
*/
491519
resolve(originalSpecifier, parentURL, importAttributes) {
492-
return hooksProxy.makeAsyncRequest('resolve', undefined, originalSpecifier, parentURL, importAttributes);
520+
// Resolve with import map before passing to loader.
521+
let spec = originalSpecifier;
522+
if (this.importMap) {
523+
spec = this.importMap.resolve(spec, parentURL);
524+
if (spec && isURL(spec)) {
525+
spec = spec.href;
526+
}
527+
}
528+
529+
return hooksProxy.makeAsyncRequest('resolve', undefined, spec, parentURL, importAttributes);
493530
}
494531

495532
resolveSync(originalSpecifier, parentURL, importAttributes) {

0 commit comments

Comments
 (0)