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

Configure Feed

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

refactor: separate npm composables (#827)

authored by

James Garbutt and committed by
GitHub
a97a7a16 8fae46cb

+901 -879
+1 -1
app/components/Org/MembersPanel.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { NewOperation } from '~/composables/useConnector' 3 - import { buildScopeTeam } from '~/utils/npm' 3 + import { buildScopeTeam } from '~/utils/npm/common' 4 4 5 5 const props = defineProps<{ 6 6 orgName: string
+1 -1
app/components/Org/TeamsPanel.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { NewOperation } from '~/composables/useConnector' 3 - import { buildScopeTeam } from '~/utils/npm' 3 + import { buildScopeTeam } from '~/utils/npm/common' 4 4 5 5 const props = defineProps<{ 6 6 orgName: string
+1 -1
app/components/Package/AccessControls.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { NewOperation } from '~/composables/useConnector' 3 - import { buildScopeTeam } from '~/utils/npm' 3 + import { buildScopeTeam } from '~/utils/npm/common' 4 4 5 5 const props = defineProps<{ 6 6 packageName: string
+1 -1
app/components/Package/Dependencies.vue
··· 1 1 <script setup lang="ts"> 2 - import { useDependencyAnalysis } from '~/composables/useDependencyAnalysis' 3 2 import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity' 3 + import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies' 4 4 5 5 const props = defineProps<{ 6 6 packageName: string
+2
app/components/Package/InstallScripts.vue
··· 1 1 <script setup lang="ts"> 2 + import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies' 3 + 2 4 const props = defineProps<{ 3 5 packageName: string 4 6 installScripts: {
+1 -1
app/components/Package/Versions.vue
··· 2 2 import type { PackageVersionInfo, SlimVersion } from '#shared/types' 3 3 import { compare } from 'semver' 4 4 import type { RouteLocationRaw } from 'vue-router' 5 - import { fetchAllPackageVersions } from '~/composables/useNpmRegistry' 5 + import { fetchAllPackageVersions } from '~/utils/npm/api' 6 6 import { 7 7 buildVersionToTagsMap, 8 8 filterExcludedTags,
+1 -1
app/components/VersionSelector.vue
··· 9 9 getVersionGroupLabel, 10 10 isSameVersionGroup, 11 11 } from '~/utils/versions' 12 - import { fetchAllPackageVersions } from '~/composables/useNpmRegistry' 12 + import { fetchAllPackageVersions } from '~/utils/npm/api' 13 13 14 14 const props = defineProps<{ 15 15 packageName: string
+264
app/composables/npm/useNpmSearch.ts
··· 1 + import type { 2 + Packument, 3 + NpmSearchResponse, 4 + NpmSearchResult, 5 + NpmDownloadCount, 6 + MinimalPackument, 7 + } from '#shared/types' 8 + import { NPM_REGISTRY, NPM_API } from '~/utils/npm/common' 9 + 10 + /** 11 + * Convert packument to search result format for display 12 + */ 13 + export function packumentToSearchResult( 14 + pkg: MinimalPackument, 15 + weeklyDownloads?: number, 16 + ): NpmSearchResult { 17 + let latestVersion = '' 18 + if (pkg['dist-tags']) { 19 + latestVersion = pkg['dist-tags'].latest || Object.values(pkg['dist-tags'])[0] || '' 20 + } 21 + const modified = pkg.time.modified || pkg.time[latestVersion] || '' 22 + 23 + return { 24 + package: { 25 + name: pkg.name, 26 + version: latestVersion, 27 + description: pkg.description, 28 + keywords: pkg.keywords, 29 + date: pkg.time[latestVersion] || modified, 30 + links: { 31 + npm: `https://www.npmjs.com/package/${pkg.name}`, 32 + }, 33 + maintainers: pkg.maintainers, 34 + }, 35 + score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } }, 36 + searchScore: 0, 37 + downloads: weeklyDownloads !== undefined ? { weekly: weeklyDownloads } : undefined, 38 + updated: pkg.time[latestVersion] || modified, 39 + } 40 + } 41 + 42 + export interface NpmSearchOptions { 43 + /** Number of results to fetch */ 44 + size?: number 45 + } 46 + 47 + export const emptySearchResponse = { 48 + objects: [], 49 + total: 0, 50 + isStale: false, 51 + time: new Date().toISOString(), 52 + } satisfies NpmSearchResponse 53 + 54 + export function useNpmSearch( 55 + query: MaybeRefOrGetter<string>, 56 + options: MaybeRefOrGetter<NpmSearchOptions> = {}, 57 + ) { 58 + const cachedFetch = useCachedFetch() 59 + // Client-side cache 60 + const cache = shallowRef<{ 61 + query: string 62 + objects: NpmSearchResult[] 63 + total: number 64 + } | null>(null) 65 + 66 + const isLoadingMore = shallowRef(false) 67 + 68 + // Standard (non-incremental) search implementation 69 + let lastSearch: NpmSearchResponse | undefined = undefined 70 + 71 + const asyncData = useLazyAsyncData( 72 + () => `search:incremental:${toValue(query)}`, 73 + async (_nuxtApp, { signal }) => { 74 + const q = toValue(query) 75 + 76 + if (!q.trim()) { 77 + return emptySearchResponse 78 + } 79 + 80 + const opts = toValue(options) 81 + 82 + // This only runs for initial load or query changes 83 + // Reset cache for new query 84 + cache.value = null 85 + 86 + const params = new URLSearchParams() 87 + params.set('text', q) 88 + // Use requested size for initial fetch 89 + params.set('size', String(opts.size ?? 25)) 90 + 91 + if (q.length === 1) { 92 + const encodedName = encodePackageName(q) 93 + const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([ 94 + cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`, { signal }), 95 + cachedFetch<NpmDownloadCount>(`${NPM_API}/downloads/point/last-week/${encodedName}`, { 96 + signal, 97 + }), 98 + ]) 99 + 100 + if (!pkg) { 101 + return emptySearchResponse 102 + } 103 + 104 + const result = packumentToSearchResult(pkg, downloads?.downloads) 105 + 106 + // If query changed/outdated, return empty search response 107 + if (q !== toValue(query)) { 108 + return emptySearchResponse 109 + } 110 + 111 + cache.value = { 112 + query: q, 113 + objects: [result], 114 + total: 1, 115 + } 116 + 117 + return { 118 + objects: [result], 119 + total: 1, 120 + isStale, 121 + time: new Date().toISOString(), 122 + } 123 + } 124 + 125 + const { data: response, isStale } = await cachedFetch<NpmSearchResponse>( 126 + `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, 127 + { signal }, 128 + 60, 129 + ) 130 + 131 + // If query changed/outdated, return empty search response 132 + if (q !== toValue(query)) { 133 + return emptySearchResponse 134 + } 135 + 136 + cache.value = { 137 + query: q, 138 + objects: response.objects, 139 + total: response.total, 140 + } 141 + 142 + return { ...response, isStale } 143 + }, 144 + { default: () => lastSearch || emptySearchResponse }, 145 + ) 146 + 147 + // Fetch more results incrementally (only used in incremental mode) 148 + async function fetchMore(targetSize: number): Promise<void> { 149 + const q = toValue(query).trim() 150 + if (!q) { 151 + cache.value = null 152 + return 153 + } 154 + 155 + // If query changed, reset cache (shouldn't happen, but safety check) 156 + if (cache.value && cache.value.query !== q) { 157 + cache.value = null 158 + await asyncData.refresh() 159 + return 160 + } 161 + 162 + const currentCount = cache.value?.objects.length ?? 0 163 + const total = cache.value?.total ?? Infinity 164 + 165 + // Already have enough or no more to fetch 166 + if (currentCount >= targetSize || currentCount >= total) { 167 + return 168 + } 169 + 170 + isLoadingMore.value = true 171 + 172 + try { 173 + // Fetch from where we left off - calculate size needed 174 + const from = currentCount 175 + const size = Math.min(targetSize - currentCount, total - currentCount) 176 + 177 + const params = new URLSearchParams() 178 + params.set('text', q) 179 + params.set('size', String(size)) 180 + params.set('from', String(from)) 181 + 182 + const { data: response } = await cachedFetch<NpmSearchResponse>( 183 + `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, 184 + {}, 185 + 60, 186 + ) 187 + 188 + // Update cache 189 + if (cache.value && cache.value.query === q) { 190 + const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) 191 + const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) 192 + cache.value = { 193 + query: q, 194 + objects: [...cache.value.objects, ...newObjects], 195 + total: response.total, 196 + } 197 + } else { 198 + cache.value = { 199 + query: q, 200 + objects: response.objects, 201 + total: response.total, 202 + } 203 + } 204 + 205 + // If we still need more, fetch again recursively 206 + if ( 207 + cache.value.objects.length < targetSize && 208 + cache.value.objects.length < cache.value.total 209 + ) { 210 + await fetchMore(targetSize) 211 + } 212 + } finally { 213 + isLoadingMore.value = false 214 + } 215 + } 216 + 217 + // Watch for size increases in incremental mode 218 + watch( 219 + () => toValue(options).size, 220 + async (newSize, oldSize) => { 221 + if (!newSize) return 222 + if (oldSize && newSize > oldSize && toValue(query).trim()) { 223 + await fetchMore(newSize) 224 + } 225 + }, 226 + ) 227 + 228 + // Computed data that uses cache in incremental mode 229 + const data = computed<NpmSearchResponse | null>(() => { 230 + if (cache.value) { 231 + return { 232 + isStale: false, 233 + objects: cache.value.objects, 234 + total: cache.value.total, 235 + time: new Date().toISOString(), 236 + } 237 + } 238 + return asyncData.data.value 239 + }) 240 + 241 + if (import.meta.client && asyncData.data.value?.isStale) { 242 + onMounted(() => { 243 + asyncData.refresh() 244 + }) 245 + } 246 + 247 + // Whether there are more results available on the server (incremental mode only) 248 + const hasMore = computed(() => { 249 + if (!cache.value) return true 250 + return cache.value.objects.length < cache.value.total 251 + }) 252 + 253 + return { 254 + ...asyncData, 255 + /** Reactive search results (uses cache in incremental mode) */ 256 + data, 257 + /** Whether currently loading more results (incremental mode only) */ 258 + isLoadingMore, 259 + /** Whether there are more results available (incremental mode only) */ 260 + hasMore, 261 + /** Manually fetch more results up to target size (incremental mode only) */ 262 + fetchMore, 263 + } 264 + }
+163
app/composables/npm/useOrgPackages.ts
··· 1 + import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types' 2 + import { emptySearchResponse, packumentToSearchResult } from './useNpmSearch' 3 + import { NPM_REGISTRY, NPM_API } from '~/utils/npm/common' 4 + import { mapWithConcurrency } from '#shared/utils/async' 5 + 6 + /** 7 + * Fetch downloads for multiple packages. 8 + * Returns a map of package name -> weekly downloads. 9 + * Uses bulk API for unscoped packages, parallel individual requests for scoped. 10 + * Note: npm bulk downloads API does not support scoped packages. 11 + */ 12 + async function fetchBulkDownloads( 13 + packageNames: string[], 14 + options: Parameters<typeof $fetch>[1] = {}, 15 + ): Promise<Map<string, number>> { 16 + const downloads = new Map<string, number>() 17 + if (packageNames.length === 0) return downloads 18 + 19 + // Separate scoped and unscoped packages 20 + const scopedPackages = packageNames.filter(n => n.startsWith('@')) 21 + const unscopedPackages = packageNames.filter(n => !n.startsWith('@')) 22 + 23 + // Fetch unscoped packages via bulk API (max 128 per request) 24 + const bulkPromises: Promise<void>[] = [] 25 + const chunkSize = 100 26 + for (let i = 0; i < unscopedPackages.length; i += chunkSize) { 27 + const chunk = unscopedPackages.slice(i, i + chunkSize) 28 + bulkPromises.push( 29 + (async () => { 30 + try { 31 + const response = await $fetch<Record<string, { downloads: number } | null>>( 32 + `${NPM_API}/downloads/point/last-week/${chunk.join(',')}`, 33 + options, 34 + ) 35 + for (const [name, data] of Object.entries(response)) { 36 + if (data?.downloads !== undefined) { 37 + downloads.set(name, data.downloads) 38 + } 39 + } 40 + } catch { 41 + // Ignore errors - downloads are optional 42 + } 43 + })(), 44 + ) 45 + } 46 + 47 + // Fetch scoped packages in parallel batches (concurrency limit to avoid overwhelming the API) 48 + // Use Promise.allSettled to not fail on individual errors 49 + const scopedBatchSize = 20 // Concurrent requests per batch 50 + for (let i = 0; i < scopedPackages.length; i += scopedBatchSize) { 51 + const batch = scopedPackages.slice(i, i + scopedBatchSize) 52 + bulkPromises.push( 53 + (async () => { 54 + const results = await Promise.allSettled( 55 + batch.map(async name => { 56 + const encoded = encodePackageName(name) 57 + const data = await $fetch<{ downloads: number }>( 58 + `${NPM_API}/downloads/point/last-week/${encoded}`, 59 + ) 60 + return { name, downloads: data.downloads } 61 + }), 62 + ) 63 + for (const result of results) { 64 + if (result.status === 'fulfilled' && result.value.downloads !== undefined) { 65 + downloads.set(result.value.name, result.value.downloads) 66 + } 67 + } 68 + })(), 69 + ) 70 + } 71 + 72 + // Wait for all fetches to complete 73 + await Promise.all(bulkPromises) 74 + 75 + return downloads 76 + } 77 + 78 + /** 79 + * Fetch all packages for an npm organization 80 + * Returns search-result-like objects for compatibility with PackageList 81 + */ 82 + export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { 83 + const cachedFetch = useCachedFetch() 84 + 85 + const asyncData = useLazyAsyncData( 86 + () => `org-packages:${toValue(orgName)}`, 87 + async (_nuxtApp, { signal }) => { 88 + const org = toValue(orgName) 89 + if (!org) { 90 + return emptySearchResponse 91 + } 92 + 93 + // Get all package names in the org 94 + let packageNames: string[] 95 + try { 96 + const { data } = await cachedFetch<Record<string, string>>( 97 + `${NPM_REGISTRY}/-/org/${encodeURIComponent(org)}/package`, 98 + { signal }, 99 + ) 100 + packageNames = Object.keys(data) 101 + } catch (err) { 102 + // Check if this is a 404 (org not found) 103 + if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 404) { 104 + throw createError({ 105 + statusCode: 404, 106 + statusMessage: 'Organization not found', 107 + message: `The organization "@${org}" does not exist on npm`, 108 + }) 109 + } 110 + // For other errors (network, etc.), return empty array to be safe 111 + packageNames = [] 112 + } 113 + 114 + if (packageNames.length === 0) { 115 + return emptySearchResponse 116 + } 117 + 118 + // Fetch packuments and downloads in parallel 119 + const [packuments, downloads] = await Promise.all([ 120 + // Fetch packuments with concurrency limit 121 + (async () => { 122 + const results = await mapWithConcurrency( 123 + packageNames, 124 + async name => { 125 + try { 126 + const encoded = encodePackageName(name) 127 + const { data: pkg } = await cachedFetch<MinimalPackument>( 128 + `${NPM_REGISTRY}/${encoded}`, 129 + { signal }, 130 + ) 131 + return pkg 132 + } catch { 133 + return null 134 + } 135 + }, 136 + 10, 137 + ) 138 + // Filter out any unpublished packages (missing dist-tags) 139 + return results.filter( 140 + (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'], 141 + ) 142 + })(), 143 + // Fetch downloads in bulk 144 + fetchBulkDownloads(packageNames, { signal }), 145 + ]) 146 + 147 + // Convert to search results with download data 148 + const results: NpmSearchResult[] = packuments.map(pkg => 149 + packumentToSearchResult(pkg, downloads.get(pkg.name)), 150 + ) 151 + 152 + return { 153 + isStale: false, 154 + objects: results, 155 + total: results.length, 156 + time: new Date().toISOString(), 157 + } satisfies NpmSearchResponse 158 + }, 159 + { default: () => emptySearchResponse }, 160 + ) 161 + 162 + return asyncData 163 + }
+133
app/composables/npm/useOutdatedDependencies.ts
··· 1 + import { maxSatisfying, prerelease, major, minor, diff, gt } from 'semver' 2 + import type { Packument } from '#shared/types' 3 + import { mapWithConcurrency } from '#shared/utils/async' 4 + import type { CachedFetchFunction } from '#shared/utils/fetch-cache-config' 5 + import { 6 + type OutdatedDependencyInfo, 7 + isNonSemverConstraint, 8 + constraintIncludesPrerelease, 9 + } from '~/utils/npm/outdated-dependencies' 10 + import { NPM_REGISTRY } from '~/utils/npm/common' 11 + 12 + // Cache for packument fetches to avoid duplicate requests across components 13 + const packumentCache = new Map<string, Promise<Packument | null>>() 14 + 15 + /** 16 + * Check if a dependency is outdated. 17 + * Returns null if up-to-date or if we can't determine. 18 + */ 19 + async function checkDependencyOutdated( 20 + cachedFetch: CachedFetchFunction, 21 + packageName: string, 22 + constraint: string, 23 + ): Promise<OutdatedDependencyInfo | null> { 24 + if (isNonSemverConstraint(constraint)) { 25 + return null 26 + } 27 + 28 + // Check in-memory cache first 29 + let packument: Packument | null 30 + const cached = packumentCache.get(packageName) 31 + if (cached) { 32 + packument = await cached 33 + } else { 34 + const promise = cachedFetch<Packument>(`${NPM_REGISTRY}/${encodePackageName(packageName)}`) 35 + .then(({ data }) => data) 36 + .catch(() => null) 37 + packumentCache.set(packageName, promise) 38 + packument = await promise 39 + } 40 + 41 + if (!packument) return null 42 + 43 + const latestTag = packument['dist-tags']?.latest 44 + if (!latestTag) return null 45 + 46 + // Handle "latest" constraint specially - return info with current version 47 + if (constraint === 'latest') { 48 + return { 49 + resolved: latestTag, 50 + latest: latestTag, 51 + majorsBehind: 0, 52 + minorsBehind: 0, 53 + diffType: null, 54 + } 55 + } 56 + 57 + let versions = Object.keys(packument.versions) 58 + const includesPrerelease = constraintIncludesPrerelease(constraint) 59 + 60 + if (!includesPrerelease) { 61 + versions = versions.filter(v => !prerelease(v)) 62 + } 63 + 64 + const resolved = maxSatisfying(versions, constraint) 65 + if (!resolved) return null 66 + 67 + if (resolved === latestTag) return null 68 + 69 + // If resolved version is newer than latest, not outdated 70 + // (e.g., using ^2.0.0-rc when latest is 1.x) 71 + if (gt(resolved, latestTag)) { 72 + return null 73 + } 74 + 75 + const diffType = diff(resolved, latestTag) 76 + const majorsBehind = major(latestTag) - major(resolved) 77 + const minorsBehind = majorsBehind === 0 ? minor(latestTag) - minor(resolved) : 0 78 + 79 + return { 80 + resolved, 81 + latest: latestTag, 82 + majorsBehind, 83 + minorsBehind, 84 + diffType, 85 + } 86 + } 87 + 88 + /** 89 + * Composable to check for outdated dependencies. 90 + * Returns a reactive map of dependency name to outdated info. 91 + */ 92 + export function useOutdatedDependencies( 93 + dependencies: MaybeRefOrGetter<Record<string, string> | undefined>, 94 + ) { 95 + const cachedFetch = useCachedFetch() 96 + const outdated = shallowRef<Record<string, OutdatedDependencyInfo>>({}) 97 + 98 + async function fetchOutdatedInfo(deps: Record<string, string> | undefined) { 99 + if (!deps || Object.keys(deps).length === 0) { 100 + outdated.value = {} 101 + return 102 + } 103 + 104 + const entries = Object.entries(deps) 105 + const batchResults = await mapWithConcurrency( 106 + entries, 107 + async ([name, constraint]) => { 108 + const info = await checkDependencyOutdated(cachedFetch, name, constraint) 109 + return [name, info] as const 110 + }, 111 + 5, 112 + ) 113 + 114 + const results: Record<string, OutdatedDependencyInfo> = {} 115 + for (const [name, info] of batchResults) { 116 + if (info) { 117 + results[name] = info 118 + } 119 + } 120 + 121 + outdated.value = results 122 + } 123 + 124 + watch( 125 + () => toValue(dependencies), 126 + deps => { 127 + fetchOutdatedInfo(deps) 128 + }, 129 + { immediate: true }, 130 + ) 131 + 132 + return outdated 133 + }
+117
app/composables/npm/usePackage.ts
··· 1 + import type { Packument, SlimPackument, SlimVersion, SlimPackumentVersion } from '#shared/types' 2 + import { NPM_REGISTRY } from '~/utils/npm/common' 3 + import { extractInstallScriptsInfo } from '~/utils/install-scripts' 4 + 5 + /** Number of recent versions to include in initial payload */ 6 + const RECENT_VERSIONS_COUNT = 5 7 + 8 + /** 9 + * Transform a full Packument into a slimmed version for client-side use. 10 + * Reduces payload size by: 11 + * - Removing readme (fetched separately) 12 + * - Including only: 5 most recent versions + one version per dist-tag + requested version 13 + * - Stripping unnecessary fields from version objects 14 + */ 15 + function transformPackument(pkg: Packument, requestedVersion?: string | null): SlimPackument { 16 + // Get versions pointed to by dist-tags 17 + const distTagVersions = new Set(Object.values(pkg['dist-tags'] ?? {})) 18 + 19 + // Get 5 most recent versions by publish time 20 + const recentVersions = Object.keys(pkg.versions) 21 + .filter(v => pkg.time[v]) 22 + .sort((a, b) => { 23 + const timeA = pkg.time[a] 24 + const timeB = pkg.time[b] 25 + if (!timeA || !timeB) return 0 26 + return new Date(timeB).getTime() - new Date(timeA).getTime() 27 + }) 28 + .slice(0, RECENT_VERSIONS_COUNT) 29 + 30 + // Combine: recent versions + dist-tag versions + requested version (deduplicated) 31 + const includedVersions = new Set([...recentVersions, ...distTagVersions]) 32 + 33 + // Add the requested version if it exists in the package 34 + if (requestedVersion && pkg.versions[requestedVersion]) { 35 + includedVersions.add(requestedVersion) 36 + } 37 + 38 + // Build filtered versions object with install scripts info per version 39 + const filteredVersions: Record<string, SlimVersion> = {} 40 + let versionData: SlimPackumentVersion | null = null 41 + for (const v of includedVersions) { 42 + const version = pkg.versions[v] 43 + if (version) { 44 + if (version.version === requestedVersion) { 45 + // Strip readme from each version, extract install scripts info 46 + const { readme: _readme, scripts, ...slimVersion } = version 47 + 48 + // Extract install scripts info (which scripts exist + npx deps) 49 + const installScripts = scripts ? extractInstallScriptsInfo(scripts) : null 50 + versionData = { 51 + ...slimVersion, 52 + installScripts: installScripts ?? undefined, 53 + } 54 + } 55 + filteredVersions[v] = { 56 + ...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}), 57 + version: version.version, 58 + deprecated: version.deprecated, 59 + tags: version.tags as string[], 60 + } 61 + } 62 + } 63 + 64 + // Build filtered time object (only for included versions + metadata) 65 + const filteredTime: Record<string, string> = {} 66 + if (pkg.time.modified) filteredTime.modified = pkg.time.modified 67 + if (pkg.time.created) filteredTime.created = pkg.time.created 68 + for (const v of includedVersions) { 69 + if (pkg.time[v]) filteredTime[v] = pkg.time[v] 70 + } 71 + 72 + return { 73 + '_id': pkg._id, 74 + '_rev': pkg._rev, 75 + 'name': pkg.name, 76 + 'description': pkg.description, 77 + 'dist-tags': pkg['dist-tags'], 78 + 'time': filteredTime, 79 + 'maintainers': pkg.maintainers, 80 + 'author': pkg.author, 81 + 'license': pkg.license, 82 + 'homepage': pkg.homepage, 83 + 'keywords': pkg.keywords, 84 + 'repository': pkg.repository, 85 + 'bugs': pkg.bugs, 86 + 'requestedVersion': versionData, 87 + 'versions': filteredVersions, 88 + } 89 + } 90 + 91 + export function usePackage( 92 + name: MaybeRefOrGetter<string>, 93 + requestedVersion?: MaybeRefOrGetter<string | null>, 94 + ) { 95 + const cachedFetch = useCachedFetch() 96 + 97 + const asyncData = useLazyAsyncData( 98 + () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`, 99 + async (_nuxtApp, { signal }) => { 100 + const encodedName = encodePackageName(toValue(name)) 101 + const { data: r, isStale } = await cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`, { 102 + signal, 103 + }) 104 + const reqVer = toValue(requestedVersion) 105 + const pkg = transformPackument(r, reqVer) 106 + return { ...pkg, isStale } 107 + }, 108 + ) 109 + 110 + if (import.meta.client && asyncData.data.value?.isStale) { 111 + onMounted(() => { 112 + asyncData.refresh() 113 + }) 114 + } 115 + 116 + return asyncData 117 + }
+29
app/composables/npm/usePackageDownloads.ts
··· 1 + import type { NpmDownloadCount } from '#shared/types' 2 + import { NPM_API } from '~/utils/npm/common' 3 + 4 + export function usePackageDownloads( 5 + name: MaybeRefOrGetter<string>, 6 + period: MaybeRefOrGetter<'last-day' | 'last-week' | 'last-month' | 'last-year'> = 'last-week', 7 + ) { 8 + const cachedFetch = useCachedFetch() 9 + 10 + const asyncData = useLazyAsyncData( 11 + () => `downloads:${toValue(name)}:${toValue(period)}`, 12 + async (_nuxtApp, { signal }) => { 13 + const encodedName = encodePackageName(toValue(name)) 14 + const { data, isStale } = await cachedFetch<NpmDownloadCount>( 15 + `${NPM_API}/downloads/point/${toValue(period)}/${encodedName}`, 16 + { signal }, 17 + ) 18 + return { ...data, isStale } 19 + }, 20 + ) 21 + 22 + if (import.meta.client && asyncData.data.value?.isStale) { 23 + onMounted(() => { 24 + asyncData.refresh() 25 + }) 26 + } 27 + 28 + return asyncData 29 + }
+18
app/composables/npm/useResolvedVersion.ts
··· 1 + import type { ResolvedPackageVersion } from 'fast-npm-meta' 2 + 3 + export function useResolvedVersion( 4 + packageName: MaybeRefOrGetter<string>, 5 + requestedVersion: MaybeRefOrGetter<string | null>, 6 + ) { 7 + return useFetch( 8 + () => { 9 + const version = toValue(requestedVersion) 10 + return version 11 + ? `https://npm.antfu.dev/${toValue(packageName)}@${version}` 12 + : `https://npm.antfu.dev/${toValue(packageName)}` 13 + }, 14 + { 15 + transform: (data: ResolvedPackageVersion) => data.version, 16 + }, 17 + ) 18 + }
-870
app/composables/useNpmRegistry.ts
··· 1 - import type { 2 - Packument, 3 - SlimPackument, 4 - NpmSearchResponse, 5 - NpmSearchResult, 6 - NpmDownloadCount, 7 - NpmPerson, 8 - PackageVersionInfo, 9 - } from '#shared/types' 10 - import { getVersions } from 'fast-npm-meta' 11 - import type { ResolvedPackageVersion } from 'fast-npm-meta' 12 - import type { ReleaseType } from 'semver' 13 - import { mapWithConcurrency } from '#shared/utils/async' 14 - import { maxSatisfying, prerelease, major, minor, diff, gt, compare } from 'semver' 15 - import { extractInstallScriptsInfo } from '~/utils/install-scripts' 16 - import type { CachedFetchFunction } from '#shared/utils/fetch-cache-config' 17 - 18 - const NPM_REGISTRY = 'https://registry.npmjs.org' 19 - const NPM_API = 'https://api.npmjs.org' 20 - 21 - // Cache for packument fetches to avoid duplicate requests across components 22 - const packumentCache = new Map<string, Promise<Packument | null>>() 23 - 24 - /** 25 - * Fetch downloads for multiple packages. 26 - * Returns a map of package name -> weekly downloads. 27 - * Uses bulk API for unscoped packages, parallel individual requests for scoped. 28 - * Note: npm bulk downloads API does not support scoped packages. 29 - */ 30 - async function fetchBulkDownloads( 31 - packageNames: string[], 32 - options: Parameters<typeof $fetch>[1] = {}, 33 - ): Promise<Map<string, number>> { 34 - const downloads = new Map<string, number>() 35 - if (packageNames.length === 0) return downloads 36 - 37 - // Separate scoped and unscoped packages 38 - const scopedPackages = packageNames.filter(n => n.startsWith('@')) 39 - const unscopedPackages = packageNames.filter(n => !n.startsWith('@')) 40 - 41 - // Fetch unscoped packages via bulk API (max 128 per request) 42 - const bulkPromises: Promise<void>[] = [] 43 - const chunkSize = 100 44 - for (let i = 0; i < unscopedPackages.length; i += chunkSize) { 45 - const chunk = unscopedPackages.slice(i, i + chunkSize) 46 - bulkPromises.push( 47 - (async () => { 48 - try { 49 - const response = await $fetch<Record<string, { downloads: number } | null>>( 50 - `${NPM_API}/downloads/point/last-week/${chunk.join(',')}`, 51 - options, 52 - ) 53 - for (const [name, data] of Object.entries(response)) { 54 - if (data?.downloads !== undefined) { 55 - downloads.set(name, data.downloads) 56 - } 57 - } 58 - } catch { 59 - // Ignore errors - downloads are optional 60 - } 61 - })(), 62 - ) 63 - } 64 - 65 - // Fetch scoped packages in parallel batches (concurrency limit to avoid overwhelming the API) 66 - // Use Promise.allSettled to not fail on individual errors 67 - const scopedBatchSize = 20 // Concurrent requests per batch 68 - for (let i = 0; i < scopedPackages.length; i += scopedBatchSize) { 69 - const batch = scopedPackages.slice(i, i + scopedBatchSize) 70 - bulkPromises.push( 71 - (async () => { 72 - const results = await Promise.allSettled( 73 - batch.map(async name => { 74 - const encoded = encodePackageName(name) 75 - const data = await $fetch<{ downloads: number }>( 76 - `${NPM_API}/downloads/point/last-week/${encoded}`, 77 - ) 78 - return { name, downloads: data.downloads } 79 - }), 80 - ) 81 - for (const result of results) { 82 - if (result.status === 'fulfilled' && result.value.downloads !== undefined) { 83 - downloads.set(result.value.name, result.value.downloads) 84 - } 85 - } 86 - })(), 87 - ) 88 - } 89 - 90 - // Wait for all fetches to complete 91 - await Promise.all(bulkPromises) 92 - 93 - return downloads 94 - } 95 - 96 - /** Number of recent versions to include in initial payload */ 97 - const RECENT_VERSIONS_COUNT = 5 98 - 99 - /** 100 - * Transform a full Packument into a slimmed version for client-side use. 101 - * Reduces payload size by: 102 - * - Removing readme (fetched separately) 103 - * - Including only: 5 most recent versions + one version per dist-tag + requested version 104 - * - Stripping unnecessary fields from version objects 105 - */ 106 - function transformPackument(pkg: Packument, requestedVersion?: string | null): SlimPackument { 107 - // Get versions pointed to by dist-tags 108 - const distTagVersions = new Set(Object.values(pkg['dist-tags'] ?? {})) 109 - 110 - // Get 5 most recent versions by publish time 111 - const recentVersions = Object.keys(pkg.versions) 112 - .filter(v => pkg.time[v]) 113 - .sort((a, b) => { 114 - const timeA = pkg.time[a] 115 - const timeB = pkg.time[b] 116 - if (!timeA || !timeB) return 0 117 - return new Date(timeB).getTime() - new Date(timeA).getTime() 118 - }) 119 - .slice(0, RECENT_VERSIONS_COUNT) 120 - 121 - // Combine: recent versions + dist-tag versions + requested version (deduplicated) 122 - const includedVersions = new Set([...recentVersions, ...distTagVersions]) 123 - 124 - // Add the requested version if it exists in the package 125 - if (requestedVersion && pkg.versions[requestedVersion]) { 126 - includedVersions.add(requestedVersion) 127 - } 128 - 129 - // Build filtered versions object with install scripts info per version 130 - const filteredVersions: Record<string, SlimVersion> = {} 131 - let versionData: SlimPackumentVersion | null = null 132 - for (const v of includedVersions) { 133 - const version = pkg.versions[v] 134 - if (version) { 135 - if (version.version === requestedVersion) { 136 - // Strip readme from each version, extract install scripts info 137 - const { readme: _readme, scripts, ...slimVersion } = version 138 - 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 - } 146 - filteredVersions[v] = { 147 - ...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}), 148 - version: version.version, 149 - deprecated: version.deprecated, 150 - tags: version.tags as string[], 151 - } 152 - } 153 - } 154 - 155 - // Build filtered time object (only for included versions + metadata) 156 - const filteredTime: Record<string, string> = {} 157 - if (pkg.time.modified) filteredTime.modified = pkg.time.modified 158 - if (pkg.time.created) filteredTime.created = pkg.time.created 159 - for (const v of includedVersions) { 160 - if (pkg.time[v]) filteredTime[v] = pkg.time[v] 161 - } 162 - 163 - return { 164 - '_id': pkg._id, 165 - '_rev': pkg._rev, 166 - 'name': pkg.name, 167 - 'description': pkg.description, 168 - 'dist-tags': pkg['dist-tags'], 169 - 'time': filteredTime, 170 - 'maintainers': pkg.maintainers, 171 - 'author': pkg.author, 172 - 'license': pkg.license, 173 - 'homepage': pkg.homepage, 174 - 'keywords': pkg.keywords, 175 - 'repository': pkg.repository, 176 - 'bugs': pkg.bugs, 177 - 'requestedVersion': versionData, 178 - 'versions': filteredVersions, 179 - } 180 - } 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 - 199 - export function usePackage( 200 - name: MaybeRefOrGetter<string>, 201 - requestedVersion?: MaybeRefOrGetter<string | null>, 202 - ) { 203 - const cachedFetch = useCachedFetch() 204 - 205 - const asyncData = useLazyAsyncData( 206 - () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`, 207 - async (_nuxtApp, { signal }) => { 208 - const encodedName = encodePackageName(toValue(name)) 209 - const { data: r, isStale } = await cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`, { 210 - signal, 211 - }) 212 - const reqVer = toValue(requestedVersion) 213 - const pkg = transformPackument(r, reqVer) 214 - return { ...pkg, isStale } 215 - }, 216 - ) 217 - 218 - if (import.meta.client && asyncData.data.value?.isStale) { 219 - onMounted(() => { 220 - asyncData.refresh() 221 - }) 222 - } 223 - 224 - return asyncData 225 - } 226 - 227 - export function usePackageDownloads( 228 - name: MaybeRefOrGetter<string>, 229 - period: MaybeRefOrGetter<'last-day' | 'last-week' | 'last-month' | 'last-year'> = 'last-week', 230 - ) { 231 - const cachedFetch = useCachedFetch() 232 - 233 - const asyncData = useLazyAsyncData( 234 - () => `downloads:${toValue(name)}:${toValue(period)}`, 235 - async (_nuxtApp, { signal }) => { 236 - const encodedName = encodePackageName(toValue(name)) 237 - const { data, isStale } = await cachedFetch<NpmDownloadCount>( 238 - `${NPM_API}/downloads/point/${toValue(period)}/${encodedName}`, 239 - { signal }, 240 - ) 241 - return { ...data, isStale } 242 - }, 243 - ) 244 - 245 - if (import.meta.client && asyncData.data.value?.isStale) { 246 - onMounted(() => { 247 - asyncData.refresh() 248 - }) 249 - } 250 - 251 - return asyncData 252 - } 253 - 254 - type NpmDownloadsRangeResponse = { 255 - start: string 256 - end: string 257 - package: string 258 - downloads: Array<{ day: string; downloads: number }> 259 - } 260 - 261 - /** 262 - * Fetch download range data from npm API. 263 - * Exported for external use (e.g., in components). 264 - */ 265 - export async function fetchNpmDownloadsRange( 266 - packageName: string, 267 - start: string, 268 - end: string, 269 - ): Promise<NpmDownloadsRangeResponse> { 270 - const encodedName = encodePackageName(packageName) 271 - return await $fetch<NpmDownloadsRangeResponse>( 272 - `${NPM_API}/downloads/range/${start}:${end}/${encodedName}`, 273 - ) 274 - } 275 - 276 - const emptySearchResponse = { 277 - objects: [], 278 - total: 0, 279 - isStale: false, 280 - time: new Date().toISOString(), 281 - } satisfies NpmSearchResponse 282 - 283 - export interface NpmSearchOptions { 284 - /** Number of results to fetch */ 285 - size?: number 286 - } 287 - 288 - export function useNpmSearch( 289 - query: MaybeRefOrGetter<string>, 290 - options: MaybeRefOrGetter<NpmSearchOptions> = {}, 291 - ) { 292 - const cachedFetch = useCachedFetch() 293 - // Client-side cache 294 - const cache = shallowRef<{ 295 - query: string 296 - objects: NpmSearchResult[] 297 - total: number 298 - } | null>(null) 299 - 300 - const isLoadingMore = shallowRef(false) 301 - 302 - // Standard (non-incremental) search implementation 303 - let lastSearch: NpmSearchResponse | undefined = undefined 304 - 305 - const asyncData = useLazyAsyncData( 306 - () => `search:incremental:${toValue(query)}`, 307 - async (_nuxtApp, { signal }) => { 308 - const q = toValue(query) 309 - 310 - if (!q.trim()) { 311 - return emptySearchResponse 312 - } 313 - 314 - const opts = toValue(options) 315 - 316 - // This only runs for initial load or query changes 317 - // Reset cache for new query 318 - cache.value = null 319 - 320 - const params = new URLSearchParams() 321 - params.set('text', q) 322 - // Use requested size for initial fetch 323 - params.set('size', String(opts.size ?? 25)) 324 - 325 - if (q.length === 1) { 326 - const encodedName = encodePackageName(q) 327 - const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([ 328 - cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`, { signal }), 329 - cachedFetch<NpmDownloadCount>(`${NPM_API}/downloads/point/last-week/${encodedName}`, { 330 - signal, 331 - }), 332 - ]) 333 - 334 - if (!pkg) { 335 - return emptySearchResponse 336 - } 337 - 338 - const result = packumentToSearchResult(pkg, downloads?.downloads) 339 - 340 - // If query changed/outdated, return empty search response 341 - if (q !== toValue(query)) { 342 - return emptySearchResponse 343 - } 344 - 345 - cache.value = { 346 - query: q, 347 - objects: [result], 348 - total: 1, 349 - } 350 - 351 - return { 352 - objects: [result], 353 - total: 1, 354 - isStale, 355 - time: new Date().toISOString(), 356 - } 357 - } 358 - 359 - const { data: response, isStale } = await cachedFetch<NpmSearchResponse>( 360 - `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, 361 - { signal }, 362 - 60, 363 - ) 364 - 365 - // If query changed/outdated, return empty search response 366 - if (q !== toValue(query)) { 367 - return emptySearchResponse 368 - } 369 - 370 - cache.value = { 371 - query: q, 372 - objects: response.objects, 373 - total: response.total, 374 - } 375 - 376 - return { ...response, isStale } 377 - }, 378 - { default: () => lastSearch || emptySearchResponse }, 379 - ) 380 - 381 - // Fetch more results incrementally (only used in incremental mode) 382 - async function fetchMore(targetSize: number): Promise<void> { 383 - const q = toValue(query).trim() 384 - if (!q) { 385 - cache.value = null 386 - return 387 - } 388 - 389 - // If query changed, reset cache (shouldn't happen, but safety check) 390 - if (cache.value && cache.value.query !== q) { 391 - cache.value = null 392 - await asyncData.refresh() 393 - return 394 - } 395 - 396 - const currentCount = cache.value?.objects.length ?? 0 397 - const total = cache.value?.total ?? Infinity 398 - 399 - // Already have enough or no more to fetch 400 - if (currentCount >= targetSize || currentCount >= total) { 401 - return 402 - } 403 - 404 - isLoadingMore.value = true 405 - 406 - try { 407 - // Fetch from where we left off - calculate size needed 408 - const from = currentCount 409 - const size = Math.min(targetSize - currentCount, total - currentCount) 410 - 411 - const params = new URLSearchParams() 412 - params.set('text', q) 413 - params.set('size', String(size)) 414 - params.set('from', String(from)) 415 - 416 - const { data: response } = await cachedFetch<NpmSearchResponse>( 417 - `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, 418 - {}, 419 - 60, 420 - ) 421 - 422 - // Update cache 423 - if (cache.value && cache.value.query === q) { 424 - const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) 425 - const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) 426 - cache.value = { 427 - query: q, 428 - objects: [...cache.value.objects, ...newObjects], 429 - total: response.total, 430 - } 431 - } else { 432 - cache.value = { 433 - query: q, 434 - objects: response.objects, 435 - total: response.total, 436 - } 437 - } 438 - 439 - // If we still need more, fetch again recursively 440 - if ( 441 - cache.value.objects.length < targetSize && 442 - cache.value.objects.length < cache.value.total 443 - ) { 444 - await fetchMore(targetSize) 445 - } 446 - } finally { 447 - isLoadingMore.value = false 448 - } 449 - } 450 - 451 - // Watch for size increases in incremental mode 452 - watch( 453 - () => toValue(options).size, 454 - async (newSize, oldSize) => { 455 - if (!newSize) return 456 - if (oldSize && newSize > oldSize && toValue(query).trim()) { 457 - await fetchMore(newSize) 458 - } 459 - }, 460 - ) 461 - 462 - // Computed data that uses cache in incremental mode 463 - const data = computed<NpmSearchResponse | null>(() => { 464 - if (cache.value) { 465 - return { 466 - isStale: false, 467 - objects: cache.value.objects, 468 - total: cache.value.total, 469 - time: new Date().toISOString(), 470 - } 471 - } 472 - return asyncData.data.value 473 - }) 474 - 475 - if (import.meta.client && asyncData.data.value?.isStale) { 476 - onMounted(() => { 477 - asyncData.refresh() 478 - }) 479 - } 480 - 481 - // Whether there are more results available on the server (incremental mode only) 482 - const hasMore = computed(() => { 483 - if (!cache.value) return true 484 - return cache.value.objects.length < cache.value.total 485 - }) 486 - 487 - return { 488 - ...asyncData, 489 - /** Reactive search results (uses cache in incremental mode) */ 490 - data, 491 - /** Whether currently loading more results (incremental mode only) */ 492 - isLoadingMore, 493 - /** Whether there are more results available (incremental mode only) */ 494 - hasMore, 495 - /** Manually fetch more results up to target size (incremental mode only) */ 496 - fetchMore, 497 - } 498 - } 499 - 500 - /** 501 - * Minimal packument data needed for package cards 502 - */ 503 - interface MinimalPackument { 504 - 'name': string 505 - 'description'?: string 506 - 'keywords'?: string[] 507 - // `dist-tags` can be missing in some later unpublished packages 508 - 'dist-tags'?: Record<string, string> 509 - 'time': Record<string, string> 510 - 'maintainers'?: NpmPerson[] 511 - } 512 - 513 - /** 514 - * Convert packument to search result format for display 515 - */ 516 - function packumentToSearchResult(pkg: MinimalPackument, weeklyDownloads?: number): NpmSearchResult { 517 - let latestVersion = '' 518 - if (pkg['dist-tags']) { 519 - latestVersion = pkg['dist-tags'].latest || Object.values(pkg['dist-tags'])[0] || '' 520 - } 521 - const modified = pkg.time.modified || pkg.time[latestVersion] || '' 522 - 523 - return { 524 - package: { 525 - name: pkg.name, 526 - version: latestVersion, 527 - description: pkg.description, 528 - keywords: pkg.keywords, 529 - date: pkg.time[latestVersion] || modified, 530 - links: { 531 - npm: `https://www.npmjs.com/package/${pkg.name}`, 532 - }, 533 - maintainers: pkg.maintainers, 534 - }, 535 - score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } }, 536 - searchScore: 0, 537 - downloads: weeklyDownloads !== undefined ? { weekly: weeklyDownloads } : undefined, 538 - updated: pkg.time[latestVersion] || modified, 539 - } 540 - } 541 - 542 - /** 543 - * Fetch all packages for an npm organization 544 - * Returns search-result-like objects for compatibility with PackageList 545 - */ 546 - export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { 547 - const cachedFetch = useCachedFetch() 548 - 549 - const asyncData = useLazyAsyncData( 550 - () => `org-packages:${toValue(orgName)}`, 551 - async (_nuxtApp, { signal }) => { 552 - const org = toValue(orgName) 553 - if (!org) { 554 - return emptySearchResponse 555 - } 556 - 557 - // Get all package names in the org 558 - let packageNames: string[] 559 - try { 560 - const { data } = await cachedFetch<Record<string, string>>( 561 - `${NPM_REGISTRY}/-/org/${encodeURIComponent(org)}/package`, 562 - { signal }, 563 - ) 564 - packageNames = Object.keys(data) 565 - } catch (err) { 566 - // Check if this is a 404 (org not found) 567 - if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 404) { 568 - throw createError({ 569 - statusCode: 404, 570 - statusMessage: 'Organization not found', 571 - message: `The organization "@${org}" does not exist on npm`, 572 - }) 573 - } 574 - // For other errors (network, etc.), return empty array to be safe 575 - packageNames = [] 576 - } 577 - 578 - if (packageNames.length === 0) { 579 - return emptySearchResponse 580 - } 581 - 582 - // Fetch packuments and downloads in parallel 583 - const [packuments, downloads] = await Promise.all([ 584 - // Fetch packuments with concurrency limit 585 - (async () => { 586 - const results = await mapWithConcurrency( 587 - packageNames, 588 - async name => { 589 - try { 590 - const encoded = encodePackageName(name) 591 - const { data: pkg } = await cachedFetch<MinimalPackument>( 592 - `${NPM_REGISTRY}/${encoded}`, 593 - { signal }, 594 - ) 595 - return pkg 596 - } catch { 597 - return null 598 - } 599 - }, 600 - 10, 601 - ) 602 - // Filter out any unpublished packages (missing dist-tags) 603 - return results.filter( 604 - (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'], 605 - ) 606 - })(), 607 - // Fetch downloads in bulk 608 - fetchBulkDownloads(packageNames, { signal }), 609 - ]) 610 - 611 - // Convert to search results with download data 612 - const results: NpmSearchResult[] = packuments.map(pkg => 613 - packumentToSearchResult(pkg, downloads.get(pkg.name)), 614 - ) 615 - 616 - return { 617 - isStale: false, 618 - objects: results, 619 - total: results.length, 620 - time: new Date().toISOString(), 621 - } satisfies NpmSearchResponse 622 - }, 623 - { default: () => emptySearchResponse }, 624 - ) 625 - 626 - return asyncData 627 - } 628 - 629 - // ============================================================================ 630 - // Package Versions 631 - // ============================================================================ 632 - 633 - // Cache for full version lists (client-side only, for non-composable usage) 634 - const allVersionsCache = new Map<string, Promise<PackageVersionInfo[]>>() 635 - 636 - /** 637 - * Fetch all versions of a package using fast-npm-meta API. 638 - * Returns version info sorted by version (newest first). 639 - * Results are cached to avoid duplicate requests. 640 - * 641 - * Note: This is a standalone async function for use in event handlers. 642 - * For composable usage, use useAllPackageVersions instead. 643 - * 644 - * @see https://github.com/antfu/fast-npm-meta 645 - */ 646 - export async function fetchAllPackageVersions(packageName: string): Promise<PackageVersionInfo[]> { 647 - const cached = allVersionsCache.get(packageName) 648 - if (cached) return cached 649 - 650 - const promise = (async () => { 651 - const data = await getVersions(packageName, { metadata: true }) 652 - 653 - return Object.entries(data.versionsMeta) 654 - .map(([version, meta]) => ({ 655 - version, 656 - time: meta.time, 657 - hasProvenance: meta.provenance === 'trustedPublisher' || meta.provenance === true, 658 - deprecated: meta.deprecated, 659 - })) 660 - .sort((a, b) => compare(b.version, a.version)) 661 - })() 662 - 663 - allVersionsCache.set(packageName, promise) 664 - return promise 665 - } 666 - 667 - // ============================================================================ 668 - // Outdated Dependencies 669 - // ============================================================================ 670 - 671 - /** Information about an outdated dependency */ 672 - export interface OutdatedDependencyInfo { 673 - /** The resolved version that satisfies the constraint */ 674 - resolved: string 675 - /** The latest available version */ 676 - latest: string 677 - /** How many major versions behind */ 678 - majorsBehind: number 679 - /** How many minor versions behind (when same major) */ 680 - minorsBehind: number 681 - /** The type of version difference */ 682 - diffType: ReleaseType | null 683 - } 684 - 685 - /** 686 - * Check if a version constraint explicitly includes a prerelease tag. 687 - * e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases 688 - */ 689 - export function constraintIncludesPrerelease(constraint: string): boolean { 690 - return ( 691 - /-(alpha|beta|rc|next|canary|dev|preview|pre|experimental)/i.test(constraint) || 692 - /-\d/.test(constraint) 693 - ) 694 - } 695 - 696 - /** 697 - * Check if a constraint is a non-semver value (git URL, file path, etc.) 698 - */ 699 - export function isNonSemverConstraint(constraint: string): boolean { 700 - return ( 701 - constraint.startsWith('git') || 702 - constraint.startsWith('http') || 703 - constraint.startsWith('file:') || 704 - constraint.startsWith('npm:') || 705 - constraint.startsWith('link:') || 706 - constraint.startsWith('workspace:') || 707 - constraint.includes('/') 708 - ) 709 - } 710 - 711 - /** 712 - * Check if a dependency is outdated. 713 - * Returns null if up-to-date or if we can't determine. 714 - */ 715 - async function checkDependencyOutdated( 716 - cachedFetch: CachedFetchFunction, 717 - packageName: string, 718 - constraint: string, 719 - ): Promise<OutdatedDependencyInfo | null> { 720 - if (isNonSemverConstraint(constraint)) { 721 - return null 722 - } 723 - 724 - // Check in-memory cache first 725 - let packument: Packument | null 726 - const cached = packumentCache.get(packageName) 727 - if (cached) { 728 - packument = await cached 729 - } else { 730 - const promise = cachedFetch<Packument>(`${NPM_REGISTRY}/${encodePackageName(packageName)}`) 731 - .then(({ data }) => data) 732 - .catch(() => null) 733 - packumentCache.set(packageName, promise) 734 - packument = await promise 735 - } 736 - 737 - if (!packument) return null 738 - 739 - const latestTag = packument['dist-tags']?.latest 740 - if (!latestTag) return null 741 - 742 - // Handle "latest" constraint specially - return info with current version 743 - if (constraint === 'latest') { 744 - return { 745 - resolved: latestTag, 746 - latest: latestTag, 747 - majorsBehind: 0, 748 - minorsBehind: 0, 749 - diffType: null, 750 - } 751 - } 752 - 753 - let versions = Object.keys(packument.versions) 754 - const includesPrerelease = constraintIncludesPrerelease(constraint) 755 - 756 - if (!includesPrerelease) { 757 - versions = versions.filter(v => !prerelease(v)) 758 - } 759 - 760 - const resolved = maxSatisfying(versions, constraint) 761 - if (!resolved) return null 762 - 763 - if (resolved === latestTag) return null 764 - 765 - // If resolved version is newer than latest, not outdated 766 - // (e.g., using ^2.0.0-rc when latest is 1.x) 767 - if (gt(resolved, latestTag)) { 768 - return null 769 - } 770 - 771 - const diffType = diff(resolved, latestTag) 772 - const majorsBehind = major(latestTag) - major(resolved) 773 - const minorsBehind = majorsBehind === 0 ? minor(latestTag) - minor(resolved) : 0 774 - 775 - return { 776 - resolved, 777 - latest: latestTag, 778 - majorsBehind, 779 - minorsBehind, 780 - diffType, 781 - } 782 - } 783 - 784 - /** 785 - * Composable to check for outdated dependencies. 786 - * Returns a reactive map of dependency name to outdated info. 787 - */ 788 - export function useOutdatedDependencies( 789 - dependencies: MaybeRefOrGetter<Record<string, string> | undefined>, 790 - ) { 791 - const cachedFetch = useCachedFetch() 792 - const outdated = shallowRef<Record<string, OutdatedDependencyInfo>>({}) 793 - 794 - async function fetchOutdatedInfo(deps: Record<string, string> | undefined) { 795 - if (!deps || Object.keys(deps).length === 0) { 796 - outdated.value = {} 797 - return 798 - } 799 - 800 - const entries = Object.entries(deps) 801 - const batchResults = await mapWithConcurrency( 802 - entries, 803 - async ([name, constraint]) => { 804 - const info = await checkDependencyOutdated(cachedFetch, name, constraint) 805 - return [name, info] as const 806 - }, 807 - 5, 808 - ) 809 - 810 - const results: Record<string, OutdatedDependencyInfo> = {} 811 - for (const [name, info] of batchResults) { 812 - if (info) { 813 - results[name] = info 814 - } 815 - } 816 - 817 - outdated.value = results 818 - } 819 - 820 - watch( 821 - () => toValue(dependencies), 822 - deps => { 823 - fetchOutdatedInfo(deps) 824 - }, 825 - { immediate: true }, 826 - ) 827 - 828 - return outdated 829 - } 830 - 831 - /** 832 - * Get tooltip text for an outdated dependency 833 - */ 834 - export function getOutdatedTooltip( 835 - info: OutdatedDependencyInfo, 836 - t: (key: string, params?: Record<string, unknown>, plural?: number) => string, 837 - ): string { 838 - if (info.majorsBehind > 0) { 839 - return t( 840 - 'package.dependencies.outdated_major', 841 - { count: info.majorsBehind, latest: info.latest }, 842 - info.majorsBehind, 843 - ) 844 - } 845 - if (info.minorsBehind > 0) { 846 - return t( 847 - 'package.dependencies.outdated_minor', 848 - { count: info.minorsBehind, latest: info.latest }, 849 - info.minorsBehind, 850 - ) 851 - } 852 - return t('package.dependencies.outdated_patch', { latest: info.latest }) 853 - } 854 - 855 - /** 856 - * Get CSS class for a dependency version based on outdated status 857 - */ 858 - export function getVersionClass(info: OutdatedDependencyInfo | undefined): string { 859 - if (!info) return 'text-fg-subtle' 860 - // Green for up-to-date (e.g. "latest" constraint) 861 - if (info.majorsBehind === 0 && info.minorsBehind === 0 && info.resolved === info.latest) { 862 - return 'text-green-500 cursor-help' 863 - } 864 - // Red for major versions behind 865 - if (info.majorsBehind > 0) return 'text-red-500 cursor-help' 866 - // Orange for minor versions behind 867 - if (info.minorsBehind > 0) return 'text-orange-500 cursor-help' 868 - // Yellow for patch versions behind 869 - return 'text-yellow-500 cursor-help' 870 - }
+3
app/utils/npm.ts app/utils/npm/common.ts
··· 1 + export const NPM_REGISTRY = 'https://registry.npmjs.org' 2 + export const NPM_API = 'https://api.npmjs.org' 3 + 1 4 /** 2 5 * Constructs a scope:team string in the format expected by npm. 3 6 * npm operations require the format @scope:team (with @ prefix).
+64
app/utils/npm/api.ts
··· 1 + import type { PackageVersionInfo } from '#shared/types' 2 + import { getVersions } from 'fast-npm-meta' 3 + import { compare } from 'semver' 4 + import { NPM_API } from './common' 5 + 6 + type NpmDownloadsRangeResponse = { 7 + start: string 8 + end: string 9 + package: string 10 + downloads: Array<{ day: string; downloads: number }> 11 + } 12 + 13 + /** 14 + * Fetch download range data from npm API. 15 + * Exported for external use (e.g., in components). 16 + */ 17 + export async function fetchNpmDownloadsRange( 18 + packageName: string, 19 + start: string, 20 + end: string, 21 + ): Promise<NpmDownloadsRangeResponse> { 22 + const encodedName = encodePackageName(packageName) 23 + return await $fetch<NpmDownloadsRangeResponse>( 24 + `${NPM_API}/downloads/range/${start}:${end}/${encodedName}`, 25 + ) 26 + } 27 + 28 + // ============================================================================ 29 + // Package Versions 30 + // ============================================================================ 31 + 32 + // Cache for full version lists (client-side only, for non-composable usage) 33 + const allVersionsCache = new Map<string, Promise<PackageVersionInfo[]>>() 34 + 35 + /** 36 + * Fetch all versions of a package using fast-npm-meta API. 37 + * Returns version info sorted by version (newest first). 38 + * Results are cached to avoid duplicate requests. 39 + * 40 + * Note: This is a standalone async function for use in event handlers. 41 + * For composable usage, use useAllPackageVersions instead. 42 + * 43 + * @see https://github.com/antfu/fast-npm-meta 44 + */ 45 + export async function fetchAllPackageVersions(packageName: string): Promise<PackageVersionInfo[]> { 46 + const cached = allVersionsCache.get(packageName) 47 + if (cached) return cached 48 + 49 + const promise = (async () => { 50 + const data = await getVersions(packageName, { metadata: true }) 51 + 52 + return Object.entries(data.versionsMeta) 53 + .map(([version, meta]) => ({ 54 + version, 55 + time: meta.time, 56 + hasProvenance: meta.provenance === 'trustedPublisher' || meta.provenance === true, 57 + deprecated: meta.deprecated, 58 + })) 59 + .sort((a, b) => compare(b.version, a.version)) 60 + })() 61 + 62 + allVersionsCache.set(packageName, promise) 63 + return promise 64 + }
+82
app/utils/npm/outdated-dependencies.ts
··· 1 + import type { ReleaseType } from 'semver' 2 + 3 + /** Information about an outdated dependency */ 4 + export interface OutdatedDependencyInfo { 5 + /** The resolved version that satisfies the constraint */ 6 + resolved: string 7 + /** The latest available version */ 8 + latest: string 9 + /** How many major versions behind */ 10 + majorsBehind: number 11 + /** How many minor versions behind (when same major) */ 12 + minorsBehind: number 13 + /** The type of version difference */ 14 + diffType: ReleaseType | null 15 + } 16 + 17 + /** 18 + * Check if a version constraint explicitly includes a prerelease tag. 19 + * e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases 20 + */ 21 + export function constraintIncludesPrerelease(constraint: string): boolean { 22 + return ( 23 + /-(alpha|beta|rc|next|canary|dev|preview|pre|experimental)/i.test(constraint) || 24 + /-\d/.test(constraint) 25 + ) 26 + } 27 + 28 + /** 29 + * Check if a constraint is a non-semver value (git URL, file path, etc.) 30 + */ 31 + export function isNonSemverConstraint(constraint: string): boolean { 32 + return ( 33 + constraint.startsWith('git') || 34 + constraint.startsWith('http') || 35 + constraint.startsWith('file:') || 36 + constraint.startsWith('npm:') || 37 + constraint.startsWith('link:') || 38 + constraint.startsWith('workspace:') || 39 + constraint.includes('/') 40 + ) 41 + } 42 + 43 + /** 44 + * Get tooltip text for an outdated dependency 45 + */ 46 + export function getOutdatedTooltip( 47 + info: OutdatedDependencyInfo, 48 + t: (key: string, params?: Record<string, unknown>, plural?: number) => string, 49 + ): string { 50 + if (info.majorsBehind > 0) { 51 + return t( 52 + 'package.dependencies.outdated_major', 53 + { count: info.majorsBehind, latest: info.latest }, 54 + info.majorsBehind, 55 + ) 56 + } 57 + if (info.minorsBehind > 0) { 58 + return t( 59 + 'package.dependencies.outdated_minor', 60 + { count: info.minorsBehind, latest: info.latest }, 61 + info.minorsBehind, 62 + ) 63 + } 64 + return t('package.dependencies.outdated_patch', { latest: info.latest }) 65 + } 66 + 67 + /** 68 + * Get CSS class for a dependency version based on outdated status 69 + */ 70 + export function getVersionClass(info: OutdatedDependencyInfo | undefined): string { 71 + if (!info) return 'text-fg-subtle' 72 + // Green for up-to-date (e.g. "latest" constraint) 73 + if (info.majorsBehind === 0 && info.minorsBehind === 0 && info.resolved === info.latest) { 74 + return 'text-green-500 cursor-help' 75 + } 76 + // Red for major versions behind 77 + if (info.majorsBehind > 0) return 'text-red-500 cursor-help' 78 + // Orange for minor versions behind 79 + if (info.minorsBehind > 0) return 'text-orange-500 cursor-help' 80 + // Yellow for patch versions behind 81 + return 'text-yellow-500 cursor-help' 82 + }
+4
nuxt.config.ts
··· 275 275 detectBrowserLanguage: false, 276 276 langDir: 'locales', 277 277 }, 278 + 279 + imports: { 280 + dirs: ['~/composables', '~/composables/*/*.ts'], 281 + }, 278 282 })
+13
shared/types/npm-registry.ts
··· 342 342 lines: number 343 343 markdownHtml?: ReadmeResponse 344 344 } 345 + 346 + /** 347 + * Minimal packument data needed for package cards 348 + */ 349 + export interface MinimalPackument { 350 + 'name': string 351 + 'description'?: string 352 + 'keywords'?: string[] 353 + // `dist-tags` can be missing in some later unpublished packages 354 + 'dist-tags'?: Record<string, string> 355 + 'time': Record<string, string> 356 + 'maintainers'?: NpmPerson[] 357 + }
+1 -1
test/nuxt/components/PackageVersions.spec.ts
··· 5 5 6 6 // Mock the fetchAllPackageVersions function 7 7 const mockFetchAllPackageVersions = vi.fn() 8 - vi.mock('~/composables/useNpmRegistry', () => ({ 8 + vi.mock('~/utils/npm/api', () => ({ 9 9 fetchAllPackageVersions: (...args: unknown[]) => mockFetchAllPackageVersions(...args), 10 10 })) 11 11
+1 -1
test/nuxt/components/VersionSelector.spec.ts
··· 4 4 5 5 // Mock the fetchAllPackageVersions function 6 6 const mockFetchAllPackageVersions = vi.fn() 7 - vi.mock('~/composables/useNpmRegistry', () => ({ 7 + vi.mock('~/utils/npm/api', () => ({ 8 8 fetchAllPackageVersions: (...args: unknown[]) => mockFetchAllPackageVersions(...args), 9 9 })) 10 10
+1 -1
test/unit/app/utils/npm.spec.ts
··· 1 1 import { describe, expect, it } from 'vitest' 2 2 3 - import { buildScopeTeam } from '../../../../app/utils/npm' 3 + import { buildScopeTeam } from '../../../../app/utils/npm/common' 4 4 import { validateScopeTeam } from '../../../../cli/src/npm-client' 5 5 import { getSpdxLicenseUrl } from '../../../../shared/utils/spdx' 6 6