[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: simplify search logic and use more minimal endpoints (#1236)

authored by

Daniel Roe and committed by
GitHub
a851e1ee 03e43e7b

+690 -450
+2 -2
app/components/Compare/PackageSelector.vue
··· 14 14 const inputValue = shallowRef('') 15 15 const isInputFocused = shallowRef(false) 16 16 17 - // Use the shared npm search composable 18 - const { data: searchData, status } = useNpmSearch(inputValue, { size: 15 }) 17 + // Use the shared search composable (supports both npm and Algolia providers) 18 + const { data: searchData, status } = useSearch(inputValue, { size: 15 }) 19 19 20 20 const isSearching = computed(() => status.value === 'pending') 21 21
+33
app/composables/npm/search-utils.ts
··· 1 + import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#shared/types' 2 + 3 + /** 4 + * Convert a lightweight package-meta API response to a search result for display. 5 + */ 6 + export function metaToSearchResult(meta: PackageMetaResponse): NpmSearchResult { 7 + return { 8 + package: { 9 + name: meta.name, 10 + version: meta.version, 11 + description: meta.description, 12 + keywords: meta.keywords, 13 + license: meta.license, 14 + date: meta.date, 15 + links: meta.links, 16 + author: meta.author, 17 + maintainers: meta.maintainers, 18 + }, 19 + score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } }, 20 + searchScore: 0, 21 + downloads: meta.weeklyDownloads !== undefined ? { weekly: meta.weeklyDownloads } : undefined, 22 + updated: meta.date, 23 + } 24 + } 25 + 26 + export function emptySearchResponse(): NpmSearchResponse { 27 + return { 28 + objects: [], 29 + total: 0, 30 + isStale: false, 31 + time: new Date().toISOString(), 32 + } 33 + }
+57 -333
app/composables/npm/useNpmSearch.ts
··· 1 - import type { 2 - Packument, 3 - NpmSearchResponse, 4 - NpmSearchResult, 5 - NpmDownloadCount, 6 - MinimalPackument, 7 - } from '#shared/types' 8 - import type { SearchProvider } from '~/composables/useSettings' 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 - } 1 + import type { NpmSearchResponse, PackageMetaResponse } from '#shared/types' 2 + import { emptySearchResponse, metaToSearchResult } from './search-utils' 41 3 42 4 export interface NpmSearchOptions { 43 - /** Number of results to fetch */ 5 + /** Number of results */ 44 6 size?: number 7 + /** Offset for pagination */ 8 + from?: number 45 9 } 46 10 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 - ) { 11 + /** 12 + * Composable that provides npm registry search functions. 13 + * 14 + * Mirrors the API shape of `useAlgoliaSearch` so that `useSearch` can 15 + * swap between providers without branching on implementation details. 16 + * 17 + * Must be called during component setup (or inside another composable) 18 + * because it reads from `useNuxtApp()`. The returned functions are safe 19 + * to call at any time (event handlers, async callbacks, etc.). 20 + */ 21 + export function useNpmSearch() { 58 22 const { $npmRegistry } = useNuxtApp() 59 - const { searchProvider } = useSearchProvider() 60 - const { search: searchAlgolia } = useAlgoliaSearch() 61 23 62 - // Client-side cache 63 - const cache = shallowRef<{ 64 - query: string 65 - provider: SearchProvider 66 - objects: NpmSearchResult[] 67 - total: number 68 - } | null>(null) 69 - 70 - const isLoadingMore = shallowRef(false) 71 - 72 - // Track rate limit errors separately for better UX 73 - // Using ref instead of shallowRef to ensure reactivity triggers properly 74 - const isRateLimited = ref(false) 75 - 76 - // Standard (non-incremental) search implementation 77 - let lastSearch: NpmSearchResponse | undefined = undefined 78 - 79 - const asyncData = useLazyAsyncData( 80 - () => `search:${searchProvider.value}:${toValue(query)}`, 81 - async ({ $npmRegistry, $npmApi }, { signal }) => { 82 - const q = toValue(query) 83 - const provider = searchProvider.value 84 - 85 - if (!q.trim()) { 86 - isRateLimited.value = false 87 - return emptySearchResponse 88 - } 89 - 90 - const opts = toValue(options) 91 - 92 - // This only runs for initial load or query changes 93 - // Reset cache for new query (but don't reset rate limit yet - only on success) 94 - cache.value = null 95 - 96 - // --- Algolia path (client-side only) --- 97 - if (provider === 'algolia') { 98 - const response = await searchAlgolia(q, { 99 - size: opts.size ?? 25, 100 - }) 101 - 102 - if (q !== toValue(query)) { 103 - return emptySearchResponse 104 - } 105 - 106 - isRateLimited.value = false 107 - 108 - cache.value = { 109 - query: q, 110 - provider, 111 - objects: response.objects, 112 - total: response.total, 113 - } 114 - 115 - return response 116 - } 117 - 118 - // --- npm registry path --- 119 - const params = new URLSearchParams() 120 - params.set('text', q) 121 - params.set('size', String(opts.size ?? 25)) 122 - 24 + /** 25 + * Search npm packages via the npm registry API. 26 + * Returns results in the same `NpmSearchResponse` format as `useAlgoliaSearch`. 27 + * 28 + * Single-character queries are handled specially: they fetch lightweight 29 + * metadata from a server-side proxy instead of a search, because the 30 + * search API returns poor results for single-char terms. The proxy 31 + * fetches the full packument + download counts server-side and returns 32 + * only the fields needed for package cards. 33 + */ 34 + async function search( 35 + query: string, 36 + options: NpmSearchOptions = {}, 37 + signal?: AbortSignal, 38 + ): Promise<NpmSearchResponse> { 39 + // Single-character: fetch lightweight metadata via server proxy 40 + if (query.length === 1) { 123 41 try { 124 - if (q.length === 1) { 125 - const encodedName = encodePackageName(q) 126 - const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([ 127 - $npmRegistry<Packument>(`/${encodedName}`, { signal }), 128 - $npmApi<NpmDownloadCount>(`/downloads/point/last-week/${encodedName}`, { 129 - signal, 130 - }), 131 - ]) 132 - 133 - if (!pkg) { 134 - return emptySearchResponse 135 - } 136 - 137 - const result = packumentToSearchResult(pkg, downloads?.downloads) 138 - 139 - // If query changed/outdated, return empty search response 140 - if (q !== toValue(query)) { 141 - return emptySearchResponse 142 - } 143 - 144 - cache.value = { 145 - query: q, 146 - provider, 147 - objects: [result], 148 - total: 1, 149 - } 150 - 151 - // Success - clear rate limit flag 152 - isRateLimited.value = false 153 - 154 - return { 155 - objects: [result], 156 - total: 1, 157 - isStale, 158 - time: new Date().toISOString(), 159 - } 160 - } 161 - 162 - const { data: response, isStale } = await $npmRegistry<NpmSearchResponse>( 163 - `/-/v1/search?${params.toString()}`, 42 + const meta = await $fetch<PackageMetaResponse>( 43 + `/api/registry/package-meta/${encodePackageName(query)}`, 164 44 { signal }, 165 - 60, 166 45 ) 167 46 168 - // If query changed/outdated, return empty search response 169 - if (q !== toValue(query)) { 170 - return emptySearchResponse 171 - } 47 + const result = metaToSearchResult(meta) 172 48 173 - cache.value = { 174 - query: q, 175 - provider, 176 - objects: response.objects, 177 - total: response.total, 49 + return { 50 + objects: [result], 51 + total: 1, 52 + isStale: false, 53 + time: new Date().toISOString(), 178 54 } 179 - 180 - // Success - clear rate limit flag 181 - isRateLimited.value = false 182 - 183 - return { ...response, isStale } 184 - } catch (error: unknown) { 185 - // Detect rate limit errors. npm's 429 response doesn't include CORS headers, 186 - // so the browser reports "Failed to fetch" instead of the actual status code. 187 - const errorMessage = (error as { message?: string })?.message || String(error) 188 - const isRateLimitError = 189 - errorMessage.includes('Failed to fetch') || errorMessage.includes('429') 190 - 191 - if (isRateLimitError) { 192 - isRateLimited.value = true 193 - return emptySearchResponse 194 - } 195 - throw error 55 + } catch { 56 + return emptySearchResponse() 196 57 } 197 - }, 198 - { default: () => lastSearch || emptySearchResponse }, 199 - ) 200 - 201 - // Fetch more results incrementally 202 - async function fetchMore(targetSize: number): Promise<void> { 203 - const q = toValue(query).trim() 204 - const provider = searchProvider.value 205 - 206 - if (!q) { 207 - cache.value = null 208 - return 209 - } 210 - 211 - // If query or provider changed, reset cache 212 - if (cache.value && (cache.value.query !== q || cache.value.provider !== provider)) { 213 - cache.value = null 214 - await asyncData.refresh() 215 - return 216 58 } 217 59 218 - const currentCount = cache.value?.objects.length ?? 0 219 - const total = cache.value?.total ?? Infinity 220 - 221 - // Already have enough or no more to fetch 222 - if (currentCount >= targetSize || currentCount >= total) { 223 - return 60 + // Standard search 61 + const params = new URLSearchParams() 62 + params.set('text', query) 63 + params.set('size', String(options.size ?? 25)) 64 + if (options.from) { 65 + params.set('from', String(options.from)) 224 66 } 225 67 226 - isLoadingMore.value = true 227 - 228 - try { 229 - const from = currentCount 230 - const size = Math.min(targetSize - currentCount, total - currentCount) 68 + const { data: response, isStale } = await $npmRegistry<NpmSearchResponse>( 69 + `/-/v1/search?${params.toString()}`, 70 + { signal }, 71 + 60, 72 + ) 231 73 232 - if (provider === 'algolia') { 233 - // Algolia incremental fetch 234 - const response = await searchAlgolia(q, { size, from }) 235 - 236 - if (cache.value && cache.value.query === q && cache.value.provider === provider) { 237 - const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) 238 - const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) 239 - cache.value = { 240 - query: q, 241 - provider, 242 - objects: [...cache.value.objects, ...newObjects], 243 - total: response.total, 244 - } 245 - } else { 246 - cache.value = { 247 - query: q, 248 - provider, 249 - objects: response.objects, 250 - total: response.total, 251 - } 252 - } 253 - } else { 254 - // npm registry incremental fetch 255 - const params = new URLSearchParams() 256 - params.set('text', q) 257 - params.set('size', String(size)) 258 - params.set('from', String(from)) 259 - 260 - const { data: response } = await $npmRegistry<NpmSearchResponse>( 261 - `/-/v1/search?${params.toString()}`, 262 - {}, 263 - 60, 264 - ) 265 - 266 - if (cache.value && cache.value.query === q && cache.value.provider === provider) { 267 - const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) 268 - const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) 269 - cache.value = { 270 - query: q, 271 - provider, 272 - objects: [...cache.value.objects, ...newObjects], 273 - total: response.total, 274 - } 275 - } else { 276 - cache.value = { 277 - query: q, 278 - provider, 279 - objects: response.objects, 280 - total: response.total, 281 - } 282 - } 283 - } 284 - 285 - // If we still need more, fetch again recursively 286 - if ( 287 - cache.value && 288 - cache.value.objects.length < targetSize && 289 - cache.value.objects.length < cache.value.total 290 - ) { 291 - await fetchMore(targetSize) 292 - } 293 - } finally { 294 - isLoadingMore.value = false 295 - } 74 + return { ...response, isStale } 296 75 } 297 76 298 - // Watch for size increases 299 - watch( 300 - () => toValue(options).size, 301 - async (newSize, oldSize) => { 302 - if (!newSize) return 303 - if (oldSize && newSize > oldSize && toValue(query).trim()) { 304 - await fetchMore(newSize) 305 - } 306 - }, 307 - ) 308 - 309 - // Re-search when provider changes 310 - watch(searchProvider, async () => { 311 - cache.value = null 312 - await asyncData.refresh() 313 - const targetSize = toValue(options).size 314 - if (targetSize) { 315 - await fetchMore(targetSize) 316 - } 317 - }) 318 - 319 - // Computed data that uses cache 320 - const data = computed<NpmSearchResponse | null>(() => { 321 - if (cache.value) { 322 - return { 323 - isStale: false, 324 - objects: cache.value.objects, 325 - total: cache.value.total, 326 - time: new Date().toISOString(), 327 - } 328 - } 329 - return asyncData.data.value 330 - }) 331 - 332 - if (import.meta.client && asyncData.data.value?.isStale) { 333 - onMounted(() => { 334 - asyncData.refresh() 335 - }) 336 - } 337 - 338 - // Whether there are more results available 339 - const hasMore = computed(() => { 340 - if (!cache.value) return true 341 - return cache.value.objects.length < cache.value.total 342 - }) 343 - 344 77 return { 345 - ...asyncData, 346 - /** Reactive search results (uses cache in incremental mode) */ 347 - data, 348 - /** Whether currently loading more results */ 349 - isLoadingMore, 350 - /** Whether there are more results available */ 351 - hasMore, 352 - /** Manually fetch more results up to target size */ 353 - fetchMore, 354 - /** Whether the search was rate limited by npm (429 error) */ 355 - isRateLimited: readonly(isRateLimited), 78 + /** Search packages by text query */ 79 + search, 356 80 } 357 81 }
+16 -19
app/composables/npm/useOrgPackages.ts
··· 1 - import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types' 2 - import { emptySearchResponse, packumentToSearchResult } from './useNpmSearch' 1 + import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#shared/types' 2 + import { emptySearchResponse, metaToSearchResult } from './search-utils' 3 3 import { mapWithConcurrency } from '#shared/utils/async' 4 4 5 5 /** ··· 7 7 * 8 8 * 1. Gets the authoritative package list from the npm registry (single request) 9 9 * 2. Fetches metadata from Algolia by exact name (single request) 10 - * 3. Falls back to individual packument fetches when Algolia is unavailable 10 + * 3. Falls back to lightweight server-side package-meta lookups 11 11 */ 12 12 export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { 13 13 const { searchProvider } = useSearchProvider() ··· 15 15 16 16 const asyncData = useLazyAsyncData( 17 17 () => `org-packages:${searchProvider.value}:${toValue(orgName)}`, 18 - async ({ $npmRegistry, ssrContext }, { signal }) => { 18 + async ({ ssrContext }, { signal }) => { 19 19 const org = toValue(orgName) 20 20 if (!org) { 21 - return emptySearchResponse 21 + return emptySearchResponse() 22 22 } 23 23 24 24 // Get the authoritative package list from the npm registry (single request) ··· 47 47 } 48 48 49 49 if (packageNames.length === 0) { 50 - return emptySearchResponse 50 + return emptySearchResponse() 51 51 } 52 52 53 53 // Fetch metadata + downloads from Algolia (single request via getObjects) ··· 62 62 } 63 63 } 64 64 65 - // npm fallback: fetch packuments individually 66 - const packuments = await mapWithConcurrency( 65 + // npm fallback: fetch lightweight metadata via server proxy 66 + const metaResults = await mapWithConcurrency( 67 67 packageNames, 68 68 async name => { 69 69 try { 70 - const encoded = encodePackageName(name) 71 - const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, { 72 - signal, 73 - }) 74 - return pkg 70 + return await $fetch<PackageMetaResponse>( 71 + `/api/registry/package-meta/${encodePackageName(name)}`, 72 + { signal }, 73 + ) 75 74 } catch { 76 75 return null 77 76 } ··· 79 78 10, 80 79 ) 81 80 82 - const validPackuments = packuments.filter( 83 - (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'], 84 - ) 85 - 86 - const results: NpmSearchResult[] = validPackuments.map(pkg => packumentToSearchResult(pkg)) 81 + const results: NpmSearchResult[] = metaResults 82 + .filter((meta): meta is PackageMetaResponse => meta !== null) 83 + .map(metaToSearchResult) 87 84 88 85 return { 89 86 isStale: false, ··· 92 89 time: new Date().toISOString(), 93 90 } satisfies NpmSearchResponse 94 91 }, 95 - { default: () => emptySearchResponse }, 92 + { default: emptySearchResponse }, 96 93 ) 97 94 98 95 return asyncData
+49 -59
app/composables/npm/useOutdatedDependencies.ts
··· 1 - import type { NuxtApp } from '#app' 1 + import type { PackageVersionsInfo } from 'fast-npm-meta' 2 + import { getVersionsBatch } from 'fast-npm-meta' 2 3 import { maxSatisfying, prerelease, major, minor, diff, gt } from 'semver' 3 - import type { Packument } from '#shared/types' 4 - import { mapWithConcurrency } from '#shared/utils/async' 5 - import type { CachedFetchFunction } from '#shared/utils/fetch-cache-config' 6 4 import { 7 5 type OutdatedDependencyInfo, 8 6 isNonSemverConstraint, 9 7 constraintIncludesPrerelease, 10 8 } from '~/utils/npm/outdated-dependencies' 11 9 12 - // Cache for packument fetches to avoid duplicate requests across components 13 - const packumentCache = new Map<string, Promise<Packument | null>>() 10 + const BATCH_SIZE = 50 14 11 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 - $npmRegistry: NuxtApp['$npmRegistry'], 22 - packageName: string, 12 + function resolveOutdated( 13 + versions: string[], 14 + latestTag: string, 23 15 constraint: string, 24 - ): Promise<OutdatedDependencyInfo | null> { 25 - if (isNonSemverConstraint(constraint)) { 26 - return null 27 - } 28 - 29 - // Check in-memory cache first 30 - let packument: Packument | null 31 - const cached = packumentCache.get(packageName) 32 - if (cached) { 33 - packument = await cached 34 - } else { 35 - const promise = $npmRegistry<Packument>(`/${encodePackageName(packageName)}`) 36 - .then(({ data }) => data) 37 - .catch(() => null) 38 - packumentCache.set(packageName, promise) 39 - packument = await promise 40 - } 41 - 42 - if (!packument) return null 43 - 44 - const latestTag = packument['dist-tags']?.latest 45 - if (!latestTag) return null 46 - 47 - // Handle "latest" constraint specially - return info with current version 16 + ): OutdatedDependencyInfo | null { 48 17 if (constraint === 'latest') { 49 18 return { 50 19 resolved: latestTag, ··· 55 24 } 56 25 } 57 26 58 - let versions = Object.keys(packument.versions) 59 - const includesPrerelease = constraintIncludesPrerelease(constraint) 60 - 61 - if (!includesPrerelease) { 62 - versions = versions.filter(v => !prerelease(v)) 27 + let filteredVersions = versions 28 + if (!constraintIncludesPrerelease(constraint)) { 29 + filteredVersions = versions.filter(v => !prerelease(v)) 63 30 } 64 31 65 - const resolved = maxSatisfying(versions, constraint) 32 + const resolved = maxSatisfying(filteredVersions, constraint) 66 33 if (!resolved) return null 67 34 68 35 if (resolved === latestTag) return null 69 36 70 - // If resolved version is newer than latest, not outdated 71 - // (e.g., using ^2.0.0-rc when latest is 1.x) 37 + // Resolved is newer than latest (e.g. ^2.0.0-rc when latest is 1.x) 72 38 if (gt(resolved, latestTag)) { 73 39 return null 74 40 } ··· 87 53 } 88 54 89 55 /** 90 - * Composable to check for outdated dependencies. 56 + * Check for outdated dependencies via fast-npm-meta batch version lookups. 91 57 * Returns a reactive map of dependency name to outdated info. 92 58 */ 93 59 export function useOutdatedDependencies( 94 60 dependencies: MaybeRefOrGetter<Record<string, string> | undefined>, 95 61 ) { 96 - const { $npmRegistry } = useNuxtApp() 97 - const cachedFetch = useCachedFetch() 98 62 const outdated = shallowRef<Record<string, OutdatedDependencyInfo>>({}) 99 63 100 64 async function fetchOutdatedInfo(deps: Record<string, string> | undefined) { ··· 103 67 return 104 68 } 105 69 106 - const entries = Object.entries(deps) 107 - const batchResults = await mapWithConcurrency( 108 - entries, 109 - async ([name, constraint]) => { 110 - const info = await checkDependencyOutdated(cachedFetch, $npmRegistry, name, constraint) 111 - return [name, info] as const 112 - }, 113 - 5, 70 + const semverEntries = Object.entries(deps).filter( 71 + ([, constraint]) => !isNonSemverConstraint(constraint), 114 72 ) 115 73 74 + if (semverEntries.length === 0) { 75 + outdated.value = {} 76 + return 77 + } 78 + 79 + const packageNames = semverEntries.map(([name]) => name) 80 + 81 + const chunks: string[][] = [] 82 + for (let i = 0; i < packageNames.length; i += BATCH_SIZE) { 83 + chunks.push(packageNames.slice(i, i + BATCH_SIZE)) 84 + } 85 + const batchResults = await Promise.all( 86 + chunks.map(chunk => getVersionsBatch(chunk, { throw: false })), 87 + ) 88 + const allVersionData = batchResults.flat() 89 + 90 + // Build a lookup map from package name to version data 91 + const versionMap = new Map<string, PackageVersionsInfo>() 92 + for (const data of allVersionData) { 93 + if ('error' in data) continue 94 + versionMap.set(data.name, data) 95 + } 96 + 116 97 const results: Record<string, OutdatedDependencyInfo> = {} 117 - for (const [name, info] of batchResults) { 98 + for (const [name, constraint] of semverEntries) { 99 + const data = versionMap.get(name) 100 + if (!data) continue 101 + 102 + const latestTag = data.distTags.latest 103 + if (!latestTag) continue 104 + 105 + const info = resolveOutdated(data.versions, latestTag, constraint) 118 106 if (info) { 119 107 results[name] = info 120 108 } ··· 126 114 watch( 127 115 () => toValue(dependencies), 128 116 deps => { 129 - fetchOutdatedInfo(deps) 117 + fetchOutdatedInfo(deps).catch(() => { 118 + // Network failure or fast-npm-meta outage — leave stale results in place 119 + }) 130 120 }, 131 121 { immediate: true }, 132 122 )
+214
app/composables/npm/useSearch.ts
··· 1 + import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' 2 + import type { SearchProvider } from '~/composables/useSettings' 3 + import { emptySearchResponse } from './search-utils' 4 + 5 + export interface SearchOptions { 6 + /** Number of results to fetch */ 7 + size?: number 8 + } 9 + 10 + export function useSearch( 11 + query: MaybeRefOrGetter<string>, 12 + options: MaybeRefOrGetter<SearchOptions> = {}, 13 + ) { 14 + const { searchProvider } = useSearchProvider() 15 + const { search: searchAlgolia } = useAlgoliaSearch() 16 + const { search: searchNpm } = useNpmSearch() 17 + 18 + const cache = shallowRef<{ 19 + query: string 20 + provider: SearchProvider 21 + objects: NpmSearchResult[] 22 + total: number 23 + } | null>(null) 24 + 25 + const isLoadingMore = shallowRef(false) 26 + 27 + const isRateLimited = ref(false) 28 + 29 + const asyncData = useLazyAsyncData( 30 + () => `search:${searchProvider.value}:${toValue(query)}`, 31 + async (_nuxtApp, { signal }) => { 32 + const q = toValue(query) 33 + const provider = searchProvider.value 34 + 35 + if (!q.trim()) { 36 + isRateLimited.value = false 37 + return emptySearchResponse() 38 + } 39 + 40 + const opts = toValue(options) 41 + 42 + cache.value = null 43 + 44 + if (provider === 'algolia') { 45 + const response = await searchAlgolia(q, { 46 + size: opts.size ?? 25, 47 + }) 48 + 49 + if (q !== toValue(query)) { 50 + return emptySearchResponse() 51 + } 52 + 53 + isRateLimited.value = false 54 + 55 + cache.value = { 56 + query: q, 57 + provider, 58 + objects: response.objects, 59 + total: response.total, 60 + } 61 + 62 + return response 63 + } 64 + 65 + try { 66 + const response = await searchNpm(q, { size: opts.size ?? 25 }, signal) 67 + 68 + if (q !== toValue(query)) { 69 + return emptySearchResponse() 70 + } 71 + 72 + cache.value = { 73 + query: q, 74 + provider, 75 + objects: response.objects, 76 + total: response.total, 77 + } 78 + 79 + isRateLimited.value = false 80 + 81 + return response 82 + } catch (error: unknown) { 83 + // npm 429 responses lack CORS headers, so the browser reports "Failed to fetch" 84 + const errorMessage = (error as { message?: string })?.message || String(error) 85 + const isRateLimitError = 86 + errorMessage.includes('Failed to fetch') || errorMessage.includes('429') 87 + 88 + if (isRateLimitError) { 89 + isRateLimited.value = true 90 + return emptySearchResponse() 91 + } 92 + throw error 93 + } 94 + }, 95 + { default: emptySearchResponse }, 96 + ) 97 + 98 + async function fetchMore(targetSize: number): Promise<void> { 99 + const q = toValue(query).trim() 100 + const provider = searchProvider.value 101 + 102 + if (!q) { 103 + cache.value = null 104 + return 105 + } 106 + 107 + if (cache.value && (cache.value.query !== q || cache.value.provider !== provider)) { 108 + cache.value = null 109 + await asyncData.refresh() 110 + return 111 + } 112 + 113 + const currentCount = cache.value?.objects.length ?? 0 114 + const total = cache.value?.total ?? Infinity 115 + 116 + if (currentCount >= targetSize || currentCount >= total) { 117 + return 118 + } 119 + 120 + isLoadingMore.value = true 121 + 122 + try { 123 + const from = currentCount 124 + const size = Math.min(targetSize - currentCount, total - currentCount) 125 + 126 + const doSearch = provider === 'algolia' ? searchAlgolia : searchNpm 127 + const response = await doSearch(q, { size, from }) 128 + 129 + if (cache.value && cache.value.query === q && cache.value.provider === provider) { 130 + const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) 131 + const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) 132 + cache.value = { 133 + query: q, 134 + provider, 135 + objects: [...cache.value.objects, ...newObjects], 136 + total: response.total, 137 + } 138 + } else { 139 + cache.value = { 140 + query: q, 141 + provider, 142 + objects: response.objects, 143 + total: response.total, 144 + } 145 + } 146 + 147 + if ( 148 + cache.value && 149 + cache.value.objects.length < targetSize && 150 + cache.value.objects.length < cache.value.total 151 + ) { 152 + await fetchMore(targetSize) 153 + } 154 + } finally { 155 + isLoadingMore.value = false 156 + } 157 + } 158 + 159 + watch( 160 + () => toValue(options).size, 161 + async (newSize, oldSize) => { 162 + if (!newSize) return 163 + if (oldSize && newSize > oldSize && toValue(query).trim()) { 164 + await fetchMore(newSize) 165 + } 166 + }, 167 + ) 168 + 169 + watch(searchProvider, async () => { 170 + cache.value = null 171 + await asyncData.refresh() 172 + const targetSize = toValue(options).size 173 + if (targetSize) { 174 + await fetchMore(targetSize) 175 + } 176 + }) 177 + 178 + const data = computed<NpmSearchResponse | null>(() => { 179 + if (cache.value) { 180 + return { 181 + isStale: false, 182 + objects: cache.value.objects, 183 + total: cache.value.total, 184 + time: new Date().toISOString(), 185 + } 186 + } 187 + return asyncData.data.value 188 + }) 189 + 190 + if (import.meta.client && asyncData.data.value?.isStale) { 191 + onMounted(() => { 192 + asyncData.refresh() 193 + }) 194 + } 195 + 196 + const hasMore = computed(() => { 197 + if (!cache.value) return true 198 + return cache.value.objects.length < cache.value.total 199 + }) 200 + 201 + return { 202 + ...asyncData, 203 + /** Reactive search results (uses cache in incremental mode) */ 204 + data, 205 + /** Whether currently loading more results */ 206 + isLoadingMore, 207 + /** Whether there are more results available */ 208 + hasMore, 209 + /** Manually fetch more results up to target size */ 210 + fetchMore, 211 + /** Whether the search was rate limited by npm (429 error) */ 212 + isRateLimited: readonly(isRateLimited), 213 + } 214 + }
+5 -5
app/composables/npm/useUserPackages.ts
··· 1 1 import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' 2 - import { emptySearchResponse } from './useNpmSearch' 2 + import { emptySearchResponse } from './search-utils' 3 3 4 4 /** Default page size for incremental loading (npm registry path) */ 5 5 const PAGE_SIZE = 50 as const ··· 47 47 async ({ $npmRegistry }, { signal }) => { 48 48 const user = toValue(username) 49 49 if (!user) { 50 - return emptySearchResponse 50 + return emptySearchResponse() 51 51 } 52 52 53 53 const provider = searchProvider.value ··· 59 59 60 60 // Guard against stale response (user/provider changed during await) 61 61 if (user !== toValue(username) || provider !== searchProvider.value) { 62 - return emptySearchResponse 62 + return emptySearchResponse() 63 63 } 64 64 65 65 // If Algolia returns results, use them. If empty, fall through to npm ··· 96 96 97 97 // Guard against stale response (user/provider changed during await) 98 98 if (user !== toValue(username) || provider !== searchProvider.value) { 99 - return emptySearchResponse 99 + return emptySearchResponse() 100 100 } 101 101 102 102 cache.value = { ··· 107 107 108 108 return { ...response, isStale } 109 109 }, 110 - { default: () => emptySearchResponse }, 110 + { default: emptySearchResponse }, 111 111 ) 112 112 // --- Fetch more (npm path only) --- 113 113 /**
+1 -1
app/pages/search.vue
··· 192 192 hasMore, 193 193 fetchMore, 194 194 isRateLimited, 195 - } = useNpmSearch(query, () => ({ 195 + } = useSearch(query, () => ({ 196 196 size: requestedSize.value, 197 197 })) 198 198
+72 -6
modules/runtime/server/cache.ts
··· 281 281 return result 282 282 } 283 283 284 + /** 285 + * Process a single package for the /versions/ endpoint. 286 + * Returns PackageVersionsInfo shape: { name, distTags, versions, specifier, time, lastSynced } 287 + */ 288 + async function processSingleVersionsMeta( 289 + packageQuery: string, 290 + storage: ReturnType<typeof useStorage>, 291 + metadata: boolean, 292 + ): Promise<Record<string, unknown>> { 293 + let packageName = packageQuery 294 + let specifier = '*' 295 + 296 + if (packageName.startsWith('@')) { 297 + const atIndex = packageName.indexOf('@', 1) 298 + if (atIndex !== -1) { 299 + specifier = packageName.slice(atIndex + 1) 300 + packageName = packageName.slice(0, atIndex) 301 + } 302 + } else { 303 + const atIndex = packageName.indexOf('@') 304 + if (atIndex !== -1) { 305 + specifier = packageName.slice(atIndex + 1) 306 + packageName = packageName.slice(0, atIndex) 307 + } 308 + } 309 + 310 + if (packageName.includes('does-not-exist') || packageName.includes('nonexistent')) { 311 + return { name: packageName, error: 'not_found' } 312 + } 313 + 314 + const fixturePath = getFixturePath('packument', packageName) 315 + const packument = await storage.getItem<any>(fixturePath) 316 + 317 + if (!packument) { 318 + return { name: packageName, error: 'not_found' } 319 + } 320 + 321 + const result: Record<string, unknown> = { 322 + name: packageName, 323 + specifier, 324 + distTags: packument['dist-tags'] || {}, 325 + versions: Object.keys(packument.versions || {}), 326 + time: packument.time || {}, 327 + lastSynced: Date.now(), 328 + } 329 + 330 + if (metadata) { 331 + const versionsMeta: Record<string, Record<string, unknown>> = {} 332 + for (const [ver, data] of Object.entries(packument.versions || {})) { 333 + const meta: Record<string, unknown> = { version: ver } 334 + const vData = data as Record<string, unknown> 335 + if (vData.deprecated) meta.deprecated = vData.deprecated 336 + if (packument.time?.[ver]) meta.time = packument.time[ver] 337 + versionsMeta[ver] = meta 338 + } 339 + result.versionsMeta = versionsMeta 340 + } 341 + 342 + return result 343 + } 344 + 284 345 async function handleFastNpmMeta( 285 346 url: string, 286 347 storage: ReturnType<typeof useStorage>, ··· 296 357 297 358 if (host !== 'npm.antfu.dev') return null 298 359 299 - const pathPart = decodeURIComponent(pathname.slice(1)) 300 - if (!pathPart) return null 360 + const rawPath = decodeURIComponent(pathname.slice(1)) 361 + if (!rawPath) return null 301 362 302 363 const metadata = searchParams.get('metadata') === 'true' 303 364 365 + // Determine if this is a /versions/ request 366 + const isVersions = rawPath.startsWith('versions/') 367 + const pathPart = isVersions ? rawPath.slice('versions/'.length) : rawPath 368 + const processFn = isVersions 369 + ? (pkg: string) => processSingleVersionsMeta(pkg, storage, metadata) 370 + : (pkg: string) => processSingleFastNpmMeta(pkg, storage, metadata) 371 + 304 372 // Handle batch requests (package1+package2+...) 305 373 if (pathPart.includes('+')) { 306 374 const packages = pathPart.split('+') 307 - const results = await Promise.all( 308 - packages.map(pkg => processSingleFastNpmMeta(pkg, storage, metadata)), 309 - ) 375 + const results = await Promise.all(packages.map(processFn)) 310 376 return { data: results } 311 377 } 312 378 313 379 // Handle single package request 314 - const result = await processSingleFastNpmMeta(pathPart, storage, metadata) 380 + const result = await processFn(pathPart) 315 381 if ('error' in result) { 316 382 return { data: null } 317 383 }
+1
nuxt.config.ts
··· 92 92 '/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 93 93 '/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 94 94 '/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 95 + '/api/registry/package-meta/**': { isr: 300 }, 95 96 '/:pkg/.well-known/skills/**': { isr: 3600 }, 96 97 '/:scope/:pkg/.well-known/skills/**': { isr: 3600 }, 97 98 '/__og-image__/**': { isr: getISRConfig(60) },
+118
server/api/registry/package-meta/[...pkg].get.ts
··· 1 + import type { NpmDownloadCount } from '#shared/types' 2 + import { 3 + CACHE_MAX_AGE_FIVE_MINUTES, 4 + ERROR_NPM_FETCH_FAILED, 5 + NPM_API, 6 + } from '#shared/utils/constants' 7 + import { encodePackageName } from '#shared/utils/npm' 8 + 9 + /** 10 + * Returns lightweight package metadata for search results. 11 + * 12 + * Fetches the full packument + weekly downloads server-side, extracts only 13 + * the fields needed for package cards, and returns a small JSON payload. 14 + * This avoids sending the full packument (which can be MBs) to the client. 15 + * 16 + * URL patterns: 17 + * - /api/registry/package-meta/packageName 18 + * - /api/registry/package-meta/@scope/packageName 19 + */ 20 + export default defineCachedEventHandler( 21 + async event => { 22 + const pkgParam = getRouterParam(event, 'pkg') 23 + if (!pkgParam) { 24 + throw createError({ statusCode: 404, message: 'Package name is required' }) 25 + } 26 + 27 + const packageName = decodeURIComponent(pkgParam) 28 + const encodedName = encodePackageName(packageName) 29 + 30 + try { 31 + const [packument, downloads] = await Promise.all([ 32 + fetchNpmPackage(packageName), 33 + $fetch<NpmDownloadCount>(`${NPM_API}/downloads/point/last-week/${encodedName}`).catch( 34 + () => null, 35 + ), 36 + ]) 37 + 38 + const latestVersion = 39 + packument['dist-tags']?.latest || Object.values(packument['dist-tags'] ?? {})[0] || '' 40 + const modified = packument.time?.modified || packument.time?.[latestVersion] || '' 41 + const date = packument.time?.[latestVersion] || modified 42 + 43 + // Extract repository URL from the packument's repository field 44 + // TODO: @npm/types says repository is always an object, but some old 45 + // packages have a bare string in the registry JSON 46 + let repositoryUrl: string | undefined 47 + if (packument.repository) { 48 + const repo = packument.repository as { url?: string } | string 49 + const rawUrl = typeof repo === 'string' ? repo : repo.url 50 + if (rawUrl) { 51 + // Normalize git+https:// and git:// URLs to https:// 52 + repositoryUrl = rawUrl 53 + .replace(/^git\+/, '') 54 + .replace(/^git:\/\//, 'https://') 55 + .replace(/\.git$/, '') 56 + } 57 + } 58 + 59 + // Extract bugs URL 60 + // TODO: @npm/types types bugs as { email?: string; url?: string } on 61 + // packuments, but some old packages store it as a plain URL string 62 + let bugsUrl: string | undefined 63 + if (packument.bugs) { 64 + const bugs = packument.bugs as { url?: string } | string 65 + bugsUrl = typeof bugs === 'string' ? bugs : bugs.url 66 + } 67 + 68 + // Normalize author field to NpmPerson shape 69 + // TODO: @npm/types types author as Contact (object), but some old 70 + // packages store it as a plain string (e.g. "Name <email>") 71 + let author: { name?: string; email?: string; url?: string } | undefined 72 + if (packument.author) { 73 + const a = packument.author as { name?: string; email?: string; url?: string } | string 74 + author = typeof a === 'string' ? { name: a } : { name: a.name, email: a.email, url: a.url } 75 + } 76 + 77 + // Normalize license to a string 78 + // TODO: @npm/types types license as string, but some old packages use 79 + // the deprecated { type, url } object format 80 + const license = packument.license 81 + ? typeof packument.license === 'string' 82 + ? packument.license 83 + : (packument.license as { type: string }).type 84 + : undefined 85 + 86 + return { 87 + name: packument.name, 88 + version: latestVersion, 89 + description: packument.description, 90 + keywords: packument.keywords, 91 + license, 92 + date, 93 + links: { 94 + npm: `https://www.npmjs.com/package/${packument.name}`, 95 + homepage: packument.homepage, 96 + repository: repositoryUrl, 97 + bugs: bugsUrl, 98 + }, 99 + author, 100 + maintainers: packument.maintainers, 101 + weeklyDownloads: downloads?.downloads, 102 + } 103 + } catch (error: unknown) { 104 + handleApiError(error, { 105 + statusCode: 502, 106 + message: ERROR_NPM_FETCH_FAILED, 107 + }) 108 + } 109 + }, 110 + { 111 + maxAge: CACHE_MAX_AGE_FIVE_MINUTES, 112 + swr: true, 113 + getKey: event => { 114 + const pkg = getRouterParam(event, 'pkg') ?? '' 115 + return `package-meta:v1:${pkg}` 116 + }, 117 + }, 118 + )
+23
shared/types/npm-registry.ts
··· 379 379 'time': Record<string, string> 380 380 'maintainers'?: NpmPerson[] 381 381 } 382 + 383 + /** 384 + * Lightweight package metadata returned by /api/registry/package-meta/. 385 + * Contains only the fields needed for search result cards, extracted 386 + * server-side from the full packument + downloads API. 387 + */ 388 + export interface PackageMetaResponse { 389 + name: string 390 + version: string 391 + description?: string 392 + keywords?: string[] 393 + license?: string 394 + date: string 395 + links: { 396 + npm: string 397 + homepage?: string 398 + repository?: string 399 + bugs?: string 400 + } 401 + author?: NpmPerson 402 + maintainers?: NpmPerson[] 403 + weeklyDownloads?: number 404 + }
+99 -24
test/fixtures/mock-routes.cjs
··· 204 204 } 205 205 206 206 /** 207 - * @param {string} urlString 208 - * @returns {MockResponse | null} 207 + * Parse a package query string into name and specifier. 208 + * Handles scoped packages: "@scope/name@specifier" and "name@specifier". 209 + * 210 + * @param {string} query 211 + * @param {string} defaultSpecifier 212 + * @returns {{ name: string; specifier: string }} 209 213 */ 210 - function matchFastNpmMeta(urlString) { 211 - const url = new URL(urlString) 212 - let packageName = decodeURIComponent(url.pathname.slice(1)) 213 - 214 - if (!packageName) return null 215 - 216 - let specifier = 'latest' 217 - if (packageName.startsWith('@')) { 218 - const atIndex = packageName.indexOf('@', 1) 214 + function parsePackageQuery(query, defaultSpecifier) { 215 + let name = query 216 + let specifier = defaultSpecifier 217 + if (name.startsWith('@')) { 218 + const atIndex = name.indexOf('@', 1) 219 219 if (atIndex !== -1) { 220 - specifier = packageName.slice(atIndex + 1) 221 - packageName = packageName.slice(0, atIndex) 220 + specifier = name.slice(atIndex + 1) 221 + name = name.slice(0, atIndex) 222 222 } 223 223 } else { 224 - const atIndex = packageName.indexOf('@') 224 + const atIndex = name.indexOf('@') 225 225 if (atIndex !== -1) { 226 - specifier = packageName.slice(atIndex + 1) 227 - packageName = packageName.slice(0, atIndex) 226 + specifier = name.slice(atIndex + 1) 227 + name = name.slice(0, atIndex) 228 228 } 229 229 } 230 + return { name, specifier } 231 + } 230 232 231 - const packument = readFixture(packageToFixturePath(packageName)) 232 - if (!packument) return null 233 + /** 234 + * Build a latest-version response for a single package (GET /:pkg endpoint). 235 + * 236 + * @param {string} query 237 + * @returns {object} 238 + */ 239 + function resolveSingleLatest(query) { 240 + const { name, specifier } = parsePackageQuery(query, 'latest') 241 + const packument = readFixture(packageToFixturePath(name)) 242 + 243 + if (!packument) { 244 + return { 245 + name, 246 + specifier, 247 + version: '0.0.0', 248 + publishedAt: new Date().toISOString(), 249 + lastSynced: Date.now(), 250 + } 251 + } 233 252 234 253 const distTags = packument['dist-tags'] 235 254 const versions = packument.versions 236 - const time = packument.time 237 255 238 256 let version 239 257 if (specifier === 'latest' || !specifier) { ··· 246 264 version = distTags && distTags.latest 247 265 } 248 266 249 - if (!version) return null 267 + if (!version) { 268 + return { 269 + name, 270 + specifier, 271 + version: '0.0.0', 272 + publishedAt: new Date().toISOString(), 273 + lastSynced: Date.now(), 274 + } 275 + } 250 276 251 - return json({ 252 - name: packageName, 277 + return { 278 + name, 253 279 specifier, 254 280 version, 255 - publishedAt: (time && time[version]) || new Date().toISOString(), 281 + publishedAt: (packument.time && packument.time[version]) || new Date().toISOString(), 282 + lastSynced: Date.now(), 283 + } 284 + } 285 + 286 + /** 287 + * Build a versions response for a single package (GET /versions/:pkg endpoint). 288 + * 289 + * @param {string} query 290 + * @returns {object} 291 + */ 292 + function resolveSingleVersions(query) { 293 + const { name, specifier } = parsePackageQuery(query, '*') 294 + const packument = readFixture(packageToFixturePath(name)) 295 + 296 + if (!packument) { 297 + return { name, error: `"https://registry.npmjs.org/${name}": 404 Not Found` } 298 + } 299 + 300 + return { 301 + name, 302 + specifier, 303 + distTags: packument['dist-tags'] || {}, 304 + versions: Object.keys(packument.versions || {}), 305 + time: packument.time || {}, 256 306 lastSynced: Date.now(), 257 - }) 307 + } 308 + } 309 + 310 + /** 311 + * @param {string} urlString 312 + * @returns {MockResponse | null} 313 + */ 314 + function matchFastNpmMeta(urlString) { 315 + const url = new URL(urlString) 316 + let pathPart = decodeURIComponent(url.pathname.slice(1)) 317 + 318 + if (!pathPart) return null 319 + 320 + // /versions/ endpoint returns version lists (used by getVersionsBatch) 321 + const isVersions = pathPart.startsWith('versions/') 322 + if (isVersions) pathPart = pathPart.slice('versions/'.length) 323 + 324 + const resolveFn = isVersions ? resolveSingleVersions : resolveSingleLatest 325 + 326 + // Batch requests: package1+package2+... 327 + if (pathPart.includes('+')) { 328 + const results = pathPart.split('+').map(resolveFn) 329 + return json(results) 330 + } 331 + 332 + return json(resolveFn(pathPart)) 258 333 } 259 334 260 335 /**
-1
test/nuxt/components/compare/PackageSelector.spec.ts
··· 3 3 import { mountSuspended } from '@nuxt/test-utils/runtime' 4 4 import PackageSelector from '~/components/Compare/PackageSelector.vue' 5 5 6 - // Mock $fetch for useNpmSearch 7 6 const mockFetch = vi.fn() 8 7 vi.stubGlobal('$fetch', mockFetch) 9 8