Skip to content
Draft
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
84 changes: 84 additions & 0 deletions app/components/PackageDependents.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { usePackageDependents } from '~/composables/useNpmRegistry'
import { formatCompactNumber } from '~/utils/formatters'

const props = defineProps<{
packageName: string
}>()

const { data, status } = usePackageDependents(() => props.packageName)

const dependents = computed(() => data.value?.dependents ?? [])
const total = computed(() => data.value?.total ?? 0)

// Expanded state for showing all dependents
const expanded = ref(false)

// Show first 10 by default, all when expanded
const visibleDependents = computed(() => {
if (expanded.value) {
return dependents.value
}
return dependents.value.slice(0, 10)
})

// Show section only when we have dependents or are loading
const showSection = computed(() => {
return status.value === 'pending' || dependents.value.length > 0
})
</script>

<template>
<section v-if="showSection" aria-labelledby="dependents-heading">
<h2 id="dependents-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
Dependents
<span v-if="status === 'success' && total > 0">(top {{ total.toLocaleString() }})</span>
</h2>

<!-- Loading state -->
<div v-if="status === 'pending'" class="space-y-2">
<div v-for="i in 5" :key="i" class="flex items-center justify-between py-1">
<div class="skeleton h-4 rounded" :style="{ width: `${60 + (i % 3) * 20}px` }" />
<div class="skeleton h-3 w-12 rounded" />
</div>
</div>

<!-- Dependents list -->
<ul
v-else-if="dependents.length > 0"
class="space-y-1 list-none m-0 p-0"
aria-label="Packages that depend on this package"
>
<li
v-for="dependent in visibleDependents"
:key="dependent.name"
class="flex items-center justify-between py-1 text-sm gap-2"
>
<NuxtLink
:to="{ name: 'package', params: { package: dependent.name.split('/') } }"
class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0"
>
{{ dependent.name }}
</NuxtLink>
<span
v-if="dependent.downloads"
class="font-mono text-xs text-fg-subtle shrink-0 flex items-center gap-1"
:title="`${dependent.downloads.toLocaleString()} downloads`"
>
<span class="i-carbon-download w-3 h-3" aria-hidden="true" />
{{ formatCompactNumber(dependent.downloads, { decimals: 1 }) }}
</span>
</li>
</ul>

<!-- Expand button -->
<button
v-if="dependents.length > 10 && !expanded"
type="button"
class="mt-2 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@click="expanded = true"
>
show top {{ dependents.length }}
</button>
</section>
</template>
45 changes: 45 additions & 0 deletions app/composables/useNpmRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) {
return useLazyAsyncData(
() => `dependents:${toValue(packageName)}`,
async () => {
const name = toValue(packageName)
if (!name) return emptyDependentsResponse

return await $fetch<DependentsResponse>(
`/api/registry/dependents/${encodeURIComponent(name)}`,
)
},
{
server: false,
default: () => emptyDependentsResponse,
},
)
}

/**
* Get CSS class for a dependency version based on outdated status
*/
Expand Down
3 changes: 3 additions & 0 deletions app/pages/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,9 @@ defineOgImageComponent('Package', {
:peer-dependencies-meta="displayVersion?.peerDependenciesMeta"
:optional-dependencies="displayVersion?.optionalDependencies"
/>

<!-- Dependents (packages that depend on this one) -->
<PackageDependents :package-name="pkg.name" />
</div>
</div>
</article>
Expand Down
112 changes: 112 additions & 0 deletions server/api/registry/dependents/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -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<DependentsResponse> => {
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
const { rawPackageName } = parsePackageParams(pkgParamSegments)

try {
const { packageName } = v.parse(PackageRouteParamsSchema, {
packageName: rawPackageName,
})

const dependentsResponse = await $fetch<DependentsViewResponse>(
`${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<DownloadsViewResponse>(
`${E18E_REGISTRY_URL}/_design/downloads/_view/downloads`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { keys: packageNames },
},
)

const downloadsMap = new Map<string, number>()
for (const row of downloadsResponse.rows) {
downloadsMap.set(row.key, row.value)
}

const versionMap = new Map<string, string>()
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()}`
},
},
)