Skip to content

Commit 2486671

Browse files
committed
perf: apply some speedups to algolia 🚀 and split out search suggestions logic
1 parent d2d893e commit 2486671

File tree

7 files changed

+484
-368
lines changed

7 files changed

+484
-368
lines changed

‎app/components/Header/SearchBox.vue‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const emit = defineEmits(['blur', 'focus'])
1515
1616
const router = useRouter()
1717
const route = useRoute()
18+
const { isAlgolia } = useSearchProvider()
1819
1920
const isSearchFocused = shallowRef(false)
2021
@@ -28,8 +29,7 @@ const searchQuery = shallowRef(normalizeSearchParam(route.query.q))
2829
// Pages that have their own local filter using ?q
2930
const pagesWithLocalFilter = new Set(['~username', 'org'])
3031
31-
// Debounced URL update for search query
32-
const updateUrlQuery = debounce((value: string) => {
32+
function updateUrlQueryImpl(value: string) {
3333
// Don't navigate away from pages that use ?q for local filtering
3434
if (pagesWithLocalFilter.has(route.name as string)) {
3535
return
@@ -48,9 +48,18 @@ const updateUrlQuery = debounce((value: string) => {
4848
q: value,
4949
},
5050
})
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+
)
5262
53-
// Watch input and debounce URL updates
5463
watch(searchQuery, value => {
5564
updateUrlQuery(value)
5665
})

‎app/composables/npm/search-utils.ts‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#shared/types'
22

3-
/**
4-
* Convert a lightweight package-meta API response to a search result for display.
5-
*/
63
export function metaToSearchResult(meta: PackageMetaResponse): NpmSearchResult {
74
return {
85
package: {
@@ -31,3 +28,36 @@ export function emptySearchResponse(): NpmSearchResponse {
3128
time: new Date().toISOString(),
3229
}
3330
}
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+
}

‎app/composables/npm/useAlgoliaSearch.ts‎

Lines changed: 135 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ import type { NpmSearchResponse, NpmSearchResult } from '#shared/types'
22
import {
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-
*/
119
let _searchClient: LiteClient | null = null
1210
let _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. */
4338
interface 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 */
6256
const 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+
7975
function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult {
8076
return {
8177
package: {
@@ -113,48 +109,53 @@ function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult {
113109
}
114110

115111
export 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
*/
131136
export 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

Comments
 (0)