@@ -2,12 +2,10 @@ import type { NpmSearchResponse, NpmSearchResult } from '#shared/types'
22import {
33 liteClient as algoliasearch ,
44 type LiteClient ,
5+ type SearchQuery ,
56 type SearchResponse ,
67} from 'algoliasearch/lite'
78
8- /**
9- * Singleton Algolia client, keyed by appId to handle config changes.
10- */
119let _searchClient : LiteClient | null = null
1210let _configuredAppId : string | null = null
1311
@@ -36,10 +34,7 @@ interface AlgoliaRepo {
3634 branch ?: string
3735}
3836
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. */
4338interface AlgoliaHit {
4439 objectID : string
4540 name : string
@@ -58,7 +53,6 @@ interface AlgoliaHit {
5853 license : string | null
5954}
6055
61- /** Fields we always request from Algolia to keep payload small */
6256const ATTRIBUTES_TO_RETRIEVE = [
6357 'name' ,
6458 'version' ,
@@ -76,6 +70,8 @@ const ATTRIBUTES_TO_RETRIEVE = [
7670 'license' ,
7771]
7872
73+ const EXISTENCE_CHECK_ATTRS = [ 'name' ]
74+
7975function hitToSearchResult ( hit : AlgoliaHit ) : NpmSearchResult {
8076 return {
8177 package : {
@@ -113,48 +109,53 @@ function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult {
113109}
114110
115111export interface AlgoliaSearchOptions {
116- /** Number of results */
117112 size ?: number
118- /** Offset for pagination */
119113 from ?: number
120- /** Algolia filters expression (e.g. 'owner.name:username') */
121114 filters ?: string
122115}
123116
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+
124132/**
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.
130135 */
131136export function useAlgoliaSearch ( ) {
132137 const { algolia } = useRuntimeConfig ( ) . public
133138 const client = getOrCreateClient ( algolia . appId , algolia . apiKey )
134139 const indexName = algolia . indexName
135140
136- /**
137- * Search npm packages via Algolia.
138- * Returns results in the same NpmSearchResponse format as the npm registry API.
139- */
140141 async function search (
141142 query : string ,
142143 options : AlgoliaSearchOptions = { } ,
143144 ) : Promise < NpmSearchResponse > {
144- const { results } = await client . search ( [
145- {
146- indexName ,
147- params : {
145+ const { results } = await client . search ( {
146+ requests : [
147+ {
148+ indexName ,
148149 query,
149150 offset : options . from ,
150151 length : options . size ,
151152 filters : options . filters || '' ,
152153 analyticsTags : [ 'npmx.dev' ] ,
153154 attributesToRetrieve : ATTRIBUTES_TO_RETRIEVE ,
154155 attributesToHighlight : [ ] ,
155- } ,
156- } ,
157- ] )
156+ } satisfies SearchQuery ,
157+ ] ,
158+ } )
158159
159160 const response = results [ 0 ] as SearchResponse < AlgoliaHit > | undefined
160161 if ( ! response ) {
@@ -169,10 +170,7 @@ export function useAlgoliaSearch() {
169170 }
170171 }
171172
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. */
176174 async function searchByOwner (
177175 ownerName : string ,
178176 options : { maxResults ?: number } = { } ,
@@ -184,35 +182,32 @@ export function useAlgoliaSearch() {
184182 let serverTotal = 0
185183 const batchSize = 200
186184
187- // Algolia supports up to 1000 results per query with offset/length pagination
188185 while ( offset < max ) {
189- // Cap at both the configured max and the server's actual total (once known)
190186 const remaining = serverTotal > 0 ? Math . min ( max , serverTotal ) - offset : max - offset
191187 if ( remaining <= 0 ) break
192188 const length = Math . min ( batchSize , remaining )
193189
194- const { results } = await client . search ( [
195- {
196- indexName ,
197- params : {
190+ const { results } = await client . search ( {
191+ requests : [
192+ {
193+ indexName ,
198194 query : '' ,
199195 offset,
200196 length,
201197 filters : `owner.name:${ ownerName } ` ,
202198 analyticsTags : [ 'npmx.dev' ] ,
203199 attributesToRetrieve : ATTRIBUTES_TO_RETRIEVE ,
204200 attributesToHighlight : [ ] ,
205- } ,
206- } ,
207- ] )
201+ } satisfies SearchQuery ,
202+ ] ,
203+ } )
208204
209205 const response = results [ 0 ] as SearchResponse < AlgoliaHit > | undefined
210206 if ( ! response ) break
211207
212208 serverTotal = response . nbHits ?? 0
213209 allHits . push ( ...response . hits )
214210
215- // If we got fewer than requested, we've exhausted all results
216211 if ( response . hits . length < length || allHits . length >= serverTotal ) {
217212 break
218213 }
@@ -223,23 +218,17 @@ export function useAlgoliaSearch() {
223218 return {
224219 isStale : false ,
225220 objects : allHits . map ( hitToSearchResult ) ,
226- // Use server total so callers can detect truncation (allHits.length < total)
227221 total : serverTotal ,
228222 time : new Date ( ) . toISOString ( ) ,
229223 }
230224 }
231225
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. */
237227 async function getPackagesByName ( packageNames : string [ ] ) : Promise < NpmSearchResponse > {
238228 if ( packageNames . length === 0 ) {
239229 return { isStale : false , objects : [ ] , total : 0 , time : new Date ( ) . toISOString ( ) }
240230 }
241231
242- // Algolia getObjects REST API: fetch up to 1000 objects by ID in a single request
243232 const response = await $fetch < { results : ( AlgoliaHit | null ) [ ] } > (
244233 `https://${ algolia . appId } -dsn.algolia.net/1/indexes/*/objects` ,
245234 {
@@ -267,12 +256,107 @@ export function useAlgoliaSearch() {
267256 }
268257 }
269258
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+
270356 return {
271- /** Search packages by text query */
272357 search,
273- /** Fetch all packages for an owner (org or user) */
358+ searchWithSuggestions ,
274359 searchByOwner,
275- /** Fetch metadata for specific packages by exact name */
276360 getPackagesByName,
277361 }
278362}
0 commit comments