forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}