|
| 1 | +import type { NpmSearchResponse, NpmSearchResult } from '#shared/types' |
| 2 | +import { |
| 3 | + liteClient as algoliasearch, |
| 4 | + type LiteClient, |
| 5 | + type SearchResponse, |
| 6 | +} from 'algoliasearch/lite' |
| 7 | + |
| 8 | +/** |
| 9 | + * Algolia search client for npm packages. |
| 10 | + * Uses npm's public Algolia index (same as npmjs.com). |
| 11 | + */ |
| 12 | +let _searchClient: LiteClient | null = null |
| 13 | + |
| 14 | +function getAlgoliaClient(): LiteClient { |
| 15 | + if (!_searchClient) { |
| 16 | + // npm's public search-only Algolia credentials (same as npmjs.com uses) |
| 17 | + _searchClient = algoliasearch('OFCNCOG2CU', 'f54e21fa3a2a0160595bb058179bfb1e') |
| 18 | + } |
| 19 | + return _searchClient |
| 20 | +} |
| 21 | + |
| 22 | +interface AlgoliaOwner { |
| 23 | + name: string |
| 24 | + email?: string |
| 25 | + avatar?: string |
| 26 | + link?: string |
| 27 | +} |
| 28 | + |
| 29 | +interface AlgoliaRepo { |
| 30 | + url: string |
| 31 | + host: string |
| 32 | + user: string |
| 33 | + project: string |
| 34 | + path: string |
| 35 | + head?: string |
| 36 | + branch?: string |
| 37 | +} |
| 38 | + |
| 39 | +/** |
| 40 | + * Shape of a hit from the Algolia `npm-search` index. |
| 41 | + * Only includes fields we retrieve via `attributesToRetrieve`. |
| 42 | + */ |
| 43 | +interface AlgoliaHit { |
| 44 | + objectID: string |
| 45 | + name: string |
| 46 | + version: string |
| 47 | + description: string | null |
| 48 | + modified: number |
| 49 | + homepage: string | null |
| 50 | + repository: AlgoliaRepo | null |
| 51 | + owners: AlgoliaOwner[] | null |
| 52 | + downloadsLast30Days: number |
| 53 | + downloadsRatio: number |
| 54 | + popular: boolean |
| 55 | + keywords: string[] |
| 56 | + deprecated: boolean | string |
| 57 | + isDeprecated: boolean |
| 58 | + license: string | null |
| 59 | +} |
| 60 | + |
| 61 | +/** Fields we always request from Algolia to keep payload small */ |
| 62 | +const ATTRIBUTES_TO_RETRIEVE = [ |
| 63 | + 'name', |
| 64 | + 'version', |
| 65 | + 'description', |
| 66 | + 'modified', |
| 67 | + 'homepage', |
| 68 | + 'repository', |
| 69 | + 'owners', |
| 70 | + 'downloadsLast30Days', |
| 71 | + 'downloadsRatio', |
| 72 | + 'popular', |
| 73 | + 'keywords', |
| 74 | + 'deprecated', |
| 75 | + 'isDeprecated', |
| 76 | + 'license', |
| 77 | +] |
| 78 | + |
| 79 | +function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult { |
| 80 | + return { |
| 81 | + package: { |
| 82 | + name: hit.name, |
| 83 | + version: hit.version, |
| 84 | + description: hit.description || '', |
| 85 | + keywords: hit.keywords, |
| 86 | + date: new Date(hit.modified).toISOString(), |
| 87 | + links: { |
| 88 | + npm: `https://www.npmjs.com/package/${hit.name}`, |
| 89 | + homepage: hit.homepage || undefined, |
| 90 | + repository: hit.repository?.url || undefined, |
| 91 | + }, |
| 92 | + maintainers: hit.owners |
| 93 | + ? hit.owners.map(owner => ({ |
| 94 | + name: owner.name, |
| 95 | + email: owner.email, |
| 96 | + })) |
| 97 | + : [], |
| 98 | + }, |
| 99 | + score: { |
| 100 | + final: 0, |
| 101 | + detail: { |
| 102 | + quality: hit.popular ? 1 : 0, |
| 103 | + popularity: hit.downloadsRatio, |
| 104 | + maintenance: 0, |
| 105 | + }, |
| 106 | + }, |
| 107 | + searchScore: 0, |
| 108 | + downloads: { |
| 109 | + weekly: Math.round(hit.downloadsLast30Days / 4.3), |
| 110 | + }, |
| 111 | + updated: new Date(hit.modified).toISOString(), |
| 112 | + } |
| 113 | +} |
| 114 | + |
| 115 | +export interface AlgoliaSearchOptions { |
| 116 | + /** Number of results */ |
| 117 | + size?: number |
| 118 | + /** Offset for pagination */ |
| 119 | + from?: number |
| 120 | + /** Algolia filters expression (e.g. 'owner.name:username') */ |
| 121 | + filters?: string |
| 122 | +} |
| 123 | + |
| 124 | +/** |
| 125 | + * Search npm packages via Algolia. |
| 126 | + * Returns results in the same NpmSearchResponse format as the npm registry API. |
| 127 | + */ |
| 128 | +export async function searchAlgolia( |
| 129 | + query: string, |
| 130 | + options: AlgoliaSearchOptions = {}, |
| 131 | +): Promise<NpmSearchResponse> { |
| 132 | + const client = getAlgoliaClient() |
| 133 | + |
| 134 | + const { results } = await client.search([ |
| 135 | + { |
| 136 | + indexName: 'npm-search', |
| 137 | + params: { |
| 138 | + query, |
| 139 | + offset: options.from, |
| 140 | + length: options.size, |
| 141 | + filters: options.filters || '', |
| 142 | + analyticsTags: ['npmx.dev'], |
| 143 | + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, |
| 144 | + attributesToHighlight: [], |
| 145 | + }, |
| 146 | + }, |
| 147 | + ]) |
| 148 | + |
| 149 | + const response = results[0] as SearchResponse<AlgoliaHit> |
| 150 | + |
| 151 | + return { |
| 152 | + isStale: false, |
| 153 | + objects: response.hits.map(hitToSearchResult), |
| 154 | + total: response.nbHits!, |
| 155 | + time: new Date().toISOString(), |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +/** |
| 160 | + * Fetch all packages in an Algolia scope (org or user). |
| 161 | + * Uses facet filters for efficient server-side filtering. |
| 162 | + * |
| 163 | + * For orgs: filters by `owner.name:orgname` which matches scoped packages. |
| 164 | + * For users: filters by `owner.name:username` which matches maintainer. |
| 165 | + */ |
| 166 | +export async function searchAlgoliaByOwner( |
| 167 | + ownerName: string, |
| 168 | + options: { maxResults?: number } = {}, |
| 169 | +): Promise<NpmSearchResponse> { |
| 170 | + const client = getAlgoliaClient() |
| 171 | + const max = options.maxResults ?? 1000 |
| 172 | + |
| 173 | + const allHits: AlgoliaHit[] = [] |
| 174 | + let offset = 0 |
| 175 | + const batchSize = 200 |
| 176 | + |
| 177 | + // Algolia supports up to 1000 results per query with offset/length pagination |
| 178 | + while (offset < max) { |
| 179 | + const length = Math.min(batchSize, max - offset) |
| 180 | + |
| 181 | + const { results } = await client.search([ |
| 182 | + { |
| 183 | + indexName: 'npm-search', |
| 184 | + params: { |
| 185 | + query: '', |
| 186 | + offset, |
| 187 | + length, |
| 188 | + filters: `owner.name:${ownerName}`, |
| 189 | + analyticsTags: ['npmx.dev'], |
| 190 | + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, |
| 191 | + attributesToHighlight: [], |
| 192 | + }, |
| 193 | + }, |
| 194 | + ]) |
| 195 | + |
| 196 | + const response = results[0] as SearchResponse<AlgoliaHit> |
| 197 | + allHits.push(...response.hits) |
| 198 | + |
| 199 | + // If we got fewer than requested, we've exhausted all results |
| 200 | + if (response.hits.length < length || allHits.length >= response.nbHits!) { |
| 201 | + break |
| 202 | + } |
| 203 | + |
| 204 | + offset += length |
| 205 | + } |
| 206 | + |
| 207 | + return { |
| 208 | + isStale: false, |
| 209 | + objects: allHits.map(hitToSearchResult), |
| 210 | + total: allHits.length, |
| 211 | + time: new Date().toISOString(), |
| 212 | + } |
| 213 | +} |
0 commit comments