[READ-ONLY] a fast, modern browser for the npm registry
at main 184 lines 6.2 kB view raw
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}