[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

perf: add server fetch cache for api responses (#183)

authored by

Daniel Roe and committed by
GitHub
4232ae21 14df6954

+622 -152
+99
app/composables/useCachedFetch.ts
··· 1 + import type { H3Event } from 'h3' 2 + 3 + /** 4 + * Type for the cachedFetch function attached to event context. 5 + */ 6 + export type CachedFetchFunction = <T = unknown>( 7 + url: string, 8 + options?: { 9 + method?: string 10 + body?: unknown 11 + headers?: Record<string, string> 12 + }, 13 + ttl?: number, 14 + ) => Promise<T> 15 + 16 + /** 17 + * Get the cachedFetch function from the current request context. 18 + * 19 + * IMPORTANT: This must be called in the composable setup context (outside of 20 + * useAsyncData handlers). The returned function can then be used inside handlers. 21 + * 22 + * @example 23 + * ```ts 24 + * export function usePackage(name: MaybeRefOrGetter<string>) { 25 + * // Get cachedFetch in setup context 26 + * const cachedFetch = useCachedFetch() 27 + * 28 + * return useLazyAsyncData( 29 + * () => `package:${toValue(name)}`, 30 + * // Use it inside the handler 31 + * () => cachedFetch<Packument>(`https://registry.npmjs.org/${toValue(name)}`) 32 + * ) 33 + * } 34 + * ``` 35 + */ 36 + export function useCachedFetch(): CachedFetchFunction { 37 + // On client, return a function that just uses $fetch 38 + if (import.meta.client) { 39 + return async <T = unknown>( 40 + url: string, 41 + options: { 42 + method?: string 43 + body?: unknown 44 + headers?: Record<string, string> 45 + } = {}, 46 + _ttl?: number, 47 + ): Promise<T> => { 48 + return (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 49 + } 50 + } 51 + 52 + // On server, get the cachedFetch from request context 53 + const event = useRequestEvent() 54 + const serverCachedFetch = event?.context?.cachedFetch 55 + 56 + // If cachedFetch is available from middleware, return it 57 + if (serverCachedFetch) { 58 + return serverCachedFetch as CachedFetchFunction 59 + } 60 + 61 + // Fallback: return a function that uses regular $fetch 62 + // (shouldn't happen in normal operation) 63 + return async <T = unknown>( 64 + url: string, 65 + options: { 66 + method?: string 67 + body?: unknown 68 + headers?: Record<string, string> 69 + } = {}, 70 + _ttl?: number, 71 + ): Promise<T> => { 72 + return (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 73 + } 74 + } 75 + 76 + /** 77 + * Create a cachedFetch function from an H3Event. 78 + * Useful when you have direct access to the event. 79 + */ 80 + export function getCachedFetchFromEvent(event: H3Event | undefined): CachedFetchFunction { 81 + const serverCachedFetch = event?.context?.cachedFetch 82 + 83 + if (serverCachedFetch) { 84 + return serverCachedFetch as CachedFetchFunction 85 + } 86 + 87 + // Fallback to regular $fetch 88 + return async <T = unknown>( 89 + url: string, 90 + options: { 91 + method?: string 92 + body?: unknown 93 + headers?: Record<string, string> 94 + } = {}, 95 + _ttl?: number, 96 + ): Promise<T> => { 97 + return (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 98 + } 99 + }
+150 -107
app/composables/useNpmRegistry.ts
··· 12 12 import { maxSatisfying, prerelease, major, minor, diff, gt, compare } from 'semver' 13 13 import { isExactVersion } from '~/utils/versions' 14 14 import { extractInstallScriptsInfo } from '~/utils/install-scripts' 15 + import type { CachedFetchFunction } from '~/composables/useCachedFetch' 15 16 16 17 const NPM_REGISTRY = 'https://registry.npmjs.org' 17 18 const NPM_API = 'https://api.npmjs.org' ··· 20 21 const packumentCache = new Map<string, Promise<Packument | null>>() 21 22 22 23 /** 23 - * Fetch a package's full packument data. 24 - * Uses caching to avoid duplicate requests. 25 - */ 26 - async function fetchNpmPackage(name: string): Promise<Packument> { 27 - const encodedName = encodePackageName(name) 28 - return await $fetch<Packument>(`${NPM_REGISTRY}/${encodedName}`) 29 - } 30 - 31 - /** 32 - * Fetch a package's packument with caching (returns null on error). 33 - * This is useful for batch operations where some packages might not exist. 34 - */ 35 - async function fetchCachedPackument(name: string): Promise<Packument | null> { 36 - const cached = packumentCache.get(name) 37 - if (cached) return cached 38 - 39 - const promise = fetchNpmPackage(name).catch(() => null) 40 - packumentCache.set(name, promise) 41 - return promise 42 - } 43 - 44 - async function searchNpmPackages( 45 - query: string, 46 - options: { 47 - size?: number 48 - from?: number 49 - quality?: number 50 - popularity?: number 51 - maintenance?: number 52 - } = {}, 53 - ): Promise<NpmSearchResponse> { 54 - const params = new URLSearchParams() 55 - params.set('text', query) 56 - if (options.size) params.set('size', String(options.size)) 57 - if (options.from) params.set('from', String(options.from)) 58 - if (options.quality !== undefined) params.set('quality', String(options.quality)) 59 - if (options.popularity !== undefined) params.set('popularity', String(options.popularity)) 60 - if (options.maintenance !== undefined) params.set('maintenance', String(options.maintenance)) 61 - 62 - return await $fetch<NpmSearchResponse>(`${NPM_REGISTRY}/-/v1/search?${params.toString()}`) 63 - } 64 - 65 - async function fetchNpmDownloads( 66 - packageName: string, 67 - period: 'last-day' | 'last-week' | 'last-month' | 'last-year' = 'last-week', 68 - ): Promise<NpmDownloadCount> { 69 - const encodedName = encodePackageName(packageName) 70 - return await $fetch<NpmDownloadCount>(`${NPM_API}/downloads/point/${period}/${encodedName}`) 71 - } 72 - 73 - /** 74 24 * Encode a package name for use in npm registry URLs. 75 25 * Handles scoped packages (e.g., @scope/name -> @scope%2Fname). 76 26 */ ··· 162 112 name: MaybeRefOrGetter<string>, 163 113 requestedVersion?: MaybeRefOrGetter<string | null>, 164 114 ) { 115 + const cachedFetch = useCachedFetch() 116 + 165 117 const asyncData = useLazyAsyncData( 166 118 () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`, 167 - () => 168 - fetchNpmPackage(toValue(name)).then(r => transformPackument(r, toValue(requestedVersion))), 119 + async () => { 120 + const encodedName = encodePackageName(toValue(name)) 121 + const pkg = await cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`) 122 + return transformPackument(pkg, toValue(requestedVersion)) 123 + }, 169 124 ) 170 125 171 126 // Resolve requestedVersion to an exact version ··· 202 157 name: MaybeRefOrGetter<string>, 203 158 period: MaybeRefOrGetter<'last-day' | 'last-week' | 'last-month' | 'last-year'> = 'last-week', 204 159 ) { 160 + const cachedFetch = useCachedFetch() 161 + 205 162 return useLazyAsyncData( 206 163 () => `downloads:${toValue(name)}:${toValue(period)}`, 207 - () => fetchNpmDownloads(toValue(name), toValue(period)), 164 + async () => { 165 + const encodedName = encodePackageName(toValue(name)) 166 + return await cachedFetch<NpmDownloadCount>( 167 + `${NPM_API}/downloads/point/${toValue(period)}/${encodedName}`, 168 + ) 169 + }, 208 170 ) 209 171 } 210 172 ··· 215 177 downloads: Array<{ day: string; downloads: number }> 216 178 } 217 179 180 + /** 181 + * Fetch download range data from npm API. 182 + * Exported for external use (e.g., in components). 183 + */ 218 184 export async function fetchNpmDownloadsRange( 219 185 packageName: string, 220 186 start: string, ··· 226 192 ) 227 193 } 228 194 195 + export function usePackageWeeklyDownloadEvolution( 196 + name: MaybeRefOrGetter<string>, 197 + options: MaybeRefOrGetter<{ 198 + weeks?: number 199 + endDate?: string 200 + }> = {}, 201 + ) { 202 + const cachedFetch = useCachedFetch() 203 + 204 + return useLazyAsyncData( 205 + () => `downloads-weekly-evolution:${toValue(name)}:${JSON.stringify(toValue(options))}`, 206 + async () => { 207 + const packageName = toValue(name) 208 + const { weeks = 12, endDate } = toValue(options) ?? {} 209 + 210 + const today = new Date() 211 + const yesterday = new Date( 212 + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1), 213 + ) 214 + 215 + const end = endDate ? new Date(`${endDate}T00:00:00.000Z`) : yesterday 216 + 217 + const start = addDays(end, -(weeks * 7) + 1) 218 + const startIso = toIsoDateString(start) 219 + const endIso = toIsoDateString(end) 220 + 221 + const encodedName = encodePackageName(packageName) 222 + const range = await cachedFetch<NpmDownloadsRangeResponse>( 223 + `${NPM_API}/downloads/range/${startIso}:${endIso}/${encodedName}`, 224 + ) 225 + const sortedDaily = [...range.downloads].sort((a, b) => a.day.localeCompare(b.day)) 226 + return buildWeeklyEvolutionFromDaily(sortedDaily) 227 + }, 228 + ) 229 + } 230 + 229 231 const emptySearchResponse = { 230 232 objects: [], 231 233 total: 0, ··· 239 241 from?: number 240 242 }> = {}, 241 243 ) { 244 + const cachedFetch = useCachedFetch() 242 245 let lastSearch: NpmSearchResponse | undefined = undefined 243 246 244 247 return useLazyAsyncData( ··· 248 251 if (!q.trim()) { 249 252 return Promise.resolve(emptySearchResponse) 250 253 } 251 - return (lastSearch = await searchNpmPackages(q, toValue(options))) 254 + 255 + const params = new URLSearchParams() 256 + params.set('text', q) 257 + const opts = toValue(options) 258 + if (opts.size) params.set('size', String(opts.size)) 259 + if (opts.from) params.set('from', String(opts.from)) 260 + 261 + // Note: Search results have a short TTL (1 minute) since they change frequently 262 + return (lastSearch = await cachedFetch<NpmSearchResponse>( 263 + `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, 264 + {}, 265 + 60, // 1 minute TTL for search results 266 + )) 252 267 }, 253 268 { default: () => lastSearch || emptySearchResponse }, 254 269 ) 255 270 } 256 271 257 272 /** 258 - * Fetch all package names in an npm organization 259 - * Uses the /-/org/{org}/package endpoint 260 - * Throws error with statusCode 404 if org doesn't exist 261 - * Returns empty array if org exists but has no packages 262 - */ 263 - async function fetchOrgPackageNames(orgName: string): Promise<string[]> { 264 - try { 265 - const data = await $fetch<Record<string, string>>( 266 - `${NPM_REGISTRY}/-/org/${encodeURIComponent(orgName)}/package`, 267 - ) 268 - return Object.keys(data) 269 - } catch (err) { 270 - // Check if this is a 404 (org not found) 271 - if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 404) { 272 - throw createError({ 273 - statusCode: 404, 274 - statusMessage: 'Organization not found', 275 - message: `The organization "@${orgName}" does not exist on npm`, 276 - }) 277 - } 278 - // For other errors (network, etc.), return empty array to be safe 279 - return [] 280 - } 281 - } 282 - 283 - /** 284 273 * Minimal packument data needed for package cards 285 274 */ 286 275 interface MinimalPackument { ··· 293 282 } 294 283 295 284 /** 296 - * Fetch minimal packument data for a single package 297 - */ 298 - async function fetchMinimalPackument(name: string): Promise<MinimalPackument | null> { 299 - try { 300 - const encoded = encodePackageName(name) 301 - return await $fetch<MinimalPackument>(`${NPM_REGISTRY}/${encoded}`, { 302 - // Only fetch the fields we need using Accept header 303 - // Note: npm registry doesn't support field filtering, so we get full packument 304 - // but we only use what we need 305 - }) 306 - } catch { 307 - // Package might not exist or be private 308 - return null 309 - } 310 - } 311 - 312 - /** 313 285 * Convert packument to search result format for display 314 286 */ 315 287 function packumentToSearchResult(pkg: MinimalPackument): NpmSearchResult { ··· 341 313 * Returns search-result-like objects for compatibility with PackageList 342 314 */ 343 315 export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { 316 + const cachedFetch = useCachedFetch() 317 + 344 318 return useLazyAsyncData( 345 319 () => `org-packages:${toValue(orgName)}`, 346 320 async () => { ··· 350 324 } 351 325 352 326 // Get all package names in the org 353 - const packageNames = await fetchOrgPackageNames(org) 327 + let packageNames: string[] 328 + try { 329 + const data = await cachedFetch<Record<string, string>>( 330 + `${NPM_REGISTRY}/-/org/${encodeURIComponent(org)}/package`, 331 + ) 332 + packageNames = Object.keys(data) 333 + } catch (err) { 334 + // Check if this is a 404 (org not found) 335 + if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 404) { 336 + throw createError({ 337 + statusCode: 404, 338 + statusMessage: 'Organization not found', 339 + message: `The organization "@${org}" does not exist on npm`, 340 + }) 341 + } 342 + // For other errors (network, etc.), return empty array to be safe 343 + packageNames = [] 344 + } 354 345 355 346 if (packageNames.length === 0) { 356 347 return emptySearchResponse ··· 362 353 363 354 for (let i = 0; i < packageNames.length; i += concurrency) { 364 355 const batch = packageNames.slice(i, i + concurrency) 365 - const packuments = await Promise.all(batch.map(name => fetchMinimalPackument(name))) 356 + const packuments = await Promise.all( 357 + batch.map(async name => { 358 + try { 359 + const encoded = encodePackageName(name) 360 + return await cachedFetch<MinimalPackument>(`${NPM_REGISTRY}/${encoded}`) 361 + } catch { 362 + return null 363 + } 364 + }), 365 + ) 366 366 367 367 for (const pkg of packuments) { 368 368 // Filter out any unpublished packages (missing dist-tags) ··· 386 386 // Package Versions 387 387 // ============================================================================ 388 388 389 - // Cache for full version lists 389 + // Cache for full version lists (client-side only, for non-composable usage) 390 390 const allVersionsCache = new Map<string, Promise<PackageVersionInfo[]>>() 391 391 392 392 /** 393 393 * Fetch all versions of a package from the npm registry. 394 394 * Returns version info sorted by version (newest first). 395 395 * Results are cached to avoid duplicate requests. 396 + * 397 + * Note: This is a standalone async function for use in event handlers. 398 + * For composable usage, use useAllPackageVersions instead. 396 399 */ 397 400 export async function fetchAllPackageVersions(packageName: string): Promise<PackageVersionInfo[]> { 398 401 const cached = allVersionsCache.get(packageName) ··· 400 403 401 404 const promise = (async () => { 402 405 const encodedName = encodePackageName(packageName) 406 + // Use regular $fetch for client-side calls (this is called on user interaction) 403 407 const data = await $fetch<{ 404 408 versions: Record<string, { deprecated?: string }> 405 409 time: Record<string, string> ··· 420 424 return promise 421 425 } 422 426 427 + /** 428 + * Composable to fetch all versions of a package. 429 + * Uses SWR caching on the server. 430 + */ 431 + export function useAllPackageVersions(packageName: MaybeRefOrGetter<string>) { 432 + const cachedFetch = useCachedFetch() 433 + 434 + return useLazyAsyncData( 435 + () => `all-versions:${toValue(packageName)}`, 436 + async () => { 437 + const encodedName = encodePackageName(toValue(packageName)) 438 + const data = await cachedFetch<{ 439 + versions: Record<string, { deprecated?: string }> 440 + time: Record<string, string> 441 + }>(`${NPM_REGISTRY}/${encodedName}`) 442 + 443 + return Object.entries(data.versions) 444 + .filter(([v]) => data.time[v]) 445 + .map(([version, versionData]) => ({ 446 + version, 447 + time: data.time[version], 448 + hasProvenance: false, // Would need to check dist.attestations for each version 449 + deprecated: versionData.deprecated, 450 + })) 451 + .sort((a, b) => compare(b.version, a.version)) as PackageVersionInfo[] 452 + }, 453 + ) 454 + } 455 + 423 456 // ============================================================================ 424 457 // Outdated Dependencies 425 458 // ============================================================================ ··· 467 500 /** 468 501 * Check if a dependency is outdated. 469 502 * Returns null if up-to-date or if we can't determine. 470 - * 471 - * A dependency is only considered "outdated" if the resolved version 472 - * is older than the latest version. If the resolved version is newer 473 - * (e.g., using ^2.0.0-rc when latest is 1.x), it's not outdated. 474 503 */ 475 504 async function checkDependencyOutdated( 505 + cachedFetch: CachedFetchFunction, 476 506 packageName: string, 477 507 constraint: string, 478 508 ): Promise<OutdatedDependencyInfo | null> { ··· 480 510 return null 481 511 } 482 512 483 - const packument = await fetchCachedPackument(packageName) 513 + // Check in-memory cache first 514 + let packument: Packument | null 515 + const cached = packumentCache.get(packageName) 516 + if (cached) { 517 + packument = await cached 518 + } else { 519 + const promise = cachedFetch<Packument>( 520 + `${NPM_REGISTRY}/${encodePackageName(packageName)}`, 521 + ).catch(() => null) 522 + packumentCache.set(packageName, promise) 523 + packument = await promise 524 + } 525 + 484 526 if (!packument) return null 485 527 486 528 const latestTag = packument['dist-tags']?.latest ··· 535 577 export function useOutdatedDependencies( 536 578 dependencies: MaybeRefOrGetter<Record<string, string> | undefined>, 537 579 ) { 580 + const cachedFetch = useCachedFetch() 538 581 const outdated = shallowRef<Record<string, OutdatedDependencyInfo>>({}) 539 582 540 583 async function fetchOutdatedInfo(deps: Record<string, string> | undefined) { ··· 551 594 const batch = entries.slice(i, i + batchSize) 552 595 const batchResults = await Promise.all( 553 596 batch.map(async ([name, constraint]) => { 554 - const info = await checkDependencyOutdated(name, constraint) 597 + const info = await checkDependencyOutdated(cachedFetch, name, constraint) 555 598 return [name, info] as const 556 599 }), 557 600 )
+88 -41
app/composables/useRepoMeta.ts
··· 1 1 import type { ProviderId, RepoRef } from '#shared/utils/git-providers' 2 2 import { parseRepoUrl, GITLAB_HOSTS } from '#shared/utils/git-providers' 3 + import type { CachedFetchFunction } from '~/composables/useCachedFetch' 4 + 5 + // TTL for git repo metadata (10 minutes - repo stats don't change frequently) 6 + const REPO_META_TTL = 60 * 10 3 7 4 8 export type RepoMetaLinks = { 5 9 repo: string ··· 73 77 id: ProviderId 74 78 parse(url: URL): RepoRef | null 75 79 links(ref: RepoRef): RepoMetaLinks 76 - fetchMeta(ref: RepoRef, links: RepoMetaLinks): Promise<RepoMeta | null> 80 + fetchMeta( 81 + cachedFetch: CachedFetchFunction, 82 + ref: RepoRef, 83 + links: RepoMetaLinks, 84 + ): Promise<RepoMeta | null> 77 85 } 78 86 79 87 const githubAdapter: ProviderAdapter = { ··· 106 114 } 107 115 }, 108 116 109 - async fetchMeta(ref, links) { 117 + async fetchMeta(cachedFetch, ref, links) { 110 118 // Using UNGH to avoid API limitations of the Github API 111 - const res = await $fetch<UnghRepoResponse>(`https://ungh.cc/repos/${ref.owner}/${ref.repo}`, { 112 - headers: { 'User-Agent': 'npmx' }, 113 - }).catch(() => null) 119 + let res: UnghRepoResponse | null = null 120 + try { 121 + res = await cachedFetch<UnghRepoResponse>( 122 + `https://ungh.cc/repos/${ref.owner}/${ref.repo}`, 123 + { headers: { 'User-Agent': 'npmx' } }, 124 + REPO_META_TTL, 125 + ) 126 + } catch { 127 + return null 128 + } 114 129 115 130 const repo = res?.repo 116 131 if (!repo) return null ··· 163 178 } 164 179 }, 165 180 166 - async fetchMeta(ref, links) { 181 + async fetchMeta(cachedFetch, ref, links) { 167 182 const baseHost = ref.host ?? 'gitlab.com' 168 183 const projectPath = encodeURIComponent(`${ref.owner}/${ref.repo}`) 169 - const res = await $fetch<GitLabProjectResponse>( 170 - `https://${baseHost}/api/v4/projects/${projectPath}`, 171 - { headers: { 'User-Agent': 'npmx' } }, 172 - ).catch(() => null) 184 + let res: GitLabProjectResponse | null = null 185 + try { 186 + res = await cachedFetch<GitLabProjectResponse>( 187 + `https://${baseHost}/api/v4/projects/${projectPath}`, 188 + { headers: { 'User-Agent': 'npmx' } }, 189 + REPO_META_TTL, 190 + ) 191 + } catch { 192 + return null 193 + } 173 194 174 195 if (!res) return null 175 196 ··· 214 235 } 215 236 }, 216 237 217 - async fetchMeta(ref, links) { 218 - const res = await $fetch<BitbucketRepoResponse>( 219 - `https://api.bitbucket.org/2.0/repositories/${ref.owner}/${ref.repo}`, 220 - { headers: { 'User-Agent': 'npmx' } }, 221 - ).catch(() => null) 238 + async fetchMeta(cachedFetch, ref, links) { 239 + let res: BitbucketRepoResponse | null = null 240 + try { 241 + res = await cachedFetch<BitbucketRepoResponse>( 242 + `https://api.bitbucket.org/2.0/repositories/${ref.owner}/${ref.repo}`, 243 + { headers: { 'User-Agent': 'npmx' } }, 244 + REPO_META_TTL, 245 + ) 246 + } catch { 247 + return null 248 + } 222 249 223 250 if (!res) return null 224 251 ··· 265 292 } 266 293 }, 267 294 268 - async fetchMeta(ref, links) { 269 - const res = await $fetch<GiteaRepoResponse>( 270 - `https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}`, 271 - { headers: { 'User-Agent': 'npmx' } }, 272 - ).catch(() => null) 295 + async fetchMeta(cachedFetch, ref, links) { 296 + let res: GiteaRepoResponse | null = null 297 + try { 298 + res = await cachedFetch<GiteaRepoResponse>( 299 + `https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}`, 300 + { headers: { 'User-Agent': 'npmx' } }, 301 + REPO_META_TTL, 302 + ) 303 + } catch { 304 + return null 305 + } 273 306 274 307 if (!res) return null 275 308 ··· 316 349 } 317 350 }, 318 351 319 - async fetchMeta(ref, links) { 320 - const res = await $fetch<GiteeRepoResponse>( 321 - `https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}`, 322 - { headers: { 'User-Agent': 'npmx' } }, 323 - ).catch(() => null) 352 + async fetchMeta(cachedFetch, ref, links) { 353 + let res: GiteeRepoResponse | null = null 354 + try { 355 + res = await cachedFetch<GiteeRepoResponse>( 356 + `https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}`, 357 + { headers: { 'User-Agent': 'npmx' } }, 358 + REPO_META_TTL, 359 + ) 360 + } catch { 361 + return null 362 + } 324 363 325 364 if (!res) return null 326 365 ··· 396 435 } 397 436 }, 398 437 399 - async fetchMeta(ref, links) { 438 + async fetchMeta(cachedFetch, ref, links) { 400 439 if (!ref.host) return null 401 440 402 - const res = await $fetch<GiteaRepoResponse>( 403 - `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, 404 - { headers: { 'User-Agent': 'npmx' } }, 405 - ).catch(() => null) 441 + // Note: Generic Gitea instances may not be in the allowlist, 442 + // so caching may not apply for self-hosted instances 443 + let res: GiteaRepoResponse | null = null 444 + try { 445 + res = await cachedFetch<GiteaRepoResponse>( 446 + `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, 447 + { headers: { 'User-Agent': 'npmx' } }, 448 + REPO_META_TTL, 449 + ) 450 + } catch { 451 + return null 452 + } 406 453 407 454 if (!res) return null 408 455 ··· 449 496 } 450 497 }, 451 498 452 - async fetchMeta(_ref, links) { 499 + async fetchMeta(_cachedFetch, _ref, links) { 453 500 // Sourcehut doesn't have a public API for repo stats 454 501 // Just return basic info without fetching 455 502 return { ··· 499 546 } 500 547 }, 501 548 502 - async fetchMeta(_ref, links) { 549 + async fetchMeta(_cachedFetch, _ref, links) { 503 550 // Tangled doesn't have a public API for repo stats yet 504 551 // Just return basic info without fetching 505 552 return { ··· 526 573 527 574 const parseRepoFromUrl = parseRepoUrl 528 575 529 - async function fetchRepoMeta(ref: RepoRef): Promise<RepoMeta | null> { 530 - const adapter = providers.find(provider => provider.id === ref.provider) 531 - if (!adapter) return null 576 + export function useRepoMeta(repositoryUrl: MaybeRefOrGetter<string | null | undefined>) { 577 + // Get cachedFetch in setup context (outside async handler) 578 + const cachedFetch = useCachedFetch() 532 579 533 - const links = adapter.links(ref) 534 - return await adapter.fetchMeta(ref, links) 535 - } 536 - 537 - export function useRepoMeta(repositoryUrl: MaybeRefOrGetter<string | null | undefined>) { 538 580 const repoRef = computed(() => { 539 581 const url = toValue(repositoryUrl) 540 582 if (!url) return null ··· 549 591 async () => { 550 592 const ref = repoRef.value 551 593 if (!ref) return null 552 - return await fetchRepoMeta(ref) 594 + 595 + const adapter = providers.find(provider => provider.id === ref.provider) 596 + if (!adapter) return null 597 + 598 + const links = adapter.links(ref) 599 + return await adapter.fetchMeta(cachedFetch, ref, links) 553 600 }, 554 601 ) 555 602
+12 -1
modules/cache.ts
··· 1 1 import { defineNuxtModule } from 'nuxt/kit' 2 2 import { provider } from 'std-env' 3 3 4 + // Storage key for fetch cache - must match shared/utils/fetch-cache-config.ts 5 + const FETCH_CACHE_STORAGE_BASE = 'fetch-cache' 6 + 4 7 export default defineNuxtModule({ 5 8 meta: { 6 9 name: 'vercel-cache', ··· 12 15 13 16 nuxt.hook('nitro:config', nitroConfig => { 14 17 nitroConfig.storage = nitroConfig.storage || {} 18 + 19 + // Main cache storage (for defineCachedFunction, etc.) 15 20 nitroConfig.storage.cache = { 21 + ...nitroConfig.storage.cache, 16 22 driver: 'vercel-runtime-cache', 17 - ...nitroConfig.storage.cache, 23 + } 24 + 25 + // Fetch cache storage (for SWR fetch caching) 26 + nitroConfig.storage[FETCH_CACHE_STORAGE_BASE] = { 27 + ...nitroConfig.storage[FETCH_CACHE_STORAGE_BASE], 28 + driver: 'vercel-runtime-cache', 18 29 } 19 30 }) 20 31 },
+8
nuxt.config.ts
··· 95 95 '@shikijs/core', 96 96 ], 97 97 }, 98 + // Storage configuration for local development 99 + // In production (Vercel), this is overridden by modules/cache.ts 100 + storage: { 101 + 'fetch-cache': { 102 + driver: 'fsLite', 103 + base: './.cache/fetch', 104 + }, 105 + }, 98 106 }, 99 107 100 108 fonts: {
+1
package.json
··· 39 39 "@vueuse/nuxt": "14.1.0", 40 40 "nuxt": "^4.3.0", 41 41 "nuxt-og-image": "^5.1.13", 42 + "ohash": "^2.0.11", 42 43 "perfect-debounce": "^2.1.0", 43 44 "sanitize-html": "^2.17.0", 44 45 "semver": "^7.7.3",
+3
pnpm-lock.yaml
··· 54 54 nuxt-og-image: 55 55 specifier: ^5.1.13 56 56 version: 5.1.13(@unhead/vue@2.1.2(vue@3.5.27(typescript@5.9.3)))(magicast@0.5.1)(unstorage@1.17.4(db0@0.3.4)(ioredis@5.9.2))(vite@8.0.0-beta.10(@types/node@25.0.10)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) 57 + ohash: 58 + specifier: ^2.0.11 59 + version: 2.0.11 57 60 perfect-debounce: 58 61 specifier: ^2.1.0 59 62 version: 2.1.0
+170
server/plugins/fetch-cache.ts
··· 1 + import type { CachedFetchEntry } from '#shared/utils/fetch-cache-config' 2 + import { 3 + FETCH_CACHE_DEFAULT_TTL, 4 + FETCH_CACHE_STORAGE_BASE, 5 + FETCH_CACHE_VERSION, 6 + isAllowedDomain, 7 + isCacheEntryStale, 8 + } from '#shared/utils/fetch-cache-config' 9 + 10 + /** 11 + * Simple hash function for cache keys. 12 + */ 13 + function simpleHash(str: string): string { 14 + let hash = 0 15 + for (let i = 0; i < str.length; i++) { 16 + const char = str.charCodeAt(i) 17 + hash = (hash << 5) - hash + char 18 + hash = hash & hash 19 + } 20 + return Math.abs(hash).toString(36) 21 + } 22 + 23 + /** 24 + * Generate a cache key for a fetch request. 25 + */ 26 + function generateFetchCacheKey(url: string | URL, method: string = 'GET', body?: unknown): string { 27 + const urlObj = typeof url === 'string' ? new URL(url) : url 28 + const bodyHash = body ? simpleHash(JSON.stringify(body)) : '' 29 + const searchHash = urlObj.search ? simpleHash(urlObj.search) : '' 30 + 31 + const parts = [ 32 + FETCH_CACHE_VERSION, 33 + urlObj.host, 34 + method.toUpperCase(), 35 + urlObj.pathname, 36 + searchHash, 37 + bodyHash, 38 + ].filter(Boolean) 39 + 40 + return parts.join(':') 41 + } 42 + 43 + export type CachedFetchFunction = <T = unknown>( 44 + url: string, 45 + options?: { 46 + method?: string 47 + body?: unknown 48 + headers?: Record<string, string> 49 + }, 50 + ttl?: number, 51 + ) => Promise<T> 52 + 53 + /** 54 + * Server middleware that attaches a cachedFetch function to the event context. 55 + * This allows app composables to access the cached fetch via useRequestEvent(). 56 + */ 57 + export default defineNitroPlugin(nitroApp => { 58 + const storage = useStorage(FETCH_CACHE_STORAGE_BASE) 59 + 60 + /** 61 + * Perform a cached fetch with stale-while-revalidate semantics. 62 + */ 63 + const cachedFetch: CachedFetchFunction = async <T = unknown>( 64 + url: string, 65 + options: { 66 + method?: string 67 + body?: unknown 68 + headers?: Record<string, string> 69 + } = {}, 70 + ttl: number = FETCH_CACHE_DEFAULT_TTL, 71 + ): Promise<T> => { 72 + // Check if this URL should be cached 73 + if (!isAllowedDomain(url)) { 74 + return (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 75 + } 76 + 77 + const method = options.method || 'GET' 78 + const cacheKey = generateFetchCacheKey(url, method, options.body) 79 + 80 + // Try to get cached response (with error handling for storage failures) 81 + let cached: CachedFetchEntry<T> | null = null 82 + try { 83 + cached = await storage.getItem<CachedFetchEntry<T>>(cacheKey) 84 + } catch (error) { 85 + // Storage read failed (e.g., ENOENT on misconfigured storage) 86 + // Log and continue without cache 87 + if (import.meta.dev) { 88 + console.warn(`[fetch-cache] Storage read failed for ${url}:`, error) 89 + } 90 + } 91 + 92 + if (cached) { 93 + if (!isCacheEntryStale(cached)) { 94 + // Cache hit, data is fresh 95 + if (import.meta.dev) { 96 + console.log(`[fetch-cache] HIT (fresh): ${url}`) 97 + } 98 + return cached.data 99 + } 100 + 101 + // Cache hit but stale - return stale data and revalidate in background 102 + if (import.meta.dev) { 103 + console.log(`[fetch-cache] HIT (stale, revalidating): ${url}`) 104 + } 105 + 106 + // Fire-and-forget background revalidation 107 + Promise.resolve().then(async () => { 108 + try { 109 + const freshData = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 110 + const entry: CachedFetchEntry<T> = { 111 + data: freshData, 112 + status: 200, 113 + headers: {}, 114 + cachedAt: Date.now(), 115 + ttl, 116 + } 117 + await storage.setItem(cacheKey, entry) 118 + if (import.meta.dev) { 119 + console.log(`[fetch-cache] Revalidated: ${url}`) 120 + } 121 + } catch (error) { 122 + if (import.meta.dev) { 123 + console.warn(`[fetch-cache] Revalidation failed: ${url}`, error) 124 + } 125 + } 126 + }) 127 + 128 + // Return stale data immediately 129 + return cached.data 130 + } 131 + 132 + // Cache miss - fetch and cache 133 + if (import.meta.dev) { 134 + console.log(`[fetch-cache] MISS: ${url}`) 135 + } 136 + 137 + const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 138 + 139 + // Try to cache the response (non-blocking, with error handling) 140 + try { 141 + const entry: CachedFetchEntry<T> = { 142 + data, 143 + status: 200, 144 + headers: {}, 145 + cachedAt: Date.now(), 146 + ttl, 147 + } 148 + await storage.setItem(cacheKey, entry) 149 + } catch (error) { 150 + // Storage write failed - log but don't fail the request 151 + if (import.meta.dev) { 152 + console.warn(`[fetch-cache] Storage write failed for ${url}:`, error) 153 + } 154 + } 155 + 156 + return data 157 + } 158 + 159 + // Attach to event context for access in composables via useRequestEvent() 160 + nitroApp.hooks.hook('request', event => { 161 + event.context.cachedFetch = cachedFetch 162 + }) 163 + }) 164 + 165 + // Extend the H3EventContext type 166 + declare module 'h3' { 167 + interface H3EventContext { 168 + cachedFetch?: CachedFetchFunction 169 + } 170 + }
+82
shared/utils/fetch-cache-config.ts
··· 1 + /** 2 + * Configuration for the stale-while-revalidate fetch cache. 3 + * 4 + * This cache intercepts external API calls during SSR and caches responses 5 + * using Nitro's storage layer (backed by Vercel's runtime cache in production). 6 + */ 7 + 8 + /** 9 + * Domains that should have their fetch responses cached. 10 + * Only requests to these domains will be intercepted and cached. 11 + */ 12 + export const FETCH_CACHE_ALLOWED_DOMAINS = [ 13 + // npm registry 14 + 'registry.npmjs.org', // npm package metadata (packuments) 15 + 'api.npmjs.org', // npm download statistics 16 + 17 + // JSR registry 18 + 'jsr.io', // JSR package metadata 19 + 20 + // Git hosting providers (for repo metadata) 21 + 'ungh.cc', // GitHub proxy (avoids rate limits) 22 + 'api.github.com', // GitHub API 23 + 'gitlab.com', // GitLab API 24 + 'api.bitbucket.org', // Bitbucket API 25 + 'codeberg.org', // Codeberg (Gitea-based) 26 + 'gitee.com', // Gitee API 27 + ] as const 28 + 29 + /** 30 + * Default TTL for cached fetch responses (in seconds). 31 + * After this time, cached data is considered "stale" but will still be 32 + * returned immediately while a background revalidation occurs. 33 + */ 34 + export const FETCH_CACHE_DEFAULT_TTL = 60 * 5 // 5 minutes 35 + 36 + /** 37 + * Cache key version prefix. 38 + * Increment this to invalidate all cached entries (e.g., after format changes). 39 + */ 40 + export const FETCH_CACHE_VERSION = 'v1' 41 + 42 + /** 43 + * Storage key prefix for fetch cache entries. 44 + */ 45 + export const FETCH_CACHE_STORAGE_BASE = 'fetch-cache' 46 + 47 + /** 48 + * Check if a URL's host is in the allowed domains list. 49 + */ 50 + export function isAllowedDomain(url: string | URL): boolean { 51 + try { 52 + const urlObj = typeof url === 'string' ? new URL(url) : url 53 + return FETCH_CACHE_ALLOWED_DOMAINS.some(domain => urlObj.host === domain) 54 + } catch { 55 + return false 56 + } 57 + } 58 + 59 + /** 60 + * Structure of a cached fetch entry stored in Nitro storage. 61 + */ 62 + export interface CachedFetchEntry<T = unknown> { 63 + /** The response body/data */ 64 + data: T 65 + /** HTTP status code */ 66 + status: number 67 + /** Response headers (subset) */ 68 + headers: Record<string, string> 69 + /** Unix timestamp when the entry was cached */ 70 + cachedAt: number 71 + /** TTL in seconds */ 72 + ttl: number 73 + } 74 + 75 + /** 76 + * Check if a cached entry is stale (past its TTL). 77 + */ 78 + export function isCacheEntryStale(entry: CachedFetchEntry): boolean { 79 + const now = Date.now() 80 + const expiresAt = entry.cachedAt + entry.ttl * 1000 81 + return now > expiresAt 82 + }
+9 -3
test/nuxt/composables/use-npm-registry.spec.ts
··· 25 25 expect(status.value).toBe('success') 26 26 }) 27 27 28 - expect(fetchSpy).toHaveBeenCalledWith('https://api.npmjs.org/downloads/point/last-week/vue') 28 + // Check that fetch was called with the correct URL (first argument) 29 + expect(fetchSpy).toHaveBeenCalled() 30 + expect(fetchSpy.mock.calls[0]?.[0]).toBe('https://api.npmjs.org/downloads/point/last-week/vue') 29 31 expect(data.value?.downloads).toBe(1234567) 30 32 }) 31 33 ··· 36 38 expect(status.value).toBe('success') 37 39 }) 38 40 39 - expect(fetchSpy).toHaveBeenCalledWith('https://api.npmjs.org/downloads/point/last-month/vue') 41 + // Check that fetch was called with the correct URL (first argument) 42 + expect(fetchSpy).toHaveBeenCalled() 43 + expect(fetchSpy.mock.calls[0]?.[0]).toBe('https://api.npmjs.org/downloads/point/last-month/vue') 40 44 }) 41 45 42 46 it('should encode scoped package names', async () => { ··· 48 52 expect(status.value).toBe('success') 49 53 }) 50 54 51 - expect(fetchSpy).toHaveBeenCalledWith( 55 + // Check that fetch was called with the correct URL (first argument) 56 + expect(fetchSpy).toHaveBeenCalled() 57 + expect(fetchSpy.mock.calls[0]?.[0]).toBe( 52 58 'https://api.npmjs.org/downloads/point/last-week/@vue%2Fcore', 53 59 ) 54 60 })