diff --git a/doc/api/errors.md b/doc/api/errors.md index 50e9f658fcbf3a..f9ed5a82058345 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -1947,6 +1947,13 @@ for more information. An invalid HTTP token was supplied. + + +### `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. + ### `ERR_INVALID_IP_ADDRESS` diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 363b9d3bb8fe8b..c7fbd4aa7e680c 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -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}` : ''; diff --git a/lib/internal/modules/esm/import_map.js b/lib/internal/modules/esm/import_map.js new file mode 100644 index 00000000000000..5dc1aaebf55993 --- /dev/null +++ b/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, +}; diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 6044765c3709f5..b6af1e7399c632 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -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'); @@ -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, @@ -391,6 +410,7 @@ class ModuleLoader { conditions: this.#defaultConditions, importAttributes, parentURL, + importMap: this.#importMap, }; return defaultResolve(originalSpecifier, context); @@ -455,6 +475,8 @@ ObjectSetPrototypeOf(ModuleLoader.prototype, null); class CustomizedModuleLoader { + importMap; + allowImportMetaResolve = true; /** @@ -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) { diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index 06a34c11254a2f..6ff7add57042a5 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -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 @@ -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. @@ -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. @@ -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) { @@ -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 && @@ -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 diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js index 6b30a1d8c76d4b..c5c7d579b63c3c 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -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, @@ -307,4 +331,6 @@ module.exports = { normalizeReferrerURL, stripBOM, toRealPath, + isRelativeSpecifier, + shouldBeTreatedAsRelativeOrAbsolutePath, }; diff --git a/lib/internal/modules/run_main.js b/lib/internal/modules/run_main.js index 1f03c313121db0..59770c8d2fb164 100644 --- a/lib/internal/modules/run_main.js +++ b/lib/internal/modules/run_main.js @@ -2,6 +2,7 @@ const { StringPrototypeEndsWith, + StringPrototypeStartsWith, } = primordials; const { containsModuleSyntax } = internalBinding('contextify'); @@ -51,6 +52,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 @@ -92,10 +94,31 @@ 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 _importMapSpecifier = getOptionValue('--experimental-import-map'); const main = pathToFileURL(mainPath).href; - handleMainPromise(loadESM((esmLoader) => { + handleMainPromise(loadESM(async (esmLoader) => { + // Load import map and throw validation errors + if (_importMapSpecifier) { + const { ImportMap } = require('internal/modules/esm/import_map'); + const { getCWDURL } = require('internal/util'); + const parentURL = getCWDURL(); + const importAttributes = { __proto__: null, type: 'json' }; + + const importMapSpecifier = await esmLoader.resolve(_importMapSpecifier, parentURL.href, importAttributes); + const job = await esmLoader.getJobFromResolveResult(importMapSpecifier, parentURL.href, importAttributes); + const { module: importMapModule } = await job.run(); + + // For data urls, use cwd as the parent url + const importMapURL = StringPrototypeStartsWith(importMapSpecifier.url, 'data:') ? + parentURL : + new URL(importMapSpecifier.url, parentURL); + + // Set method ensures this is passed to the customized loader if necessary + esmLoader.setImportMap(new ImportMap(importMapModule.getNamespace().default, importMapURL)); + } + return esmLoader.import(main, undefined, { __proto__: null }); })); } diff --git a/src/node_options.cc b/src/node_options.cc index 29cb7fc6b29b89..5085f45c42446e 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -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", + "load an import map for module resolution", + &EnvironmentOptions::import_map_specifier, + kAllowedInEnvvar); #if HAVE_INSPECTOR AddOption("--cpu-prof", "Start the V8 CPU profiler on start up, and write the CPU profile " diff --git a/src/node_options.h b/src/node_options.h index 30955c779714ce..9ee0bdf5c67339 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -179,6 +179,7 @@ class EnvironmentOptions : public Options { bool extra_info_on_fatal_exception = true; std::string unhandled_rejections; std::vector userland_loaders; + std::string import_map_specifier; bool verify_base_objects = #ifdef DEBUG true; diff --git a/test/es-module/test-import-map.mjs b/test/es-module/test-import-map.mjs new file mode 100644 index 00000000000000..038ed9d760e2d2 --- /dev/null +++ b/test/es-module/test-import-map.mjs @@ -0,0 +1,262 @@ +// Flags: --expose-internals + +import { spawnPromisified } from '../common/index.mjs'; +import fixtures from '../common/fixtures.js'; +import tmpdir from '../common/tmpdir.js'; +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import path from 'node:path'; +import { execPath } from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import import_map from 'internal/modules/esm/import_map'; +const { ImportMap } = import_map; +import binding from 'internal/test/binding'; +const { primordials: { SafeMap, JSONStringify, ObjectEntries } } = binding; + +const importMapFixture = (...p) => fixtures.path('es-module-loaders', 'import-maps', ...p); +const getImportMapPathURL = (name, filename = 'importmap.json') => { + return pathToFileURL(importMapFixture(name, filename)); +}; +const readImportMapFile = async (name, filename) => { + const url = getImportMapPathURL(name, filename); + return [url, (await import(url, { with: { type: 'json' } })).default]; +}; +const getImportMap = async (name, filename) => { + const [url, rawMap] = await readImportMapFile(name, filename); + return new ImportMap(rawMap, url); +}; +const spawnPromisifiedWtihImportMap = async (name) => { + const entryPoint = importMapFixture(name, 'index.mjs'); + const importMapPath = getImportMapPathURL(name).pathname; + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-import-map', importMapPath, + entryPoint, + ], { + cwd: importMapFixture(name), + }); + assert.strictEqual(code, 0, [stderr, stdout]); + assert.strictEqual(signal, null); + return stdout; +} + +describe('Import Maps', { concurrency: true }, () => { + it('processImportMap - simple import map', async () => { + const importMap = await getImportMap('simple'); + assert.deepStrictEqual(importMap.imports, new SafeMap(Object.entries({ + foo: './foo/index.mjs' + }))); + const expectedScopes = new SafeMap(); + const fooScopeKey = new URL(importMap.baseURL, pathToFileURL('node_modules/foo')); + const fooScopeMap = new SafeMap(Object.entries({ + bar: './baz.mjs' + })); + expectedScopes.set(fooScopeKey, fooScopeMap); + assert.deepStrictEqual(importMap.scopes, expectedScopes); + }); + + it('processImportMap - invalid import map', async () => { + await assert.rejects( + getImportMap('invalid'), + { + code: 'ERR_INVALID_IMPORT_MAP' + } + ); + await assert.rejects( + getImportMap('invalid', 'missing-scopes.json'), + { + code: 'ERR_INVALID_IMPORT_MAP' + } + ); + await assert.rejects( + getImportMap('invalid', 'array-imports.json'), + { + code: 'ERR_INVALID_IMPORT_MAP' + } + ); + }); + + it('resolve - empty import map', async () => { + const importMap = await getImportMap('empty'); + const spec = importMap.resolve('test'); + assert.strictEqual(spec, 'test'); + }); + + it('resolve - simple import map', async () => { + const importMap = await getImportMap('simple'); + const entryPoint = new URL('index.mjs', importMap.baseURL); + assert.strictEqual( + importMap.resolve('foo').pathname, + new URL('foo/index.mjs', entryPoint).pathname + ); + assert.strictEqual( + importMap.resolve('bar', new URL('foo/index.mjs', entryPoint)).pathname, + new URL('baz.mjs', entryPoint).pathname + ); + assert.strictEqual(importMap.resolve('bar'), 'bar'); + }); + + it('resolve - scope with correct precedence', async () => { + const importMap = await getImportMap('scope-order'); + const entryPoint = new URL('index.mjs', importMap.baseURL); + assert.strictEqual( + importMap.resolve('zed', new URL('node_modules/bar', entryPoint)).pathname, + new URL('node_modules/bar/node_modules/zed/index.mjs', entryPoint).pathname + ); + assert.strictEqual( + importMap.resolve('zed', new URL('node_modules/bar/node_modules/zed', entryPoint)).pathname, + new URL('baz.mjs', entryPoint).pathname + ); + }); + + it('resolve - data url', async () => { + const importMap = await getImportMap('data-uri'); + assert.strictEqual( + importMap.resolve('foo').href, + 'data:text/javascript,export default () => \'data foo\'' + ); + assert.strictEqual( + importMap.resolve('foo/bar').href, + 'data:text/javascript,export default () => \'data bar\'' + ); + assert.strictEqual( + importMap.resolve('baz', new URL('./index.mjs', importMap.baseURL)).href, + 'data:text/javascript,export default () => \'data baz\'' + ); + assert.strictEqual( + importMap.resolve('data:text/javascript,export default () => \'bad\'', new URL('./index.mjs', importMap.baseURL)).href, + 'data:text/javascript,export default () => \'data qux\'' + ); + }); + + it('should throw on startup on invalid import map', async () => { + await assert.rejects( + spawnPromisifiedWtihImportMap('invalid'), + /Error \[ERR_INVALID_IMPORT_MAP\]: Invalid import map: top level key "imports" is required and must be a plain object/ + ); + }); + + it('should pass --experimental-import-map', async () => { + const stdout = await spawnPromisifiedWtihImportMap('simple'); + assert.strictEqual(stdout, 'baz\n'); + }); + + it('should handle import maps with data uris', async () => { + const stdout = await spawnPromisifiedWtihImportMap('data-uri'); + assert.strictEqual(stdout, 'data foo\ndata bar\ndata baz\ndata qux\n'); + }); + + it('should handle import maps with bare specifiers', async () => { + const stdout = await spawnPromisifiedWtihImportMap('bare'); + assert.strictEqual(stdout, 'zed\n'); + }); + + it('should handle import maps with absolute paths', async () => { + tmpdir.refresh(); + const entryPoint = importMapFixture('simple', 'index.mjs'); + const importMapPath = path.resolve(tmpdir.path, 'absolute.json'); + + // Read simple import map and convert to absolute file paths + const [, simple] = await readImportMapFile('simple'); + const importsEntries = ObjectEntries(simple.imports); + for (const { 0: key, 1: val } of importsEntries) { + simple.imports[key] = importMapFixture('simple', val); + } + const scopesEntries = ObjectEntries(simple.scopes); + for (const { 0: scope, 1: map } of scopesEntries) { + const mapEntries = ObjectEntries(map); + const absScope = importMapFixture('simple', scope); + simple.scopes[absScope] = {}; + for (const { 0: key, 1: val } of mapEntries) { + simple.scopes[absScope][key] = importMapFixture('simple', val); + } + delete simple.scopes[scope]; + } + + await writeFile(importMapPath, JSONStringify(simple)); + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-import-map', importMapPath, + entryPoint, + ], { + cwd: importMapFixture('simple'), + }); + + assert.strictEqual(code, 0, [stderr, stdout]); + assert.strictEqual(signal, null); + assert.strictEqual(stdout, 'baz\n'); + }); + + it('should handle import maps as data uris', async () => { + const entryPoint = importMapFixture('data-uri', 'index.mjs'); + const [, importMap] = await readImportMapFile('data-uri'); + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + // '--inspect', '--inspect-brk', + '--experimental-import-map', `data:application/json,${JSONStringify(importMap)}`, + entryPoint, + ], { + cwd: importMapFixture('data-uri'), + }); + + assert.strictEqual(code, 0, [stderr, stdout]); + assert.strictEqual(signal, null); + assert.strictEqual(stdout, 'data foo\ndata bar\ndata baz\ndata qux\n'); + }); + + it('should handle http imports', async () => { + const entryPoint = importMapFixture('simple', 'index.mjs'); + const server = http.createServer((req, res) => { + res + .writeHead(200, { 'Content-Type': 'application/javascript' }) + .end('export default () => \'http\''); + }); + await (new Promise((resolve, reject) => { + server.listen((err) => { + if (err) return reject(err); + resolve(); + }); + })); + const { port } = server.address(); + + tmpdir.refresh(); + const importMapPath = path.resolve(tmpdir.path, 'http.json'); + await writeFile(importMapPath, JSONStringify({ + imports: { + foo: `http://localhost:${port}` + }, + scopes: {} + })); + + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--experimental-network-imports', + '--experimental-import-map', importMapPath, + entryPoint, + ], { + cwd: importMapFixture('simple'), + }); + + server.close(); + assert.strictEqual(code, 0, stderr); + assert.strictEqual(signal, null); + assert.strictEqual(stdout, 'http\n'); + }); + + it('should work with other loaders', async () => { + const entryPoint = importMapFixture('simple', 'index.mjs'); + const importMapPath = getImportMapPathURL('simple').pathname; + const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ + '--no-warnings', + '--experimental-import-map', importMapPath, + '--loader', fixtures.fileURL('es-module-loaders', 'loader-resolve-passthru.mjs'), + entryPoint, + ], { + cwd: importMapFixture(), + }); + + assert.strictEqual(code, 0, [stderr, stdout].join('\n')); + assert.strictEqual(signal, null); + assert.strictEqual(stdout, 'baz\n'); + }); +}); diff --git a/test/fixtures/es-module-loaders/import-maps/bare/importmap.json b/test/fixtures/es-module-loaders/import-maps/bare/importmap.json new file mode 100644 index 00000000000000..6e190c13b58099 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/bare/importmap.json @@ -0,0 +1,10 @@ +{ + "imports": { + "foo": "bar" + }, + "scopes": { + "node_modules/bar": { + "baz": "zed" + } + } +} diff --git a/test/fixtures/es-module-loaders/import-maps/bare/index.mjs b/test/fixtures/es-module-loaders/import-maps/bare/index.mjs new file mode 100644 index 00000000000000..6e57ae8261907b --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/bare/index.mjs @@ -0,0 +1,2 @@ +import foo from 'foo'; +console.log(foo()); diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/index.mjs b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/index.mjs new file mode 100644 index 00000000000000..f50f1f6ada0c10 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/index.mjs @@ -0,0 +1,4 @@ +import baz from 'baz'; +export default () => { + return baz(); +} diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/package.json b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/package.json new file mode 100644 index 00000000000000..27210b7a5b4293 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/bar/package.json @@ -0,0 +1,5 @@ +{ + "exports": { + ".": "./index.mjs" + } +} diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/index.mjs b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/index.mjs new file mode 100644 index 00000000000000..fd8809d269b73b --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/index.mjs @@ -0,0 +1,3 @@ +export default () => { + throw new Error('baz should have been remapped to zed'); +} diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/package.json b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/package.json new file mode 100644 index 00000000000000..27210b7a5b4293 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/baz/package.json @@ -0,0 +1,5 @@ +{ + "exports": { + ".": "./index.mjs" + } +} diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/index.mjs b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/index.mjs new file mode 100644 index 00000000000000..aee26b7d61bdad --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/index.mjs @@ -0,0 +1,3 @@ +export default () => { + throw new Error('foo should have been remapped to bar'); +} diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/package.json b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/package.json new file mode 100644 index 00000000000000..27210b7a5b4293 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/foo/package.json @@ -0,0 +1,5 @@ +{ + "exports": { + ".": "./index.mjs" + } +} diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/index.mjs b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/index.mjs new file mode 100644 index 00000000000000..a6628b588d5a75 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/index.mjs @@ -0,0 +1,3 @@ +export default () => { + return 'zed'; +} diff --git a/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/package.json b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/package.json new file mode 100644 index 00000000000000..27210b7a5b4293 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/bare/node_modules/zed/package.json @@ -0,0 +1,5 @@ +{ + "exports": { + ".": "./index.mjs" + } +} diff --git a/test/fixtures/es-module-loaders/import-maps/data-uri/baz.mjs b/test/fixtures/es-module-loaders/import-maps/data-uri/baz.mjs new file mode 100644 index 00000000000000..e9ba458c37dcd6 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/data-uri/baz.mjs @@ -0,0 +1 @@ +throw new Error('baz.mjs should be remapped to a data uri'); diff --git a/test/fixtures/es-module-loaders/import-maps/data-uri/importmap.json b/test/fixtures/es-module-loaders/import-maps/data-uri/importmap.json new file mode 100644 index 00000000000000..07e61fdd68c69f --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/data-uri/importmap.json @@ -0,0 +1,12 @@ +{ + "imports": { + "foo": "data:text/javascript,export default () => 'data foo'", + "foo/bar": "data:text/javascript,export default () => 'data bar'", + "data:text/javascript,export default () => 'bad'": "data:text/javascript,export default () => 'data qux'" + }, + "scopes": { + "./index.mjs": { + "baz": "data:text/javascript,export default () => 'data baz'" + } + } +} diff --git a/test/fixtures/es-module-loaders/import-maps/data-uri/index.mjs b/test/fixtures/es-module-loaders/import-maps/data-uri/index.mjs new file mode 100644 index 00000000000000..4c7fece9f0ac5d --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/data-uri/index.mjs @@ -0,0 +1,11 @@ +import foo from 'foo'; +console.log(foo()); + +import bar from 'foo/bar'; +console.log(bar()); + +import baz from 'baz'; +console.log(baz()); + +import qux from 'data:text/javascript,export default () => \'bad\''; +console.log(qux()); diff --git a/test/fixtures/es-module-loaders/import-maps/empty/importmap.json b/test/fixtures/es-module-loaders/import-maps/empty/importmap.json new file mode 100644 index 00000000000000..9cac6b3e8a3b11 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/empty/importmap.json @@ -0,0 +1,4 @@ +{ + "imports": {}, + "scopes": {} +} diff --git a/test/fixtures/es-module-loaders/import-maps/invalid/array-imports.json b/test/fixtures/es-module-loaders/import-maps/invalid/array-imports.json new file mode 100644 index 00000000000000..355fafaacdc141 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/invalid/array-imports.json @@ -0,0 +1,3 @@ +{ + "imports": [] +} diff --git a/test/fixtures/es-module-loaders/import-maps/invalid/importmap.json b/test/fixtures/es-module-loaders/import-maps/invalid/importmap.json new file mode 100644 index 00000000000000..20f606f9935ea5 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/invalid/importmap.json @@ -0,0 +1,4 @@ +{ + "missing": "the required keys", + "scopes": [] +} diff --git a/test/fixtures/es-module-loaders/import-maps/invalid/missing-scopes.json b/test/fixtures/es-module-loaders/import-maps/invalid/missing-scopes.json new file mode 100644 index 00000000000000..959a501196db82 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/invalid/missing-scopes.json @@ -0,0 +1,4 @@ +{ + "imports": {}, + "missing": "the scopes key" +} diff --git a/test/fixtures/es-module-loaders/import-maps/scope-order/importmap.json b/test/fixtures/es-module-loaders/import-maps/scope-order/importmap.json new file mode 100644 index 00000000000000..c7a1c2caacd1f6 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/scope-order/importmap.json @@ -0,0 +1,16 @@ +{ + "imports": { + "foo": "./node_modules/foo/index.mjs" + }, + "scopes": { + "node_modules/foo": { + "bar": "./node_modules/bar/zed.mjs" + }, + "node_modules/bar": { + "zed": "./node_modules/bar/node_modules/zed/index.mjs" + }, + "node_modules/bar/node_modules/zed": { + "zed": "./baz.mjs" + } + } +} diff --git a/test/fixtures/es-module-loaders/import-maps/simple/baz.mjs b/test/fixtures/es-module-loaders/import-maps/simple/baz.mjs new file mode 100644 index 00000000000000..eecc080ab601de --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/simple/baz.mjs @@ -0,0 +1,3 @@ +export default () => { + return 'baz'; +}; \ No newline at end of file diff --git a/test/fixtures/es-module-loaders/import-maps/simple/foo/index.mjs b/test/fixtures/es-module-loaders/import-maps/simple/foo/index.mjs new file mode 100644 index 00000000000000..683543f39a735c --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/simple/foo/index.mjs @@ -0,0 +1,4 @@ +import bar from 'bar'; +export default () => { + return bar(); +} \ No newline at end of file diff --git a/test/fixtures/es-module-loaders/import-maps/simple/foo/package.json b/test/fixtures/es-module-loaders/import-maps/simple/foo/package.json new file mode 100644 index 00000000000000..94d250b64f9ac8 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/simple/foo/package.json @@ -0,0 +1,3 @@ +{ + "main": "index.mjs" +} \ No newline at end of file diff --git a/test/fixtures/es-module-loaders/import-maps/simple/importmap.json b/test/fixtures/es-module-loaders/import-maps/simple/importmap.json new file mode 100644 index 00000000000000..4cd4dc21ad3138 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/simple/importmap.json @@ -0,0 +1,10 @@ +{ + "imports": { + "foo": "./foo/index.mjs" + }, + "scopes": { + "foo": { + "bar": "./baz.mjs" + } + } +} diff --git a/test/fixtures/es-module-loaders/import-maps/simple/index.mjs b/test/fixtures/es-module-loaders/import-maps/simple/index.mjs new file mode 100644 index 00000000000000..591640d8bb7a2d --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/simple/index.mjs @@ -0,0 +1,2 @@ +import foo from 'foo'; +console.log(foo()); \ No newline at end of file diff --git a/test/fixtures/es-module-loaders/import-maps/simple/node_modules/foo/index.mjs b/test/fixtures/es-module-loaders/import-maps/simple/node_modules/foo/index.mjs new file mode 100644 index 00000000000000..5092a2a87fa621 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/simple/node_modules/foo/index.mjs @@ -0,0 +1,3 @@ +export default () => { + throw new Error('foo should have been remapped to ./foo'); +} diff --git a/test/fixtures/es-module-loaders/import-maps/simple/node_modules/foo/package.json b/test/fixtures/es-module-loaders/import-maps/simple/node_modules/foo/package.json new file mode 100644 index 00000000000000..94d250b64f9ac8 --- /dev/null +++ b/test/fixtures/es-module-loaders/import-maps/simple/node_modules/foo/package.json @@ -0,0 +1,3 @@ +{ + "main": "index.mjs" +} \ No newline at end of file