TypeScript · 8497 bytes Raw Blame History
1 import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2 import { createHash } from 'crypto';
3
4 interface CacheEntry {
5 data: any;
6 headers: Record<string, string>;
7 statusCode: number;
8 timestamp: number;
9 ttl: number;
10 size: number;
11 }
12
13 class MemoryCache {
14 private cache = new Map<string, CacheEntry>();
15 private maxSize: number;
16 private currentSize = 0;
17 private maxEntrySize: number;
18 private defaultTTL: number;
19
20 constructor(options: {
21 maxSize?: number;
22 maxEntrySize?: number;
23 defaultTTL?: number;
24 } = {}) {
25 this.maxSize = options.maxSize || 100 * 1024 * 1024; // 100MB
26 this.maxEntrySize = options.maxEntrySize || 10 * 1024 * 1024; // 10MB per entry
27 this.defaultTTL = options.defaultTTL || 5 * 60 * 1000; // 5 minutes
28 }
29
30 set(key: string, data: any, headers: Record<string, string>, statusCode: number, ttl?: number): boolean {
31 const size = this.estimateSize(data);
32
33 // Don't cache large entries
34 if (size > this.maxEntrySize) {
35 return false;
36 }
37
38 // Evict entries if needed
39 this.evictIfNeeded(size);
40
41 const entry: CacheEntry = {
42 data,
43 headers,
44 statusCode,
45 timestamp: Date.now(),
46 ttl: ttl || this.defaultTTL,
47 size,
48 };
49
50 this.cache.set(key, entry);
51 this.currentSize += size;
52
53 return true;
54 }
55
56 get(key: string): CacheEntry | null {
57 const entry = this.cache.get(key);
58
59 if (!entry) {
60 return null;
61 }
62
63 // Check if expired
64 if (Date.now() - entry.timestamp > entry.ttl) {
65 this.delete(key);
66 return null;
67 }
68
69 return entry;
70 }
71
72 delete(key: string): boolean {
73 const entry = this.cache.get(key);
74 if (entry) {
75 this.cache.delete(key);
76 this.currentSize -= entry.size;
77 return true;
78 }
79 return false;
80 }
81
82 clear(): void {
83 this.cache.clear();
84 this.currentSize = 0;
85 }
86
87 getStats() {
88 return {
89 entries: this.cache.size,
90 size: this.currentSize,
91 maxSize: this.maxSize,
92 hitRate: this.calculateHitRate(),
93 };
94 }
95
96 private evictIfNeeded(newEntrySize: number): void {
97 // Use LRU eviction
98 while (this.currentSize + newEntrySize > this.maxSize && this.cache.size > 0) {
99 const oldestKey = this.cache.keys().next().value;
100 if (oldestKey) {
101 this.delete(oldestKey);
102 }
103 }
104 }
105
106 private estimateSize(data: any): number {
107 try {
108 return JSON.stringify(data).length * 2; // Rough estimate (UTF-16)
109 } catch {
110 return 1024; // Default size if can't stringify
111 }
112 }
113
114 private hitRate = 0;
115 private hits = 0;
116 private misses = 0;
117
118 recordHit(): void {
119 this.hits++;
120 this.updateHitRate();
121 }
122
123 recordMiss(): void {
124 this.misses++;
125 this.updateHitRate();
126 }
127
128 private updateHitRate(): void {
129 const total = this.hits + this.misses;
130 this.hitRate = total > 0 ? (this.hits / total) * 100 : 0;
131 }
132
133 private calculateHitRate(): number {
134 return this.hitRate;
135 }
136 }
137
138 const cache = new MemoryCache();
139
140 export async function cacheMiddleware(fastify: FastifyInstance) {
141 // Cache configuration
142 const cacheConfig = {
143 // Routes that should be cached
144 cacheable: [
145 '/api/files',
146 '/api/status/network',
147 '/api/status/node',
148 ],
149 // Routes that should never be cached
150 nocache: [
151 '/api/auth/',
152 '/api/files/upload',
153 '/api/files/bulk/',
154 ],
155 // Default TTL for different route patterns
156 ttl: {
157 '/api/files': 30 * 1000, // 30 seconds
158 '/api/status/': 10 * 1000, // 10 seconds
159 },
160 };
161
162 // Pre-handler to check cache
163 fastify.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => {
164 // Only cache GET requests
165 if (request.method !== 'GET') {
166 return;
167 }
168
169 // Check if route should be cached
170 if (!shouldCacheRoute(request.url, cacheConfig)) {
171 return;
172 }
173
174 const cacheKey = generateCacheKey(request);
175 const cached = cache.get(cacheKey);
176
177 if (cached) {
178 cache.recordHit();
179
180 // Set cached headers
181 Object.entries(cached.headers).forEach(([key, value]) => {
182 reply.header(key, value);
183 });
184
185 // Add cache headers
186 reply.header('X-Cache', 'HIT');
187 reply.header('X-Cache-Time', new Date(cached.timestamp).toISOString());
188
189 reply.code(cached.statusCode);
190 return reply.send(cached.data);
191 } else {
192 cache.recordMiss();
193 reply.header('X-Cache', 'MISS');
194 }
195 });
196
197 // Post-handler to cache responses
198 fastify.addHook('onSend', async (request: FastifyRequest, reply: FastifyReply, payload: any) => {
199 // Only cache successful GET requests
200 if (request.method !== 'GET' || reply.statusCode >= 400) {
201 return payload;
202 }
203
204 // Check if route should be cached
205 if (!shouldCacheRoute(request.url, cacheConfig)) {
206 return payload;
207 }
208
209 // Don't cache if already cached
210 if (reply.getHeader('X-Cache') === 'HIT') {
211 return payload;
212 }
213
214 const cacheKey = generateCacheKey(request);
215 const ttl = getTTLForRoute(request.url, cacheConfig);
216
217 // Get response headers (excluding some)
218 const headers: Record<string, string> = {};
219 const excludeHeaders = ['set-cookie', 'authorization', 'x-cache'];
220
221 Object.entries(reply.getHeaders()).forEach(([key, value]) => {
222 if (!excludeHeaders.includes(key.toLowerCase()) && typeof value === 'string') {
223 headers[key] = value;
224 }
225 });
226
227 // Cache the response
228 cache.set(cacheKey, payload, headers, reply.statusCode, ttl);
229
230 return payload;
231 });
232
233 // Cache management endpoints
234 fastify.get('/api/cache/stats', async () => {
235 return cache.getStats();
236 });
237
238 fastify.delete('/api/cache/clear', {
239 preHandler: fastify.authenticate,
240 }, async () => {
241 cache.clear();
242 return { success: true, message: 'Cache cleared' };
243 });
244
245 fastify.delete('/api/cache/invalidate', {
246 preHandler: fastify.authenticate,
247 schema: {
248 querystring: {
249 type: 'object',
250 properties: {
251 pattern: { type: 'string' },
252 },
253 },
254 },
255 }, async (request: FastifyRequest) => {
256 const { pattern } = request.query as { pattern?: string };
257
258 if (!pattern) {
259 return { success: false, message: 'Pattern is required' };
260 }
261
262 let invalidated = 0;
263 const regex = new RegExp(pattern);
264
265 for (const key of cache['cache'].keys()) {
266 if (regex.test(key)) {
267 cache.delete(key);
268 invalidated++;
269 }
270 }
271
272 return {
273 success: true,
274 message: `Invalidated ${invalidated} cache entries`,
275 invalidated,
276 };
277 });
278
279 // Periodic cache cleanup
280 setInterval(() => {
281 const stats = cache.getStats();
282 fastify.log.debug({
283 cache: {
284 entries: stats.entries,
285 sizeMB: Math.round(stats.size / 1024 / 1024),
286 hitRate: Math.round(stats.hitRate * 100) / 100,
287 },
288 }, 'Cache statistics');
289
290 // Force cleanup if cache is getting full
291 if (stats.size > stats.maxSize * 0.9) {
292 // Remove oldest 10% of entries
293 const keysToRemove = Math.floor(stats.entries * 0.1);
294 let removed = 0;
295
296 for (const key of cache['cache'].keys()) {
297 if (removed >= keysToRemove) break;
298 cache.delete(key);
299 removed++;
300 }
301
302 fastify.log.info({ removedEntries: removed }, 'Cache cleanup performed');
303 }
304 }, 60 * 1000); // Check every minute
305 }
306
307 function shouldCacheRoute(url: string, config: any): boolean {
308 // Check nocache patterns first
309 for (const pattern of config.nocache) {
310 if (url.startsWith(pattern)) {
311 return false;
312 }
313 }
314
315 // Check cacheable patterns
316 for (const pattern of config.cacheable) {
317 if (url.startsWith(pattern)) {
318 return true;
319 }
320 }
321
322 return false;
323 }
324
325 function generateCacheKey(request: FastifyRequest): string {
326 const url = request.url;
327 const method = request.method;
328 const headers = request.headers;
329
330 // Include relevant headers in cache key
331 const relevantHeaders = ['accept', 'accept-encoding'];
332 const headerKey = relevantHeaders
333 .map(h => `${h}:${headers[h] || ''}`)
334 .join('|');
335
336 const keyString = `${method}:${url}:${headerKey}`;
337
338 return createHash('md5').update(keyString).digest('hex');
339 }
340
341 function getTTLForRoute(url: string, config: any): number {
342 for (const [pattern, ttl] of Object.entries(config.ttl)) {
343 if (url.startsWith(pattern)) {
344 return ttl as number;
345 }
346 }
347
348 return 5 * 60 * 1000; // Default 5 minutes
349 }