Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions app/composables/npm/useAlgoliaSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}
Expand Down
50 changes: 41 additions & 9 deletions app/composables/npm/useOrgPackages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) {
const route = useRoute()
Expand All @@ -24,7 +45,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
async ({ ssrContext }, { signal }) => {
const org = toValue(orgName)
if (!org) {
return emptySearchResponse()
return emptyOrgResponse()
}

// Get the authoritative package list from the npm registry (single request)
Expand Down Expand Up @@ -53,15 +74,25 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
}

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
Expand Down Expand Up @@ -92,10 +123,11 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
isStale: false,
objects: results,
total: results.length,
totalPackages,
time: new Date().toISOString(),
} satisfies NpmSearchResponse
} satisfies OrgPackagesResponse
},
{ default: emptySearchResponse },
{ default: emptyOrgResponse },
)

return asyncData
Expand Down
14 changes: 12 additions & 2 deletions app/pages/org/[org].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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',
})
</script>
Expand All @@ -163,7 +167,13 @@ defineOgImageComponent('Default', {
<div>
<h1 class="font-mono text-2xl sm:text-3xl font-medium">@{{ orgName }}</h1>
<p v-if="status === 'success'" class="text-fg-muted text-sm mt-1">
{{ $t('org.public_packages', { count: $n(packageCount) }, packageCount) }}
{{
$t(
'org.public_packages',
{ count: $n(totalPackages || packageCount) },
totalPackages || packageCount,
)
}}
</p>
</div>

Expand Down
Loading