|
| 1 | +import { LRUCache } from 'lru-cache'; |
| 2 | +import { getDataFetchCache } from '../utils'; |
| 3 | + |
| 4 | +import type { |
| 5 | + CacheConfig, |
| 6 | + CacheItem, |
| 7 | + DataFetch, |
| 8 | + DataFetchParams, |
| 9 | +} from '../types'; |
| 10 | + |
| 11 | +export const CacheSize = { |
| 12 | + KB: 1024, |
| 13 | + MB: 1024 * 1024, |
| 14 | + GB: 1024 * 1024 * 1024, |
| 15 | +} as const; |
| 16 | + |
| 17 | +export const CacheTime = { |
| 18 | + SECOND: 1000, |
| 19 | + MINUTE: 60 * 1000, |
| 20 | + HOUR: 60 * 60 * 1000, |
| 21 | + DAY: 24 * 60 * 60 * 1000, |
| 22 | + WEEK: 7 * 24 * 60 * 60 * 1000, |
| 23 | + MONTH: 30 * 24 * 60 * 60 * 1000, |
| 24 | +} as const; |
| 25 | + |
| 26 | +export type CacheStatus = 'hit' | 'stale' | 'miss'; |
| 27 | + |
| 28 | +export interface CacheStatsInfo { |
| 29 | + status: CacheStatus; |
| 30 | + key: string | symbol; |
| 31 | + params: DataFetchParams; |
| 32 | + result: any; |
| 33 | +} |
| 34 | + |
| 35 | +interface CacheOptions { |
| 36 | + tag?: string | string[]; |
| 37 | + maxAge?: number; |
| 38 | + revalidate?: number; |
| 39 | + getKey?: <Args extends any[]>(...args: Args) => string; |
| 40 | + customKey?: <Args extends any[]>(options: { |
| 41 | + params: Args; |
| 42 | + fn: (...args: Args) => any; |
| 43 | + generatedKey: string; |
| 44 | + }) => string | symbol; |
| 45 | + onCache?: (info: CacheStatsInfo) => boolean; |
| 46 | +} |
| 47 | + |
| 48 | +function getTagKeyMap() { |
| 49 | + const dataFetchCache = getDataFetchCache(); |
| 50 | + if (!dataFetchCache || !dataFetchCache.tagKeyMap) { |
| 51 | + const tagKeyMap = new Map<string, Set<string>>(); |
| 52 | + globalThis.__MF_DATA_FETCH_CACHE__ ||= {}; |
| 53 | + globalThis.__MF_DATA_FETCH_CACHE__.tagKeyMap = tagKeyMap; |
| 54 | + return tagKeyMap; |
| 55 | + } |
| 56 | + return dataFetchCache.tagKeyMap; |
| 57 | +} |
| 58 | + |
| 59 | +function addTagKeyRelation(tag: string, key: string) { |
| 60 | + const tagKeyMap = getTagKeyMap(); |
| 61 | + let keys = tagKeyMap.get(tag); |
| 62 | + if (!keys) { |
| 63 | + keys = new Set(); |
| 64 | + tagKeyMap.set(tag, keys); |
| 65 | + } |
| 66 | + keys.add(key); |
| 67 | +} |
| 68 | + |
| 69 | +function getCacheConfig() { |
| 70 | + const dataFetchCache = getDataFetchCache(); |
| 71 | + if (!dataFetchCache || !dataFetchCache.cacheConfig) { |
| 72 | + const cacheConfig: CacheConfig = { |
| 73 | + maxSize: CacheSize.GB, |
| 74 | + }; |
| 75 | + globalThis.__MF_DATA_FETCH_CACHE__ ||= {}; |
| 76 | + globalThis.__MF_DATA_FETCH_CACHE__.cacheConfig = cacheConfig; |
| 77 | + return cacheConfig; |
| 78 | + } |
| 79 | + return dataFetchCache.cacheConfig; |
| 80 | +} |
| 81 | + |
| 82 | +export function configureCache(config: CacheConfig): void { |
| 83 | + const cacheConfig = getCacheConfig(); |
| 84 | + Object.assign(cacheConfig, config); |
| 85 | +} |
| 86 | + |
| 87 | +function getLRUCache() { |
| 88 | + const dataFetchCache = getDataFetchCache(); |
| 89 | + const cacheConfig = getCacheConfig(); |
| 90 | + |
| 91 | + if (!dataFetchCache || !dataFetchCache.cacheStore) { |
| 92 | + const cacheStore = new LRUCache<string, Map<string, CacheItem<any>>>({ |
| 93 | + maxSize: cacheConfig.maxSize ?? CacheSize.GB, |
| 94 | + sizeCalculation: (value: Map<string, CacheItem<any>>): number => { |
| 95 | + if (!value.size) { |
| 96 | + return 1; |
| 97 | + } |
| 98 | + |
| 99 | + let size = 0; |
| 100 | + for (const [k, item] of value.entries()) { |
| 101 | + size += k.length * 2; |
| 102 | + size += estimateObjectSize(item.data); |
| 103 | + size += 8; |
| 104 | + } |
| 105 | + return size; |
| 106 | + }, |
| 107 | + updateAgeOnGet: true, |
| 108 | + updateAgeOnHas: true, |
| 109 | + }); |
| 110 | + globalThis.__MF_DATA_FETCH_CACHE__ ||= {}; |
| 111 | + globalThis.__MF_DATA_FETCH_CACHE__.cacheStore = cacheStore; |
| 112 | + return cacheStore; |
| 113 | + } |
| 114 | + |
| 115 | + return dataFetchCache.cacheStore; |
| 116 | +} |
| 117 | + |
| 118 | +function estimateObjectSize(data: unknown): number { |
| 119 | + const type = typeof data; |
| 120 | + |
| 121 | + if (type === 'number') return 8; |
| 122 | + if (type === 'boolean') return 4; |
| 123 | + if (type === 'string') return Math.max((data as string).length * 2, 1); |
| 124 | + if (data === null || data === undefined) return 1; |
| 125 | + |
| 126 | + if (ArrayBuffer.isView(data)) { |
| 127 | + return Math.max(data.byteLength, 1); |
| 128 | + } |
| 129 | + |
| 130 | + if (Array.isArray(data)) { |
| 131 | + return Math.max( |
| 132 | + data.reduce((acc, item) => acc + estimateObjectSize(item), 0), |
| 133 | + 1, |
| 134 | + ); |
| 135 | + } |
| 136 | + |
| 137 | + if (data instanceof Map || data instanceof Set) { |
| 138 | + return 1024; |
| 139 | + } |
| 140 | + |
| 141 | + if (data instanceof Date) { |
| 142 | + return 8; |
| 143 | + } |
| 144 | + |
| 145 | + if (type === 'object') { |
| 146 | + return Math.max( |
| 147 | + Object.entries(data).reduce( |
| 148 | + (acc, [key, value]) => acc + key.length * 2 + estimateObjectSize(value), |
| 149 | + 0, |
| 150 | + ), |
| 151 | + 1, |
| 152 | + ); |
| 153 | + } |
| 154 | + |
| 155 | + return 1; |
| 156 | +} |
| 157 | + |
| 158 | +export function generateKey(dataFetchOptions: DataFetchParams): string { |
| 159 | + return JSON.stringify(dataFetchOptions, (_, value) => { |
| 160 | + if (value && typeof value === 'object' && !Array.isArray(value)) { |
| 161 | + return Object.keys(value) |
| 162 | + .sort() |
| 163 | + .reduce((result: Record<string, unknown>, key) => { |
| 164 | + result[key] = value[key]; |
| 165 | + return result; |
| 166 | + }, {}); |
| 167 | + } |
| 168 | + return value; |
| 169 | + }); |
| 170 | +} |
| 171 | + |
| 172 | +export function cache<T>( |
| 173 | + fn: DataFetch<T>, |
| 174 | + options?: CacheOptions, |
| 175 | +): DataFetch<T> { |
| 176 | + const { |
| 177 | + tag = 'default', |
| 178 | + maxAge = CacheTime.MINUTE * 5, |
| 179 | + revalidate = 0, |
| 180 | + onCache, |
| 181 | + getKey, |
| 182 | + } = options || {}; |
| 183 | + |
| 184 | + const tags = Array.isArray(tag) ? tag : [tag]; |
| 185 | + |
| 186 | + return async (dataFetchOptions) => { |
| 187 | + // if downgrade, skip cache |
| 188 | + if (dataFetchOptions.isDowngrade || !dataFetchOptions._id) { |
| 189 | + return fn(dataFetchOptions); |
| 190 | + } |
| 191 | + const store = getLRUCache(); |
| 192 | + |
| 193 | + const now = Date.now(); |
| 194 | + const storeKey = dataFetchOptions._id; |
| 195 | + const cacheKey = getKey |
| 196 | + ? getKey(dataFetchOptions) |
| 197 | + : generateKey(dataFetchOptions); |
| 198 | + |
| 199 | + tags.forEach((t) => addTagKeyRelation(t, cacheKey)); |
| 200 | + |
| 201 | + let cacheStore = store.get(cacheKey); |
| 202 | + if (!cacheStore) { |
| 203 | + cacheStore = new Map(); |
| 204 | + } |
| 205 | + |
| 206 | + const cached = cacheStore.get(storeKey); |
| 207 | + if (cached) { |
| 208 | + const age = now - cached.timestamp; |
| 209 | + |
| 210 | + if (age < maxAge) { |
| 211 | + if (onCache) { |
| 212 | + const useCache = onCache({ |
| 213 | + status: 'hit', |
| 214 | + key: cacheKey, |
| 215 | + params: dataFetchOptions, |
| 216 | + result: cached.data, |
| 217 | + }); |
| 218 | + if (!useCache) { |
| 219 | + return fn(dataFetchOptions); |
| 220 | + } |
| 221 | + } |
| 222 | + return cached.data; |
| 223 | + } |
| 224 | + |
| 225 | + if (revalidate > 0 && age < maxAge + revalidate) { |
| 226 | + if (onCache) { |
| 227 | + onCache({ |
| 228 | + status: 'stale', |
| 229 | + key: cacheKey, |
| 230 | + params: dataFetchOptions, |
| 231 | + result: cached.data, |
| 232 | + }); |
| 233 | + } |
| 234 | + |
| 235 | + if (!cached.isRevalidating) { |
| 236 | + cached.isRevalidating = true; |
| 237 | + Promise.resolve().then(async () => { |
| 238 | + try { |
| 239 | + const newData = await fn(dataFetchOptions); |
| 240 | + cacheStore!.set(storeKey, { |
| 241 | + data: newData, |
| 242 | + timestamp: Date.now(), |
| 243 | + isRevalidating: false, |
| 244 | + }); |
| 245 | + |
| 246 | + store.set(cacheKey, cacheStore!); |
| 247 | + } catch (error) { |
| 248 | + cached.isRevalidating = false; |
| 249 | + console.error('Background revalidation failed:', error); |
| 250 | + } |
| 251 | + }); |
| 252 | + } |
| 253 | + return cached.data; |
| 254 | + } |
| 255 | + } |
| 256 | + |
| 257 | + const data = await fn(dataFetchOptions); |
| 258 | + cacheStore.set(storeKey, { |
| 259 | + data, |
| 260 | + timestamp: now, |
| 261 | + isRevalidating: false, |
| 262 | + }); |
| 263 | + store.set(cacheKey, cacheStore); |
| 264 | + |
| 265 | + if (onCache) { |
| 266 | + onCache({ |
| 267 | + status: 'miss', |
| 268 | + key: cacheKey, |
| 269 | + params: dataFetchOptions, |
| 270 | + result: data, |
| 271 | + }); |
| 272 | + } |
| 273 | + |
| 274 | + return data; |
| 275 | + }; |
| 276 | +} |
| 277 | + |
| 278 | +export function revalidateTag(tag: string): void { |
| 279 | + const tagKeyMap = getTagKeyMap(); |
| 280 | + const keys = tagKeyMap.get(tag); |
| 281 | + const lruCache = getLRUCache(); |
| 282 | + if (keys) { |
| 283 | + keys.forEach((key) => { |
| 284 | + lruCache?.delete(key); |
| 285 | + }); |
| 286 | + } |
| 287 | +} |
| 288 | + |
| 289 | +export function clearStore(): void { |
| 290 | + const lruCache = getLRUCache(); |
| 291 | + const tagKeyMap = getTagKeyMap(); |
| 292 | + |
| 293 | + lruCache?.clear(); |
| 294 | + delete globalThis.__MF_DATA_FETCH_CACHE__?.cacheStore; |
| 295 | + tagKeyMap.clear(); |
| 296 | +} |
0 commit comments