Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit b0a4cbb

Browse files
committedNov 17, 2023
module: add import map support
1 parent 33704c4 commit b0a4cbb

35 files changed

+713
-54
lines changed
 

‎doc/api/errors.md

+7
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

+1
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}` : '';
+177
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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);
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+
// When using the customized loader the parent
64+
// will be a string (for transferring to the worker)
65+
// so just handle that here
66+
if (!isURL(parentURL)) {
67+
parentURL = new URL(parentURL);
68+
}
69+
70+
// Process scopes
71+
for (const { 0: prefix, 1: mapping } of this.#scopes) {
72+
const _mappedSpecifier = mapping.get(specifier);
73+
if (StringPrototypeStartsWith(parentURL.pathname, prefix.pathname) && _mappedSpecifier) {
74+
const mappedSpecifier = this.#getMappedSpecifier(_mappedSpecifier);
75+
if (mappedSpecifier !== _mappedSpecifier) {
76+
mapping.set(specifier, mappedSpecifier);
77+
}
78+
specifier = mappedSpecifier;
79+
break;
80+
}
81+
}
82+
83+
// Handle bare specifiers with sub paths
84+
let spec = specifier;
85+
let hasSlash = (typeof specifier === 'string' && specifier.indexOf('/')) || -1;
86+
let subSpec;
87+
let bareSpec;
88+
if (isURL(spec)) {
89+
spec = spec.href;
90+
} else if (hasSlash !== -1) {
91+
hasSlash += 1;
92+
subSpec = StringPrototypeSlice(spec, hasSlash);
93+
bareSpec = StringPrototypeSlice(spec, 0, hasSlash);
94+
}
95+
96+
let _mappedSpecifier = this.#imports.get(bareSpec) || this.#imports.get(spec);
97+
if (_mappedSpecifier) {
98+
// Re-assemble sub spec
99+
if (_mappedSpecifier === spec && subSpec) {
100+
_mappedSpecifier += subSpec;
101+
}
102+
const mappedSpecifier = this.#getMappedSpecifier(_mappedSpecifier);
103+
104+
if (mappedSpecifier !== _mappedSpecifier) {
105+
this.imports.set(specifier, mappedSpecifier);
106+
}
107+
specifier = mappedSpecifier;
108+
}
109+
110+
return specifier;
111+
}
112+
113+
process(raw) {
114+
if (!raw) {
115+
throw new ERR_INVALID_IMPORT_MAP('top level must be a plain object');
116+
}
117+
118+
// Validation and normalization
119+
if (raw.imports === null || typeof raw.imports !== 'object' || ArrayIsArray(raw.imports)) {
120+
throw new ERR_INVALID_IMPORT_MAP('top level key "imports" is required and must be a plain object');
121+
}
122+
if (raw.scopes === null || typeof raw.scopes !== 'object' || ArrayIsArray(raw.scopes)) {
123+
throw new ERR_INVALID_IMPORT_MAP('top level key "scopes" is required and must be a plain object');
124+
}
125+
126+
// Normalize imports
127+
const importsEntries = ObjectEntries(raw.imports);
128+
for (let i = 0; i < importsEntries.length; i++) {
129+
const { 0: specifier, 1: mapping } = importsEntries[i];
130+
if (!specifier || typeof specifier !== 'string') {
131+
throw new ERR_INVALID_IMPORT_MAP('module specifier keys must be non-empty strings');
132+
}
133+
if (!mapping || typeof mapping !== 'string') {
134+
throw new ERR_INVALID_IMPORT_MAP('module specifier values must be non-empty strings');
135+
}
136+
if (StringPrototypeEndsWith(specifier, '/') && !StringPrototypeEndsWith(mapping, '/')) {
137+
throw new ERR_INVALID_IMPORT_MAP('module specifier keys ending with "/" must have values that end with "/"');
138+
}
139+
140+
this.imports.set(specifier, mapping);
141+
}
142+
143+
// Normalize scopes
144+
// Sort the keys according to spec and add to the map in order
145+
// which preserves the sorted map requirement
146+
const sortedScopes = ArrayPrototypeReverse(ArrayPrototypeSort(ObjectKeys(raw.scopes)));
147+
for (let i = 0; i < sortedScopes.length; i++) {
148+
let scope = sortedScopes[i];
149+
const _scopeMap = raw.scopes[scope];
150+
if (!scope || typeof scope !== 'string') {
151+
throw new ERR_INVALID_IMPORT_MAP('import map scopes keys must be non-empty strings');
152+
}
153+
if (!_scopeMap || typeof _scopeMap !== 'object') {
154+
throw new ERR_INVALID_IMPORT_MAP(`scope values must be plain objects (${scope} is ${typeof _scopeMap})`);
155+
}
156+
157+
// Normalize scope
158+
scope = new URL(scope, this.#baseURL);
159+
160+
const scopeMap = new SafeMap();
161+
const scopeEntries = ObjectEntries(_scopeMap);
162+
for (let i = 0; i < scopeEntries.length; i++) {
163+
const { 0: specifier, 1: mapping } = scopeEntries[i];
164+
if (StringPrototypeEndsWith(specifier, '/') && !StringPrototypeEndsWith(mapping, '/')) {
165+
throw new ERR_INVALID_IMPORT_MAP('module specifier keys ending with "/" must have values that end with "/"');
166+
}
167+
scopeMap.set(specifier, mapping);
168+
}
169+
170+
this.scopes.set(scope, scopeMap);
171+
}
172+
}
173+
}
174+
175+
module.exports = {
176+
ImportMap,
177+
};

‎lib/internal/modules/esm/loader.js

+32-1
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ class ModuleLoader {
129129
*/
130130
#customizations;
131131

132+
/**
133+
* The loaders importMap instance
134+
*
135+
* Note: private to ensure you must call setImportMap to ensure
136+
* this is properly passed down to the customized loader
137+
*/
138+
#importMap;
139+
132140
constructor(customizations) {
133141
if (getOptionValue('--experimental-network-imports')) {
134142
emitExperimentalWarning('Network Imports');
@@ -188,11 +196,22 @@ class ModuleLoader {
188196
this.#customizations = customizations;
189197
if (customizations) {
190198
this.allowImportMetaResolve = customizations.allowImportMetaResolve;
199+
if (this.#importMap) {
200+
this.#customizations.importMap = this.#importMap;
201+
}
191202
} else {
192203
this.allowImportMetaResolve = true;
193204
}
194205
}
195206

207+
setImportMap(importMap) {
208+
if (this.#customizations) {
209+
this.#customizations.importMap = importMap;
210+
} else {
211+
this.#importMap = importMap;
212+
}
213+
}
214+
196215
async eval(
197216
source,
198217
url = pathToFileURL(`${process.cwd()}/[eval${++this.evalIndex}]`).href,
@@ -391,6 +410,7 @@ class ModuleLoader {
391410
conditions: this.#defaultConditions,
392411
importAttributes,
393412
parentURL,
413+
importMap: this.#importMap,
394414
};
395415

396416
return defaultResolve(originalSpecifier, context);
@@ -455,6 +475,8 @@ ObjectSetPrototypeOf(ModuleLoader.prototype, null);
455475

456476
class CustomizedModuleLoader {
457477

478+
importMap;
479+
458480
allowImportMetaResolve = true;
459481

460482
/**
@@ -489,7 +511,16 @@ class CustomizedModuleLoader {
489511
* @returns {{ format: string, url: URL['href'] }}
490512
*/
491513
resolve(originalSpecifier, parentURL, importAttributes) {
492-
return hooksProxy.makeAsyncRequest('resolve', undefined, originalSpecifier, parentURL, importAttributes);
514+
// Resolve with import map before passing to loader.
515+
let spec = originalSpecifier;
516+
if (this.importMap) {
517+
spec = this.importMap.resolve(spec, parentURL);
518+
if (spec && isURL(spec)) {
519+
spec = spec.href;
520+
}
521+
}
522+
523+
return hooksProxy.makeAsyncRequest('resolve', undefined, spec, parentURL, importAttributes);
493524
}
494525

495526
resolveSync(originalSpecifier, parentURL, importAttributes) {

0 commit comments

Comments
 (0)
Please sign in to comment.