Skip to content

Commit

Permalink
module: add import map support
Browse files Browse the repository at this point in the history
  • Loading branch information
wesleytodd committed Nov 17, 2023
1 parent 33704c4 commit b0a4cbb
Show file tree
Hide file tree
Showing 35 changed files with 713 additions and 54 deletions.
7 changes: 7 additions & 0 deletions doc/api/errors.md
Expand Up @@ -1947,6 +1947,13 @@ for more information.

An invalid HTTP token was supplied.

<a id="ERR_INVALID_IMPORT_MAP"></a>

### `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.

<a id="ERR_INVALID_IP_ADDRESS"></a>

### `ERR_INVALID_IP_ADDRESS`
Expand Down
1 change: 1 addition & 0 deletions lib/internal/errors.js
Expand Up @@ -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}` : '';
Expand Down
177 changes: 177 additions & 0 deletions 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,
};
33 changes: 32 additions & 1 deletion lib/internal/modules/esm/loader.js
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -391,6 +410,7 @@ class ModuleLoader {
conditions: this.#defaultConditions,
importAttributes,
parentURL,
importMap: this.#importMap,
};

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

class CustomizedModuleLoader {

importMap;

allowImportMetaResolve = true;

/**
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit b0a4cbb

Please sign in to comment.