UNPKG

downpop

Version:

Just a simple way to get a glance at download stats for npm packages.

257 lines (229 loc) 7.89 kB
import { buildChart } from './barchart.js'; const npmDownloadStatsBaseUrl = `https://api.npmjs.org/downloads/point/`; const timeRanges = [ 'last-day', 'last-week', 'last-month', 'last-year' ]; /** * @typedef {Object} NpmPackageInfo * @property {string} package * @property {number} downloads * @property {string} start * @property {string} end */ /** * @typedef {Object} PackageInfo * @property {NpmPackageInfo[]} last-day * @property {NpmPackageInfo[]} last-week * @property {NpmPackageInfo[]} last-month * @property {NpmPackageInfo[]} last-year */ /** * @typedef {Object} PackageInfoCharts * @property {string} last-day * @property {string} last-week * @property {string} last-month * @property {string} last-year */ /** * @typedef {Object} PackageInfoChartsResult * @property {PackageInfoCharts} charts * @property {string} error */ async function getFetch() { // If we are in a browser, we will just use the // native window.fetch function // // If we are in Node.js, we will use node-fetch // // This isn't fool proof, but should work for most cases if (typeof window === 'object' && typeof window.fetch === 'function') { return window.fetch; } else { return (await import('node-fetch')).default; } } /** * @param {string} packageName * @returns {boolean} */ function isScopedPackage(packageName) { return (packageName || '')[0] === '@'; } /** * @param {any} packageInfo * @returns {boolean} */ function isPackageInfo(packageInfo) { return ( typeof packageInfo === 'object' && typeof packageInfo.downloads === 'number' && typeof packageInfo.start === 'string' && typeof packageInfo.end === 'string' && typeof packageInfo.package === 'string' ); } /** * @param {string|string[]} packageNames * @returns {string[]} */ function normalizeNpmPackageNames(packageNames) { if (Array.isArray(packageNames)) { return packageNames; } return [packageNames]; } /** * @returns {PackageInfo} */ function normalizeNpmPackageInfo(packageInfo) { const normalizedPackageInfo = {}; timeRanges .forEach(timeRange => { const packageInfoForTimeRange = packageInfo[timeRange]; if (isPackageInfo(packageInfoForTimeRange)) { normalizedPackageInfo[timeRange] = [packageInfoForTimeRange]; } else { normalizedPackageInfo[timeRange] = Object.values(packageInfoForTimeRange); } }); return normalizedPackageInfo; } /** * @param {PackageInfo} bulkPackageInfo * @param {PackageInfo} individualPackageInfo * @returns {PackageInfo} */ function mergeNpmPackageInfo(bulkPackageInfo, individualPackageInfo) { if (!bulkPackageInfo) { bulkPackageInfo = {}; } if (!individualPackageInfo) { individualPackageInfo = {}; } const mergedPackageInfo = {}; timeRanges .forEach(timeRange => { mergedPackageInfo[timeRange] = [] .concat(bulkPackageInfo[timeRange] || []) .concat(individualPackageInfo[timeRange] || []) .filter(packageInfo => !!packageInfo); }); return mergedPackageInfo; } /** * @param {string} packageName * @returns {Promise<PackageInfo>} */ async function _getNpmPackageInfo(packageName) { try { const fetch = await getFetch(); const downloadStatsRequestUrls = timeRanges .map(timeRange => `${npmDownloadStatsBaseUrl}${timeRange}/${packageName}`); const downloadStatsRequestPromises = downloadStatsRequestUrls .map((downloadStatsRequestUrl, i) => fetch(downloadStatsRequestUrl) .then(response => response.json()) .then(result => ({ [timeRanges[i]]: result, }))); const downloadStatsRequestResults = await Promise.all(downloadStatsRequestPromises); return normalizeNpmPackageInfo(downloadStatsRequestResults .reduce((downloadStatsResults, downloadStatsResult) => ({ ...downloadStatsResults, ...downloadStatsResult, }), {})); } catch (err) { return null; } } /** * @param {string[]} packageNames * @returns {Promise<PackageInfo>} */ async function _getBulkNpmPackageInfo(packageNames) { if (packageNames.length <= 0) { return null; } // construct a "package name" from all the packageNames provided and leverage // the _getNpmPackageInfo utility function above const bulkPackageName = packageNames.join(','); return await _getNpmPackageInfo(bulkPackageName); } /** * @param {string|string[]} packageNames * @returns {Promise<PackageInfo>} */ export async function getNpmPackageInfo(packageNames) { const scopedPackages = []; const nonScopedPackages = []; // splitting scoped vs. non-scoped here // since non-scoped packages support bulk queries // while scoped packages do not // https://github.com/npm/registry/blob/master/docs/download-counts.md#bulk-queries normalizeNpmPackageNames(packageNames) .forEach(packageName => { if (isScopedPackage(packageName)) { scopedPackages.push(packageName); } else { nonScopedPackages.push(packageName); } }); const bulkPackageInfoPromise = _getBulkNpmPackageInfo(nonScopedPackages); const individualPackageInfoPromises = scopedPackages .map(scopedPackage => _getNpmPackageInfo(scopedPackage)); const [ bulkPackageResults, individualPackageResults ] = await Promise.all([ bulkPackageInfoPromise, ...individualPackageInfoPromises ]); return mergeNpmPackageInfo(bulkPackageResults, individualPackageResults); } /** * @param {string|string[]} packageNames * @returns {Promise<PackageInfoChartsResult>} */ export async function buildNpmPackageInfoCharts(packageNames) { const result = { charts: [], error: '', }; try { const npmPackageInfoResults = await getNpmPackageInfo(packageNames); const packageNamesSuccessfullyFetched = new Set(npmPackageInfoResults[timeRanges[0]] .map(packageInfo => packageInfo.package)); const packageNamesFailedToFetch = packageNames.filter(packageName => !packageNamesSuccessfullyFetched.has(packageName)); // Yay! We got package data, let's form a pretty chart string if (packageNamesSuccessfullyFetched.size > 0) { timeRanges .forEach(timeRange => { let startDate, endDate; const labels = []; const values = []; npmPackageInfoResults[timeRange] .forEach(packageInfo => { if (!startDate || !endDate) { startDate = packageInfo.start; endDate = packageInfo.end; } labels.push(packageInfo.package); values.push(packageInfo.downloads); }); const title = `Number of downloads in ${timeRange} (${startDate} - ${endDate})`; result.charts[timeRange] = buildChart(labels, values, 50, title); }); } // Inform about packages we failed to info for if (packageNamesFailedToFetch.length > 0) { result.error = `Failed to get download stats for the following packages: ${packageNamesFailedToFetch.join(', ')}`; } } catch (err) { result.error = `An error occurred while attempting to collect npm package info: ${err}`; } return result; }