Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

module: add import map support #50590

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
196 changes: 196 additions & 0 deletions lib/internal/modules/esm/import_map.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
'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()

/**
* Process a raw import map object
* @param {object} raw The plain import map object read from JSON file
* @param {URL} baseURL The url to resolve relative to
*/
constructor(raw, baseURL) {
wesleytodd marked this conversation as resolved.
Show resolved Hide resolved
this.#baseURL = baseURL;
this.process(raw);
}

// These are convenience methods mostly for tests
get baseURL() {
return this.#baseURL;
}

get imports() {
return this.#imports;
}

get scopes() {
return this.#scopes;
}

/**
* Cache for mapped specifiers
* @param {string | URL} originalSpecifier The original specifier being mapped
*/
#getMappedSpecifier(originalSpecifier) {
let mappedSpecifier = this.#specifiers.get(originalSpecifier);

// Specifiers are processed and cached in this.#specifiers
if (!mappedSpecifier) {
// Try processing as a url, fall back for bare specifiers
try {
if (shouldBeTreatedAsRelativeOrAbsolutePath(originalSpecifier)) {
mappedSpecifier = new URL(originalSpecifier, this.#baseURL);
} else {
mappedSpecifier = new URL(originalSpecifier);
}
} catch {
// Ignore exception
mappedSpecifier = originalSpecifier;
}
this.#specifiers.set(originalSpecifier, mappedSpecifier);
}
return mappedSpecifier;
}

/**
* Resolve the module according to the import map.
* @param {string | URL} specifier The specified URL of the module to be resolved.
* @param {string | URL} [parentURL] The URL path of the module's parent.
* @returns {string | URL} The resolved module specifier
*/
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 slashIndex = (typeof specifier === 'string' && specifier.indexOf('/')) || -1;
let subSpec;
let bareSpec;
if (isURL(spec)) {
spec = spec.href;
} else if (slashIndex !== -1) {
slashIndex += 1;
subSpec = StringPrototypeSlice(spec, slashIndex);
bareSpec = StringPrototypeSlice(spec, 0, slashIndex);
}

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 a raw import map object
* @param {object} raw The plain import map object read from JSON file
*/
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);
wesleytodd marked this conversation as resolved.
Show resolved Hide resolved

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,
};
39 changes: 38 additions & 1 deletion lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ class ModuleLoader {
*/
#customizations;

/**
* The loaders importMap instance
*
* Note that this is private to ensure you must call setImportMap
* to ensure this is properly passed down to the customized loader
* @see {ModuleLoader.setImportMap}
*/
#importMap;

constructor(customizations) {
if (getOptionValue('--experimental-network-imports')) {
emitExperimentalWarning('Network Imports');
Expand Down Expand Up @@ -188,11 +197,27 @@ class ModuleLoader {
this.#customizations = customizations;
if (customizations) {
this.allowImportMetaResolve = customizations.allowImportMetaResolve;
if (this.#importMap) {
this.#customizations.importMap = this.#importMap;
}
} else {
this.allowImportMetaResolve = true;
}
}

/**
* Set the import map instance for use when resolving
*
* @param {object} importMap
*/
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 +416,7 @@ class ModuleLoader {
conditions: this.#defaultConditions,
importAttributes,
parentURL,
importMap: this.#importMap,
};

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

class CustomizedModuleLoader {

importMap;

allowImportMetaResolve = true;

/**
Expand Down Expand Up @@ -489,7 +517,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