From 58f54626263a66ca3a50aeaa39581cffeebebbba Mon Sep 17 00:00:00 2001 From: Vincent Taverna Date: Mon, 26 Jan 2026 02:13:33 -0500 Subject: [PATCH 1/2] feat: add dependents sorted by download --- app/components/PackageDependents.vue | 84 ++++++++++++++++++++++++++++ app/composables/useNpmRegistry.ts | 44 +++++++++++++++ app/pages/[...package].vue | 3 + 3 files changed, 131 insertions(+) create mode 100644 app/components/PackageDependents.vue diff --git a/app/components/PackageDependents.vue b/app/components/PackageDependents.vue new file mode 100644 index 00000000..e08e1c06 --- /dev/null +++ b/app/components/PackageDependents.vue @@ -0,0 +1,84 @@ + + + diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index 7542b934..a378de00 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -555,3 +555,47 @@ export function getOutdatedTooltip(info: OutdatedDependencyInfo): string { } return `Patch update available (latest: ${info.latest})` } + +// ============================================================================ +// Package Dependents +// ============================================================================ + +/** + * Fetch packages that depend on a given package (dependents). + * Results are sorted by weekly download count (most downloaded first) + * to help with security triage when vulnerabilities are discovered. + */ +export function usePackageDependents( + packageName: MaybeRefOrGetter, + options: MaybeRefOrGetter<{ size?: number }> = {}, +) { + return useLazyAsyncData( + () => `dependents:${toValue(packageName)}:${JSON.stringify(toValue(options))}`, + async () => { + const name = toValue(packageName) + if (!name) return emptySearchResponse + + const { size = 50 } = toValue(options) + + // Use the existing searchNpmPackages with depends-on: query + // This finds packages that have `name` as a dependency + const response = await searchNpmPackages(`depends-on:${name}`, { size }) + + // Sort by weekly downloads (descending) for security triage + const sortedObjects = [...response.objects].sort((a, b) => { + const aDownloads = a.downloads?.weekly ?? 0 + const bDownloads = b.downloads?.weekly ?? 0 + return bDownloads - aDownloads + }) + + return { + ...response, + objects: sortedObjects, + } + }, + { + server: false, + default: () => emptySearchResponse, + }, + ) +} diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index 6434097a..9624972d 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -789,6 +789,9 @@ defineOgImageComponent('Package', { :peer-dependencies-meta="displayVersion?.peerDependenciesMeta" :optional-dependencies="displayVersion?.optionalDependencies" /> + + + From 6d6a3c7d59d2157acbfeaab9eb27f128de2b0b41 Mon Sep 17 00:00:00 2001 From: Vincent Taverna Date: Mon, 26 Jan 2026 12:55:36 -0500 Subject: [PATCH 2/2] Configure devminer for dependents download counts Note: The data from /registry is stale currently, but is being refreshed. Work to add download counts to /live_registry is in progress. --- app/components/PackageDependents.vue | 16 +-- app/composables/useNpmRegistry.ts | 51 ++++---- .../api/registry/dependents/[...pkg].get.ts | 112 ++++++++++++++++++ 3 files changed, 146 insertions(+), 33 deletions(-) create mode 100644 server/api/registry/dependents/[...pkg].get.ts diff --git a/app/components/PackageDependents.vue b/app/components/PackageDependents.vue index e08e1c06..b521442b 100644 --- a/app/components/PackageDependents.vue +++ b/app/components/PackageDependents.vue @@ -8,7 +8,7 @@ const props = defineProps<{ const { data, status } = usePackageDependents(() => props.packageName) -const dependents = computed(() => data.value?.objects ?? []) +const dependents = computed(() => data.value?.dependents ?? []) const total = computed(() => data.value?.total ?? 0) // Expanded state for showing all dependents @@ -32,7 +32,7 @@ const showSection = computed(() => {

Dependents - ({{ total.toLocaleString() }}) + (top {{ total.toLocaleString() }})

