···11-import type {
22- Packument,
33- SlimPackument,
44- NpmSearchResponse,
55- NpmSearchResult,
66- NpmDownloadCount,
77- NpmPerson,
88- PackageVersionInfo,
99-} from '#shared/types'
1010-import { getVersions } from 'fast-npm-meta'
1111-import type { ResolvedPackageVersion } from 'fast-npm-meta'
1212-import type { ReleaseType } from 'semver'
1313-import { mapWithConcurrency } from '#shared/utils/async'
1414-import { maxSatisfying, prerelease, major, minor, diff, gt, compare } from 'semver'
1515-import { extractInstallScriptsInfo } from '~/utils/install-scripts'
1616-import type { CachedFetchFunction } from '#shared/utils/fetch-cache-config'
1717-1818-const NPM_REGISTRY = 'https://registry.npmjs.org'
1919-const NPM_API = 'https://api.npmjs.org'
2020-2121-// Cache for packument fetches to avoid duplicate requests across components
2222-const packumentCache = new Map<string, Promise<Packument | null>>()
2323-2424-/**
2525- * Fetch downloads for multiple packages.
2626- * Returns a map of package name -> weekly downloads.
2727- * Uses bulk API for unscoped packages, parallel individual requests for scoped.
2828- * Note: npm bulk downloads API does not support scoped packages.
2929- */
3030-async function fetchBulkDownloads(
3131- packageNames: string[],
3232- options: Parameters<typeof $fetch>[1] = {},
3333-): Promise<Map<string, number>> {
3434- const downloads = new Map<string, number>()
3535- if (packageNames.length === 0) return downloads
3636-3737- // Separate scoped and unscoped packages
3838- const scopedPackages = packageNames.filter(n => n.startsWith('@'))
3939- const unscopedPackages = packageNames.filter(n => !n.startsWith('@'))
4040-4141- // Fetch unscoped packages via bulk API (max 128 per request)
4242- const bulkPromises: Promise<void>[] = []
4343- const chunkSize = 100
4444- for (let i = 0; i < unscopedPackages.length; i += chunkSize) {
4545- const chunk = unscopedPackages.slice(i, i + chunkSize)
4646- bulkPromises.push(
4747- (async () => {
4848- try {
4949- const response = await $fetch<Record<string, { downloads: number } | null>>(
5050- `${NPM_API}/downloads/point/last-week/${chunk.join(',')}`,
5151- options,
5252- )
5353- for (const [name, data] of Object.entries(response)) {
5454- if (data?.downloads !== undefined) {
5555- downloads.set(name, data.downloads)
5656- }
5757- }
5858- } catch {
5959- // Ignore errors - downloads are optional
6060- }
6161- })(),
6262- )
6363- }
6464-6565- // Fetch scoped packages in parallel batches (concurrency limit to avoid overwhelming the API)
6666- // Use Promise.allSettled to not fail on individual errors
6767- const scopedBatchSize = 20 // Concurrent requests per batch
6868- for (let i = 0; i < scopedPackages.length; i += scopedBatchSize) {
6969- const batch = scopedPackages.slice(i, i + scopedBatchSize)
7070- bulkPromises.push(
7171- (async () => {
7272- const results = await Promise.allSettled(
7373- batch.map(async name => {
7474- const encoded = encodePackageName(name)
7575- const data = await $fetch<{ downloads: number }>(
7676- `${NPM_API}/downloads/point/last-week/${encoded}`,
7777- )
7878- return { name, downloads: data.downloads }
7979- }),
8080- )
8181- for (const result of results) {
8282- if (result.status === 'fulfilled' && result.value.downloads !== undefined) {
8383- downloads.set(result.value.name, result.value.downloads)
8484- }
8585- }
8686- })(),
8787- )
8888- }
8989-9090- // Wait for all fetches to complete
9191- await Promise.all(bulkPromises)
9292-9393- return downloads
9494-}
9595-9696-/** Number of recent versions to include in initial payload */
9797-const RECENT_VERSIONS_COUNT = 5
9898-9999-/**
100100- * Transform a full Packument into a slimmed version for client-side use.
101101- * Reduces payload size by:
102102- * - Removing readme (fetched separately)
103103- * - Including only: 5 most recent versions + one version per dist-tag + requested version
104104- * - Stripping unnecessary fields from version objects
105105- */
106106-function transformPackument(pkg: Packument, requestedVersion?: string | null): SlimPackument {
107107- // Get versions pointed to by dist-tags
108108- const distTagVersions = new Set(Object.values(pkg['dist-tags'] ?? {}))
109109-110110- // Get 5 most recent versions by publish time
111111- const recentVersions = Object.keys(pkg.versions)
112112- .filter(v => pkg.time[v])
113113- .sort((a, b) => {
114114- const timeA = pkg.time[a]
115115- const timeB = pkg.time[b]
116116- if (!timeA || !timeB) return 0
117117- return new Date(timeB).getTime() - new Date(timeA).getTime()
118118- })
119119- .slice(0, RECENT_VERSIONS_COUNT)
120120-121121- // Combine: recent versions + dist-tag versions + requested version (deduplicated)
122122- const includedVersions = new Set([...recentVersions, ...distTagVersions])
123123-124124- // Add the requested version if it exists in the package
125125- if (requestedVersion && pkg.versions[requestedVersion]) {
126126- includedVersions.add(requestedVersion)
127127- }
128128-129129- // Build filtered versions object with install scripts info per version
130130- const filteredVersions: Record<string, SlimVersion> = {}
131131- let versionData: SlimPackumentVersion | null = null
132132- for (const v of includedVersions) {
133133- const version = pkg.versions[v]
134134- if (version) {
135135- if (version.version === requestedVersion) {
136136- // Strip readme from each version, extract install scripts info
137137- const { readme: _readme, scripts, ...slimVersion } = version
138138-139139- // Extract install scripts info (which scripts exist + npx deps)
140140- const installScripts = scripts ? extractInstallScriptsInfo(scripts) : null
141141- versionData = {
142142- ...slimVersion,
143143- installScripts: installScripts ?? undefined,
144144- }
145145- }
146146- filteredVersions[v] = {
147147- ...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}),
148148- version: version.version,
149149- deprecated: version.deprecated,
150150- tags: version.tags as string[],
151151- }
152152- }
153153- }
154154-155155- // Build filtered time object (only for included versions + metadata)
156156- const filteredTime: Record<string, string> = {}
157157- if (pkg.time.modified) filteredTime.modified = pkg.time.modified
158158- if (pkg.time.created) filteredTime.created = pkg.time.created
159159- for (const v of includedVersions) {
160160- if (pkg.time[v]) filteredTime[v] = pkg.time[v]
161161- }
162162-163163- return {
164164- '_id': pkg._id,
165165- '_rev': pkg._rev,
166166- 'name': pkg.name,
167167- 'description': pkg.description,
168168- 'dist-tags': pkg['dist-tags'],
169169- 'time': filteredTime,
170170- 'maintainers': pkg.maintainers,
171171- 'author': pkg.author,
172172- 'license': pkg.license,
173173- 'homepage': pkg.homepage,
174174- 'keywords': pkg.keywords,
175175- 'repository': pkg.repository,
176176- 'bugs': pkg.bugs,
177177- 'requestedVersion': versionData,
178178- 'versions': filteredVersions,
179179- }
180180-}
181181-182182-export function useResolvedVersion(
183183- packageName: MaybeRefOrGetter<string>,
184184- requestedVersion: MaybeRefOrGetter<string | null>,
185185-) {
186186- return useFetch(
187187- () => {
188188- const version = toValue(requestedVersion)
189189- return version
190190- ? `https://npm.antfu.dev/${toValue(packageName)}@${version}`
191191- : `https://npm.antfu.dev/${toValue(packageName)}`
192192- },
193193- {
194194- transform: (data: ResolvedPackageVersion) => data.version,
195195- },
196196- )
197197-}
198198-199199-export function usePackage(
200200- name: MaybeRefOrGetter<string>,
201201- requestedVersion?: MaybeRefOrGetter<string | null>,
202202-) {
203203- const cachedFetch = useCachedFetch()
204204-205205- const asyncData = useLazyAsyncData(
206206- () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`,
207207- async (_nuxtApp, { signal }) => {
208208- const encodedName = encodePackageName(toValue(name))
209209- const { data: r, isStale } = await cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`, {
210210- signal,
211211- })
212212- const reqVer = toValue(requestedVersion)
213213- const pkg = transformPackument(r, reqVer)
214214- return { ...pkg, isStale }
215215- },
216216- )
217217-218218- if (import.meta.client && asyncData.data.value?.isStale) {
219219- onMounted(() => {
220220- asyncData.refresh()
221221- })
222222- }
223223-224224- return asyncData
225225-}
226226-227227-export function usePackageDownloads(
228228- name: MaybeRefOrGetter<string>,
229229- period: MaybeRefOrGetter<'last-day' | 'last-week' | 'last-month' | 'last-year'> = 'last-week',
230230-) {
231231- const cachedFetch = useCachedFetch()
232232-233233- const asyncData = useLazyAsyncData(
234234- () => `downloads:${toValue(name)}:${toValue(period)}`,
235235- async (_nuxtApp, { signal }) => {
236236- const encodedName = encodePackageName(toValue(name))
237237- const { data, isStale } = await cachedFetch<NpmDownloadCount>(
238238- `${NPM_API}/downloads/point/${toValue(period)}/${encodedName}`,
239239- { signal },
240240- )
241241- return { ...data, isStale }
242242- },
243243- )
244244-245245- if (import.meta.client && asyncData.data.value?.isStale) {
246246- onMounted(() => {
247247- asyncData.refresh()
248248- })
249249- }
250250-251251- return asyncData
252252-}
253253-254254-type NpmDownloadsRangeResponse = {
255255- start: string
256256- end: string
257257- package: string
258258- downloads: Array<{ day: string; downloads: number }>
259259-}
260260-261261-/**
262262- * Fetch download range data from npm API.
263263- * Exported for external use (e.g., in components).
264264- */
265265-export async function fetchNpmDownloadsRange(
266266- packageName: string,
267267- start: string,
268268- end: string,
269269-): Promise<NpmDownloadsRangeResponse> {
270270- const encodedName = encodePackageName(packageName)
271271- return await $fetch<NpmDownloadsRangeResponse>(
272272- `${NPM_API}/downloads/range/${start}:${end}/${encodedName}`,
273273- )
274274-}
275275-276276-const emptySearchResponse = {
277277- objects: [],
278278- total: 0,
279279- isStale: false,
280280- time: new Date().toISOString(),
281281-} satisfies NpmSearchResponse
282282-283283-export interface NpmSearchOptions {
284284- /** Number of results to fetch */
285285- size?: number
286286-}
287287-288288-export function useNpmSearch(
289289- query: MaybeRefOrGetter<string>,
290290- options: MaybeRefOrGetter<NpmSearchOptions> = {},
291291-) {
292292- const cachedFetch = useCachedFetch()
293293- // Client-side cache
294294- const cache = shallowRef<{
295295- query: string
296296- objects: NpmSearchResult[]
297297- total: number
298298- } | null>(null)
299299-300300- const isLoadingMore = shallowRef(false)
301301-302302- // Standard (non-incremental) search implementation
303303- let lastSearch: NpmSearchResponse | undefined = undefined
304304-305305- const asyncData = useLazyAsyncData(
306306- () => `search:incremental:${toValue(query)}`,
307307- async (_nuxtApp, { signal }) => {
308308- const q = toValue(query)
309309-310310- if (!q.trim()) {
311311- return emptySearchResponse
312312- }
313313-314314- const opts = toValue(options)
315315-316316- // This only runs for initial load or query changes
317317- // Reset cache for new query
318318- cache.value = null
319319-320320- const params = new URLSearchParams()
321321- params.set('text', q)
322322- // Use requested size for initial fetch
323323- params.set('size', String(opts.size ?? 25))
324324-325325- if (q.length === 1) {
326326- const encodedName = encodePackageName(q)
327327- const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([
328328- cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`, { signal }),
329329- cachedFetch<NpmDownloadCount>(`${NPM_API}/downloads/point/last-week/${encodedName}`, {
330330- signal,
331331- }),
332332- ])
333333-334334- if (!pkg) {
335335- return emptySearchResponse
336336- }
337337-338338- const result = packumentToSearchResult(pkg, downloads?.downloads)
339339-340340- // If query changed/outdated, return empty search response
341341- if (q !== toValue(query)) {
342342- return emptySearchResponse
343343- }
344344-345345- cache.value = {
346346- query: q,
347347- objects: [result],
348348- total: 1,
349349- }
350350-351351- return {
352352- objects: [result],
353353- total: 1,
354354- isStale,
355355- time: new Date().toISOString(),
356356- }
357357- }
358358-359359- const { data: response, isStale } = await cachedFetch<NpmSearchResponse>(
360360- `${NPM_REGISTRY}/-/v1/search?${params.toString()}`,
361361- { signal },
362362- 60,
363363- )
364364-365365- // If query changed/outdated, return empty search response
366366- if (q !== toValue(query)) {
367367- return emptySearchResponse
368368- }
369369-370370- cache.value = {
371371- query: q,
372372- objects: response.objects,
373373- total: response.total,
374374- }
375375-376376- return { ...response, isStale }
377377- },
378378- { default: () => lastSearch || emptySearchResponse },
379379- )
380380-381381- // Fetch more results incrementally (only used in incremental mode)
382382- async function fetchMore(targetSize: number): Promise<void> {
383383- const q = toValue(query).trim()
384384- if (!q) {
385385- cache.value = null
386386- return
387387- }
388388-389389- // If query changed, reset cache (shouldn't happen, but safety check)
390390- if (cache.value && cache.value.query !== q) {
391391- cache.value = null
392392- await asyncData.refresh()
393393- return
394394- }
395395-396396- const currentCount = cache.value?.objects.length ?? 0
397397- const total = cache.value?.total ?? Infinity
398398-399399- // Already have enough or no more to fetch
400400- if (currentCount >= targetSize || currentCount >= total) {
401401- return
402402- }
403403-404404- isLoadingMore.value = true
405405-406406- try {
407407- // Fetch from where we left off - calculate size needed
408408- const from = currentCount
409409- const size = Math.min(targetSize - currentCount, total - currentCount)
410410-411411- const params = new URLSearchParams()
412412- params.set('text', q)
413413- params.set('size', String(size))
414414- params.set('from', String(from))
415415-416416- const { data: response } = await cachedFetch<NpmSearchResponse>(
417417- `${NPM_REGISTRY}/-/v1/search?${params.toString()}`,
418418- {},
419419- 60,
420420- )
421421-422422- // Update cache
423423- if (cache.value && cache.value.query === q) {
424424- const existingNames = new Set(cache.value.objects.map(obj => obj.package.name))
425425- const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name))
426426- cache.value = {
427427- query: q,
428428- objects: [...cache.value.objects, ...newObjects],
429429- total: response.total,
430430- }
431431- } else {
432432- cache.value = {
433433- query: q,
434434- objects: response.objects,
435435- total: response.total,
436436- }
437437- }
438438-439439- // If we still need more, fetch again recursively
440440- if (
441441- cache.value.objects.length < targetSize &&
442442- cache.value.objects.length < cache.value.total
443443- ) {
444444- await fetchMore(targetSize)
445445- }
446446- } finally {
447447- isLoadingMore.value = false
448448- }
449449- }
450450-451451- // Watch for size increases in incremental mode
452452- watch(
453453- () => toValue(options).size,
454454- async (newSize, oldSize) => {
455455- if (!newSize) return
456456- if (oldSize && newSize > oldSize && toValue(query).trim()) {
457457- await fetchMore(newSize)
458458- }
459459- },
460460- )
461461-462462- // Computed data that uses cache in incremental mode
463463- const data = computed<NpmSearchResponse | null>(() => {
464464- if (cache.value) {
465465- return {
466466- isStale: false,
467467- objects: cache.value.objects,
468468- total: cache.value.total,
469469- time: new Date().toISOString(),
470470- }
471471- }
472472- return asyncData.data.value
473473- })
474474-475475- if (import.meta.client && asyncData.data.value?.isStale) {
476476- onMounted(() => {
477477- asyncData.refresh()
478478- })
479479- }
480480-481481- // Whether there are more results available on the server (incremental mode only)
482482- const hasMore = computed(() => {
483483- if (!cache.value) return true
484484- return cache.value.objects.length < cache.value.total
485485- })
486486-487487- return {
488488- ...asyncData,
489489- /** Reactive search results (uses cache in incremental mode) */
490490- data,
491491- /** Whether currently loading more results (incremental mode only) */
492492- isLoadingMore,
493493- /** Whether there are more results available (incremental mode only) */
494494- hasMore,
495495- /** Manually fetch more results up to target size (incremental mode only) */
496496- fetchMore,
497497- }
498498-}
499499-500500-/**
501501- * Minimal packument data needed for package cards
502502- */
503503-interface MinimalPackument {
504504- 'name': string
505505- 'description'?: string
506506- 'keywords'?: string[]
507507- // `dist-tags` can be missing in some later unpublished packages
508508- 'dist-tags'?: Record<string, string>
509509- 'time': Record<string, string>
510510- 'maintainers'?: NpmPerson[]
511511-}
512512-513513-/**
514514- * Convert packument to search result format for display
515515- */
516516-function packumentToSearchResult(pkg: MinimalPackument, weeklyDownloads?: number): NpmSearchResult {
517517- let latestVersion = ''
518518- if (pkg['dist-tags']) {
519519- latestVersion = pkg['dist-tags'].latest || Object.values(pkg['dist-tags'])[0] || ''
520520- }
521521- const modified = pkg.time.modified || pkg.time[latestVersion] || ''
522522-523523- return {
524524- package: {
525525- name: pkg.name,
526526- version: latestVersion,
527527- description: pkg.description,
528528- keywords: pkg.keywords,
529529- date: pkg.time[latestVersion] || modified,
530530- links: {
531531- npm: `https://www.npmjs.com/package/${pkg.name}`,
532532- },
533533- maintainers: pkg.maintainers,
534534- },
535535- score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } },
536536- searchScore: 0,
537537- downloads: weeklyDownloads !== undefined ? { weekly: weeklyDownloads } : undefined,
538538- updated: pkg.time[latestVersion] || modified,
539539- }
540540-}
541541-542542-/**
543543- * Fetch all packages for an npm organization
544544- * Returns search-result-like objects for compatibility with PackageList
545545- */
546546-export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
547547- const cachedFetch = useCachedFetch()
548548-549549- const asyncData = useLazyAsyncData(
550550- () => `org-packages:${toValue(orgName)}`,
551551- async (_nuxtApp, { signal }) => {
552552- const org = toValue(orgName)
553553- if (!org) {
554554- return emptySearchResponse
555555- }
556556-557557- // Get all package names in the org
558558- let packageNames: string[]
559559- try {
560560- const { data } = await cachedFetch<Record<string, string>>(
561561- `${NPM_REGISTRY}/-/org/${encodeURIComponent(org)}/package`,
562562- { signal },
563563- )
564564- packageNames = Object.keys(data)
565565- } catch (err) {
566566- // Check if this is a 404 (org not found)
567567- if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 404) {
568568- throw createError({
569569- statusCode: 404,
570570- statusMessage: 'Organization not found',
571571- message: `The organization "@${org}" does not exist on npm`,
572572- })
573573- }
574574- // For other errors (network, etc.), return empty array to be safe
575575- packageNames = []
576576- }
577577-578578- if (packageNames.length === 0) {
579579- return emptySearchResponse
580580- }
581581-582582- // Fetch packuments and downloads in parallel
583583- const [packuments, downloads] = await Promise.all([
584584- // Fetch packuments with concurrency limit
585585- (async () => {
586586- const results = await mapWithConcurrency(
587587- packageNames,
588588- async name => {
589589- try {
590590- const encoded = encodePackageName(name)
591591- const { data: pkg } = await cachedFetch<MinimalPackument>(
592592- `${NPM_REGISTRY}/${encoded}`,
593593- { signal },
594594- )
595595- return pkg
596596- } catch {
597597- return null
598598- }
599599- },
600600- 10,
601601- )
602602- // Filter out any unpublished packages (missing dist-tags)
603603- return results.filter(
604604- (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'],
605605- )
606606- })(),
607607- // Fetch downloads in bulk
608608- fetchBulkDownloads(packageNames, { signal }),
609609- ])
610610-611611- // Convert to search results with download data
612612- const results: NpmSearchResult[] = packuments.map(pkg =>
613613- packumentToSearchResult(pkg, downloads.get(pkg.name)),
614614- )
615615-616616- return {
617617- isStale: false,
618618- objects: results,
619619- total: results.length,
620620- time: new Date().toISOString(),
621621- } satisfies NpmSearchResponse
622622- },
623623- { default: () => emptySearchResponse },
624624- )
625625-626626- return asyncData
627627-}
628628-629629-// ============================================================================
630630-// Package Versions
631631-// ============================================================================
632632-633633-// Cache for full version lists (client-side only, for non-composable usage)
634634-const allVersionsCache = new Map<string, Promise<PackageVersionInfo[]>>()
635635-636636-/**
637637- * Fetch all versions of a package using fast-npm-meta API.
638638- * Returns version info sorted by version (newest first).
639639- * Results are cached to avoid duplicate requests.
640640- *
641641- * Note: This is a standalone async function for use in event handlers.
642642- * For composable usage, use useAllPackageVersions instead.
643643- *
644644- * @see https://github.com/antfu/fast-npm-meta
645645- */
646646-export async function fetchAllPackageVersions(packageName: string): Promise<PackageVersionInfo[]> {
647647- const cached = allVersionsCache.get(packageName)
648648- if (cached) return cached
649649-650650- const promise = (async () => {
651651- const data = await getVersions(packageName, { metadata: true })
652652-653653- return Object.entries(data.versionsMeta)
654654- .map(([version, meta]) => ({
655655- version,
656656- time: meta.time,
657657- hasProvenance: meta.provenance === 'trustedPublisher' || meta.provenance === true,
658658- deprecated: meta.deprecated,
659659- }))
660660- .sort((a, b) => compare(b.version, a.version))
661661- })()
662662-663663- allVersionsCache.set(packageName, promise)
664664- return promise
665665-}
666666-667667-// ============================================================================
668668-// Outdated Dependencies
669669-// ============================================================================
670670-671671-/** Information about an outdated dependency */
672672-export interface OutdatedDependencyInfo {
673673- /** The resolved version that satisfies the constraint */
674674- resolved: string
675675- /** The latest available version */
676676- latest: string
677677- /** How many major versions behind */
678678- majorsBehind: number
679679- /** How many minor versions behind (when same major) */
680680- minorsBehind: number
681681- /** The type of version difference */
682682- diffType: ReleaseType | null
683683-}
684684-685685-/**
686686- * Check if a version constraint explicitly includes a prerelease tag.
687687- * e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases
688688- */
689689-export function constraintIncludesPrerelease(constraint: string): boolean {
690690- return (
691691- /-(alpha|beta|rc|next|canary|dev|preview|pre|experimental)/i.test(constraint) ||
692692- /-\d/.test(constraint)
693693- )
694694-}
695695-696696-/**
697697- * Check if a constraint is a non-semver value (git URL, file path, etc.)
698698- */
699699-export function isNonSemverConstraint(constraint: string): boolean {
700700- return (
701701- constraint.startsWith('git') ||
702702- constraint.startsWith('http') ||
703703- constraint.startsWith('file:') ||
704704- constraint.startsWith('npm:') ||
705705- constraint.startsWith('link:') ||
706706- constraint.startsWith('workspace:') ||
707707- constraint.includes('/')
708708- )
709709-}
710710-711711-/**
712712- * Check if a dependency is outdated.
713713- * Returns null if up-to-date or if we can't determine.
714714- */
715715-async function checkDependencyOutdated(
716716- cachedFetch: CachedFetchFunction,
717717- packageName: string,
718718- constraint: string,
719719-): Promise<OutdatedDependencyInfo | null> {
720720- if (isNonSemverConstraint(constraint)) {
721721- return null
722722- }
723723-724724- // Check in-memory cache first
725725- let packument: Packument | null
726726- const cached = packumentCache.get(packageName)
727727- if (cached) {
728728- packument = await cached
729729- } else {
730730- const promise = cachedFetch<Packument>(`${NPM_REGISTRY}/${encodePackageName(packageName)}`)
731731- .then(({ data }) => data)
732732- .catch(() => null)
733733- packumentCache.set(packageName, promise)
734734- packument = await promise
735735- }
736736-737737- if (!packument) return null
738738-739739- const latestTag = packument['dist-tags']?.latest
740740- if (!latestTag) return null
741741-742742- // Handle "latest" constraint specially - return info with current version
743743- if (constraint === 'latest') {
744744- return {
745745- resolved: latestTag,
746746- latest: latestTag,
747747- majorsBehind: 0,
748748- minorsBehind: 0,
749749- diffType: null,
750750- }
751751- }
752752-753753- let versions = Object.keys(packument.versions)
754754- const includesPrerelease = constraintIncludesPrerelease(constraint)
755755-756756- if (!includesPrerelease) {
757757- versions = versions.filter(v => !prerelease(v))
758758- }
759759-760760- const resolved = maxSatisfying(versions, constraint)
761761- if (!resolved) return null
762762-763763- if (resolved === latestTag) return null
764764-765765- // If resolved version is newer than latest, not outdated
766766- // (e.g., using ^2.0.0-rc when latest is 1.x)
767767- if (gt(resolved, latestTag)) {
768768- return null
769769- }
770770-771771- const diffType = diff(resolved, latestTag)
772772- const majorsBehind = major(latestTag) - major(resolved)
773773- const minorsBehind = majorsBehind === 0 ? minor(latestTag) - minor(resolved) : 0
774774-775775- return {
776776- resolved,
777777- latest: latestTag,
778778- majorsBehind,
779779- minorsBehind,
780780- diffType,
781781- }
782782-}
783783-784784-/**
785785- * Composable to check for outdated dependencies.
786786- * Returns a reactive map of dependency name to outdated info.
787787- */
788788-export function useOutdatedDependencies(
789789- dependencies: MaybeRefOrGetter<Record<string, string> | undefined>,
790790-) {
791791- const cachedFetch = useCachedFetch()
792792- const outdated = shallowRef<Record<string, OutdatedDependencyInfo>>({})
793793-794794- async function fetchOutdatedInfo(deps: Record<string, string> | undefined) {
795795- if (!deps || Object.keys(deps).length === 0) {
796796- outdated.value = {}
797797- return
798798- }
799799-800800- const entries = Object.entries(deps)
801801- const batchResults = await mapWithConcurrency(
802802- entries,
803803- async ([name, constraint]) => {
804804- const info = await checkDependencyOutdated(cachedFetch, name, constraint)
805805- return [name, info] as const
806806- },
807807- 5,
808808- )
809809-810810- const results: Record<string, OutdatedDependencyInfo> = {}
811811- for (const [name, info] of batchResults) {
812812- if (info) {
813813- results[name] = info
814814- }
815815- }
816816-817817- outdated.value = results
818818- }
819819-820820- watch(
821821- () => toValue(dependencies),
822822- deps => {
823823- fetchOutdatedInfo(deps)
824824- },
825825- { immediate: true },
826826- )
827827-828828- return outdated
829829-}
830830-831831-/**
832832- * Get tooltip text for an outdated dependency
833833- */
834834-export function getOutdatedTooltip(
835835- info: OutdatedDependencyInfo,
836836- t: (key: string, params?: Record<string, unknown>, plural?: number) => string,
837837-): string {
838838- if (info.majorsBehind > 0) {
839839- return t(
840840- 'package.dependencies.outdated_major',
841841- { count: info.majorsBehind, latest: info.latest },
842842- info.majorsBehind,
843843- )
844844- }
845845- if (info.minorsBehind > 0) {
846846- return t(
847847- 'package.dependencies.outdated_minor',
848848- { count: info.minorsBehind, latest: info.latest },
849849- info.minorsBehind,
850850- )
851851- }
852852- return t('package.dependencies.outdated_patch', { latest: info.latest })
853853-}
854854-855855-/**
856856- * Get CSS class for a dependency version based on outdated status
857857- */
858858-export function getVersionClass(info: OutdatedDependencyInfo | undefined): string {
859859- if (!info) return 'text-fg-subtle'
860860- // Green for up-to-date (e.g. "latest" constraint)
861861- if (info.majorsBehind === 0 && info.minorsBehind === 0 && info.resolved === info.latest) {
862862- return 'text-green-500 cursor-help'
863863- }
864864- // Red for major versions behind
865865- if (info.majorsBehind > 0) return 'text-red-500 cursor-help'
866866- // Orange for minor versions behind
867867- if (info.minorsBehind > 0) return 'text-orange-500 cursor-help'
868868- // Yellow for patch versions behind
869869- return 'text-yellow-500 cursor-help'
870870-}
+3
app/utils/npm.ts
app/utils/npm/common.ts
···11+export const NPM_REGISTRY = 'https://registry.npmjs.org'
22+export const NPM_API = 'https://api.npmjs.org'
33+14/**
25 * Constructs a scope:team string in the format expected by npm.
36 * npm operations require the format @scope:team (with @ prefix).
+64
app/utils/npm/api.ts
···11+import type { PackageVersionInfo } from '#shared/types'
22+import { getVersions } from 'fast-npm-meta'
33+import { compare } from 'semver'
44+import { NPM_API } from './common'
55+66+type NpmDownloadsRangeResponse = {
77+ start: string
88+ end: string
99+ package: string
1010+ downloads: Array<{ day: string; downloads: number }>
1111+}
1212+1313+/**
1414+ * Fetch download range data from npm API.
1515+ * Exported for external use (e.g., in components).
1616+ */
1717+export async function fetchNpmDownloadsRange(
1818+ packageName: string,
1919+ start: string,
2020+ end: string,
2121+): Promise<NpmDownloadsRangeResponse> {
2222+ const encodedName = encodePackageName(packageName)
2323+ return await $fetch<NpmDownloadsRangeResponse>(
2424+ `${NPM_API}/downloads/range/${start}:${end}/${encodedName}`,
2525+ )
2626+}
2727+2828+// ============================================================================
2929+// Package Versions
3030+// ============================================================================
3131+3232+// Cache for full version lists (client-side only, for non-composable usage)
3333+const allVersionsCache = new Map<string, Promise<PackageVersionInfo[]>>()
3434+3535+/**
3636+ * Fetch all versions of a package using fast-npm-meta API.
3737+ * Returns version info sorted by version (newest first).
3838+ * Results are cached to avoid duplicate requests.
3939+ *
4040+ * Note: This is a standalone async function for use in event handlers.
4141+ * For composable usage, use useAllPackageVersions instead.
4242+ *
4343+ * @see https://github.com/antfu/fast-npm-meta
4444+ */
4545+export async function fetchAllPackageVersions(packageName: string): Promise<PackageVersionInfo[]> {
4646+ const cached = allVersionsCache.get(packageName)
4747+ if (cached) return cached
4848+4949+ const promise = (async () => {
5050+ const data = await getVersions(packageName, { metadata: true })
5151+5252+ return Object.entries(data.versionsMeta)
5353+ .map(([version, meta]) => ({
5454+ version,
5555+ time: meta.time,
5656+ hasProvenance: meta.provenance === 'trustedPublisher' || meta.provenance === true,
5757+ deprecated: meta.deprecated,
5858+ }))
5959+ .sort((a, b) => compare(b.version, a.version))
6060+ })()
6161+6262+ allVersionsCache.set(packageName, promise)
6363+ return promise
6464+}
+82
app/utils/npm/outdated-dependencies.ts
···11+import type { ReleaseType } from 'semver'
22+33+/** Information about an outdated dependency */
44+export interface OutdatedDependencyInfo {
55+ /** The resolved version that satisfies the constraint */
66+ resolved: string
77+ /** The latest available version */
88+ latest: string
99+ /** How many major versions behind */
1010+ majorsBehind: number
1111+ /** How many minor versions behind (when same major) */
1212+ minorsBehind: number
1313+ /** The type of version difference */
1414+ diffType: ReleaseType | null
1515+}
1616+1717+/**
1818+ * Check if a version constraint explicitly includes a prerelease tag.
1919+ * e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases
2020+ */
2121+export function constraintIncludesPrerelease(constraint: string): boolean {
2222+ return (
2323+ /-(alpha|beta|rc|next|canary|dev|preview|pre|experimental)/i.test(constraint) ||
2424+ /-\d/.test(constraint)
2525+ )
2626+}
2727+2828+/**
2929+ * Check if a constraint is a non-semver value (git URL, file path, etc.)
3030+ */
3131+export function isNonSemverConstraint(constraint: string): boolean {
3232+ return (
3333+ constraint.startsWith('git') ||
3434+ constraint.startsWith('http') ||
3535+ constraint.startsWith('file:') ||
3636+ constraint.startsWith('npm:') ||
3737+ constraint.startsWith('link:') ||
3838+ constraint.startsWith('workspace:') ||
3939+ constraint.includes('/')
4040+ )
4141+}
4242+4343+/**
4444+ * Get tooltip text for an outdated dependency
4545+ */
4646+export function getOutdatedTooltip(
4747+ info: OutdatedDependencyInfo,
4848+ t: (key: string, params?: Record<string, unknown>, plural?: number) => string,
4949+): string {
5050+ if (info.majorsBehind > 0) {
5151+ return t(
5252+ 'package.dependencies.outdated_major',
5353+ { count: info.majorsBehind, latest: info.latest },
5454+ info.majorsBehind,
5555+ )
5656+ }
5757+ if (info.minorsBehind > 0) {
5858+ return t(
5959+ 'package.dependencies.outdated_minor',
6060+ { count: info.minorsBehind, latest: info.latest },
6161+ info.minorsBehind,
6262+ )
6363+ }
6464+ return t('package.dependencies.outdated_patch', { latest: info.latest })
6565+}
6666+6767+/**
6868+ * Get CSS class for a dependency version based on outdated status
6969+ */
7070+export function getVersionClass(info: OutdatedDependencyInfo | undefined): string {
7171+ if (!info) return 'text-fg-subtle'
7272+ // Green for up-to-date (e.g. "latest" constraint)
7373+ if (info.majorsBehind === 0 && info.minorsBehind === 0 && info.resolved === info.latest) {
7474+ return 'text-green-500 cursor-help'
7575+ }
7676+ // Red for major versions behind
7777+ if (info.majorsBehind > 0) return 'text-red-500 cursor-help'
7878+ // Orange for minor versions behind
7979+ if (info.minorsBehind > 0) return 'text-orange-500 cursor-help'
8080+ // Yellow for patch versions behind
8181+ return 'text-yellow-500 cursor-help'
8282+}