unpkg/modules/utils/npm.js

238 lines
5.4 KiB
JavaScript

import url from 'url';
import https from 'https';
import LRUCache from 'lru-cache';
import semver from 'semver';
import debug from './debug.js';
import bufferStream from './bufferStream.js';
const npmRegistryURL =
process.env.NPM_REGISTRY_URL || 'https://registry.npmjs.org';
const agent = new https.Agent({
keepAlive: true
});
const oneMegabyte = 1024 * 1024;
const oneSecond = 1000;
const oneMinute = oneSecond * 60;
const cache = new LRUCache({
max: oneMegabyte * 40,
length: Buffer.byteLength,
maxAge: oneSecond
});
const notFound = '';
function get(options) {
return new Promise((accept, reject) => {
https.get(options, accept).on('error', reject);
});
}
function isScopedPackageName(packageName) {
return packageName.startsWith('@');
}
function encodePackageName(packageName) {
return isScopedPackageName(packageName)
? `@${encodeURIComponent(packageName.substring(1))}`
: encodeURIComponent(packageName);
}
async function fetchPackageInfo(packageName) {
const name = encodePackageName(packageName);
const infoURL = `${npmRegistryURL}/${name}`;
debug('Fetching package info for %s from %s', packageName, infoURL);
const { hostname, pathname } = url.parse(infoURL);
const options = {
agent: agent,
hostname: hostname,
path: pathname,
headers: {
Accept: 'application/json'
}
};
const res = await get(options);
if (res.statusCode === 200) {
return bufferStream(res).then(JSON.parse);
}
if (res.statusCode === 404) {
return null;
}
const data = await bufferStream(res);
const content = data.toString('utf-8');
throw new Error(
`Failed to fetch info for ${packageName}\nstatus: ${res.statusCode}\ndata: ${content}`
);
}
async function fetchVersionsAndTags(packageName) {
const info = await fetchPackageInfo(packageName);
if (info && info.versions) {
return {
versions: Object.keys(info.versions),
tags: info['dist-tags']
};
}
return null;
}
async function getVersionsAndTags(packageName) {
const cacheKey = `versions-${packageName}`;
const cacheValue = cache.get(cacheKey);
if (cacheValue != null) {
return cacheValue === notFound ? null : JSON.parse(cacheValue);
}
const value = await fetchVersionsAndTags(packageName);
if (value == null) {
cache.set(cacheKey, notFound, 5 * oneMinute);
return null;
}
cache.set(cacheKey, JSON.stringify(value), oneMinute);
return value;
}
function byVersion(a, b) {
return semver.lt(a, b) ? -1 : semver.gt(a, b) ? 1 : 0;
}
/**
* Returns an array of available versions, sorted by semver.
*/
export async function getAvailableVersions(packageName) {
const versionsAndTags = await getVersionsAndTags(packageName);
if (versionsAndTags) {
return versionsAndTags.versions.sort(byVersion);
}
return [];
}
/**
* Resolves the semver range or tag to a valid version.
* Output is cached to avoid over-fetching from the registry.
*/
export async function resolveVersion(packageName, range) {
const versionsAndTags = await getVersionsAndTags(packageName);
if (versionsAndTags) {
const { versions, tags } = versionsAndTags;
if (range in tags) {
range = tags[range];
}
return versions.includes(range)
? range
: semver.maxSatisfying(versions, range);
}
return null;
}
// All the keys that sometimes appear in package info
// docs that we don't need. There are probably more.
const packageConfigExcludeKeys = [
'browserify',
'bugs',
'directories',
'engines',
'files',
'homepage',
'keywords',
'maintainers',
'scripts'
];
function cleanPackageConfig(doc) {
return Object.keys(doc).reduce((memo, key) => {
if (!key.startsWith('_') && !packageConfigExcludeKeys.includes(key)) {
memo[key] = doc[key];
}
return memo;
}, {});
}
async function fetchPackageConfig(packageName, version) {
const info = await fetchPackageInfo(packageName);
if (!info || !(version in info.versions)) {
return null;
}
return cleanPackageConfig(info.versions[version]);
}
/**
* Returns metadata about a package, mostly the same as package.json.
* Output is cached to avoid over-fetching from the registry.
*/
export async function getPackageConfig(packageName, version) {
const cacheKey = `config-${packageName}-${version}`;
const cacheValue = cache.get(cacheKey);
if (cacheValue != null) {
return cacheValue === notFound ? null : JSON.parse(cacheValue);
}
const value = await fetchPackageConfig(packageName, version);
if (value == null) {
cache.set(cacheKey, notFound, 5 * oneMinute);
return null;
}
cache.set(cacheKey, JSON.stringify(value), oneMinute);
return value;
}
/**
* Returns a stream of the tarball'd contents of the given package.
*/
export async function getPackage(packageName, version) {
const tarballName = isScopedPackageName(packageName)
? packageName.split('/')[1]
: packageName;
const tarballURL = `${npmRegistryURL}/${packageName}/-/${tarballName}-${version}.tgz`;
debug('Fetching package for %s from %s', packageName, tarballURL);
const { hostname, pathname } = url.parse(tarballURL);
const options = {
agent: agent,
hostname: hostname,
path: pathname
};
const res = await get(options);
if (res.statusCode === 200) {
return res;
}
const data = await bufferStream(res);
const spec = `${packageName}@${version}`;
const content = data.toString('utf-8');
throw new Error(
`Failed to fetch tarball for ${spec}\nstatus: ${res.statusCode}\ndata: ${content}`
);
}