@@ -51,22 +51,22 @@ const showSection = computed(() => { >
  • - {{ dependent.package.name }} + {{ dependent.name }}
  • diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index 502fb447..bb2123f3 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -612,42 +612,43 @@ export function getOutdatedTooltip(info: OutdatedDependencyInfo): string { // Package Dependents // ============================================================================ +export interface DependentPackage { + name: string + downloads: number + description?: string + version?: string +} + +export interface DependentsResponse { + dependents: DependentPackage[] + total: number +} + +const emptyDependentsResponse: DependentsResponse = { + dependents: [], + total: 0, +} + /** * Fetch packages that depend on a given package (dependents). - * Results are sorted by weekly download count (most downloaded first) + * Uses the e18e CouchDB mirror to get accurate dependency data. + * Results are sorted by download count (most downloaded first) * to help with security triage when vulnerabilities are discovered. */ -export function usePackageDependents( - packageName: MaybeRefOrGetter, - options: MaybeRefOrGetter<{ size?: number }> = {}, -) { +export function usePackageDependents(packageName: MaybeRefOrGetter) { return useLazyAsyncData( - () => `dependents:${toValue(packageName)}:${JSON.stringify(toValue(options))}`, + () => `dependents:${toValue(packageName)}`, async () => { const name = toValue(packageName) - if (!name) return emptySearchResponse - - const { size = 50 } = toValue(options) - - // Use the existing searchNpmPackages with depends-on: query - // This finds packages that have `name` as a dependency - const response = await searchNpmPackages(`depends-on:${name}`, { size }) - - // Sort by weekly downloads (descending) for security triage - const sortedObjects = [...response.objects].sort((a, b) => { - const aDownloads = a.downloads?.weekly ?? 0 - const bDownloads = b.downloads?.weekly ?? 0 - return bDownloads - aDownloads - }) + if (!name) return emptyDependentsResponse - return { - ...response, - objects: sortedObjects, - } + return await $fetch( + `/api/registry/dependents/${encodeURIComponent(name)}`, + ) }, { server: false, - default: () => emptySearchResponse, + default: () => emptyDependentsResponse, }, ) } diff --git a/server/api/registry/dependents/[...pkg].get.ts b/server/api/registry/dependents/[...pkg].get.ts new file mode 100644 index 00000000..aa44e2c5 --- /dev/null +++ b/server/api/registry/dependents/[...pkg].get.ts @@ -0,0 +1,112 @@ +import * as v from 'valibot' +import { PackageRouteParamsSchema } from '#shared/schemas/package' +import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' + +const E18E_LIVE_REGISTRY_URL = 'https://npm.devminer.xyz/live_registry' +const E18E_REGISTRY_URL = 'https://npm.devminer.xyz/registry' + +interface DependentsViewResponse { + total_rows: number + offset: number + rows: { + id: string + key: string + value: { name: string; version: string } + }[] +} + +interface DownloadsViewResponse { + total_rows: number + offset: number + rows: { + id: string + key: string + value: number + }[] +} + +export interface DependentPackage { + name: string + downloads: number + version?: string +} + +export interface DependentsResponse { + dependents: DependentPackage[] + total: number +} + +/** + * GET /api/registry/dependents/:name + * + * Fetch packages that depend on the given package using the e18e CouchDB mirror. + * Uses CouchDB views for efficient lookups, then fetches download stats separately. + * Results are sorted by download count (most downloaded first) for security triage. + */ +export default defineCachedEventHandler( + async (event): Promise => { + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] + const { rawPackageName } = parsePackageParams(pkgParamSegments) + + try { + const { packageName } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + }) + + const dependentsResponse = await $fetch( + `${E18E_LIVE_REGISTRY_URL}/_design/dependents/_view/dependents2?key=${encodeURIComponent(JSON.stringify(packageName))}&limit=250`, + ) + + if (dependentsResponse.rows.length === 0) { + return { dependents: [], total: 0 } + } + + const packageNames = dependentsResponse.rows.map(row => row.value.name) + + const downloadsResponse = await $fetch( + `${E18E_REGISTRY_URL}/_design/downloads/_view/downloads`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { keys: packageNames }, + }, + ) + + const downloadsMap = new Map() + for (const row of downloadsResponse.rows) { + downloadsMap.set(row.key, row.value) + } + + const versionMap = new Map() + for (const row of dependentsResponse.rows) { + versionMap.set(row.value.name, row.value.version) + } + + const dependents: DependentPackage[] = packageNames + .map(name => ({ + name, + downloads: downloadsMap.get(name) ?? 0, + version: versionMap.get(name), + })) + .sort((a, b) => b.downloads - a.downloads) + + return { + dependents, + total: dependents.length, + } + } catch { + return { + dependents: [], + total: 0, + } + } + }, + { + maxAge: CACHE_MAX_AGE_ONE_HOUR, + swr: true, + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `dependents:v2:${pkg.replace(/\/+$/, '').trim()}` + }, + }, +)