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

fix: use fast-npm-meta to get latest versions (#664)

authored by

Daniel Roe and committed by
GitHub
8b6d617d 828fe546

+217 -211
+4 -14
app/components/Package/Versions.vue
··· 1 1 <script setup lang="ts"> 2 - import type { PackageVersionInfo, PackumentVersion } from '#shared/types' 2 + import type { PackageVersionInfo, SlimVersion } from '#shared/types' 3 3 import { compare } from 'semver' 4 4 import type { RouteLocationRaw } from 'vue-router' 5 5 import { fetchAllPackageVersions } from '~/composables/useNpmRegistry' ··· 14 14 15 15 const props = defineProps<{ 16 16 packageName: string 17 - versions: Record<string, PackumentVersion> 17 + versions: Record<string, SlimVersion> 18 18 distTags: Record<string, string> 19 19 time: Record<string, string> 20 20 }>() ··· 31 31 deprecated?: string 32 32 } 33 33 34 - // Check if a version has provenance/attestations 35 - function hasProvenance(version: PackumentVersion | undefined): boolean { 36 - if (!version?.dist) return false 37 - const dist = version.dist as { attestations?: unknown } 38 - return !!dist.attestations 39 - } 40 - 41 34 // Build route object for package version link 42 35 function versionRoute(version: string): RouteLocationRaw { 43 36 return { ··· 53 46 // Deduplicates so each version appears only once, with all its tags 54 47 const allTagRows = computed(() => { 55 48 // Group tags by version with their metadata 56 - const versionMap = new Map< 57 - string, 58 - { tags: string[]; versionData: PackumentVersion | undefined } 59 - >() 49 + const versionMap = new Map<string, { tags: string[]; versionData: SlimVersion | undefined }>() 60 50 for (const [tag, version] of Object.entries(props.distTags)) { 61 51 const existing = versionMap.get(version) 62 52 if (existing) { ··· 88 78 version, 89 79 time: props.time[version], 90 80 tags, 91 - hasProvenance: hasProvenance(versionData), 81 + hasProvenance: versionData?.hasProvenance, 92 82 deprecated: versionData?.deprecated, 93 83 } as VersionDisplay, 94 84 }))
+47 -57
app/composables/useNpmRegistry.ts
··· 1 1 import type { 2 2 Packument, 3 - PackumentVersion, 4 3 SlimPackument, 5 4 NpmSearchResponse, 6 5 NpmSearchResult, ··· 8 7 NpmPerson, 9 8 PackageVersionInfo, 10 9 } from '#shared/types' 10 + import { getVersions } from 'fast-npm-meta' 11 + import type { ResolvedPackageVersion } from 'fast-npm-meta' 11 12 import type { ReleaseType } from 'semver' 12 13 import { mapWithConcurrency } from '#shared/utils/async' 13 14 import { maxSatisfying, prerelease, major, minor, diff, gt, compare } from 'semver' 14 - import { isExactVersion } from '~/utils/versions' 15 15 import { extractInstallScriptsInfo } from '~/utils/install-scripts' 16 16 import type { CachedFetchFunction } from '#shared/utils/fetch-cache-config' 17 17 ··· 93 93 return downloads 94 94 } 95 95 96 - /** 97 - * Encode a package name for use in npm registry URLs. 98 - * Handles scoped packages (e.g., @scope/name -> @scope%2Fname). 99 - */ 100 - export function encodePackageName(name: string): string { 101 - if (name.startsWith('@')) { 102 - return `@${encodeURIComponent(name.slice(1))}` 103 - } 104 - return encodeURIComponent(name) 105 - } 106 - 107 96 /** Number of recent versions to include in initial payload */ 108 97 const RECENT_VERSIONS_COUNT = 5 109 98 ··· 138 127 } 139 128 140 129 // Build filtered versions object with install scripts info per version 141 - const filteredVersions: Record<string, PackumentVersion> = {} 130 + const filteredVersions: Record<string, SlimVersion> = {} 131 + let versionData: SlimPackumentVersion | null = null 142 132 for (const v of includedVersions) { 143 133 const version = pkg.versions[v] 144 134 if (version) { 145 - // Strip readme from each version, extract install scripts info 146 - const { readme: _readme, scripts, ...slimVersion } = version 135 + if (version.version === requestedVersion) { 136 + // Strip readme from each version, extract install scripts info 137 + const { readme: _readme, scripts, ...slimVersion } = version 147 138 148 - // Extract install scripts info (which scripts exist + npx deps) 149 - const installScripts = scripts ? extractInstallScriptsInfo(scripts) : null 150 - 139 + // Extract install scripts info (which scripts exist + npx deps) 140 + const installScripts = scripts ? extractInstallScriptsInfo(scripts) : null 141 + versionData = { 142 + ...slimVersion, 143 + installScripts: installScripts ?? undefined, 144 + } 145 + } 151 146 filteredVersions[v] = { 152 - ...slimVersion, 153 - installScripts: installScripts ?? undefined, 154 - } as PackumentVersion 147 + ...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}), 148 + version: version.version, 149 + deprecated: version.deprecated, 150 + tags: version.tags as string[], 151 + } 155 152 } 156 153 } 157 154 ··· 177 174 'keywords': pkg.keywords, 178 175 'repository': pkg.repository, 179 176 'bugs': pkg.bugs, 177 + 'requestedVersion': versionData, 180 178 'versions': filteredVersions, 181 179 } 182 180 } 183 181 182 + export function useResolvedVersion( 183 + packageName: MaybeRefOrGetter<string>, 184 + requestedVersion: MaybeRefOrGetter<string | null>, 185 + ) { 186 + return useFetch( 187 + () => { 188 + const version = toValue(requestedVersion) 189 + return version 190 + ? `https://npm.antfu.dev/${toValue(packageName)}@${version}` 191 + : `https://npm.antfu.dev/${toValue(packageName)}` 192 + }, 193 + { 194 + transform: (data: ResolvedPackageVersion) => data.version, 195 + }, 196 + ) 197 + } 198 + 184 199 export function usePackage( 185 200 name: MaybeRefOrGetter<string>, 186 201 requestedVersion?: MaybeRefOrGetter<string | null>, ··· 196 211 }) 197 212 const reqVer = toValue(requestedVersion) 198 213 const pkg = transformPackument(r, reqVer) 199 - const resolvedVersion = getResolvedVersion(pkg, reqVer) 200 - return { ...pkg, resolvedVersion, isStale } 214 + return { ...pkg, isStale } 201 215 }, 202 216 ) 203 217 ··· 208 222 } 209 223 210 224 return asyncData 211 - } 212 - 213 - function getResolvedVersion(pkg: SlimPackument, reqVer?: string | null): string | null { 214 - if (!pkg || !reqVer) return null 215 - 216 - // 1. Check if it's already an exact version in pkg.versions 217 - if (isExactVersion(reqVer) && pkg.versions[reqVer]) { 218 - return reqVer 219 - } 220 - 221 - // 2. Check if it's a dist-tag (latest, next, beta, etc.) 222 - const tagVersion = pkg['dist-tags']?.[reqVer] 223 - if (tagVersion) { 224 - return tagVersion 225 - } 226 - 227 - // 3. Try to resolve as a semver range 228 - const versions = Object.keys(pkg.versions) 229 - const resolved = maxSatisfying(versions, reqVer) 230 - return resolved 231 225 } 232 226 233 227 export function usePackageDownloads( ··· 600 594 const allVersionsCache = new Map<string, Promise<PackageVersionInfo[]>>() 601 595 602 596 /** 603 - * Fetch all versions of a package from the npm registry. 597 + * Fetch all versions of a package using fast-npm-meta API. 604 598 * Returns version info sorted by version (newest first). 605 599 * Results are cached to avoid duplicate requests. 606 600 * 607 601 * Note: This is a standalone async function for use in event handlers. 608 602 * For composable usage, use useAllPackageVersions instead. 603 + * 604 + * @see https://github.com/antfu/fast-npm-meta 609 605 */ 610 606 export async function fetchAllPackageVersions(packageName: string): Promise<PackageVersionInfo[]> { 611 607 const cached = allVersionsCache.get(packageName) 612 608 if (cached) return cached 613 609 614 610 const promise = (async () => { 615 - const encodedName = encodePackageName(packageName) 616 - // Use regular $fetch for client-side calls (this is called on user interaction) 617 - const data = await $fetch<{ 618 - versions: Record<string, { deprecated?: string }> 619 - time: Record<string, string> 620 - }>(`${NPM_REGISTRY}/${encodedName}`) 611 + const data = await getVersions(packageName, { metadata: true }) 621 612 622 - return Object.entries(data.versions) 623 - .filter(([v]) => data.time[v]) 624 - .map(([version, versionData]) => ({ 613 + return Object.entries(data.versionsMeta) 614 + .map(([version, meta]) => ({ 625 615 version, 626 - time: data.time[version], 627 - hasProvenance: false, // Would need to check dist.attestations for each version 628 - deprecated: versionData.deprecated, 616 + time: meta.time, 617 + hasProvenance: meta.provenance === 'trustedPublisher' || meta.provenance === true, 618 + deprecated: meta.deprecated, 629 619 })) 630 620 .sort((a, b) => compare(b.version, a.version)) 631 621 })()
+1 -7
app/composables/usePackageComparison.ts
··· 1 1 import type { FacetValue, ComparisonFacet, ComparisonPackage } from '#shared/types' 2 + import { encodePackageName } from '#shared/utils/npm' 2 3 import type { PackageAnalysisResponse } from './usePackageAnalysis' 3 4 4 5 export interface PackageComparisonData { ··· 220 221 isFacetLoading, 221 222 isColumnLoading, 222 223 } 223 - } 224 - 225 - function encodePackageName(name: string): string { 226 - if (name.startsWith('@')) { 227 - return `@${encodeURIComponent(name.slice(1))}` 228 - } 229 - return encodeURIComponent(name) 230 224 } 231 225 232 226 function computeFacetValue(facet: ComparisonFacet, data: PackageComparisonData): FacetValue | null {
+30 -47
app/pages/[...package].vue
··· 104 104 const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion) 105 105 const { data: moduleReplacement } = useModuleReplacement(packageName) 106 106 107 - const { data: pkg, status, error } = await usePackage(packageName, requestedVersion) 108 - const resolvedVersion = computed(() => pkg.value?.resolvedVersion ?? null) 109 - 110 - // Get the version to display (resolved version or latest) 111 - const displayVersion = computed(() => { 112 - if (!pkg.value) return null 107 + const { data: resolvedVersion } = await useResolvedVersion(packageName, requestedVersion) 113 108 114 - // Use resolved version if available 115 - if (resolvedVersion.value) { 116 - return pkg.value.versions[resolvedVersion.value] ?? null 117 - } 118 - 119 - // Fallback to latest 120 - const latestTag = pkg.value['dist-tags']?.latest 121 - if (!latestTag) return null 122 - return pkg.value.versions[latestTag] ?? null 123 - }) 109 + const { data: pkg, status, error } = usePackage(packageName, requestedVersion) 110 + const displayVersion = computed(() => pkg.value?.requestedVersion ?? null) 124 111 125 112 // Process package description 126 113 const pkgDescription = useMarkdown(() => ({ ··· 138 125 // This is the same composable used by PackageVulnerabilityTree and PackageDeprecatedTree 139 126 const { data: vulnTree, status: vulnTreeStatus } = useDependencyAnalysis( 140 127 packageName, 141 - () => displayVersion.value?.version ?? '', 128 + () => resolvedVersion.value ?? '', 142 129 ) 143 130 144 131 // Keep latestVersion for comparison (to show "(latest)" badge) ··· 261 248 262 249 // Docs URL: use our generated API docs 263 250 const docsLink = computed(() => { 264 - if (!displayVersion.value) return null 251 + if (!resolvedVersion.value) return null 265 252 266 253 return { 267 254 name: 'docs' as const, 268 255 params: { 269 - path: [...pkg.value!.name.split('/'), 'v', displayVersion.value.version], 256 + path: [...pkg.value!.name.split('/'), 'v', resolvedVersion.value], 270 257 }, 271 258 } 272 259 }) ··· 360 347 onKeyStroke( 361 348 e => isKeyWithoutModifiers(e, '.') && !isEditableElement(e.target), 362 349 e => { 363 - if (pkg.value == null || displayVersion.value == null) return 350 + if (pkg.value == null || resolvedVersion.value == null) return 364 351 e.preventDefault() 365 352 navigateTo({ 366 353 name: 'code', 367 354 params: { 368 - path: [pkg.value.name, 'v', displayVersion.value.version], 355 + path: [pkg.value.name, 'v', resolvedVersion.value], 369 356 }, 370 357 }) 371 358 }, ··· 393 380 394 381 defineOgImageComponent('Package', { 395 382 name: () => pkg.value?.name ?? 'Package', 396 - version: () => displayVersion.value?.version ?? '', 383 + version: () => resolvedVersion.value ?? '', 397 384 downloads: () => (downloads.value ? $n(downloads.value.downloads) : ''), 398 385 license: () => pkg.value?.license ?? '', 399 386 stars: () => stars.value ?? 0, ··· 455 442 456 443 <span id="copy-pkg-name" class="sr-only">{{ $t('package.copy_name') }}</span> 457 444 <span 458 - v-if="displayVersion" 445 + v-if="resolvedVersion" 459 446 class="inline-flex items-baseline gap-1.5 font-mono text-base sm:text-lg text-fg-muted shrink-0" 460 447 > 461 448 <!-- Version resolution indicator (e.g., "latest → 4.2.0") --> 462 - <template v-if="resolvedVersion !== requestedVersion"> 449 + <template v-if="requestedVersion && resolvedVersion !== requestedVersion"> 463 450 <span class="font-mono text-fg-muted text-sm">{{ requestedVersion }}</span> 464 451 <span class="i-carbon:arrow-right rtl-flip w-3 h-3" aria-hidden="true" /> 465 452 </template> 466 453 467 454 <NuxtLink 468 - v-if="resolvedVersion !== requestedVersion" 469 - :to="`/${pkg.name}/v/${displayVersion.version}`" 455 + v-if="requestedVersion && resolvedVersion !== requestedVersion" 456 + :to="`/${pkg.name}/v/${resolvedVersion}`" 470 457 :title="$t('package.view_permalink')" 471 - >{{ displayVersion.version }}</NuxtLink 458 + >{{ resolvedVersion }}</NuxtLink 472 459 > 473 - <span v-else>v{{ displayVersion.version }}</span> 460 + <span v-else>v{{ resolvedVersion }}</span> 474 461 475 462 <a 476 463 v-if="hasProvenance(displayVersion)" 477 - :href="`https://www.npmjs.com/package/${pkg.name}/v/${displayVersion.version}#provenance`" 464 + :href="`https://www.npmjs.com/package/${pkg.name}/v/${resolvedVersion}#provenance`" 478 465 target="_blank" 479 466 rel="noopener noreferrer" 480 467 class="inline-flex items-center justify-center gap-1.5 text-fg-muted hover:text-fg transition-colors duration-200 min-w-6 min-h-6" ··· 483 470 <span class="i-solar:shield-check-outline w-3.5 h-3.5 shrink-0" aria-hidden="true" /> 484 471 </a> 485 472 <span 486 - v-if=" 487 - requestedVersion && 488 - latestVersion && 489 - displayVersion.version !== latestVersion.version 490 - " 473 + v-if="requestedVersion && latestVersion && resolvedVersion !== latestVersion.version" 491 474 class="text-fg-subtle text-sm shrink-0" 492 475 >{{ $t('package.not_latest') }}</span 493 476 > ··· 496 479 <!-- Package metrics (module format, types) --> 497 480 <ClientOnly> 498 481 <PackageMetricsBadges 499 - v-if="displayVersion" 482 + v-if="resolvedVersion" 500 483 :package-name="pkg.name" 501 - :version="displayVersion.version" 484 + :version="resolvedVersion" 502 485 :is-binary="isBinaryOnly" 503 486 class="self-baseline ms-1 sm:ms-2" 504 487 /> ··· 512 495 513 496 <!-- Internal navigation: Docs + Code + Compare (hidden on mobile, shown in external links instead) --> 514 497 <nav 515 - v-if="displayVersion" 498 + v-if="resolvedVersion" 516 499 :aria-label="$t('package.navigation')" 517 500 class="hidden sm:flex items-center gap-0.5 p-0.5 bg-bg-subtle border border-border-subtle rounded-md shrink-0 ms-auto self-center" 518 501 > ··· 535 518 :to="{ 536 519 name: 'code', 537 520 params: { 538 - path: [...pkg.name.split('/'), 'v', displayVersion.version], 521 + path: [...pkg.name.split('/'), 'v', resolvedVersion], 539 522 }, 540 523 }" 541 524 class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 border border-transparent text-fg-subtle hover:text-fg hover:bg-bg hover:shadow hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 inline-flex items-center gap-1.5" ··· 686 669 {{ $t('package.links.docs') }} 687 670 </NuxtLink> 688 671 </li> 689 - <li v-if="displayVersion" class="sm:hidden"> 672 + <li v-if="resolvedVersion" class="sm:hidden"> 690 673 <NuxtLink 691 674 :to="{ 692 675 name: 'code', 693 676 params: { 694 - path: [...pkg.name.split('/'), 'v', displayVersion.version], 677 + path: [...pkg.name.split('/'), 'v', resolvedVersion], 695 678 }, 696 679 }" 697 680 class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" ··· 789 772 790 773 <a 791 774 v-if="getDependencyCount(displayVersion) > 0" 792 - :href="`https://node-modules.dev/grid/depth#install=${pkg.name}${displayVersion?.version ? `@${displayVersion.version}` : ''}`" 775 + :href="`https://node-modules.dev/grid/depth#install=${pkg.name}${resolvedVersion ? `@${resolvedVersion}` : ''}`" 793 776 target="_blank" 794 777 rel="noopener noreferrer" 795 778 class="text-fg-subtle hover:text-fg transition-colors duration-200 inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1" ··· 959 942 <!-- Vulnerability scan --> 960 943 <ClientOnly> 961 944 <PackageVulnerabilityTree 962 - v-if="displayVersion" 945 + v-if="resolvedVersion" 963 946 :package-name="pkg.name" 964 - :version="displayVersion.version" 947 + :version="resolvedVersion" 965 948 /> 966 949 <PackageDeprecatedTree 967 - v-if="displayVersion" 950 + v-if="resolvedVersion" 968 951 :package-name="pkg.name" 969 - :version="displayVersion.version" 952 + :version="resolvedVersion" 970 953 class="mt-3" 971 954 /> 972 955 </ClientOnly> ··· 1108 1091 1109 1092 <!-- Dependencies --> 1110 1093 <PackageDependencies 1111 - v-if="hasDependencies && displayVersion" 1094 + v-if="hasDependencies && resolvedVersion && displayVersion" 1112 1095 :package-name="pkg.name" 1113 - :version="displayVersion.version" 1096 + :version="resolvedVersion" 1114 1097 :dependencies="displayVersion.dependencies" 1115 1098 :peer-dependencies="displayVersion.peerDependencies" 1116 1099 :peer-dependencies-meta="displayVersion.peerDependenciesMeta"
+5 -6
app/pages/docs/[...path].vue
··· 1 1 <script setup lang="ts"> 2 2 import { setResponseHeader } from 'h3' 3 3 import type { DocsResponse } from '#shared/types' 4 - import { assertValidPackageName } from '#shared/utils/npm' 4 + import { assertValidPackageName, fetchLatestVersion } from '#shared/utils/npm' 5 5 6 6 definePageMeta({ 7 7 name: 'docs', ··· 39 39 40 40 const latestVersion = computed(() => pkg.value?.['dist-tags']?.latest ?? null) 41 41 42 - if (import.meta.server && !requestedVersion.value) { 42 + if (import.meta.server && !requestedVersion.value && packageName.value) { 43 43 const app = useNuxtApp() 44 - const { data: pkg } = await usePackage(packageName) 45 - const latest = pkg.value?.['dist-tags']?.latest 46 - if (latest) { 44 + const version = await fetchLatestVersion(packageName.value) 45 + if (version) { 47 46 setResponseHeader(useRequestEvent()!, 'Cache-Control', 'no-cache') 48 47 app.runWithContext(() => 49 - navigateTo('/docs/' + packageName.value + '/v/' + latest, { redirectCode: 302 }), 48 + navigateTo('/docs/' + packageName.value + '/v/' + version, { redirectCode: 302 }), 50 49 ) 51 50 } 52 51 }
+1
package.json
··· 104 104 "@vitest/coverage-v8": "4.0.18", 105 105 "@vue/test-utils": "2.4.6", 106 106 "axe-core": "4.11.1", 107 + "fast-npm-meta": "1.0.0", 107 108 "knip": "5.82.1", 108 109 "lint-staged": "16.2.7", 109 110 "playwright-core": "1.58.0",
+8
pnpm-lock.yaml
··· 219 219 axe-core: 220 220 specifier: 4.11.1 221 221 version: 4.11.1 222 + fast-npm-meta: 223 + specifier: 1.0.0 224 + version: 1.0.0 222 225 knip: 223 226 specifier: 5.82.1 224 227 version: 5.82.1(@types/node@24.10.9)(typescript@5.9.3) ··· 5685 5688 5686 5689 fast-npm-meta@0.4.8: 5687 5690 resolution: {integrity: sha512-ybZVlDZ2PkO79dosM+6CLZfKWRH8MF0PiWlw8M4mVWJl8IEJrPfxYc7Tsu830Dwj/R96LKXfePGTSzKWbPJ08w==} 5691 + 5692 + fast-npm-meta@1.0.0: 5693 + resolution: {integrity: sha512-LZN+gRB2TnkkhRbNnTOgEXyhNBkDypngGX9nJzcKzrOaIQqfZnWEDYdxBqGw6bgiyl0tzpxDuTk1G7tKHyUcsg==} 5688 5694 5689 5695 fast-redact@3.5.0: 5690 5696 resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} ··· 15800 15806 fast-levenshtein@2.0.6: {} 15801 15807 15802 15808 fast-npm-meta@0.4.8: {} 15809 + 15810 + fast-npm-meta@1.0.0: {} 15803 15811 15804 15812 fast-redact@3.5.0: {} 15805 15813
+36 -35
server/api/registry/analysis/[...pkg].get.ts
··· 18 18 ERROR_PACKAGE_ANALYSIS_FAILED, 19 19 } from '#shared/utils/constants' 20 20 import { parseRepoUrl } from '#shared/utils/git-providers' 21 - 22 - /** Minimal packument data needed to check deprecation status */ 23 - interface MinimalPackument { 24 - 'name': string 25 - 'dist-tags'?: { latest?: string } 26 - 'versions'?: Record<string, { deprecated?: string }> 27 - } 21 + import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta' 28 22 29 23 export default defineCachedEventHandler( 30 24 async event => { ··· 90 84 } 91 85 92 86 /** 93 - * Fetch @types package info including deprecation status. 87 + * Fetch @types package info including deprecation status using fast-npm-meta. 94 88 * Returns undefined if the package doesn't exist. 95 89 */ 96 90 async function fetchTypesPackageInfo(packageName: string): Promise<TypesPackageInfo | undefined> { 97 - try { 98 - const encodedName = encodePackageName(packageName) 99 - // Fetch abbreviated packument to check latest version's deprecation status 100 - const packument = await $fetch<MinimalPackument>(`${NPM_REGISTRY}/${encodedName}`, { 101 - headers: { 102 - // Request abbreviated packument to reduce payload 103 - Accept: 'application/vnd.npm.install-v1+json', 104 - }, 105 - }) 106 - 107 - // Get the latest version's deprecation message if any 108 - const latestVersion = packument['dist-tags']?.latest 109 - const deprecated = latestVersion ? packument.versions?.[latestVersion]?.deprecated : undefined 110 - 111 - return { 112 - packageName, 113 - deprecated, 114 - } 115 - } catch { 91 + const result = await getLatestVersion(packageName, { metadata: true, throw: false }) 92 + if ('error' in result) { 116 93 return undefined 94 + } 95 + return { 96 + packageName, 97 + deprecated: result.deprecated, 117 98 } 118 99 } 119 100 ··· 135 116 } 136 117 137 118 /** 138 - * Find an associated create-* package by trying multiple naming patterns in parallel. 119 + * Find an associated create-* package by trying multiple naming patterns using batch API. 139 120 * Returns the first associated package found (preferring create-{name} over create-{name}-app). 140 121 */ 141 122 async function findAssociatedCreatePackage( ··· 143 124 basePkg: ExtendedPackageJson, 144 125 ): Promise<CreatePackageInfo | undefined> { 145 126 const candidates = getCreatePackageNameCandidates(packageName) 146 - const results = await Promise.all(candidates.map(name => fetchCreatePackageInfo(name, basePkg))) 147 - return results.find(r => r !== undefined) 127 + 128 + // Use batch API to fetch all candidates in a single request 129 + const results = await getLatestVersionBatch(candidates, { metadata: true, throw: false }) 130 + 131 + // Process results in order (first valid match wins) 132 + for (let i = 0; i < candidates.length; i++) { 133 + const result = results[i] 134 + const candidateName = candidates[i] 135 + if (!result || !candidateName || 'error' in result) continue 136 + 137 + // Need to fetch full package data for association validation (maintainers/repo) 138 + const createPkgInfo = await fetchCreatePackageForValidation( 139 + candidateName, 140 + basePkg, 141 + result.deprecated, 142 + ) 143 + if (createPkgInfo) { 144 + return createPkgInfo 145 + } 146 + } 147 + 148 + return undefined 148 149 } 149 150 150 151 /** 151 - * Fetch create-* package info including deprecation status. 152 - * Validates that the create-* package is actually associated with the base package. 153 - * Returns undefined if the package doesn't exist or isn't associated. 152 + * Fetch create-* package metadata for association validation. 153 + * Returns CreatePackageInfo if the package is associated with the base package. 154 154 */ 155 - async function fetchCreatePackageInfo( 155 + async function fetchCreatePackageForValidation( 156 156 createPkgName: string, 157 157 basePkg: ExtendedPackageJson, 158 + deprecated: string | undefined, 158 159 ): Promise<CreatePackageInfo | undefined> { 159 160 try { 160 161 const encodedName = encodePackageName(createPkgName) ··· 168 169 169 170 return { 170 171 packageName: createPkgName, 171 - deprecated: createPkg.deprecated, 172 + deprecated, 172 173 } 173 174 } catch { 174 175 return undefined
+2 -2
server/api/registry/badge/[...pkg].get.ts
··· 2 2 import { createError, getRouterParam, setHeader } from 'h3' 3 3 import { PackageRouteParamsSchema } from '#shared/schemas/package' 4 4 import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' 5 - import { fetchNpmPackage } from '#server/utils/npm' 5 + import { fetchLatestVersionWithFallback } from '#server/utils/npm' 6 6 import { assertValidPackageName } from '#shared/utils/npm' 7 7 import { handleApiError } from '#server/utils/error-handler' 8 8 ··· 31 31 const label = `./ ${packageName}` 32 32 33 33 const value = 34 - requestedVersion ?? (await fetchNpmPackage(packageName))['dist-tags']?.latest ?? 'unknown' 34 + requestedVersion ?? (await fetchLatestVersionWithFallback(packageName)) ?? 'unknown' 35 35 36 36 const leftWidth = measureTextWidth(label) 37 37 const rightWidth = measureTextWidth(value)
+5 -5
server/api/registry/install-size/[...pkg].get.ts
··· 22 22 version: rawVersion, 23 23 }) 24 24 25 - // If no version specified, resolve to latest 26 - let version = requestedVersion 25 + // If no version specified, resolve to latest using fast-npm-meta (lightweight) 26 + let version: string | undefined = requestedVersion 27 27 if (!version) { 28 - const packument = await fetchNpmPackage(packageName) 29 - version = packument['dist-tags']?.latest 30 - if (!version) { 28 + const latestVersion = await fetchLatestVersionWithFallback(packageName) 29 + if (!latestVersion) { 31 30 throw createError({ 32 31 statusCode: 404, 33 32 message: 'No latest version found', 34 33 }) 35 34 } 35 + version = latestVersion 36 36 } 37 37 38 38 return await calculateInstallSize(packageName, version)
+5 -5
server/api/registry/vulnerabilities/[...pkg].get.ts
··· 19 19 version: rawVersion, 20 20 }) 21 21 22 - // If no version specified, resolve to latest 23 - let version = requestedVersion 22 + // If no version specified, resolve to latest using fast-npm-meta (lightweight) 23 + let version: string | undefined = requestedVersion 24 24 if (!version) { 25 - const packument = await fetchNpmPackage(packageName) 26 - version = packument['dist-tags']?.latest 27 - if (!version) { 25 + const latestVersion = await fetchLatestVersionWithFallback(packageName) 26 + if (!latestVersion) { 28 27 throw createError({ 29 28 statusCode: 404, 30 29 message: 'No latest version found', 31 30 }) 32 31 } 32 + version = latestVersion 33 33 } 34 34 35 35 return await analyzeDependencyTree(packageName, version)
+23 -8
server/utils/npm.ts
··· 1 1 import type { Packument } from '#shared/types' 2 + import { encodePackageName, fetchLatestVersion } from '#shared/utils/npm' 2 3 import { maxSatisfying, prerelease } from 'semver' 4 + import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants' 3 5 4 6 const NPM_REGISTRY = 'https://registry.npmjs.org' 5 7 6 - function encodePackageName(name: string): string { 7 - if (name.startsWith('@')) { 8 - return `@${encodeURIComponent(name.slice(1))}` 9 - } 10 - return encodeURIComponent(name) 11 - } 12 - 13 8 export const fetchNpmPackage = defineCachedFunction( 14 9 async (name: string): Promise<Packument> => { 15 10 const encodedName = encodePackageName(name) 16 11 return await $fetch<Packument>(`${NPM_REGISTRY}/${encodedName}`) 17 12 }, 18 13 { 19 - maxAge: 60 * 5, 14 + maxAge: CACHE_MAX_AGE_FIVE_MINUTES, 20 15 swr: true, 21 16 name: 'npm-package', 22 17 getKey: (name: string) => name, 23 18 }, 24 19 ) 20 + 21 + /** 22 + * Get the latest version of a package using fast-npm-meta API. 23 + * Falls back to full packument if fast-npm-meta fails. 24 + * 25 + * @param name Package name 26 + * @returns Latest version string or null if not found 27 + */ 28 + export async function fetchLatestVersionWithFallback(name: string): Promise<string | null> { 29 + const version = await fetchLatestVersion(name) 30 + if (version) return version 31 + 32 + // Fallback to full packument (also cached) 33 + try { 34 + const packument = await fetchNpmPackage(name) 35 + return packument['dist-tags']?.latest ?? null 36 + } catch { 37 + return null 38 + } 39 + } 25 40 26 41 /** 27 42 * Check if a version constraint explicitly includes a prerelease tag.
+7 -1
shared/types/npm-registry.ts
··· 30 30 installScripts?: InstallScriptsInfo 31 31 } 32 32 33 + export type SlimVersion = Pick<SlimPackumentVersion, 'version' | 'deprecated' | 'tags'> & { 34 + hasProvenance?: true 35 + } 36 + 33 37 /** 34 38 * Slimmed down Packument for client-side use. 35 39 * Strips unnecessary fields to reduce payload size. ··· 52 56 'keywords'?: string[] 53 57 'repository'?: { type?: string; url?: string; directory?: string } 54 58 'bugs'?: { url?: string; email?: string } 59 + /** current version */ 60 + 'requestedVersion': SlimPackumentVersion | null 55 61 /** Only includes dist-tag versions (with installScripts info added per version) */ 56 - 'versions': Record<string, SlimPackumentVersion> 62 + 'versions': Record<string, SlimVersion> 57 63 } 58 64 59 65 /**
+1
shared/utils/constants.ts
··· 1 1 // Duration 2 2 export const CACHE_MAX_AGE_ONE_MINUTE = 60 3 + export const CACHE_MAX_AGE_FIVE_MINUTES = 60 * 5 3 4 export const CACHE_MAX_AGE_ONE_HOUR = 60 * 60 4 5 export const CACHE_MAX_AGE_ONE_DAY = 60 * 60 * 24 5 6 export const CACHE_MAX_AGE_ONE_YEAR = 60 * 60 * 24 * 365
+29
shared/utils/npm.ts
··· 1 + import { getLatestVersion } from 'fast-npm-meta' 1 2 import { createError } from 'h3' 2 3 import validatePackageName from 'validate-npm-package-name' 4 + 5 + /** 6 + * Encode package name for URL usage. 7 + * Scoped packages need special handling (@scope/name → @scope%2Fname) 8 + */ 9 + export function encodePackageName(name: string): string { 10 + if (name.startsWith('@')) { 11 + return `@${encodeURIComponent(name.slice(1))}` 12 + } 13 + return encodeURIComponent(name) 14 + } 15 + 16 + /** 17 + * Fetch the latest version of a package using fast-npm-meta API. 18 + * This is a lightweight alternative to fetching the full packument. 19 + * 20 + * @param name Package name 21 + * @returns Latest version string or null if not found 22 + * @see https://github.com/antfu/fast-npm-meta 23 + */ 24 + export async function fetchLatestVersion(name: string): Promise<string | null> { 25 + try { 26 + const meta = await getLatestVersion(name) 27 + return meta.version 28 + } catch { 29 + return null 30 + } 31 + } 3 32 4 33 /** 5 34 * Validate an npm package name and throw an HTTP error if invalid.
+1 -1
test/e2e/vulnerabilities.spec.ts
··· 73 73 ) 74 74 const response = await page.request.get(url) 75 75 76 - expect(response.status()).toBe(502) // Based on handleApiError fallback 76 + expect(response.status()).toBe(404) // Package not found returns 404 77 77 }) 78 78 })
+4 -6
test/nuxt/a11y.spec.ts
··· 504 504 505 505 describe('PackageVersions', () => { 506 506 it('should have no accessibility violations', async () => { 507 - // Minimal mock data satisfying PackumentVersion type 507 + // Minimal mock data satisfying SlimVersion type 508 508 const mockVersion = { 509 - _id: 'vue@3.5.0', 510 - _npmVersion: '10.0.0', 511 - name: 'vue', 512 509 version: '3.5.0', 513 - dist: { tarball: '', shasum: '', signatures: [] }, 510 + deprecated: undefined, 511 + tags: undefined, 514 512 } 515 513 const component = await mountSuspended(PackageVersions, { 516 514 props: { 517 515 packageName: 'vue', 518 516 versions: { 519 517 '3.5.0': mockVersion, 520 - '3.4.0': { ...mockVersion, _id: 'vue@3.4.0', version: '3.4.0' }, 518 + '3.4.0': { ...mockVersion, version: '3.4.0' }, 521 519 }, 522 520 distTags: { 523 521 latest: '3.5.0',
+8 -17
test/nuxt/components/PackageVersions.spec.ts
··· 1 1 import { describe, expect, it, vi, beforeEach } from 'vitest' 2 2 import { mountSuspended } from '@nuxt/test-utils/runtime' 3 3 import PackageVersions from '~/components/Package/Versions.vue' 4 - import type { PackumentVersion } from '#shared/types' 4 + import type { SlimVersion } from '#shared/types' 5 5 6 6 // Mock the fetchAllPackageVersions function 7 7 const mockFetchAllPackageVersions = vi.fn() ··· 10 10 })) 11 11 12 12 /** 13 - * Helper to create a minimal PackumentVersion for testing 13 + * Helper to create a minimal SlimVersion for testing 14 14 */ 15 15 function createVersion( 16 16 version: string, ··· 18 18 deprecated?: string 19 19 hasProvenance?: boolean 20 20 } = {}, 21 - ): PackumentVersion { 22 - const dist: Record<string, unknown> = { 23 - tarball: `https://registry.npmjs.org/test-package/-/test-package-${version}.tgz`, 24 - shasum: 'abc123', 25 - } 26 - if (options.hasProvenance) { 27 - dist.attestations = { url: 'https://example.com', provenance: { predicateType: 'test' } } 28 - } 21 + ): SlimVersion { 29 22 return { 30 - _id: `test-package@${version}`, 31 - _npmVersion: '10.0.0', 32 - name: 'test-package', 33 23 version, 34 - dist, 35 24 deprecated: options.deprecated, 36 - } as unknown as PackumentVersion 25 + tags: undefined, 26 + ...(options.hasProvenance ? { hasProvenance: true } : {}), 27 + } as SlimVersion 37 28 } 38 29 39 30 describe('PackageVersions', () => { ··· 459 450 460 451 it('shows count of hidden tagged versions', async () => { 461 452 // Create more than MAX_VISIBLE_TAGS (10) dist-tags 462 - const versions: Record<string, PackumentVersion> = {} 453 + const versions: Record<string, SlimVersion> = {} 463 454 const distTags: Record<string, string> = {} 464 455 const time: Record<string, string> = {} 465 456 ··· 551 542 describe('MAX_VISIBLE_TAGS limit', () => { 552 543 it('limits visible tag rows to 10', async () => { 553 544 // Create 15 dist-tags 554 - const versions: Record<string, PackumentVersion> = {} 545 + const versions: Record<string, SlimVersion> = {} 555 546 const distTags: Record<string, string> = {} 556 547 const time: Record<string, string> = {} 557 548