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 7, 2023
1 parent 33704c4 commit 5663357
Show file tree
Hide file tree
Showing 28 changed files with 486 additions and 28 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
107 changes: 107 additions & 0 deletions lib/internal/modules/esm/import_map.js
@@ -0,0 +1,107 @@
'use strict';
const { isURL, URL } = require('internal/url');
const { ObjectEntries, ObjectKeys, SafeMap, ArrayIsArray } = primordials;
const { codes: { ERR_INVALID_IMPORT_MAP } } = require('internal/errors');

class ImportMap {
#baseURL;
imports = new SafeMap();
scopes = new SafeMap();

constructor(raw, baseURL) {
this.#baseURL = baseURL;
processImportMap(this, this.#baseURL, raw);
}

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

resolve(specifier, parentURL = this.baseURL) {
// Process scopes
for (const { 0: prefix, 1: mapping } of this.scopes) {
let mappedSpecifier = mapping.get(specifier);
if (parentURL.pathname.startsWith(prefix.pathname) && mappedSpecifier) {
if (!isURL(mappedSpecifier)) {
mappedSpecifier = new URL(mappedSpecifier, this.baseURL);
mapping.set(specifier, mappedSpecifier);
}
specifier = mappedSpecifier;
break;
}
}

let spec = specifier;
if (isURL(specifier)) {
spec = specifier.pathname;
}
let importMapping = this.imports.get(spec);
if (importMapping) {
if (!isURL(importMapping)) {
importMapping = new URL(importMapping, this.baseURL);
this.imports.set(spec, importMapping);
}
return importMapping;
}

return specifier;
}
}

function processImportMap(importMap, baseURL, raw) {
// Validation and normalization
if (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 (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
for (const { 0: specifier, 1: mapping } of ObjectEntries(raw.imports)) {
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 (specifier.endsWith('/') && !mapping.endsWith('/')) {
throw new ERR_INVALID_IMPORT_MAP('module specifier values for keys ending with / must also end with /');
}

importMap.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 = ObjectKeys(raw.scopes).sort().reverse();
for (let scope of sortedScopes) {
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, baseURL);

const scopeMap = new SafeMap();
for (const { 0: specifier, 1: mapping } of ObjectEntries(_scopeMap)) {
if (specifier.endsWith('/') && !mapping.endsWith('/')) {
throw new ERR_INVALID_IMPORT_MAP('module specifier values for keys ending with / must also end with /');
}
scopeMap.set(specifier, mapping);
}

importMap.scopes.set(scope, scopeMap);
}

return importMap;
}

module.exports = {
ImportMap,
};
6 changes: 6 additions & 0 deletions lib/internal/modules/esm/loader.js
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
76 changes: 49 additions & 27 deletions lib/internal/modules/esm/resolve.js
Expand Up @@ -1026,6 +1026,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 +1066,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 +1085,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 +1109,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
17 changes: 16 additions & 1 deletion lib/internal/modules/run_main.js
Expand Up @@ -51,6 +51,7 @@ function resolveMainPath(main) {
*/
function shouldUseESMLoader(mainPath) {
if (getOptionValue('--experimental-default-type') === 'module') { return true; }
if (getOptionValue('--experimental-import-map')) { return true; }

/**
* @type {string[]} userLoaders A list of custom loaders registered by the user
Expand Down Expand Up @@ -92,10 +93,24 @@ function shouldUseESMLoader(mainPath) {
*/
function runMainESM(mainPath) {
const { loadESM } = require('internal/process/esm_loader');
const { pathToFileURL } = require('internal/url');
const { pathToFileURL, URL } = require('internal/url');
const _importMapPath = getOptionValue('--experimental-import-map');
const main = pathToFileURL(mainPath).href;

handleMainPromise(loadESM((esmLoader) => {
// Load import map and throw validation errors
if (_importMapPath) {
const { ImportMap } = require('internal/modules/esm/import_map');
const { getCWDURL } = require('internal/util');

const importMapPath = esmLoader.resolve(_importMapPath, getCWDURL(), { __proto__: null, type: 'json' });
return esmLoader.import(importMapPath.url, getCWDURL(), { __proto__: null, type: 'json' })
.then((importedMapFile) => {
esmLoader.importMap = new ImportMap(importedMapFile.default, new URL(importMapPath.url));
return esmLoader.import(main, undefined, { __proto__: null });
});
}

return esmLoader.import(main, undefined, { __proto__: null });
}));
}
Expand Down
4 changes: 4 additions & 0 deletions src/node_options.cc
Expand Up @@ -546,6 +546,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
&EnvironmentOptions::prof_process);
// Options after --prof-process are passed through to the prof processor.
AddAlias("--prof-process", { "--prof-process", "--" });
AddOption("--experimental-import-map",
"set the path to an import map.json",
&EnvironmentOptions::import_map_path,
kAllowedInEnvvar);
#if HAVE_INSPECTOR
AddOption("--cpu-prof",
"Start the V8 CPU profiler on start up, and write the CPU profile "
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Expand Up @@ -179,6 +179,7 @@ class EnvironmentOptions : public Options {
bool extra_info_on_fatal_exception = true;
std::string unhandled_rejections;
std::vector<std::string> userland_loaders;
std::string import_map_path;
bool verify_base_objects =
#ifdef DEBUG
true;
Expand Down

0 comments on commit 5663357

Please sign in to comment.