Skip to content

Commit 608361d

Browse files
committed
fix: source profile data from npm
fix: use dedicated useUserPackages composable with correct 404 handling
1 parent 7f1e259 commit 608361d

File tree

2 files changed

+56
-226
lines changed

2 files changed

+56
-226
lines changed
Lines changed: 52 additions & 207 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,9 @@
1-
/** Default page size for incremental loading (npm registry path) */
2-
const PAGE_SIZE = 50 as const
3-
4-
/** npm search API practical limit for maintainer queries */
5-
const MAX_RESULTS = 250
6-
71
/**
8-
* Fetch packages for a given npm user/maintainer.
9-
*
10-
* The composable handles all loading strategy internally based on the active
11-
* search provider. Consumers get a uniform interface regardless of provider:
12-
*
13-
* - **Algolia**: Fetches all packages at once via `owner.name` filter (fast).
14-
* - **npm**: Incrementally paginates through `maintainer:` search results.
2+
* Fetch all packages for a given npm user.
153
*
16-
* @example
17-
* ```ts
18-
* const { data, status, hasMore, isLoadingMore, loadMore } = useUserPackages(username)
19-
* ```
4+
* Mirrors {@link useOrgPackages} — both use the same npm registry endpoint
5+
* (`/-/org/<name>/package`) which accepts usernames and org names alike.
6+
* The only difference: unknown users return an empty list instead of a 404.
207
*/
218
export function useUserPackages(username: MaybeRefOrGetter<string>) {
229
const route = useRoute()
@@ -26,24 +13,7 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
2613
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
2714
return 'algolia'
2815
})
29-
// this is only used in npm path, but we need to extract it when the composable runs
30-
const { $npmRegistry } = useNuxtApp()
31-
const { searchByOwner } = useAlgoliaSearch()
32-
33-
// --- Incremental loading state (npm path) ---
34-
const currentPage = shallowRef(1)
35-
36-
/** Tracks which provider actually served the current data (may differ from
37-
* searchProvider when Algolia returns empty and we fall through to npm) */
38-
const activeProvider = shallowRef<'npm' | 'algolia'>(searchProviderValue.value)
39-
40-
const cache = shallowRef<{
41-
username: string
42-
objects: NpmSearchResult[]
43-
total: number
44-
} | null>(null)
45-
46-
const isLoadingMore = shallowRef(false)
16+
const { getPackagesByName } = useAlgoliaSearch()
4717

