diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62602a84c..21aefdfa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: run: pnpm install - name: 🧪 Unit tests - run: pnpm test:unit run --coverage --reporter=junit --outputFile=test-report.junit.xml + run: pnpm test:unit run --coverage --reporter=default --reporter=junit --outputFile=test-report.junit.xml - name: ⬆︎ Upload test results to Codecov if: ${{ !cancelled() }} @@ -115,7 +115,7 @@ jobs: run: pnpm playwright install chromium-headless-shell - name: 🧪 Component tests - run: pnpm test:nuxt run --coverage --reporter=junit --outputFile=test-report.junit.xml + run: pnpm test:nuxt run --coverage --reporter=default --reporter=junit --outputFile=test-report.junit.xml - name: ⬆︎ Upload coverage reports to Codecov uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 diff --git a/app/components/SearchProviderToggle.client.vue b/app/components/SearchProviderToggle.client.vue new file mode 100644 index 000000000..bb1dff5d1 --- /dev/null +++ b/app/components/SearchProviderToggle.client.vue @@ -0,0 +1,117 @@ + + + diff --git a/app/components/SearchProviderToggle.server.vue b/app/components/SearchProviderToggle.server.vue new file mode 100644 index 000000000..0cb00326c --- /dev/null +++ b/app/components/SearchProviderToggle.server.vue @@ -0,0 +1,7 @@ + diff --git a/app/composables/npm/useAlgoliaSearch.ts b/app/composables/npm/useAlgoliaSearch.ts new file mode 100644 index 000000000..989fde22f --- /dev/null +++ b/app/composables/npm/useAlgoliaSearch.ts @@ -0,0 +1,238 @@ +import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' +import { + liteClient as algoliasearch, + type LiteClient, + type SearchResponse, +} from 'algoliasearch/lite' + +/** + * Singleton Algolia client, keyed by appId to handle config changes. + */ +let _searchClient: LiteClient | null = null +let _configuredAppId: string | null = null + +function getOrCreateClient(appId: string, apiKey: string): LiteClient { + if (!_searchClient || _configuredAppId !== appId) { + _searchClient = algoliasearch(appId, apiKey) + _configuredAppId = appId + } + return _searchClient +} + +interface AlgoliaOwner { + name: string + email?: string + avatar?: string + link?: string +} + +interface AlgoliaRepo { + url: string + host: string + user: string + project: string + path: string + head?: string + branch?: string +} + +/** + * Shape of a hit from the Algolia `npm-search` index. + * Only includes fields we retrieve via `attributesToRetrieve`. + */ +interface AlgoliaHit { + objectID: string + name: string + version: string + description: string | null + modified: number + homepage: string | null + repository: AlgoliaRepo | null + owners: AlgoliaOwner[] | null + downloadsLast30Days: number + downloadsRatio: number + popular: boolean + keywords: string[] + deprecated: boolean | string + isDeprecated: boolean + license: string | null +} + +/** Fields we always request from Algolia to keep payload small */ +const ATTRIBUTES_TO_RETRIEVE = [ + 'name', + 'version', + 'description', + 'modified', + 'homepage', + 'repository', + 'owners', + 'downloadsLast30Days', + 'downloadsRatio', + 'popular', + 'keywords', + 'deprecated', + 'isDeprecated', + 'license', +] + +function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult { + return { + package: { + name: hit.name, + version: hit.version, + description: hit.description || '', + keywords: hit.keywords, + date: new Date(hit.modified).toISOString(), + links: { + npm: `https://www.npmjs.com/package/${hit.name}`, + homepage: hit.homepage || undefined, + repository: hit.repository?.url || undefined, + }, + maintainers: hit.owners + ? hit.owners.map(owner => ({ + name: owner.name, + email: owner.email, + })) + : [], + }, + score: { + final: 0, + detail: { + quality: hit.popular ? 1 : 0, + popularity: hit.downloadsRatio, + maintenance: 0, + }, + }, + searchScore: 0, + downloads: { + weekly: Math.round(hit.downloadsLast30Days / 4.3), + }, + updated: new Date(hit.modified).toISOString(), + } +} + +export interface AlgoliaSearchOptions { + /** Number of results */ + size?: number + /** Offset for pagination */ + from?: number + /** Algolia filters expression (e.g. 'owner.name:username') */ + filters?: string +} + +/** + * Composable that provides Algolia search functions for npm packages. + * + * Must be called during component setup (or inside another composable) + * because it reads from `useRuntimeConfig()`. The returned functions + * are safe to call at any time (event handlers, async callbacks, etc.). + */ +export function useAlgoliaSearch() { + const { algolia } = useRuntimeConfig().public + const client = getOrCreateClient(algolia.appId, algolia.apiKey) + const indexName = algolia.indexName + + /** + * Search npm packages via Algolia. + * Returns results in the same NpmSearchResponse format as the npm registry API. + */ + async function search( + query: string, + options: AlgoliaSearchOptions = {}, + ): Promise { + const { results } = await client.search([ + { + indexName, + params: { + query, + offset: options.from, + length: options.size, + filters: options.filters || '', + analyticsTags: ['npmx.dev'], + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, + attributesToHighlight: [], + }, + }, + ]) + + const response = results[0] as SearchResponse | undefined + if (!response) { + throw new Error('Algolia returned an empty response') + } + + return { + isStale: false, + objects: response.hits.map(hitToSearchResult), + total: response.nbHits ?? 0, + time: new Date().toISOString(), + } + } + + /** + * Fetch all packages for an Algolia owner (org or user). + * Uses `owner.name` filter for efficient server-side filtering. + */ + async function searchByOwner( + ownerName: string, + options: { maxResults?: number } = {}, + ): Promise { + const max = options.maxResults ?? 1000 + + const allHits: AlgoliaHit[] = [] + let offset = 0 + let serverTotal = 0 + const batchSize = 200 + + // Algolia supports up to 1000 results per query with offset/length pagination + while (offset < max) { + // Cap at both the configured max and the server's actual total (once known) + const remaining = serverTotal > 0 ? Math.min(max, serverTotal) - offset : max - offset + if (remaining <= 0) break + const length = Math.min(batchSize, remaining) + + const { results } = await client.search([ + { + indexName, + params: { + query: '', + offset, + length, + filters: `owner.name:${ownerName}`, + analyticsTags: ['npmx.dev'], + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, + attributesToHighlight: [], + }, + }, + ]) + + const response = results[0] as SearchResponse | undefined + if (!response) break + + serverTotal = response.nbHits ?? 0 + allHits.push(...response.hits) + + // If we got fewer than requested, we've exhausted all results + if (response.hits.length < length || allHits.length >= serverTotal) { + break + } + + offset += length + } + + return { + isStale: false, + objects: allHits.map(hitToSearchResult), + // Use server total so callers can detect truncation (allHits.length < total) + total: serverTotal, + time: new Date().toISOString(), + } + } + + return { + /** Search packages by text query */ + search, + /** Fetch all packages for an owner (org or user) */ + searchByOwner, + } +} diff --git a/app/composables/npm/useNpmSearch.ts b/app/composables/npm/useNpmSearch.ts index af62c2a0a..2a0076183 100644 --- a/app/composables/npm/useNpmSearch.ts +++ b/app/composables/npm/useNpmSearch.ts @@ -5,6 +5,7 @@ import type { NpmDownloadCount, MinimalPackument, } from '#shared/types' +import type { SearchProvider } from '~/composables/useSettings' /** * Convert packument to search result format for display @@ -55,10 +56,13 @@ export function useNpmSearch( options: MaybeRefOrGetter = {}, ) { 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) @@ -73,9 +77,10 @@ export function useNpmSearch( let lastSearch: NpmSearchResponse | undefined = undefined const asyncData = useLazyAsyncData( - () => `search:incremental:${toValue(query)}`, + () => `search:${searchProvider.value}:${toValue(query)}`, async ({ $npmRegistry, $npmApi }, { signal }) => { const q = toValue(query) + const provider = searchProvider.value if (!q.trim()) { isRateLimited.value = false @@ -88,9 +93,31 @@ export function useNpmSearch( // 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) - // Use requested size for initial fetch params.set('size', String(opts.size ?? 25)) try { @@ -116,6 +143,7 @@ export function useNpmSearch( cache.value = { query: q, + provider, objects: [result], total: 1, } @@ -144,6 +172,7 @@ export function useNpmSearch( cache.value = { query: q, + provider, objects: response.objects, total: response.total, } @@ -169,16 +198,18 @@ export function useNpmSearch( { default: () => lastSearch || emptySearchResponse }, ) - // Fetch more results incrementally (only used in incremental mode) + // 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 changed, reset cache (shouldn't happen, but safety check) - if (cache.value && cache.value.query !== q) { + // 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 @@ -195,40 +226,65 @@ export function useNpmSearch( isLoadingMore.value = true try { - // Fetch from where we left off - calculate size needed const from = currentCount const size = Math.min(targetSize - currentCount, total - currentCount) - 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, - ) - - // Update cache - if (cache.value && cache.value.query === q) { - 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, - objects: [...cache.value.objects, ...newObjects], - total: response.total, + 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 { - cache.value = { - query: q, - objects: response.objects, - total: response.total, + // 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, + } } } // If we still need more, fetch again recursively if ( + cache.value && cache.value.objects.length < targetSize && cache.value.objects.length < cache.value.total ) { @@ -239,7 +295,7 @@ export function useNpmSearch( } } - // Watch for size increases in incremental mode + // Watch for size increases watch( () => toValue(options).size, async (newSize, oldSize) => { @@ -250,7 +306,13 @@ export function useNpmSearch( }, ) - // Computed data that uses cache in incremental mode + // Re-search when provider changes + watch(searchProvider, () => { + cache.value = null + asyncData.refresh() + }) + + // Computed data that uses cache const data = computed(() => { if (cache.value) { return { @@ -269,7 +331,7 @@ export function useNpmSearch( }) } - // Whether there are more results available on the server (incremental mode only) + // Whether there are more results available const hasMore = computed(() => { if (!cache.value) return true return cache.value.objects.length < cache.value.total @@ -279,11 +341,11 @@ export function useNpmSearch( ...asyncData, /** Reactive search results (uses cache in incremental mode) */ data, - /** Whether currently loading more results (incremental mode only) */ + /** Whether currently loading more results */ isLoadingMore, - /** Whether there are more results available (incremental mode only) */ + /** Whether there are more results available */ hasMore, - /** Manually fetch more results up to target size (incremental mode only) */ + /** 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/useOrgPackages.ts b/app/composables/npm/useOrgPackages.ts index 3cb66f950..335141952 100644 --- a/app/composables/npm/useOrgPackages.ts +++ b/app/composables/npm/useOrgPackages.ts @@ -77,19 +77,27 @@ async function fetchBulkDownloads( } /** - * Fetch all packages for an npm organization - * Returns search-result-like objects for compatibility with PackageList + * Fetch all packages for an npm organization. + * + * Always uses the npm registry's org endpoint as the source of truth for which + * packages belong to the org. When Algolia is enabled, uses it to quickly fetch + * metadata for those packages (instead of N+1 packument fetches). */ export function useOrgPackages(orgName: MaybeRefOrGetter) { + const { searchProvider } = useSearchProvider() + const { searchByOwner } = useAlgoliaSearch() + const asyncData = useLazyAsyncData( - () => `org-packages:${toValue(orgName)}`, - async ({ $npmRegistry, $npmApi }, { signal }) => { + () => `org-packages:${searchProvider.value}:${toValue(orgName)}`, + async ({ $npmRegistry, $npmApi, ssrContext }, { signal }) => { const org = toValue(orgName) if (!org) { return emptySearchResponse } - // Get all package names in the org + // Always get the authoritative package list from the npm registry. + // Algolia's owner.name filter doesn't precisely match npm org membership + // (e.g. it includes @nuxtjs/* packages for the @nuxt org). let packageNames: string[] try { const { packages } = await $fetch<{ packages: string[]; count: number }>( @@ -100,11 +108,15 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { } catch (err) { // Check if this is a 404 (org not found) if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 404) { - throw createError({ + const error = createError({ statusCode: 404, statusMessage: 'Organization not found', message: `The organization "@${org}" does not exist on npm`, }) + if (import.meta.server) { + ssrContext!.payload.error = error + } + throw error } // For other errors (network, etc.), return empty array to be safe packageNames = [] @@ -114,9 +126,33 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { return emptySearchResponse } - // Fetch packuments and downloads in parallel + // --- Algolia fast path: use Algolia to get metadata for known packages --- + if (searchProvider.value === 'algolia') { + try { + const response = await searchByOwner(org) + if (response.objects.length > 0) { + // Filter Algolia results to only include packages that are + // actually in the org (per the npm registry's authoritative list) + const orgPackageSet = new Set(packageNames.map(n => n.toLowerCase())) + const filtered = response.objects.filter(obj => + orgPackageSet.has(obj.package.name.toLowerCase()), + ) + + if (filtered.length > 0) { + return { + ...response, + objects: filtered, + total: filtered.length, + } + } + } + } catch { + // Fall through to npm registry path + } + } + + // --- npm registry path: fetch packuments individually --- const [packuments, downloads] = await Promise.all([ - // Fetch packuments with concurrency limit (async () => { const results = await mapWithConcurrency( packageNames, @@ -133,16 +169,13 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { }, 10, ) - // Filter out any unpublished packages (missing dist-tags) return results.filter( (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'], ) })(), - // Fetch downloads in bulk fetchBulkDownloads($npmApi, packageNames, { signal }), ]) - // Convert to search results with download data const results: NpmSearchResult[] = packuments.map(pkg => packumentToSearchResult(pkg, downloads.get(pkg.name)), ) diff --git a/app/composables/npm/useUserPackages.ts b/app/composables/npm/useUserPackages.ts new file mode 100644 index 000000000..1855a7624 --- /dev/null +++ b/app/composables/npm/useUserPackages.ts @@ -0,0 +1,243 @@ +import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' +import { emptySearchResponse } from './useNpmSearch' + +/** Default page size for incremental loading (npm registry path) */ +const PAGE_SIZE = 50 as const + +/** npm search API practical limit for maintainer queries */ +const MAX_RESULTS = 250 + +/** + * Fetch packages for a given npm user/maintainer. + * + * The composable handles all loading strategy internally based on the active + * search provider. Consumers get a uniform interface regardless of provider: + * + * - **Algolia**: Fetches all packages at once via `owner.name` filter (fast). + * - **npm**: Incrementally paginates through `maintainer:` search results. + * + * @example + * ```ts + * const { data, status, hasMore, isLoadingMore, loadMore } = useUserPackages(username) + * ``` + */ +export function useUserPackages(username: MaybeRefOrGetter) { + const { searchProvider } = useSearchProvider() + // this is only used in npm path, but we need to extract it when the composable runs + const { $npmRegistry } = useNuxtApp() + const { searchByOwner } = useAlgoliaSearch() + + // --- Incremental loading state (npm path) --- + const currentPage = shallowRef(1) + + /** Tracks which provider actually served the current data (may differ from + * searchProvider when Algolia returns empty and we fall through to npm) */ + const activeProvider = shallowRef<'npm' | 'algolia'>(searchProvider.value) + + const cache = shallowRef<{ + username: string + objects: NpmSearchResult[] + total: number + } | null>(null) + + const isLoadingMore = shallowRef(false) + + const asyncData = useLazyAsyncData( + () => `user-packages:${searchProvider.value}:${toValue(username)}`, + async ({ $npmRegistry }, { signal }) => { + const user = toValue(username) + if (!user) { + return emptySearchResponse + } + + const provider = searchProvider.value + + // --- Algolia: fetch all at once --- + if (provider === 'algolia') { + try { + const response = await searchByOwner(user) + + // Guard against stale response (user/provider changed during await) + if (user !== toValue(username) || provider !== searchProvider.value) { + return emptySearchResponse + } + + // If Algolia returns results, use them. If empty, fall through to npm + // registry which uses `maintainer:` search (matches all maintainers, + // not just the primary owner that Algolia's owner.name indexes). + if (response.objects.length > 0) { + activeProvider.value = 'algolia' + cache.value = { + username: user, + objects: response.objects, + total: response.total, + } + return response + } + } catch { + // Fall through to npm registry path on Algolia failure + } + } + + // --- npm registry: initial page (or Algolia fallback) --- + activeProvider.value = 'npm' + cache.value = null + currentPage.value = 1 + + const params = new URLSearchParams() + params.set('text', `maintainer:${user}`) + params.set('size', String(PAGE_SIZE)) + + const { data: response, isStale } = await $npmRegistry( + `/-/v1/search?${params.toString()}`, + { signal }, + 60, + ) + + // Guard against stale response (user/provider changed during await) + if (user !== toValue(username) || provider !== searchProvider.value) { + return emptySearchResponse + } + + cache.value = { + username: user, + objects: response.objects, + total: response.total, + } + + return { ...response, isStale } + }, + { default: () => emptySearchResponse }, + ) + // --- Fetch more (npm path only) --- + /** + * Fetch the next page of results from npm registry. + * @param manageLoadingState - When false, caller manages isLoadingMore (used by loadAll to prevent flicker) + */ + async function fetchMore(manageLoadingState = true): Promise { + const user = toValue(username) + // Use activeProvider: if Algolia fell through to npm, we still need pagination + if (!user || activeProvider.value !== 'npm') return + + if (cache.value && cache.value.username !== user) { + cache.value = null + await asyncData.refresh() + return + } + + const currentCount = cache.value?.objects.length ?? 0 + const total = Math.min(cache.value?.total ?? Infinity, MAX_RESULTS) + + if (currentCount >= total) return + + if (manageLoadingState) isLoadingMore.value = true + + try { + const from = currentCount + const size = Math.min(PAGE_SIZE, total - currentCount) + + const params = new URLSearchParams() + params.set('text', `maintainer:${user}`) + params.set('size', String(size)) + params.set('from', String(from)) + + const { data: response } = await $npmRegistry( + `/-/v1/search?${params.toString()}`, + {}, + 60, + ) + + // Guard against stale response + if (user !== toValue(username) || activeProvider.value !== 'npm') return + + if (cache.value && cache.value.username === user) { + 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 = { + username: user, + objects: [...cache.value.objects, ...newObjects], + total: response.total, + } + } else { + cache.value = { + username: user, + objects: response.objects, + total: response.total, + } + } + } finally { + if (manageLoadingState) isLoadingMore.value = false + } + } + + /** Load the next page of results (no-op if all loaded or using Algolia) */ + async function loadMore(): Promise { + if (isLoadingMore.value || !hasMore.value) return + currentPage.value++ + await fetchMore() + } + + /** Load all remaining results at once (e.g. when user starts filtering) */ + async function loadAll(): Promise { + if (!hasMore.value) return + + isLoadingMore.value = true + try { + while (hasMore.value) { + await fetchMore(false) + } + } finally { + isLoadingMore.value = false + } + } + + // asyncdata will automatically rerun due to key, but we need to reset cache/page + // when provider changes + watch(searchProvider, newProvider => { + cache.value = null + currentPage.value = 1 + activeProvider.value = newProvider + }) + + // Computed data that uses cache (only if it belongs to the current username) + const data = computed(() => { + const user = toValue(username) + if (cache.value && cache.value.username === user) { + return { + isStale: false, + objects: cache.value.objects, + total: cache.value.total, + time: new Date().toISOString(), + } + } + return asyncData.data.value + }) + + /** Whether there are more results available to load (npm path only) */ + const hasMore = computed(() => { + if (!toValue(username)) return false + // Algolia fetches everything in one request; only npm needs pagination + if (activeProvider.value !== 'npm') return false + if (!cache.value) return true + // npm path: more available if we haven't hit the server total or our cap + const fetched = cache.value.objects.length + const available = cache.value.total + return fetched < available && fetched < MAX_RESULTS + }) + + return { + ...asyncData, + /** Reactive package results */ + data, + /** Whether currently loading more results */ + isLoadingMore, + /** Whether there are more results available */ + hasMore, + /** Load next page of results */ + loadMore, + /** Load all remaining results (for filter/sort) */ + loadAll, + /** Default page size (for display) */ + pageSize: PAGE_SIZE, + } +} diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 7bf06a803..31476415e 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -8,6 +8,9 @@ type BackgroundThemeId = keyof typeof BACKGROUND_THEMES type AccentColorId = keyof typeof ACCENT_COLORS.light +/** Available search providers */ +export type SearchProvider = 'npm' | 'algolia' + /** * Application settings stored in localStorage */ @@ -24,6 +27,8 @@ export interface AppSettings { hidePlatformPackages: boolean /** User-selected locale */ selectedLocale: LocaleObject['code'] | null + /** Search provider for package search */ + searchProvider: SearchProvider sidebar: { collapsed: string[] } @@ -36,6 +41,7 @@ const DEFAULT_SETTINGS: AppSettings = { hidePlatformPackages: true, selectedLocale: null, preferredBackgroundTheme: null, + searchProvider: import.meta.test ? 'npm' : 'algolia', sidebar: { collapsed: [], }, @@ -105,6 +111,32 @@ export function useAccentColor() { } } +/** + * Composable for managing the search provider setting. + */ +export function useSearchProvider() { + const { settings } = useSettings() + + const searchProvider = computed({ + get: () => settings.value.searchProvider, + set: (value: SearchProvider) => { + settings.value.searchProvider = value + }, + }) + + const isAlgolia = computed(() => searchProvider.value === 'algolia') + + function toggle() { + searchProvider.value = searchProvider.value === 'npm' ? 'algolia' : 'npm' + } + + return { + searchProvider, + isAlgolia, + toggle, + } +} + export function useBackgroundTheme() { const backgroundThemes = Object.entries(BACKGROUND_THEMES).map(([id, value]) => ({ id: id as BackgroundThemeId, diff --git a/app/pages/search.vue b/app/pages/search.vue index 173228ff1..a11090c29 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -75,7 +75,6 @@ const { isRateLimited, } = useNpmSearch(query, () => ({ size: requestedSize.value, - incremental: true, })) // Results to display (directly from incremental search) @@ -317,14 +316,13 @@ interface ValidatedSuggestion { /** Cache for existence checks to avoid repeated API calls */ const existenceCache = ref>({}) -interface NpmSearchResponse { - total: number - objects: Array<{ package: { name: string } }> -} +const { search: algoliaSearch } = useAlgoliaSearch() +const { isAlgolia } = useSearchProvider() /** - * Check if an org exists by searching for packages with @orgname scope - * Uses the search API which has CORS enabled + * Check if an org exists by searching for scoped packages (@orgname/...). + * When Algolia is active, searches for `@name/` scoped packages via text query. + * Falls back to npm registry search API otherwise. */ async function checkOrgExists(name: string): Promise { const cacheKey = `org:${name.toLowerCase()}` @@ -334,12 +332,24 @@ async function checkOrgExists(name: string): Promise { } existenceCache.value[cacheKey] = 'pending' try { - // Search for packages in the @org scope - const response = await $fetch(`${NPM_REGISTRY}/-/v1/search`, { - query: { text: `@${name}`, size: 5 }, - }) - // Verify at least one result actually starts with @orgname/ const scopePrefix = `@${name.toLowerCase()}/` + + if (isAlgolia.value) { + // Algolia: search for scoped packages — use the scope as a text query + // and verify a result actually starts with @name/ + const response = await algoliaSearch(`@${name}`, { size: 5 }) + const exists = response.objects.some(obj => + obj.package.name.toLowerCase().startsWith(scopePrefix), + ) + existenceCache.value[cacheKey] = exists + return exists + } + + // npm registry: search for packages in the @org scope + const response = await $fetch<{ total: number; objects: Array<{ package: { name: string } }> }>( + `${NPM_REGISTRY}/-/v1/search`, + { query: { text: `@${name}`, size: 5 } }, + ) const exists = response.objects.some(obj => obj.package.name.toLowerCase().startsWith(scopePrefix), ) @@ -352,8 +362,10 @@ async function checkOrgExists(name: string): Promise { } /** - * Check if a user exists by searching for packages they maintain - * Uses the search API which has CORS enabled + * Check if a user exists by searching for packages they maintain. + * Always uses the npm registry `maintainer:` search because Algolia's + * `owner.name` field represents the org/account, not individual maintainers, + * and cannot reliably distinguish users from orgs. */ async function checkUserExists(name: string): Promise { const cacheKey = `user:${name.toLowerCase()}` @@ -419,10 +431,16 @@ const parsedQuery = computed(() => { const validatedSuggestions = ref([]) const suggestionsLoading = shallowRef(false) -/** Debounced function to validate suggestions */ -const validateSuggestions = debounce(async (parsed: ParsedQuery) => { +/** Counter to discard stale async results when query changes rapidly */ +let suggestionRequestId = 0 + +/** Validate suggestions (check org/user existence) */ +async function validateSuggestionsImpl(parsed: ParsedQuery) { + const requestId = ++suggestionRequestId + if (!parsed.type || !parsed.name) { validatedSuggestions.value = [] + suggestionsLoading.value = false return } @@ -432,11 +450,13 @@ const validateSuggestions = debounce(async (parsed: ParsedQuery) => { try { if (parsed.type === 'user') { const exists = await checkUserExists(parsed.name) + if (requestId !== suggestionRequestId) return if (exists) { suggestions.push({ type: 'user', name: parsed.name, exists: true }) } } else if (parsed.type === 'org') { const exists = await checkOrgExists(parsed.name) + if (requestId !== suggestionRequestId) return if (exists) { suggestions.push({ type: 'org', name: parsed.name, exists: true }) } @@ -446,6 +466,7 @@ const validateSuggestions = debounce(async (parsed: ParsedQuery) => { checkOrgExists(parsed.name), checkUserExists(parsed.name), ]) + if (requestId !== suggestionRequestId) return // Org first (more common) if (orgExists) { suggestions.push({ type: 'org', name: parsed.name, exists: true }) @@ -455,21 +476,47 @@ const validateSuggestions = debounce(async (parsed: ParsedQuery) => { } } } finally { - suggestionsLoading.value = false + // Only clear loading if this is still the active request + if (requestId === suggestionRequestId) { + suggestionsLoading.value = false + } } - validatedSuggestions.value = suggestions -}, 200) + if (requestId === suggestionRequestId) { + validatedSuggestions.value = suggestions + } +} + +// Debounce lightly for npm (extra API calls are slower), skip debounce for Algolia (fast) +const validateSuggestionsDebounced = debounce(validateSuggestionsImpl, 100) // Validate suggestions when query changes watch( parsedQuery, parsed => { - validateSuggestions(parsed) + if (isAlgolia.value) { + // Algolia existence checks are fast - fire immediately + validateSuggestionsImpl(parsed) + } else { + validateSuggestionsDebounced(parsed) + } }, { immediate: true }, ) +// Re-validate suggestions and clear caches when provider changes +watch(isAlgolia, () => { + // Cancel any pending debounced validation from the previous provider + validateSuggestionsDebounced.cancel?.() + // Clear existence cache since results may differ between providers + existenceCache.value = {} + // Re-validate with current query + const parsed = parsedQuery.value + if (parsed.type) { + validateSuggestionsImpl(parsed) + } +}) + /** Check if there's an exact package match in results */ const hasExactPackageMatch = computed(() => { const q = query.value.trim().toLowerCase() @@ -663,9 +710,12 @@ defineOgImageComponent('Default', {