···11+import { getQuery } from 'h3'
22+import * as v from 'valibot'
33+import { hash } from 'ohash'
44+import type { VersionDistributionResponse } from '#shared/types'
55+import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants'
66+import { groupVersionDownloads } from '#server/utils/version-downloads'
77+88+/**
99+ * Raw response from npm downloads API
1010+ * GET https://api.npmjs.org/versions/{package}/last-week
1111+ */
1212+interface NpmVersionDownloadsResponse {
1313+ package: string
1414+ downloads: Record<string, number>
1515+}
1616+1717+/**
1818+ * Query parameter validation schema
1919+ */
2020+const QuerySchema = v.object({
2121+ mode: v.optional(v.picklist(['major', 'minor'] as const), 'major'),
2222+ filterThreshold: v.optional(
2323+ v.pipe(
2424+ v.string(),
2525+ v.toNumber(), // Fails validation on invalid conversion (e.g., "abc") instead of producing NaN
2626+ v.minValue(0), // Ensure non-negative values
2727+ ),
2828+ ),
2929+ filterOldVersions: v.optional(v.picklist(['true', 'false'] as const), 'false'),
3030+})
3131+3232+/**
3333+ * GET /api/registry/downloads/:name/versions or /api/registry/downloads/@scope/name/versions
3434+ *
3535+ * Fetch per-version download statistics and group by major or minor version.
3636+ * Data is cached for 1 hour with stale-while-revalidate.
3737+ *
3838+ * Query parameters:
3939+ * - mode: 'major' | 'minor' (default: 'major')
4040+ * - filterThreshold: minimum percentage to include (default: 1)
4141+ * - filterOldVersions: 'true' to include only versions published in last year (default: 'false')
4242+ */
4343+export default defineCachedEventHandler(
4444+ async event => {
4545+ // Supports: /downloads/lodash/versions, /downloads/@scope/name/versions
4646+ const slugParam = getRouterParam(event, 'slug')
4747+ const pkgParamSegments = slugParam?.split('/') ?? []
4848+4949+ const lastSegment = pkgParamSegments.at(-1)
5050+ if (!lastSegment || lastSegment !== 'versions') {
5151+ throw createError({
5252+ statusCode: 404,
5353+ message: 'Invalid endpoint. Expected /versions',
5454+ })
5555+ }
5656+5757+ const segments = pkgParamSegments.slice(0, -1)
5858+5959+ const { rawPackageName } = parsePackageParams(segments)
6060+6161+ if (!rawPackageName) {
6262+ throw createError({
6363+ statusCode: 404,
6464+ message: 'Package name is required',
6565+ })
6666+ }
6767+6868+ try {
6969+ const query = getQuery(event)
7070+ const parsed = v.parse(QuerySchema, query)
7171+ const mode = parsed.mode
7272+ const filterThreshold = parsed.filterThreshold ?? 1
7373+ const filterOldVersionsBool = parsed.filterOldVersions === 'true'
7474+7575+ const url = `https://api.npmjs.org/versions/${rawPackageName}/last-week`
7676+ const npmResponse = await fetch(url)
7777+7878+ if (!npmResponse.ok) {
7979+ if (npmResponse.status === 404) {
8080+ throw createError({
8181+ statusCode: 404,
8282+ message: 'Package not found',
8383+ })
8484+ }
8585+ throw createError({
8686+ statusCode: 502,
8787+ message: 'Failed to fetch version download data from npm API',
8888+ })
8989+ }
9090+9191+ const data: NpmVersionDownloadsResponse = await npmResponse.json()
9292+9393+ let groups = groupVersionDownloads(data.downloads, mode)
9494+9595+ if (filterThreshold > 0) {
9696+ groups = groups.filter(group => group.percentage >= filterThreshold)
9797+ }
9898+9999+ const totalDownloads = Object.values(data.downloads).reduce((sum, count) => sum + count, 0)
100100+101101+ const apiResponse: VersionDistributionResponse = {
102102+ package: rawPackageName,
103103+ mode,
104104+ totalDownloads,
105105+ groups,
106106+ timestamp: new Date().toISOString(),
107107+ }
108108+109109+ if (filterOldVersionsBool) {
110110+ try {
111111+ const oneYearAgo = new Date()
112112+ oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1)
113113+ const afterDate = oneYearAgo.toISOString()
114114+115115+ // Decode package name in case it's URL-encoded (e.g., %40prisma%2Fclient -> @prisma/client)
116116+ const decodedPackageName = decodeURIComponent(rawPackageName)
117117+118118+ // Fetch directly from npm-fast-meta HTTP API
119119+ const fastMetaUrl = `https://npm.antfu.dev/versions/${encodeURIComponent(decodedPackageName)}?after=${encodeURIComponent(afterDate)}`
120120+ const fastMetaResponse = await fetch(fastMetaUrl)
121121+122122+ if (!fastMetaResponse.ok) {
123123+ throw new Error(`npm-fast-meta returned ${fastMetaResponse.status}`)
124124+ }
125125+126126+ const versionData = (await fastMetaResponse.json()) as { versions: string[] }
127127+ apiResponse.recentVersions = versionData.versions
128128+ } catch {
129129+ // Graceful degradation - don't fail entire request if npm-fast-meta fails
130130+ }
131131+ }
132132+133133+ return apiResponse
134134+ } catch (error: unknown) {
135135+ handleApiError(error, {
136136+ statusCode: 502,
137137+ message: 'Failed to fetch version download distribution',
138138+ })
139139+ }
140140+ },
141141+ {
142142+ maxAge: CACHE_MAX_AGE_ONE_HOUR,
143143+ swr: true,
144144+ getKey: event => {
145145+ const slug = getRouterParam(event, 'slug') ?? ''
146146+ const query = getQuery(event)
147147+ // Use ohash to create deterministic cache key from query params
148148+ // This ensures different param combinations = different cache entries
149149+ return `version-downloads:v5:${slug}:${hash(query)}`
150150+ },
151151+ },
152152+)
+169
server/utils/version-downloads.ts
···11+import semver from 'semver'
22+import type {
33+ VersionDownloadPoint,
44+ VersionGroupDownloads,
55+ VersionGroupingMode,
66+} from '#shared/types'
77+88+/**
99+ * Intermediate data structure for version processing
1010+ */
1111+interface ProcessedVersion {
1212+ version: string
1313+ downloads: number
1414+ major: number
1515+ minor: number
1616+ parsed: semver.SemVer
1717+}
1818+1919+/**
2020+ * Filter out versions below a usage threshold
2121+ * @param versions Array of version download points
2222+ * @param thresholdPercent Minimum percentage to include (default: 0.1%)
2323+ * @returns Filtered array of versions
2424+ */
2525+export function filterLowUsageVersions(
2626+ versions: VersionDownloadPoint[],
2727+ thresholdPercent: number = 0.1,
2828+): VersionDownloadPoint[] {
2929+ return versions.filter(v => v.percentage >= thresholdPercent)
3030+}
3131+3232+/**
3333+ * Parse and validate version strings, calculating total downloads
3434+ * @param rawDownloads Raw download data from npm API
3535+ * @returns Array of processed versions with parsed semver data
3636+ */
3737+function parseVersions(rawDownloads: Record<string, number>): ProcessedVersion[] {
3838+ const processed: ProcessedVersion[] = []
3939+4040+ for (const [version, downloads] of Object.entries(rawDownloads)) {
4141+ const parsed = semver.parse(version)
4242+ if (!parsed) continue
4343+4444+ processed.push({
4545+ version,
4646+ downloads,
4747+ major: parsed.major,
4848+ minor: parsed.minor,
4949+ parsed,
5050+ })
5151+ }
5252+5353+ processed.sort((a, b) => semver.rcompare(a.version, b.version))
5454+5555+ return processed
5656+}
5757+5858+/**
5959+ * Calculate percentage for each version
6060+ * @param versions Processed versions
6161+ * @param totalDownloads Total download count
6262+ * @returns Array of version download points with percentages
6363+ */
6464+function addPercentages(
6565+ versions: ProcessedVersion[],
6666+ totalDownloads: number,
6767+): VersionDownloadPoint[] {
6868+ return versions.map(v => ({
6969+ version: v.version,
7070+ downloads: v.downloads,
7171+ percentage: totalDownloads > 0 ? (v.downloads / totalDownloads) * 100 : 0,
7272+ }))
7373+}
7474+7575+/**
7676+ * Group versions by major version (e.g., 1.x, 2.x)
7777+ * @param rawDownloads Raw download data from npm API
7878+ * @returns Array of version groups sorted by downloads descending
7979+ */
8080+export function groupByMajor(rawDownloads: Record<string, number>): VersionGroupDownloads[] {
8181+ const processed = parseVersions(rawDownloads)
8282+ const totalDownloads = processed.reduce((sum, v) => sum + v.downloads, 0)
8383+8484+ const groups = new Map<number, ProcessedVersion[]>()
8585+ for (const version of processed) {
8686+ const existing = groups.get(version.major) || []
8787+ existing.push(version)
8888+ groups.set(version.major, existing)
8989+ }
9090+9191+ const result: VersionGroupDownloads[] = []
9292+ for (const [major, versions] of groups.entries()) {
9393+ const groupDownloads = versions.reduce((sum, v) => sum + v.downloads, 0)
9494+ const percentage = totalDownloads > 0 ? (groupDownloads / totalDownloads) * 100 : 0
9595+9696+ result.push({
9797+ groupKey: `${major}.x`,
9898+ label: `v${major}.x`,
9999+ downloads: groupDownloads,
100100+ percentage,
101101+ versions: addPercentages(versions, totalDownloads),
102102+ })
103103+ }
104104+105105+ result.sort((a, b) => b.downloads - a.downloads)
106106+107107+ return result
108108+}
109109+110110+/**
111111+ * Group versions by major.minor (e.g., 1.2.x, 1.3.x)
112112+ * Special handling for 0.x versions - treat them as separate majors
113113+ * @param rawDownloads Raw download data from npm API
114114+ * @returns Array of version groups sorted by downloads descending
115115+ */
116116+export function groupByMinor(rawDownloads: Record<string, number>): VersionGroupDownloads[] {
117117+ const processed = parseVersions(rawDownloads)
118118+ const totalDownloads = processed.reduce((sum, v) => sum + v.downloads, 0)
119119+120120+ // Group by major.minor
121121+ const groups = new Map<string, ProcessedVersion[]>()
122122+ for (const version of processed) {
123123+ // For 0.x versions, treat each minor as significant (0.9.x, 0.10.x are different)
124124+ // For 1.x+, group by major.minor normally
125125+ const groupKey = `${version.major}.${version.minor}`
126126+ const existing = groups.get(groupKey) || []
127127+ existing.push(version)
128128+ groups.set(groupKey, existing)
129129+ }
130130+131131+ // Convert to VersionGroupDownloads
132132+ const result: VersionGroupDownloads[] = []
133133+ for (const [groupKey, versions] of groups.entries()) {
134134+ const groupDownloads = versions.reduce((sum, v) => sum + v.downloads, 0)
135135+ const percentage = totalDownloads > 0 ? (groupDownloads / totalDownloads) * 100 : 0
136136+137137+ result.push({
138138+ groupKey: `${groupKey}.x`,
139139+ label: `v${groupKey}.x`,
140140+ downloads: groupDownloads,
141141+ percentage,
142142+ versions: addPercentages(versions, totalDownloads),
143143+ })
144144+ }
145145+146146+ result.sort((a, b) => b.downloads - a.downloads)
147147+148148+ return result
149149+}
150150+151151+/**
152152+ * Group versions by the specified mode
153153+ * @param rawDownloads Raw download data from npm API
154154+ * @param mode Grouping mode ('major' or 'minor')
155155+ * @returns Array of version groups sorted by downloads descending
156156+ */
157157+export function groupVersionDownloads(
158158+ rawDownloads: Record<string, number>,
159159+ mode: VersionGroupingMode,
160160+): VersionGroupDownloads[] {
161161+ switch (mode) {
162162+ case 'major':
163163+ return groupByMajor(rawDownloads)
164164+ case 'minor':
165165+ return groupByMinor(rawDownloads)
166166+ default:
167167+ throw new Error(`Invalid grouping mode: ${mode}`)
168168+ }
169169+}
+1
shared/types/index.ts
···88export * from './i18n-status'
99export * from './comparison'
1010export * from './skills'
1111+export * from './version-downloads'
+60
shared/types/version-downloads.ts
···11+/**
22+ * Version Downloads Distribution Types
33+ * Types for version download statistics and grouping.
44+ *
55+ * These types support fetching per-version download counts from npm API
66+ * and grouping them by major/minor versions for distribution analysis.
77+ */
88+99+/**
1010+ * Download data for a single package version
1111+ */
1212+export interface VersionDownloadPoint {
1313+ /** Semantic version string (e.g., "1.2.3") */
1414+ version: string
1515+ /** Download count for this version */
1616+ downloads: number
1717+ /** Percentage of total downloads (0-100) */
1818+ percentage: number
1919+}
2020+2121+/**
2222+ * Aggregated download data for a version group (major or minor)
2323+ */
2424+export interface VersionGroupDownloads {
2525+ /** Group identifier (e.g., "1.x" for major, "1.2.x" for minor) */
2626+ groupKey: string
2727+ /** Human-readable label (e.g., "v1.x", "v1.2.x") */
2828+ label: string
2929+ /** Total downloads for all versions in this group */
3030+ downloads: number
3131+ /** Percentage of total downloads (0-100) */
3232+ percentage: number
3333+ /** Individual versions in this group */
3434+ versions: VersionDownloadPoint[]
3535+}
3636+3737+/**
3838+ * Mode for grouping versions
3939+ * - 'major': Group by major version (1.x, 2.x)
4040+ * - 'minor': Group by minor version (1.2.x, 1.3.x)
4141+ */
4242+export type VersionGroupingMode = 'major' | 'minor'
4343+4444+/**
4545+ * API response for version download distribution
4646+ */
4747+export interface VersionDistributionResponse {
4848+ /** Package name */
4949+ package: string
5050+ /** Grouping mode used */
5151+ mode: VersionGroupingMode
5252+ /** Total downloads across all versions */
5353+ totalDownloads: number
5454+ /** Grouped version data */
5555+ groups: VersionGroupDownloads[]
5656+ /** ISO 8601 timestamp when data was fetched */
5757+ timestamp: string
5858+ /** List of version strings published within the last year (only present when filterOldVersions=true) */
5959+ recentVersions?: string[]
6060+}
···4040 'Settings/TranslationHelper.vue': 'i18n helper component - requires specific locale status data',
4141 'Package/WeeklyDownloadStats.vue':
4242 'Uses vue-data-ui VueUiSparkline - has DOM measurement issues in test environment',
4343+ 'Package/VersionDistribution.vue':
4444+ 'Uses vue-data-ui VueUiXy - has DOM measurement issues in test environment',
4345 'UserCombobox.vue': 'Unused component - intended for future admin features',
4446 'SkeletonBlock.vue': 'Already covered indirectly via other component tests',
4547 'SkeletonInline.vue': 'Already covered indirectly via other component tests',