|
| 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 | + * Singleton Algolia client, keyed by appId to handle config changes. |
| 10 | + */ |
| 11 | +let _searchClient: LiteClient | null = null |
| 12 | +let _configuredAppId: string | null = null |
| 13 | + |
| 14 | +function getOrCreateClient(appId: string, apiKey: string): LiteClient { |
| 15 | + if (!_searchClient || _configuredAppId !== appId) { |
| 16 | + _searchClient = algoliasearch(appId, apiKey) |
| 17 | + _configuredAppId = appId |
| 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 | + * Composable that provides Algolia search functions for npm packages. |
| 126 | + * |
| 127 | + * Must be called during component setup (or inside another composable) |
| 128 | + * because it reads from `useRuntimeConfig()`. The returned functions |
| 129 | + * are safe to call at any time (event handlers, async callbacks, etc.). |
| 130 | + */ |
| 131 | +export function useAlgoliaSearch() { |
| 132 | + const { algolia } = useRuntimeConfig().public |
| 133 | + const client = getOrCreateClient(algolia.appId, algolia.apiKey) |
| 134 | + const indexName = algolia.indexName |
| 135 | + |
| 136 | + /** |
| 137 | + * Search npm packages via Algolia. |
| 138 | + * Returns results in the same NpmSearchResponse format as the npm registry API. |
| 139 | + */ |
| 140 | + async function search( |
| 141 | + query: string, |
| 142 | + options: AlgoliaSearchOptions = {}, |
| 143 | + ): Promise<NpmSearchResponse> { |
| 144 | + const { results } = await client.search([ |
| 145 | + { |
| 146 | + indexName, |
| 147 | + params: { |
| 148 | + query, |
| 149 | + offset: options.from, |
| 150 | + length: options.size, |
| 151 | + filters: options.filters || '', |
| 152 | + analyticsTags: ['npmx.dev'], |
| 153 | + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, |
| 154 | + attributesToHighlight: [], |
| 155 | + }, |
| 156 | + }, |
| 157 | + ]) |
| 158 | + |
| 159 | + const response = results[0] as SearchResponse<AlgoliaHit> | undefined |
| 160 | + if (!response) { |
| 161 | + throw new Error('Algolia returned an empty response') |
| 162 | + } |
| 163 | + |
| 164 | + return { |
| 165 | + isStale: false, |
| 166 | + objects: response.hits.map(hitToSearchResult), |
| 167 | + total: response.nbHits ?? 0, |
| 168 | + time: new Date().toISOString(), |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + /** |
| 173 | + * Fetch all packages for an Algolia owner (org or user). |
| 174 | + * Uses `owner.name` filter for efficient server-side filtering. |
| 175 | + */ |
| 176 | + async function searchByOwner( |
| 177 | + ownerName: string, |
| 178 | + options: { maxResults?: number } = {}, |
| 179 | + ): Promise<NpmSearchResponse> { |
| 180 | + const max = options.maxResults ?? 1000 |
| 181 | + |
| 182 | + const allHits: AlgoliaHit[] = [] |
| 183 | + let offset = 0 |
| 184 | + let serverTotal = 0 |
| 185 | + const batchSize = 200 |
| 186 | + |
| 187 | + // Algolia supports up to 1000 results per query with offset/length pagination |
| 188 | + while (offset < max) { |
| 189 | + // Cap at both the configured max and the server's actual total (once known) |
| 190 | + const remaining = serverTotal > 0 ? Math.min(max, serverTotal) - offset : max - offset |
| 191 | + if (remaining <= 0) break |
| 192 | + const length = Math.min(batchSize, remaining) |
| 193 | + |
| 194 | + const { results } = await client.search([ |
| 195 | + { |
| 196 | + indexName, |
| 197 | + params: { |
| 198 | + query: '', |
| 199 | + offset, |
| 200 | + length, |
| 201 | + filters: `owner.name:${ownerName}`, |
| 202 | + analyticsTags: ['npmx.dev'], |
| 203 | + attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE, |
| 204 | + attributesToHighlight: [], |
| 205 | + }, |
| 206 | + }, |
| 207 | + ]) |
| 208 | + |
| 209 | + const response = results[0] as SearchResponse<AlgoliaHit> | undefined |
| 210 | + if (!response) break |
| 211 | + |
| 212 | + serverTotal = response.nbHits ?? 0 |
| 213 | + allHits.push(...response.hits) |
| 214 | + |
| 215 | + // If we got fewer than requested, we've exhausted all results |
| 216 | + if (response.hits.length < length || allHits.length >= serverTotal) { |
| 217 | + break |
| 218 | + } |
| 219 | + |
| 220 | + offset += length |
| 221 | + } |
| 222 | + |
| 223 | + return { |
| 224 | + isStale: false, |
| 225 | + objects: allHits.map(hitToSearchResult), |
| 226 | + // Use server total so callers can detect truncation (allHits.length < total) |
| 227 | + total: serverTotal, |
| 228 | + time: new Date().toISOString(), |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + return { |
| 233 | + /** Search packages by text query */ |
| 234 | + search, |
| 235 | + /** Fetch all packages for an owner (org or user) */ |
| 236 | + searchByOwner, |
| 237 | + } |
| 238 | +} |
0 commit comments