|
1 | | -import type { NuxtApp } from '#app' |
2 | 1 | import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types' |
3 | 2 | import { emptySearchResponse, packumentToSearchResult } from './useNpmSearch' |
4 | 3 | import { mapWithConcurrency } from '#shared/utils/async' |
5 | 4 |
|
6 | | -/** |
7 | | - * Fetch downloads for multiple packages. |
8 | | - * Returns a map of package name -> weekly downloads. |
9 | | - * Uses bulk API for unscoped packages, parallel individual requests for scoped. |
10 | | - * Note: npm bulk downloads API does not support scoped packages. |
11 | | - */ |
12 | | -async function fetchBulkDownloads( |
13 | | - $npmApi: NuxtApp['$npmApi'], |
14 | | - packageNames: string[], |
15 | | - options: Parameters<typeof $fetch>[1] = {}, |
16 | | -): Promise<Map<string, number>> { |
17 | | - const downloads = new Map<string, number>() |
18 | | - if (packageNames.length === 0) return downloads |
19 | | - |
20 | | - // Separate scoped and unscoped packages |
21 | | - const scopedPackages = packageNames.filter(n => n.startsWith('@')) |
22 | | - const unscopedPackages = packageNames.filter(n => !n.startsWith('@')) |
23 | | - |
24 | | - // Fetch unscoped packages via bulk API (max 128 per request) |
25 | | - const bulkPromises: Promise<void>[] = [] |
26 | | - const chunkSize = 100 |
27 | | - for (let i = 0; i < unscopedPackages.length; i += chunkSize) { |
28 | | - const chunk = unscopedPackages.slice(i, i + chunkSize) |
29 | | - bulkPromises.push( |
30 | | - (async () => { |
31 | | - try { |
32 | | - const response = await $npmApi<Record<string, { downloads: number } | null>>( |
33 | | - `/downloads/point/last-week/${chunk.join(',')}`, |
34 | | - options, |
35 | | - ) |
36 | | - for (const [name, data] of Object.entries(response.data)) { |
37 | | - if (data?.downloads !== undefined) { |
38 | | - downloads.set(name, data.downloads) |
39 | | - } |
40 | | - } |
41 | | - } catch { |
42 | | - // Ignore errors - downloads are optional |
43 | | - } |
44 | | - })(), |
45 | | - ) |
46 | | - } |
47 | | - |
48 | | - // Fetch scoped packages in parallel batches (concurrency limit to avoid overwhelming the API) |
49 | | - // Use Promise.allSettled to not fail on individual errors |
50 | | - const scopedBatchSize = 20 // Concurrent requests per batch |
51 | | - for (let i = 0; i < scopedPackages.length; i += scopedBatchSize) { |
52 | | - const batch = scopedPackages.slice(i, i + scopedBatchSize) |
53 | | - bulkPromises.push( |
54 | | - (async () => { |
55 | | - const results = await Promise.allSettled( |
56 | | - batch.map(async name => { |
57 | | - const encoded = encodePackageName(name) |
58 | | - const { data } = await $npmApi<{ downloads: number }>( |
59 | | - `/downloads/point/last-week/${encoded}`, |
60 | | - ) |
61 | | - return { name, downloads: data.downloads } |
62 | | - }), |
63 | | - ) |
64 | | - for (const result of results) { |
65 | | - if (result.status === 'fulfilled' && result.value.downloads !== undefined) { |
66 | | - downloads.set(result.value.name, result.value.downloads) |
67 | | - } |
68 | | - } |
69 | | - })(), |
70 | | - ) |
71 | | - } |
72 | | - |
73 | | - // Wait for all fetches to complete |
74 | | - await Promise.all(bulkPromises) |
75 | | - |
76 | | - return downloads |
77 | | -} |
78 | | - |
79 | 5 | /** |
80 | 6 | * Fetch all packages for an npm organization. |
81 | 7 | * |
82 | | - * Always uses the npm registry's org endpoint as the source of truth for which |
83 | | - * packages belong to the org. When Algolia is enabled, uses it to quickly fetch |
84 | | - * metadata for those packages (instead of N+1 packument fetches). |
| 8 | + * 1. Gets the authoritative package list from the npm registry (single request) |
| 9 | + * 2. Fetches metadata from Algolia by exact name (single request) |
| 10 | + * 3. Falls back to individual packument fetches when Algolia is unavailable |
85 | 11 | */ |
86 | 12 | export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { |
87 | 13 | const { searchProvider } = useSearchProvider() |
88 | | - const { searchByOwner } = useAlgoliaSearch() |
| 14 | + const { getPackagesByName } = useAlgoliaSearch() |
89 | 15 |
|
90 | 16 | const asyncData = useLazyAsyncData( |
91 | 17 | () => `org-packages:${searchProvider.value}:${toValue(orgName)}`, |
92 | | - async ({ $npmRegistry, $npmApi, ssrContext }, { signal }) => { |
| 18 | + async ({ $npmRegistry, ssrContext }, { signal }) => { |
93 | 19 | const org = toValue(orgName) |
94 | 20 | if (!org) { |
95 | 21 | return emptySearchResponse |
96 | 22 | } |
97 | 23 |
|
98 | | - // Always get the authoritative package list from the npm registry. |
99 | | - // Algolia's owner.name filter doesn't precisely match npm org membership |
100 | | - // (e.g. it includes @nuxtjs/* packages for the @nuxt org). |
| 24 | + // Get the authoritative package list from the npm registry (single request) |
101 | 25 | let packageNames: string[] |
102 | 26 | try { |
103 | 27 | const { packages } = await $fetch<{ packages: string[]; count: number }>( |
@@ -126,60 +50,41 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { |
126 | 50 | return emptySearchResponse |
127 | 51 | } |
128 | 52 |
|
129 | | - // --- Algolia fast path: use Algolia to get metadata for known packages --- |
| 53 | + // Fetch metadata + downloads from Algolia (single request via getObjects) |
130 | 54 | if (searchProvider.value === 'algolia') { |
131 | 55 | try { |
132 | | - const response = await searchByOwner(org) |
| 56 | + const response = await getPackagesByName(packageNames) |
133 | 57 | if (response.objects.length > 0) { |
134 | | - // Filter Algolia results to only include packages that are |
135 | | - // actually in the org (per the npm registry's authoritative list) |
136 | | - const orgPackageSet = new Set(packageNames.map(n => n.toLowerCase())) |
137 | | - const filtered = response.objects.filter(obj => |
138 | | - orgPackageSet.has(obj.package.name.toLowerCase()), |
139 | | - ) |
140 | | - |
141 | | - if (filtered.length > 0) { |
142 | | - return { |
143 | | - ...response, |
144 | | - objects: filtered, |
145 | | - total: filtered.length, |
146 | | - } |
147 | | - } |
| 58 | + return response |
148 | 59 | } |
149 | 60 | } catch { |
150 | 61 | // Fall through to npm registry path |
151 | 62 | } |
152 | 63 | } |
153 | 64 |
|
154 | | - // --- npm registry path: fetch packuments individually --- |
155 | | - const [packuments, downloads] = await Promise.all([ |
156 | | - (async () => { |
157 | | - const results = await mapWithConcurrency( |
158 | | - packageNames, |
159 | | - async name => { |
160 | | - try { |
161 | | - const encoded = encodePackageName(name) |
162 | | - const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, { |
163 | | - signal, |
164 | | - }) |
165 | | - return pkg |
166 | | - } catch { |
167 | | - return null |
168 | | - } |
169 | | - }, |
170 | | - 10, |
171 | | - ) |
172 | | - return results.filter( |
173 | | - (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'], |
174 | | - ) |
175 | | - })(), |
176 | | - fetchBulkDownloads($npmApi, packageNames, { signal }), |
177 | | - ]) |
| 65 | + // npm fallback: fetch packuments individually |
| 66 | + const packuments = await mapWithConcurrency( |
| 67 | + packageNames, |
| 68 | + async name => { |
| 69 | + try { |
| 70 | + const encoded = encodePackageName(name) |
| 71 | + const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, { |
| 72 | + signal, |
| 73 | + }) |
| 74 | + return pkg |
| 75 | + } catch { |
| 76 | + return null |
| 77 | + } |
| 78 | + }, |
| 79 | + 10, |
| 80 | + ) |
178 | 81 |
|
179 | | - const results: NpmSearchResult[] = packuments.map(pkg => |
180 | | - packumentToSearchResult(pkg, downloads.get(pkg.name)), |
| 82 | + const validPackuments = packuments.filter( |
| 83 | + (pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'], |
181 | 84 | ) |
182 | 85 |
|
| 86 | + const results: NpmSearchResult[] = validPackuments.map(pkg => packumentToSearchResult(pkg)) |
| 87 | + |
183 | 88 | return { |
184 | 89 | isStale: false, |
185 | 90 | objects: results, |
|
0 commit comments