From daee65e0d57294c19600bc2f1577dce9492c34ef Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 13:17:57 +0200 Subject: [PATCH] feat(perf): add cache warmup for improved performance Implement automatic cache warming when users first connect to views. Pre-renders all URLs in a view to eliminate initial load latency. Also adds periodic warmup for active devices and removes cache TTL refresh on cache hits to preserve original expiration timing. --- src/server.ts | 72 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 8bce0db..c34edfb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,6 +16,7 @@ const CONFIG = { MAX_CONCURRENT_RENDERS: 16, QUEUE_TIMEOUT_MS: 30000, CACHE_CLEANUP_INTERVAL_MS: 30000, + WARMUP_INTERVAL_MS: 60000, } as const; // ═══════════════════════════════════════════════════════════════ @@ -215,6 +216,7 @@ function viewCacheAll(): View[] { // ═══════════════════════════════════════════════════════════════ const devices = new Map(); +const warmedUpViews = new Set(); const DEVICE_TTL_MS = 120_000; function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): void { @@ -227,6 +229,14 @@ function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: st existing.requestCount++; } else { devices.set(ip, { ip, viewId, viewName, userAgent, lastSeen: Date.now(), requestCount: 1 }); + + // First user connecting to this view - trigger warmup + if (!warmedUpViews.has(viewId)) { + warmedUpViews.add(viewId); + warmupView(viewId).catch(err => { + console.error(`[warmup] Failed to warm up view ${viewId}:`, err); + }); + } } } @@ -467,6 +477,64 @@ function cachePurgeExpired(): void { setInterval(cachePurgeExpired, CONFIG.CACHE_CLEANUP_INTERVAL_MS); +async function warmupView(viewId: string): Promise { + const view = viewCacheGet(viewId); + if (!view) { + console.warn(`[warmup] View ${viewId} not found`); + return; + } + + console.log(`[warmup] Starting precache for view "${view.name}" (${view.urls.length} URLs)`); + + for (let i = 0; i < view.urls.length; i++) { + const key = renderCacheKey(view.id, i); + const cached = renderCache.get(key); + const now = Date.now(); + + if (!cached || now >= cached.expiresAt) { + try { + await getOrRender(view, i); + console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`); + } catch (err: any) { + console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`); + } + } + } + + console.log(`[warmup] Precache complete for view "${view.name}"`); +} + +async function warmupCache(): Promise { + const activeDevices = devicesSnapshot(); + if (activeDevices.length === 0) { + return; + } + + console.log(`[warmup] Starting periodic precache for ${activeDevices.length} active device(s)`); + + const views = viewCacheAll(); + for (const view of views) { + for (let i = 0; i < view.urls.length; i++) { + const key = renderCacheKey(view.id, i); + const cached = renderCache.get(key); + const now = Date.now(); + + if (!cached || now >= cached.expiresAt) { + try { + await getOrRender(view, i); + console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`); + } catch (err: any) { + console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`); + } + } + } + } + + console.log(`[warmup] Periodic precache complete`); +} + +setInterval(warmupCache, CONFIG.WARMUP_INTERVAL_MS); + function cacheInvalidate(viewId: string): void { const prefix = `${viewId}:`; for (const key of renderCache.keys()) { @@ -478,6 +546,8 @@ function cacheInvalidate(viewId: string): void { renderQueue.splice(i, 1); } } + // Allow re-warming if view is recreated + warmedUpViews.delete(viewId); } function drainRenderQueue(): void { @@ -504,7 +574,6 @@ function drainRenderQueue(): void { const cached = renderCache.get(next.key); if (cached && Date.now() < cached.expiresAt) { - cached.expiresAt = Date.now() + view.cacheTtlSec * 1000; next.resolve(cached.body); continue; } @@ -564,7 +633,6 @@ async function getOrRender(view: View, urlIndex: number): Promise { const cached = renderCache.get(key); if (cached && now < cached.expiresAt) { - cached.expiresAt = now + view.cacheTtlSec * 1000; return cached.body; }