Skip to content

Commit 8296a24

Browse files
committed
fix: refine and improve
1 parent 8cd2aa0 commit 8296a24

File tree

6 files changed

+119
-101
lines changed

6 files changed

+119
-101
lines changed

app/components/SearchProviderToggle.client.vue

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,15 @@ useEventListener('keydown', event => {
1717

1818
<template>
1919
<div ref="toggleRef" class="relative">
20-
<button
21-
type="button"
22-
class="flex items-center justify-center w-8 h-8 rounded-md text-fg-subtle hover:text-fg hover:bg-bg-elevated transition-colors duration-200 focus-visible:outline-accent/70"
20+
<ButtonBase
2321
:aria-label="$t('settings.search_provider')"
2422
:aria-expanded="isOpen"
2523
aria-haspopup="true"
24+
size="small"
25+
class="border-none w-8 h-8 !px-0 justify-center"
26+
classicon="i-carbon:settings"
2627
@click="isOpen = !isOpen"
27-
>
28-
<span class="i-carbon:settings w-4 h-4" aria-hidden="true" />
29-
</button>
28+
/>
3029

3130
<Transition
3231
enter-active-class="transition-all duration-150"

app/composables/npm/useAlgoliaSearch.ts

Lines changed: 93 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,15 @@ import {
66
} from 'algoliasearch/lite'
77

88
/**
9-
* Algolia search client for npm packages.
10-
* Credentials and index name come from runtimeConfig.public.algolia.
9+
* Singleton Algolia client, keyed by appId to handle config changes.
1110
*/
1211
let _searchClient: LiteClient | null = null
1312
let _configuredAppId: string | null = null
1413

15-
function getAlgoliaClient(): LiteClient {
16-
const { algolia } = useRuntimeConfig().public
17-
// Re-create client if app ID changed (shouldn't happen, but be safe)
18-
if (!_searchClient || _configuredAppId !== algolia.appId) {
19-
_searchClient = algoliasearch(algolia.appId, algolia.apiKey)
20-
_configuredAppId = algolia.appId
14+
function getOrCreateClient(appId: string, apiKey: string): LiteClient {
15+
if (!_searchClient || _configuredAppId !== appId) {
16+
_searchClient = algoliasearch(appId, apiKey)
17+
_configuredAppId = appId
2118
}
2219
return _searchClient
2320
}
@@ -125,92 +122,111 @@ export interface AlgoliaSearchOptions {
125122
}
126123

127124
/**
128-
* Search npm packages via Algolia.
129-
* Returns results in the same NpmSearchResponse format as the npm registry API.
130-
*/
131-
export async function searchAlgolia(
132-
query: string,
133-
options: AlgoliaSearchOptions = {},
134-
): Promise<NpmSearchResponse> {
135-
const client = getAlgoliaClient()
136-
137-
const { results } = await client.search([
138-
{
139-
indexName: 'npm-search',
140-
params: {
141-
query,
142-
offset: options.from,
143-
length: options.size,
144-
filters: options.filters || '',
145-
analyticsTags: ['npmx.dev'],
146-
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
147-
attributesToHighlight: [],
148-
},
149-
},
150-
])
151-
152-
const response = results[0] as SearchResponse<AlgoliaHit>
153-
154-
return {
155-
isStale: false,
156-
objects: response.hits.map(hitToSearchResult),
157-
total: response.nbHits!,
158-
time: new Date().toISOString(),
159-
}
160-
}
161-
162-
/**
163-
* Fetch all packages in an Algolia scope (org or user).
164-
* Uses facet filters for efficient server-side filtering.
125+
* Composable that provides Algolia search functions for npm packages.
165126
*
166-
* For orgs: filters by `owner.name:orgname` which matches scoped packages.
167-
* For users: filters by `owner.name:username` which matches maintainer.
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.).
168130
*/
169-
export async function searchAlgoliaByOwner(
170-
ownerName: string,
171-
options: { maxResults?: number } = {},
172-
): Promise<NpmSearchResponse> {
173-
const client = getAlgoliaClient()
174-
const max = options.maxResults ?? 1000
175-
176-
const allHits: AlgoliaHit[] = []
177-
let offset = 0
178-
const batchSize = 200
179-
180-
// Algolia supports up to 1000 results per query with offset/length pagination
181-
while (offset < max) {
182-
const length = Math.min(batchSize, max - offset)
183-
131+
export function useAlgoliaSearch() {
132+
const { algolia } = useRuntimeConfig().public
133+
const client = getOrCreateClient(algolia.appId, algolia.apiKey)
134+
const indexName = algolia.indexName
135+
136+
/**
137+
* Search npm packages via Algolia.
138+
* Returns results in the same NpmSearchResponse format as the npm registry API.
139+
*/
140+
async function search(
141+
query: string,
142+
options: AlgoliaSearchOptions = {},
143+
): Promise<NpmSearchResponse> {
184144
const { results } = await client.search([
185145
{
186-
indexName: 'npm-search',
146+
indexName,
187147
params: {
188-
query: '',
189-
offset,
190-
length,
191-
filters: `owner.name:${ownerName}`,
148+
query,
149+
offset: options.from,
150+
length: options.size,
151+
filters: options.filters || '',
192152
analyticsTags: ['npmx.dev'],
193153
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
194154
attributesToHighlight: [],
195155
},
196156
},
197157
])
198158

199-
const response = results[0] as SearchResponse<AlgoliaHit>
200-
allHits.push(...response.hits)
159+
const response = results[0] as SearchResponse<AlgoliaHit> | undefined
160+
if (!response) {
161+
return { isStale: false, objects: [], total: 0, time: new Date().toISOString() }
162+
}
163+
164+
return {
165+
isStale: false,
166+
objects: response.hits.map(hitToSearchResult),
167+
total: response.nbHits ?? 0,
168+
time: new Date().toISOString(),
169+
}
170+
}
171+
172+
/**
173+
* Fetch all packages for an Algolia owner (org or user).
174+
* Uses `owner.name` filter for efficient server-side filtering.
175+
*/
176+
async function searchByOwner(
177+
ownerName: string,
178+
options: { maxResults?: number } = {},
179+
): Promise<NpmSearchResponse> {
180+
const max = options.maxResults ?? 1000
181+
182+
const allHits: AlgoliaHit[] = []
183+
let offset = 0
184+
const batchSize = 200
185+
186+
// Algolia supports up to 1000 results per query with offset/length pagination
187+
while (offset < max) {
188+
const length = Math.min(batchSize, max - offset)
189+
190+
const { results } = await client.search([
191+
{
192+
indexName,
193+
params: {
194+
query: '',
195+
offset,
196+
length,
197+
filters: `owner.name:${ownerName}`,
198+
analyticsTags: ['npmx.dev'],
199+
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
200+
attributesToHighlight: [],
201+
},
202+
},
203+
])
204+
205+
const response = results[0] as SearchResponse<AlgoliaHit> | undefined
206+
if (!response) break
201207

202-
// If we got fewer than requested, we've exhausted all results
203-
if (response.hits.length < length || allHits.length >= response.nbHits!) {
204-
break
208+
allHits.push(...response.hits)
209+
210+
// If we got fewer than requested, we've exhausted all results
211+
if (response.hits.length < length || allHits.length >= (response.nbHits ?? 0)) {
212+
break
213+
}
214+
215+
offset += length
205216
}
206217

207-
offset += length
218+
return {
219+
isStale: false,
220+
objects: allHits.map(hitToSearchResult),
221+
total: allHits.length,
222+
time: new Date().toISOString(),
223+
}
208224
}
209225

210226
return {
211-
isStale: false,
212-
objects: allHits.map(hitToSearchResult),
213-
total: allHits.length,
214-
time: new Date().toISOString(),
227+
/** Search packages by text query */
228+
search,
229+
/** Fetch all packages for an owner (org or user) */
230+
searchByOwner,
215231
}
216232
}

app/composables/npm/useNpmSearch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import type {
66
MinimalPackument,
77
} from '#shared/types'
88
import type { SearchProvider } from '~/composables/useSettings'
9-
import { searchAlgolia } from './useAlgoliaSearch'
109

1110
/**
1211
* Convert packument to search result format for display
@@ -58,6 +57,7 @@ export function useNpmSearch(
5857
) {
5958
const { $npmRegistry } = useNuxtApp()
6059
const { searchProvider } = useSearchProvider()
60+
const { search: searchAlgolia } = useAlgoliaSearch()
6161

6262
// Client-side cache
6363
const cache = shallowRef<{

app/composables/npm/useOrgPackages.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { NuxtApp } from '#app'
22
import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types'
33
import { emptySearchResponse, packumentToSearchResult } from './useNpmSearch'
4-
import { searchAlgoliaByOwner } from './useAlgoliaSearch'
54
import { mapWithConcurrency } from '#shared/utils/async'
65

76
/**
@@ -86,6 +85,7 @@ async function fetchBulkDownloads(
8685
*/
8786
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
8887
const { searchProvider } = useSearchProvider()
88+
const { searchByOwner } = useAlgoliaSearch()
8989

9090
const asyncData = useLazyAsyncData(
9191
() => `org-packages:${searchProvider.value}:${toValue(orgName)}`,
@@ -98,7 +98,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
9898
// --- Algolia fast path ---
9999
if (searchProvider.value === 'algolia') {
100100
try {
101-
return await searchAlgoliaByOwner(org)
101+
return await searchByOwner(org)
102102
} catch {
103103
// Fall through to npm registry path on Algolia failure
104104
}

app/composables/npm/useUserPackages.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { NpmSearchResponse, NpmSearchResult } from '#shared/types'
22
import { emptySearchResponse } from './useNpmSearch'
3-
import { searchAlgoliaByOwner } from './useAlgoliaSearch'
43

54
/** Default page size for incremental loading (npm registry path) */
65
const PAGE_SIZE = 50 as const
@@ -25,6 +24,7 @@ const MAX_RESULTS = 250
2524
export function useUserPackages(username: MaybeRefOrGetter<string>) {
2625
const { searchProvider } = useSearchProvider()
2726
const { $npmRegistry } = useNuxtApp()
27+
const { searchByOwner } = useAlgoliaSearch()
2828

2929
// --- Incremental loading state (npm path) ---
3030
const currentPage = shallowRef(1)
@@ -50,7 +50,13 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
5050
// --- Algolia: fetch all at once ---
5151
if (provider === 'algolia') {
5252
try {
53-
const response = await searchAlgoliaByOwner(user)
53+
const response = await searchByOwner(user)
54+
55+
// Guard against stale response (user/provider changed during await)
56+
if (user !== toValue(username) || provider !== searchProvider.value) {
57+
return emptySearchResponse
58+
}
59+
5460
cache.value = {
5561
username: user,
5662
objects: response.objects,
@@ -76,6 +82,11 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
7682
60,
7783
)
7884

85+
// Guard against stale response (user/provider changed during await)
86+
if (user !== toValue(username) || provider !== searchProvider.value) {
87+
return emptySearchResponse
88+
}
89+
7990
cache.value = {
8091
username: user,
8192
objects: response.objects,
@@ -89,7 +100,8 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
89100
// --- Fetch more (npm path only) ---
90101
async function fetchMore(): Promise<void> {
91102
const user = toValue(username)
92-
if (!user || searchProvider.value !== 'npm') return
103+
const provider = searchProvider.value
104+
if (!user || provider !== 'npm') return
93105

94106
if (cache.value && cache.value.username !== user) {
95107
cache.value = null
@@ -119,6 +131,9 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
119131
60,
120132
)
121133

134+
// Guard against stale response
135+
if (user !== toValue(username) || provider !== searchProvider.value) return
136+
122137
if (cache.value && cache.value.username === user) {
123138
const existingNames = new Set(cache.value.objects.map(obj => obj.package.name))
124139
const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name))

app/pages/search.vue

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@ import { normalizeSearchParam } from '#shared/utils/url'
99
const route = useRoute()
1010
const router = useRouter()
1111
12-
// Search provider
13-
const { isAlgolia } = useSearchProvider()
14-
1512
// Preferences (persisted to localStorage)
1613
const {
1714
viewMode,
@@ -760,15 +757,6 @@ defineOgImageComponent('Default', {
760757
<span v-if="status === 'pending'" class="text-fg-subtle">{{
761758
$t('search.updating')
762759
}}</span>
763-
<a
764-
v-if="isAlgolia"
765-
href="https://www.algolia.com/developers"
766-
target="_blank"
767-
rel="noopener noreferrer"
768-
class="text-fg-subtle hover:text-fg-muted text-xs ms-2"
769-
>
770-
{{ $t('search.algolia_disclaimer') }}
771-
</a>
772760
</p>
773761
<!-- Show "x of y packages" (paginated/table mode only) -->
774762
<p

0 commit comments

Comments
 (0)