Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1// In-memory LRU cache for file contents and metadata
2
3interface CacheEntry<T> {
4 value: T;
5 size: number;
6 timestamp: number;
7}
8
9interface CacheStats {
10 hits: number;
11 misses: number;
12 evictions: number;
13 currentSize: number;
14 currentCount: number;
15}
16
17export class LRUCache<T> {
18 private cache: Map<string, CacheEntry<T>>;
19 private maxSize: number;
20 private maxCount: number;
21 private currentSize: number;
22 private stats: CacheStats;
23
24 constructor(maxSize: number, maxCount: number) {
25 this.cache = new Map();
26 this.maxSize = maxSize;
27 this.maxCount = maxCount;
28 this.currentSize = 0;
29 this.stats = {
30 hits: 0,
31 misses: 0,
32 evictions: 0,
33 currentSize: 0,
34 currentCount: 0,
35 };
36 }
37
38 get(key: string): T | null {
39 const entry = this.cache.get(key);
40 if (!entry) {
41 this.stats.misses++;
42 return null;
43 }
44
45 // Move to end (most recently used)
46 this.cache.delete(key);
47 this.cache.set(key, entry);
48
49 this.stats.hits++;
50 return entry.value;
51 }
52
53 set(key: string, value: T, size: number): void {
54 // Remove existing entry if present
55 if (this.cache.has(key)) {
56 const existing = this.cache.get(key)!;
57 this.currentSize -= existing.size;
58 this.cache.delete(key);
59 }
60
61 // Evict entries if needed
62 while (
63 (this.cache.size >= this.maxCount || this.currentSize + size > this.maxSize) &&
64 this.cache.size > 0
65 ) {
66 const firstKey = this.cache.keys().next().value;
67 if (!firstKey) break; // Should never happen, but satisfy TypeScript
68 const firstEntry = this.cache.get(firstKey);
69 if (!firstEntry) break; // Should never happen, but satisfy TypeScript
70 this.cache.delete(firstKey);
71 this.currentSize -= firstEntry.size;
72 this.stats.evictions++;
73 }
74
75 // Add new entry
76 this.cache.set(key, {
77 value,
78 size,
79 timestamp: Date.now(),
80 });
81 this.currentSize += size;
82
83 // Update stats
84 this.stats.currentSize = this.currentSize;
85 this.stats.currentCount = this.cache.size;
86 }
87
88 delete(key: string): boolean {
89 const entry = this.cache.get(key);
90 if (!entry) return false;
91
92 this.cache.delete(key);
93 this.currentSize -= entry.size;
94 this.stats.currentSize = this.currentSize;
95 this.stats.currentCount = this.cache.size;
96 return true;
97 }
98
99 // Invalidate all entries for a specific site
100 invalidateSite(did: string, rkey: string): number {
101 const prefix = `${did}:${rkey}:`;
102 let count = 0;
103
104 for (const key of Array.from(this.cache.keys())) {
105 if (key.startsWith(prefix)) {
106 this.delete(key);
107 count++;
108 }
109 }
110
111 return count;
112 }
113
114 // Get cache size
115 size(): number {
116 return this.cache.size;
117 }
118
119 clear(): void {
120 this.cache.clear();
121 this.currentSize = 0;
122 this.stats.currentSize = 0;
123 this.stats.currentCount = 0;
124 }
125
126 getStats(): CacheStats {
127 return { ...this.stats };
128 }
129
130 // Get cache hit rate
131 getHitRate(): number {
132 const total = this.stats.hits + this.stats.misses;
133 return total === 0 ? 0 : (this.stats.hits / total) * 100;
134 }
135}
136
137// File metadata cache entry
138export interface FileMetadata {
139 encoding?: 'gzip';
140 mimeType: string;
141}
142
143// Global cache instances
144const FILE_CACHE_SIZE = 100 * 1024 * 1024; // 100MB
145const FILE_CACHE_COUNT = 500;
146const METADATA_CACHE_COUNT = 2000;
147
148export const fileCache = new LRUCache<Buffer>(FILE_CACHE_SIZE, FILE_CACHE_COUNT);
149export const metadataCache = new LRUCache<FileMetadata>(1024 * 1024, METADATA_CACHE_COUNT); // 1MB for metadata
150export const rewrittenHtmlCache = new LRUCache<Buffer>(50 * 1024 * 1024, 200); // 50MB for rewritten HTML
151
152// Helper to generate cache keys
153export function getCacheKey(did: string, rkey: string, filePath: string, suffix?: string): string {
154 const base = `${did}:${rkey}:${filePath}`;
155 return suffix ? `${base}:${suffix}` : base;
156}
157
158// Invalidate all caches for a site
159export function invalidateSiteCache(did: string, rkey: string): void {
160 const fileCount = fileCache.invalidateSite(did, rkey);
161 const metaCount = metadataCache.invalidateSite(did, rkey);
162 const htmlCount = rewrittenHtmlCache.invalidateSite(did, rkey);
163
164 console.log(`[Cache] Invalidated site ${did}:${rkey} - ${fileCount} files, ${metaCount} metadata, ${htmlCount} HTML`);
165}
166
167// Track sites currently being cached (to prevent serving stale cache during updates)
168const sitesBeingCached = new Set<string>();
169
170export function markSiteAsBeingCached(did: string, rkey: string): void {
171 const key = `${did}:${rkey}`;
172 sitesBeingCached.add(key);
173}
174
175export function unmarkSiteAsBeingCached(did: string, rkey: string): void {
176 const key = `${did}:${rkey}`;
177 sitesBeingCached.delete(key);
178}
179
180export function isSiteBeingCached(did: string, rkey: string): boolean {
181 const key = `${did}:${rkey}`;
182 return sitesBeingCached.has(key);
183}
184
185// Get overall cache statistics
186export function getCacheStats() {
187 return {
188 files: fileCache.getStats(),
189 fileHitRate: fileCache.getHitRate(),
190 metadata: metadataCache.getStats(),
191 metadataHitRate: metadataCache.getHitRate(),
192 rewrittenHtml: rewrittenHtmlCache.getStats(),
193 rewrittenHtmlHitRate: rewrittenHtmlCache.getHitRate(),
194 sitesBeingCached: sitesBeingCached.size,
195 };
196}