forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { H3Event } from 'h3'
2import type { CachedFetchEntry, CachedFetchResult } from '#shared/utils/fetch-cache-config'
3import { $fetch } from 'ofetch'
4import {
5 FETCH_CACHE_DEFAULT_TTL,
6 FETCH_CACHE_STORAGE_BASE,
7 FETCH_CACHE_VERSION,
8 isAllowedDomain,
9 isCacheEntryStale,
10} from '#shared/utils/fetch-cache-config'
11
12/**
13 * Simple hash function for cache keys.
14 */
15function simpleHash(str: string): string {
16 let hash = 0
17 for (let i = 0; i < str.length; i++) {
18 const char = str.charCodeAt(i)
19 hash = (hash << 5) - hash + char
20 hash = hash & hash
21 }
22 return Math.abs(hash).toString(36)
23}
24
25/**
26 * Generate a cache key for a fetch request.
27 */
28function generateFetchCacheKey(url: string | URL, method: string = 'GET', body?: unknown): string {
29 const urlObj = typeof url === 'string' ? new URL(url) : url
30 const bodyHash = body ? simpleHash(JSON.stringify(body)) : ''
31 const searchHash = urlObj.search ? simpleHash(urlObj.search) : ''
32
33 const parts = [
34 FETCH_CACHE_VERSION,
35 urlObj.host,
36 method.toUpperCase(),
37 urlObj.pathname,
38 searchHash,
39 bodyHash,
40 ].filter(Boolean)
41
42 return parts.join(':')
43}
44
45/**
46 * Server plugin that attaches a cachedFetch function to the event context.
47 * This allows app composables to access the cached fetch via useRequestEvent().
48 *
49 * The cachedFetch function implements stale-while-revalidate (SWR) semantics:
50 * - Fresh cache hit: Return cached data immediately
51 * - Stale cache hit: Return stale data immediately + revalidate in background via waitUntil
52 * - Cache miss: Fetch data, return immediately, cache in background via waitUntil
53 */
54export default defineNitroPlugin(nitroApp => {
55 const storage = useStorage(FETCH_CACHE_STORAGE_BASE)
56
57 /**
58 * Factory that creates a cachedFetch function bound to a specific request event.
59 * This allows using event.waitUntil() for background revalidation.
60 */
61 function createCachedFetch(event: H3Event): CachedFetchFunction {
62 return async <T = unknown>(
63 url: string,
64 options: Parameters<typeof $fetch>[1] = {},
65 ttl: number = FETCH_CACHE_DEFAULT_TTL,
66 ): Promise<CachedFetchResult<T>> => {
67 // Check if this URL should be cached
68 if (!isAllowedDomain(url)) {
69 const data = (await $fetch(url, options)) as T
70 return { data, isStale: false, cachedAt: null }
71 }
72
73 const method = options.method || 'GET'
74 const cacheKey = generateFetchCacheKey(url, method, options.body)
75
76 // Try to get cached response (with error handling for storage failures)
77 let cached: CachedFetchEntry<T> | null = null
78 try {
79 cached = await storage.getItem<CachedFetchEntry<T>>(cacheKey)
80 } catch (error) {
81 // Storage read failed (e.g., ENOENT on misconfigured storage)
82 // Log and continue without cache
83 if (import.meta.dev) {
84 // eslint-disable-next-line no-console
85 console.warn(`[fetch-cache] Storage read failed for ${url}:`, error)
86 }
87 }
88
89 if (cached) {
90 const isStale = isCacheEntryStale(cached)
91
92 if (!isStale) {
93 // Cache hit, data is fresh
94 if (import.meta.dev) {
95 // eslint-disable-next-line no-console
96 console.log(`[fetch-cache] HIT (fresh): ${url}`)
97 }
98 return { data: cached.data, isStale: false, cachedAt: cached.cachedAt }
99 }
100
101 // Cache hit but stale - return stale data and revalidate in background
102 if (import.meta.dev) {
103 // eslint-disable-next-line no-console
104 console.log(`[fetch-cache] HIT (stale, revalidating): ${url}`)
105 }
106
107 // Background revalidation using event.waitUntil()
108 // This ensures the revalidation completes even in serverless environments
109 event.waitUntil(
110 (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) {
126 if (import.meta.dev) {
127 // eslint-disable-next-line no-console
128 console.warn(`[fetch-cache] Revalidation failed: ${url}`, error)
129 }
130 }
131 })(),
132 )
133
134 // Return stale data immediately
135 return { data: cached.data, isStale: true, cachedAt: cached.cachedAt }
136 }
137
138 // Cache miss - fetch and return immediately, cache in background
139 if (import.meta.dev) {
140 // eslint-disable-next-line no-console
141 console.log(`[fetch-cache] MISS: ${url}`)
142 }
143
144 const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
145 const cachedAt = Date.now()
146
147 // Defer cache write to background via waitUntil for faster response
148 event.waitUntil(
149 (async () => {
150 try {
151 const entry: CachedFetchEntry<T> = {
152 data,
153 status: 200,
154 headers: {},
155 cachedAt,
156 ttl,
157 }
158 await storage.setItem(cacheKey, entry)
159 } catch (error) {
160 // Storage write failed - log but don't fail the request
161 if (import.meta.dev) {
162 // eslint-disable-next-line no-console
163 console.warn(`[fetch-cache] Storage write failed for ${url}:`, error)
164 }
165 }
166 })(),
167 )
168
169 return { data, isStale: false, cachedAt }
170 }
171 }
172
173 // Attach to event context for access in composables via useRequestEvent()
174 nitroApp.hooks.hook('request', event => {
175 event.context.cachedFetch ||= createCachedFetch(event)
176 })
177})
178
179// Extend the H3EventContext type
180declare module 'h3' {
181 interface H3EventContext {
182 cachedFetch?: CachedFetchFunction
183 }
184}