UNPKG

@velcro/resolver

Version:

Resolve references to absolute urls using the node module resolution algorithm using an generic host interface

789 lines (778 loc) 30.5 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var tsPrimitives = require('ts-primitives'); (function (ResolvedEntryKind) { ResolvedEntryKind["Directory"] = "directory"; ResolvedEntryKind["File"] = "file"; })(exports.ResolvedEntryKind || (exports.ResolvedEntryKind = {})); function isValidPackageJson(json) { return (typeof json === 'object' && json !== null && !hasInvalidOptionalStringField(json, 'name') && !hasInvalidOptionalStringField(json, 'version') && !hasInvalidBrowserField(json) && !hasInvalidOptionalStringField(json, 'main') && !hasInvalidOptionalStringField(json, 'module') && !hasInvalidOptionalStringField(json, 'jsnext:main') && !hasInvalidOptionalStringField(json, 'unpkg') && !hasInvalidDependenciesField(json, 'dependencies') && !hasInvalidDependenciesField(json, 'devDependencies') && !hasInvalidDependenciesField(json, 'peerDependencies')); } function hasInvalidBrowserField(json) { let error = ''; const browser = json.browser; if (browser) { if (typeof browser === 'object') { for (const key in browser) { if (typeof key !== 'string') { error = `The key ${key} of .browser must be a string`; break; } if (typeof browser[key] !== 'string' && browser[key] !== false) { error = `The value ${key} of .browser must be a string or false`; break; } } } } return error; } function hasInvalidOptionalStringField(json, field) { return json[field] !== undefined && typeof json[field] !== 'string'; } function hasInvalidDependenciesField(json, field) { return (json[field] !== undefined && typeof json[field] === 'object' && json[field] !== null && !Object.keys(json[field]).every(key => typeof key === 'string' && typeof json[field][key] === 'string')); } const CHAR_DOT = 46; /* . */ const CHAR_FORWARD_SLASH = 47; /* / */ const TRAILING_SLASH_RX = /\/?$/; function ensureTrailingSlash(pathname) { return pathname.replace(TRAILING_SLASH_RX, '/'); } function parseBufferAsPackageJson(decoder, content, spec) { try { const text = decoder.decode(content); return parseTextAsPackageJson(text, spec); } catch (err) { throw new Error(`Error decoding manifest buffer for package ${spec}: ${err.message}`); } } function parseTextAsPackageJson(text, spec) { let json; try { json = JSON.parse(text); } catch (err) { throw new Error(`Error parsing manifest as json for package ${spec}: ${err.message}`); } if (!isValidPackageJson(json)) { throw new Error(`Invalid manifest for the package ${spec}`); } return json; } function getFirstPathSegmentAfterPrefix(child, parent) { const childHref = child.pathname; const parentHref = parent.pathname; const parentOffset = parentHref.charAt(parentHref.length - 1) === '/' ? -1 : 0; for (let i = 0; i <= childHref.length; i++) { if (i < parentHref.length) { if (childHref.charAt(i) !== parentHref.charAt(i)) { throw new Error(`The child entry ${child.href} does not have the pathname of ${parent.href} as a prefix`); } } else if (i === parentHref.length + parentOffset) { if (childHref.charAt(i) !== '/') { throw new Error(`The child entry ${child.href} does not have the pathname of ${parent.href} as a prefix`); } } else if (childHref.charAt(i) === '/') { return childHref.slice(parentHref.length + 1 + parentOffset, i); } } return childHref.slice(parentHref.length + 1 + parentOffset); } function validateString(value, name) { if (typeof value !== 'string') { throw new TypeError(`The '${name}' argument must be of type string but got ${typeof value}`); } } function basename(path, ext) { if (ext !== undefined) { validateString(ext, 'ext'); } validateString(path, 'path'); let start = 0; let end = -1; let matchedSlash = true; let i; if (ext !== undefined && ext.length > 0 && ext.length <= path.length) { if (ext.length === path.length && ext === path) { return ''; } let extIdx = ext.length - 1; let firstNonSlashEnd = -1; for (i = path.length - 1; i >= start; --i) { const code = path.charCodeAt(i); if (isPathSeparator(code)) { // If we reached a path separator that was not part of a set of path // separators at the end of the string, stop now if (!matchedSlash) { start = i + 1; break; } } else { if (firstNonSlashEnd === -1) { // We saw the first non-path separator, remember this index in case // we need it if the extension ends up not matching matchedSlash = false; firstNonSlashEnd = i + 1; } if (extIdx >= 0) { // Try to match the explicit extension if (code === ext.charCodeAt(extIdx)) { if (--extIdx === -1) { // We matched the extension, so mark this as the end of our path // component end = i; } } else { // Extension does not match, so our result is the entire path // component extIdx = -1; end = firstNonSlashEnd; } } } } if (start === end) { end = firstNonSlashEnd; } else if (end === -1) { end = path.length; } return path.slice(start, end); } else { for (i = path.length - 1; i >= start; --i) { if (isPathSeparator(path.charCodeAt(i))) { // If we reached a path separator that was not part of a set of path // separators at the end of the string, stop now if (!matchedSlash) { start = i + 1; break; } } else if (end === -1) { // We saw the first non-path separator, mark this as the end of our // path component matchedSlash = false; end = i + 1; } } if (end === -1) { return ''; } return path.slice(start, end); } } function extname(path) { validateString(path, 'path'); let start = 0; let startDot = -1; let startPart = 0; let end = -1; let matchedSlash = true; // Track the state of characters (if any) we see before our first dot and // after any path separator we find let preDotState = 0; for (let i = path.length - 1; i >= start; --i) { const code = path.charCodeAt(i); if (isPathSeparator(code)) { // If we reached a path separator that was not part of a set of path // separators at the end of the string, stop now if (!matchedSlash) { startPart = i + 1; break; } continue; } if (end === -1) { // We saw the first non-path separator, mark this as the end of our // extension matchedSlash = false; end = i + 1; } if (code === CHAR_DOT) { // If this is our first dot, mark it as the start of our extension if (startDot === -1) { startDot = i; } else if (preDotState !== 1) { preDotState = 1; } } else if (startDot !== -1) { // We saw a non-dot and non-path separator before our dot, so we should // have a good chance at having a non-empty extension preDotState = -1; } } if (startDot === -1 || end === -1 || // We saw a non-dot character immediately before the dot preDotState === 0 || // The (right-most) trimmed path component is exactly '..' (preDotState === 1 && startDot === end - 1 && startDot === startPart + 1)) { return ''; } return path.slice(startDot, end); } function dirname(path) { validateString(path, 'path'); const len = path.length; if (len === 0) { return '.'; } let rootEnd = -1; let end = -1; let matchedSlash = true; let offset = 0; const code = path.charCodeAt(0); // Try to match a root if (len > 1) { if (isPathSeparator(code)) { // Possible UNC root rootEnd = offset = 1; if (isPathSeparator(path.charCodeAt(1))) { // Matched double path separator at beginning let j = 2; let last = j; // Match 1 or more non-path separators for (; j < len; ++j) { if (isPathSeparator(path.charCodeAt(j))) { break; } } if (j < len && j !== last) { // Matched! last = j; // Match 1 or more path separators for (; j < len; ++j) { if (!isPathSeparator(path.charCodeAt(j))) { break; } } if (j < len && j !== last) { // Matched! last = j; // Match 1 or more non-path separators for (; j < len; ++j) { if (isPathSeparator(path.charCodeAt(j))) { break; } } if (j === len) { // We matched a UNC root only return path; } if (j !== last) { // We matched a UNC root with leftovers // Offset by 1 to include the separator after the UNC root to // treat it as a "normal root" on top of a (UNC) root rootEnd = offset = j + 1; } } } } } } else if (isPathSeparator(code)) { // `path` contains just a path separator, exit early to avoid // unnecessary work return path; } for (let i = len - 1; i >= offset; --i) { if (isPathSeparator(path.charCodeAt(i))) { if (!matchedSlash) { end = i; break; } } else { // We saw the first non-path separator matchedSlash = false; } } if (end === -1) { if (rootEnd === -1) { return '.'; } else { end = rootEnd; } } return path.slice(0, end); } function join(initialSegment, ...pathSegments) { let pathname = initialSegment; for (let i = 0; i < pathSegments.length; i++) { let segment = pathSegments[i]; if (segment.startsWith('/')) { segment = segment.slice(1); } if (pathname.endsWith('/')) { pathname += segment; } else { pathname += `/${segment}`; } } return pathname; } function resolve(...pathSegments) { let resolvedPath = ''; let resolvedAbsolute = false; for (let i = pathSegments.length - 1; i >= -1 && !resolvedAbsolute; i--) { let path; if (i >= 0) { path = pathSegments[i]; } else { break; } validateString(path, 'path'); // Skip empty entries if (path.length === 0) { continue; } resolvedPath = path + '/' + resolvedPath; resolvedAbsolute = path.charCodeAt(0) === CHAR_FORWARD_SLASH; } // At this point the path should be resolved to a full absolute path, but // handle relative paths to be safe (might happen when process.cwd() fails) // Normalize the path resolvedPath = normalizeString(resolvedPath, !resolvedAbsolute, '/', isPathSeparator); if (resolvedAbsolute) { if (resolvedPath.length > 0) { return '/' + resolvedPath; } else { return '/'; } } else if (resolvedPath.length > 0) { return resolvedPath; } else { return '.'; } } function isPathSeparator(code) { return code === CHAR_FORWARD_SLASH; } function normalizeString(path, allowAboveRoot, separator, isPathSeparator) { let res = ''; let lastSegmentLength = 0; let lastSlash = -1; let dots = 0; let code = -1; for (let i = 0; i <= path.length; ++i) { if (i < path.length) { code = path.charCodeAt(i); } else if (isPathSeparator(code)) { break; } else { code = CHAR_FORWARD_SLASH; } if (isPathSeparator(code)) { if (lastSlash === i - 1 || dots === 1) ; else if (lastSlash !== i - 1 && dots === 2) { if (res.length < 2 || lastSegmentLength !== 2 || res.charCodeAt(res.length - 1) !== CHAR_DOT || res.charCodeAt(res.length - 2) !== CHAR_DOT) { if (res.length > 2) { const lastSlashIndex = res.lastIndexOf(separator); if (lastSlashIndex === -1) { res = ''; lastSegmentLength = 0; } else { res = res.slice(0, lastSlashIndex); lastSegmentLength = res.length - 1 - res.lastIndexOf(separator); } lastSlash = i; dots = 0; continue; } else if (res.length === 2 || res.length === 1) { res = ''; lastSegmentLength = 0; lastSlash = i; dots = 0; continue; } } if (allowAboveRoot) { if (res.length > 0) { res += `${separator}..`; } else { res = '..'; } lastSegmentLength = 2; } } else { if (res.length > 0) { res += separator + path.slice(lastSlash + 1, i); } else { res = path.slice(lastSlash + 1, i); } lastSegmentLength = i - lastSlash - 1; } lastSlash = i; dots = 0; } else if (code === CHAR_DOT && dots !== -1) { ++dots; } else { dots = -1; } } return res; } var util = /*#__PURE__*/Object.freeze({ __proto__: null, ensureTrailingSlash: ensureTrailingSlash, parseBufferAsPackageJson: parseBufferAsPackageJson, getFirstPathSegmentAfterPrefix: getFirstPathSegmentAfterPrefix, basename: basename, extname: extname, dirname: dirname, join: join, resolve: resolve }); class Decoder { constructor() { if (typeof TextDecoder !== 'undefined') { this.decoder = new TextDecoder(); } } decode(buf) { const str = this.decoder ? this.decoder.decode(buf) : Buffer.from(buf).toString('utf-8'); return str.charCodeAt(0) === 0xfeff ? str.slice(1) : str; } } class ExtendableError extends Error { constructor(message) { super(message); this.name = this.constructor.name; if (typeof Error.captureStackTrace === 'function') { Error.captureStackTrace(this, this.constructor); } else { this.stack = new Error(message).stack; } } } class EntryNotFoundError extends ExtendableError { constructor(url) { super(`Not found ${url.href}`); this.url = url; } } class AbstractResolverHost { getCanonicalUrl(_resolver, url) { return Promise.resolve(url); } } const TRAILING_SLASH_RX$1 = /\/?$/; class Resolver { constructor(host, options = {}) { this.host = host; this.decoder = new Decoder(); this.extensions = Array.from(options.extensions || Resolver.defaultExtensions); this.packageMain = options.packageMain || ['main']; } async resolve(url, options = {}) { if (!(url instanceof URL)) { try { url = new URL(url); } catch (err) { throw new Error(`Invalid URL: ${url}: ${err.message}`); } } let token = options.token; if (!token) { const tokenSource = new tsPrimitives.CancellationTokenSource(); token = tokenSource.token; } const optionsWithDefaults = { extensions: options.extensions || this.extensions, ignoreBrowserOverrides: typeof options.ignoreBrowserOverrides === 'undefined' ? false : options.ignoreBrowserOverrides, packageMain: options.packageMain || this.packageMain, token, }; const canonicalUrlPromise = this.host.getCanonicalUrl(this, url, { token }); // To figure out if the url should be resolved as a file or as a directory, we need to first canonicalize the url // if the host supports this and resolve the root url for the given asset. const [canonicalUrl, rootUrl] = await Promise.all([ canonicalUrlPromise, this.host.getResolveRoot(this, url, { token }), ]); if (token.isCancellationRequested) { throw new tsPrimitives.CanceledError('Canceled'); } const rootHref = rootUrl.href; const rootHrefWithoutTrailingSlash = rootHref.replace(TRAILING_SLASH_RX$1, ''); const canonicalHref = canonicalUrl.href; if (!canonicalHref.startsWith(rootHrefWithoutTrailingSlash)) { throw new Error(`Unable to resolve a module whose path ${canonicalHref} is above the host's root ${rootHref}`); } const resolvedUrl = rootHrefWithoutTrailingSlash === canonicalHref || rootHref == canonicalHref ? await this.resolveAsDirectory(canonicalUrl, optionsWithDefaults) : await this.resolveAsFile(canonicalUrl, optionsWithDefaults); if (token.isCancellationRequested) { throw new tsPrimitives.CanceledError('Canceled'); } return { ignored: resolvedUrl === false, resolvedUrl: resolvedUrl || undefined, rootUrl, }; } /** * Resolve a reference treating it as a directory * * 1. If there is a `package.json` file and this has a `main` entry, use that * 2. Assume `index` if no main file is found in the `package.json` manifest * * The outcome of this process will then be resolved as if it were a file. */ async resolveAsDirectory(url, options) { const [rootUrl, entries] = await Promise.all([ this.host.getResolveRoot(this, url, { token: options.token }), this.host.listEntries(this, url, { token: options.token }), ]); if (options.token.isCancellationRequested) { throw new tsPrimitives.CanceledError('Canceled'); } let mainPathname = 'index'; // Step 1: Look for a package.json with an main field const packageJsonEntry = entries.find(entry => basename(entry.url.pathname) === 'package.json'); if (packageJsonEntry) { const packageJsonContent = await this.host.readFileContent(this, packageJsonEntry.url, { token: options.token }); if (options.token.isCancellationRequested) { throw new tsPrimitives.CanceledError('Canceled'); } const packageJson = parseBufferAsPackageJson(this.decoder, packageJsonContent, url.href); for (const packageMain of this.packageMain) { const pathname = packageJson[packageMain]; if (typeof pathname === 'string') { mainPathname = pathname; break; } } } const mainUrl = new URL(resolve(url.pathname, mainPathname), rootUrl); return this.resolveAsFile(mainUrl, options); } /** * Resolve a reference treating it as a file * * 1. List entries in the containing directory * 2. Look for an exact file match or a file match with one of the supplied extensions * 3. Look for a matching child directory and attempt to resolve that as a directory */ async resolveAsFile(url, options) { if (url.pathname === '' || url.pathname === '/') { throw new TypeError(`Unable to resolve the root as a file: ${url.href}`); } const rootUrl = await this.host.getResolveRoot(this, url, { token: options.token }); if (options.token.isCancellationRequested) { throw new tsPrimitives.CanceledError('Canceled'); } // The parent package.json is only interesting if we are going to look at the `browser` // field and then consider browser mapping overrides in there. const parentPackageJson = this.packageMain.includes('browser') && !options.ignoreBrowserOverrides ? await this.readParentPackageJson(url, { token: options.token }) : undefined; if (options.token.isCancellationRequested) { throw new tsPrimitives.CanceledError('Canceled'); } const browserOverrides = new Map(); if (parentPackageJson && typeof parentPackageJson.packageJson.browser === 'object') { const browserMap = parentPackageJson.packageJson.browser; const packageJsonDir = dirname(parentPackageJson.url.pathname); for (const entry in browserMap) { const impliedUrl = new URL(resolve(packageJsonDir, entry), parentPackageJson.url); const targetSpec = browserMap[entry]; const target = targetSpec === false ? false : new URL(resolve(packageJsonDir, targetSpec), parentPackageJson.url); if (impliedUrl.href === url.href) { if (target === false) { return false; } // console.warn('REMAPPED %s to %s', url, target); // We found an exact match so let's make sure we resolve the re-mapped file but // also that we don't go through the browser overrides rodeo again. return this.resolveAsFile(target, { ...options, ignoreBrowserOverrides: true }); } browserOverrides.set(impliedUrl.href, target); } } const containingUrl = new URL(ensureTrailingSlash(dirname(url.pathname)), rootUrl); const filename = basename(url.pathname); const entries = await this.host.listEntries(this, containingUrl, { token: options.token }); if (options.token.isCancellationRequested) { throw new tsPrimitives.CanceledError('Canceled'); } const entryDirectoryMap = new Map(); const entryFileMap = new Map(); for (const entry of entries) { if (entry.url.href === url.href && entry.type == exports.ResolvedEntryKind.File) { // Found an exact match return entry.url; } if (entry.type === exports.ResolvedEntryKind.Directory) { const childFilename = getFirstPathSegmentAfterPrefix(entry.url, containingUrl); entryDirectoryMap.set(childFilename, entry); } else if (entry.type === exports.ResolvedEntryKind.File) { const childFilename = basename(entry.url.pathname); entryFileMap.set(childFilename, entry); } } // Look for browser overrides for (const ext of options.extensions) { const mapping = browserOverrides.get(`${url.href}${ext}`); if (mapping === false) { // console.warn('REMAPPED %s to undefined', url); return false; } else if (mapping) { // console.warn('REMAPPED %s to %s', url, mapping); return this.resolveAsFile(mapping, { ...options, ignoreBrowserOverrides: true }); } const match = entryFileMap.get(`${filename}${ext}`); if (match) { if (match.type !== exports.ResolvedEntryKind.File) { continue; } return match.url; } } // First, attempt to find a matching file or directory const match = entryDirectoryMap.get(filename); if (match) { if (match.type !== exports.ResolvedEntryKind.Directory) { throw new Error(`Invariant violation ${match.type} is unexpected`); } return this.resolveAsDirectory(match.url, options); } throw new EntryNotFoundError(url); } async readParentPackageJson(url, options = {}) { url = await this.host.getCanonicalUrl(this, url, { token: options.token }); if (options.token && options.token.isCancellationRequested) { throw new tsPrimitives.CanceledError('Canceled'); } const hostRootUrl = await this.host.getResolveRoot(this, url, { token: options.token }); if (options.token && options.token.isCancellationRequested) { throw new tsPrimitives.CanceledError('Canceled'); } const hostRootHref = ensureTrailingSlash(hostRootUrl.href); const containingDirUrl = new URL(ensureTrailingSlash(dirname(url.pathname)), url); const readPackageJsonOrRecurse = async (dir) => { if (!dir.href.startsWith(hostRootHref)) { // Terminal condition for recursion return undefined; } const entries = await this.host.listEntries(this, dir, { token: options.token }); if (options.token && options.token.isCancellationRequested) { throw new tsPrimitives.CanceledError('Canceled'); } const packageJsonEntry = entries.find(entry => entry.type === exports.ResolvedEntryKind.File && entry.url.pathname.endsWith('/package.json')); if (packageJsonEntry) { // Found! Let's try to parse try { const parentPackageJsonContent = await this.host.readFileContent(this, packageJsonEntry.url, { token: options.token, }); if (options.token && options.token.isCancellationRequested) { throw new tsPrimitives.CanceledError('Canceled'); } const packageJson = parseBufferAsPackageJson(this.decoder, parentPackageJsonContent, packageJsonEntry.url.href); return { packageJson, url: packageJsonEntry.url }; } catch (err) { if (err instanceof tsPrimitives.CanceledError || (err && err.name === 'CanceledError')) { throw err; } console.warn(`Error reading the parent package manifest for ${url.href} from ${packageJsonEntry.url.href}: ${err.message}`); } } // Not found here, let's try one up const parentDir = new URL(ensureTrailingSlash(dirname(dir.pathname)), dir); // Skip infinite recursion if (parentDir.href === dir.href) { return undefined; } return readPackageJsonOrRecurse(parentDir); }; return readPackageJsonOrRecurse(containingDirUrl); } } Resolver.defaultExtensions = [ '.js', '.jsx', '.es6', '.es', '.mjs', '.ts', '.tsx', '.json', ]; Resolver.path = { basename, dirname, extname, resolve, }; Object.defineProperty(exports, 'CanceledError', { enumerable: true, get: function () { return tsPrimitives.CanceledError; } }); Object.defineProperty(exports, 'CancellationToken', { enumerable: true, get: function () { return tsPrimitives.CancellationToken; } }); Object.defineProperty(exports, 'CancellationTokenSource', { enumerable: true, get: function () { return tsPrimitives.CancellationTokenSource; } }); exports.AbstractResolverHost = AbstractResolverHost; exports.Decoder = Decoder; exports.EntryNotFoundError = EntryNotFoundError; exports.Resolver = Resolver; exports.isValidPackageJson = isValidPackageJson; exports.util = util; //# sourceMappingURL=index.js.map