diff --git a/app/composables/useCachedFetch.ts b/app/composables/useCachedFetch.ts index 19c3884b29..970470fb6f 100644 --- a/app/composables/useCachedFetch.ts +++ b/app/composables/useCachedFetch.ts @@ -1,3 +1,5 @@ +import type { CachedFetchResult } from '#shared/utils/fetch-cache-config' + /** * Type for the cachedFetch function attached to event context. */ @@ -9,7 +11,7 @@ export type CachedFetchFunction = ( headers?: Record }, ttl?: number, -) => Promise +) => Promise> /** * Get the cachedFetch function from the current request context. @@ -17,6 +19,11 @@ export type CachedFetchFunction = ( * IMPORTANT: This must be called in the composable setup context (outside of * useAsyncData handlers). The returned function can then be used inside handlers. * + * The returned function returns a wrapper object with staleness metadata: + * - `data`: The response data + * - `isStale`: Whether the data came from stale cache + * - `cachedAt`: Unix timestamp when cached, or null if fresh fetch + * * @example * ```ts * export function usePackage(name: MaybeRefOrGetter) { @@ -25,15 +32,18 @@ export type CachedFetchFunction = ( * * return useLazyAsyncData( * () => `package:${toValue(name)}`, - * // Use it inside the handler - * () => cachedFetch(`https://registry.npmjs.org/${toValue(name)}`) + * // Use it inside the handler - destructure { data } or { data, isStale } + * async () => { + * const { data } = await cachedFetch(`https://registry.npmjs.org/${toValue(name)}`) + * return data + * } * ) * } * ``` * @public */ export function useCachedFetch(): CachedFetchFunction { - // On client, return a function that just uses $fetch + // On client, return a function that just uses $fetch (no caching, not stale) if (import.meta.client) { return async ( url: string, @@ -43,8 +53,9 @@ export function useCachedFetch(): CachedFetchFunction { headers?: Record } = {}, _ttl?: number, - ): Promise => { - return (await $fetch(url, options as Parameters[1])) as T + ): Promise> => { + const data = (await $fetch(url, options as Parameters[1])) as T + return { data, isStale: false, cachedAt: null } } } @@ -67,7 +78,8 @@ export function useCachedFetch(): CachedFetchFunction { headers?: Record } = {}, _ttl?: number, - ): Promise => { - return (await $fetch(url, options as Parameters[1])) as T + ): Promise> => { + const data = (await $fetch(url, options as Parameters[1])) as T + return { data, isStale: false, cachedAt: null } } } diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index c340b377bd..f970317e1b 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -183,17 +183,25 @@ export function usePackage( ) { const cachedFetch = useCachedFetch() - return useLazyAsyncData( + const asyncData = useLazyAsyncData( () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`, async () => { const encodedName = encodePackageName(toValue(name)) - const r = await cachedFetch(`${NPM_REGISTRY}/${encodedName}`) + const { data: r, isStale } = await cachedFetch(`${NPM_REGISTRY}/${encodedName}`) const reqVer = toValue(requestedVersion) const pkg = transformPackument(r, reqVer) const resolvedVersion = getResolvedVersion(pkg, reqVer) - return { ...pkg, resolvedVersion } + return { ...pkg, resolvedVersion, isStale } }, ) + + if (import.meta.client && asyncData.data.value?.isStale) { + onMounted(() => { + asyncData.refresh() + }) + } + + return asyncData } function getResolvedVersion(pkg: SlimPackument, reqVer?: string | null): string | null { @@ -223,15 +231,24 @@ export function usePackageDownloads( ) { const cachedFetch = useCachedFetch() - return useLazyAsyncData( + const asyncData = useLazyAsyncData( () => `downloads:${toValue(name)}:${toValue(period)}`, async () => { const encodedName = encodePackageName(toValue(name)) - return await cachedFetch( + const { data, isStale } = await cachedFetch( `${NPM_API}/downloads/point/${toValue(period)}/${encodedName}`, ) + return { ...data, isStale } }, ) + + if (import.meta.client && asyncData.data.value?.isStale) { + onMounted(() => { + asyncData.refresh() + }) + } + + return asyncData } type NpmDownloadsRangeResponse = { @@ -260,6 +277,7 @@ export async function fetchNpmDownloadsRange( const emptySearchResponse = { objects: [], total: 0, + isStale: false, time: new Date().toISOString(), } satisfies NpmSearchResponse @@ -305,7 +323,7 @@ export function useNpmSearch( // Use requested size for initial fetch params.set('size', String(opts.size ?? 25)) - const response = await cachedFetch( + const { data: response, isStale } = await cachedFetch( `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, {}, 60, @@ -317,7 +335,7 @@ export function useNpmSearch( total: response.total, } - return response + return { ...response, isStale } }, { default: () => lastSearch || emptySearchResponse }, ) @@ -357,7 +375,7 @@ export function useNpmSearch( params.set('size', String(size)) params.set('from', String(from)) - const response = await cachedFetch( + const { data: response } = await cachedFetch( `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, {}, 60, @@ -405,6 +423,7 @@ export function useNpmSearch( const data = computed(() => { if (cache.value) { return { + isStale: false, objects: cache.value.objects, total: cache.value.total, time: new Date().toISOString(), @@ -413,6 +432,12 @@ export function useNpmSearch( return asyncData.data.value }) + if (import.meta.client && asyncData.data.value?.isStale) { + onMounted(() => { + asyncData.refresh() + }) + } + // Whether there are more results available on the server (incremental mode only) const hasMore = computed(() => { if (!cache.value) return true @@ -482,7 +507,7 @@ function packumentToSearchResult(pkg: MinimalPackument, weeklyDownloads?: number export function useOrgPackages(orgName: MaybeRefOrGetter) { const cachedFetch = useCachedFetch() - return useLazyAsyncData( + const asyncData = useLazyAsyncData( () => `org-packages:${toValue(orgName)}`, async () => { const org = toValue(orgName) @@ -493,7 +518,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { // Get all package names in the org let packageNames: string[] try { - const data = await cachedFetch>( + const { data } = await cachedFetch>( `${NPM_REGISTRY}/-/org/${encodeURIComponent(org)}/package`, ) packageNames = Object.keys(data) @@ -526,7 +551,10 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { batch.map(async name => { try { const encoded = encodePackageName(name) - return await cachedFetch(`${NPM_REGISTRY}/${encoded}`) + const { data: pkg } = await cachedFetch( + `${NPM_REGISTRY}/${encoded}`, + ) + return pkg } catch { return null } @@ -551,6 +579,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { ) return { + isStale: false, objects: results, total: results.length, time: new Date().toISOString(), @@ -558,6 +587,8 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { }, { default: () => emptySearchResponse }, ) + + return asyncData } // ============================================================================ @@ -665,9 +696,9 @@ async function checkDependencyOutdated( if (cached) { packument = await cached } else { - const promise = cachedFetch( - `${NPM_REGISTRY}/${encodePackageName(packageName)}`, - ).catch(() => null) + const promise = cachedFetch(`${NPM_REGISTRY}/${encodePackageName(packageName)}`) + .then(({ data }) => data) + .catch(() => null) packumentCache.set(packageName, promise) packument = await promise } diff --git a/app/composables/useRepoMeta.ts b/app/composables/useRepoMeta.ts index 7c4fd26206..4517014b95 100644 --- a/app/composables/useRepoMeta.ts +++ b/app/composables/useRepoMeta.ts @@ -145,11 +145,12 @@ const githubAdapter: ProviderAdapter = { // Using UNGH to avoid API limitations of the Github API let res: UnghRepoResponse | null = null try { - res = await cachedFetch( + const { data } = await cachedFetch( `https://ungh.cc/repos/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx' } }, REPO_META_TTL, ) + res = data } catch { return null } @@ -210,11 +211,12 @@ const gitlabAdapter: ProviderAdapter = { const projectPath = encodeURIComponent(`${ref.owner}/${ref.repo}`) let res: GitLabProjectResponse | null = null try { - res = await cachedFetch( + const { data } = await cachedFetch( `https://${baseHost}/api/v4/projects/${projectPath}`, { headers: { 'User-Agent': 'npmx' } }, REPO_META_TTL, ) + res = data } catch { return null } @@ -265,11 +267,12 @@ const bitbucketAdapter: ProviderAdapter = { async fetchMeta(cachedFetch, ref, links) { let res: BitbucketRepoResponse | null = null try { - res = await cachedFetch( + const { data } = await cachedFetch( `https://api.bitbucket.org/2.0/repositories/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx' } }, REPO_META_TTL, ) + res = data } catch { return null } @@ -322,11 +325,12 @@ const codebergAdapter: ProviderAdapter = { async fetchMeta(cachedFetch, ref, links) { let res: GiteaRepoResponse | null = null try { - res = await cachedFetch( + const { data } = await cachedFetch( `https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx' } }, REPO_META_TTL, ) + res = data } catch { return null } @@ -379,11 +383,12 @@ const giteeAdapter: ProviderAdapter = { async fetchMeta(cachedFetch, ref, links) { let res: GiteeRepoResponse | null = null try { - res = await cachedFetch( + const { data } = await cachedFetch( `https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx' } }, REPO_META_TTL, ) + res = data } catch { return null } @@ -469,11 +474,12 @@ const giteaAdapter: ProviderAdapter = { // so caching may not apply for self-hosted instances let res: GiteaRepoResponse | null = null try { - res = await cachedFetch( + const { data } = await cachedFetch( `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx' } }, REPO_META_TTL, ) + res = data } catch { return null } @@ -577,7 +583,7 @@ const tangledAdapter: ProviderAdapter = { // Tangled doesn't have a public JSON API, but we can scrape the star count // from the HTML page (it's in the hx-post URL as countHint=N) try { - const html = await cachedFetch( + const { data: html } = await cachedFetch( `https://tangled.org/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx', 'Accept': 'text/html' } }, REPO_META_TTL, @@ -594,7 +600,7 @@ const tangledAdapter: ProviderAdapter = { if (atUriMatch) { try { //Get counts of records that reference this repo in the atmosphere using constellation - const allLinks = await cachedFetch( + const { data: allLinks } = await cachedFetch( `https://constellation.microcosm.blue/links/all?target=${atUri}`, { headers: { 'User-Agent': 'npmx' } }, REPO_META_TTL, @@ -655,11 +661,12 @@ const radicleAdapter: ProviderAdapter = { async fetchMeta(cachedFetch, ref, links) { let res: RadicleProjectResponse | null = null try { - res = await cachedFetch( + const { data } = await cachedFetch( `https://seed.radicle.at/api/v1/projects/${ref.repo}`, { headers: { 'User-Agent': 'npmx' } }, REPO_META_TTL, ) + res = data } catch { return null } @@ -720,11 +727,12 @@ const forgejoAdapter: ProviderAdapter = { let res: GiteaRepoResponse | null = null try { - res = await cachedFetch( + const { data } = await cachedFetch( `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, { headers: { 'User-Agent': 'npmx' } }, REPO_META_TTL, ) + res = data } catch { return null } diff --git a/server/plugins/fetch-cache.ts b/server/plugins/fetch-cache.ts index 4b5929cdff..3173b184fa 100644 --- a/server/plugins/fetch-cache.ts +++ b/server/plugins/fetch-cache.ts @@ -1,4 +1,5 @@ -import type { CachedFetchEntry } from '#shared/utils/fetch-cache-config' +import type { H3Event } from 'h3' +import type { CachedFetchEntry, CachedFetchResult } from '#shared/utils/fetch-cache-config' import { FETCH_CACHE_DEFAULT_TTL, FETCH_CACHE_STORAGE_BASE, @@ -48,124 +49,143 @@ export type CachedFetchFunction = ( headers?: Record }, ttl?: number, -) => Promise +) => Promise> /** - * Server middleware that attaches a cachedFetch function to the event context. + * Server plugin that attaches a cachedFetch function to the event context. * This allows app composables to access the cached fetch via useRequestEvent(). + * + * The cachedFetch function implements stale-while-revalidate (SWR) semantics: + * - Fresh cache hit: Return cached data immediately + * - Stale cache hit: Return stale data immediately + revalidate in background via waitUntil + * - Cache miss: Fetch data, return immediately, cache in background via waitUntil */ export default defineNitroPlugin(nitroApp => { const storage = useStorage(FETCH_CACHE_STORAGE_BASE) /** - * Perform a cached fetch with stale-while-revalidate semantics. + * Factory that creates a cachedFetch function bound to a specific request event. + * This allows using event.waitUntil() for background revalidation. */ - const cachedFetch: CachedFetchFunction = async ( - url: string, - options: { - method?: string - body?: unknown - headers?: Record - } = {}, - ttl: number = FETCH_CACHE_DEFAULT_TTL, - ): Promise => { - // Check if this URL should be cached - if (!isAllowedDomain(url)) { - return (await $fetch(url, options as Parameters[1])) as T - } - - const method = options.method || 'GET' - const cacheKey = generateFetchCacheKey(url, method, options.body) - - // Try to get cached response (with error handling for storage failures) - let cached: CachedFetchEntry | null = null - try { - cached = await storage.getItem>(cacheKey) - } catch (error) { - // Storage read failed (e.g., ENOENT on misconfigured storage) - // Log and continue without cache - if (import.meta.dev) { - // eslint-disable-next-line no-console - console.warn(`[fetch-cache] Storage read failed for ${url}:`, error) + function createCachedFetch(event: H3Event): CachedFetchFunction { + return async ( + url: string, + options: { + method?: string + body?: unknown + headers?: Record + } = {}, + ttl: number = FETCH_CACHE_DEFAULT_TTL, + ): Promise> => { + // Check if this URL should be cached + if (!isAllowedDomain(url)) { + const data = (await $fetch(url, options as Parameters[1])) as T + return { data, isStale: false, cachedAt: null } } - } - if (cached) { - if (!isCacheEntryStale(cached)) { - // Cache hit, data is fresh + const method = options.method || 'GET' + const cacheKey = generateFetchCacheKey(url, method, options.body) + + // Try to get cached response (with error handling for storage failures) + let cached: CachedFetchEntry | null = null + try { + cached = await storage.getItem>(cacheKey) + } catch (error) { + // Storage read failed (e.g., ENOENT on misconfigured storage) + // Log and continue without cache if (import.meta.dev) { // eslint-disable-next-line no-console - console.log(`[fetch-cache] HIT (fresh): ${url}`) + console.warn(`[fetch-cache] Storage read failed for ${url}:`, error) } - return cached.data } - // Cache hit but stale - return stale data and revalidate in background - if (import.meta.dev) { - // eslint-disable-next-line no-console - console.log(`[fetch-cache] HIT (stale, revalidating): ${url}`) - } + if (cached) { + const isStale = isCacheEntryStale(cached) - // Fire-and-forget background revalidation - Promise.resolve().then(async () => { - try { - const freshData = (await $fetch(url, options as Parameters[1])) as T - const entry: CachedFetchEntry = { - data: freshData, - status: 200, - headers: {}, - cachedAt: Date.now(), - ttl, - } - await storage.setItem(cacheKey, entry) - if (import.meta.dev) { - // eslint-disable-next-line no-console - console.log(`[fetch-cache] Revalidated: ${url}`) - } - } catch (error) { + if (!isStale) { + // Cache hit, data is fresh if (import.meta.dev) { // eslint-disable-next-line no-console - console.warn(`[fetch-cache] Revalidation failed: ${url}`, error) + console.log(`[fetch-cache] HIT (fresh): ${url}`) } + return { data: cached.data, isStale: false, cachedAt: cached.cachedAt } } - }) - - // Return stale data immediately - return cached.data - } - // Cache miss - fetch and cache - if (import.meta.dev) { - // eslint-disable-next-line no-console - console.log(`[fetch-cache] MISS: ${url}`) - } - - const data = (await $fetch(url, options as Parameters[1])) as T + // Cache hit but stale - return stale data and revalidate in background + if (import.meta.dev) { + // eslint-disable-next-line no-console + console.log(`[fetch-cache] HIT (stale, revalidating): ${url}`) + } - // Try to cache the response (non-blocking, with error handling) - try { - const entry: CachedFetchEntry = { - data, - status: 200, - headers: {}, - cachedAt: Date.now(), - ttl, + // Background revalidation using event.waitUntil() + // This ensures the revalidation completes even in serverless environments + event.waitUntil( + (async () => { + try { + const freshData = (await $fetch(url, options as Parameters[1])) as T + const entry: CachedFetchEntry = { + data: freshData, + status: 200, + headers: {}, + cachedAt: Date.now(), + ttl, + } + await storage.setItem(cacheKey, entry) + if (import.meta.dev) { + // eslint-disable-next-line no-console + console.log(`[fetch-cache] Revalidated: ${url}`) + } + } catch (error) { + if (import.meta.dev) { + // eslint-disable-next-line no-console + console.warn(`[fetch-cache] Revalidation failed: ${url}`, error) + } + } + })(), + ) + + // Return stale data immediately + return { data: cached.data, isStale: true, cachedAt: cached.cachedAt } } - await storage.setItem(cacheKey, entry) - } catch (error) { - // Storage write failed - log but don't fail the request + + // Cache miss - fetch and return immediately, cache in background if (import.meta.dev) { // eslint-disable-next-line no-console - console.warn(`[fetch-cache] Storage write failed for ${url}:`, error) + console.log(`[fetch-cache] MISS: ${url}`) } - } - return data + const data = (await $fetch(url, options as Parameters[1])) as T + const cachedAt = Date.now() + + // Defer cache write to background via waitUntil for faster response + event.waitUntil( + (async () => { + try { + const entry: CachedFetchEntry = { + data, + status: 200, + headers: {}, + cachedAt, + ttl, + } + await storage.setItem(cacheKey, entry) + } catch (error) { + // Storage write failed - log but don't fail the request + if (import.meta.dev) { + // eslint-disable-next-line no-console + console.warn(`[fetch-cache] Storage write failed for ${url}:`, error) + } + } + })(), + ) + + return { data, isStale: false, cachedAt } + } } // Attach to event context for access in composables via useRequestEvent() nitroApp.hooks.hook('request', event => { - event.context.cachedFetch = cachedFetch + event.context.cachedFetch = createCachedFetch(event) }) }) diff --git a/shared/types/npm-registry.ts b/shared/types/npm-registry.ts index 8722201f64..a29019f367 100644 --- a/shared/types/npm-registry.ts +++ b/shared/types/npm-registry.ts @@ -83,6 +83,7 @@ export interface NpmPerson { * Note: Not covered by @npm/types (see https://github.com/npm/types/issues/28) */ export interface NpmSearchResponse { + isStale: boolean objects: NpmSearchResult[] total: number time: string diff --git a/shared/utils/fetch-cache-config.ts b/shared/utils/fetch-cache-config.ts index b9bb85f6e4..eb8ed1459d 100644 --- a/shared/utils/fetch-cache-config.ts +++ b/shared/utils/fetch-cache-config.ts @@ -80,3 +80,17 @@ export function isCacheEntryStale(entry: CachedFetchEntry): boolean { const expiresAt = entry.cachedAt + entry.ttl * 1000 return now > expiresAt } + +/** + * Result returned by cachedFetch with staleness metadata. + * This allows consumers to know if the data came from stale cache + * and potentially trigger client-side revalidation. + */ +export interface CachedFetchResult { + /** The response data */ + data: T + /** Whether the data came from stale cache (past TTL) */ + isStale: boolean + /** Unix timestamp when the data was cached, or null if fresh fetch */ + cachedAt: number | null +}