-
-
Notifications
You must be signed in to change notification settings - Fork 424
Expand file tree
/
Copy pathfetch-cache.ts
More file actions
192 lines (172 loc) · 6.39 KB
/
fetch-cache.ts
File metadata and controls
192 lines (172 loc) · 6.39 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
import type { H3Event } from 'h3'
import type { CachedFetchEntry, CachedFetchResult } from '#shared/utils/fetch-cache-config'
import { $fetch } from 'ofetch'
import {
FETCH_CACHE_DEFAULT_TTL,
FETCH_CACHE_STORAGE_BASE,
FETCH_CACHE_VERSION,
isAllowedDomain,
isCacheEntryStale,
} from '#shared/utils/fetch-cache-config'
import { shouldBypassCacheFor } from '../utils/cache-bypass'
/**
* Simple hash function for cache keys.
*/
function simpleHash(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash
}
return Math.abs(hash).toString(36)
}
/**
* Generate a cache key for a fetch request.
*/
function generateFetchCacheKey(url: string | URL, method: string = 'GET', body?: unknown): string {
const urlObj = typeof url === 'string' ? new URL(url) : url
const bodyHash = body ? simpleHash(JSON.stringify(body)) : ''
const searchHash = urlObj.search ? simpleHash(urlObj.search) : ''
const parts = [
FETCH_CACHE_VERSION,
urlObj.host,
method.toUpperCase(),
urlObj.pathname,
searchHash,
bodyHash,
].filter(Boolean)
return parts.join(':')
}
/**
* 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)
/**
* Factory that creates a cachedFetch function bound to a specific request event.
* This allows using event.waitUntil() for background revalidation.
*/
function createCachedFetch(event: H3Event): CachedFetchFunction {
return async <T = unknown>(
url: string,
options: Parameters<typeof $fetch>[1] = {},
ttl: number = FETCH_CACHE_DEFAULT_TTL,
): Promise<CachedFetchResult<T>> => {
// Check if cache bypass is requested for the fetch layer
const bypassCache = shouldBypassCacheFor(event, 'fetch')
// Check if this URL should be cached (or if bypass is requested)
if (bypassCache || !isAllowedDomain(url)) {
if (bypassCache && import.meta.dev) {
// eslint-disable-next-line no-console
console.log(`[fetch-cache] BYPASS: ${url}`)
}
const data = (await $fetch(url, options)) as T
return { data, isStale: false, cachedAt: null }
}
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<T> | null = null
try {
cached = await storage.getItem<CachedFetchEntry<T>>(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)
}
}
if (cached) {
const isStale = isCacheEntryStale(cached)
if (!isStale) {
// Cache hit, data is fresh
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.log(`[fetch-cache] HIT (fresh): ${url}`)
}
return { data: cached.data, isStale: false, cachedAt: cached.cachedAt }
}
// 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}`)
}
// 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<typeof $fetch>[1])) as T
const entry: CachedFetchEntry<T> = {
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 }
}
// Cache miss - fetch and return immediately, cache in background
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.log(`[fetch-cache] MISS: ${url}`)
}
const data = (await $fetch(url, options as Parameters<typeof $fetch>[1])) as T
const cachedAt = Date.now()
// Defer cache write to background via waitUntil for faster response
event.waitUntil(
(async () => {
try {
const entry: CachedFetchEntry<T> = {
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 = createCachedFetch(event)
})
})
// Extend the H3EventContext type
declare module 'h3' {
interface H3EventContext {
cachedFetch?: CachedFetchFunction
}
}