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

perf: speed up algolia ๐Ÿš€ + split out search suggestion logic (#1271)

authored by

Daniel Roe and committed by
GitHub
15dffc24 14449ea6

+484 -368
+13 -4
app/components/Header/SearchBox.vue
··· 15 15 16 16 const router = useRouter() 17 17 const route = useRoute() 18 + const { isAlgolia } = useSearchProvider() 18 19 19 20 const isSearchFocused = shallowRef(false) 20 21 ··· 28 29 // Pages that have their own local filter using ?q 29 30 const pagesWithLocalFilter = new Set(['~username', 'org']) 30 31 31 - // Debounced URL update for search query 32 - const updateUrlQuery = debounce((value: string) => { 32 + function updateUrlQueryImpl(value: string) { 33 33 // Don't navigate away from pages that use ?q for local filtering 34 34 if (pagesWithLocalFilter.has(route.name as string)) { 35 35 return ··· 48 48 q: value, 49 49 }, 50 50 }) 51 - }, 250) 51 + } 52 + 53 + const updateUrlQueryNpm = debounce(updateUrlQueryImpl, 250) 54 + const updateUrlQueryAlgolia = debounce(updateUrlQueryImpl, 80) 55 + 56 + const updateUrlQuery = Object.assign( 57 + (value: string) => (isAlgolia.value ? updateUrlQueryAlgolia : updateUrlQueryNpm)(value), 58 + { 59 + flush: () => (isAlgolia.value ? updateUrlQueryAlgolia : updateUrlQueryNpm).flush(), 60 + }, 61 + ) 52 62 53 - // Watch input and debounce URL updates 54 63 watch(searchQuery, value => { 55 64 updateUrlQuery(value) 56 65 })
+33 -3
app/composables/npm/search-utils.ts
··· 1 1 import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#shared/types' 2 2 3 - /** 4 - * Convert a lightweight package-meta API response to a search result for display. 5 - */ 6 3 export function metaToSearchResult(meta: PackageMetaResponse): NpmSearchResult { 7 4 return { 8 5 package: { ··· 31 28 time: new Date().toISOString(), 32 29 } 33 30 } 31 + 32 + export interface SearchSuggestion { 33 + type: 'user' | 'org' 34 + name: string 35 + exists: boolean 36 + } 37 + 38 + export type SuggestionIntent = 'user' | 'org' | 'both' | null 39 + 40 + export function isValidNpmName(name: string): boolean { 41 + if (!name || name.length === 0 || name.length > 214) return false 42 + if (!/^[a-z0-9]/i.test(name)) return false 43 + return /^[\w-]+$/.test(name) 44 + } 45 + 46 + /** Parse a search query into a suggestion intent (`~user`, `@org`, or plain `both`). */ 47 + export function parseSuggestionIntent(query: string): { intent: SuggestionIntent; name: string } { 48 + const q = query.trim() 49 + if (!q) return { intent: null, name: '' } 50 + 51 + if (q.startsWith('~')) { 52 + const name = q.slice(1) 53 + return isValidNpmName(name) ? { intent: 'user', name } : { intent: null, name: '' } 54 + } 55 + 56 + if (q.startsWith('@')) { 57 + if (q.includes('/')) return { intent: null, name: '' } 58 + const name = q.slice(1) 59 + return isValidNpmName(name) ? { intent: 'org', name } : { intent: null, name: '' } 60 + } 61 + 62 + return isValidNpmName(q) ? { intent: 'both', name: q } : { intent: null, name: '' } 63 + }
+135 -51
app/composables/npm/useAlgoliaSearch.ts
··· 2 2 import { 3 3 liteClient as algoliasearch, 4 4 type LiteClient, 5 + type SearchQuery, 5 6 type SearchResponse, 6 7 } from 'algoliasearch/lite' 7 8 8 - /** 9 - * Singleton Algolia client, keyed by appId to handle config changes. 10 - */ 11 9 let _searchClient: LiteClient | null = null 12 10 let _configuredAppId: string | null = null 13 11 ··· 36 34 branch?: string 37 35 } 38 36 39 - /** 40 - * Shape of a hit from the Algolia `npm-search` index. 41 - * Only includes fields we retrieve via `attributesToRetrieve`. 42 - */ 37 + /** Shape of a hit from the Algolia `npm-search` index. */ 43 38 interface AlgoliaHit { 44 39 objectID: string 45 40 name: string ··· 58 53 license: string | null 59 54 } 60 55 61 - /** Fields we always request from Algolia to keep payload small */ 62 56 const ATTRIBUTES_TO_RETRIEVE = [ 63 57 'name', 64 58 'version', ··· 75 69 'isDeprecated', 76 70 'license', 77 71 ] 72 + 73 + const EXISTENCE_CHECK_ATTRS = ['name'] 78 74 79 75 function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult { 80 76 return { ··· 113 109 } 114 110 115 111 export interface AlgoliaSearchOptions { 116 - /** Number of results */ 117 112 size?: number 118 - /** Offset for pagination */ 119 113 from?: number 120 - /** Algolia filters expression (e.g. 'owner.name:username') */ 121 114 filters?: string 122 115 } 123 116 117 + /** Extra checks bundled into a single multi-search request. */ 118 + export interface AlgoliaMultiSearchChecks { 119 + name?: string 120 + checkOrg?: boolean 121 + checkUser?: boolean 122 + checkPackage?: string 123 + } 124 + 125 + export interface AlgoliaSearchWithSuggestionsResult { 126 + search: NpmSearchResponse 127 + orgExists: boolean 128 + userExists: boolean 129 + packageExists: boolean | null 130 + } 131 + 124 132 /** 125 - * Composable that provides Algolia search functions for npm packages. 126 - * 127 - * Must be called during component setup (or inside another composable) 128 - * because it reads from `useRuntimeConfig()`. The returned functions 129 - * are safe to call at any time (event handlers, async callbacks, etc.). 133 + * Composable providing Algolia search for npm packages. 134 + * Must be called during component setup. 130 135 */ 131 136 export function useAlgoliaSearch() { 132 137 const { algolia } = useRuntimeConfig().public 133 138 const client = getOrCreateClient(algolia.appId, algolia.apiKey) 134 139 const indexName = algolia.indexName 135 140 136 - /** 137 - * Search npm packages via Algolia. 138 - * Returns results in the same NpmSearchResponse format as the npm registry API. 139 - */ 140 141 async function search( 141 142 query: string, 142 143 options: AlgoliaSearchOptions = {}, 143 144 ): Promise<NpmSearchResponse> { 144 - const { results } = await client.search([ 145 - { 146 - indexName, 147 - params: { 145 + const { results } = await client.search({ 146 + requests: [ 147 + { 148 + indexName, 148 149 query, 149 150 offset: options.from, 150 151 length: options.size, ··· 152 153 analyticsTags: ['npmx.dev'], 153 154 attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, 154 155 attributesToHighlight: [], 155 - }, 156 - }, 157 - ]) 156 + } satisfies SearchQuery, 157 + ], 158 + }) 158 159 159 160 const response = results[0] as SearchResponse<AlgoliaHit> | undefined 160 161 if (!response) { ··· 169 170 } 170 171 } 171 172 172 - /** 173 - * Fetch all packages for an Algolia owner (org or user). 174 - * Uses `owner.name` filter for efficient server-side filtering. 175 - */ 173 + /** Fetch all packages for an owner using `owner.name` filter with pagination. */ 176 174 async function searchByOwner( 177 175 ownerName: string, 178 176 options: { maxResults?: number } = {}, ··· 184 182 let serverTotal = 0 185 183 const batchSize = 200 186 184 187 - // Algolia supports up to 1000 results per query with offset/length pagination 188 185 while (offset < max) { 189 - // Cap at both the configured max and the server's actual total (once known) 190 186 const remaining = serverTotal > 0 ? Math.min(max, serverTotal) - offset : max - offset 191 187 if (remaining <= 0) break 192 188 const length = Math.min(batchSize, remaining) 193 189 194 - const { results } = await client.search([ 195 - { 196 - indexName, 197 - params: { 190 + const { results } = await client.search({ 191 + requests: [ 192 + { 193 + indexName, 198 194 query: '', 199 195 offset, 200 196 length, ··· 202 198 analyticsTags: ['npmx.dev'], 203 199 attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, 204 200 attributesToHighlight: [], 205 - }, 206 - }, 207 - ]) 201 + } satisfies SearchQuery, 202 + ], 203 + }) 208 204 209 205 const response = results[0] as SearchResponse<AlgoliaHit> | undefined 210 206 if (!response) break ··· 212 208 serverTotal = response.nbHits ?? 0 213 209 allHits.push(...response.hits) 214 210 215 - // If we got fewer than requested, we've exhausted all results 216 211 if (response.hits.length < length || allHits.length >= serverTotal) { 217 212 break 218 213 } ··· 223 218 return { 224 219 isStale: false, 225 220 objects: allHits.map(hitToSearchResult), 226 - // Use server total so callers can detect truncation (allHits.length < total) 227 221 total: serverTotal, 228 222 time: new Date().toISOString(), 229 223 } 230 224 } 231 225 232 - /** 233 - * Fetch metadata for specific packages by exact name. 234 - * Uses Algolia's getObjects REST API to look up packages by objectID 235 - * (which equals the package name in the npm-search index). 236 - */ 226 + /** Fetch metadata for specific packages by exact name using Algolia's getObjects API. */ 237 227 async function getPackagesByName(packageNames: string[]): Promise<NpmSearchResponse> { 238 228 if (packageNames.length === 0) { 239 229 return { isStale: false, objects: [], total: 0, time: new Date().toISOString() } 240 230 } 241 231 242 - // Algolia getObjects REST API: fetch up to 1000 objects by ID in a single request 243 232 const response = await $fetch<{ results: (AlgoliaHit | null)[] }>( 244 233 `https://${algolia.appId}-dsn.algolia.net/1/indexes/*/objects`, 245 234 { ··· 267 256 } 268 257 } 269 258 259 + /** 260 + * Combined search + org/user/package existence checks in a single 261 + * Algolia multi-search request. 262 + */ 263 + async function searchWithSuggestions( 264 + query: string, 265 + options: AlgoliaSearchOptions = {}, 266 + checks?: AlgoliaMultiSearchChecks, 267 + ): Promise<AlgoliaSearchWithSuggestionsResult> { 268 + const requests: SearchQuery[] = [ 269 + { 270 + indexName, 271 + query, 272 + offset: options.from, 273 + length: options.size, 274 + filters: options.filters || '', 275 + analyticsTags: ['npmx.dev'], 276 + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, 277 + attributesToHighlight: [], 278 + }, 279 + ] 280 + 281 + const orgQueryIndex = checks?.checkOrg && checks.name ? requests.length : -1 282 + if (checks?.checkOrg && checks.name) { 283 + requests.push({ 284 + indexName, 285 + query: `"@${checks.name}"`, 286 + length: 1, 287 + analyticsTags: ['npmx.dev'], 288 + attributesToRetrieve: EXISTENCE_CHECK_ATTRS, 289 + attributesToHighlight: [], 290 + }) 291 + } 292 + 293 + const userQueryIndex = checks?.checkUser && checks.name ? requests.length : -1 294 + if (checks?.checkUser && checks.name) { 295 + requests.push({ 296 + indexName, 297 + query: '', 298 + filters: `owner.name:${checks.name}`, 299 + length: 1, 300 + analyticsTags: ['npmx.dev'], 301 + attributesToRetrieve: EXISTENCE_CHECK_ATTRS, 302 + attributesToHighlight: [], 303 + }) 304 + } 305 + 306 + const packageQueryIndex = checks?.checkPackage ? requests.length : -1 307 + if (checks?.checkPackage) { 308 + requests.push({ 309 + indexName, 310 + query: '', 311 + filters: `objectID:${checks.checkPackage}`, 312 + length: 1, 313 + analyticsTags: ['npmx.dev'], 314 + attributesToRetrieve: EXISTENCE_CHECK_ATTRS, 315 + attributesToHighlight: [], 316 + }) 317 + } 318 + 319 + const { results } = await client.search({ requests }) 320 + 321 + const mainResponse = results[0] as SearchResponse<AlgoliaHit> | undefined 322 + if (!mainResponse) { 323 + throw new Error('Algolia returned an empty response') 324 + } 325 + 326 + const searchResult: NpmSearchResponse = { 327 + isStale: false, 328 + objects: mainResponse.hits.map(hitToSearchResult), 329 + total: mainResponse.nbHits ?? 0, 330 + time: new Date().toISOString(), 331 + } 332 + 333 + let orgExists = false 334 + if (orgQueryIndex >= 0 && checks?.name) { 335 + const orgResponse = results[orgQueryIndex] as SearchResponse<AlgoliaHit> | undefined 336 + const scopePrefix = `@${checks.name.toLowerCase()}/` 337 + orgExists = 338 + orgResponse?.hits?.some(h => h.name?.toLowerCase().startsWith(scopePrefix)) ?? false 339 + } 340 + 341 + let userExists = false 342 + if (userQueryIndex >= 0) { 343 + const userResponse = results[userQueryIndex] as SearchResponse<AlgoliaHit> | undefined 344 + userExists = (userResponse?.nbHits ?? 0) > 0 345 + } 346 + 347 + let packageExists: boolean | null = null 348 + if (packageQueryIndex >= 0) { 349 + const pkgResponse = results[packageQueryIndex] as SearchResponse<AlgoliaHit> | undefined 350 + packageExists = (pkgResponse?.nbHits ?? 0) > 0 351 + } 352 + 353 + return { search: searchResult, orgExists, userExists, packageExists } 354 + } 355 + 270 356 return { 271 - /** Search packages by text query */ 272 357 search, 273 - /** Fetch all packages for an owner (org or user) */ 358 + searchWithSuggestions, 274 359 searchByOwner, 275 - /** Fetch metadata for specific packages by exact name */ 276 360 getPackagesByName, 277 361 } 278 362 }
+30 -21
app/composables/npm/useNpmSearch.ts
··· 2 2 import { emptySearchResponse, metaToSearchResult } from './search-utils' 3 3 4 4 export interface NpmSearchOptions { 5 - /** Number of results */ 6 5 size?: number 7 - /** Offset for pagination */ 8 6 from?: number 7 + } 8 + 9 + async function checkOrgExists(name: string): Promise<boolean> { 10 + try { 11 + const scopePrefix = `@${name.toLowerCase()}/` 12 + const response = await $fetch<{ 13 + total: number 14 + objects: Array<{ package: { name: string } }> 15 + }>(`${NPM_REGISTRY}/-/v1/search`, { query: { text: `@${name}`, size: 5 } }) 16 + return response.objects.some(obj => obj.package.name.toLowerCase().startsWith(scopePrefix)) 17 + } catch { 18 + return false 19 + } 20 + } 21 + 22 + async function checkUserExists(name: string): Promise<boolean> { 23 + try { 24 + const response = await $fetch<{ total: number }>(`${NPM_REGISTRY}/-/v1/search`, { 25 + query: { text: `maintainer:${name}`, size: 1 }, 26 + }) 27 + return response.total > 0 28 + } catch { 29 + return false 30 + } 9 31 } 10 32 11 33 /** 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.). 34 + * Composable providing npm registry search. 35 + * Must be called during component setup. 20 36 */ 21 37 export function useNpmSearch() { 22 38 const { $npmRegistry } = useNuxtApp() 23 39 24 40 /** 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. 41 + * Search npm packages. Single-character queries fetch lightweight metadata 42 + * via a server proxy since the search API returns poor results for them. 33 43 */ 34 44 async function search( 35 45 query: string, 36 46 options: NpmSearchOptions = {}, 37 47 signal?: AbortSignal, 38 48 ): Promise<NpmSearchResponse> { 39 - // Single-character: fetch lightweight metadata via server proxy 40 49 if (query.length === 1) { 41 50 try { 42 51 const meta = await $fetch<PackageMetaResponse>( ··· 57 66 } 58 67 } 59 68 60 - // Standard search 61 69 const params = new URLSearchParams() 62 70 params.set('text', query) 63 71 params.set('size', String(options.size ?? 25)) ··· 75 83 } 76 84 77 85 return { 78 - /** Search packages by text query */ 79 86 search, 87 + checkOrgExists, 88 + checkUserExists, 80 89 } 81 90 }
+248 -24
app/composables/npm/useSearch.ts
··· 1 1 import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' 2 2 import type { SearchProvider } from '~/composables/useSettings' 3 - import { emptySearchResponse } from './search-utils' 3 + import type { AlgoliaMultiSearchChecks } from './useAlgoliaSearch' 4 + import { type SearchSuggestion, emptySearchResponse, parseSuggestionIntent } from './search-utils' 5 + import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name' 4 6 5 7 export interface SearchOptions { 6 - /** Number of results to fetch */ 7 8 size?: number 8 9 } 9 10 11 + export interface UseSearchConfig { 12 + /** 13 + * Enable org/user suggestion and package-availability checks alongside search. 14 + * Algolia bundles these into the same multi-search request. 15 + * npm runs them as separate API calls in parallel. 16 + */ 17 + suggestions?: boolean 18 + } 19 + 10 20 export function useSearch( 11 21 query: MaybeRefOrGetter<string>, 12 22 options: MaybeRefOrGetter<SearchOptions> = {}, 23 + config: UseSearchConfig = {}, 13 24 ) { 14 25 const { searchProvider } = useSearchProvider() 15 - const { search: searchAlgolia } = useAlgoliaSearch() 16 - const { search: searchNpm } = useNpmSearch() 26 + const { search: searchAlgolia, searchWithSuggestions: algoliaMultiSearch } = useAlgoliaSearch() 27 + const { 28 + search: searchNpm, 29 + checkOrgExists: checkOrgNpm, 30 + checkUserExists: checkUserNpm, 31 + } = useNpmSearch() 17 32 18 33 const cache = shallowRef<{ 19 34 query: string ··· 23 38 } | null>(null) 24 39 25 40 const isLoadingMore = shallowRef(false) 41 + const isRateLimited = ref(false) 26 42 27 - const isRateLimited = ref(false) 43 + const suggestions = shallowRef<SearchSuggestion[]>([]) 44 + const suggestionsLoading = shallowRef(false) 45 + const packageAvailability = shallowRef<{ name: string; available: boolean } | null>(null) 46 + const existenceCache = shallowRef<Record<string, boolean>>({}) 47 + let suggestionRequestId = 0 48 + 49 + /** 50 + * Determine which extra checks to include in the Algolia multi-search. 51 + * Returns `undefined` when nothing uncached needs checking. 52 + */ 53 + function buildAlgoliaChecks(q: string): AlgoliaMultiSearchChecks | undefined { 54 + if (!config.suggestions) return undefined 55 + 56 + const { intent, name } = parseSuggestionIntent(q) 57 + const lowerName = name.toLowerCase() 58 + 59 + const checks: AlgoliaMultiSearchChecks = {} 60 + let hasChecks = false 61 + 62 + if (intent && name) { 63 + const wantOrg = intent === 'org' || intent === 'both' 64 + const wantUser = intent === 'user' || intent === 'both' 65 + 66 + if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) { 67 + checks.name = name 68 + checks.checkOrg = true 69 + hasChecks = true 70 + } 71 + if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) { 72 + checks.name = name 73 + checks.checkUser = true 74 + hasChecks = true 75 + } 76 + } 77 + 78 + const trimmed = q.trim() 79 + if (isValidNewPackageName(trimmed)) { 80 + checks.checkPackage = trimmed 81 + hasChecks = true 82 + } 83 + 84 + return hasChecks ? checks : undefined 85 + } 86 + 87 + /** 88 + * Update suggestion and package-availability state from multi-search results. 89 + * Only writes to the cache for checks that were actually sent; reads from 90 + * existing cache for the rest. 91 + */ 92 + function processAlgoliaChecks( 93 + q: string, 94 + checks: AlgoliaMultiSearchChecks | undefined, 95 + result: { orgExists: boolean; userExists: boolean; packageExists: boolean | null }, 96 + ) { 97 + const { intent, name } = parseSuggestionIntent(q) 98 + 99 + if (intent && name) { 100 + const lowerName = name.toLowerCase() 101 + const wantOrg = intent === 'org' || intent === 'both' 102 + const wantUser = intent === 'user' || intent === 'both' 103 + 104 + const updates: Record<string, boolean> = {} 105 + if (checks?.checkOrg) updates[`org:${lowerName}`] = result.orgExists 106 + if (checks?.checkUser) updates[`user:${lowerName}`] = result.userExists 107 + if (Object.keys(updates).length > 0) { 108 + existenceCache.value = { ...existenceCache.value, ...updates } 109 + } 110 + 111 + // Prefer org over user when both match (orgs always match owner.name too) 112 + const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`] 113 + const isUser = wantUser && existenceCache.value[`user:${lowerName}`] 114 + 115 + const newSuggestions: SearchSuggestion[] = [] 116 + if (isOrg) { 117 + newSuggestions.push({ type: 'org', name, exists: true }) 118 + } 119 + if (isUser && !isOrg) { 120 + newSuggestions.push({ type: 'user', name, exists: true }) 121 + } 122 + suggestions.value = newSuggestions 123 + } else { 124 + suggestions.value = [] 125 + } 126 + 127 + const trimmed = q.trim() 128 + if (result.packageExists !== null && isValidNewPackageName(trimmed)) { 129 + packageAvailability.value = { name: trimmed, available: !result.packageExists } 130 + } else if (!isValidNewPackageName(trimmed)) { 131 + packageAvailability.value = null 132 + } 133 + 134 + suggestionsLoading.value = false 135 + } 28 136 29 137 const asyncData = useLazyAsyncData( 30 138 () => `search:${searchProvider.value}:${toValue(query)}`, ··· 38 146 } 39 147 40 148 const opts = toValue(options) 41 - 42 149 cache.value = null 43 150 44 151 if (provider === 'algolia') { 45 - const response = await searchAlgolia(q, { 46 - size: opts.size ?? 25, 47 - }) 152 + const checks = config.suggestions ? buildAlgoliaChecks(q) : undefined 153 + 154 + if (config.suggestions) { 155 + suggestionsLoading.value = true 156 + const result = await algoliaMultiSearch(q, { size: opts.size ?? 25 }, checks) 157 + 158 + if (q !== toValue(query)) { 159 + return emptySearchResponse() 160 + } 48 161 49 - if (q !== toValue(query)) { 50 - return emptySearchResponse() 162 + isRateLimited.value = false 163 + processAlgoliaChecks(q, checks, result) 164 + return result.search 51 165 } 52 166 53 - isRateLimited.value = false 167 + const response = await searchAlgolia(q, { size: opts.size ?? 25 }) 54 168 55 - cache.value = { 56 - query: q, 57 - provider, 58 - objects: response.objects, 59 - total: response.total, 169 + if (q !== toValue(query)) { 170 + return emptySearchResponse() 60 171 } 61 172 173 + isRateLimited.value = false 62 174 return response 63 175 } 64 176 ··· 77 189 } 78 190 79 191 isRateLimited.value = false 80 - 81 192 return response 82 193 } catch (error: unknown) { 83 - // npm 429 responses lack CORS headers, so the browser reports "Failed to fetch" 84 194 const errorMessage = (error as { message?: string })?.message || String(error) 85 195 const isRateLimitError = 86 196 errorMessage.includes('Failed to fetch') || errorMessage.includes('429') ··· 110 220 return 111 221 } 112 222 223 + // Seed cache from asyncData for Algolia (which skips cache on initial fetch) 224 + if (!cache.value && asyncData.data.value) { 225 + const d = asyncData.data.value 226 + cache.value = { 227 + query: q, 228 + provider, 229 + objects: [...d.objects], 230 + total: d.total, 231 + } 232 + } 233 + 113 234 const currentCount = cache.value?.objects.length ?? 0 114 235 const total = cache.value?.total ?? Infinity 115 236 ··· 168 289 169 290 watch(searchProvider, async () => { 170 291 cache.value = null 292 + existenceCache.value = {} 171 293 await asyncData.refresh() 172 294 const targetSize = toValue(options).size 173 295 if (targetSize) { ··· 198 320 return cache.value.objects.length < cache.value.total 199 321 }) 200 322 323 + // npm suggestion checking (Algolia handles suggestions inside the search handler above) 324 + if (config.suggestions) { 325 + async function validateSuggestionsNpm(q: string) { 326 + const requestId = ++suggestionRequestId 327 + const { intent, name } = parseSuggestionIntent(q) 328 + 329 + const trimmed = q.trim() 330 + if (isValidNewPackageName(trimmed)) { 331 + checkPackageExists(trimmed) 332 + .then(exists => { 333 + if (trimmed === toValue(query).trim()) { 334 + packageAvailability.value = { name: trimmed, available: !exists } 335 + } 336 + }) 337 + .catch(() => { 338 + packageAvailability.value = null 339 + }) 340 + } else { 341 + packageAvailability.value = null 342 + } 343 + 344 + if (!intent || !name) { 345 + suggestions.value = [] 346 + suggestionsLoading.value = false 347 + return 348 + } 349 + 350 + suggestionsLoading.value = true 351 + const result: SearchSuggestion[] = [] 352 + const lowerName = name.toLowerCase() 353 + 354 + try { 355 + const wantOrg = intent === 'org' || intent === 'both' 356 + const wantUser = intent === 'user' || intent === 'both' 357 + 358 + const promises: Promise<void>[] = [] 359 + 360 + if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) { 361 + promises.push( 362 + checkOrgNpm(name) 363 + .then(exists => { 364 + existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: exists } 365 + }) 366 + .catch(() => { 367 + existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: false } 368 + }), 369 + ) 370 + } 371 + 372 + if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) { 373 + promises.push( 374 + checkUserNpm(name) 375 + .then(exists => { 376 + existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: exists } 377 + }) 378 + .catch(() => { 379 + existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: false } 380 + }), 381 + ) 382 + } 383 + 384 + if (promises.length > 0) { 385 + await Promise.all(promises) 386 + } 387 + 388 + if (requestId !== suggestionRequestId) return 389 + 390 + const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`] 391 + const isUser = wantUser && existenceCache.value[`user:${lowerName}`] 392 + 393 + if (isOrg) { 394 + result.push({ type: 'org', name, exists: true }) 395 + } 396 + if (isUser && !isOrg) { 397 + result.push({ type: 'user', name, exists: true }) 398 + } 399 + } finally { 400 + if (requestId === suggestionRequestId) { 401 + suggestionsLoading.value = false 402 + } 403 + } 404 + 405 + if (requestId === suggestionRequestId) { 406 + suggestions.value = result 407 + } 408 + } 409 + 410 + watch( 411 + () => toValue(query), 412 + q => { 413 + if (searchProvider.value !== 'algolia') { 414 + validateSuggestionsNpm(q) 415 + } 416 + }, 417 + { immediate: true }, 418 + ) 419 + 420 + watch(searchProvider, () => { 421 + if (searchProvider.value !== 'algolia') { 422 + validateSuggestionsNpm(toValue(query)) 423 + } 424 + }) 425 + } 426 + 201 427 return { 202 428 ...asyncData, 203 - /** Reactive search results (uses cache in incremental mode) */ 204 429 data, 205 - /** Whether currently loading more results */ 206 430 isLoadingMore, 207 - /** Whether there are more results available */ 208 431 hasMore, 209 - /** Manually fetch more results up to target size */ 210 432 fetchMore, 211 - /** Whether the search was rate limited by npm (429 error) */ 212 433 isRateLimited: readonly(isRateLimited), 434 + suggestions: readonly(suggestions), 435 + suggestionsLoading: readonly(suggestionsLoading), 436 + packageAvailability: readonly(packageAvailability), 213 437 } 214 438 }
+14 -3
app/pages/index.vue
··· 2 2 import { debounce } from 'perfect-debounce' 3 3 import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks' 4 4 5 + const { isAlgolia } = useSearchProvider() 6 + 5 7 const searchQuery = shallowRef('') 6 8 const isSearchFocused = shallowRef(false) 7 9 ··· 18 20 } 19 21 } 20 22 21 - const handleInput = isTouchDevice() 22 - ? search 23 - : debounce(search, 250, { leading: true, trailing: true }) 23 + const handleInputNpm = debounce(search, 250, { leading: true, trailing: true }) 24 + const handleInputAlgolia = debounce(search, 80, { leading: true, trailing: true }) 25 + 26 + function handleInput() { 27 + if (isTouchDevice()) { 28 + search() 29 + } else if (isAlgolia.value) { 30 + handleInputAlgolia() 31 + } else { 32 + handleInputNpm() 33 + } 34 + } 24 35 25 36 useSeoMeta({ 26 37 title: () => $t('seo.home.title'),
+11 -262
app/pages/search.vue
··· 3 3 import { parseSortOption, PROVIDER_SORT_KEYS } from '#shared/types/preferences' 4 4 import { onKeyDown } from '@vueuse/core' 5 5 import { debounce } from 'perfect-debounce' 6 - import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name' 6 + import { isValidNewPackageName } from '~/utils/package-name' 7 7 import { isPlatformSpecificPackage } from '~/utils/platform-packages' 8 8 import { normalizeSearchParam } from '#shared/utils/url' 9 9 ··· 11 11 const router = useRouter() 12 12 13 13 // Search provider 14 - const { search: algoliaSearch } = useAlgoliaSearch() 15 14 const { isAlgolia } = useSearchProvider() 16 15 17 16 // Preferences (persisted to localStorage) ··· 184 183 } 185 184 }) 186 185 187 - // Use incremental search with client-side caching 186 + // Use incremental search with client-side caching + org/user suggestions 188 187 const { 189 188 data: results, 190 189 status, ··· 192 191 hasMore, 193 192 fetchMore, 194 193 isRateLimited, 195 - } = useSearch(query, () => ({ 196 - size: requestedSize.value, 197 - })) 194 + suggestions: validatedSuggestions, 195 + packageAvailability, 196 + } = useSearch( 197 + query, 198 + () => ({ 199 + size: requestedSize.value, 200 + }), 201 + { suggestions: true }, 202 + ) 198 203 199 204 // Client-side sorted results for display 200 205 // The search API already handles text filtering, so we only need to sort. ··· 280 285 // Check if current query could be a valid package name 281 286 const isValidPackageName = computed(() => isValidNewPackageName(query.value.trim())) 282 287 283 - // Check if package name is available (doesn't exist on npm) 284 - const packageAvailability = shallowRef<{ name: string; available: boolean } | null>(null) 285 - 286 - // Debounced check for package availability 287 - const checkAvailability = debounce(async (name: string) => { 288 - if (!isValidNewPackageName(name)) { 289 - packageAvailability.value = null 290 - return 291 - } 292 - 293 - try { 294 - const exists = await checkPackageExists(name) 295 - // Only update if this is still the current query 296 - if (name === query.value.trim()) { 297 - packageAvailability.value = { name, available: !exists } 298 - } 299 - } catch { 300 - packageAvailability.value = null 301 - } 302 - }, 300) 303 - 304 - // Trigger availability check when query changes 305 - watch( 306 - query, 307 - q => { 308 - const trimmed = q.trim() 309 - if (isValidNewPackageName(trimmed)) { 310 - checkAvailability(trimmed) 311 - } else { 312 - packageAvailability.value = null 313 - } 314 - }, 315 - { immediate: true }, 316 - ) 317 - 318 288 // Get connector state 319 289 const { isConnected, npmUser, listOrgUsers } = useConnector() 320 290 ··· 376 346 }) 377 347 378 348 const claimPackageModalRef = useTemplateRef('claimPackageModalRef') 379 - 380 - /** 381 - * Check if a string is a valid npm username/org name 382 - * npm usernames: 1-214 characters, lowercase, alphanumeric, hyphen, underscore 383 - * Must not start with hyphen or underscore 384 - */ 385 - function isValidNpmName(name: string): boolean { 386 - if (!name || name.length === 0 || name.length > 214) return false 387 - // Must start with alphanumeric 388 - if (!/^[a-z0-9]/i.test(name)) return false 389 - // Can contain alphanumeric, hyphen, underscore 390 - return /^[\w-]+$/.test(name) 391 - } 392 - 393 - /** Validated user/org suggestion */ 394 - interface ValidatedSuggestion { 395 - type: 'user' | 'org' 396 - name: string 397 - exists: boolean 398 - } 399 - 400 - /** Cache for existence checks to avoid repeated API calls */ 401 - const existenceCache = ref<Record<string, boolean | 'pending'>>({}) 402 - 403 - /** 404 - * Check if an org exists by searching for scoped packages (@orgname/...). 405 - * When Algolia is active, searches for `@name/` scoped packages via text query. 406 - * Falls back to npm registry search API otherwise. 407 - */ 408 - async function checkOrgExists(name: string): Promise<boolean> { 409 - const cacheKey = `org:${name.toLowerCase()}` 410 - if (cacheKey in existenceCache.value) { 411 - const cached = existenceCache.value[cacheKey] 412 - return cached === true 413 - } 414 - existenceCache.value[cacheKey] = 'pending' 415 - try { 416 - const scopePrefix = `@${name.toLowerCase()}/` 417 - 418 - if (isAlgolia.value) { 419 - // Algolia: search for scoped packages โ€” use the scope as a text query 420 - // and verify a result actually starts with @name/ 421 - const response = await algoliaSearch(`@${name}`, { size: 5 }) 422 - const exists = response.objects.some(obj => 423 - obj.package.name.toLowerCase().startsWith(scopePrefix), 424 - ) 425 - existenceCache.value[cacheKey] = exists 426 - return exists 427 - } 428 - 429 - // npm registry: search for packages in the @org scope 430 - const response = await $fetch<{ total: number; objects: Array<{ package: { name: string } }> }>( 431 - `${NPM_REGISTRY}/-/v1/search`, 432 - { query: { text: `@${name}`, size: 5 } }, 433 - ) 434 - const exists = response.objects.some(obj => 435 - obj.package.name.toLowerCase().startsWith(scopePrefix), 436 - ) 437 - existenceCache.value[cacheKey] = exists 438 - return exists 439 - } catch { 440 - existenceCache.value[cacheKey] = false 441 - return false 442 - } 443 - } 444 - 445 - /** 446 - * Check if a user exists by searching for packages they maintain. 447 - * Always uses the npm registry `maintainer:` search because Algolia's 448 - * `owner.name` field represents the org/account, not individual maintainers, 449 - * and cannot reliably distinguish users from orgs. 450 - */ 451 - async function checkUserExists(name: string): Promise<boolean> { 452 - const cacheKey = `user:${name.toLowerCase()}` 453 - if (cacheKey in existenceCache.value) { 454 - const cached = existenceCache.value[cacheKey] 455 - return cached === true 456 - } 457 - existenceCache.value[cacheKey] = 'pending' 458 - try { 459 - const response = await $fetch<{ total: number }>(`${NPM_REGISTRY}/-/v1/search`, { 460 - query: { text: `maintainer:${name}`, size: 1 }, 461 - }) 462 - const exists = response.total > 0 463 - existenceCache.value[cacheKey] = exists 464 - return exists 465 - } catch { 466 - existenceCache.value[cacheKey] = false 467 - return false 468 - } 469 - } 470 - 471 - /** 472 - * Parse the search query to extract potential user/org name 473 - */ 474 - interface ParsedQuery { 475 - type: 'user' | 'org' | 'both' | null 476 - name: string 477 - } 478 - 479 - const parsedQuery = computed<ParsedQuery>(() => { 480 - const q = query.value.trim() 481 - if (!q) return { type: null, name: '' } 482 - 483 - // Query starts with ~ - explicit user search 484 - if (q.startsWith('~')) { 485 - const name = q.slice(1) 486 - if (isValidNpmName(name)) { 487 - return { type: 'user', name } 488 - } 489 - return { type: null, name: '' } 490 - } 491 - 492 - // Query starts with @ - org search (without slash) 493 - if (q.startsWith('@')) { 494 - // If it contains a slash, it's a scoped package search 495 - if (q.includes('/')) return { type: null, name: '' } 496 - const name = q.slice(1) 497 - if (isValidNpmName(name)) { 498 - return { type: 'org', name } 499 - } 500 - return { type: null, name: '' } 501 - } 502 - 503 - // Plain query - could be user, org, or package 504 - if (isValidNpmName(q)) { 505 - return { type: 'both', name: q } 506 - } 507 - 508 - return { type: null, name: '' } 509 - }) 510 - 511 - /** Validated suggestions (only those that exist) */ 512 - const validatedSuggestions = ref<ValidatedSuggestion[]>([]) 513 - const suggestionsLoading = shallowRef(false) 514 - 515 - /** Counter to discard stale async results when query changes rapidly */ 516 - let suggestionRequestId = 0 517 - 518 - /** Validate suggestions (check org/user existence) */ 519 - async function validateSuggestionsImpl(parsed: ParsedQuery) { 520 - const requestId = ++suggestionRequestId 521 - 522 - if (!parsed.type || !parsed.name) { 523 - validatedSuggestions.value = [] 524 - suggestionsLoading.value = false 525 - return 526 - } 527 - 528 - suggestionsLoading.value = true 529 - const suggestions: ValidatedSuggestion[] = [] 530 - 531 - try { 532 - if (parsed.type === 'user') { 533 - const exists = await checkUserExists(parsed.name) 534 - if (requestId !== suggestionRequestId) return 535 - if (exists) { 536 - suggestions.push({ type: 'user', name: parsed.name, exists: true }) 537 - } 538 - } else if (parsed.type === 'org') { 539 - const exists = await checkOrgExists(parsed.name) 540 - if (requestId !== suggestionRequestId) return 541 - if (exists) { 542 - suggestions.push({ type: 'org', name: parsed.name, exists: true }) 543 - } 544 - } else if (parsed.type === 'both') { 545 - // Check both in parallel 546 - const [orgExists, userExists] = await Promise.all([ 547 - checkOrgExists(parsed.name), 548 - checkUserExists(parsed.name), 549 - ]) 550 - if (requestId !== suggestionRequestId) return 551 - // Org first (more common) 552 - if (orgExists) { 553 - suggestions.push({ type: 'org', name: parsed.name, exists: true }) 554 - } 555 - if (userExists) { 556 - suggestions.push({ type: 'user', name: parsed.name, exists: true }) 557 - } 558 - } 559 - } finally { 560 - // Only clear loading if this is still the active request 561 - if (requestId === suggestionRequestId) { 562 - suggestionsLoading.value = false 563 - } 564 - } 565 - 566 - if (requestId === suggestionRequestId) { 567 - validatedSuggestions.value = suggestions 568 - } 569 - } 570 - 571 - // Debounce lightly for npm (extra API calls are slower), skip debounce for Algolia (fast) 572 - const validateSuggestionsDebounced = debounce(validateSuggestionsImpl, 100) 573 - 574 - // Validate suggestions when query changes 575 - watch( 576 - parsedQuery, 577 - parsed => { 578 - if (isAlgolia.value) { 579 - // Algolia existence checks are fast - fire immediately 580 - validateSuggestionsImpl(parsed) 581 - } else { 582 - validateSuggestionsDebounced(parsed) 583 - } 584 - }, 585 - { immediate: true }, 586 - ) 587 - 588 - // Re-validate suggestions and clear caches when provider changes 589 - watch(isAlgolia, () => { 590 - // Cancel any pending debounced validation from the previous provider 591 - validateSuggestionsDebounced.cancel?.() 592 - // Clear existence cache since results may differ between providers 593 - existenceCache.value = {} 594 - // Re-validate with current query 595 - const parsed = parsedQuery.value 596 - if (parsed.type) { 597 - validateSuggestionsImpl(parsed) 598 - } 599 - }) 600 349 601 350 /** Check if there's an exact package match in results */ 602 351 const hasExactPackageMatch = computed(() => {