diff --git a/app/components/Compare/PackageSelector.vue b/app/components/Compare/PackageSelector.vue index 9c31395e7..28752bbaf 100644 --- a/app/components/Compare/PackageSelector.vue +++ b/app/components/Compare/PackageSelector.vue @@ -14,8 +14,8 @@ const maxPackages = computed(() => props.max ?? 4) const inputValue = shallowRef('') const isInputFocused = shallowRef(false) -// Use the shared npm search composable -const { data: searchData, status } = useNpmSearch(inputValue, { size: 15 }) +// Use the shared search composable (supports both npm and Algolia providers) +const { data: searchData, status } = useSearch(inputValue, { size: 15 }) const isSearching = computed(() => status.value === 'pending') diff --git a/app/composables/npm/search-utils.ts b/app/composables/npm/search-utils.ts new file mode 100644 index 000000000..f7fda1445 --- /dev/null +++ b/app/composables/npm/search-utils.ts @@ -0,0 +1,33 @@ +import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#shared/types' + +/** + * Convert a lightweight package-meta API response to a search result for display. + */ +export function metaToSearchResult(meta: PackageMetaResponse): NpmSearchResult { + return { + package: { + name: meta.name, + version: meta.version, + description: meta.description, + keywords: meta.keywords, + license: meta.license, + date: meta.date, + links: meta.links, + author: meta.author, + maintainers: meta.maintainers, + }, + score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } }, + searchScore: 0, + downloads: meta.weeklyDownloads !== undefined ? { weekly: meta.weeklyDownloads } : undefined, + updated: meta.date, + } +} + +export function emptySearchResponse(): NpmSearchResponse { + return { + objects: [], + total: 0, + isStale: false, + time: new Date().toISOString(), + } +} diff --git a/app/composables/npm/useNpmSearch.ts b/app/composables/npm/useNpmSearch.ts index ca3504770..cb4b1ef28 100644 --- a/app/composables/npm/useNpmSearch.ts +++ b/app/composables/npm/useNpmSearch.ts @@ -1,357 +1,81 @@ -import type { - Packument, - NpmSearchResponse, - NpmSearchResult, - NpmDownloadCount, - MinimalPackument, -} from '#shared/types' -import type { SearchProvider } from '~/composables/useSettings' - -/** - * Convert packument to search result format for display - */ -export function packumentToSearchResult( - pkg: MinimalPackument, - weeklyDownloads?: number, -): NpmSearchResult { - let latestVersion = '' - if (pkg['dist-tags']) { - latestVersion = pkg['dist-tags'].latest || Object.values(pkg['dist-tags'])[0] || '' - } - const modified = pkg.time.modified || pkg.time[latestVersion] || '' - - return { - package: { - name: pkg.name, - version: latestVersion, - description: pkg.description, - keywords: pkg.keywords, - date: pkg.time[latestVersion] || modified, - links: { - npm: `https://www.npmjs.com/package/${pkg.name}`, - }, - maintainers: pkg.maintainers, - }, - score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } }, - searchScore: 0, - downloads: weeklyDownloads !== undefined ? { weekly: weeklyDownloads } : undefined, - updated: pkg.time[latestVersion] || modified, - } -} +import type { NpmSearchResponse, PackageMetaResponse } from '#shared/types' +import { emptySearchResponse, metaToSearchResult } from './search-utils' export interface NpmSearchOptions { - /** Number of results to fetch */ + /** Number of results */ size?: number + /** Offset for pagination */ + from?: number } -export const emptySearchResponse = { - objects: [], - total: 0, - isStale: false, - time: new Date().toISOString(), -} satisfies NpmSearchResponse - -export function useNpmSearch( - query: MaybeRefOrGetter, - options: MaybeRefOrGetter = {}, -) { +/** + * Composable that provides npm registry search functions. + * + * Mirrors the API shape of `useAlgoliaSearch` so that `useSearch` can + * swap between providers without branching on implementation details. + * + * Must be called during component setup (or inside another composable) + * because it reads from `useNuxtApp()`. The returned functions are safe + * to call at any time (event handlers, async callbacks, etc.). + */ +export function useNpmSearch() { const { $npmRegistry } = useNuxtApp() - const { searchProvider } = useSearchProvider() - const { search: searchAlgolia } = useAlgoliaSearch() - - // Client-side cache - const cache = shallowRef<{ - query: string - provider: SearchProvider - objects: NpmSearchResult[] - total: number - } | null>(null) - - const isLoadingMore = shallowRef(false) - - // Track rate limit errors separately for better UX - // Using ref instead of shallowRef to ensure reactivity triggers properly - const isRateLimited = ref(false) - - // Standard (non-incremental) search implementation - let lastSearch: NpmSearchResponse | undefined = undefined - - const asyncData = useLazyAsyncData( - () => `search:${searchProvider.value}:${toValue(query)}`, - async ({ $npmRegistry, $npmApi }, { signal }) => { - const q = toValue(query) - const provider = searchProvider.value - - if (!q.trim()) { - isRateLimited.value = false - return emptySearchResponse - } - - const opts = toValue(options) - - // This only runs for initial load or query changes - // Reset cache for new query (but don't reset rate limit yet - only on success) - cache.value = null - - // --- Algolia path (client-side only) --- - if (provider === 'algolia') { - const response = await searchAlgolia(q, { - size: opts.size ?? 25, - }) - - if (q !== toValue(query)) { - return emptySearchResponse - } - - isRateLimited.value = false - - cache.value = { - query: q, - provider, - objects: response.objects, - total: response.total, - } - - return response - } - - // --- npm registry path --- - const params = new URLSearchParams() - params.set('text', q) - params.set('size', String(opts.size ?? 25)) + /** + * Search npm packages via the npm registry API. + * Returns results in the same `NpmSearchResponse` format as `useAlgoliaSearch`. + * + * Single-character queries are handled specially: they fetch lightweight + * metadata from a server-side proxy instead of a search, because the + * search API returns poor results for single-char terms. The proxy + * fetches the full packument + download counts server-side and returns + * only the fields needed for package cards. + */ + async function search( + query: string, + options: NpmSearchOptions = {}, + signal?: AbortSignal, + ): Promise { + // Single-character: fetch lightweight metadata via server proxy + if (query.length === 1) { try { - if (q.length === 1) { - const encodedName = encodePackageName(q) - const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([ - $npmRegistry(`/${encodedName}`, { signal }), - $npmApi(`/downloads/point/last-week/${encodedName}`, { - signal, - }), - ]) - - if (!pkg) { - return emptySearchResponse - } - - const result = packumentToSearchResult(pkg, downloads?.downloads) - - // If query changed/outdated, return empty search response - if (q !== toValue(query)) { - return emptySearchResponse - } - - cache.value = { - query: q, - provider, - objects: [result], - total: 1, - } - - // Success - clear rate limit flag - isRateLimited.value = false - - return { - objects: [result], - total: 1, - isStale, - time: new Date().toISOString(), - } - } - - const { data: response, isStale } = await $npmRegistry( - `/-/v1/search?${params.toString()}`, + const meta = await $fetch( + `/api/registry/package-meta/${encodePackageName(query)}`, { signal }, - 60, ) - // If query changed/outdated, return empty search response - if (q !== toValue(query)) { - return emptySearchResponse - } - - cache.value = { - query: q, - provider, - objects: response.objects, - total: response.total, - } - - // Success - clear rate limit flag - isRateLimited.value = false - - return { ...response, isStale } - } catch (error: unknown) { - // Detect rate limit errors. npm's 429 response doesn't include CORS headers, - // so the browser reports "Failed to fetch" instead of the actual status code. - const errorMessage = (error as { message?: string })?.message || String(error) - const isRateLimitError = - errorMessage.includes('Failed to fetch') || errorMessage.includes('429') - - if (isRateLimitError) { - isRateLimited.value = true - return emptySearchResponse - } - throw error - } - }, - { default: () => lastSearch || emptySearchResponse }, - ) - - // Fetch more results incrementally - async function fetchMore(targetSize: number): Promise { - const q = toValue(query).trim() - const provider = searchProvider.value - - if (!q) { - cache.value = null - return - } - - // If query or provider changed, reset cache - if (cache.value && (cache.value.query !== q || cache.value.provider !== provider)) { - cache.value = null - await asyncData.refresh() - return - } - - const currentCount = cache.value?.objects.length ?? 0 - const total = cache.value?.total ?? Infinity + const result = metaToSearchResult(meta) - // Already have enough or no more to fetch - if (currentCount >= targetSize || currentCount >= total) { - return - } - - isLoadingMore.value = true - - try { - const from = currentCount - const size = Math.min(targetSize - currentCount, total - currentCount) - - if (provider === 'algolia') { - // Algolia incremental fetch - const response = await searchAlgolia(q, { size, from }) - - if (cache.value && cache.value.query === q && cache.value.provider === provider) { - const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) - const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) - cache.value = { - query: q, - provider, - objects: [...cache.value.objects, ...newObjects], - total: response.total, - } - } else { - cache.value = { - query: q, - provider, - objects: response.objects, - total: response.total, - } - } - } else { - // npm registry incremental fetch - const params = new URLSearchParams() - params.set('text', q) - params.set('size', String(size)) - params.set('from', String(from)) - - const { data: response } = await $npmRegistry( - `/-/v1/search?${params.toString()}`, - {}, - 60, - ) - - if (cache.value && cache.value.query === q && cache.value.provider === provider) { - const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) - const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) - cache.value = { - query: q, - provider, - objects: [...cache.value.objects, ...newObjects], - total: response.total, - } - } else { - cache.value = { - query: q, - provider, - objects: response.objects, - total: response.total, - } + return { + objects: [result], + total: 1, + isStale: false, + time: new Date().toISOString(), } + } catch { + return emptySearchResponse() } - - // If we still need more, fetch again recursively - if ( - cache.value && - cache.value.objects.length < targetSize && - cache.value.objects.length < cache.value.total - ) { - await fetchMore(targetSize) - } - } finally { - isLoadingMore.value = false } - } - - // Watch for size increases - watch( - () => toValue(options).size, - async (newSize, oldSize) => { - if (!newSize) return - if (oldSize && newSize > oldSize && toValue(query).trim()) { - await fetchMore(newSize) - } - }, - ) - // Re-search when provider changes - watch(searchProvider, async () => { - cache.value = null - await asyncData.refresh() - const targetSize = toValue(options).size - if (targetSize) { - await fetchMore(targetSize) + // Standard search + const params = new URLSearchParams() + params.set('text', query) + params.set('size', String(options.size ?? 25)) + if (options.from) { + params.set('from', String(options.from)) } - }) - // Computed data that uses cache - const data = computed(() => { - if (cache.value) { - return { - isStale: false, - objects: cache.value.objects, - total: cache.value.total, - time: new Date().toISOString(), - } - } - return asyncData.data.value - }) + const { data: response, isStale } = await $npmRegistry( + `/-/v1/search?${params.toString()}`, + { signal }, + 60, + ) - if (import.meta.client && asyncData.data.value?.isStale) { - onMounted(() => { - asyncData.refresh() - }) + return { ...response, isStale } } - // Whether there are more results available - const hasMore = computed(() => { - if (!cache.value) return true - return cache.value.objects.length < cache.value.total - }) - return { - ...asyncData, - /** Reactive search results (uses cache in incremental mode) */ - data, - /** Whether currently loading more results */ - isLoadingMore, - /** Whether there are more results available */ - hasMore, - /** Manually fetch more results up to target size */ - fetchMore, - /** Whether the search was rate limited by npm (429 error) */ - isRateLimited: readonly(isRateLimited), + /** Search packages by text query */ + search, } } diff --git a/app/composables/npm/useOrgPackages.ts b/app/composables/npm/useOrgPackages.ts index 7a392f87b..038d0712a 100644 --- a/app/composables/npm/useOrgPackages.ts +++ b/app/composables/npm/useOrgPackages.ts @@ -1,5 +1,5 @@ -import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types' -import { emptySearchResponse, packumentToSearchResult } from './useNpmSearch' +import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#shared/types' +import { emptySearchResponse, metaToSearchResult } from './search-utils' import { mapWithConcurrency } from '#shared/utils/async' /** @@ -7,7 +7,7 @@ import { mapWithConcurrency } from '#shared/utils/async' * * 1. Gets the authoritative package list from the npm registry (single request) * 2. Fetches metadata from Algolia by exact name (single request) - * 3. Falls back to individual packument fetches when Algolia is unavailable + * 3. Falls back to lightweight server-side package-meta lookups */ export function useOrgPackages(orgName: MaybeRefOrGetter) { const { searchProvider } = useSearchProvider() @@ -15,10 +15,10 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { const asyncData = useLazyAsyncData( () => `org-packages:${searchProvider.value}:${toValue(orgName)}`, - async ({ $npmRegistry, ssrContext }, { signal }) => { + async ({ ssrContext }, { signal }) => { const org = toValue(orgName) if (!org) { - return emptySearchResponse + return emptySearchResponse() } // Get the authoritative package list from the npm registry (single request) @@ -47,7 +47,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { } if (packageNames.length === 0) { - return emptySearchResponse + return emptySearchResponse() } // Fetch metadata + downloads from Algolia (single request via getObjects) @@ -62,16 +62,15 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { } } - // npm fallback: fetch packuments individually - const packuments = await mapWithConcurrency( + // npm fallback: fetch lightweight metadata via server proxy + const metaResults = await mapWithConcurrency( packageNames, async name => { try { - const encoded = encodePackageName(name) - const { data: pkg } = await $npmRegistry(`/${encoded}`, { - signal, - }) - return pkg + return await $fetch( + `/api/registry/package-meta/${encodePackageName(name)}`, + { signal }, + ) } catch { return null } @@ -79,11 +78,9 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { 10, ) - const validPackuments = packuments.filter( - (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'], - ) - - const results: NpmSearchResult[] = validPackuments.map(pkg => packumentToSearchResult(pkg)) + const results: NpmSearchResult[] = metaResults + .filter((meta): meta is PackageMetaResponse => meta !== null) + .map(metaToSearchResult) return { isStale: false, @@ -92,7 +89,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { time: new Date().toISOString(), } satisfies NpmSearchResponse }, - { default: () => emptySearchResponse }, + { default: emptySearchResponse }, ) return asyncData diff --git a/app/composables/npm/useOutdatedDependencies.ts b/app/composables/npm/useOutdatedDependencies.ts index d5beb093b..37ee65c6e 100644 --- a/app/composables/npm/useOutdatedDependencies.ts +++ b/app/composables/npm/useOutdatedDependencies.ts @@ -1,50 +1,19 @@ -import type { NuxtApp } from '#app' +import type { PackageVersionsInfo } from 'fast-npm-meta' +import { getVersionsBatch } from 'fast-npm-meta' import { maxSatisfying, prerelease, major, minor, diff, gt } from 'semver' -import type { Packument } from '#shared/types' -import { mapWithConcurrency } from '#shared/utils/async' -import type { CachedFetchFunction } from '#shared/utils/fetch-cache-config' import { type OutdatedDependencyInfo, isNonSemverConstraint, constraintIncludesPrerelease, } from '~/utils/npm/outdated-dependencies' -// Cache for packument fetches to avoid duplicate requests across components -const packumentCache = new Map>() +const BATCH_SIZE = 50 -/** - * Check if a dependency is outdated. - * Returns null if up-to-date or if we can't determine. - */ -async function checkDependencyOutdated( - cachedFetch: CachedFetchFunction, - $npmRegistry: NuxtApp['$npmRegistry'], - packageName: string, +function resolveOutdated( + versions: string[], + latestTag: string, constraint: string, -): Promise { - if (isNonSemverConstraint(constraint)) { - return null - } - - // Check in-memory cache first - let packument: Packument | null - const cached = packumentCache.get(packageName) - if (cached) { - packument = await cached - } else { - const promise = $npmRegistry(`/${encodePackageName(packageName)}`) - .then(({ data }) => data) - .catch(() => null) - packumentCache.set(packageName, promise) - packument = await promise - } - - if (!packument) return null - - const latestTag = packument['dist-tags']?.latest - if (!latestTag) return null - - // Handle "latest" constraint specially - return info with current version +): OutdatedDependencyInfo | null { if (constraint === 'latest') { return { resolved: latestTag, @@ -55,20 +24,17 @@ async function checkDependencyOutdated( } } - let versions = Object.keys(packument.versions) - const includesPrerelease = constraintIncludesPrerelease(constraint) - - if (!includesPrerelease) { - versions = versions.filter(v => !prerelease(v)) + let filteredVersions = versions + if (!constraintIncludesPrerelease(constraint)) { + filteredVersions = versions.filter(v => !prerelease(v)) } - const resolved = maxSatisfying(versions, constraint) + const resolved = maxSatisfying(filteredVersions, constraint) if (!resolved) return null if (resolved === latestTag) return null - // If resolved version is newer than latest, not outdated - // (e.g., using ^2.0.0-rc when latest is 1.x) + // Resolved is newer than latest (e.g. ^2.0.0-rc when latest is 1.x) if (gt(resolved, latestTag)) { return null } @@ -87,14 +53,12 @@ async function checkDependencyOutdated( } /** - * Composable to check for outdated dependencies. + * Check for outdated dependencies via fast-npm-meta batch version lookups. * Returns a reactive map of dependency name to outdated info. */ export function useOutdatedDependencies( dependencies: MaybeRefOrGetter | undefined>, ) { - const { $npmRegistry } = useNuxtApp() - const cachedFetch = useCachedFetch() const outdated = shallowRef>({}) async function fetchOutdatedInfo(deps: Record | undefined) { @@ -103,18 +67,42 @@ export function useOutdatedDependencies( return } - const entries = Object.entries(deps) - const batchResults = await mapWithConcurrency( - entries, - async ([name, constraint]) => { - const info = await checkDependencyOutdated(cachedFetch, $npmRegistry, name, constraint) - return [name, info] as const - }, - 5, + const semverEntries = Object.entries(deps).filter( + ([, constraint]) => !isNonSemverConstraint(constraint), + ) + + if (semverEntries.length === 0) { + outdated.value = {} + return + } + + const packageNames = semverEntries.map(([name]) => name) + + const chunks: string[][] = [] + for (let i = 0; i < packageNames.length; i += BATCH_SIZE) { + chunks.push(packageNames.slice(i, i + BATCH_SIZE)) + } + const batchResults = await Promise.all( + chunks.map(chunk => getVersionsBatch(chunk, { throw: false })), ) + const allVersionData = batchResults.flat() + + // Build a lookup map from package name to version data + const versionMap = new Map() + for (const data of allVersionData) { + if ('error' in data) continue + versionMap.set(data.name, data) + } const results: Record = {} - for (const [name, info] of batchResults) { + for (const [name, constraint] of semverEntries) { + const data = versionMap.get(name) + if (!data) continue + + const latestTag = data.distTags.latest + if (!latestTag) continue + + const info = resolveOutdated(data.versions, latestTag, constraint) if (info) { results[name] = info } @@ -126,7 +114,9 @@ export function useOutdatedDependencies( watch( () => toValue(dependencies), deps => { - fetchOutdatedInfo(deps) + fetchOutdatedInfo(deps).catch(() => { + // Network failure or fast-npm-meta outage — leave stale results in place + }) }, { immediate: true }, ) diff --git a/app/composables/npm/useSearch.ts b/app/composables/npm/useSearch.ts new file mode 100644 index 000000000..54522754b --- /dev/null +++ b/app/composables/npm/useSearch.ts @@ -0,0 +1,214 @@ +import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' +import type { SearchProvider } from '~/composables/useSettings' +import { emptySearchResponse } from './search-utils' + +export interface SearchOptions { + /** Number of results to fetch */ + size?: number +} + +export function useSearch( + query: MaybeRefOrGetter, + options: MaybeRefOrGetter = {}, +) { + const { searchProvider } = useSearchProvider() + const { search: searchAlgolia } = useAlgoliaSearch() + const { search: searchNpm } = useNpmSearch() + + const cache = shallowRef<{ + query: string + provider: SearchProvider + objects: NpmSearchResult[] + total: number + } | null>(null) + + const isLoadingMore = shallowRef(false) + + const isRateLimited = ref(false) + + const asyncData = useLazyAsyncData( + () => `search:${searchProvider.value}:${toValue(query)}`, + async (_nuxtApp, { signal }) => { + const q = toValue(query) + const provider = searchProvider.value + + if (!q.trim()) { + isRateLimited.value = false + return emptySearchResponse() + } + + const opts = toValue(options) + + cache.value = null + + if (provider === 'algolia') { + const response = await searchAlgolia(q, { + size: opts.size ?? 25, + }) + + if (q !== toValue(query)) { + return emptySearchResponse() + } + + isRateLimited.value = false + + cache.value = { + query: q, + provider, + objects: response.objects, + total: response.total, + } + + return response + } + + try { + const response = await searchNpm(q, { size: opts.size ?? 25 }, signal) + + if (q !== toValue(query)) { + return emptySearchResponse() + } + + cache.value = { + query: q, + provider, + objects: response.objects, + total: response.total, + } + + isRateLimited.value = false + + return response + } catch (error: unknown) { + // npm 429 responses lack CORS headers, so the browser reports "Failed to fetch" + const errorMessage = (error as { message?: string })?.message || String(error) + const isRateLimitError = + errorMessage.includes('Failed to fetch') || errorMessage.includes('429') + + if (isRateLimitError) { + isRateLimited.value = true + return emptySearchResponse() + } + throw error + } + }, + { default: emptySearchResponse }, + ) + + async function fetchMore(targetSize: number): Promise { + const q = toValue(query).trim() + const provider = searchProvider.value + + if (!q) { + cache.value = null + return + } + + if (cache.value && (cache.value.query !== q || cache.value.provider !== provider)) { + cache.value = null + await asyncData.refresh() + return + } + + const currentCount = cache.value?.objects.length ?? 0 + const total = cache.value?.total ?? Infinity + + if (currentCount >= targetSize || currentCount >= total) { + return + } + + isLoadingMore.value = true + + try { + const from = currentCount + const size = Math.min(targetSize - currentCount, total - currentCount) + + const doSearch = provider === 'algolia' ? searchAlgolia : searchNpm + const response = await doSearch(q, { size, from }) + + if (cache.value && cache.value.query === q && cache.value.provider === provider) { + const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) + const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) + cache.value = { + query: q, + provider, + objects: [...cache.value.objects, ...newObjects], + total: response.total, + } + } else { + cache.value = { + query: q, + provider, + objects: response.objects, + total: response.total, + } + } + + if ( + cache.value && + cache.value.objects.length < targetSize && + cache.value.objects.length < cache.value.total + ) { + await fetchMore(targetSize) + } + } finally { + isLoadingMore.value = false + } + } + + watch( + () => toValue(options).size, + async (newSize, oldSize) => { + if (!newSize) return + if (oldSize && newSize > oldSize && toValue(query).trim()) { + await fetchMore(newSize) + } + }, + ) + + watch(searchProvider, async () => { + cache.value = null + await asyncData.refresh() + const targetSize = toValue(options).size + if (targetSize) { + await fetchMore(targetSize) + } + }) + + const data = computed(() => { + if (cache.value) { + return { + isStale: false, + objects: cache.value.objects, + total: cache.value.total, + time: new Date().toISOString(), + } + } + return asyncData.data.value + }) + + if (import.meta.client && asyncData.data.value?.isStale) { + onMounted(() => { + asyncData.refresh() + }) + } + + const hasMore = computed(() => { + if (!cache.value) return true + return cache.value.objects.length < cache.value.total + }) + + return { + ...asyncData, + /** Reactive search results (uses cache in incremental mode) */ + data, + /** Whether currently loading more results */ + isLoadingMore, + /** Whether there are more results available */ + hasMore, + /** Manually fetch more results up to target size */ + fetchMore, + /** Whether the search was rate limited by npm (429 error) */ + isRateLimited: readonly(isRateLimited), + } +} diff --git a/app/composables/npm/useUserPackages.ts b/app/composables/npm/useUserPackages.ts index 1855a7624..c42f53064 100644 --- a/app/composables/npm/useUserPackages.ts +++ b/app/composables/npm/useUserPackages.ts @@ -1,5 +1,5 @@ import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' -import { emptySearchResponse } from './useNpmSearch' +import { emptySearchResponse } from './search-utils' /** Default page size for incremental loading (npm registry path) */ const PAGE_SIZE = 50 as const @@ -47,7 +47,7 @@ export function useUserPackages(username: MaybeRefOrGetter) { async ({ $npmRegistry }, { signal }) => { const user = toValue(username) if (!user) { - return emptySearchResponse + return emptySearchResponse() } const provider = searchProvider.value @@ -59,7 +59,7 @@ export function useUserPackages(username: MaybeRefOrGetter) { // Guard against stale response (user/provider changed during await) if (user !== toValue(username) || provider !== searchProvider.value) { - return emptySearchResponse + return emptySearchResponse() } // If Algolia returns results, use them. If empty, fall through to npm @@ -96,7 +96,7 @@ export function useUserPackages(username: MaybeRefOrGetter) { // Guard against stale response (user/provider changed during await) if (user !== toValue(username) || provider !== searchProvider.value) { - return emptySearchResponse + return emptySearchResponse() } cache.value = { @@ -107,7 +107,7 @@ export function useUserPackages(username: MaybeRefOrGetter) { return { ...response, isStale } }, - { default: () => emptySearchResponse }, + { default: emptySearchResponse }, ) // --- Fetch more (npm path only) --- /** diff --git a/app/pages/search.vue b/app/pages/search.vue index a44d8af13..297423e91 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -192,7 +192,7 @@ const { hasMore, fetchMore, isRateLimited, -} = useNpmSearch(query, () => ({ +} = useSearch(query, () => ({ size: requestedSize.value, })) diff --git a/modules/runtime/server/cache.ts b/modules/runtime/server/cache.ts index 1152a9cfa..c27c6df25 100644 --- a/modules/runtime/server/cache.ts +++ b/modules/runtime/server/cache.ts @@ -281,6 +281,67 @@ async function processSingleFastNpmMeta( return result } +/** + * Process a single package for the /versions/ endpoint. + * Returns PackageVersionsInfo shape: { name, distTags, versions, specifier, time, lastSynced } + */ +async function processSingleVersionsMeta( + packageQuery: string, + storage: ReturnType, + metadata: boolean, +): Promise> { + let packageName = packageQuery + let specifier = '*' + + if (packageName.startsWith('@')) { + const atIndex = packageName.indexOf('@', 1) + if (atIndex !== -1) { + specifier = packageName.slice(atIndex + 1) + packageName = packageName.slice(0, atIndex) + } + } else { + const atIndex = packageName.indexOf('@') + if (atIndex !== -1) { + specifier = packageName.slice(atIndex + 1) + packageName = packageName.slice(0, atIndex) + } + } + + if (packageName.includes('does-not-exist') || packageName.includes('nonexistent')) { + return { name: packageName, error: 'not_found' } + } + + const fixturePath = getFixturePath('packument', packageName) + const packument = await storage.getItem(fixturePath) + + if (!packument) { + return { name: packageName, error: 'not_found' } + } + + const result: Record = { + name: packageName, + specifier, + distTags: packument['dist-tags'] || {}, + versions: Object.keys(packument.versions || {}), + time: packument.time || {}, + lastSynced: Date.now(), + } + + if (metadata) { + const versionsMeta: Record> = {} + for (const [ver, data] of Object.entries(packument.versions || {})) { + const meta: Record = { version: ver } + const vData = data as Record + if (vData.deprecated) meta.deprecated = vData.deprecated + if (packument.time?.[ver]) meta.time = packument.time[ver] + versionsMeta[ver] = meta + } + result.versionsMeta = versionsMeta + } + + return result +} + async function handleFastNpmMeta( url: string, storage: ReturnType, @@ -296,22 +357,27 @@ async function handleFastNpmMeta( if (host !== 'npm.antfu.dev') return null - const pathPart = decodeURIComponent(pathname.slice(1)) - if (!pathPart) return null + const rawPath = decodeURIComponent(pathname.slice(1)) + if (!rawPath) return null const metadata = searchParams.get('metadata') === 'true' + // Determine if this is a /versions/ request + const isVersions = rawPath.startsWith('versions/') + const pathPart = isVersions ? rawPath.slice('versions/'.length) : rawPath + const processFn = isVersions + ? (pkg: string) => processSingleVersionsMeta(pkg, storage, metadata) + : (pkg: string) => processSingleFastNpmMeta(pkg, storage, metadata) + // Handle batch requests (package1+package2+...) if (pathPart.includes('+')) { const packages = pathPart.split('+') - const results = await Promise.all( - packages.map(pkg => processSingleFastNpmMeta(pkg, storage, metadata)), - ) + const results = await Promise.all(packages.map(processFn)) return { data: results } } // Handle single package request - const result = await processSingleFastNpmMeta(pathPart, storage, metadata) + const result = await processFn(pathPart) if ('error' in result) { return { data: null } } diff --git a/nuxt.config.ts b/nuxt.config.ts index 753a467b6..fcea735ba 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -92,6 +92,7 @@ export default defineNuxtConfig({ '/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, + '/api/registry/package-meta/**': { isr: 300 }, '/:pkg/.well-known/skills/**': { isr: 3600 }, '/:scope/:pkg/.well-known/skills/**': { isr: 3600 }, '/__og-image__/**': { isr: getISRConfig(60) }, diff --git a/server/api/registry/package-meta/[...pkg].get.ts b/server/api/registry/package-meta/[...pkg].get.ts new file mode 100644 index 000000000..a59b91f5a --- /dev/null +++ b/server/api/registry/package-meta/[...pkg].get.ts @@ -0,0 +1,118 @@ +import type { NpmDownloadCount } from '#shared/types' +import { + CACHE_MAX_AGE_FIVE_MINUTES, + ERROR_NPM_FETCH_FAILED, + NPM_API, +} from '#shared/utils/constants' +import { encodePackageName } from '#shared/utils/npm' + +/** + * Returns lightweight package metadata for search results. + * + * Fetches the full packument + weekly downloads server-side, extracts only + * the fields needed for package cards, and returns a small JSON payload. + * This avoids sending the full packument (which can be MBs) to the client. + * + * URL patterns: + * - /api/registry/package-meta/packageName + * - /api/registry/package-meta/@scope/packageName + */ +export default defineCachedEventHandler( + async event => { + const pkgParam = getRouterParam(event, 'pkg') + if (!pkgParam) { + throw createError({ statusCode: 404, message: 'Package name is required' }) + } + + const packageName = decodeURIComponent(pkgParam) + const encodedName = encodePackageName(packageName) + + try { + const [packument, downloads] = await Promise.all([ + fetchNpmPackage(packageName), + $fetch(`${NPM_API}/downloads/point/last-week/${encodedName}`).catch( + () => null, + ), + ]) + + const latestVersion = + packument['dist-tags']?.latest || Object.values(packument['dist-tags'] ?? {})[0] || '' + const modified = packument.time?.modified || packument.time?.[latestVersion] || '' + const date = packument.time?.[latestVersion] || modified + + // Extract repository URL from the packument's repository field + // TODO: @npm/types says repository is always an object, but some old + // packages have a bare string in the registry JSON + let repositoryUrl: string | undefined + if (packument.repository) { + const repo = packument.repository as { url?: string } | string + const rawUrl = typeof repo === 'string' ? repo : repo.url + if (rawUrl) { + // Normalize git+https:// and git:// URLs to https:// + repositoryUrl = rawUrl + .replace(/^git\+/, '') + .replace(/^git:\/\//, 'https://') + .replace(/\.git$/, '') + } + } + + // Extract bugs URL + // TODO: @npm/types types bugs as { email?: string; url?: string } on + // packuments, but some old packages store it as a plain URL string + let bugsUrl: string | undefined + if (packument.bugs) { + const bugs = packument.bugs as { url?: string } | string + bugsUrl = typeof bugs === 'string' ? bugs : bugs.url + } + + // Normalize author field to NpmPerson shape + // TODO: @npm/types types author as Contact (object), but some old + // packages store it as a plain string (e.g. "Name ") + let author: { name?: string; email?: string; url?: string } | undefined + if (packument.author) { + const a = packument.author as { name?: string; email?: string; url?: string } | string + author = typeof a === 'string' ? { name: a } : { name: a.name, email: a.email, url: a.url } + } + + // Normalize license to a string + // TODO: @npm/types types license as string, but some old packages use + // the deprecated { type, url } object format + const license = packument.license + ? typeof packument.license === 'string' + ? packument.license + : (packument.license as { type: string }).type + : undefined + + return { + name: packument.name, + version: latestVersion, + description: packument.description, + keywords: packument.keywords, + license, + date, + links: { + npm: `https://www.npmjs.com/package/${packument.name}`, + homepage: packument.homepage, + repository: repositoryUrl, + bugs: bugsUrl, + }, + author, + maintainers: packument.maintainers, + weeklyDownloads: downloads?.downloads, + } + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_NPM_FETCH_FAILED, + }) + } + }, + { + maxAge: CACHE_MAX_AGE_FIVE_MINUTES, + swr: true, + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `package-meta:v1:${pkg}` + }, + }, +) diff --git a/shared/types/npm-registry.ts b/shared/types/npm-registry.ts index f0652d8f6..dc1f25f06 100644 --- a/shared/types/npm-registry.ts +++ b/shared/types/npm-registry.ts @@ -379,3 +379,26 @@ export interface MinimalPackument { 'time': Record 'maintainers'?: NpmPerson[] } + +/** + * Lightweight package metadata returned by /api/registry/package-meta/. + * Contains only the fields needed for search result cards, extracted + * server-side from the full packument + downloads API. + */ +export interface PackageMetaResponse { + name: string + version: string + description?: string + keywords?: string[] + license?: string + date: string + links: { + npm: string + homepage?: string + repository?: string + bugs?: string + } + author?: NpmPerson + maintainers?: NpmPerson[] + weeklyDownloads?: number +} diff --git a/test/fixtures/mock-routes.cjs b/test/fixtures/mock-routes.cjs index 10c1886ff..d97915bf7 100644 --- a/test/fixtures/mock-routes.cjs +++ b/test/fixtures/mock-routes.cjs @@ -204,36 +204,54 @@ function matchOsvApi(urlString) { } /** - * @param {string} urlString - * @returns {MockResponse | null} + * Parse a package query string into name and specifier. + * Handles scoped packages: "@scope/name@specifier" and "name@specifier". + * + * @param {string} query + * @param {string} defaultSpecifier + * @returns {{ name: string; specifier: string }} */ -function matchFastNpmMeta(urlString) { - const url = new URL(urlString) - let packageName = decodeURIComponent(url.pathname.slice(1)) - - if (!packageName) return null - - let specifier = 'latest' - if (packageName.startsWith('@')) { - const atIndex = packageName.indexOf('@', 1) +function parsePackageQuery(query, defaultSpecifier) { + let name = query + let specifier = defaultSpecifier + if (name.startsWith('@')) { + const atIndex = name.indexOf('@', 1) if (atIndex !== -1) { - specifier = packageName.slice(atIndex + 1) - packageName = packageName.slice(0, atIndex) + specifier = name.slice(atIndex + 1) + name = name.slice(0, atIndex) } } else { - const atIndex = packageName.indexOf('@') + const atIndex = name.indexOf('@') if (atIndex !== -1) { - specifier = packageName.slice(atIndex + 1) - packageName = packageName.slice(0, atIndex) + specifier = name.slice(atIndex + 1) + name = name.slice(0, atIndex) } } + return { name, specifier } +} + +/** + * Build a latest-version response for a single package (GET /:pkg endpoint). + * + * @param {string} query + * @returns {object} + */ +function resolveSingleLatest(query) { + const { name, specifier } = parsePackageQuery(query, 'latest') + const packument = readFixture(packageToFixturePath(name)) - const packument = readFixture(packageToFixturePath(packageName)) - if (!packument) return null + if (!packument) { + return { + name, + specifier, + version: '0.0.0', + publishedAt: new Date().toISOString(), + lastSynced: Date.now(), + } + } const distTags = packument['dist-tags'] const versions = packument.versions - const time = packument.time let version if (specifier === 'latest' || !specifier) { @@ -246,15 +264,72 @@ function matchFastNpmMeta(urlString) { version = distTags && distTags.latest } - if (!version) return null + if (!version) { + return { + name, + specifier, + version: '0.0.0', + publishedAt: new Date().toISOString(), + lastSynced: Date.now(), + } + } - return json({ - name: packageName, + return { + name, specifier, version, - publishedAt: (time && time[version]) || new Date().toISOString(), + publishedAt: (packument.time && packument.time[version]) || new Date().toISOString(), lastSynced: Date.now(), - }) + } +} + +/** + * Build a versions response for a single package (GET /versions/:pkg endpoint). + * + * @param {string} query + * @returns {object} + */ +function resolveSingleVersions(query) { + const { name, specifier } = parsePackageQuery(query, '*') + const packument = readFixture(packageToFixturePath(name)) + + if (!packument) { + return { name, error: `"https://registry.npmjs.org/${name}": 404 Not Found` } + } + + return { + name, + specifier, + distTags: packument['dist-tags'] || {}, + versions: Object.keys(packument.versions || {}), + time: packument.time || {}, + lastSynced: Date.now(), + } +} + +/** + * @param {string} urlString + * @returns {MockResponse | null} + */ +function matchFastNpmMeta(urlString) { + const url = new URL(urlString) + let pathPart = decodeURIComponent(url.pathname.slice(1)) + + if (!pathPart) return null + + // /versions/ endpoint returns version lists (used by getVersionsBatch) + const isVersions = pathPart.startsWith('versions/') + if (isVersions) pathPart = pathPart.slice('versions/'.length) + + const resolveFn = isVersions ? resolveSingleVersions : resolveSingleLatest + + // Batch requests: package1+package2+... + if (pathPart.includes('+')) { + const results = pathPart.split('+').map(resolveFn) + return json(results) + } + + return json(resolveFn(pathPart)) } /** diff --git a/test/nuxt/components/compare/PackageSelector.spec.ts b/test/nuxt/components/compare/PackageSelector.spec.ts index 5b004f420..0d1901e58 100644 --- a/test/nuxt/components/compare/PackageSelector.spec.ts +++ b/test/nuxt/components/compare/PackageSelector.spec.ts @@ -3,7 +3,6 @@ import { ref } from 'vue' import { mountSuspended } from '@nuxt/test-utils/runtime' import PackageSelector from '~/components/Compare/PackageSelector.vue' -// Mock $fetch for useNpmSearch const mockFetch = vi.fn() vi.stubGlobal('$fetch', mockFetch)