···11+import type { CachedFetchResult } from '#shared/utils/fetch-cache-config'
22+13/**
24 * Type for the cachedFetch function attached to event context.
35 */
···911 headers?: Record<string, string>
1012 },
1113 ttl?: number,
1212-) => Promise<T>
1414+) => Promise<CachedFetchResult<T>>
13151416/**
1517 * Get the cachedFetch function from the current request context.
···1719 * IMPORTANT: This must be called in the composable setup context (outside of
1820 * useAsyncData handlers). The returned function can then be used inside handlers.
1921 *
2222+ * The returned function returns a wrapper object with staleness metadata:
2323+ * - `data`: The response data
2424+ * - `isStale`: Whether the data came from stale cache
2525+ * - `cachedAt`: Unix timestamp when cached, or null if fresh fetch
2626+ *
2027 * @example
2128 * ```ts
2229 * export function usePackage(name: MaybeRefOrGetter<string>) {
···2532 *
2633 * return useLazyAsyncData(
2734 * () => `package:${toValue(name)}`,
2828- * // Use it inside the handler
2929- * () => cachedFetch<Packument>(`https://registry.npmjs.org/${toValue(name)}`)
3535+ * // Use it inside the handler - destructure { data } or { data, isStale }
3636+ * async () => {
3737+ * const { data } = await cachedFetch<Packument>(`https://registry.npmjs.org/${toValue(name)}`)
3838+ * return data
3939+ * }
3040 * )
3141 * }
3242 * ```
3343 * @public
3444 */
3545export function useCachedFetch(): CachedFetchFunction {
3636- // On client, return a function that just uses $fetch
4646+ // On client, return a function that just uses $fetch (no caching, not stale)
3747 if (import.meta.client) {
3848 return async <T = unknown>(
3949 url: string,
···4353 headers?: Record<string, string>
4454 } = {},
4555 _ttl?: number,
4646- ): Promise<T> => {
4747- return (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
5656+ ): Promise<CachedFetchResult<T>> => {
5757+ const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
5858+ return { data, isStale: false, cachedAt: null }
4859 }
4960 }
5061···6778 headers?: Record<string, string>
6879 } = {},
6980 _ttl?: number,
7070- ): Promise<T> => {
7171- return (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
8181+ ): Promise<CachedFetchResult<T>> => {
8282+ const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
8383+ return { data, isStale: false, cachedAt: null }
7284 }
7385}
···145145 // Using UNGH to avoid API limitations of the Github API
146146 let res: UnghRepoResponse | null = null
147147 try {
148148- res = await cachedFetch<UnghRepoResponse>(
148148+ const { data } = await cachedFetch<UnghRepoResponse>(
149149 `https://ungh.cc/repos/${ref.owner}/${ref.repo}`,
150150 { headers: { 'User-Agent': 'npmx' } },
151151 REPO_META_TTL,
152152 )
153153+ res = data
153154 } catch {
154155 return null
155156 }
···210211 const projectPath = encodeURIComponent(`${ref.owner}/${ref.repo}`)
211212 let res: GitLabProjectResponse | null = null
212213 try {
213213- res = await cachedFetch<GitLabProjectResponse>(
214214+ const { data } = await cachedFetch<GitLabProjectResponse>(
214215 `https://${baseHost}/api/v4/projects/${projectPath}`,
215216 { headers: { 'User-Agent': 'npmx' } },
216217 REPO_META_TTL,
217218 )
219219+ res = data
218220 } catch {
219221 return null
220222 }
···265267 async fetchMeta(cachedFetch, ref, links) {
266268 let res: BitbucketRepoResponse | null = null
267269 try {
268268- res = await cachedFetch<BitbucketRepoResponse>(
270270+ const { data } = await cachedFetch<BitbucketRepoResponse>(
269271 `https://api.bitbucket.org/2.0/repositories/${ref.owner}/${ref.repo}`,
270272 { headers: { 'User-Agent': 'npmx' } },
271273 REPO_META_TTL,
272274 )
275275+ res = data
273276 } catch {
274277 return null
275278 }
···322325 async fetchMeta(cachedFetch, ref, links) {
323326 let res: GiteaRepoResponse | null = null
324327 try {
325325- res = await cachedFetch<GiteaRepoResponse>(
328328+ const { data } = await cachedFetch<GiteaRepoResponse>(
326329 `https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}`,
327330 { headers: { 'User-Agent': 'npmx' } },
328331 REPO_META_TTL,
329332 )
333333+ res = data
330334 } catch {
331335 return null
332336 }
···379383 async fetchMeta(cachedFetch, ref, links) {
380384 let res: GiteeRepoResponse | null = null
381385 try {
382382- res = await cachedFetch<GiteeRepoResponse>(
386386+ const { data } = await cachedFetch<GiteeRepoResponse>(
383387 `https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}`,
384388 { headers: { 'User-Agent': 'npmx' } },
385389 REPO_META_TTL,
386390 )
391391+ res = data
387392 } catch {
388393 return null
389394 }
···469474 // so caching may not apply for self-hosted instances
470475 let res: GiteaRepoResponse | null = null
471476 try {
472472- res = await cachedFetch<GiteaRepoResponse>(
477477+ const { data } = await cachedFetch<GiteaRepoResponse>(
473478 `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`,
474479 { headers: { 'User-Agent': 'npmx' } },
475480 REPO_META_TTL,
476481 )
482482+ res = data
477483 } catch {
478484 return null
479485 }
···577583 // Tangled doesn't have a public JSON API, but we can scrape the star count
578584 // from the HTML page (it's in the hx-post URL as countHint=N)
579585 try {
580580- const html = await cachedFetch<string>(
586586+ const { data: html } = await cachedFetch<string>(
581587 `https://tangled.org/${ref.owner}/${ref.repo}`,
582588 { headers: { 'User-Agent': 'npmx', 'Accept': 'text/html' } },
583589 REPO_META_TTL,
···594600 if (atUriMatch) {
595601 try {
596602 //Get counts of records that reference this repo in the atmosphere using constellation
597597- const allLinks = await cachedFetch<ConstellationAllLinksResponse>(
603603+ const { data: allLinks } = await cachedFetch<ConstellationAllLinksResponse>(
598604 `https://constellation.microcosm.blue/links/all?target=${atUri}`,
599605 { headers: { 'User-Agent': 'npmx' } },
600606 REPO_META_TTL,
···655661 async fetchMeta(cachedFetch, ref, links) {
656662 let res: RadicleProjectResponse | null = null
657663 try {
658658- res = await cachedFetch<RadicleProjectResponse>(
664664+ const { data } = await cachedFetch<RadicleProjectResponse>(
659665 `https://seed.radicle.at/api/v1/projects/${ref.repo}`,
660666 { headers: { 'User-Agent': 'npmx' } },
661667 REPO_META_TTL,
662668 )
669669+ res = data
663670 } catch {
664671 return null
665672 }
···720727721728 let res: GiteaRepoResponse | null = null
722729 try {
723723- res = await cachedFetch<GiteaRepoResponse>(
730730+ const { data } = await cachedFetch<GiteaRepoResponse>(
724731 `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`,
725732 { headers: { 'User-Agent': 'npmx' } },
726733 REPO_META_TTL,
727734 )
735735+ res = data
728736 } catch {
729737 return null
730738 }
+106-86
server/plugins/fetch-cache.ts
···11-import type { CachedFetchEntry } from '#shared/utils/fetch-cache-config'
11+import type { H3Event } from 'h3'
22+import type { CachedFetchEntry, CachedFetchResult } from '#shared/utils/fetch-cache-config'
23import {
34 FETCH_CACHE_DEFAULT_TTL,
45 FETCH_CACHE_STORAGE_BASE,
···4849 headers?: Record<string, string>
4950 },
5051 ttl?: number,
5151-) => Promise<T>
5252+) => Promise<CachedFetchResult<T>>
52535354/**
5454- * Server middleware that attaches a cachedFetch function to the event context.
5555+ * Server plugin that attaches a cachedFetch function to the event context.
5556 * This allows app composables to access the cached fetch via useRequestEvent().
5757+ *
5858+ * The cachedFetch function implements stale-while-revalidate (SWR) semantics:
5959+ * - Fresh cache hit: Return cached data immediately
6060+ * - Stale cache hit: Return stale data immediately + revalidate in background via waitUntil
6161+ * - Cache miss: Fetch data, return immediately, cache in background via waitUntil
5662 */
5763export default defineNitroPlugin(nitroApp => {
5864 const storage = useStorage(FETCH_CACHE_STORAGE_BASE)
59656066 /**
6161- * Perform a cached fetch with stale-while-revalidate semantics.
6767+ * Factory that creates a cachedFetch function bound to a specific request event.
6868+ * This allows using event.waitUntil() for background revalidation.
6269 */
6363- const cachedFetch: CachedFetchFunction = async <T = unknown>(
6464- url: string,
6565- options: {
6666- method?: string
6767- body?: unknown
6868- headers?: Record<string, string>
6969- } = {},
7070- ttl: number = FETCH_CACHE_DEFAULT_TTL,
7171- ): Promise<T> => {
7272- // Check if this URL should be cached
7373- if (!isAllowedDomain(url)) {
7474- return (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
7575- }
7676-7777- const method = options.method || 'GET'
7878- const cacheKey = generateFetchCacheKey(url, method, options.body)
7979-8080- // Try to get cached response (with error handling for storage failures)
8181- let cached: CachedFetchEntry<T> | null = null
8282- try {
8383- cached = await storage.getItem<CachedFetchEntry<T>>(cacheKey)
8484- } catch (error) {
8585- // Storage read failed (e.g., ENOENT on misconfigured storage)
8686- // Log and continue without cache
8787- if (import.meta.dev) {
8888- // eslint-disable-next-line no-console
8989- console.warn(`[fetch-cache] Storage read failed for ${url}:`, error)
7070+ function createCachedFetch(event: H3Event): CachedFetchFunction {
7171+ return async <T = unknown>(
7272+ url: string,
7373+ options: {
7474+ method?: string
7575+ body?: unknown
7676+ headers?: Record<string, string>
7777+ } = {},
7878+ ttl: number = FETCH_CACHE_DEFAULT_TTL,
7979+ ): Promise<CachedFetchResult<T>> => {
8080+ // Check if this URL should be cached
8181+ if (!isAllowedDomain(url)) {
8282+ const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
8383+ return { data, isStale: false, cachedAt: null }
9084 }
9191- }
92859393- if (cached) {
9494- if (!isCacheEntryStale(cached)) {
9595- // Cache hit, data is fresh
8686+ const method = options.method || 'GET'
8787+ const cacheKey = generateFetchCacheKey(url, method, options.body)
8888+8989+ // Try to get cached response (with error handling for storage failures)
9090+ let cached: CachedFetchEntry<T> | null = null
9191+ try {
9292+ cached = await storage.getItem<CachedFetchEntry<T>>(cacheKey)
9393+ } catch (error) {
9494+ // Storage read failed (e.g., ENOENT on misconfigured storage)
9595+ // Log and continue without cache
9696 if (import.meta.dev) {
9797 // eslint-disable-next-line no-console
9898- console.log(`[fetch-cache] HIT (fresh): ${url}`)
9898+ console.warn(`[fetch-cache] Storage read failed for ${url}:`, error)
9999 }
100100- return cached.data
101100 }
102101103103- // Cache hit but stale - return stale data and revalidate in background
104104- if (import.meta.dev) {
105105- // eslint-disable-next-line no-console
106106- console.log(`[fetch-cache] HIT (stale, revalidating): ${url}`)
107107- }
102102+ if (cached) {
103103+ const isStale = isCacheEntryStale(cached)
108104109109- // Fire-and-forget background revalidation
110110- Promise.resolve().then(async () => {
111111- try {
112112- const freshData = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
113113- const entry: CachedFetchEntry<T> = {
114114- data: freshData,
115115- status: 200,
116116- headers: {},
117117- cachedAt: Date.now(),
118118- ttl,
119119- }
120120- await storage.setItem(cacheKey, entry)
121121- if (import.meta.dev) {
122122- // eslint-disable-next-line no-console
123123- console.log(`[fetch-cache] Revalidated: ${url}`)
124124- }
125125- } catch (error) {
105105+ if (!isStale) {
106106+ // Cache hit, data is fresh
126107 if (import.meta.dev) {
127108 // eslint-disable-next-line no-console
128128- console.warn(`[fetch-cache] Revalidation failed: ${url}`, error)
109109+ console.log(`[fetch-cache] HIT (fresh): ${url}`)
129110 }
111111+ return { data: cached.data, isStale: false, cachedAt: cached.cachedAt }
130112 }
131131- })
132113133133- // Return stale data immediately
134134- return cached.data
135135- }
136136-137137- // Cache miss - fetch and cache
138138- if (import.meta.dev) {
139139- // eslint-disable-next-line no-console
140140- console.log(`[fetch-cache] MISS: ${url}`)
141141- }
114114+ // Cache hit but stale - return stale data and revalidate in background
115115+ if (import.meta.dev) {
116116+ // eslint-disable-next-line no-console
117117+ console.log(`[fetch-cache] HIT (stale, revalidating): ${url}`)
118118+ }
142119143143- const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
120120+ // Background revalidation using event.waitUntil()
121121+ // This ensures the revalidation completes even in serverless environments
122122+ event.waitUntil(
123123+ (async () => {
124124+ try {
125125+ const freshData = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
126126+ const entry: CachedFetchEntry<T> = {
127127+ data: freshData,
128128+ status: 200,
129129+ headers: {},
130130+ cachedAt: Date.now(),
131131+ ttl,
132132+ }
133133+ await storage.setItem(cacheKey, entry)
134134+ if (import.meta.dev) {
135135+ // eslint-disable-next-line no-console
136136+ console.log(`[fetch-cache] Revalidated: ${url}`)
137137+ }
138138+ } catch (error) {
139139+ if (import.meta.dev) {
140140+ // eslint-disable-next-line no-console
141141+ console.warn(`[fetch-cache] Revalidation failed: ${url}`, error)
142142+ }
143143+ }
144144+ })(),
145145+ )
144146145145- // Try to cache the response (non-blocking, with error handling)
146146- try {
147147- const entry: CachedFetchEntry<T> = {
148148- data,
149149- status: 200,
150150- headers: {},
151151- cachedAt: Date.now(),
152152- ttl,
147147+ // Return stale data immediately
148148+ return { data: cached.data, isStale: true, cachedAt: cached.cachedAt }
153149 }
154154- await storage.setItem(cacheKey, entry)
155155- } catch (error) {
156156- // Storage write failed - log but don't fail the request
150150+151151+ // Cache miss - fetch and return immediately, cache in background
157152 if (import.meta.dev) {
158153 // eslint-disable-next-line no-console
159159- console.warn(`[fetch-cache] Storage write failed for ${url}:`, error)
154154+ console.log(`[fetch-cache] MISS: ${url}`)
160155 }
161161- }
162156163163- return data
157157+ const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
158158+ const cachedAt = Date.now()
159159+160160+ // Defer cache write to background via waitUntil for faster response
161161+ event.waitUntil(
162162+ (async () => {
163163+ try {
164164+ const entry: CachedFetchEntry<T> = {
165165+ data,
166166+ status: 200,
167167+ headers: {},
168168+ cachedAt,
169169+ ttl,
170170+ }
171171+ await storage.setItem(cacheKey, entry)
172172+ } catch (error) {
173173+ // Storage write failed - log but don't fail the request
174174+ if (import.meta.dev) {
175175+ // eslint-disable-next-line no-console
176176+ console.warn(`[fetch-cache] Storage write failed for ${url}:`, error)
177177+ }
178178+ }
179179+ })(),
180180+ )
181181+182182+ return { data, isStale: false, cachedAt }
183183+ }
164184 }
165185166186 // Attach to event context for access in composables via useRequestEvent()
167187 nitroApp.hooks.hook('request', event => {
168168- event.context.cachedFetch = cachedFetch
188188+ event.context.cachedFetch = createCachedFetch(event)
169189 })
170190})
171191
+1
shared/types/npm-registry.ts
···8383 * Note: Not covered by @npm/types (see https://github.com/npm/types/issues/28)
8484 */
8585export interface NpmSearchResponse {
8686+ isStale: boolean
8687 objects: NpmSearchResult[]
8788 total: number
8889 time: string
+14
shared/utils/fetch-cache-config.ts
···8080 const expiresAt = entry.cachedAt + entry.ttl * 1000
8181 return now > expiresAt
8282}
8383+8484+/**
8585+ * Result returned by cachedFetch with staleness metadata.
8686+ * This allows consumers to know if the data came from stale cache
8787+ * and potentially trigger client-side revalidation.
8888+ */
8989+export interface CachedFetchResult<T> {
9090+ /** The response data */
9191+ data: T
9292+ /** Whether the data came from stale cache (past TTL) */
9393+ isStale: boolean
9494+ /** Unix timestamp when the data was cached, or null if fresh fetch */
9595+ cachedAt: number | null
9696+}