@velcro/strategy-cdn
Version:
Velcro resolver strategy for resolving modules from a cdn such as Unpkg or JsDelivr
451 lines (443 loc) • 18.2 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var common = require('@velcro/common');
var resolver = require('@velcro/resolver');
var satisfies = require('semver/functions/satisfies');
var validRange = require('semver/ranges/valid');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var satisfies__default = /*#__PURE__*/_interopDefaultLegacy(satisfies);
var validRange__default = /*#__PURE__*/_interopDefaultLegacy(validRange);
function isValidEntry(entry) {
if (!entry || typeof entry !== 'object')
return false;
return isValidFile(entry) || isValidDirectory(entry);
}
function isValidDirectory(entry) {
return (typeof entry === 'object' &&
entry &&
entry.type === resolver.ResolverStrategy.EntryKind.Directory &&
typeof entry.path === 'string' &&
entry.path &&
(typeof entry.files === 'undefined' ||
(Array.isArray(entry.files) && entry.files.every(isValidEntry))));
}
function isValidFile(entry) {
return (typeof entry === 'object' &&
entry &&
entry.type === resolver.ResolverStrategy.EntryKind.File &&
typeof entry.path === 'string' &&
entry.path);
}
function specToString(spec) {
return `${spec.spec}${spec.pathname}`;
}
class JSDelivrCdn {
constructor() {
this.name = 'jsdelivr';
this.specRx = /^\/((@[^/]+\/[^/@]+|[^/@]+)(?:@([^/]+))?)(.*)?$/;
}
isValidUrl(url) {
return url.scheme === JSDelivrCdn.protocol || url.authority === JSDelivrCdn.host;
}
normalizePackageListing(result) {
if (!result || typeof result !== 'object') {
throw new Error(`Unexpected package listing contents`);
}
const files = result.files;
if (!Array.isArray(files)) {
throw new Error(`Unexpected package listing contents`);
}
const mapChildEntry = (parent, child) => {
if (!child || typeof child !== 'object') {
throw new Error(`Unexpected entry in package listing contents`);
}
const name = child.name;
if (typeof name !== 'string') {
throw new Error(`Unexpected entry in package listing contents`);
}
const path = `${parent}/${name}`;
if (child.type === resolver.ResolverStrategy.EntryKind.Directory) {
const files = child.files;
if (!Array.isArray(files)) {
throw new Error(`Unexpected entry in package listing contents`);
}
return {
type: resolver.ResolverStrategy.EntryKind.Directory,
path,
files: files.map((file) => mapChildEntry(path, file)),
};
}
else if (child.type === resolver.ResolverStrategy.EntryKind.File) {
return {
type: resolver.ResolverStrategy.EntryKind.File,
path,
};
}
throw new Error(`Error mapping child entry in package file listing`);
};
return {
type: resolver.ResolverStrategy.EntryKind.Directory,
path: '/',
files: files.map((file) => mapChildEntry('', file)),
};
}
parseUrl(url) {
if (common.Uri.isUri(url)) {
url = url.path;
}
const prefix = `/npm`;
if (!url.startsWith(prefix)) {
throw new Error(`Unable to parse unexpected ${this.name} url: ${url}`);
}
url = url.slice(prefix.length);
/**
* 1: scope + name + version
* 2: scope + name
* 3: version?
* 4: pathname
*/
const matches = url.match(this.specRx);
if (!matches) {
throw new Error(`Unable to parse unexpected unpkg url: ${url}`);
}
return {
spec: matches[1],
name: matches[2],
version: matches[3] || '',
pathname: matches[4] || '',
};
}
urlForPackageFile(spec, pathname) {
return common.Uri.from({
scheme: JSDelivrCdn.protocol,
authority: JSDelivrCdn.host,
path: `/npm/${spec}${pathname}`,
});
}
urlForPackageList(spec) {
return common.Uri.from({
scheme: JSDelivrCdn.protocol,
authority: JSDelivrCdn.dataHost,
path: `/v1/package/npm/${spec}/tree`,
});
}
}
JSDelivrCdn.protocol = 'https';
JSDelivrCdn.host = 'cdn.jsdelivr.net';
JSDelivrCdn.dataHost = 'data.jsdelivr.com';
class UnpkgCdn {
constructor() {
this.name = 'unpkg';
this.UNPKG_SPEC_RX = /^\/((@[^/]+\/[^/@]+|[^/@]+)(?:@([^/]+))?)(.*)?$/;
}
isValidUrl(url) {
return url.scheme === UnpkgCdn.protocol || url.authority === UnpkgCdn.host;
}
normalizePackageListing(result) {
if (!isValidDirectory(result)) {
throw new Error(`Error normalizing directory listing`);
}
return result;
}
parseUrl(url) {
if (common.Uri.isUri(url)) {
url = url.path;
}
/**
* 1: scope + name + version
* 2: scope + name
* 3: version?
* 4: pathname
*/
const matches = url.match(this.UNPKG_SPEC_RX);
if (!matches) {
throw new Error(`Unable to parse unexpected unpkg url: ${url}`);
}
return {
spec: matches[1],
name: matches[2],
version: matches[3] || '',
pathname: matches[4] || '',
};
}
urlForPackageFile(spec, pathname) {
return common.Uri.from({
scheme: UnpkgCdn.protocol,
authority: UnpkgCdn.host,
path: `/${spec}${pathname}`,
});
}
urlForPackageList(spec) {
return common.Uri.from({
scheme: UnpkgCdn.protocol,
authority: UnpkgCdn.host,
path: `/${spec}/`,
query: 'meta',
});
}
}
UnpkgCdn.protocol = 'https';
UnpkgCdn.host = 'unpkg.com';
class CdnStrategy extends resolver.AbstractResolverStrategyWithRoot {
constructor(readUrlFn, cdn) {
super(cdn.urlForPackageFile('', ''));
this.contentCache = new Map();
this.locks = new Map();
this.packageEntriesCache = new Map();
this.packageJsonCache = new Map();
this.cdn = cdn;
this.readUrlFn = readUrlFn;
}
_withRootUriCheck(uri, fn) {
if (!common.Uri.isPrefixOf(this.rootUri, uri)) {
throw new Error(`This strategy is only able to handle URIs under '${this.rootUri.toString()}' and is unable to handle '${uri.toString()}'`);
}
return fn(this.rootUri);
}
async getUrlForBareModule(ctx, name, spec, path) {
const unresolvedUri = this.cdn.urlForPackageFile(`${name}@${spec}`, path);
const resolveReturn = await ctx.resolveUri(unresolvedUri);
return resolveReturn;
}
getCanonicalUrl(ctx, uri) {
return this._withRootUriCheck(uri, async () => {
const unresolvedSpec = this.cdn.parseUrl(uri);
const packageJsonReturn = ctx.runInChildContext('CdnStrategy._readPackageJsonWithCache', specToString(unresolvedSpec), (ctx) => this._readPackageJsonWithCache(ctx, unresolvedSpec));
const packageJson = common.isThenable(packageJsonReturn)
? await packageJsonReturn
: packageJsonReturn;
return {
uri: this.cdn.urlForPackageFile(`${packageJson.name}@${packageJson.version}`, unresolvedSpec.pathname),
};
});
// const results = all([ctx.getRootUrl(uri), ctx.getResolveRoot(uri)], ctx.token);
// const [rootUriResult, resolveRootResult] = isThenable(results) ? await results : results;
}
getResolveRoot(ctx, uri) {
return this._withRootUriCheck(uri, async () => {
const unresolvedSpec = this.cdn.parseUrl(uri);
const packageJsonReturn = this._readPackageJsonWithCache(ctx, unresolvedSpec);
const packageJson = common.isThenable(packageJsonReturn)
? await packageJsonReturn
: packageJsonReturn;
return {
uri: this.cdn.urlForPackageFile(`${packageJson.name}@${packageJson.version}`, '/'),
};
});
}
getRootUrl() {
return {
uri: this.cdn.urlForPackageFile('', ''),
};
}
listEntries(ctx, uri) {
return this._withRootUriCheck(uri, async () => {
const unresolvedSpec = this.cdn.parseUrl(uri);
const results = common.all([
ctx.getResolveRoot(uri),
this._readPackageJsonWithCache(ctx, unresolvedSpec),
this._readPackageEntriesWithCache(ctx, unresolvedSpec),
], ctx.token);
const [{ uri: resolveRootUri }, packageJson, entriesReturn] = common.isThenable(results)
? await results
: results;
const canonicalizedSpec = {
name: packageJson.name,
pathname: unresolvedSpec.pathname,
spec: `${packageJson.name}@${packageJson.version}`,
version: packageJson.version,
};
// Proactively cache the canonicalized package entries
this.packageEntriesCache.get(packageJson.name).set(packageJson.version, entriesReturn);
const traversalSegments = canonicalizedSpec.pathname.split('/').filter(Boolean);
let parentEntry = entriesReturn;
while (parentEntry && traversalSegments.length) {
const segment = traversalSegments.shift();
if (parentEntry.type !== resolver.ResolverStrategy.EntryKind.Directory || !parentEntry.files) {
throw new common.EntryNotFoundError(uri);
}
parentEntry = parentEntry.files.find((file) => file.type === resolver.ResolverStrategy.EntryKind.Directory && common.basename(file.path) === segment);
}
if (!parentEntry) {
throw new common.EntryNotFoundError(uri);
}
if (!parentEntry.files) {
return {
entries: [],
};
}
return {
entries: parentEntry.files.map((entry) => {
return {
type: entry.type,
uri: common.Uri.joinPath(resolveRootUri, `.${entry.path}`),
};
}),
};
});
}
readFileContent(ctx, uri) {
return this._withRootUriCheck(uri, () => {
const uriStr = uri.toString();
const cached = this.contentCache.get(uriStr);
if (cached === null) {
return Promise.reject(new common.EntryNotFoundError(uri));
}
if (cached) {
return cached;
}
ctx.recordVisit(uri, resolver.ResolverContext.VisitKind.File);
const readReturn = this.readUrlFn(uriStr, ctx.token);
if (readReturn === null) {
this.contentCache.set(uriStr, null);
return Promise.reject(new common.EntryNotFoundError(uri));
}
if (common.isThenable(readReturn)) {
const wrappedReturn = readReturn.then((data) => {
if (data === null) {
this.contentCache.delete(uriStr);
return Promise.reject(new common.EntryNotFoundError(uri));
}
const entry = { content: data };
this.contentCache.set(uriStr, entry);
return entry;
});
this.contentCache.set(uriStr, wrappedReturn);
return wrappedReturn;
}
const entry = { content: readReturn };
this.contentCache.set(uriStr, entry);
return entry;
});
}
_readPackageEntriesWithCache(ctx, spec) {
ctx.debug('%s._readPackageEntriesWithCache(%s)', this.constructor.name, specToString(spec));
return this._withLock(`packageEntries:${spec.name}`, () => {
let packageEntriesCacheForModule = this.packageEntriesCache.get(spec.name);
if (packageEntriesCacheForModule) {
const exactMatch = packageEntriesCacheForModule.get(spec.version);
if (exactMatch) {
// console.log('[HIT-EXACT] readPackageJsonWithCache(%s)', spec.spec);
return exactMatch;
}
const range = validRange__default['default'](spec.version);
if (range) {
for (const [version, entries] of packageEntriesCacheForModule) {
if (satisfies__default['default'](version, range)) {
return entries;
}
}
}
}
else {
packageEntriesCacheForModule = new Map();
this.packageEntriesCache.set(spec.name, packageEntriesCacheForModule);
}
return this._readPackageEntries(ctx, spec).then((rootDir) => {
packageEntriesCacheForModule.set(spec.version, rootDir);
return rootDir;
});
});
}
async _readPackageEntries(ctx, spec) {
ctx.debug('%s._readPackageEntries(%s)', this.constructor.name, specToString(spec));
const uri = this.cdn.urlForPackageList(spec.spec);
const href = uri.toString();
ctx.recordVisit(uri, resolver.ResolverContext.VisitKind.Directory);
const data = await common.checkCancellation(this.readUrlFn(href, ctx.token), ctx.token);
if (data === null) {
throw new common.EntryNotFoundError(spec);
}
const dataStr = ctx.decoder.decode(data);
return this.cdn.normalizePackageListing(JSON.parse(dataStr));
}
_readPackageJsonWithCache(ctx, spec) {
return this._withLock(`packageJson:${spec.name}`, () => {
let packageJsonCacheForModule = this.packageJsonCache.get(spec.name);
if (packageJsonCacheForModule) {
const exactMatch = packageJsonCacheForModule.get(spec.version);
if (exactMatch) {
// console.log('[HIT-EXACT] readPackageJsonWithCache(%s)', spec.spec);
for (const visit of exactMatch.visited) {
ctx.recordVisit(visit.uri, visit.type);
}
return exactMatch.packageJson;
}
const range = validRange__default['default'](spec.version);
if (range) {
for (const [version, entry] of packageJsonCacheForModule) {
if (satisfies__default['default'](version, range)) {
// console.log('[HIT] readPackageJsonWithCache(%s)', spec.spec);
for (const visit of entry.visited) {
ctx.recordVisit(visit.uri, visit.type);
}
return entry.packageJson;
}
}
}
}
else {
packageJsonCacheForModule = new Map();
this.packageJsonCache.set(spec.name, packageJsonCacheForModule);
}
return this._readPackageJson(spec, ctx).then((packageJson) => {
packageJsonCacheForModule.set(packageJson.version, { packageJson, visited: ctx.visited });
return packageJson;
});
});
}
async _readPackageJson(spec, ctx) {
ctx.debug('%s._readPackageJson(%s)', this.constructor.name, specToString(spec));
const uri = this.cdn.urlForPackageFile(spec.spec, '/package.json');
const contentReturn = ctx.readFileContent(uri);
const contentResult = common.isThenable(contentReturn) ? await contentReturn : contentReturn;
let manifest;
try {
manifest = common.parseBufferAsRootPackageJson(ctx.decoder, contentResult.content, spec.spec);
}
catch (err) {
throw new Error(`Error parsing manifest as json for package '${specToString(spec)}' at '${uri.toString()}': ${err.message}`);
}
// Since we know what the canonicalized version is now (we didn't until the promise resolved)
// and the package.json was parsed), we can proactively seed the content cache for the
// canonical url.
const canonicalHref = this.cdn
.urlForPackageFile(`${manifest.name}@${manifest.version}`, '/package.json')
.toString();
this.contentCache.set(canonicalHref, contentResult);
return manifest;
}
_withLock(lockKey, fn) {
const lock = this.locks.get(lockKey);
const runCriticalSection = () => {
const ret = fn();
if (common.isThenable(ret)) {
const locked = ret.then((result) => {
this.locks.delete(lockKey);
return result;
}, (err) => {
this.locks.delete(lockKey);
return Promise.reject(err);
});
this.locks.set(lockKey, locked);
return ret;
}
// No need to lock in non-promise
return ret;
};
if (common.isThenable(lock)) {
return lock.then(runCriticalSection);
}
return runCriticalSection();
}
static forJsDelivr(readUrlFn) {
return new CdnStrategy(readUrlFn, new JSDelivrCdn());
}
static forUnpkg(readUrlFn) {
return new CdnStrategy(readUrlFn, new UnpkgCdn());
}
}
const version = '0.56.2';
exports.CdnStrategy = CdnStrategy;
exports.version = version;
//# sourceMappingURL=index.js.map