[READ-ONLY] a fast, modern browser for the npm registry
at main 486 lines 15 kB view raw
1import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' 2import type { SearchProvider } from '~/composables/useSettings' 3import type { AlgoliaMultiSearchChecks } from './useAlgoliaSearch' 4import { type SearchSuggestion, emptySearchResponse, parseSuggestionIntent } from './search-utils' 5import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name' 6 7function emptySearchPayload() { 8 return { 9 searchResponse: emptySearchResponse(), 10 suggestions: [] as SearchSuggestion[], 11 packageAvailability: null as { name: string; available: boolean } | null, 12 } 13} 14 15export interface SearchOptions { 16 size?: number 17} 18 19export interface UseSearchConfig { 20 /** 21 * Enable org/user suggestion and package-availability checks alongside search. 22 * Algolia bundles these into the same multi-search request. 23 * npm runs them as separate API calls in parallel. 24 */ 25 suggestions?: boolean 26} 27 28export function useSearch( 29 query: MaybeRefOrGetter<string>, 30 searchProvider: MaybeRefOrGetter<SearchProvider>, 31 options: MaybeRefOrGetter<SearchOptions> = {}, 32 config: UseSearchConfig = {}, 33) { 34 const { search: searchAlgolia, searchWithSuggestions: algoliaMultiSearch } = useAlgoliaSearch() 35 const { 36 search: searchNpm, 37 checkOrgExists: checkOrgNpm, 38 checkUserExists: checkUserNpm, 39 } = useNpmSearch() 40 41 const cache = shallowRef<{ 42 query: string 43 provider: SearchProvider 44 objects: NpmSearchResult[] 45 total: number 46 } | null>(null) 47 48 const isLoadingMore = shallowRef(false) 49 const isRateLimited = shallowRef(false) 50 51 const suggestions = shallowRef<SearchSuggestion[]>([]) 52 const suggestionsLoading = shallowRef(false) 53 const packageAvailability = shallowRef<{ name: string; available: boolean } | null>(null) 54 const existenceCache = shallowRef<Record<string, boolean>>({}) 55 const suggestionRequestId = shallowRef(0) 56 57 /** 58 * Determine which extra checks to include in the Algolia multi-search. 59 * Returns `undefined` when nothing uncached needs checking. 60 */ 61 function buildAlgoliaChecks(q: string): AlgoliaMultiSearchChecks | undefined { 62 if (!config.suggestions) return undefined 63 64 const { intent, name } = parseSuggestionIntent(q) 65 const lowerName = name.toLowerCase() 66 67 const checks: AlgoliaMultiSearchChecks = {} 68 let hasChecks = false 69 70 if (intent && name) { 71 const wantOrg = intent === 'org' || intent === 'both' 72 const wantUser = intent === 'user' || intent === 'both' 73 74 if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) { 75 checks.name = name 76 checks.checkOrg = true 77 hasChecks = true 78 } 79 if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) { 80 checks.name = name 81 checks.checkUser = true 82 hasChecks = true 83 } 84 } 85 86 const trimmed = q.trim() 87 if (isValidNewPackageName(trimmed)) { 88 checks.checkPackage = trimmed 89 hasChecks = true 90 } 91 92 return hasChecks ? checks : undefined 93 } 94 95 /** 96 * Update suggestion and package-availability state from multi-search results. 97 * Only writes to the cache for checks that were actually sent; reads from 98 * existing cache for the rest. 99 */ 100 function processAlgoliaChecks( 101 q: string, 102 checks: AlgoliaMultiSearchChecks | undefined, 103 result: { orgExists: boolean; userExists: boolean; packageExists: boolean | null }, 104 ) { 105 const { intent, name } = parseSuggestionIntent(q) 106 107 if (intent && name) { 108 const lowerName = name.toLowerCase() 109 const wantOrg = intent === 'org' || intent === 'both' 110 const wantUser = intent === 'user' || intent === 'both' 111 112 const updates: Record<string, boolean> = {} 113 if (checks?.checkOrg) updates[`org:${lowerName}`] = result.orgExists 114 if (checks?.checkUser) updates[`user:${lowerName}`] = result.userExists 115 if (Object.keys(updates).length > 0) { 116 existenceCache.value = { ...existenceCache.value, ...updates } 117 } 118 119 // Prefer org over user when both match (orgs always match owner.name too) 120 const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`] 121 const isUser = wantUser && existenceCache.value[`user:${lowerName}`] 122 123 const newSuggestions: SearchSuggestion[] = [] 124 if (isOrg) { 125 newSuggestions.push({ type: 'org', name, exists: true }) 126 } 127 if (isUser && !isOrg) { 128 newSuggestions.push({ type: 'user', name, exists: true }) 129 } 130 suggestions.value = newSuggestions 131 } else { 132 suggestions.value = [] 133 } 134 135 const trimmed = q.trim() 136 if (result.packageExists !== null && isValidNewPackageName(trimmed)) { 137 packageAvailability.value = { name: trimmed, available: !result.packageExists } 138 } else if (!isValidNewPackageName(trimmed)) { 139 packageAvailability.value = null 140 } 141 142 suggestionsLoading.value = false 143 } 144 145 const asyncData = useLazyAsyncData( 146 () => `search:${toValue(searchProvider)}:${toValue(query)}`, 147 async (_nuxtApp, { signal }) => { 148 const q = toValue(query) 149 const provider = toValue(searchProvider) 150 151 if (!q.trim()) { 152 isRateLimited.value = false 153 return emptySearchPayload() 154 } 155 156 const opts = toValue(options) 157 cache.value = null 158 159 if (provider === 'algolia') { 160 const checks = config.suggestions ? buildAlgoliaChecks(q) : undefined 161 162 if (config.suggestions) { 163 suggestionsLoading.value = true 164 const result = await algoliaMultiSearch(q, { size: opts.size ?? 25 }, checks) 165 166 if (q !== toValue(query)) { 167 return emptySearchPayload() 168 } 169 170 isRateLimited.value = false 171 processAlgoliaChecks(q, checks, result) 172 return { 173 searchResponse: result.search, 174 suggestions: suggestions.value, 175 packageAvailability: packageAvailability.value, 176 } 177 } 178 179 const response = await searchAlgolia(q, { size: opts.size ?? 25 }) 180 181 if (q !== toValue(query)) { 182 return emptySearchPayload() 183 } 184 185 isRateLimited.value = false 186 return { 187 searchResponse: response, 188 suggestions: [], 189 packageAvailability: null, 190 } 191 } 192 193 try { 194 const response = await searchNpm(q, { size: opts.size ?? 25 }, signal) 195 196 if (q !== toValue(query)) { 197 return emptySearchPayload() 198 } 199 200 cache.value = { 201 query: q, 202 provider, 203 objects: response.objects, 204 total: response.total, 205 } 206 207 isRateLimited.value = false 208 return { 209 searchResponse: response, 210 suggestions: [], 211 packageAvailability: null, 212 } 213 } catch (error: unknown) { 214 const errorMessage = (error as { message?: string })?.message || String(error) 215 const isRateLimitError = 216 errorMessage.includes('Failed to fetch') || errorMessage.includes('429') 217 218 if (isRateLimitError) { 219 isRateLimited.value = true 220 return emptySearchPayload() 221 } 222 throw error 223 } 224 }, 225 { default: emptySearchPayload }, 226 ) 227 228 async function fetchMore(targetSize: number): Promise<void> { 229 const q = toValue(query).trim() 230 const provider = toValue(searchProvider) 231 232 if (!q) { 233 cache.value = null 234 return 235 } 236 237 if (cache.value && (cache.value.query !== q || cache.value.provider !== provider)) { 238 cache.value = null 239 await asyncData.refresh() 240 return 241 } 242 243 // Seed cache from asyncData for Algolia (which skips cache on initial fetch) 244 if (!cache.value && asyncData.data.value) { 245 const { searchResponse } = asyncData.data.value 246 cache.value = { 247 query: q, 248 provider, 249 objects: [...searchResponse.objects], 250 total: searchResponse.total, 251 } 252 } 253 254 const currentCount = cache.value?.objects.length ?? 0 255 const total = cache.value?.total ?? Infinity 256 257 if (currentCount >= targetSize || currentCount >= total) { 258 return 259 } 260 261 isLoadingMore.value = true 262 263 try { 264 const from = currentCount 265 const size = Math.min(targetSize - currentCount, total - currentCount) 266 267 const doSearch = provider === 'algolia' ? searchAlgolia : searchNpm 268 const response = await doSearch(q, { size, from }) 269 270 if (cache.value && cache.value.query === q && cache.value.provider === provider) { 271 const existingNames = new Set(cache.value.objects.map(obj => obj.package.name)) 272 const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name)) 273 cache.value = { 274 query: q, 275 provider, 276 objects: [...cache.value.objects, ...newObjects], 277 total: response.total, 278 } 279 } else { 280 cache.value = { 281 query: q, 282 provider, 283 objects: response.objects, 284 total: response.total, 285 } 286 } 287 288 if ( 289 cache.value && 290 cache.value.objects.length < targetSize && 291 cache.value.objects.length < cache.value.total 292 ) { 293 await fetchMore(targetSize) 294 } 295 } finally { 296 isLoadingMore.value = false 297 } 298 } 299 300 watch( 301 () => toValue(options).size, 302 async (newSize, oldSize) => { 303 if (!newSize) return 304 if (oldSize && newSize > oldSize && toValue(query).trim()) { 305 await fetchMore(newSize) 306 } 307 }, 308 ) 309 310 watch( 311 () => toValue(searchProvider), 312 async () => { 313 cache.value = null 314 existenceCache.value = {} 315 await asyncData.refresh() 316 const targetSize = toValue(options).size 317 if (targetSize) { 318 await fetchMore(targetSize) 319 } 320 }, 321 ) 322 323 const data = computed<NpmSearchResponse | null>(() => { 324 if (cache.value) { 325 return { 326 isStale: false, 327 objects: cache.value.objects, 328 total: cache.value.total, 329 time: new Date().toISOString(), 330 } 331 } 332 return asyncData.data.value?.searchResponse ?? null 333 }) 334 335 const hasMore = computed(() => { 336 if (!cache.value) return true 337 return cache.value.objects.length < cache.value.total 338 }) 339 340 async function validateSuggestionsNpm(q: string) { 341 const requestId = ++suggestionRequestId.value 342 const { intent, name } = parseSuggestionIntent(q) 343 let availability: { name: string; available: boolean } | null = null 344 345 const promises: Promise<void>[] = [] 346 347 const trimmed = q.trim() 348 if (isValidNewPackageName(trimmed)) { 349 promises.push( 350 checkPackageExists(trimmed) 351 .then(exists => { 352 if (trimmed === toValue(query).trim()) { 353 availability = { name: trimmed, available: !exists } 354 packageAvailability.value = availability 355 } 356 }) 357 .catch(() => { 358 availability = null 359 }), 360 ) 361 } else { 362 availability = null 363 } 364 365 if (!intent || !name) { 366 suggestionsLoading.value = false 367 await Promise.all(promises) 368 return { suggestions: [], packageAvailability: availability } 369 } 370 371 suggestionsLoading.value = true 372 const result: SearchSuggestion[] = [] 373 const lowerName = name.toLowerCase() 374 375 try { 376 const wantOrg = intent === 'org' || intent === 'both' 377 const wantUser = intent === 'user' || intent === 'both' 378 379 if (wantOrg && existenceCache.value[`org:${lowerName}`] === undefined) { 380 promises.push( 381 checkOrgNpm(name) 382 .then(exists => { 383 existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: exists } 384 }) 385 .catch(() => { 386 existenceCache.value = { ...existenceCache.value, [`org:${lowerName}`]: false } 387 }), 388 ) 389 } 390 391 if (wantUser && existenceCache.value[`user:${lowerName}`] === undefined) { 392 promises.push( 393 checkUserNpm(name) 394 .then(exists => { 395 existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: exists } 396 }) 397 .catch(() => { 398 existenceCache.value = { ...existenceCache.value, [`user:${lowerName}`]: false } 399 }), 400 ) 401 } 402 403 if (promises.length > 0) { 404 await Promise.all(promises) 405 } 406 407 if (requestId !== suggestionRequestId.value) 408 return { suggestions: [], packageAvailability: availability } 409 410 const isOrg = wantOrg && existenceCache.value[`org:${lowerName}`] 411 const isUser = wantUser && existenceCache.value[`user:${lowerName}`] 412 413 if (isOrg) { 414 result.push({ type: 'org', name, exists: true }) 415 } 416 if (isUser && !isOrg) { 417 result.push({ type: 'user', name, exists: true }) 418 } 419 } finally { 420 if (requestId === suggestionRequestId.value) { 421 suggestionsLoading.value = false 422 } 423 } 424 425 if (requestId === suggestionRequestId.value) { 426 suggestions.value = result 427 return { suggestions: result, packageAvailability: availability } 428 } 429 430 return { suggestions: [], packageAvailability: availability } 431 } 432 433 const npmSuggestions = useLazyAsyncData( 434 () => `npm-suggestions:${toValue(searchProvider)}:${toValue(query)}`, 435 async () => { 436 const q = toValue(query).trim() 437 if (toValue(searchProvider) === 'algolia' || !q) 438 return { suggestions: [], packageAvailability: null } 439 const { intent, name } = parseSuggestionIntent(q) 440 if (!intent || !name) return { suggestions: [], packageAvailability: null } 441 return validateSuggestionsNpm(q) 442 }, 443 { default: () => ({ suggestions: [], packageAvailability: null }) }, 444 ) 445 446 watch( 447 [() => asyncData.data.value.suggestions, () => npmSuggestions.data.value.suggestions], 448 ([algoliaSuggestions, npmSuggestionsValue]) => { 449 if (algoliaSuggestions.length || npmSuggestionsValue.length) { 450 suggestions.value = algoliaSuggestions.length ? algoliaSuggestions : npmSuggestionsValue 451 } 452 }, 453 { immediate: true }, 454 ) 455 456 watch( 457 [ 458 () => asyncData.data.value?.packageAvailability, 459 () => npmSuggestions.data.value.packageAvailability, 460 ], 461 ([algoliaPackageAvailability, npmPackageAvailability]) => { 462 if (algoliaPackageAvailability || npmPackageAvailability) { 463 packageAvailability.value = algoliaPackageAvailability || npmPackageAvailability 464 } 465 }, 466 { immediate: true }, 467 ) 468 469 if (import.meta.client && asyncData.data.value?.searchResponse.isStale) { 470 onMounted(() => { 471 asyncData.refresh() 472 }) 473 } 474 475 return { 476 ...asyncData, 477 data, 478 isLoadingMore, 479 hasMore, 480 fetchMore, 481 isRateLimited: readonly(isRateLimited), 482 suggestions: readonly(suggestions), 483 suggestionsLoading: readonly(suggestionsLoading), 484 packageAvailability: readonly(packageAvailability), 485 } 486}