[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: use stale data and revalidate on client side (#429)

authored by

Daniel Roe and committed by
GitHub
3ff77981 ace20d14

+204 -118
+20 -8
app/composables/useCachedFetch.ts
··· 1 + import type { CachedFetchResult } from '#shared/utils/fetch-cache-config' 2 + 1 3 /** 2 4 * Type for the cachedFetch function attached to event context. 3 5 */ ··· 9 11 headers?: Record<string, string> 10 12 }, 11 13 ttl?: number, 12 - ) => Promise<T> 14 + ) => Promise<CachedFetchResult<T>> 13 15 14 16 /** 15 17 * Get the cachedFetch function from the current request context. ··· 17 19 * IMPORTANT: This must be called in the composable setup context (outside of 18 20 * useAsyncData handlers). The returned function can then be used inside handlers. 19 21 * 22 + * The returned function returns a wrapper object with staleness metadata: 23 + * - `data`: The response data 24 + * - `isStale`: Whether the data came from stale cache 25 + * - `cachedAt`: Unix timestamp when cached, or null if fresh fetch 26 + * 20 27 * @example 21 28 * ```ts 22 29 * export function usePackage(name: MaybeRefOrGetter<string>) { ··· 25 32 * 26 33 * return useLazyAsyncData( 27 34 * () => `package:${toValue(name)}`, 28 - * // Use it inside the handler 29 - * () => cachedFetch<Packument>(`https://registry.npmjs.org/${toValue(name)}`) 35 + * // Use it inside the handler - destructure { data } or { data, isStale } 36 + * async () => { 37 + * const { data } = await cachedFetch<Packument>(`https://registry.npmjs.org/${toValue(name)}`) 38 + * return data 39 + * } 30 40 * ) 31 41 * } 32 42 * ``` 33 43 * @public 34 44 */ 35 45 export function useCachedFetch(): CachedFetchFunction { 36 - // On client, return a function that just uses $fetch 46 + // On client, return a function that just uses $fetch (no caching, not stale) 37 47 if (import.meta.client) { 38 48 return async <T = unknown>( 39 49 url: string, ··· 43 53 headers?: Record<string, string> 44 54 } = {}, 45 55 _ttl?: number, 46 - ): Promise<T> => { 47 - return (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 56 + ): Promise<CachedFetchResult<T>> => { 57 + const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 58 + return { data, isStale: false, cachedAt: null } 48 59 } 49 60 } 50 61 ··· 67 78 headers?: Record<string, string> 68 79 } = {}, 69 80 _ttl?: number, 70 - ): Promise<T> => { 71 - return (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 81 + ): Promise<CachedFetchResult<T>> => { 82 + const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 83 + return { data, isStale: false, cachedAt: null } 72 84 } 73 85 }
+45 -14
app/composables/useNpmRegistry.ts
··· 183 183 ) { 184 184 const cachedFetch = useCachedFetch() 185 185 186 - return useLazyAsyncData( 186 + const asyncData = useLazyAsyncData( 187 187 () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`, 188 188 async () => { 189 189 const encodedName = encodePackageName(toValue(name)) 190 - const r = await cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`) 190 + const { data: r, isStale } = await cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`) 191 191 const reqVer = toValue(requestedVersion) 192 192 const pkg = transformPackument(r, reqVer) 193 193 const resolvedVersion = getResolvedVersion(pkg, reqVer) 194 - return { ...pkg, resolvedVersion } 194 + return { ...pkg, resolvedVersion, isStale } 195 195 }, 196 196 ) 197 + 198 + if (import.meta.client && asyncData.data.value?.isStale) { 199 + onMounted(() => { 200 + asyncData.refresh() 201 + }) 202 + } 203 + 204 + return asyncData 197 205 } 198 206 199 207 function getResolvedVersion(pkg: SlimPackument, reqVer?: string | null): string | null { ··· 223 231 ) { 224 232 const cachedFetch = useCachedFetch() 225 233 226 - return useLazyAsyncData( 234 + const asyncData = useLazyAsyncData( 227 235 () => `downloads:${toValue(name)}:${toValue(period)}`, 228 236 async () => { 229 237 const encodedName = encodePackageName(toValue(name)) 230 - return await cachedFetch<NpmDownloadCount>( 238 + const { data, isStale } = await cachedFetch<NpmDownloadCount>( 231 239 `${NPM_API}/downloads/point/${toValue(period)}/${encodedName}`, 232 240 ) 241 + return { ...data, isStale } 233 242 }, 234 243 ) 244 + 245 + if (import.meta.client && asyncData.data.value?.isStale) { 246 + onMounted(() => { 247 + asyncData.refresh() 248 + }) 249 + } 250 + 251 + return asyncData 235 252 } 236 253 237 254 type NpmDownloadsRangeResponse = { ··· 260 277 const emptySearchResponse = { 261 278 objects: [], 262 279 total: 0, 280 + isStale: false, 263 281 time: new Date().toISOString(), 264 282 } satisfies NpmSearchResponse 265 283 ··· 305 323 // Use requested size for initial fetch 306 324 params.set('size', String(opts.size ?? 25)) 307 325 308 - const response = await cachedFetch<NpmSearchResponse>( 326 + const { data: response, isStale } = await cachedFetch<NpmSearchResponse>( 309 327 `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, 310 328 {}, 311 329 60, ··· 317 335 total: response.total, 318 336 } 319 337 320 - return response 338 + return { ...response, isStale } 321 339 }, 322 340 { default: () => lastSearch || emptySearchResponse }, 323 341 ) ··· 357 375 params.set('size', String(size)) 358 376 params.set('from', String(from)) 359 377 360 - const response = await cachedFetch<NpmSearchResponse>( 378 + const { data: response } = await cachedFetch<NpmSearchResponse>( 361 379 `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, 362 380 {}, 363 381 60, ··· 405 423 const data = computed<NpmSearchResponse | null>(() => { 406 424 if (cache.value) { 407 425 return { 426 + isStale: false, 408 427 objects: cache.value.objects, 409 428 total: cache.value.total, 410 429 time: new Date().toISOString(), ··· 413 432 return asyncData.data.value 414 433 }) 415 434 435 + if (import.meta.client && asyncData.data.value?.isStale) { 436 + onMounted(() => { 437 + asyncData.refresh() 438 + }) 439 + } 440 + 416 441 // Whether there are more results available on the server (incremental mode only) 417 442 const hasMore = computed(() => { 418 443 if (!cache.value) return true ··· 482 507 export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { 483 508 const cachedFetch = useCachedFetch() 484 509 485 - return useLazyAsyncData( 510 + const asyncData = useLazyAsyncData( 486 511 () => `org-packages:${toValue(orgName)}`, 487 512 async () => { 488 513 const org = toValue(orgName) ··· 493 518 // Get all package names in the org 494 519 let packageNames: string[] 495 520 try { 496 - const data = await cachedFetch<Record<string, string>>( 521 + const { data } = await cachedFetch<Record<string, string>>( 497 522 `${NPM_REGISTRY}/-/org/${encodeURIComponent(org)}/package`, 498 523 ) 499 524 packageNames = Object.keys(data) ··· 526 551 batch.map(async name => { 527 552 try { 528 553 const encoded = encodePackageName(name) 529 - return await cachedFetch<MinimalPackument>(`${NPM_REGISTRY}/${encoded}`) 554 + const { data: pkg } = await cachedFetch<MinimalPackument>( 555 + `${NPM_REGISTRY}/${encoded}`, 556 + ) 557 + return pkg 530 558 } catch { 531 559 return null 532 560 } ··· 551 579 ) 552 580 553 581 return { 582 + isStale: false, 554 583 objects: results, 555 584 total: results.length, 556 585 time: new Date().toISOString(), ··· 558 587 }, 559 588 { default: () => emptySearchResponse }, 560 589 ) 590 + 591 + return asyncData 561 592 } 562 593 563 594 // ============================================================================ ··· 665 696 if (cached) { 666 697 packument = await cached 667 698 } else { 668 - const promise = cachedFetch<Packument>( 669 - `${NPM_REGISTRY}/${encodePackageName(packageName)}`, 670 - ).catch(() => null) 699 + const promise = cachedFetch<Packument>(`${NPM_REGISTRY}/${encodePackageName(packageName)}`) 700 + .then(({ data }) => data) 701 + .catch(() => null) 671 702 packumentCache.set(packageName, promise) 672 703 packument = await promise 673 704 }
+18 -10
app/composables/useRepoMeta.ts
··· 145 145 // Using UNGH to avoid API limitations of the Github API 146 146 let res: UnghRepoResponse | null = null 147 147 try { 148 - res = await cachedFetch<UnghRepoResponse>( 148 + const { data } = await cachedFetch<UnghRepoResponse>( 149 149 `https://ungh.cc/repos/${ref.owner}/${ref.repo}`, 150 150 { headers: { 'User-Agent': 'npmx' } }, 151 151 REPO_META_TTL, 152 152 ) 153 + res = data 153 154 } catch { 154 155 return null 155 156 } ··· 210 211 const projectPath = encodeURIComponent(`${ref.owner}/${ref.repo}`) 211 212 let res: GitLabProjectResponse | null = null 212 213 try { 213 - res = await cachedFetch<GitLabProjectResponse>( 214 + const { data } = await cachedFetch<GitLabProjectResponse>( 214 215 `https://${baseHost}/api/v4/projects/${projectPath}`, 215 216 { headers: { 'User-Agent': 'npmx' } }, 216 217 REPO_META_TTL, 217 218 ) 219 + res = data 218 220 } catch { 219 221 return null 220 222 } ··· 265 267 async fetchMeta(cachedFetch, ref, links) { 266 268 let res: BitbucketRepoResponse | null = null 267 269 try { 268 - res = await cachedFetch<BitbucketRepoResponse>( 270 + const { data } = await cachedFetch<BitbucketRepoResponse>( 269 271 `https://api.bitbucket.org/2.0/repositories/${ref.owner}/${ref.repo}`, 270 272 { headers: { 'User-Agent': 'npmx' } }, 271 273 REPO_META_TTL, 272 274 ) 275 + res = data 273 276 } catch { 274 277 return null 275 278 } ··· 322 325 async fetchMeta(cachedFetch, ref, links) { 323 326 let res: GiteaRepoResponse | null = null 324 327 try { 325 - res = await cachedFetch<GiteaRepoResponse>( 328 + const { data } = await cachedFetch<GiteaRepoResponse>( 326 329 `https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}`, 327 330 { headers: { 'User-Agent': 'npmx' } }, 328 331 REPO_META_TTL, 329 332 ) 333 + res = data 330 334 } catch { 331 335 return null 332 336 } ··· 379 383 async fetchMeta(cachedFetch, ref, links) { 380 384 let res: GiteeRepoResponse | null = null 381 385 try { 382 - res = await cachedFetch<GiteeRepoResponse>( 386 + const { data } = await cachedFetch<GiteeRepoResponse>( 383 387 `https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}`, 384 388 { headers: { 'User-Agent': 'npmx' } }, 385 389 REPO_META_TTL, 386 390 ) 391 + res = data 387 392 } catch { 388 393 return null 389 394 } ··· 469 474 // so caching may not apply for self-hosted instances 470 475 let res: GiteaRepoResponse | null = null 471 476 try { 472 - res = await cachedFetch<GiteaRepoResponse>( 477 + const { data } = await cachedFetch<GiteaRepoResponse>( 473 478 `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, 474 479 { headers: { 'User-Agent': 'npmx' } }, 475 480 REPO_META_TTL, 476 481 ) 482 + res = data 477 483 } catch { 478 484 return null 479 485 } ··· 577 583 // Tangled doesn't have a public JSON API, but we can scrape the star count 578 584 // from the HTML page (it's in the hx-post URL as countHint=N) 579 585 try { 580 - const html = await cachedFetch<string>( 586 + const { data: html } = await cachedFetch<string>( 581 587 `https://tangled.org/${ref.owner}/${ref.repo}`, 582 588 { headers: { 'User-Agent': 'npmx', 'Accept': 'text/html' } }, 583 589 REPO_META_TTL, ··· 594 600 if (atUriMatch) { 595 601 try { 596 602 //Get counts of records that reference this repo in the atmosphere using constellation 597 - const allLinks = await cachedFetch<ConstellationAllLinksResponse>( 603 + const { data: allLinks } = await cachedFetch<ConstellationAllLinksResponse>( 598 604 `https://constellation.microcosm.blue/links/all?target=${atUri}`, 599 605 { headers: { 'User-Agent': 'npmx' } }, 600 606 REPO_META_TTL, ··· 655 661 async fetchMeta(cachedFetch, ref, links) { 656 662 let res: RadicleProjectResponse | null = null 657 663 try { 658 - res = await cachedFetch<RadicleProjectResponse>( 664 + const { data } = await cachedFetch<RadicleProjectResponse>( 659 665 `https://seed.radicle.at/api/v1/projects/${ref.repo}`, 660 666 { headers: { 'User-Agent': 'npmx' } }, 661 667 REPO_META_TTL, 662 668 ) 669 + res = data 663 670 } catch { 664 671 return null 665 672 } ··· 720 727 721 728 let res: GiteaRepoResponse | null = null 722 729 try { 723 - res = await cachedFetch<GiteaRepoResponse>( 730 + const { data } = await cachedFetch<GiteaRepoResponse>( 724 731 `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, 725 732 { headers: { 'User-Agent': 'npmx' } }, 726 733 REPO_META_TTL, 727 734 ) 735 + res = data 728 736 } catch { 729 737 return null 730 738 }
+106 -86
server/plugins/fetch-cache.ts
··· 1 - import type { CachedFetchEntry } from '#shared/utils/fetch-cache-config' 1 + import type { H3Event } from 'h3' 2 + import type { CachedFetchEntry, CachedFetchResult } from '#shared/utils/fetch-cache-config' 2 3 import { 3 4 FETCH_CACHE_DEFAULT_TTL, 4 5 FETCH_CACHE_STORAGE_BASE, ··· 48 49 headers?: Record<string, string> 49 50 }, 50 51 ttl?: number, 51 - ) => Promise<T> 52 + ) => Promise<CachedFetchResult<T>> 52 53 53 54 /** 54 - * Server middleware that attaches a cachedFetch function to the event context. 55 + * Server plugin that attaches a cachedFetch function to the event context. 55 56 * This allows app composables to access the cached fetch via useRequestEvent(). 57 + * 58 + * The cachedFetch function implements stale-while-revalidate (SWR) semantics: 59 + * - Fresh cache hit: Return cached data immediately 60 + * - Stale cache hit: Return stale data immediately + revalidate in background via waitUntil 61 + * - Cache miss: Fetch data, return immediately, cache in background via waitUntil 56 62 */ 57 63 export default defineNitroPlugin(nitroApp => { 58 64 const storage = useStorage(FETCH_CACHE_STORAGE_BASE) 59 65 60 66 /** 61 - * Perform a cached fetch with stale-while-revalidate semantics. 67 + * Factory that creates a cachedFetch function bound to a specific request event. 68 + * This allows using event.waitUntil() for background revalidation. 62 69 */ 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 - // eslint-disable-next-line no-console 89 - console.warn(`[fetch-cache] Storage read failed for ${url}:`, error) 70 + function createCachedFetch(event: H3Event): CachedFetchFunction { 71 + return async <T = unknown>( 72 + url: string, 73 + options: { 74 + method?: string 75 + body?: unknown 76 + headers?: Record<string, string> 77 + } = {}, 78 + ttl: number = FETCH_CACHE_DEFAULT_TTL, 79 + ): Promise<CachedFetchResult<T>> => { 80 + // Check if this URL should be cached 81 + if (!isAllowedDomain(url)) { 82 + const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 83 + return { data, isStale: false, cachedAt: null } 90 84 } 91 - } 92 85 93 - if (cached) { 94 - if (!isCacheEntryStale(cached)) { 95 - // Cache hit, data is fresh 86 + const method = options.method || 'GET' 87 + const cacheKey = generateFetchCacheKey(url, method, options.body) 88 + 89 + // Try to get cached response (with error handling for storage failures) 90 + let cached: CachedFetchEntry<T> | null = null 91 + try { 92 + cached = await storage.getItem<CachedFetchEntry<T>>(cacheKey) 93 + } catch (error) { 94 + // Storage read failed (e.g., ENOENT on misconfigured storage) 95 + // Log and continue without cache 96 96 if (import.meta.dev) { 97 97 // eslint-disable-next-line no-console 98 - console.log(`[fetch-cache] HIT (fresh): ${url}`) 98 + console.warn(`[fetch-cache] Storage read failed for ${url}:`, error) 99 99 } 100 - return cached.data 101 100 } 102 101 103 - // Cache hit but stale - return stale data and revalidate in background 104 - if (import.meta.dev) { 105 - // eslint-disable-next-line no-console 106 - console.log(`[fetch-cache] HIT (stale, revalidating): ${url}`) 107 - } 102 + if (cached) { 103 + const isStale = isCacheEntryStale(cached) 108 104 109 - // Fire-and-forget background revalidation 110 - Promise.resolve().then(async () => { 111 - try { 112 - const freshData = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 113 - const entry: CachedFetchEntry<T> = { 114 - data: freshData, 115 - status: 200, 116 - headers: {}, 117 - cachedAt: Date.now(), 118 - ttl, 119 - } 120 - await storage.setItem(cacheKey, entry) 121 - if (import.meta.dev) { 122 - // eslint-disable-next-line no-console 123 - console.log(`[fetch-cache] Revalidated: ${url}`) 124 - } 125 - } catch (error) { 105 + if (!isStale) { 106 + // Cache hit, data is fresh 126 107 if (import.meta.dev) { 127 108 // eslint-disable-next-line no-console 128 - console.warn(`[fetch-cache] Revalidation failed: ${url}`, error) 109 + console.log(`[fetch-cache] HIT (fresh): ${url}`) 129 110 } 111 + return { data: cached.data, isStale: false, cachedAt: cached.cachedAt } 130 112 } 131 - }) 132 113 133 - // Return stale data immediately 134 - return cached.data 135 - } 136 - 137 - // Cache miss - fetch and cache 138 - if (import.meta.dev) { 139 - // eslint-disable-next-line no-console 140 - console.log(`[fetch-cache] MISS: ${url}`) 141 - } 114 + // Cache hit but stale - return stale data and revalidate in background 115 + if (import.meta.dev) { 116 + // eslint-disable-next-line no-console 117 + console.log(`[fetch-cache] HIT (stale, revalidating): ${url}`) 118 + } 142 119 143 - const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 120 + // Background revalidation using event.waitUntil() 121 + // This ensures the revalidation completes even in serverless environments 122 + event.waitUntil( 123 + (async () => { 124 + try { 125 + const freshData = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 126 + const entry: CachedFetchEntry<T> = { 127 + data: freshData, 128 + status: 200, 129 + headers: {}, 130 + cachedAt: Date.now(), 131 + ttl, 132 + } 133 + await storage.setItem(cacheKey, entry) 134 + if (import.meta.dev) { 135 + // eslint-disable-next-line no-console 136 + console.log(`[fetch-cache] Revalidated: ${url}`) 137 + } 138 + } catch (error) { 139 + if (import.meta.dev) { 140 + // eslint-disable-next-line no-console 141 + console.warn(`[fetch-cache] Revalidation failed: ${url}`, error) 142 + } 143 + } 144 + })(), 145 + ) 144 146 145 - // Try to cache the response (non-blocking, with error handling) 146 - try { 147 - const entry: CachedFetchEntry<T> = { 148 - data, 149 - status: 200, 150 - headers: {}, 151 - cachedAt: Date.now(), 152 - ttl, 147 + // Return stale data immediately 148 + return { data: cached.data, isStale: true, cachedAt: cached.cachedAt } 153 149 } 154 - await storage.setItem(cacheKey, entry) 155 - } catch (error) { 156 - // Storage write failed - log but don't fail the request 150 + 151 + // Cache miss - fetch and return immediately, cache in background 157 152 if (import.meta.dev) { 158 153 // eslint-disable-next-line no-console 159 - console.warn(`[fetch-cache] Storage write failed for ${url}:`, error) 154 + console.log(`[fetch-cache] MISS: ${url}`) 160 155 } 161 - } 162 156 163 - return data 157 + const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T 158 + const cachedAt = Date.now() 159 + 160 + // Defer cache write to background via waitUntil for faster response 161 + event.waitUntil( 162 + (async () => { 163 + try { 164 + const entry: CachedFetchEntry<T> = { 165 + data, 166 + status: 200, 167 + headers: {}, 168 + cachedAt, 169 + ttl, 170 + } 171 + await storage.setItem(cacheKey, entry) 172 + } catch (error) { 173 + // Storage write failed - log but don't fail the request 174 + if (import.meta.dev) { 175 + // eslint-disable-next-line no-console 176 + console.warn(`[fetch-cache] Storage write failed for ${url}:`, error) 177 + } 178 + } 179 + })(), 180 + ) 181 + 182 + return { data, isStale: false, cachedAt } 183 + } 164 184 } 165 185 166 186 // Attach to event context for access in composables via useRequestEvent() 167 187 nitroApp.hooks.hook('request', event => { 168 - event.context.cachedFetch = cachedFetch 188 + event.context.cachedFetch = createCachedFetch(event) 169 189 }) 170 190 }) 171 191
+1
shared/types/npm-registry.ts
··· 83 83 * Note: Not covered by @npm/types (see https://github.com/npm/types/issues/28) 84 84 */ 85 85 export interface NpmSearchResponse { 86 + isStale: boolean 86 87 objects: NpmSearchResult[] 87 88 total: number 88 89 time: string
+14
shared/utils/fetch-cache-config.ts
··· 80 80 const expiresAt = entry.cachedAt + entry.ttl * 1000 81 81 return now > expiresAt 82 82 } 83 + 84 + /** 85 + * Result returned by cachedFetch with staleness metadata. 86 + * This allows consumers to know if the data came from stale cache 87 + * and potentially trigger client-side revalidation. 88 + */ 89 + export interface CachedFetchResult<T> { 90 + /** The response data */ 91 + data: T 92 + /** Whether the data came from stale cache (past TTL) */ 93 + isStale: boolean 94 + /** Unix timestamp when the data was cached, or null if fresh fetch */ 95 + cachedAt: number | null 96 + }