[READ-ONLY] a fast, modern browser for the npm registry
at main 564 lines 18 kB view raw
1import type { 2 FacetValue, 3 ComparisonFacet, 4 ComparisonPackage, 5 Packument, 6 VulnerabilityTreeResult, 7} from '#shared/types' 8import type { PackageLikes } from '#shared/types/social' 9import { encodePackageName } from '#shared/utils/npm' 10import type { PackageAnalysisResponse } from './usePackageAnalysis' 11import { isBinaryOnlyPackage } from '#shared/utils/binary-detection' 12import { getDependencyCount } from '~/utils/npm/dependency-count' 13 14/** Special identifier for the "What Would James Do?" comparison column */ 15export const NO_DEPENDENCY_ID = '__no_dependency__' 16 17/** 18 * Special display values for the "no dependency" column. 19 * These are explicit markers that get special rendering treatment. 20 */ 21export const NoDependencyDisplay = { 22 /** Display as "–" (en-dash) */ 23 DASH: '__display_dash__', 24 /** Display as "Up to you!" with good status */ 25 UP_TO_YOU: '__display_up_to_you__', 26} as const 27 28export interface PackageComparisonData { 29 package: ComparisonPackage 30 downloads?: number 31 /** Total likes from atproto */ 32 totalLikes?: number 33 /** Package's own unpacked size (from dist.unpackedSize) */ 34 packageSize?: number 35 /** Number of direct dependencies */ 36 directDeps: number | null 37 /** Install size data (fetched lazily) */ 38 installSize?: { 39 selfSize: number 40 totalSize: number 41 /** Total dependency count */ 42 dependencyCount: number 43 } 44 analysis?: PackageAnalysisResponse 45 vulnerabilities?: { 46 count: number 47 severity: { critical: number; high: number; moderate: number; low: number } 48 } 49 metadata?: { 50 license?: string 51 /** 52 * Publish date of this version (ISO 8601 date-time string). 53 * Uses `time[version]` from the registry, NOT `time.modified`. 54 * For example, if the package was most recently published 3 years ago 55 * but a maintainer was removed last week, this would show the '3 years ago' time. 56 */ 57 lastUpdated?: string 58 engines?: { node?: string; npm?: string } 59 deprecated?: string 60 } 61 /** Whether this is a binary-only package (CLI without library entry points) */ 62 isBinaryOnly?: boolean 63 /** Marks this as the "no dependency" column for special display */ 64 isNoDependency?: boolean 65} 66 67/** 68 * Composable for fetching and comparing multiple packages. 69 * 70 */ 71export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) { 72 const { t } = useI18n() 73 const numberFormatter = useNumberFormatter() 74 const compactNumberFormatter = useCompactNumberFormatter() 75 const bytesFormatter = useBytesFormatter() 76 const packages = computed(() => toValue(packageNames)) 77 78 // Cache of fetched data by package name (source of truth) 79 const cache = shallowRef(new Map<string, PackageComparisonData>()) 80 81 // Derived array in current package order 82 const packagesData = computed(() => packages.value.map(name => cache.value.get(name) ?? null)) 83 84 const status = shallowRef<'idle' | 'pending' | 'success' | 'error'>('idle') 85 const error = shallowRef<Error | null>(null) 86 87 // Track which packages are currently being fetched 88 const loadingPackages = shallowRef(new Set<string>()) 89 90 // Track install size loading separately (it's slower) 91 const installSizeLoading = shallowRef(false) 92 93 // Fetch function - only fetches packages not already in cache 94 async function fetchPackages(names: string[]) { 95 if (names.length === 0) { 96 status.value = 'idle' 97 return 98 } 99 100 // Handle "no dependency" column - add to cache immediately 101 if (names.includes(NO_DEPENDENCY_ID) && !cache.value.has(NO_DEPENDENCY_ID)) { 102 const newCache = new Map(cache.value) 103 newCache.set(NO_DEPENDENCY_ID, createNoDependencyData()) 104 cache.value = newCache 105 } 106 107 // Only fetch packages not already cached (excluding "no dep" which has no remote data) 108 const namesToFetch = names.filter(name => name !== NO_DEPENDENCY_ID && !cache.value.has(name)) 109 110 if (namesToFetch.length === 0) { 111 status.value = 'success' 112 return 113 } 114 115 status.value = 'pending' 116 error.value = null 117 118 // Mark packages as loading 119 loadingPackages.value = new Set(namesToFetch) 120 121 try { 122 // First pass: fetch fast data (package info, downloads, analysis, vulns) 123 const results = await Promise.all( 124 namesToFetch.map(async (name): Promise<PackageComparisonData | null> => { 125 try { 126 // Fetch basic package info first (required) 127 const pkgData = await $fetch<Packument>( 128 `https://registry.npmjs.org/${encodePackageName(name)}`, 129 ) 130 131 const latestVersion = pkgData['dist-tags']?.latest 132 if (!latestVersion) return null 133 134 // Fetch fast additional data in parallel (optional - failures are ok) 135 const [downloads, analysis, vulns, likes] = await Promise.all([ 136 $fetch<{ downloads: number }>( 137 `https://api.npmjs.org/downloads/point/last-week/${encodePackageName(name)}`, 138 ).catch(() => null), 139 $fetch<PackageAnalysisResponse>( 140 `/api/registry/analysis/${encodePackageName(name)}`, 141 ).catch(() => null), 142 $fetch<VulnerabilityTreeResult>( 143 `/api/registry/vulnerabilities/${encodePackageName(name)}`, 144 ).catch(() => null), 145 $fetch<PackageLikes>(`/api/social/likes/${encodePackageName(name)}`).catch( 146 () => null, 147 ), 148 ]) 149 const versionData = pkgData.versions[latestVersion] 150 const packageSize = versionData?.dist?.unpackedSize 151 152 // Detect if package is binary-only 153 const isBinary = isBinaryOnlyPackage({ 154 name: pkgData.name, 155 bin: versionData?.bin, 156 main: versionData?.main, 157 module: versionData?.module, 158 exports: versionData?.exports, 159 }) 160 161 // Vulnerabilities 162 let vulnsTotal: number = 0 163 let vulnsSeverity = { critical: 0, high: 0, moderate: 0, low: 0 } 164 165 if (vulns) { 166 const { total, ...severity } = vulns.totalCounts 167 vulnsTotal = total 168 vulnsSeverity = severity 169 } 170 171 return { 172 package: { 173 name: pkgData.name, 174 version: latestVersion, 175 description: undefined, 176 }, 177 downloads: downloads?.downloads, 178 packageSize, 179 directDeps: versionData ? getDependencyCount(versionData) : null, 180 installSize: undefined, // Will be filled in second pass 181 analysis: analysis ?? undefined, 182 vulnerabilities: { 183 count: vulnsTotal, 184 severity: vulnsSeverity, 185 }, 186 metadata: { 187 license: 188 typeof pkgData.license === 'object' && 'type' in pkgData.license 189 ? pkgData.license.type 190 : pkgData.license, 191 // Use version-specific publish time, NOT time.modified (which can be 192 // updated by metadata changes like maintainer additions) 193 lastUpdated: pkgData.time?.[latestVersion], 194 engines: analysis?.engines, 195 deprecated: versionData?.deprecated, 196 }, 197 isBinaryOnly: isBinary, 198 totalLikes: likes?.totalLikes, 199 } 200 } catch { 201 return null 202 } 203 }), 204 ) 205 206 // Add results to cache 207 const newCache = new Map(cache.value) 208 for (const [i, name] of namesToFetch.entries()) { 209 const data = results[i] 210 if (data) { 211 newCache.set(name, data) 212 } 213 } 214 cache.value = newCache 215 loadingPackages.value = new Set() 216 status.value = 'success' 217 218 // Second pass: fetch slow install size data in background for new packages 219 installSizeLoading.value = true 220 Promise.all( 221 namesToFetch.map(async name => { 222 try { 223 const installSize = await $fetch<{ 224 selfSize: number 225 totalSize: number 226 dependencyCount: number 227 }>(`/api/registry/install-size/${encodePackageName(name)}`) 228 229 // Update cache with install size 230 const existing = cache.value.get(name) 231 if (existing) { 232 const updated = new Map(cache.value) 233 updated.set(name, { ...existing, installSize }) 234 cache.value = updated 235 } 236 } catch { 237 // Install size fetch failed, leave as undefined 238 } 239 }), 240 ).finally(() => { 241 installSizeLoading.value = false 242 }) 243 } catch (e) { 244 loadingPackages.value = new Set() 245 error.value = e as Error 246 status.value = 'error' 247 } 248 } 249 250 // Watch for package changes and refetch (client-side only) 251 if (import.meta.client) { 252 watch( 253 packages, 254 newPackages => { 255 fetchPackages(newPackages) 256 }, 257 { immediate: true }, 258 ) 259 } 260 261 // Compute values for each facet 262 function getFacetValues(facet: ComparisonFacet): (FacetValue | null)[] { 263 if (!packagesData.value || packagesData.value.length === 0) return [] 264 265 return packagesData.value.map(pkg => { 266 if (!pkg) return null 267 return computeFacetValue( 268 facet, 269 pkg, 270 numberFormatter.value.format, 271 compactNumberFormatter.value.format, 272 bytesFormatter.format, 273 t, 274 ) 275 }) 276 } 277 278 // Check if a facet depends on slow-loading data 279 function isFacetLoading(facet: ComparisonFacet): boolean { 280 if (!installSizeLoading.value) return false 281 // These facets depend on install-size API 282 return facet === 'installSize' || facet === 'totalDependencies' 283 } 284 285 // Check if a specific column (package) is loading 286 function isColumnLoading(index: number): boolean { 287 const name = packages.value[index] 288 return name ? loadingPackages.value.has(name) : false 289 } 290 291 return { 292 packagesData: readonly(packagesData), 293 status: readonly(status), 294 error: readonly(error), 295 getFacetValues, 296 isFacetLoading, 297 isColumnLoading, 298 } 299} 300 301/** 302 * Creates mock data for the "What Would James Do?" comparison column. 303 * This represents the baseline of having no dependency at all. 304 * 305 * Uses explicit display markers (NoDependencyDisplay) instead of undefined 306 * to clearly indicate intentional special values vs missing data. 307 */ 308function createNoDependencyData(): PackageComparisonData { 309 return { 310 package: { 311 name: NO_DEPENDENCY_ID, 312 version: '', 313 description: undefined, 314 }, 315 isNoDependency: true, 316 downloads: undefined, 317 totalLikes: undefined, 318 packageSize: 0, 319 directDeps: 0, 320 installSize: { 321 selfSize: 0, 322 totalSize: 0, 323 dependencyCount: 0, 324 }, 325 analysis: undefined, 326 vulnerabilities: undefined, 327 metadata: { 328 license: NoDependencyDisplay.DASH, 329 lastUpdated: NoDependencyDisplay.UP_TO_YOU, 330 engines: undefined, 331 deprecated: undefined, 332 }, 333 } 334} 335 336/** 337 * Converts a special display marker to its FacetValue representation. 338 */ 339function resolveNoDependencyDisplay( 340 marker: string, 341 t: (key: string) => string, 342): { display: string; status: FacetValue['status'] } | null { 343 switch (marker) { 344 case NoDependencyDisplay.DASH: 345 return { display: '–', status: 'neutral' } 346 case NoDependencyDisplay.UP_TO_YOU: 347 return { display: t('compare.facets.values.up_to_you'), status: 'good' } 348 default: 349 return null 350 } 351} 352 353function computeFacetValue( 354 facet: ComparisonFacet, 355 data: PackageComparisonData, 356 formatNumber: (num: number) => string, 357 formatCompactNumber: (num: number) => string, 358 formatBytes: (num: number) => string, 359 t: (key: string, params?: Record<string, unknown>) => string, 360): FacetValue | null { 361 const { isNoDependency } = data 362 363 switch (facet) { 364 case 'downloads': { 365 if (data.downloads === undefined) { 366 if (isNoDependency) return { raw: 0, display: '–', status: 'neutral' } 367 return null 368 } 369 return { 370 raw: data.downloads, 371 display: formatCompactNumber(data.downloads), 372 status: 'neutral', 373 } 374 } 375 case 'totalLikes': { 376 if (data.totalLikes === undefined) return null 377 return { 378 raw: data.totalLikes, 379 display: formatCompactNumber(data.totalLikes), 380 status: 'neutral', 381 } 382 } 383 case 'packageSize': { 384 // A size of zero is valid 385 if (data.packageSize == null) return null 386 return { 387 raw: data.packageSize, 388 display: formatBytes(data.packageSize), 389 status: data.packageSize > 5 * 1024 * 1024 ? 'warning' : 'neutral', 390 } 391 } 392 case 'installSize': { 393 // A size of zero is valid 394 if (data.installSize == null) return null 395 return { 396 raw: data.installSize.totalSize, 397 display: formatBytes(data.installSize.totalSize), 398 status: data.installSize.totalSize > 50 * 1024 * 1024 ? 'warning' : 'neutral', 399 } 400 } 401 case 'moduleFormat': { 402 if (!data.analysis) { 403 if (isNoDependency) 404 return { 405 raw: 'up-to-you', 406 display: t('compare.facets.values.up_to_you'), 407 status: 'good', 408 } 409 return null 410 } 411 const format = data.analysis.moduleFormat 412 return { 413 raw: format, 414 display: format === 'dual' ? 'ESM + CJS' : format.toUpperCase(), 415 status: format === 'esm' || format === 'dual' ? 'good' : 'neutral', 416 } 417 } 418 case 'types': { 419 if (data.isBinaryOnly) { 420 return { 421 raw: 'binary', 422 display: 'N/A', 423 status: 'muted', 424 tooltip: t('compare.facets.binary_only_tooltip'), 425 } 426 } 427 if (!data.analysis) { 428 if (isNoDependency) 429 return { 430 raw: 'up-to-you', 431 display: t('compare.facets.values.up_to_you'), 432 status: 'good', 433 } 434 return null 435 } 436 const types = data.analysis.types 437 return { 438 raw: types.kind, 439 display: 440 types.kind === 'included' 441 ? t('compare.facets.values.types_included') 442 : types.kind === '@types' 443 ? '@types' 444 : t('compare.facets.values.types_none'), 445 status: types.kind === 'included' ? 'good' : types.kind === '@types' ? 'info' : 'bad', 446 } 447 } 448 case 'engines': { 449 const engines = data.metadata?.engines 450 if (!engines?.node) { 451 if (isNoDependency) 452 return { 453 raw: 'up-to-you', 454 display: t('compare.facets.values.up_to_you'), 455 status: 'good', 456 } 457 return { 458 raw: null, 459 display: t('compare.facets.values.any'), 460 status: 'neutral', 461 } 462 } 463 return { 464 raw: engines.node, 465 display: `Node.js ${engines.node}`, 466 status: 'neutral', 467 } 468 } 469 case 'vulnerabilities': { 470 if (!data.vulnerabilities) { 471 if (isNoDependency) 472 return { 473 raw: 'up-to-you', 474 display: t('compare.facets.values.up_to_you'), 475 status: 'good', 476 } 477 return null 478 } 479 const count = data.vulnerabilities.count 480 const sev = data.vulnerabilities.severity 481 return { 482 raw: count, 483 display: 484 count === 0 485 ? t('compare.facets.values.none') 486 : t('compare.facets.values.vulnerabilities_summary', { 487 count, 488 critical: sev.critical, 489 high: sev.high, 490 }), 491 status: count === 0 ? 'good' : sev.critical > 0 || sev.high > 0 ? 'bad' : 'warning', 492 } 493 } 494 case 'lastUpdated': { 495 const lastUpdated = data.metadata?.lastUpdated 496 const resolved = lastUpdated ? resolveNoDependencyDisplay(lastUpdated, t) : null 497 if (resolved) return { raw: 0, ...resolved } 498 if (!lastUpdated) return null 499 const date = new Date(lastUpdated) 500 return { 501 raw: date.getTime(), 502 display: lastUpdated, 503 status: isStale(date) ? 'warning' : 'neutral', 504 type: 'date', 505 } 506 } 507 case 'license': { 508 const license = data.metadata?.license 509 const resolved = license ? resolveNoDependencyDisplay(license, t) : null 510 if (resolved) return { raw: null, ...resolved } 511 if (!license) { 512 if (isNoDependency) return { raw: null, display: '–', status: 'neutral' } 513 return { 514 raw: null, 515 display: t('compare.facets.values.unknown'), 516 status: 'warning', 517 } 518 } 519 return { 520 raw: license, 521 display: license, 522 status: 'neutral', 523 } 524 } 525 case 'dependencies': { 526 const depCount = data.directDeps 527 if (depCount == null) return null 528 return { 529 raw: depCount, 530 display: formatNumber(depCount), 531 status: depCount > 10 ? 'warning' : 'neutral', 532 } 533 } 534 case 'deprecated': { 535 const isDeprecated = !!data.metadata?.deprecated 536 return { 537 raw: isDeprecated, 538 display: isDeprecated 539 ? t('compare.facets.values.deprecated') 540 : t('compare.facets.values.not_deprecated'), 541 status: isDeprecated ? 'bad' : 'good', 542 } 543 } 544 case 'totalDependencies': { 545 if (!data.installSize) return null 546 const totalDepCount = data.installSize.dependencyCount 547 return { 548 raw: totalDepCount, 549 display: formatNumber(totalDepCount), 550 status: totalDepCount > 50 ? 'warning' : 'neutral', 551 } 552 } 553 default: { 554 return null 555 } 556 } 557} 558 559function isStale(date: Date): boolean { 560 const now = new Date() 561 const diffMs = now.getTime() - date.getTime() 562 const diffYears = diffMs / (1000 * 60 * 60 * 24 * 365) 563 return diffYears > 2 // Considered stale if not updated in 2+ years 564}