Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 141 additions & 17 deletions app/pages/package/[[org]]/[name]/versions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ definePageMeta({
name: 'package-versions',
})

interface NpmWebsiteVersionDownload {
version: string
downloads: number
}

interface NpmWebsiteVersionsResponse {
packages: Array<{
packageName: string
versions: NpmWebsiteVersionDownload[]
}>
}

/** Number of flat items (headers + version rows) to render statically during SSR */
const SSR_COUNT = 20

Expand All @@ -26,6 +38,9 @@ const packageName = computed(() => {
const { org, name } = route.params
return org ? `${org}/${name}` : name
})
const packageNameQueryParam = computed(() => {
return packageName.value ? { packages: packageName.value } : {}
})
const orgName = computed(() => route.params.org?.replace('@', '') ?? null)

// ─── Phase 1: lightweight fetch (page load) ───────────────────────────────────
Expand All @@ -49,6 +64,54 @@ const distTags = computed(() => versionSummary.value?.distTags ?? {})
const versionStrings = computed(() => versionSummary.value?.versions ?? [])
const versionTimes = computed(() => versionSummary.value?.time ?? {})

const { data: npmWebsiteVersions } = useLazyFetch<NpmWebsiteVersionsResponse>(
() => '/api/registry/downloads/versions',
{
key: () => `downloads-versions:${packageName.value}`,
query: packageNameQueryParam,
deep: false,
default: () => ({ packages: [] }),
getCachedData(key, nuxtApp) {
return nuxtApp.static.data[key] ?? nuxtApp.payload.data[key]
},
},
)

const packageVersions = computed(() => {
return (
npmWebsiteVersions.value?.packages.find(pkg => pkg.packageName === packageName.value)
?.versions ?? []
)
})

const numberFormatter = useNumberFormatter()
const { t } = useI18n()
const versionDownloadsMap = computed(
() => new Map(packageVersions.value.map(({ version, downloads }) => [version, downloads])),
)

function getVersionDownloads(version: string): number | undefined {
return versionDownloadsMap.value.get(version)
}

function getGroupDownloads(versions: string[]): number | undefined {
let total = 0
let hasValue = false

for (const version of versions) {
const downloads = getVersionDownloads(version)
if (downloads === undefined) continue
total += downloads
hasValue = true
}

return hasValue ? total : undefined
}

function getDownloadsAriaLabel(downloads: number): string {
return `${numberFormatter.value.format(downloads)} ${t('package.downloads.title')}`
}

// ─── Phase 2: full metadata (fired automatically after phase 1 completes) ────
// Fetches deprecated status, provenance, and exact times needed for version rows.

