forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}