@velcro/resolver
Version:
Resolve references to absolute urls using the node module resolution algorithm using an generic host interface
764 lines (755 loc) • 29.8 kB
JavaScript
import { CancellationTokenSource, CanceledError } from 'ts-primitives';
export { CanceledError, CancellationToken, CancellationTokenSource } from 'ts-primitives';
var ResolvedEntryKind;
(function (ResolvedEntryKind) {
ResolvedEntryKind["Directory"] = "directory";
ResolvedEntryKind["File"] = "file";
})(ResolvedEntryKind || (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 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 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 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 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 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 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 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 CanceledError('Canceled');
}
const entryDirectoryMap = new Map();
const entryFileMap = new Map();
for (const entry of entries) {
if (entry.url.href === url.href && entry.type == ResolvedEntryKind.File) {
// Found an exact match
return entry.url;
}
if (entry.type === ResolvedEntryKind.Directory) {
const childFilename = getFirstPathSegmentAfterPrefix(entry.url, containingUrl);
entryDirectoryMap.set(childFilename, entry);
}
else if (entry.type === 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 !== 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 !== 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 CanceledError('Canceled');
}
const hostRootUrl = await this.host.getResolveRoot(this, url, { token: options.token });
if (options.token && options.token.isCancellationRequested) {
throw new 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 CanceledError('Canceled');
}
const packageJsonEntry = entries.find(entry => entry.type === 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 CanceledError('Canceled');
}
const packageJson = parseBufferAsPackageJson(this.decoder, parentPackageJsonContent, packageJsonEntry.url.href);
return { packageJson, url: packageJsonEntry.url };
}
catch (err) {
if (err instanceof 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,
};
export { AbstractResolverHost, Decoder, EntryNotFoundError, ResolvedEntryKind, Resolver, isValidPackageJson, util };
//# sourceMappingURL=index.js.map