Something went wrong. Try again.
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
Something went wrong. Try again.
11 kB · 379 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380/** * Centralized in-memory cache manager for the hosting service. * * Replaces the scattered TTL Maps (domains, customDomains, settings, handles) * and the LRU redirect-rules cache with a single, namespace-aware cache. */
interface NamespaceConfig { /** Time-to-live in milliseconds. Entries older than this are stale. */ ttl?: number /** Maximum number of entries before LRU eviction kicks in. */ maxEntries?: number /** Maximum total estimated size (bytes) before LRU eviction kicks in. */ maxSize?: number /** Estimate the byte size of a value. Required when maxSize is set. */ estimateSize?: (value: unknown) => number}
interface CacheEntry { value: unknown timestamp: number size: number ttl?: number}
interface NamespaceStats { hits: number misses: number evictions: number entries: number sizeBytes: number}
interface GetOrFetchOpts<T> { /** Skip caching when predicate returns false (e.g. don't cache null). */ cacheIf?: (value: T) => boolean /** Per-entry TTL override (ms). Can be a number or a function of the value. */ ttl?: number | ((value: T) => number | undefined)}
interface InvalidationFence { valid: boolean}
interface PendingFetch { promise: Promise<unknown> fence: InvalidationFence}
function hasValidFence(fence: InvalidationFence | undefined): boolean { return fence?.valid ?? true}
function isTooLargeToCache(config: NamespaceConfig, size: number): boolean { return config.maxSize !== undefined && size > config.maxSize}
function shouldEvictForIncomingEntry( config: NamespaceConfig, entryCount: number, sizeBytes: number, incomingSize: number,): boolean { if (entryCount === 0) return false if (config.maxEntries !== undefined && entryCount >= config.maxEntries) return true return config.maxSize !== undefined && sizeBytes + incomingSize > config.maxSize}
export class CacheManager<NS extends string = string> { private namespaces: Map<NS, Map<string, CacheEntry>> = new Map() private configs: Map<NS, NamespaceConfig> = new Map() private stats: Map<NS, NamespaceStats> = new Map() private pendingFetches: Map<NS, Map<string, PendingFetch>> = new Map() private cleanupTimer: ReturnType<typeof setInterval> | null = null
constructor(config: Record<NS, NamespaceConfig>) { for (const [ns, cfg] of Object.entries(config) as [NS, NamespaceConfig][]) { this.namespaces.set(ns, new Map()) this.configs.set(ns, cfg) this.stats.set(ns, { hits: 0, misses: 0, evictions: 0, entries: 0, sizeBytes: 0 }) } }
// ── Primary API ──────────────────────────────────────────────────────
getOrFetch<T>(ns: NS, key: string, fetcher: () => T | Promise<T>, opts?: GetOrFetchOpts<T>): Promise<T> { const existing = this.get<T>(ns, key) if (existing !== undefined) return Promise.resolve(existing)
const pending = this.pendingFetches.get(ns)?.get(key) if (pending) return pending.promise as Promise<T>
const fence: InvalidationFence = { valid: true } let resolvePromise!: (value: T | PromiseLike<T>) => void let rejectPromise!: (reason?: unknown) => void const promise = new Promise<T>((resolve, reject) => { resolvePromise = resolve rejectPromise = reject }) const request: PendingFetch = { promise, fence } this.pendingFetchesFor(ns).set(key, request)
const fetchAndCache = async (): Promise<void> => { try { const value = await fetcher() if (request.fence.valid) { const shouldCache = !opts?.cacheIf || opts.cacheIf(value) if (shouldCache && request.fence.valid) { const ttl = typeof opts?.ttl === 'function' ? opts.ttl(value) : opts?.ttl this.store(ns, key, value, ttl, request.fence) } } resolvePromise(value) } catch (error) { rejectPromise(error) } finally { this.finishPendingFetch(ns, key, request) } }
void fetchAndCache() return promise }
get<T>(ns: NS, key: string): T | undefined { const map = this.namespaces.get(ns) const cfg = this.configs.get(ns) const st = this.stats.get(ns) if (!map || !cfg || !st) return undefined
const entry = map.get(key) if (!entry) { st.misses++ return undefined }
// TTL check (per-entry override takes precedence over namespace default) const effectiveTtl = entry.ttl ?? cfg.ttl if (effectiveTtl && Date.now() - entry.timestamp > effectiveTtl) { map.delete(key) st.entries = map.size st.sizeBytes -= entry.size st.misses++ return undefined }
// Touch for LRU: delete + re-insert moves to end of Map iteration order map.delete(key) map.set(key, entry)
st.hits++ return entry.value as T }
set(ns: NS, key: string, value: unknown, ttl?: number): void { this.invalidatePendingFetch(ns, key) this.store(ns, key, value, ttl) }
private store(ns: NS, key: string, value: unknown, ttl?: number, fence?: InvalidationFence): void { const map = this.namespaces.get(ns) const cfg = this.configs.get(ns) const st = this.stats.get(ns) if (!map || !cfg || !st) return if (!hasValidFence(fence)) return
const size = cfg.estimateSize ? cfg.estimateSize(value) : 0 if (!hasValidFence(fence)) return
// Remove an older value even when the replacement is too large to cache. const existing = map.get(key) if (existing) { st.sizeBytes -= existing.size map.delete(key) st.entries = map.size } if (isTooLargeToCache(cfg, size)) return
this.evictUntilFits(map, cfg, st, size) map.set(key, { value, timestamp: Date.now(), size, ttl }) st.sizeBytes += size st.entries = map.size }
private evictUntilFits( map: Map<string, CacheEntry>, config: NamespaceConfig, stats: NamespaceStats, incomingSize: number, ): void { while (shouldEvictForIncomingEntry(config, map.size, stats.sizeBytes, incomingSize)) { const oldest = map.keys().next().value if (oldest === undefined) return
const evicted = map.get(oldest)! map.delete(oldest) stats.sizeBytes -= evicted.size stats.evictions++ } }
private pendingFetchesFor(ns: NS): Map<string, PendingFetch> { const pending = this.pendingFetches.get(ns) if (pending) return pending
const created = new Map<string, PendingFetch>() this.pendingFetches.set(ns, created) return created }
private finishPendingFetch(ns: NS, key: string, request: PendingFetch): void { const pending = this.pendingFetches.get(ns) if (!pending || pending.get(key) !== request) return
pending.delete(key) if (pending.size === 0) this.pendingFetches.delete(ns) }
/** * A fenced request remains alive for its original callers, but is removed from * the join map so a miss after invalidation starts a fresh fetch. The request * keeps its own fence, so its later completion cannot repopulate the cache. */ private invalidatePendingFetch(ns: NS, key: string): void { const pending = this.pendingFetches.get(ns) const request = pending?.get(key) if (!pending || !request) return
request.fence.valid = false pending.delete(key) if (pending.size === 0) this.pendingFetches.delete(ns) }
private invalidatePendingFetchesByPrefix(ns: NS, prefix: string): void { const pending = this.pendingFetches.get(ns) if (!pending) return
for (const [key, request] of pending) { if (!key.startsWith(prefix)) continue request.fence.valid = false pending.delete(key) } if (pending.size === 0) this.pendingFetches.delete(ns) }
private invalidateAllPendingFetches(ns: NS): void { const pending = this.pendingFetches.get(ns) if (!pending) return
for (const request of pending.values()) { request.fence.valid = false } this.pendingFetches.delete(ns) }
delete(ns: NS, key: string): void { this.invalidatePendingFetch(ns, key)
const map = this.namespaces.get(ns) const st = this.stats.get(ns) if (!map || !st) return
const entry = map.get(key) if (entry) { map.delete(key) st.sizeBytes -= entry.size st.entries = map.size } }
deletePrefix(ns: NS, prefix: string): void { this.invalidatePendingFetchesByPrefix(ns, prefix)
const map = this.namespaces.get(ns) const st = this.stats.get(ns) if (!map || !st) return
for (const [key, entry] of map) { if (key.startsWith(prefix)) { map.delete(key) st.sizeBytes -= entry.size } } st.entries = map.size }
clear(ns: NS): void { this.invalidateAllPendingFetches(ns)
const map = this.namespaces.get(ns) const st = this.stats.get(ns) if (!map || !st) return
map.clear() st.entries = 0 st.sizeBytes = 0 }
clearAll(): void { for (const ns of this.namespaces.keys()) { this.clear(ns) } }
// ── Stats ────────────────────────────────────────────────────────────
getStats(): Record<NS, NamespaceStats> { const out = {} as Record<NS, NamespaceStats> for (const [ns, st] of this.stats) { out[ns] = { ...st } } return out }
// ── Periodic cleanup ─────────────────────────────────────────────────
startCleanup(intervalMs = 30 * 60_000): void { if (this.cleanupTimer) return
this.cleanupTimer = setInterval(() => { const now = Date.now() for (const [ns, cfg] of this.configs) { const map = this.namespaces.get(ns)! const st = this.stats.get(ns)! for (const [key, entry] of map) { const effectiveTtl = entry.ttl ?? cfg.ttl if (effectiveTtl && now - entry.timestamp > effectiveTtl) { map.delete(key) st.sizeBytes -= entry.size } } st.entries = map.size } }, intervalMs) }
stopCleanup(): void { if (this.cleanupTimer) { clearInterval(this.cleanupTimer) this.cleanupTimer = null } }}
// ── Singleton ────────────────────────────────────────────────────────────
const INVALIDATION_SAFETY_TTL_MS = 10_000const REDIRECT_RULES_SAFETY_TTL_MS = 30_000
type CacheNamespace = | 'domains' | 'customDomains' | 'settings' | 'handles' | 'redirectRules' | 'siteCache' | 'siteFiles' | 'sourceCidMismatches'
// Invalidation remains immediate. These TTLs bound stale data when an invalidation event cannot be delivered.export const cache = new CacheManager<CacheNamespace>({ domains: { ttl: INVALIDATION_SAFETY_TTL_MS, maxEntries: 5000 }, customDomains: { ttl: INVALIDATION_SAFETY_TTL_MS, maxEntries: 5000 }, settings: { ttl: INVALIDATION_SAFETY_TTL_MS, maxEntries: 1000 }, handles: { ttl: 10 * 60_000, maxEntries: 5000 }, redirectRules: { ttl: REDIRECT_RULES_SAFETY_TTL_MS, maxEntries: 1000, maxSize: 10 * 1024 * 1024, estimateSize: (v) => (v as unknown[]).length * 100, }, siteCache: { ttl: INVALIDATION_SAFETY_TTL_MS, maxEntries: 5000 }, // Negative-result cache for per-site fallback files (SPA, custom 404, auto-detected 404 pages). // Stores null when a file is confirmed absent so repeated 404 responses don't re-hit S3. siteFiles: { ttl: INVALIDATION_SAFETY_TTL_MS, maxEntries: 10_000 }, // Short-lived local markers for non-transient source-CID validation failures. // Invalidation clears these immediately. sourceCidMismatches: { ttl: INVALIDATION_SAFETY_TTL_MS, maxEntries: 10_000 },})