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

WIP: import map loader #1

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
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
107 changes: 107 additions & 0 deletions lib/internal/modules/esm/import_map.js
Original file line number Diff line number Diff line change
@@ -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
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
76 changes: 49 additions & 27 deletions lib/internal/modules/esm/resolve.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading
Loading