UNPKG

enhanced-resolve

Version:

Offers a async require.resolve function. It's highly configurable.

298 lines (265 loc) • 9.74 kB
/* MIT License http://www.opensource.org/licenses/mit-license.php */ "use strict"; const { readJson } = require("./util/fs"); const { createError, findPackageIds, parsePackageMap, } = require("./util/packageMap"); /** @typedef {import("./Resolver")} Resolver */ /** @typedef {import("./Resolver").ResolveContext} ResolveContext */ /** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */ /** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */ /** @typedef {import("./util/packageMap").PackageMap} PackageMap */ /** @typedef {import("./util/packageMap").PackageMapPackages} PackageMapPackages */ /** * @typedef {object} PackageMapOptions * @property {string} configFile absolute path of the configuration file, read lazily when `packages` is not given * @property {PackageMapPackages | null} packages an already-parsed `packages` object, used instead of reading `configFile` */ /** @typedef {(err: Error | null, packageMap?: PackageMap) => void} PackageMapCallback */ const PACKAGE_NAME_REGEXP = /^(@[^/]+\/)?[^/]+/; /** * Resolves bare specifiers through a Node.js package map instead of walking * `node_modules`: the importing package's `dependencies` table names the * package id to use, and that entry's location is handed to the regular * resolution pipeline to finish the job (`exports`, `main`, extensions...). * * The importing package is taken from an explicit package id when the caller * propagates one (`context.packageId`, or `packageId` carried over from a * previous result) and located by path otherwise. Two situations are hard * errors rather than a miss, because guessing would silently resolve to the * wrong package: the importer sitting outside every mapped package, and * several package entries sharing the importer's location. A specifier that is * simply absent from `dependencies` is reported as unresolved, and no * `node_modules` fallback is attempted. * @experimental Package maps are stability 1 (experimental) in Node.js and are * only reachable there behind `--experimental-package-map`. This plugin tracks * that specification and may change with it, including in a patch release. */ module.exports = class PackageMapPlugin { /** * @param {string | ResolveStepHook} source source * @param {PackageMapOptions} options package map options * @param {boolean} symlinks whether the resolver resolves symlinks * @param {string | ResolveStepHook} target target */ constructor(source, options, symlinks, target) { this.source = source; this.options = options; this.symlinks = symlinks; this.target = target; /** @type {PackageMap | undefined} */ this._packageMap = undefined; /** @type {PackageMapCallback[] | undefined} */ this._pending = undefined; } /** * Read and parse the configuration file once, queueing every request that * arrives while the read is in flight so a cold start does not re-read the * same file for each of them. * @param {Resolver} resolver the resolver * @param {ResolveContext} resolveContext resolve context * @param {PackageMapCallback} callback callback * @returns {void} */ _getPackageMap(resolver, resolveContext, callback) { const { configFile, packages } = this.options; if (configFile && resolveContext.fileDependencies) { resolveContext.fileDependencies.add(configFile); } if (this._packageMap !== undefined) { return callback(null, this._packageMap); } if (this._pending) { this._pending.push(callback); return; } this._pending = [callback]; /** * @param {Error | null} error error * @param {PackageMap=} packageMap package map * @returns {void} */ const finish = (error, packageMap) => { const pending = /** @type {PackageMapCallback[]} */ (this._pending); this._pending = undefined; for (const pendingCallback of pending) { pendingCallback(error, packageMap); } }; // Package locations are matched against the paths the pipeline hands // over, and those have their symlinks resolved when `symlinks` is on. // Resolve the configuration file the same way so both sides are spelled // alike; with `symlinks` off both keep the symlinked spelling instead. this._resolveConfigFile(resolver, (realConfigFile) => { if (packages) { /** @type {PackageMap} */ let packageMap; try { packageMap = parsePackageMap({ packages }, realConfigFile); } catch (err) { return finish(/** @type {Error} */ (err)); } this._packageMap = packageMap; return finish(null, packageMap); } readJson( resolver.fileSystem, realConfigFile, { stripComments: false }, (err, content) => { /** @type {Error | null} */ let error = err || null; /** @type {PackageMap | undefined} */ let packageMap; if (!error) { try { packageMap = parsePackageMap( /** @type {import("./Resolver").JsonObject} */ (content), realConfigFile, ); this._packageMap = packageMap; } catch (parseErr) { error = /** @type {Error} */ (parseErr); } } finish(error, packageMap); }, ); }); } /** * Hand back the configuration file path package locations resolve against: * its real path when the resolver follows symlinks, and the path as given * otherwise. A configuration file whose real path cannot be read (it may not * exist, or the file system may not implement it) is used as-is, so the * read below reports the problem. * @param {Resolver} resolver the resolver * @param {(configFile: string) => void} callback callback * @returns {void} */ _resolveConfigFile(resolver, callback) { const { configFile } = this.options; const { realpath } = resolver.fileSystem; if (!this.symlinks || !realpath) return callback(configFile); realpath(configFile, (err, result) => { callback(err || !result ? configFile : String(result)); }); } /** * @param {Resolver} resolver the resolver * @returns {void} */ apply(resolver) { /** @type {ResolveStepHook} */ const target = resolver.ensureHook(this.target); resolver .getHook(this.source) .tapAsync("PackageMapPlugin", (request, resolveContext, callback) => { const req = request.request; if (!req) return callback(); // Builtins are not affected by package maps. if (req.startsWith("node:")) return callback(); const packageMatch = PACKAGE_NAME_REGEXP.exec(req); if (!packageMatch) return callback(); const [packageName] = packageMatch; const innerRequest = `.${req.slice(packageName.length)}`; this._getPackageMap(resolver, resolveContext, (err, packageMap) => { if (err) return callback(err); const { packages } = /** @type {PackageMap} */ (packageMap); const issuerPath = request.path; // A package id may be any key of the `packages` object, the empty // string included, so distinguish "not provided" from falsy. const contextId = request.context && /** @type {string | undefined} */ (request.context.packageId); const explicitId = request.packageId !== undefined ? request.packageId : contextId; /** @type {string} */ let issuerId; if (explicitId !== undefined && explicitId !== null) { if (!packages.has(explicitId)) { return callback( createError( `Unknown package id "${explicitId}" for request "${req}"`, "ERR_PACKAGE_MAP_UNKNOWN_PACKAGE", ), ); } issuerId = explicitId; } else { const ids = issuerPath === false ? [] : findPackageIds( /** @type {PackageMap} */ (packageMap), issuerPath, ); if (ids.length === 0) { return callback( createError( `Cannot resolve "${req}" from "${issuerPath}": the importing file is not inside any package of the package map`, "ERR_PACKAGE_MAP_EXTERNAL_FILE", ), ); } if (ids.length > 1) { return callback( createError( `Cannot resolve "${req}" from "${issuerPath}": the package ids ${ids .map((id) => `"${id}"`) .join( ", ", )} share this location, so the importing package is ambiguous. Propagate the package id of the previous resolution to disambiguate.`, "ERR_PACKAGE_MAP_AMBIGUOUS_PACKAGE", ), ); } [issuerId] = ids; } const issuer = /** @type {import("./util/packageMap").PackageMapEntry} */ ( packages.get(issuerId) ); const targetId = issuer.dependencies.get(packageName); if (targetId === undefined) { if (resolveContext.log) { resolveContext.log( `"${packageName}" is not a dependency of package "${issuerId}" in the package map`, ); } // Bail without a result: the package map is authoritative, so // there is no `node_modules` fallback to try. return callback(null, null); } const targetEntry = /** @type {import("./util/packageMap").PackageMapEntry} */ ( packages.get(targetId) ); /** @type {ResolveRequest} */ const obj = { ...request, path: targetEntry.path, request: innerRequest, packageId: targetId, fullySpecified: request.fullySpecified && innerRequest !== ".", }; resolver.doResolve( target, obj, `resolved "${packageName}" to package "${targetId}" by the package map`, resolveContext, (resolveErr, result) => { if (resolveErr) return callback(resolveErr); if (result) return callback(null, result); // Skip alternatives return callback(null, null); }, ); }); }); } };