4818
const asyncData = useLazyAsyncData(
4919
() => `user-packages:${searchProviderValue.value}:${toValue(username)}`,
@@ -53,197 +23,72 @@ export function useUserPackages(username: MaybeRefOrGetter<string>) {
5323
return emptySearchResponse()
5424
}
5525

56-
const provider = searchProviderValue.value
26+
let packageNames: string[]
27+
try {
28+
const { packages } = await $fetch<{ packages: string[]; count: number }>(
29+
`/api/registry/org/${encodeURIComponent(user)}/packages`,
30+
{ signal },
31+
)
32+
packageNames = packages
33+
} catch {
34+
// Unknown user or network error — show empty state, not a 404
35+
return emptySearchResponse()
36+
}
5737

58-
// --- Algolia: fetch all at once ---
59-
if (provider === 'algolia') {
60-
try {
61-
const response = await searchByOwner(user)
38+
if (user !== toValue(username)) {
39+
return emptySearchResponse()
40+
}
6241

63-
// Guard against stale response (user/provider changed during await)
64-
if (user !== toValue(username) || provider !== searchProviderValue.value) {
42+
if (packageNames.length === 0) {
43+
return emptySearchResponse()
44+
}
45+
46+
if (searchProviderValue.value === 'algolia') {
47+
try {
48+
const response = await getPackagesByName(packageNames)
49+
if (user !== toValue(username)) {
6550
return emptySearchResponse()
6651
}
67-
68-
// If Algolia returns results, use them. If empty, fall through to npm
69-
// registry which uses `maintainer:` search (matches all maintainers,
70-
// not just the primary owner that Algolia's owner.name indexes).
7152
if (response.objects.length > 0) {
72-
activeProvider.value = 'algolia'
73-
cache.value = {
74-
username: user,
75-
objects: response.objects,
76-
total: response.total,
77-
}
7853
return response
7954
}
8055
} catch {
81-
// Fall through to npm registry path on Algolia failure
56+
// Fall through to npm registry path
8257
}
8358
}
8459

85-
// --- npm registry: initial page (or Algolia fallback) ---
86-
activeProvider.value = 'npm'
87-
cache.value = null
88-
currentPage.value = 1
89-
90-
const params = new URLSearchParams()
91-
params.set('text', `maintainer:${user}`)
92-
params.set('size', String(PAGE_SIZE))
93-
94-
const { data: response, isStale } = await $npmRegistry<NpmSearchResponse>(
95-
`/-/v1/search?${params.toString()}`,
96-
{ signal },
97-
60,
60+
const metaResults = await mapWithConcurrency(
61+
packageNames,
62+
async name => {
63+
try {
64+
return await $fetch<PackageMetaResponse>(
65+
`/api/registry/package-meta/${encodePackageName(name)}`,
66+
{ signal },
67+
)
68+
} catch {
69+
return null
70+
}
71+
},
72+
10,
9873
)
9974

100-
// Guard against stale response (user/provider changed during await)
101-
if (user !== toValue(username) || provider !== searchProviderValue.value) {
75+
if (user !== toValue(username)) {
10276
return emptySearchResponse()
10377
}
10478

105-
cache.value = {
106-
username: user,
107-
objects: response.objects,
108-
total: response.total,
109-
}
110-
111-
return { ...response, isStale }
112-
},
113-
{ default: emptySearchResponse },
114-
)
115-
// --- Fetch more (npm path only) ---
116-
/**
117-
* Fetch the next page of results from npm registry.
118-
* @param manageLoadingState - When false, caller manages isLoadingMore (used by loadAll to prevent flicker)
119-
*/
120-
async function fetchMore(manageLoadingState = true): Promise<void> {
121-
const user = toValue(username)
122-
// Use activeProvider: if Algolia fell through to npm, we still need pagination
123-
if (!user || activeProvider.value !== 'npm') return
124-
125-
if (cache.value && cache.value.username !== user) {
126-
cache.value = null
127-
await asyncData.refresh()
128-
return
129-
}
79+
const results: NpmSearchResult[] = metaResults
80+
.filter((meta): meta is PackageMetaResponse => meta !== null)
81+
.map(metaToSearchResult)
13082

131-
const currentCount = cache.value?.objects.length ?? 0
132-
const total = Math.min(cache.value?.total ?? Infinity, MAX_RESULTS)
133-
134-
if (currentCount >= total) return
135-
136-
if (manageLoadingState) isLoadingMore.value = true
137-
138-
try {
139-
const from = currentCount
140-
const size = Math.min(PAGE_SIZE, total - currentCount)
141-
142-
const params = new URLSearchParams()
143-
params.set('text', `maintainer:${user}`)
144-
params.set('size', String(size))
145-
params.set('from', String(from))
146-
147-
const { data: response } = await $npmRegistry<NpmSearchResponse>(
148-
`/-/v1/search?${params.toString()}`,
149-
{},
150-
60,
151-
)
152-
153-
// Guard against stale response
154-
if (user !== toValue(username) || activeProvider.value !== 'npm') return
155-
156-
if (cache.value && cache.value.username === user) {
157-
const existingNames = new Set(cache.value.objects.map(obj => obj.package.name))
158-
const newObjects = response.objects.filter(obj => !existingNames.has(obj.package.name))
159-
cache.value = {
160-
username: user,
161-
objects: [...cache.value.objects, ...newObjects],
162-
total: response.total,
163-
}
164-
} else {
165-
cache.value = {
166-
username: user,
167-
objects: response.objects,
168-
total: response.total,
169-
}
170-
}
171-
} finally {
172-
if (manageLoadingState) isLoadingMore.value = false
173-
}
174-
}
175-
176-
/** Load the next page of results (no-op if all loaded or using Algolia) */
177-
async function loadMore(): Promise<void> {
178-
if (isLoadingMore.value || !hasMore.value) return
179-
currentPage.value++
180-
await fetchMore()
181-
}
182-
183-
/** Load all remaining results at once (e.g. when user starts filtering) */
184-
async function loadAll(): Promise<void> {
185-
if (!hasMore.value) return
186-
187-
isLoadingMore.value = true
188-
try {
189-
while (hasMore.value) {
190-
await fetchMore(false)
191-
}
192-
} finally {
193-
isLoadingMore.value = false
194-
}
195-
}
196-
197-
// asyncdata will automatically rerun due to key, but we need to reset cache/page
198-
// when provider changes
199-
watch(
200-
() => searchProviderValue.value,
201-
newProvider => {
202-
cache.value = null
203-
currentPage.value = 1
204-
activeProvider.value = newProvider
205-
},
206-
)
207-
208-
// Computed data that uses cache (only if it belongs to the current username)
209-
const data = computed<NpmSearchResponse | null>(() => {
210-
const user = toValue(username)
211-
if (cache.value && cache.value.username === user) {
21283
return {
21384
isStale: false,
214-
objects: cache.value.objects,
215-
total: cache.value.total,
85+
objects: results,
86+
total: results.length,
21687
time: new Date().toISOString(),
217-
}
218-
}
219-
return asyncData.data.value
220-
})
221-
222-
/** Whether there are more results available to load (npm path only) */
223-
const hasMore = computed(() => {
224-
if (!toValue(username)) return false
225-
// Algolia fetches everything in one request; only npm needs pagination
226-
if (activeProvider.value !== 'npm') return false
227-
if (!cache.value) return true
228-
// npm path: more available if we haven't hit the server total or our cap
229-
const fetched = cache.value.objects.length
230-
const available = cache.value.total
231-
return fetched < available && fetched < MAX_RESULTS
232-
})
88+
} satisfies NpmSearchResponse
89+
},
90+
{ default: emptySearchResponse },
91+
)
23392

234-
return {
235-
...asyncData,
236-
/** Reactive package results */
237-
data,
238-
/** Whether currently loading more results */
239-
isLoadingMore,
240-
/** Whether there are more results available */
241-
hasMore,
242-
/** Load next page of results */
243-
loadMore,
244-
/** Load all remaining results (for filter/sort) */
245-
loadAll,
246-
/** Default page size (for display) */
247-
pageSize: PAGE_SIZE,
248-
}
93+
return asyncData
24994
}

