diff --git a/app/composables/npm/useAlgoliaSearch.ts b/app/composables/npm/useAlgoliaSearch.ts index eb10a2e7ae..fbb5580fc0 100644 --- a/app/composables/npm/useAlgoliaSearch.ts +++ b/app/composables/npm/useAlgoliaSearch.ts @@ -229,29 +229,38 @@ export function useAlgoliaSearch() { return { isStale: false, objects: [], total: 0, time: new Date().toISOString() } } - const response = await $fetch<{ results: (AlgoliaHit | null)[] }>( - `https://${algolia.appId}-dsn.algolia.net/1/indexes/*/objects`, - { - method: 'POST', - headers: { - 'x-algolia-api-key': algolia.apiKey, - 'x-algolia-application-id': algolia.appId, - }, - body: { - requests: packageNames.map(name => ({ - indexName, - objectID: name, - attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, - })), + // Algolia getObjects has a limit of 1000 objects per request, so batch if needed + const BATCH_SIZE = 1000 + const allHits: AlgoliaHit[] = [] + + for (let i = 0; i < packageNames.length; i += BATCH_SIZE) { + const batch = packageNames.slice(i, i + BATCH_SIZE) + const response = await $fetch<{ results: (AlgoliaHit | null)[] }>( + `https://${algolia.appId}-dsn.algolia.net/1/indexes/*/objects`, + { + method: 'POST', + headers: { + 'x-algolia-api-key': algolia.apiKey, + 'x-algolia-application-id': algolia.appId, + }, + body: { + requests: batch.map(name => ({ + indexName, + objectID: name, + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, + })), + }, }, - }, - ) + ) + + const hits = response.results.filter((r): r is AlgoliaHit => r !== null && 'name' in r) + allHits.push(...hits) + } - const hits = response.results.filter((r): r is AlgoliaHit => r !== null && 'name' in r) return { isStale: false, - objects: hits.map(hitToSearchResult), - total: hits.length, + objects: allHits.map(hitToSearchResult), + total: allHits.length, time: new Date().toISOString(), } } diff --git a/app/composables/npm/useOrgPackages.ts b/app/composables/npm/useOrgPackages.ts index 3e1321d605..9460cfb637 100644 --- a/app/composables/npm/useOrgPackages.ts +++ b/app/composables/npm/useOrgPackages.ts @@ -3,11 +3,32 @@ import { emptySearchResponse, metaToSearchResult } from './search-utils' import { mapWithConcurrency } from '#shared/utils/async' /** - * Fetch all packages for an npm organization. + * Maximum number of packages to fetch metadata for. + * Large orgs (e.g. @types with 8000+ packages) would otherwise trigger + * thousands of network requests, causing severe performance degradation. + * Algolia batches in chunks of 1000; npm fallback fetches individually. + */ +const MAX_ORG_PACKAGES = 1000 + +export interface OrgPackagesResponse extends NpmSearchResponse { + /** Total number of packages in the org (may exceed objects.length if capped) */ + totalPackages: number +} + +function emptyOrgResponse(): OrgPackagesResponse { + return { + ...emptySearchResponse(), + totalPackages: 0, + } +} + +/** + * Fetch packages for an npm organization. * * 1. Gets the authoritative package list from the npm registry (single request) - * 2. Fetches metadata from Algolia by exact name (single request) - * 3. Falls back to lightweight server-side package-meta lookups + * 2. Caps to MAX_ORG_PACKAGES to prevent excessive network requests + * 3. Fetches metadata from Algolia by exact name (batched in chunks of 1000) + * 4. Falls back to lightweight server-side package-meta lookups */ export function useOrgPackages(orgName: MaybeRefOrGetter) { const route = useRoute() @@ -24,7 +45,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { async ({ ssrContext }, { signal }) => { const org = toValue(orgName) if (!org) { - return emptySearchResponse() + return emptyOrgResponse() } // Get the authoritative package list from the npm registry (single request) @@ -53,15 +74,25 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { } if (packageNames.length === 0) { - return emptySearchResponse() + return emptyOrgResponse() + } + + const totalPackages = packageNames.length + + // Cap the number of packages to fetch metadata for + if (packageNames.length > MAX_ORG_PACKAGES) { + packageNames = packageNames.slice(0, MAX_ORG_PACKAGES) } - // Fetch metadata + downloads from Algolia (single request via getObjects) + // Fetch metadata + downloads from Algolia (batched in chunks of 1000) if (searchProviderValue.value === 'algolia') { try { const response = await getPackagesByName(packageNames) if (response.objects.length > 0) { - return response + return { + ...response, + totalPackages, + } satisfies OrgPackagesResponse } } catch { // Fall through to npm registry path @@ -92,10 +123,11 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { isStale: false, objects: results, total: results.length, + totalPackages, time: new Date().toISOString(), - } satisfies NpmSearchResponse + } satisfies OrgPackagesResponse }, - { default: emptySearchResponse }, + { default: emptyOrgResponse }, ) return asyncData diff --git a/app/pages/org/[org].vue b/app/pages/org/[org].vue index 37e439ad02..fda052902b 100644 --- a/app/pages/org/[org].vue +++ b/app/pages/org/[org].vue @@ -33,6 +33,7 @@ watch( ) const packages = computed(() => results.value?.objects ?? []) +const totalPackages = computed(() => results.value?.totalPackages ?? 0) const packageCount = computed(() => packages.value.length) // Preferences (persisted to localStorage) @@ -141,7 +142,10 @@ useSeoMeta({ defineOgImageComponent('Default', { title: () => `@${orgName.value}`, - description: () => (packageCount.value ? `${packageCount.value} packages` : 'npm organization'), + description: () => + totalPackages.value || packageCount.value + ? `${totalPackages.value || packageCount.value} packages` + : 'npm organization', primaryColor: '#60a5fa', }) @@ -163,7 +167,13 @@ defineOgImageComponent('Default', {

@{{ orgName }}

- {{ $t('org.public_packages', { count: $n(packageCount) }, packageCount) }} + {{ + $t( + 'org.public_packages', + { count: $n(totalPackages || packageCount) }, + totalPackages || packageCount, + ) + }}