Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

caching consolidation, fix tiered storage

+270 -315
+6 -5
apps/hosting-service/src/index.ts
··· 2 2 import { serve } from '@hono/node-server'; 3 3 import { initializeGrafanaExporters, createLogger } from '@wispplace/observability'; 4 4 import { mkdirSync, existsSync } from 'fs'; 5 - import { startDomainCacheCleanup, stopDomainCacheCleanup, closeDatabase, CACHE_ONLY } from './lib/db'; 5 + import { closeDatabase, CACHE_ONLY } from './lib/db'; 6 + import { cache } from './lib/cache-manager'; 6 7 import { closeRevalidateQueue } from './lib/revalidate-queue'; 7 8 import { startCacheInvalidationSubscriber, stopCacheInvalidationSubscriber } from './lib/cache-invalidation'; 8 9 import { storage, getStorageConfig } from './lib/storage'; ··· 24 25 logger.info('Created cache directory', { CACHE_DIR }); 25 26 } 26 27 27 - // Start domain cache cleanup 28 - startDomainCacheCleanup(); 28 + // Start in-memory cache cleanup 29 + cache.startCleanup(); 29 30 30 31 // Start cache invalidation subscriber (listens for firehose-service updates via Redis pub/sub) 31 32 startCacheInvalidationSubscriber(); ··· 87 88 // Graceful shutdown 88 89 process.on('SIGINT', async () => { 89 90 logger.info('Shutting down...'); 90 - stopDomainCacheCleanup(); 91 + cache.stopCleanup(); 91 92 await stopCacheInvalidationSubscriber(); 92 93 await closeRevalidateQueue(); 93 94 await closeDatabase(); ··· 97 98 98 99 process.on('SIGTERM', async () => { 99 100 logger.info('Shutting down...'); 100 - stopDomainCacheCleanup(); 101 + cache.stopCleanup(); 101 102 await stopCacheInvalidationSubscriber(); 102 103 await closeRevalidateQueue(); 103 104 await closeDatabase();
+4 -3
apps/hosting-service/src/lib/cache-invalidation.ts
··· 8 8 9 9 import Redis from 'ioredis'; 10 10 import { storage } from './storage'; 11 - import { clearRedirectRulesCache } from './site-cache'; 11 + import { cache } from './cache-manager'; 12 12 13 13 const CHANNEL = 'wisp:cache-invalidate'; 14 14 ··· 63 63 const deleted = await storage.invalidate(prefix); 64 64 console.log(`[CacheInvalidation] Cleared ${deleted} keys from tiered storage for ${did}/${rkey}`); 65 65 66 - // Clear redirect rules cache 67 - clearRedirectRulesCache(did, rkey); 66 + // Clear in-memory caches for this site 67 + cache.delete('redirectRules', `${did}:${rkey}`); 68 + cache.delete('settings', `${did}:${rkey}`); 68 69 } catch (err) { 69 70 console.error('[CacheInvalidation] Error processing message:', err); 70 71 }
+208
apps/hosting-service/src/lib/cache-manager.ts
··· 1 + /** 2 + * Centralized in-memory cache manager for the hosting service. 3 + * 4 + * Replaces the scattered TTL Maps (domains, customDomains, settings, handles) 5 + * and the LRU redirect-rules cache with a single, namespace-aware cache. 6 + */ 7 + 8 + interface NamespaceConfig { 9 + /** Time-to-live in milliseconds. Entries older than this are stale. */ 10 + ttl?: number; 11 + /** Maximum number of entries before LRU eviction kicks in. */ 12 + maxEntries?: number; 13 + /** Maximum total estimated size (bytes) before LRU eviction kicks in. */ 14 + maxSize?: number; 15 + /** Estimate the byte size of a value. Required when maxSize is set. */ 16 + estimateSize?: (value: unknown) => number; 17 + } 18 + 19 + interface CacheEntry { 20 + value: unknown; 21 + timestamp: number; 22 + size: number; 23 + } 24 + 25 + interface NamespaceStats { 26 + hits: number; 27 + misses: number; 28 + evictions: number; 29 + entries: number; 30 + sizeBytes: number; 31 + } 32 + 33 + interface GetOrFetchOpts<T> { 34 + /** Skip caching when predicate returns false (e.g. don't cache null). */ 35 + cacheIf?: (value: T) => boolean; 36 + } 37 + 38 + export class CacheManager<NS extends string = string> { 39 + private namespaces: Map<NS, Map<string, CacheEntry>> = new Map(); 40 + private configs: Map<NS, NamespaceConfig> = new Map(); 41 + private stats: Map<NS, NamespaceStats> = new Map(); 42 + private cleanupTimer: ReturnType<typeof setInterval> | null = null; 43 + 44 + constructor(config: Record<NS, NamespaceConfig>) { 45 + for (const [ns, cfg] of Object.entries(config) as [NS, NamespaceConfig][]) { 46 + this.namespaces.set(ns, new Map()); 47 + this.configs.set(ns, cfg); 48 + this.stats.set(ns, { hits: 0, misses: 0, evictions: 0, entries: 0, sizeBytes: 0 }); 49 + } 50 + } 51 + 52 + // ── Primary API ────────────────────────────────────────────────────── 53 + 54 + async getOrFetch<T>( 55 + ns: NS, 56 + key: string, 57 + fetcher: () => T | Promise<T>, 58 + opts?: GetOrFetchOpts<T>, 59 + ): Promise<T> { 60 + const existing = this.get<T>(ns, key); 61 + if (existing !== undefined) return existing; 62 + 63 + const value = await fetcher(); 64 + 65 + if (!opts?.cacheIf || opts.cacheIf(value)) { 66 + this.set(ns, key, value); 67 + } 68 + 69 + return value; 70 + } 71 + 72 + get<T>(ns: NS, key: string): T | undefined { 73 + const map = this.namespaces.get(ns); 74 + const cfg = this.configs.get(ns); 75 + const st = this.stats.get(ns); 76 + if (!map || !cfg || !st) return undefined; 77 + 78 + const entry = map.get(key); 79 + if (!entry) { 80 + st.misses++; 81 + return undefined; 82 + } 83 + 84 + // TTL check 85 + if (cfg.ttl && Date.now() - entry.timestamp > cfg.ttl) { 86 + map.delete(key); 87 + st.entries = map.size; 88 + st.sizeBytes -= entry.size; 89 + st.misses++; 90 + return undefined; 91 + } 92 + 93 + // Touch for LRU: delete + re-insert moves to end of Map iteration order 94 + map.delete(key); 95 + map.set(key, entry); 96 + 97 + st.hits++; 98 + return entry.value as T; 99 + } 100 + 101 + set(ns: NS, key: string, value: unknown): void { 102 + const map = this.namespaces.get(ns); 103 + const cfg = this.configs.get(ns); 104 + const st = this.stats.get(ns); 105 + if (!map || !cfg || !st) return; 106 + 107 + const size = cfg.estimateSize ? cfg.estimateSize(value) : 0; 108 + 109 + // Remove existing entry first 110 + const existing = map.get(key); 111 + if (existing) { 112 + st.sizeBytes -= existing.size; 113 + map.delete(key); 114 + } 115 + 116 + // LRU eviction 117 + while (map.size > 0) { 118 + const overCount = cfg.maxEntries !== undefined && map.size >= cfg.maxEntries; 119 + const overSize = cfg.maxSize !== undefined && st.sizeBytes + size > cfg.maxSize; 120 + if (!overCount && !overSize) break; 121 + 122 + const oldest = map.keys().next().value; 123 + if (oldest === undefined) break; 124 + const evicted = map.get(oldest)!; 125 + map.delete(oldest); 126 + st.sizeBytes -= evicted.size; 127 + st.evictions++; 128 + } 129 + 130 + map.set(key, { value, timestamp: Date.now(), size }); 131 + st.sizeBytes += size; 132 + st.entries = map.size; 133 + } 134 + 135 + delete(ns: NS, key: string): void { 136 + const map = this.namespaces.get(ns); 137 + const st = this.stats.get(ns); 138 + if (!map || !st) return; 139 + 140 + const entry = map.get(key); 141 + if (entry) { 142 + map.delete(key); 143 + st.sizeBytes -= entry.size; 144 + st.entries = map.size; 145 + } 146 + } 147 + 148 + clear(ns: NS): void { 149 + const map = this.namespaces.get(ns); 150 + const st = this.stats.get(ns); 151 + if (!map || !st) return; 152 + 153 + map.clear(); 154 + st.entries = 0; 155 + st.sizeBytes = 0; 156 + } 157 + 158 + // ── Stats ──────────────────────────────────────────────────────────── 159 + 160 + getStats(): Record<NS, NamespaceStats> { 161 + const out = {} as Record<NS, NamespaceStats>; 162 + for (const [ns, st] of this.stats) { 163 + out[ns] = { ...st }; 164 + } 165 + return out; 166 + } 167 + 168 + // ── Periodic cleanup ───────────────────────────────────────────────── 169 + 170 + startCleanup(intervalMs = 30 * 60_000): void { 171 + if (this.cleanupTimer) return; 172 + 173 + this.cleanupTimer = setInterval(() => { 174 + const now = Date.now(); 175 + for (const [ns, cfg] of this.configs) { 176 + if (!cfg.ttl) continue; 177 + const map = this.namespaces.get(ns)!; 178 + const st = this.stats.get(ns)!; 179 + for (const [key, entry] of map) { 180 + if (now - entry.timestamp > cfg.ttl) { 181 + map.delete(key); 182 + st.sizeBytes -= entry.size; 183 + } 184 + } 185 + st.entries = map.size; 186 + } 187 + }, intervalMs); 188 + } 189 + 190 + stopCleanup(): void { 191 + if (this.cleanupTimer) { 192 + clearInterval(this.cleanupTimer); 193 + this.cleanupTimer = null; 194 + } 195 + } 196 + } 197 + 198 + // ── Singleton ──────────────────────────────────────────────────────────── 199 + 200 + type CacheNamespace = 'domains' | 'customDomains' | 'settings' | 'handles' | 'redirectRules'; 201 + 202 + export const cache = new CacheManager<CacheNamespace>({ 203 + domains: { ttl: 5 * 60_000, maxEntries: 5000 }, 204 + customDomains: { ttl: 5 * 60_000, maxEntries: 5000 }, 205 + settings: { ttl: 5 * 60_000, maxEntries: 1000 }, 206 + handles: { ttl: 10 * 60_000, maxEntries: 5000 }, 207 + redirectRules: { maxEntries: 1000, maxSize: 10 * 1024 * 1024, estimateSize: (v) => (v as unknown[]).length * 100 }, 208 + });
+4 -120
apps/hosting-service/src/lib/cache.ts
··· 1 1 /** 2 - * Cache management for wisp-hosting-service 2 + * Cache statistics for wisp-hosting-service 3 3 * 4 4 * With tiered storage, most caching is handled transparently. 5 - * This module provides a generic LRU cache and exposes storage stats. 5 + * In-memory caches are managed by the centralized CacheManager. 6 6 */ 7 7 8 8 import { storage } from './storage'; 9 - 10 - // In-memory LRU cache for rewritten HTML (for path rewriting in subdomain routes) 11 - interface CacheEntry<T> { 12 - value: T; 13 - size: number; 14 - timestamp: number; 15 - } 16 - 17 - interface CacheStats { 18 - hits: number; 19 - misses: number; 20 - evictions: number; 21 - currentSize: number; 22 - currentCount: number; 23 - } 24 - 25 - export class LRUCache<T> { 26 - private cache: Map<string, CacheEntry<T>>; 27 - private maxSize: number; 28 - private maxCount: number; 29 - private currentSize: number; 30 - private stats: CacheStats; 31 - 32 - constructor(maxSize: number, maxCount: number) { 33 - this.cache = new Map(); 34 - this.maxSize = maxSize; 35 - this.maxCount = maxCount; 36 - this.currentSize = 0; 37 - this.stats = { 38 - hits: 0, 39 - misses: 0, 40 - evictions: 0, 41 - currentSize: 0, 42 - currentCount: 0, 43 - }; 44 - } 45 - 46 - get(key: string): T | null { 47 - const entry = this.cache.get(key); 48 - if (!entry) { 49 - this.stats.misses++; 50 - return null; 51 - } 52 - 53 - // Move to end (most recently used) 54 - this.cache.delete(key); 55 - this.cache.set(key, entry); 56 - 57 - this.stats.hits++; 58 - return entry.value; 59 - } 60 - 61 - set(key: string, value: T, size: number): void { 62 - // Remove existing entry if present 63 - if (this.cache.has(key)) { 64 - const existing = this.cache.get(key)!; 65 - this.currentSize -= existing.size; 66 - this.cache.delete(key); 67 - } 68 - 69 - // Evict entries if needed 70 - while ( 71 - (this.cache.size >= this.maxCount || this.currentSize + size > this.maxSize) && 72 - this.cache.size > 0 73 - ) { 74 - const firstKey = this.cache.keys().next().value; 75 - if (!firstKey) break; // Should never happen, but satisfy TypeScript 76 - const firstEntry = this.cache.get(firstKey); 77 - if (!firstEntry) break; // Should never happen, but satisfy TypeScript 78 - this.cache.delete(firstKey); 79 - this.currentSize -= firstEntry.size; 80 - this.stats.evictions++; 81 - } 82 - 83 - // Add new entry 84 - this.cache.set(key, { 85 - value, 86 - size, 87 - timestamp: Date.now(), 88 - }); 89 - this.currentSize += size; 90 - 91 - // Update stats 92 - this.stats.currentSize = this.currentSize; 93 - this.stats.currentCount = this.cache.size; 94 - } 95 - 96 - delete(key: string): boolean { 97 - const entry = this.cache.get(key); 98 - if (!entry) return false; 99 - 100 - this.cache.delete(key); 101 - this.currentSize -= entry.size; 102 - this.stats.currentSize = this.currentSize; 103 - this.stats.currentCount = this.cache.size; 104 - return true; 105 - } 106 - 107 - size(): number { 108 - return this.cache.size; 109 - } 110 - 111 - clear(): void { 112 - this.cache.clear(); 113 - this.currentSize = 0; 114 - this.stats.currentSize = 0; 115 - this.stats.currentCount = 0; 116 - } 117 - 118 - getStats(): CacheStats { 119 - return { ...this.stats }; 120 - } 121 - 122 - getHitRate(): number { 123 - const total = this.stats.hits + this.stats.misses; 124 - return total === 0 ? 0 : (this.stats.hits / total) * 100; 125 - } 126 - } 9 + import { cache } from './cache-manager'; 127 10 128 11 // Get overall cache statistics 129 12 export async function getCacheStats() { ··· 131 14 132 15 return { 133 16 tieredStorage: tieredStats, 17 + inMemory: cache.getStats(), 134 18 }; 135 19 }
+30 -120
apps/hosting-service/src/lib/db.ts
··· 1 1 import postgres from 'postgres'; 2 2 import { createHash } from 'crypto'; 3 3 import type { DomainLookup, CustomDomainLookup, SiteCache, SiteSettingsCache } from '@wispplace/database'; 4 + import { cache } from './cache-manager'; 4 5 5 6 const sql = postgres( 6 7 process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp', ··· 13 14 // Cache-only mode: skip all DB writes and only use tiered storage 14 15 export const CACHE_ONLY = process.env.CACHE_ONLY === 'true'; 15 16 16 - // Domain lookup cache with TTL 17 - const DOMAIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 18 - 19 - interface CachedDomain<T> { 20 - value: T; 21 - timestamp: number; 22 - } 23 - 24 - const domainCache = new Map<string, CachedDomain<DomainLookup | null>>(); 25 - const customDomainCache = new Map<string, CachedDomain<CustomDomainLookup | null>>(); 26 - 27 - let cleanupInterval: NodeJS.Timeout | null = null; 28 - 29 - export function startDomainCacheCleanup() { 30 - if (cleanupInterval) return; 31 - 32 - cleanupInterval = setInterval(() => { 33 - const now = Date.now(); 34 - 35 - for (const [key, entry] of domainCache.entries()) { 36 - if (now - entry.timestamp > DOMAIN_CACHE_TTL) { 37 - domainCache.delete(key); 38 - } 39 - } 40 - 41 - for (const [key, entry] of customDomainCache.entries()) { 42 - if (now - entry.timestamp > DOMAIN_CACHE_TTL) { 43 - customDomainCache.delete(key); 44 - } 45 - } 46 - 47 - for (const [key, entry] of settingsCache.entries()) { 48 - if (now - entry.timestamp > SETTINGS_CACHE_TTL) { 49 - settingsCache.delete(key); 50 - } 51 - } 52 - }, 30 * 60 * 1000); // Run every 30 minutes 53 - } 54 - 55 - export function stopDomainCacheCleanup() { 56 - if (cleanupInterval) { 57 - clearInterval(cleanupInterval); 58 - cleanupInterval = null; 59 - } 60 - } 61 - 62 17 export async function getWispDomain(domain: string): Promise<DomainLookup | null> { 63 18 const key = domain.toLowerCase(); 64 - 65 - // Check cache first 66 - const cached = domainCache.get(key); 67 - if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 68 - return cached.value; 69 - } 70 - 71 - // Query database 72 - const result = await sql<DomainLookup[]>` 73 - SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1 74 - `; 75 - const data = result[0] || null; 76 - 77 - // Cache the result 78 - domainCache.set(key, { value: data, timestamp: Date.now() }); 79 - 80 - return data; 19 + return cache.getOrFetch('domains', key, async () => { 20 + const result = await sql<DomainLookup[]>` 21 + SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1 22 + `; 23 + return result[0] || null; 24 + }); 81 25 } 82 26 83 27 export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> { 84 28 const key = domain.toLowerCase(); 85 - 86 - // Check cache first 87 - const cached = customDomainCache.get(key); 88 - if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 89 - return cached.value; 90 - } 91 - 92 - // Query database 93 - const result = await sql<CustomDomainLookup[]>` 94 - SELECT id, domain, did, rkey, verified FROM custom_domains 95 - WHERE domain = ${key} AND verified = true LIMIT 1 96 - `; 97 - const data = result[0] || null; 98 - 99 - // Cache the result 100 - customDomainCache.set(key, { value: data, timestamp: Date.now() }); 101 - 102 - return data; 29 + return cache.getOrFetch('customDomains', key, async () => { 30 + const result = await sql<CustomDomainLookup[]>` 31 + SELECT id, domain, did, rkey, verified FROM custom_domains 32 + WHERE domain = ${key} AND verified = true LIMIT 1 33 + `; 34 + return result[0] || null; 35 + }); 103 36 } 104 37 105 38 export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> { 106 - const key = `hash:${hash}`; 107 - 108 - // Check cache first 109 - const cached = customDomainCache.get(key); 110 - if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 111 - return cached.value; 112 - } 113 - 114 - // Query database 115 - const result = await sql<CustomDomainLookup[]>` 116 - SELECT id, domain, did, rkey, verified FROM custom_domains 117 - WHERE id = ${hash} AND verified = true LIMIT 1 118 - `; 119 - const data = result[0] || null; 120 - 121 - // Cache the result 122 - customDomainCache.set(key, { value: data, timestamp: Date.now() }); 123 - 124 - return data; 39 + return cache.getOrFetch('customDomains', `hash:${hash}`, async () => { 40 + const result = await sql<CustomDomainLookup[]>` 41 + SELECT id, domain, did, rkey, verified FROM custom_domains 42 + WHERE id = ${hash} AND verified = true LIMIT 1 43 + `; 44 + return result[0] || null; 45 + }); 125 46 } 126 47 127 48 export async function upsertSite(did: string, rkey: string, displayName?: string) { ··· 253 174 254 175 // Site cache queries 255 176 256 - const SETTINGS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 257 - const settingsCache = new Map<string, CachedDomain<SiteSettingsCache | null>>(); 258 - 259 177 export async function getSiteSettingsCache(did: string, rkey: string): Promise<SiteSettingsCache | null> { 260 - const key = `${did}:${rkey}`; 261 - 262 - const cached = settingsCache.get(key); 263 - if (cached && Date.now() - cached.timestamp < SETTINGS_CACHE_TTL) { 264 - return cached.value; 265 - } 266 - 267 - const result = await sql<SiteSettingsCache[]>` 268 - SELECT did, rkey, record_cid, directory_listing, spa_mode, custom_404, index_files, clean_urls, headers, cached_at, updated_at 269 - FROM site_settings_cache 270 - WHERE did = ${did} AND rkey = ${rkey} 271 - LIMIT 1 272 - `; 273 - const data = result[0] || null; 274 - 275 - settingsCache.set(key, { value: data, timestamp: Date.now() }); 276 - return data; 178 + return cache.getOrFetch('settings', `${did}:${rkey}`, async () => { 179 + const result = await sql<SiteSettingsCache[]>` 180 + SELECT did, rkey, record_cid, directory_listing, spa_mode, custom_404, index_files, clean_urls, headers, cached_at, updated_at 181 + FROM site_settings_cache 182 + WHERE did = ${did} AND rkey = ${rkey} 183 + LIMIT 1 184 + `; 185 + return result[0] || null; 186 + }); 277 187 } 278 188 279 189 export async function getSiteCache(did: string, rkey: string): Promise<SiteCache | null> {
+7 -15
apps/hosting-service/src/lib/file-serving.ts
··· 12 12 import { isHtmlContent } from './html-rewriter'; 13 13 import { generate404Page, generateDirectoryListing } from './page-generators'; 14 14 import { getIndexFiles, applyCustomHeaders } from './request-utils'; 15 - import { getRedirectRulesFromCache, setRedirectRulesInCache } from './site-cache'; 15 + import { cache } from './cache-manager'; 16 16 import { storage } from './storage'; 17 17 import { getSiteCache } from './db'; 18 18 import { enqueueRevalidate } from './revalidate-queue'; ··· 187 187 const indexFiles = getIndexFiles(settings); 188 188 189 189 // Check for redirect rules first (_redirects wins over settings) 190 - let redirectRules = getRedirectRulesFromCache(did, rkey); 191 - 192 - if (redirectRules === null) { 193 - // Load rules (not in cache or evicted) 194 - redirectRules = await span(trace, 'storage:redirectRules', () => loadRedirectRules(did, rkey)); 195 - setRedirectRulesInCache(did, rkey, redirectRules); 196 - } 190 + const redirectRules = await cache.getOrFetch('redirectRules', `${did}:${rkey}`, () => 191 + span(trace, 'storage:redirectRules', () => loadRedirectRules(did, rkey)) 192 + ); 197 193 198 194 // Apply redirect rules if any exist 199 195 if (redirectRules.length > 0) { ··· 488 484 const indexFiles = getIndexFiles(settings); 489 485 490 486 // Check for redirect rules first (_redirects wins over settings) 491 - let redirectRules = getRedirectRulesFromCache(did, rkey); 492 - 493 - if (redirectRules === null) { 494 - // Load rules (not in cache or evicted) 495 - redirectRules = await span(trace, 'storage:redirectRules', () => loadRedirectRules(did, rkey)); 496 - setRedirectRulesInCache(did, rkey, redirectRules); 497 - } 487 + const redirectRules = await cache.getOrFetch('redirectRules', `${did}:${rkey}`, () => 488 + span(trace, 'storage:redirectRules', () => loadRedirectRules(did, rkey)) 489 + ); 498 490 499 491 // Apply redirect rules if any exist 500 492 if (redirectRules.length > 0) {
-37
apps/hosting-service/src/lib/site-cache.ts
··· 1 - /** 2 - * Redirect rules cache utilities 3 - */ 4 - 5 - import { LRUCache } from './cache'; 6 - import type { RedirectRule } from './redirects'; 7 - 8 - // Cache for redirect rules (per site) - LRU with 1000 site limit 9 - // Each entry is relatively small (array of redirect rules), so 1000 sites should be < 10MB 10 - const redirectRulesCache = new LRUCache<RedirectRule[]>(10 * 1024 * 1024, 1000); // 10MB, 1000 sites 11 - 12 - /** 13 - * Clear redirect rules cache for a specific site 14 - * Should be called when a site is updated/recached 15 - */ 16 - export function clearRedirectRulesCache(did: string, rkey: string) { 17 - const cacheKey = `${did}:${rkey}`; 18 - redirectRulesCache.delete(cacheKey); 19 - } 20 - 21 - /** 22 - * Get redirect rules from cache 23 - */ 24 - export function getRedirectRulesFromCache(did: string, rkey: string): RedirectRule[] | null { 25 - const cacheKey = `${did}:${rkey}`; 26 - return redirectRulesCache.get(cacheKey); 27 - } 28 - 29 - /** 30 - * Set redirect rules in cache 31 - */ 32 - export function setRedirectRulesInCache(did: string, rkey: string, rules: RedirectRule[]) { 33 - const cacheKey = `${did}:${rkey}`; 34 - // Estimate size: roughly 100 bytes per rule 35 - const estimatedSize = rules.length * 100; 36 - redirectRulesCache.set(cacheKey, rules, estimatedSize); 37 - }
+4 -11
apps/hosting-service/src/server.ts
··· 13 13 import { isValidRkey, extractHeaders } from './lib/request-utils'; 14 14 import { serveFromCache, serveFromCacheWithRewrite } from './lib/file-serving'; 15 15 import { getRevalidateMetrics } from './lib/revalidate-metrics'; 16 + import { cache } from './lib/cache-manager'; 16 17 17 18 const logger = createLogger('hosting-service'); 18 19 19 - // Cache handle → DID resolutions for 10 minutes to avoid hitting bsky API on every request 20 - const HANDLE_CACHE_TTL = 10 * 60 * 1000; 21 - const handleCache = new Map<string, { did: string; timestamp: number }>(); 22 - 23 20 async function resolveDidCached(identifier: string): Promise<string | null> { 24 21 if (identifier.startsWith('did:')) return identifier; 25 - const cached = handleCache.get(identifier); 26 - if (cached && Date.now() - cached.timestamp < HANDLE_CACHE_TTL) { 27 - return cached.did; 28 - } 29 - const did = await resolveDid(identifier); 30 - if (did) handleCache.set(identifier, { did, timestamp: Date.now() }); 31 - return did; 22 + return cache.getOrFetch('handles', identifier, () => resolveDid(identifier), { 23 + cacheIf: (v) => v !== null, 24 + }); 32 25 } 33 26 34 27 const BASE_HOST_ENV = process.env.BASE_HOST || 'wisp.place';
+7 -4
packages/@wispplace/tiered-storage/src/tiers/DiskStorageTier.ts
··· 180 180 const data = await readFile(filePath); 181 181 return new Uint8Array(data); 182 182 } catch (error) { 183 - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 183 + const code = (error as NodeJS.ErrnoException).code; 184 + if (code === 'ENOENT' || code === 'ENOTDIR') { 184 185 return null; 185 186 } 186 187 throw error; ··· 218 219 219 220 return { data: new Uint8Array(dataBuffer), metadata }; 220 221 } catch (error) { 221 - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 222 + const code = (error as NodeJS.ErrnoException).code; 223 + if (code === 'ENOENT' || code === 'ENOTDIR') { 222 224 return null; 223 225 } 224 226 throw error; ··· 256 258 257 259 return { stream, metadata }; 258 260 } catch (error) { 259 - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 261 + const code = (error as NodeJS.ErrnoException).code; 262 + if (code === 'ENOENT' || code === 'ENOTDIR') { 260 263 return null; 261 264 } 262 265 throw error; ··· 435 438 return metadata; 436 439 } catch (error) { 437 440 const code = (error as NodeJS.ErrnoException).code; 438 - if (code === 'ENOENT') { 441 + if (code === 'ENOENT' || code === 'ENOTDIR') { 439 442 return null; 440 443 } 441 444 if (error instanceof SyntaxError) {