diff --git a/app/components/PackageDependents.vue b/app/components/PackageDependents.vue new file mode 100644 index 00000000..b521442b --- /dev/null +++ b/app/components/PackageDependents.vue @@ -0,0 +1,84 @@ + + + diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index 22c01617..bb2123f3 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -608,6 +608,51 @@ export function getOutdatedTooltip(info: OutdatedDependencyInfo): string { return `Patch update available (latest: ${info.latest})` } +// ============================================================================ +// 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). + * 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) { + return useLazyAsyncData( + () => `dependents:${toValue(packageName)}`, + async () => { + const name = toValue(packageName) + if (!name) return emptyDependentsResponse + + return await $fetch( + `/api/registry/dependents/${encodeURIComponent(name)}`, + ) + }, + { + server: false, + default: () => emptyDependentsResponse, + }, + ) +} + /** * Get CSS class for a dependency version based on outdated status */ diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index dbe71d17..7769ad44 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -892,6 +892,9 @@ defineOgImageComponent('Package', { :peer-dependencies-meta="displayVersion?.peerDependenciesMeta" :optional-dependencies="displayVersion?.optionalDependencies" /> + + + 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()}` + }, + }, +)