app/pages/~[username]/index.vue

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,25 +32,14 @@ const debouncedUpdateUrl = debounce((filter: string, sort: string) => {
3232
updateUrl({ filter, sort })
3333
}, 300)
3434
35-
// Load all results when user starts filtering/sorting (so client-side filter works on full set)
35+
// Update URL when filter/sort changes (debounced)
3636
watch([filterText, sortOption], ([filter, sort]) => {
37-
if (filter !== '' || sort !== 'downloads') {
38-
loadAll()
39-
}
4037
debouncedUpdateUrl(filter, sort)
4138
})
4239
43-
// Fetch packages (composable manages pagination & provider dispatch internally)
44-
const {
45-
data: results,
46-
status,
47-
error,
48-
isLoadingMore,
49-
hasMore,
50-
loadMore,
51-
loadAll,
52-
pageSize,
53-
} = useUserPackages(username)
40+
// Fetch packages from npm registry (same endpoint as org page, but
41+
// unknown users get empty results instead of a 404 error page)
42+
const { data: results, status, error } = useUserPackages(username)
5443
5544
// Get initial page from URL (for scroll restoration on reload)
5645
const initialPage = computed(() => {
@@ -217,11 +206,7 @@ defineOgImageComponent('Default', {
217206
<PackageList
218207
v-else
219208
:results="filteredAndSortedPackages"
220-
:has-more="hasMore"
221-
:is-loading="isLoadingMore"
222-
:page-size="pageSize"
223209
:initial-page="initialPage"
224-
@load-more="loadMore"
225210
@page-change="handlePageChange"
226211
/>
227212
</section>

0 commit comments

Comments
 (0)