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 */
218export 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}
0 commit comments