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 11, 2023
1 parent 33704c4 commit b60b205
Show file tree
Hide file tree
Showing 34 changed files with 645 additions and 53 deletions.
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
170 changes: 170 additions & 0 deletions lib/internal/modules/esm/import_map.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
'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, this.#baseURL);
}

// 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) {
// 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;
if (isURL(spec)) {
spec = spec.href;
} else if (hasSlash !== -1) {
hasSlash += 1;
subSpec = StringPrototypeSlice(spec, hasSlash);
spec = StringPrototypeSlice(spec, 0, hasSlash);
}

let _mappedSpecifier = this.#imports.get(spec);
if (_mappedSpecifier) {
// Re-assemble sub spec
if (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,
};
6 changes: 6 additions & 0 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ class ModuleLoader {
*/
#customizations;

/**
* The loaders importMap instance
*/
importMap;

constructor(customizations) {
if (getOptionValue('--experimental-network-imports')) {
emitExperimentalWarning('Network Imports');
Expand Down Expand Up @@ -391,6 +396,7 @@ class ModuleLoader {
conditions: this.#defaultConditions,
importAttributes,
parentURL,
importMap: this.importMap,
};

return defaultResolve(originalSpecifier, context);
Expand Down
101 changes: 50 additions & 51 deletions lib/internal/modules/esm/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const { getPackageScopeConfig } = require('internal/modules/esm/package_config')
const { getConditionsSet } = require('internal/modules/esm/utils');
const packageJsonReader = require('internal/modules/package_json_reader');
const { internalModuleStat } = internalBinding('fs');
const { shouldBeTreatedAsRelativeOrAbsolutePath, isRelativeSpecifier } = require('internal/modules/helpers');

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

/**
* Determines whether a specifier is a relative path.
* @param {string} specifier - The specifier to check.
*/
function isRelativeSpecifier(specifier) {
if (specifier[0] === '.') {
if (specifier.length === 1 || specifier[1] === '/') { return true; }
if (specifier[1] === '.') {
if (specifier.length === 2 || specifier[2] === '/') { return true; }
}
}
return false;
}

/**
* Determines whether a specifier should be treated as a relative or absolute path.
* @param {string} specifier - The specifier to check.
*/
function shouldBeTreatedAsRelativeOrAbsolutePath(specifier) {
if (specifier === '') { return false; }
if (specifier[0] === '/') { return true; }
return isRelativeSpecifier(specifier);
}

/**
* Resolves a module specifier to a URL.
* @param {string} specifier - The module specifier to resolve.
Expand Down Expand Up @@ -1026,6 +1003,35 @@ function throwIfInvalidParentURL(parentURL) {
}
}

/**
* Process policy
*/
function processPolicy(specifier, context) {
const { parentURL, conditions } = context;
const redirects = policy.manifest.getDependencyMapper(parentURL);
if (redirects) {
const { resolve, reaction } = redirects;
const destination = resolve(specifier, new SafeSet(conditions));
let missing = true;
if (destination === true) {
missing = false;
} else if (destination) {
const href = destination.href;
return { __proto__: null, url: href };
}
if (missing) {
// Prevent network requests from firing if resolution would be banned.
// Network requests can extract data by doing things like putting
// secrets in query params
reaction(new ERR_MANIFEST_DEPENDENCY_MISSING(
parentURL,
specifier,
ArrayPrototypeJoin([...conditions], ', ')),
);
}
}
}

/**
* Resolves the given specifier using the provided context, which includes the parent URL and conditions.
* Throws an error if the parent URL is invalid or if the resolution is disallowed by the policy manifest.
Expand All @@ -1037,31 +1043,8 @@ function throwIfInvalidParentURL(parentURL) {
*/
function defaultResolve(specifier, context = {}) {
let { parentURL, conditions } = context;
const { importMap } = context;
throwIfInvalidParentURL(parentURL);
if (parentURL && policy?.manifest) {
const redirects = policy.manifest.getDependencyMapper(parentURL);
if (redirects) {
const { resolve, reaction } = redirects;
const destination = resolve(specifier, new SafeSet(conditions));
let missing = true;
if (destination === true) {
missing = false;
} else if (destination) {
const href = destination.href;
return { __proto__: null, url: href };
}
if (missing) {
// Prevent network requests from firing if resolution would be banned.
// Network requests can extract data by doing things like putting
// secrets in query params
reaction(new ERR_MANIFEST_DEPENDENCY_MISSING(
parentURL,
specifier,
ArrayPrototypeJoin([...conditions], ', ')),
);
}
}
}

let parsedParentURL;
if (parentURL) {
Expand All @@ -1079,8 +1062,19 @@ function defaultResolve(specifier, context = {}) {
} else {
parsed = new URL(specifier);
}
} catch {
// Ignore exception
}

// Avoid accessing the `protocol` property due to the lazy getters.
// Import maps are processed before policies and data/http handling
// so policies apply to the result of any mapping
if (importMap) {
// Intentionally mutating here as we don't think it is a problem
parsed = specifier = importMap.resolve(parsed || specifier, parsedParentURL);
}

// Avoid accessing the `protocol` property due to the lazy getters.
if (parsed) {
const protocol = parsed.protocol;
if (protocol === 'data:' ||
(experimentalNetworkImports &&
Expand All @@ -1092,8 +1086,13 @@ function defaultResolve(specifier, context = {}) {
) {
return { __proto__: null, url: parsed.href };
}
} catch {
// Ignore exception
}

if (parentURL && policy?.manifest) {
const policyResolution = processPolicy(specifier, context);
if (policyResolution) {
return policyResolution;
}
}

// There are multiple deep branches that can either throw or return; instead
Expand Down
26 changes: 26 additions & 0 deletions lib/internal/modules/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,30 @@ function normalizeReferrerURL(referrer) {
return new URL(referrer).href;
}

/**
* Determines whether a specifier is a relative path.
* @param {string} specifier - The specifier to check.
*/
function isRelativeSpecifier(specifier) {
if (specifier[0] === '.') {
if (specifier.length === 1 || specifier[1] === '/') { return true; }
if (specifier[1] === '.') {
if (specifier.length === 2 || specifier[2] === '/') { return true; }
}
}
return false;
}

/**
* Determines whether a specifier should be treated as a relative or absolute path.
* @param {string} specifier - The specifier to check.
*/
function shouldBeTreatedAsRelativeOrAbsolutePath(specifier) {
if (specifier === '') { return false; }
if (specifier[0] === '/') { return true; }
return isRelativeSpecifier(specifier);
}

module.exports = {
addBuiltinLibsToObject,
getCjsConditions,
Expand All @@ -307,4 +331,6 @@ module.exports = {
normalizeReferrerURL,
stripBOM,
toRealPath,
isRelativeSpecifier,
shouldBeTreatedAsRelativeOrAbsolutePath,
};
Loading

0 comments on commit b60b205

Please sign in to comment.