Expand Down Expand Up @@ -279,10 +342,20 @@ const flatItems = computed<FlatItem[]>(() => {
class="text-2xl font-semibold tracking-tight after:absolute after:inset-0 after:content-['']"
:title="latestTagRow!.version"
dir="ltr"
>{{ latestTagRow!.version }}</LinkBase
>v{{ latestTagRow!.version }}</LinkBase
>
</div>
<!-- Right: deprecated + date + provenance -->
<!-- Right: downloads + deprecated + date + provenance -->
<div
v-if="getVersionDownloads(latestTagRow!.version)"
class="grid grid-flow-col auto-cols-max items-center gap-1 text-sm font-medium text-fg tabular-nums shrink-0"
:aria-label="getDownloadsAriaLabel(getVersionDownloads(latestTagRow!.version)!)"
dir="ltr"
:title="getDownloadsAriaLabel(getVersionDownloads(latestTagRow!.version)!)"
>
<span>{{ numberFormatter.format(getVersionDownloads(latestTagRow!.version)!) }}</span>
<span class="i-lucide:chart-line" aria-hidden="true"></span>
</div>
<div class="flex flex-col items-end gap-1.5 shrink-0 relative z-10">
<span
v-if="fullVersionMap?.get(latestTagRow!.version)?.deprecated"
Expand All @@ -300,7 +373,7 @@ const flatItems = computed<FlatItem[]>(() => {
<DateTime
v-if="getVersionTime(latestTagRow!.version)"
:datetime="getVersionTime(latestTagRow!.version)!"
class="text-xs text-fg-subtle"
class="text-xs text-fg-subtle whitespace-nowrap"
year="numeric"
month="short"
day="numeric"
Expand Down Expand Up @@ -336,9 +409,22 @@ const flatItems = computed<FlatItem[]>(() => {
:title="row.version"
dir="ltr"
>
{{ row.version }}
v{{ row.version }}
</LinkBase>

<!-- Downloads -->
<span
v-if="getVersionDownloads(row.version)"
class="w-28 grid grid-flow-col auto-cols-max items-center justify-end gap-1 text-xs text-fg-muted tabular-nums shrink-0 relative z-10"
:aria-label="getDownloadsAriaLabel(getVersionDownloads(row.version)!)"
dir="ltr"
:title="getDownloadsAriaLabel(getVersionDownloads(row.version)!)"
>
<span>{{ numberFormatter.format(getVersionDownloads(row.version)!) }}</span>
<span class="i-lucide:chart-line" aria-hidden="true"></span>
</span>
<span v-else class="w-28 shrink-0" />

<!-- Deprecated + Date + Provenance -->
<div class="flex items-center gap-2 shrink-0 relative z-10">
<span
Expand All @@ -350,7 +436,7 @@ const flatItems = computed<FlatItem[]>(() => {
<DateTime
v-if="getVersionTime(row.version)"
:datetime="getVersionTime(row.version)!"
class="text-xs text-fg-subtle hidden sm:block"
class="text-xs text-fg-subtle hidden sm:block w-24 text-end"
year="numeric"
month="short"
day="numeric"
Expand Down Expand Up @@ -427,14 +513,25 @@ const flatItems = computed<FlatItem[]>(() => {
>deprecated</span
>
<span class="text-xs text-fg-subtle">({{ item.versions.length }})</span>
<span class="ms-auto flex items-center gap-3 shrink-0">
<span class="text-xs text-fg-muted" :title="item.versions[0]" dir="ltr">{{
item.versions[0]
}}</span>
<span class="text-xs text-fg-muted" :title="item.versions[0]" dir="ltr"
>v{{ item.versions[0] }}</span
>
<span
v-if="getGroupDownloads(item.versions)"
class="ms-auto w-28 grid grid-flow-col auto-cols-max items-center justify-end gap-1 text-xs text-fg-muted tabular-nums shrink-0"
:aria-label="getDownloadsAriaLabel(getGroupDownloads(item.versions)!)"
dir="ltr"
:title="getDownloadsAriaLabel(getGroupDownloads(item.versions)!)"
>
<span>{{ numberFormatter.format(getGroupDownloads(item.versions)!) }}</span>
<span class="i-lucide:chart-line" aria-hidden="true"></span>
</span>
<span v-else class="ms-auto w-28 shrink-0" />
<span class="flex items-center gap-3 shrink-0">
<DateTime
v-if="getVersionTime(item.versions[0])"
:datetime="getVersionTime(item.versions[0])!"
class="text-xs text-fg-subtle hidden sm:block"
class="text-xs text-fg-subtle hidden sm:block whitespace-nowrap w-24 text-end"
year="numeric"
month="short"
day="numeric"
Expand Down Expand Up @@ -474,7 +571,7 @@ const flatItems = computed<FlatItem[]>(() => {
"
dir="ltr"
>
{{ item.version }}
v{{ item.version }}
</LinkBase>
<div
v-if="versionToTagsMap.get(item.version)?.length"
Expand All @@ -499,13 +596,27 @@ const flatItems = computed<FlatItem[]>(() => {
</span>
</div>

<!-- Right side -->
<!-- Downloads -->
<span
v-if="getVersionDownloads(item.version)"
class="w-28 grid grid-flow-col auto-cols-max items-center justify-end gap-1 text-xs text-fg-muted tabular-nums shrink-0 relative z-10"
:aria-label="getDownloadsAriaLabel(getVersionDownloads(item.version)!)"
:title="getDownloadsAriaLabel(getVersionDownloads(item.version)!)"
dir="ltr"
>
<span>{{
numberFormatter.format(getVersionDownloads(item.version)!)
}}</span>
<span class="i-lucide:chart-line" aria-hidden="true"></span>
</span>
<span v-else class="w-28 shrink-0" />

<!-- Date + Provenance -->
<div class="flex items-center gap-2 shrink-0 relative z-10">
<!-- Metadata: date + provenance -->
<DateTime
v-if="getVersionTime(item.version)"
:datetime="getVersionTime(item.version)!"
class="text-xs text-fg-subtle hidden sm:block"
class="text-xs text-fg-subtle hidden sm:block whitespace-nowrap w-24 text-end"
year="numeric"
month="short"
day="numeric"
Expand Down Expand Up @@ -539,12 +650,25 @@ const flatItems = computed<FlatItem[]>(() => {
</span>
<span class="text-sm font-medium">{{ item.label }}</span>
<span class="text-xs text-fg-subtle">({{ item.versions.length }})</span>
<span class="ms-auto flex items-center gap-3 shrink-0">
<span class="text-xs text-fg-muted" dir="ltr">{{ item.versions[0] }}</span>
<span v-if="item.versions[0]" class="text-xs text-fg-muted" dir="ltr"
>v{{ item.versions[0] }}</span
>
<span
v-if="getGroupDownloads(item.versions)"
class="ms-auto w-28 grid grid-flow-col auto-cols-max items-center justify-end gap-1 text-xs text-fg-muted tabular-nums shrink-0"
:aria-label="getDownloadsAriaLabel(getGroupDownloads(item.versions)!)"
dir="ltr"
:title="getDownloadsAriaLabel(getGroupDownloads(item.versions)!)"
>
<span>{{ numberFormatter.format(getGroupDownloads(item.versions)!) }}</span>
<span class="i-lucide:chart-line" aria-hidden="true"></span>
</span>
<span v-else class="ms-auto w-28 shrink-0" />
<span class="flex items-center gap-3 shrink-0">
<DateTime
v-if="getVersionTime(item.versions[0] ?? '')"
:datetime="getVersionTime(item.versions[0] ?? '')!"
class="text-xs text-fg-subtle hidden sm:block"
class="text-xs text-fg-subtle hidden sm:block whitespace-nowrap w-24 text-end"
year="numeric"
month="short"
day="numeric"
Expand Down
2 changes: 1 addition & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export default defineNuxtConfig({
isr: {
expiration: 60 * 60 /* one hour */,
passQuery: true,
allowQuery: ['mode', 'filterOldVersions', 'filterThreshold'],
allowQuery: ['mode', 'filterOldVersions', 'filterThreshold', 'packages'],
},
},
'/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
Expand Down
55 changes: 54 additions & 1 deletion server/api/registry/downloads/[...slug].get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as v from 'valibot'
import { hash } from 'ohash'
import { fetchNpmVersionDownloadsFromApi } from '#server/utils/npm-website-versions'

/**
* Raw response from npm downloads API
Expand All @@ -15,6 +16,7 @@ interface NpmVersionDownloadsResponse {
*/
const QuerySchema = v.object({
mode: v.optional(v.picklist(['major', 'minor'] as const), 'major'),
packages: v.optional(v.union([v.string(), v.array(v.string())])),
filterThreshold: v.optional(
v.pipe(
v.string(),
Expand All @@ -25,10 +27,21 @@ const QuerySchema = v.object({
filterOldVersions: v.optional(v.picklist(['true', 'false'] as const), 'false'),
})

function normalizePackages(packages: string | string[] | undefined): string[] {
if (!packages) return []

const values = Array.isArray(packages) ? packages : [packages]
return [
...new Set(values.flatMap(value => value.split(',').map(pkg => pkg.trim())).filter(Boolean)),
]
}

/**
* GET /api/registry/downloads/:name/versions or /api/registry/downloads/@scope/name/versions
* GET /api/registry/downloads/versions?packages=pkg-a,pkg-b
*
* Fetch per-version download statistics and group by major or minor version.
* Fetch per-version download statistics and group by major or minor version,
* or fetch raw per-version download lists for one or more packages.
* Data is cached for 1 hour with stale-while-revalidate.
*
* Query parameters:
Expand All @@ -50,6 +63,46 @@ export default defineCachedEventHandler(
})
}

try {
const query = getQuery(event)
const parsed = v.parse(QuerySchema, query)
// Supports: /downloads/versions?packages=a,b and repeated ?packages=a&packages=b
if (pkgParamSegments.length === 1) {
const packageNames = normalizePackages(parsed.packages)

if (packageNames.length === 0) {
return {
packages: [],
timestamp: new Date().toISOString(),
}
}

try {
const packages = await Promise.all(
packageNames.map(async packageName => ({
packageName,
versions: await fetchNpmVersionDownloadsFromApi(packageName),
})),
)

return {
packages,
timestamp: new Date().toISOString(),
}
} catch (error: unknown) {
handleApiError(error, {
statusCode: 502,
message: 'Failed to fetch version download data from npm API',
})
}
}
} catch (error: unknown) {
handleApiError(error, {
statusCode: 502,
message: 'Failed to fetch version download data from npm API',
})
}

const segments = pkgParamSegments.slice(0, -1)

const { rawPackageName } = parsePackageParams(segments)
Expand Down
37 changes: 37 additions & 0 deletions server/utils/npm-website-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export interface NpmWebsiteVersionDownload {
version: string
downloads: number
}

interface NpmApiVersionDownloadsResponse {
downloads: Record<string, number>
}

export async function fetchNpmVersionDownloadsFromApi(
packageName: string,
): Promise<NpmWebsiteVersionDownload[]> {
const encodedName = encodePackageName(packageName)

const versionsResponse = await fetch(`https://api.npmjs.org/versions/${encodedName}/last-week`)

if (!versionsResponse.ok) {
if (versionsResponse.status === 404) {
throw createError({
statusCode: 404,
message: 'Package not found',
})
}

throw createError({
statusCode: 502,
message: 'Failed to fetch version download data from npm API',
})
}

const versionsData = (await versionsResponse.json()) as NpmApiVersionDownloadsResponse

return Object.entries(versionsData.downloads).map(([version, downloads]) => ({
version,
downloads,
}))
}
Loading
Loading