[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: highlight outdated dependencies

+274 -43
+23 -2
app/components/PackageDependencies.vue
··· 1 1 <script setup lang="ts"> 2 + import { useOutdatedDependencies, getOutdatedTooltip } from '~/composables/useNpmRegistry' 3 + import type { OutdatedDependencyInfo } from '~/composables/useNpmRegistry' 4 + 2 5 const props = defineProps<{ 3 6 packageName: string 4 7 dependencies?: Record<string, string> ··· 6 9 peerDependenciesMeta?: Record<string, { optional?: boolean }> 7 10 optionalDependencies?: Record<string, string> 8 11 }>() 12 + 13 + // Fetch outdated info for dependencies 14 + const outdatedDeps = useOutdatedDependencies(() => props.dependencies) 15 + 16 + /** 17 + * Get CSS class for a dependency version based on outdated status 18 + */ 19 + function getVersionClass(info: OutdatedDependencyInfo | undefined): string { 20 + if (!info) return 'text-fg-subtle' 21 + 22 + // Red for major versions behind 23 + if (info.majorsBehind > 0) return 'text-red-500 cursor-help' 24 + // Orange for minor versions behind 25 + if (info.minorsBehind > 0) return 'text-orange-500 cursor-help' 26 + // Yellow for patch versions behind 27 + return 'text-yellow-500 cursor-help' 28 + } 9 29 10 30 // Expanded state for each section 11 31 const depsExpanded = ref(false) ··· 62 82 {{ dep }} 63 83 </NuxtLink> 64 84 <span 65 - class="font-mono text-xs text-fg-subtle max-w-[50%] text-right truncate" 66 - :title="version" 85 + class="font-mono text-xs text-right truncate" 86 + :class="getVersionClass(outdatedDeps[dep])" 87 + :title="outdatedDeps[dep] ? getOutdatedTooltip(outdatedDeps[dep]) : version" 67 88 > 68 89 {{ version }} 69 90 </span>
+3 -9
app/components/PackageMetricsBadges.vue
··· 57 57 58 58 const hasTypes = computed(() => { 59 59 if (!analysis.value) return false 60 - return analysis.value.types.kind === 'included' || analysis.value.types.kind === '@types' 60 + return analysis.value.types?.kind === 'included' || analysis.value.types?.kind === '@types' 61 61 }) 62 62 63 63 const typesTooltip = computed(() => { 64 64 if (!analysis.value) return '' 65 - switch (analysis.value.types.kind) { 65 + switch (analysis.value.types?.kind) { 66 66 case 'included': 67 67 return 'TypeScript types included' 68 68 case '@types': ··· 82 82 </script> 83 83 84 84 <template> 85 - <!-- Loading skeleton --> 86 - <div v-if="status === 'pending'" class="flex items-center gap-1.5"> 87 - <span class="skeleton w-8 h-5 rounded" /> 88 - <span class="skeleton w-12 h-5 rounded" /> 89 - </div> 90 - 91 - <ul v-else-if="analysis" class="flex items-center gap-1.5 list-none m-0 p-0"> 85 + <ul v-if="analysis" class="flex items-center gap-1.5 list-none m-0 p-0"> 92 86 <!-- TypeScript types --> 93 87 <li v-if="hasTypes"> 94 88 <component
+4 -25
app/components/PackageVersions.vue
··· 8 8 getPrereleaseChannel, 9 9 parseVersion, 10 10 } from '~/utils/versions' 11 + import { fetchAllPackageVersions } from '~/composables/useNpmRegistry' 11 12 12 13 const props = defineProps<{ 13 14 packageName: string ··· 107 108 >([]) 108 109 const otherVersionsLoading = ref(false) 109 110 110 - // Cached full version list 111 + // Cached full version list (local to component instance) 111 112 const allVersionsCache = ref<PackageVersionInfo[] | null>(null) 112 113 const loadingVersions = ref(false) 113 114 const hasLoadedAll = ref(false) 114 115 115 - // npm registry packument type (simplified) 116 - interface NpmPackument { 117 - versions: Record<string, unknown> 118 - time: Record<string, string> 119 - } 120 - 121 - // Load all versions directly from npm registry 116 + // Load all versions using shared function 122 117 async function loadAllVersions(): Promise<PackageVersionInfo[]> { 123 118 if (allVersionsCache.value) return allVersionsCache.value 124 119 ··· 136 131 137 132 loadingVersions.value = true 138 133 try { 139 - // Fetch directly from npm registry 140 - const encodedName = props.packageName.startsWith('@') 141 - ? `@${encodeURIComponent(props.packageName.slice(1))}` 142 - : encodeURIComponent(props.packageName) 143 - 144 - const data = await $fetch<NpmPackument>(`https://registry.npmjs.org/${encodedName}`) 145 - 146 - // Convert to our format 147 - const versions: PackageVersionInfo[] = Object.keys(data.versions) 148 - .filter(v => data.time[v]) 149 - .map(version => ({ 150 - version, 151 - time: data.time[version], 152 - hasProvenance: false, 153 - })) 154 - .sort((a, b) => compareVersions(b.version, a.version)) 155 - 134 + const versions = await fetchAllPackageVersions(props.packageName) 156 135 allVersionsCache.value = versions 157 136 hasLoadedAll.value = true 158 137 return versions
+224 -1
app/composables/useNpmRegistry.ts
··· 6 6 NpmSearchResult, 7 7 NpmDownloadCount, 8 8 NpmPerson, 9 + PackageVersionInfo, 9 10 } from '#shared/types' 11 + import type { ReleaseType } from 'semver' 12 + import { maxSatisfying, prerelease, major, minor, diff, gt } from 'semver' 13 + import { compareVersions } from '~/utils/versions' 10 14 11 15 const NPM_REGISTRY = 'https://registry.npmjs.org' 12 16 const NPM_API = 'https://api.npmjs.org' 13 17 18 + // Cache for packument fetches to avoid duplicate requests across components 19 + const packumentCache = new Map<string, Promise<Packument | null>>() 20 + 21 + /** 22 + * Fetch a package's full packument data. 23 + * Uses caching to avoid duplicate requests. 24 + */ 14 25 async function fetchNpmPackage(name: string): Promise<Packument> { 15 26 const encodedName = encodePackageName(name) 16 27 return await $fetch<Packument>(`${NPM_REGISTRY}/${encodedName}`) 17 28 } 18 29 30 + /** 31 + * Fetch a package's packument with caching (returns null on error). 32 + * This is useful for batch operations where some packages might not exist. 33 + */ 34 + async function fetchCachedPackument(name: string): Promise<Packument | null> { 35 + const cached = packumentCache.get(name) 36 + if (cached) return cached 37 + 38 + const promise = fetchNpmPackage(name).catch(() => null) 39 + packumentCache.set(name, promise) 40 + return promise 41 + } 42 + 19 43 async function searchNpmPackages( 20 44 query: string, 21 45 options: { ··· 45 69 return await $fetch<NpmDownloadCount>(`${NPM_API}/downloads/point/${period}/${encodedName}`) 46 70 } 47 71 48 - function encodePackageName(name: string): string { 72 + /** 73 + * Encode a package name for use in npm registry URLs. 74 + * Handles scoped packages (e.g., @scope/name -> @scope%2Fname). 75 + */ 76 + export function encodePackageName(name: string): string { 49 77 if (name.startsWith('@')) { 50 78 return `@${encodeURIComponent(name.slice(1))}` 51 79 } ··· 326 354 { default: () => emptySearchResponse }, 327 355 ) 328 356 } 357 + 358 + // ============================================================================ 359 + // Package Versions 360 + // ============================================================================ 361 + 362 + // Cache for full version lists 363 + const allVersionsCache = new Map<string, Promise<PackageVersionInfo[]>>() 364 + 365 + /** 366 + * Fetch all versions of a package from the npm registry. 367 + * Returns version info sorted by version (newest first). 368 + * Results are cached to avoid duplicate requests. 369 + */ 370 + export async function fetchAllPackageVersions(packageName: string): Promise<PackageVersionInfo[]> { 371 + const cached = allVersionsCache.get(packageName) 372 + if (cached) return cached 373 + 374 + const promise = (async () => { 375 + const encodedName = encodePackageName(packageName) 376 + const data = await $fetch<{ versions: Record<string, unknown>; time: Record<string, string> }>( 377 + `${NPM_REGISTRY}/${encodedName}`, 378 + ) 379 + 380 + return Object.keys(data.versions) 381 + .filter(v => data.time[v]) 382 + .map(version => ({ 383 + version, 384 + time: data.time[version], 385 + hasProvenance: false, // Would need to check dist.attestations for each version 386 + })) 387 + .sort((a, b) => compareVersions(b.version, a.version)) 388 + })() 389 + 390 + allVersionsCache.set(packageName, promise) 391 + return promise 392 + } 393 + 394 + // ============================================================================ 395 + // Outdated Dependencies 396 + // ============================================================================ 397 + 398 + /** Information about an outdated dependency */ 399 + export interface OutdatedDependencyInfo { 400 + /** The resolved version that satisfies the constraint */ 401 + resolved: string 402 + /** The latest available version */ 403 + latest: string 404 + /** How many major versions behind */ 405 + majorsBehind: number 406 + /** How many minor versions behind (when same major) */ 407 + minorsBehind: number 408 + /** The type of version difference */ 409 + diffType: ReleaseType | null 410 + } 411 + 412 + /** 413 + * Check if a version constraint explicitly includes a prerelease tag. 414 + * e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases 415 + */ 416 + function constraintIncludesPrerelease(constraint: string): boolean { 417 + return ( 418 + /-(alpha|beta|rc|next|canary|dev|preview|pre|experimental)/i.test(constraint) || 419 + /-\d/.test(constraint) 420 + ) 421 + } 422 + 423 + /** 424 + * Check if a constraint is a non-semver value (git URL, file path, etc.) 425 + */ 426 + function isNonSemverConstraint(constraint: string): boolean { 427 + return ( 428 + constraint.startsWith('git') || 429 + constraint.startsWith('http') || 430 + constraint.startsWith('file:') || 431 + constraint.startsWith('npm:') || 432 + constraint.startsWith('link:') || 433 + constraint.startsWith('workspace:') || 434 + constraint.includes('/') 435 + ) 436 + } 437 + 438 + /** 439 + * Check if a dependency is outdated. 440 + * Returns null if up-to-date or if we can't determine. 441 + * 442 + * A dependency is only considered "outdated" if the resolved version 443 + * is older than the latest version. If the resolved version is newer 444 + * (e.g., using ^2.0.0-rc when latest is 1.x), it's not outdated. 445 + */ 446 + async function checkDependencyOutdated( 447 + packageName: string, 448 + constraint: string, 449 + ): Promise<OutdatedDependencyInfo | null> { 450 + if (isNonSemverConstraint(constraint)) { 451 + return null 452 + } 453 + 454 + const packument = await fetchCachedPackument(packageName) 455 + if (!packument) return null 456 + 457 + let versions = Object.keys(packument.versions) 458 + const includesPrerelease = constraintIncludesPrerelease(constraint) 459 + 460 + if (!includesPrerelease) { 461 + versions = versions.filter(v => !prerelease(v)) 462 + } 463 + 464 + const resolved = maxSatisfying(versions, constraint) 465 + if (!resolved) return null 466 + 467 + const latestTag = packument['dist-tags']?.latest 468 + if (!latestTag || resolved === latestTag) return null 469 + 470 + // If resolved version is newer than latest, not outdated 471 + // (e.g., using ^2.0.0-rc when latest is 1.x) 472 + if (gt(resolved, latestTag)) { 473 + return null 474 + } 475 + 476 + const diffType = diff(resolved, latestTag) 477 + const majorsBehind = major(latestTag) - major(resolved) 478 + const minorsBehind = majorsBehind === 0 ? minor(latestTag) - minor(resolved) : 0 479 + 480 + return { 481 + resolved, 482 + latest: latestTag, 483 + majorsBehind, 484 + minorsBehind, 485 + diffType, 486 + } 487 + } 488 + 489 + /** 490 + * Composable to check for outdated dependencies. 491 + * Returns a reactive map of dependency name to outdated info. 492 + */ 493 + export function useOutdatedDependencies( 494 + dependencies: MaybeRefOrGetter<Record<string, string> | undefined>, 495 + ) { 496 + const outdated = ref<Record<string, OutdatedDependencyInfo>>({}) 497 + 498 + async function fetchOutdatedInfo(deps: Record<string, string> | undefined) { 499 + if (!deps || Object.keys(deps).length === 0) { 500 + outdated.value = {} 501 + return 502 + } 503 + 504 + const results: Record<string, OutdatedDependencyInfo> = {} 505 + const entries = Object.entries(deps) 506 + const batchSize = 5 507 + 508 + for (let i = 0; i < entries.length; i += batchSize) { 509 + const batch = entries.slice(i, i + batchSize) 510 + const batchResults = await Promise.all( 511 + batch.map(async ([name, constraint]) => { 512 + const info = await checkDependencyOutdated(name, constraint) 513 + return [name, info] as const 514 + }), 515 + ) 516 + 517 + for (const [name, info] of batchResults) { 518 + if (info) { 519 + results[name] = info 520 + } 521 + } 522 + } 523 + 524 + outdated.value = results 525 + } 526 + 527 + watch( 528 + () => toValue(dependencies), 529 + deps => { 530 + fetchOutdatedInfo(deps) 531 + }, 532 + { immediate: true }, 533 + ) 534 + 535 + return outdated 536 + } 537 + 538 + /** 539 + * Get tooltip text for an outdated dependency 540 + */ 541 + export function getOutdatedTooltip(info: OutdatedDependencyInfo): string { 542 + if (info.majorsBehind > 0) { 543 + const s = info.majorsBehind === 1 ? '' : 's' 544 + return `${info.majorsBehind} major version${s} behind (latest: ${info.latest})` 545 + } 546 + if (info.minorsBehind > 0) { 547 + const s = info.minorsBehind === 1 ? '' : 's' 548 + return `${info.minorsBehind} minor version${s} behind (latest: ${info.latest})` 549 + } 550 + return `Patch update available (latest: ${info.latest})` 551 + }
+20 -6
app/pages/[...package].vue
··· 80 80 totalSize: number 81 81 dependencyCount: number 82 82 } 83 - const { data: installSize, status: installSizeStatus } = useLazyFetch<InstallSizeResult | null>( 83 + const { 84 + data: installSize, 85 + status: installSizeStatus, 86 + execute: fetchInstallSize, 87 + } = useLazyFetch<InstallSizeResult | null>( 84 88 () => { 85 89 const base = `/api/registry/install-size/${packageName.value}` 86 90 const version = requestedVersion.value ··· 88 92 }, 89 93 { 90 94 server: false, 95 + immediate: false, 91 96 }, 92 97 ) 98 + onMounted(() => fetchInstallSize()) 93 99 94 100 const sizeTooltip = computed(() => { 95 101 const chunks = [ ··· 335 341 </a> 336 342 337 343 <!-- Package metrics (module format, types) --> 338 - <PackageMetricsBadges 339 - v-if="displayVersion" 340 - :package-name="pkg.name" 341 - :version="displayVersion.version" 342 - /> 344 + <ClientOnly> 345 + <PackageMetricsBadges 346 + v-if="displayVersion" 347 + :package-name="pkg.name" 348 + :version="displayVersion.version" 349 + /> 350 + <template #fallback> 351 + <ul class="flex items-center gap-1.5"> 352 + <li class="skeleton w-8 h-5 rounded" /> 353 + <li class="skeleton w-12 h-5 rounded" /> 354 + </ul> 355 + </template> 356 + </ClientOnly> 343 357 </div> 344 358 <!-- Fixed height description container to prevent CLS --> 345 359 <div ref="descriptionRef" class="relative max-w-2xl min-h-[4.5rem]">