From 94141e928cd0c09a4bfd872877152897cfca4362 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 08:04:59 +0200 Subject: [PATCH] feat(api, ui): add debug panel and device tracking Add real-time debug panel with server metrics, view status, cache visualization, and device tracking. Add viewport/cache TTL config to views. Simplify cache management and remove rate limiting. --- public/index.html | 1378 ++++++++++++++++++++++++++++++++------------- src/server.ts | 789 +++++++++++++++----------- views.db | Bin 12288 -> 12288 bytes 3 files changed, 1447 insertions(+), 720 deletions(-) diff --git a/public/index.html b/public/index.html index 818a436..e59e495 100644 --- a/public/index.html +++ b/public/index.html @@ -1,252 +1,713 @@ - + - - - -Samsung TV Proxy - - - + @media (max-width: 640px) { + .app { + padding: 16px 10px; + } + .card { + padding: 14px; + } + .debug-grid { + grid-template-columns: repeat(2, 1fr); + } + .debug-table { + font-size: 11px; + } + .debug-table td, + .debug-table th { + padding: 6px 6px; + } + } + + + +
+
+

Samsung TV Proxy

+
+ + +
+
-
-
-

Samsung TV Proxy

- -
+
-
-
+ +
+
+

🔍 Live Debug

+
+
+ polling every + + +
+
- - +
+
+
+
Uptime (s)
+
+
+
+
Views
+
+
+
+
Active Renders
+
+
+
+
Queue Depth
+
+
+
+
Cache Entries
+
+
+
+
Devices
+
+
- -
+
+

📺 Views & Rotation

+
+ + + + + + + + + + + + + + +
ViewMethodViewportCurrent URLIdxRemainingCycleCache TTL
+
+
- + async function pollDebug() { + if (!debugVisible) return; + try { + const res = await fetch(origin + "/api/debug"); + lastDebugData = await res.json(); + renderDebug(lastDebugData); + } catch { + /* silent */ + } + } - + let debugTickInterval = null; + + function renderDebug(data) { + const s = data.server; + document.getElementById("stat-uptime").textContent = s.uptime; + document.getElementById("stat-views").textContent = data.views.length; + document.getElementById("stat-renders").textContent = s.activeRenders; + document.getElementById("stat-queue").textContent = s.queueLength; + document.getElementById("stat-cache").textContent = s.cacheEntries; + document.getElementById("stat-devices").textContent = data.devices.length; + + // ── Views table ── + document.getElementById("debug-views-tbody").innerHTML = data.views + .map((v) => { + const methodBadge = + v.method === "mjpeg" + ? 'MJPEG' + : 'SSR'; + return ` + + ${esc(v.name)} + ${methodBadge} + ${v.viewportWidth}×${v.viewportHeight} + ${esc(v.currentUrl)} + ${v.currentUrlIndex}/${v.urls.length} + ${v.remainingSec}s + ${v.totalCycleSec}s + ${v.cacheTtlSec}s + `; + }) + .join(""); + + // ── Cache table ── + document.getElementById("debug-cache-tbody").innerHTML = data.views + .flatMap((v) => + v.cacheEntries.map( + (c) => ` + + ${esc(v.name)} + ${c.urlIndex}/${v.urls.length} + ${esc(c.url)} + ${c.ttlRemainingSec}s + `, + ), + ) + .join(""); + + // ── Devices table ── + document.getElementById("debug-devices-tbody").innerHTML = data.devices + .map((d) => { + const ago = Math.round((Date.now() - d.lastSeen) / 1000); + return ` + + ${esc(d.ip)} + ${esc(d.viewName)} + ${d.requestCount} + ${ago}s ago + ${esc(d.userAgent.slice(0, 60))} + `; + }) + .join(""); + + if (!debugTickInterval) { + debugTickInterval = setInterval(tickDebugValues, 500); + } + } + + function tickDebugValues() { + document.querySelectorAll("[data-remaining]").forEach((el) => { + let val = parseFloat(el.getAttribute("data-remaining")); + val = Math.max(0, val - 0.5); + el.setAttribute("data-remaining", val); + el.textContent = val.toFixed(1) + "s"; + if (val <= 3) el.style.color = "var(--danger)"; + else if (val <= 10) el.style.color = "var(--orange)"; + else el.style.color = "var(--yellow)"; + }); + + document.querySelectorAll("[data-ttl]").forEach((el) => { + let val = parseFloat(el.getAttribute("data-ttl")); + val = Math.max(0, val - 0.5); + el.setAttribute("data-ttl", val); + el.textContent = val.toFixed(1) + "s"; + if (val <= 0) { + el.classList.remove("fresh"); + el.classList.add("expired"); + } + }); + } + + // ═══════════════════════════════════════════════ + // KEYBOARD + // ═══════════════════════════════════════════════ + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeModal(); + }); + + // ═══════════════════════════════════════════════ + // INIT + // ═══════════════════════════════════════════════ + + loadViews(); + + diff --git a/src/server.ts b/src/server.ts index da7900a..7875a94 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,17 +13,19 @@ const CONFIG = { PAGE_POOL_WARM: 4, PAGE_ACQUIRE_TIMEOUT_MS: 3000, RENDER_TIMEOUT_MS: 25000, - PREWARM_MARGIN_SEC: 5, - RATE_LIMIT_MS: 2000, MAX_CONCURRENT_RENDERS: 16, - STATIC_CACHE_TTL_SEC: 60, + QUEUE_TIMEOUT_MS: 30000, + CACHE_CLEANUP_INTERVAL_MS: 30000, } as const; // ═══════════════════════════════════════════════════════════════ // TYPES // ═══════════════════════════════════════════════════════════════ -interface URLItem { url: string; durationSec: number } +interface URLItem { + url: string; + durationSec: number; +} interface View { id: string; @@ -31,33 +33,40 @@ interface View { urls: URLItem[]; method: "mjpeg" | "ssr"; metaRefreshEnabled: boolean; + cacheTtlSec: number; + viewportWidth: number; + viewportHeight: number; createdAt: number; } interface ViewRow { - id: string; name: string; urls: string; method: string; - meta_refresh_enabled: number; created_at: number; + id: string; + name: string; + urls: string; + method: string; + meta_refresh_enabled: number; + cache_ttl_sec: number; + viewport_width: number; + viewport_height: number; + created_at: number; } interface CreateViewBody { - name: string; urls: URLItem[]; method: "mjpeg" | "ssr"; + name: string; + urls: URLItem[]; + method: "mjpeg" | "ssr"; metaRefreshEnabled?: boolean; + cacheTtlSec?: number; + viewportWidth?: number; + viewportHeight?: number; } type UpdateViewBody = Partial; -interface InlinePayload { - css: Array<{ url: string; text: string }>; - images: Array<{ src: string; dataUri: string }>; - favicons: Array<{ href: string; dataUri: string }>; - bgImages: Array<{ url: string; dataUri: string }>; -} - interface CachedRender { body: string; urlIndex: number; - expiresAt: number; // unix ms - nextBoundary: number; // unix ms — when rotation advances - prewarmTimer?: ReturnType; + createdAt: number; + expiresAt: number; } interface ActiveRender { @@ -65,6 +74,24 @@ interface ActiveRender { urlIndex: number; } +interface QueuedRender { + resolve: (body: string) => void; + reject: (err: Error) => void; + key: string; + viewId: string; + urlIndex: number; + queuedAt: number; +} + +interface DeviceEntry { + ip: string; + viewId: string; + viewName: string; + userAgent: string; + lastSeen: number; + requestCount: number; +} + // ═══════════════════════════════════════════════════════════════ // DATABASE // ═══════════════════════════════════════════════════════════════ @@ -73,44 +100,86 @@ const db = new Database("views.db", { create: true }); db.run(` CREATE TABLE IF NOT EXISTS views ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - urls TEXT NOT NULL DEFAULT '[]', - method TEXT NOT NULL DEFAULT 'ssr', - meta_refresh_enabled INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + urls TEXT NOT NULL DEFAULT '[]', + method TEXT NOT NULL DEFAULT 'ssr', + meta_refresh_enabled INTEGER NOT NULL DEFAULT 0, + cache_ttl_sec INTEGER NOT NULL DEFAULT 60, + viewport_width INTEGER NOT NULL DEFAULT 1920, + viewport_height INTEGER NOT NULL DEFAULT 1080, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) ) `); +// Migrations for upgrading from older schema +try { + db.run("ALTER TABLE views ADD COLUMN cache_ttl_sec INTEGER NOT NULL DEFAULT 60"); +} catch {} +try { + db.run("ALTER TABLE views ADD COLUMN viewport_width INTEGER NOT NULL DEFAULT 1920"); +} catch {} +try { + db.run("ALTER TABLE views ADD COLUMN viewport_height INTEGER NOT NULL DEFAULT 1080"); +} catch {} + function rowToView(row: ViewRow): View { return { - id: row.id, name: row.name, urls: JSON.parse(row.urls), + id: row.id, + name: row.name, + urls: JSON.parse(row.urls), method: row.method as View["method"], metaRefreshEnabled: row.meta_refresh_enabled === 1, + cacheTtlSec: row.cache_ttl_sec ?? 60, + viewportWidth: row.viewport_width ?? 1920, + viewportHeight: row.viewport_height ?? 1080, createdAt: row.created_at, }; } function dbLoadAll(): View[] { - return (db.query("SELECT * FROM views ORDER BY created_at DESC") - .all() as ViewRow[]).map(rowToView); + return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map( + rowToView, + ); } function dbLoad(id: string): View | null { - const row = db.query("SELECT * FROM views WHERE id = ?") - .get(id) as ViewRow | undefined; + const row = db.query("SELECT * FROM views WHERE id = ?").get(id) as ViewRow | undefined; return row ? rowToView(row) : null; } function dbInsert(v: View): void { - db.run(`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,created_at) - VALUES (?,?,?,?,?,?)`, - [v.id, v.name, JSON.stringify(v.urls), v.method, v.metaRefreshEnabled ? 1 : 0, v.createdAt]); + db.run( + `INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at) + VALUES (?,?,?,?,?,?,?,?,?)`, + [ + v.id, + v.name, + JSON.stringify(v.urls), + v.method, + v.metaRefreshEnabled ? 1 : 0, + v.cacheTtlSec, + v.viewportWidth, + v.viewportHeight, + v.createdAt, + ], + ); } function dbUpdate(v: View): void { - db.run("UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=? WHERE id=?", - [v.name, JSON.stringify(v.urls), v.method, v.metaRefreshEnabled ? 1 : 0, v.id]); + db.run( + "UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=? WHERE id=?", + [ + v.name, + JSON.stringify(v.urls), + v.method, + v.metaRefreshEnabled ? 1 : 0, + v.cacheTtlSec, + v.viewportWidth, + v.viewportHeight, + v.id, + ], + ); } function dbDelete(id: string): boolean { @@ -118,7 +187,7 @@ function dbDelete(id: string): boolean { } // ═══════════════════════════════════════════════════════════════ -// OPT #3: IN-MEMORY VIEW CACHE +// IN-MEMORY VIEW CACHE // ═══════════════════════════════════════════════════════════════ const viewCache = new Map(); @@ -135,15 +204,40 @@ function viewCacheAll(): View[] { return [...viewCache.values()].sort((a, b) => b.createdAt - a.createdAt); } -function viewCachePut(v: View): void { viewCache.set(v.id, v); } +// ═══════════════════════════════════════════════════════════════ +// DEVICE TRACKING +// ═══════════════════════════════════════════════════════════════ -function viewCacheDelete(id: string): void { - viewCache.delete(id); - renderCacheInvalidate(id); +const devices = new Map(); +const DEVICE_TTL_MS = 120_000; + +function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): void { + const existing = devices.get(ip); + if (existing) { + existing.viewId = viewId; + existing.viewName = viewName; + existing.userAgent = userAgent; + existing.lastSeen = Date.now(); + existing.requestCount++; + } else { + devices.set(ip, { ip, viewId, viewName, userAgent, lastSeen: Date.now(), requestCount: 1 }); + } +} + +function devicesPrune(): void { + const cutoff = Date.now() - DEVICE_TTL_MS; + for (const [ip, d] of devices) { + if (d.lastSeen < cutoff) devices.delete(ip); + } +} + +function devicesSnapshot(): DeviceEntry[] { + devicesPrune(); + return [...devices.values()].sort((a, b) => b.lastSeen - a.lastSeen); } // ═══════════════════════════════════════════════════════════════ -// OPT #8: BROWSER POOL + OPT #4: PAGE POOL +// BROWSER + PAGE POOL // ═══════════════════════════════════════════════════════════════ interface BrowserSlot { @@ -168,32 +262,29 @@ class PagePool { executablePath: "/usr/bin/chromium", headless: true, args: [ - "--no-sandbox", "--disable-setuid-sandbox", - "--disable-dev-shm-usage", "--disable-gpu", + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", "--disable-software-rasterizer", - "--disable-extensions", "--mute-audio", + "--disable-extensions", + "--mute-audio", ], }); browser.on("disconnected", () => { console.error(`[browser-pool] Browser ${i} disconnected`); }); - const slot: BrowserSlot = { - browser, - available: [], - inUse: 0, - max: CONFIG.PAGES_PER_BROWSER, - }; - - // Pre-warm pages + const slot: BrowserSlot = { browser, available: [], inUse: 0, max: CONFIG.PAGES_PER_BROWSER }; for (let j = 0; j < CONFIG.PAGE_POOL_WARM; j++) { const page = await this.createPage(slot); slot.available.push(page); } - this.slots.push(slot); } - console.log(`[browser-pool] ${CONFIG.BROWSER_COUNT} browsers × ${CONFIG.PAGES_PER_BROWSER} pages ready`); + console.log( + `[browser-pool] ${CONFIG.BROWSER_COUNT} browsers × ${CONFIG.PAGES_PER_BROWSER} pages ready`, + ); } private async createPage(slot: BrowserSlot): Promise { @@ -212,58 +303,63 @@ class PagePool { return page; } - private async resetPage(page: Page): Promise { + async resetPage(page: Page): Promise { try { await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 }); - // Clear storage to prevent cross-view contamination await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); - // Clear cookies for the blank origin const client = await page.createCDPSession(); await client.send("Network.clearBrowserCookies"); await client.detach(); - } catch { - // Page may be broken — close and don't return to pool - try { await page.close(); } catch { } + return true; + } catch (err) { + // If reset fails (e.g. page already detached), close it and signal failure + console.warn(`[page-pool] resetPage failed, discarding page: ${err}`); + try { + await page.close(); + } catch {} + return false; } } acquire(): Promise { - // Try available page first for (const slot of this.slots) { if (slot.available.length > 0) { - const page = slot.available.pop()!; slot.inUse++; - return Promise.resolve(page); + return Promise.resolve(slot.available.pop()!); } if (slot.inUse < slot.max) { slot.inUse++; return this.createPage(slot); } } - - // All full — wait with timeout return new Promise((resolve, reject) => { const timeout = setTimeout(() => { - const idx = this.waiters.findIndex(w => w.timeout === timeout); + const idx = this.waiters.findIndex((w) => w.timeout === timeout); if (idx >= 0) this.waiters.splice(idx, 1); reject(new Error("Page pool exhausted")); }, CONFIG.PAGE_ACQUIRE_TIMEOUT_MS); - this.waiters.push({ resolve, reject, timeout }); }); } async release(page: Page): Promise { const slot = this.pageToSlot.get(page); - if (!slot) { await page.close().catch(() => { }); return; } - + if (!slot) { + await page.close().catch(() => {}); + return; + } slot.inUse--; - await this.resetPage(page); - // Wake a waiter + const ok = await this.resetPage(page); + if (!ok) { + // Page was closed during reset — don't recycle it + this.pageToSlot.delete(page); + return; + } + if (this.waiters.length > 0) { const w = this.waiters.shift()!; clearTimeout(w.timeout); @@ -271,12 +367,11 @@ class PagePool { w.resolve(page); return; } - - // Keep for reuse (don't exceed max) if (slot.available.length + slot.inUse < slot.max) { slot.available.push(page); } else { - await page.close().catch(() => { }); + await page.close().catch(() => {}); + this.pageToSlot.delete(page); } } @@ -286,8 +381,8 @@ class PagePool { async shutdown(): Promise { for (const slot of this.slots) { - for (const p of slot.available) await p.close().catch(() => { }); - await slot.browser.close().catch(() => { }); + for (const p of slot.available) await p.close().catch(() => {}); + await slot.browser.close().catch(() => {}); } this.slots = []; } @@ -296,162 +391,186 @@ class PagePool { let pagePool: PagePool; // ═══════════════════════════════════════════════════════════════ -// OPT #6: RATE LIMITER -// ═══════════════════════════════════════════════════════════════ - -const rateLimitMap = new Map(); - -function rateLimitCheck(ip: string): boolean { - const now = Date.now(); - const last = rateLimitMap.get(ip); - if (last && now - last < CONFIG.RATE_LIMIT_MS) return false; - rateLimitMap.set(ip, now); - - // Periodic cleanup - if (rateLimitMap.size > 10000) { - const cutoff = now - CONFIG.RATE_LIMIT_MS * 2; - for (const [k, v] of rateLimitMap) { - if (v < cutoff) rateLimitMap.delete(k); - } - } - return true; -} - -// ═══════════════════════════════════════════════════════════════ -// OPT #1 + #5: RENDER CACHE WITH PRE-WARMING +// RENDER CACHE + QUEUE // ═══════════════════════════════════════════════════════════════ const renderCache = new Map(); const inFlightRenders = new Map(); +const renderQueue: QueuedRender[] = []; function renderCacheKey(viewId: string, urlIndex: number): string { return `${viewId}:${urlIndex}`; } -function computeExpiry(view: View, urlIndex: number): { expiresAt: number; nextBoundary: number } { +function cachePurgeExpired(): void { const now = Date.now(); - if (view.urls.length <= 1) { - return { - expiresAt: now + CONFIG.STATIC_CACHE_TTL_SEC * 1000, - nextBoundary: now + CONFIG.STATIC_CACHE_TTL_SEC * 1000, - }; - } - const remaining = getRemainingSec(view) * 1000; - const nextBoundary = now + remaining; - return { expiresAt: nextBoundary, nextBoundary }; -} - -function schedulePrewarm(key: string, viewId: string, urlIndex: number, nextBoundary: number): void { - const entry = renderCache.get(key); - if (!entry) return; - - const marginMs = CONFIG.PREWARM_MARGIN_SEC * 1000; - const fireAt = nextBoundary - marginMs; - const delay = fireAt - Date.now(); - - if (delay <= 0) return; // Too close to boundary - - // Clear stale prewarm - if (entry.prewarmTimer) clearTimeout(entry.prewarmTimer); - - entry.prewarmTimer = setTimeout(() => { - // Check view still exists - const view = viewCacheGet(viewId); - if (!view) return; - - const idx = getCurrentURLIndex(view); - const nextIdx = (idx + 1) % view.urls.length; - const nextKey = renderCacheKey(viewId, nextIdx); - - // Don't prewarm if already cached or in flight - if (renderCache.has(nextKey) || inFlightRenders.has(nextKey)) return; - - // If no one is using this view right now, skip prewarm - // (check: is current cache entry still being hit? crude heuristic — if it exists, prewarm) - console.log(`[prewarm] view=${viewId} nextUrl=${nextIdx}`); - - // Fire background render - renderViewToCache(view, nextIdx, nextKey).catch(() => { }); - }, delay); -} - -function renderCacheInvalidate(viewId: string): void { - const prefix = `${viewId}:`; for (const [key, entry] of renderCache) { - if (key.startsWith(prefix)) { - if (entry.prewarmTimer) clearTimeout(entry.prewarmTimer); - renderCache.delete(key); + if (now >= entry.expiresAt) renderCache.delete(key); + } +} + +setInterval(cachePurgeExpired, CONFIG.CACHE_CLEANUP_INTERVAL_MS); + +function cacheInvalidate(viewId: string): void { + const prefix = `${viewId}:`; + for (const key of renderCache.keys()) { + if (key.startsWith(prefix)) renderCache.delete(key); + } + for (let i = renderQueue.length - 1; i >= 0; i--) { + if (renderQueue[i].viewId === viewId) { + renderQueue[i].reject(new Error("View deleted")); + renderQueue.splice(i, 1); } } } +function drainRenderQueue(): void { + const now = Date.now(); + + for (let i = renderQueue.length - 1; i >= 0; i--) { + if (now - renderQueue[i].queuedAt > CONFIG.QUEUE_TIMEOUT_MS) { + const q = renderQueue[i]; + q.reject(new Error("Render queue timeout")); + console.log( + `[queue] Dropped waiter for ${q.key} (timeout ${Math.round((now - q.queuedAt) / 1000)}s)`, + ); + renderQueue.splice(i, 1); + } + } + + while (renderQueue.length > 0 && pagePool.activeRenders < CONFIG.MAX_CONCURRENT_RENDERS) { + const next = renderQueue.shift()!; + const view = viewCacheGet(next.viewId); + if (!view) { + next.reject(new Error("View deleted while queued")); + continue; + } + + const cached = renderCache.get(next.key); + if (cached && Date.now() < cached.expiresAt) { + cached.expiresAt = Date.now() + view.cacheTtlSec * 1000; + next.resolve(cached.body); + continue; + } + + console.log( + `[queue] Dequeued ${next.key} (waited ${Math.round((now - next.queuedAt) / 1000)}s)`, + ); + const renderPromise = renderViewToCache(view, next.urlIndex, next.key); + inFlightRenders.set(next.key, { promise: renderPromise, urlIndex: next.urlIndex }); + renderPromise.then( + (body) => { + next.resolve(body); + }, + (err) => { + next.reject(err); + }, + ); + } +} + async function renderViewToCache(view: View, urlIndex: number, key: string): Promise { const urlItem = view.urls[urlIndex]; let body: string; if (view.method === "mjpeg") { - const jpeg = await renderScreenshotFrame(urlItem.url); + const jpeg = await renderScreenshotFrame(urlItem.url, view.viewportWidth, view.viewportHeight); body = jpeg.toString("base64"); } else { - body = await renderSSR(urlItem.url); + body = await renderSSR(urlItem.url, view.viewportWidth, view.viewportHeight); } - const { expiresAt, nextBoundary } = computeExpiry(view, urlIndex); - - const entry: CachedRender = { body, urlIndex, expiresAt, nextBoundary }; + const now = Date.now(); + const entry: CachedRender = { + body, + urlIndex, + createdAt: now, + expiresAt: now + view.cacheTtlSec * 1000, + }; renderCache.set(key, entry); - // Schedule prewarm for next rotation boundary - if (view.urls.length > 1) { - // Also prewarm next URL index - const nextIdx = (urlIndex + 1) % view.urls.length; - const nextKey = renderCacheKey(view.id, nextIdx); - if (!renderCache.has(nextKey)) { - schedulePrewarm(key, view.id, urlIndex, nextBoundary); - } - } + drainRenderQueue(); return body; } async function getOrRender(view: View, urlIndex: number): Promise { const key = renderCacheKey(view.id, urlIndex); + const now = Date.now(); - // Cache hit const cached = renderCache.get(key); - if (cached && Date.now() < cached.expiresAt && cached.urlIndex === urlIndex) { + if (cached && now < cached.expiresAt) { + cached.expiresAt = now + view.cacheTtlSec * 1000; return cached.body; } - // Deduplicate in-flight renders const inFlight = inFlightRenders.get(key); - if (inFlight && inFlight.urlIndex === urlIndex) { + if (inFlight) { return inFlight.promise; } - // Check global concurrency - if (pagePool.activeRenders >= CONFIG.MAX_CONCURRENT_RENDERS) { - // Serve stale if possible - if (cached) { - console.log(`[overload] Serving stale for view=${view.id}`); - // Trigger async refresh but don't wait - renderViewToCache(view, urlIndex, key).catch(() => { }); - return cached.body; + if (pagePool.activeRenders < CONFIG.MAX_CONCURRENT_RENDERS) { + const promise = renderViewToCache(view, urlIndex, key); + inFlightRenders.set(key, { promise, urlIndex }); + try { + return await promise; + } finally { + inFlightRenders.delete(key); } - throw new Error("Server overloaded — retry"); } - const promise = renderViewToCache(view, urlIndex, key); - inFlightRenders.set(key, { promise, urlIndex }); - try { - return await promise; - } finally { - inFlightRenders.delete(key); + console.log(`[queue] Enqueuing ${key} (position=${renderQueue.length})`); + return new Promise((resolve, reject) => { + renderQueue.push({ + resolve, + reject, + key, + viewId: view.id, + urlIndex, + queuedAt: Date.now(), + }); + }); +} + +function cacheStats(): Array<{ + key: string; + viewId: string; + urlIndex: number; + url: string; + createdAt: number; + expiresAt: number; + ttlRemainingSec: number; +}> { + const now = Date.now(); + const result: Array<{ + key: string; + viewId: string; + urlIndex: number; + url: string; + createdAt: number; + expiresAt: number; + ttlRemainingSec: number; + }> = []; + + for (const [key, entry] of renderCache) { + const [viewId, idxStr] = key.split(":"); + const view = viewCacheGet(viewId); + result.push({ + key, + viewId, + urlIndex: entry.urlIndex, + url: view?.urls[entry.urlIndex]?.url ?? "?", + createdAt: entry.createdAt, + expiresAt: entry.expiresAt, + ttlRemainingSec: Math.max(0, Math.round((entry.expiresAt - now) / 1000)), + }); } + + return result.sort((a, b) => a.ttlRemainingSec - b.ttlRemainingSec); } // ═══════════════════════════════════════════════════════════════ -// ROTATION LOGIC (wall-clock based, stateless) +// ROTATION LOGIC // ═══════════════════════════════════════════════════════════════ function getCurrentURLIndex(view: View): number { @@ -483,7 +602,7 @@ function getRemainingSec(view: View): number { } // ═══════════════════════════════════════════════════════════════ -// OPT #2: SINGLE-PASS INLINING +// SINGLE-PASS INLINING (SSR mode) // ═══════════════════════════════════════════════════════════════ function injectMetaRefresh(html: string, intervalSec: number): string { @@ -506,11 +625,17 @@ function stripScriptTags(html: string): string { } async function inlineAllResources(page: Page, html: string): Promise { - // Single page.evaluate — parallel fetches inside browser, one IPC round-trip + interface InlinePayload { + css: Array<{ url: string; text: string }>; + images: Array<{ src: string; dataUri: string }>; + favicons: Array<{ href: string; dataUri: string }>; + bgImages: Array<{ url: string; dataUri: string }>; + } + let payload: InlinePayload; + try { payload = await page.evaluate((): Promise => { - // Declare FileReader type for browser context function blobToDataUri(blob: Blob): Promise { return new Promise((resolve) => { const reader = new FileReader(); @@ -518,178 +643,138 @@ async function inlineAllResources(page: Page, html: string): Promise { reader.readAsDataURL(blob); }); } - async function collect(): Promise { - const result: InlinePayload = { - css: [], - images: [], - favicons: [], - bgImages: [], - }; - - // ── CSS ── - const cssLinks = [...document.querySelectorAll( - 'link[rel="stylesheet"]' - )]; + const result: InlinePayload = { css: [], images: [], favicons: [], bgImages: [] }; + // CSS + const cssLinks = [...document.querySelectorAll('link[rel="stylesheet"]')]; const cssResults = await Promise.allSettled( cssLinks.map(async (link) => { const res = await fetch(link.href); if (!res.ok) throw new Error("fetch failed"); return { url: link.href, text: await res.text() }; - }) + }), ); result.css = cssResults - .filter((r): r is PromiseFulfilledResult<{ url: string; text: string }> => - r.status === "fulfilled" + .filter( + (r): r is PromiseFulfilledResult<{ url: string; text: string }> => + r.status === "fulfilled", ) - .map(r => r.value); - - // ── Images () ── + .map((r) => r.value); + // Images const imgs = [ - ...document.querySelectorAll( - 'img[src]:not([src^="data:"])' - ), + ...document.querySelectorAll('img[src]:not([src^="data:"])'), ]; const imgResults = await Promise.allSettled( imgs.map(async (img) => { const res = await fetch(img.src); if (!res.ok) throw new Error("fetch failed"); - const blob = await res.blob(); - return { src: img.src, dataUri: await blobToDataUri(blob) }; - }) + return { src: img.src, dataUri: await blobToDataUri(await res.blob()) }; + }), ); result.images = imgResults - .filter((r): r is PromiseFulfilledResult<{ src: string; dataUri: string }> => - r.status === "fulfilled" + .filter( + (r): r is PromiseFulfilledResult<{ src: string; dataUri: string }> => + r.status === "fulfilled", ) - .map(r => r.value); - - // ── CSS background-images ── - const styleNodes = [ - ...document.querySelectorAll("style"), - ]; + .map((r) => r.value); + // Background images + const styleText = [...document.querySelectorAll("style")] + .map((s) => s.textContent ?? "") + .join("\n"); + const elemStyle = [...document.querySelectorAll("[style]")] + .map((e) => e.getAttribute("style") ?? "") + .join("\n"); + const allStyle = styleText + "\n" + elemStyle; const urlRegex = /url\s*\(\s*["']?([^)"'\s]+)["']?\s*\)/gi; const bgUrls = new Set(); - // Also scan inline styles on elements - const allElems = [...document.querySelectorAll("[style]")]; - const allStyleText = [ - ...styleNodes.map(s => s.textContent ?? ""), - ...allElems.map(e => e.getAttribute("style") ?? ""), - ].join("\n"); let m: RegExpExecArray | null; - while ((m = urlRegex.exec(allStyleText)) !== null) { - const u = m[1]; - if (!u.startsWith("data:") && !u.startsWith("#")) { - bgUrls.add(u); - } + while ((m = urlRegex.exec(allStyle)) !== null) { + if (!m[1].startsWith("data:") && !m[1].startsWith("#")) bgUrls.add(m[1]); } const bgResults = await Promise.allSettled( [...bgUrls].map(async (url) => { - if (!/\.(png|jpe?g|gif|svg|webp|ico)/i.test(url)) { - throw new Error("not an image"); - } + if (!/\.(png|jpe?g|gif|svg|webp|ico)/i.test(url)) throw new Error("not image"); const res = await fetch(url); if (!res.ok) throw new Error("fetch failed"); - const blob = await res.blob(); - return { url, dataUri: await blobToDataUri(blob) }; - }) + return { url, dataUri: await blobToDataUri(await res.blob()) }; + }), ); result.bgImages = bgResults - .filter((r): r is PromiseFulfilledResult<{ url: string; dataUri: string }> => - r.status === "fulfilled" + .filter( + (r): r is PromiseFulfilledResult<{ url: string; dataUri: string }> => + r.status === "fulfilled", ) - .map(r => r.value); - - // ── Favicons ── - const icons = [ - ...document.querySelectorAll( - 'link[rel*="icon"]' - ), - ]; + .map((r) => r.value); + // Favicons + const icons = [...document.querySelectorAll('link[rel*="icon"]')]; const iconResults = await Promise.allSettled( icons.map(async (link) => { - const href = link.href; - if (href.startsWith("data:")) throw new Error("already inline"); - const res = await fetch(href); + if (link.href.startsWith("data:")) throw new Error("skip"); + const res = await fetch(link.href); if (!res.ok) throw new Error("fetch failed"); - const blob = await res.blob(); - return { href, dataUri: await blobToDataUri(blob) }; - }) + return { href: link.href, dataUri: await blobToDataUri(await res.blob()) }; + }), ); result.favicons = iconResults - .filter((r): r is PromiseFulfilledResult<{ href: string; dataUri: string }> => - r.status === "fulfilled" + .filter( + (r): r is PromiseFulfilledResult<{ href: string; dataUri: string }> => + r.status === "fulfilled", ) - .map(r => r.value); - + .map((r) => r.value); return result; } - return collect(); }); } catch { - // evaluate() may fail if page context is gone — return html as-is return html; } - // ── Apply inlining on Node side (fast string replacement) ── - - // CSS for (const css of payload.css) { const escaped = css.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); html = html.replace( new RegExp(`]*\\bhref\\s*=\\s*"${escaped}"[^>]*>`, "gi"), - `` + ``, ); } - - // Images for (const img of payload.images) { const escaped = img.src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); html = html.replace( new RegExp(`(]*\\bsrc\\s*=\\s*)"${escaped}"`, "gi"), - `$1"${img.dataUri}"` + `$1"${img.dataUri}"`, ); } - - // Background images for (const bg of payload.bgImages) { const escaped = bg.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); html = html.replace( new RegExp(`url\\s*\\(\\s*["']?${escaped}["']?\\s*\\)`, "gi"), - `url(${bg.dataUri})` + `url(${bg.dataUri})`, ); } - - // Favicons for (const fav of payload.favicons) { const escaped = fav.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); html = html.replace( new RegExp(`(]*\\bhref\\s*=\\s*)"${escaped}"`, "gi"), - `$1"${fav.dataUri}"` + `$1"${fav.dataUri}"`, ); } - - // Strip resource hints that need external network html = html.replace( /]*\brel\s*=\s*"(?:preload|prefetch|preconnect|dns-prefetch|modulepreload)"[^>]*>/gi, - "" + "", ); - - // Remove attributes that break offline rendering html = html.replace(/\s+crossorigin\s*=\s*"[^"]*"/gi, ""); html = html.replace(/\s+integrity\s*=\s*"[^"]*"/gi, ""); - return html; } -async function renderSSR(url: string): Promise { +async function renderSSR( + url: string, + viewportWidth = 1920, + viewportHeight = 1080, +): Promise { const page = await pagePool.acquire(); try { - await page.goto(url, { - waitUntil: "networkidle0", - timeout: CONFIG.RENDER_TIMEOUT_MS, - }); + await page.setViewport({ width: viewportWidth, height: viewportHeight }); + await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS }); let html = await page.content(); html = await inlineAllResources(page, html); html = stripScriptTags(html); @@ -700,19 +785,19 @@ async function renderSSR(url: string): Promise { } // ═══════════════════════════════════════════════════════════════ -// SCREENSHOT (MJPEG replacement) RENDERING +// SCREENSHOT RENDERING // ═══════════════════════════════════════════════════════════════ -async function renderScreenshotFrame(url: string): Promise { +async function renderScreenshotFrame( + url: string, + viewportWidth = 1920, + viewportHeight = 1080, +): Promise { const page = await pagePool.acquire(); try { - await page.goto(url, { - waitUntil: "networkidle0", - timeout: CONFIG.RENDER_TIMEOUT_MS, - }); - return Buffer.from( - await page.screenshot({ type: "jpeg", quality: 75, fullPage: false }) - ); + await page.setViewport({ width: viewportWidth, height: viewportHeight }); + await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS }); + return Buffer.from(await page.screenshot({ type: "jpeg", quality: 75, fullPage: false })); } finally { await pagePool.release(page); } @@ -738,10 +823,10 @@ function screenshotHTML(jpegBase64: string, refreshSec: number): string { } // ═══════════════════════════════════════════════════════════════ -// ERROR / OVERLOAD PAGES +// ERROR PAGE // ═══════════════════════════════════════════════════════════════ -function errorPage(message: string, status: number, retrySec = 10): string { +function errorPage(message: string, retrySec = 10): string { return ` Error @@ -776,22 +861,17 @@ async function serveView(view: View): Promise { body = await getOrRender(view, idx); } catch (err: any) { console.error(`[serve:${view.id}] Render failed: ${err.message}`); - return new Response(errorPage(`Failed: ${view.urls[idx]?.url}`, 502), { + return new Response(errorPage(`Failed: ${view.urls[idx]?.url}`), { status: 502, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } - // Build final HTML from cached body let html: string; if (view.method === "mjpeg") { - // body = base64 JPEG html = screenshotHTML(body, getRemainingSec(view)); } else { - // body = raw SSR HTML - html = view.metaRefreshEnabled - ? injectMetaRefresh(body, getRemainingSec(view)) - : body; + html = view.metaRefreshEnabled ? injectMetaRefresh(body, getRemainingSec(view)) : body; } return new Response(html, { @@ -814,16 +894,54 @@ function json(data: unknown, status = 200): Response { } // ═══════════════════════════════════════════════════════════════ -// API HANDLERS +// API // ═══════════════════════════════════════════════════════════════ async function handleAPI(req: Request, path: string): Promise { const method = req.method; + // GET /api/views if (path === "/api/views" && method === "GET") { return json(viewCacheAll()); } + // GET /api/debug + if (path === "/api/debug" && method === "GET") { + const viewsDebug = viewCacheAll().map((v) => { + const idx = getCurrentURLIndex(v); + return { + id: v.id, + name: v.name, + urls: v.urls, + method: v.method, + metaRefreshEnabled: v.metaRefreshEnabled, + cacheTtlSec: v.cacheTtlSec, + viewportWidth: v.viewportWidth, + viewportHeight: v.viewportHeight, + currentUrlIndex: idx, + currentUrl: v.urls[idx]?.url ?? "", + remainingSec: getRemainingSec(v), + totalCycleSec: v.urls.reduce((s, u) => s + u.durationSec, 0), + cacheEntries: cacheStats().filter((c) => c.viewId === v.id), + }; + }); + + return json({ + server: { + uptime: Math.round(process.uptime()), + activeRenders: pagePool?.activeRenders ?? 0, + queueLength: renderQueue.length, + cacheEntries: renderCache.size, + browsers: CONFIG.BROWSER_COUNT, + pagesPerBrowser: CONFIG.PAGES_PER_BROWSER, + maxConcurrentRenders: CONFIG.MAX_CONCURRENT_RENDERS, + }, + views: viewsDebug, + devices: devicesSnapshot(), + }); + } + + // POST /api/views if (path === "/api/views" && method === "POST") { try { const body: CreateViewBody = JSON.parse(await req.text()); @@ -840,17 +958,20 @@ async function handleAPI(req: Request, path: string): Promise { const view: View = { id: crypto.randomUUID(), name: body.name.trim(), - urls: body.urls.map(u => ({ + urls: body.urls.map((u) => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), })), method: body.method, metaRefreshEnabled: body.metaRefreshEnabled ?? false, + cacheTtlSec: Math.max(0, body.cacheTtlSec ?? 60), + viewportWidth: Math.max(320, Math.min(7680, body.viewportWidth ?? 1920)), + viewportHeight: Math.max(240, Math.min(4320, body.viewportHeight ?? 1080)), createdAt: Math.floor(Date.now() / 1000), }; dbInsert(view); - viewCachePut(view); + viewCache.set(view.id, view); console.log(`[api] Created "${view.name}" (${view.id}) method=${view.method}`); return json(view, 201); } catch { @@ -858,6 +979,7 @@ async function handleAPI(req: Request, path: string): Promise { } } + // /api/views/:id const viewMatch = path.match(/^\/api\/views\/([a-f0-9-]+)$/); if (viewMatch) { const viewId = viewMatch[1]; @@ -877,7 +999,7 @@ async function handleAPI(req: Request, path: string): Promise { if (body.name !== undefined) view.name = body.name.trim(); if (body.urls !== undefined) { if (body.urls.length === 0) return json({ error: "At least one URL is required" }, 400); - view.urls = body.urls.map(u => ({ + view.urls = body.urls.map((u) => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), })); @@ -887,11 +1009,17 @@ async function handleAPI(req: Request, path: string): Promise { return json({ error: 'method must be "mjpeg" or "ssr"' }, 400); view.method = body.method; } - if (body.metaRefreshEnabled !== undefined) view.metaRefreshEnabled = body.metaRefreshEnabled; + if (body.metaRefreshEnabled !== undefined) + view.metaRefreshEnabled = body.metaRefreshEnabled; + if (body.cacheTtlSec !== undefined) view.cacheTtlSec = Math.max(0, body.cacheTtlSec); + if (body.viewportWidth !== undefined) + view.viewportWidth = Math.max(320, Math.min(7680, body.viewportWidth)); + if (body.viewportHeight !== undefined) + view.viewportHeight = Math.max(240, Math.min(4320, body.viewportHeight)); dbUpdate(view); - renderCacheInvalidate(viewId); - viewCachePut(view); + cacheInvalidate(viewId); + viewCache.set(view.id, view); console.log(`[api] Updated "${view.name}" (${view.id})`); return json(view); } catch { @@ -901,7 +1029,8 @@ async function handleAPI(req: Request, path: string): Promise { if (method === "DELETE") { if (!dbDelete(viewId)) return json({ error: "View not found" }, 404); - viewCacheDelete(viewId); + cacheInvalidate(viewId); + viewCache.delete(viewId); console.log(`[api] Deleted ${viewId}`); return json({ ok: true }); } @@ -911,7 +1040,7 @@ async function handleAPI(req: Request, path: string): Promise { } // ═══════════════════════════════════════════════════════════════ -// STATIC FILE HELPERS +// STATIC // ═══════════════════════════════════════════════════════════════ const STATIC_DIR = new URL("../public", import.meta.url).pathname; @@ -920,7 +1049,7 @@ async function serveStatic(path: string): Promise { const filePath = path === "/" ? "/index.html" : path; try { const file = Bun.file(STATIC_DIR + filePath); - await file.slice(0, 0).text(); // probe + await file.slice(0, 0).text(); return new Response(file); } catch { try { @@ -932,7 +1061,7 @@ async function serveStatic(path: string): Promise { } // ═══════════════════════════════════════════════════════════════ -// SERVER BOOTSTRAP +// SERVER // ═══════════════════════════════════════════════════════════════ const server = Bun.serve({ @@ -942,37 +1071,33 @@ const server = Bun.serve({ const url = new URL(req.url); const path = url.pathname; - // Rate limit - const ip = server.requestIP(req)?.address ?? "unknown"; - const isLocalhost = ip === "127.0.0.1" || ip === "::1" || ip === "localhost"; - if (!isLocalhost && !rateLimitCheck(ip)) { - return new Response("Too Many Requests", { status: 429 }); - } - - // API - if (path.startsWith("/api/")) { - return handleAPI(req, path); - } - - // View serving + // View serving — /v/:id const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/); if (viewMatch) { const view = viewCacheGet(viewMatch[1]); + const ip = server.requestIP(req)?.address ?? "unknown"; if (!view) { - return new Response(errorPage("View not found", 404), { + return new Response(errorPage("View not found"), { status: 404, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } if (view.urls.length === 0) { - return new Response(errorPage("No URLs configured", 500), { + return new Response(errorPage("No URLs configured"), { status: 500, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } + + deviceTrack(ip, view.id, view.name, req.headers.get("User-Agent") ?? "unknown"); return serveView(view); } + // API + if (path.startsWith("/api/")) { + return handleAPI(req, path); + } + // Health if (path === "/health") { return json({ @@ -981,8 +1106,7 @@ const server = Bun.serve({ views: viewCacheAll().length, cacheEntries: renderCache.size, activeRenders: pagePool?.activeRenders ?? 0, - browsers: CONFIG.BROWSER_COUNT, - pagesPerBrowser: CONFIG.PAGES_PER_BROWSER, + queueLength: renderQueue.length, }); } @@ -1012,22 +1136,17 @@ async function init() { console.log(` http://localhost:${CONFIG.PORT}`); console.log(` Views: http://localhost:${CONFIG.PORT}/v/`); console.log(` Health: http://localhost:${CONFIG.PORT}/health`); + console.log(` Debug: http://localhost:${CONFIG.PORT}/api/debug`); console.log(` Browsers: ${CONFIG.BROWSER_COUNT} × ${CONFIG.PAGES_PER_BROWSER} pages`); - console.log(` Cache: ${CONFIG.STATIC_CACHE_TTL_SEC}s static / rotation-boundary dynamic\n`); + console.log(` Cache TTL cleanup: ${CONFIG.CACHE_CLEANUP_INTERVAL_MS / 1000}s intervals\n`); } async function shutdown() { console.log("\n[shutdown] Closing…"); - // Cancel all prewarm timers - for (const entry of renderCache.values()) { - if (entry.prewarmTimer) clearTimeout(entry.prewarmTimer); - } - - // Wait for in-flight renders if (inFlightRenders.size > 0) { console.log(`[shutdown] Waiting for ${inFlightRenders.size} in-flight render(s)…`); - await Promise.allSettled([...inFlightRenders.values()].map(r => r.promise)); + await Promise.allSettled([...inFlightRenders.values()].map((r) => r.promise)); } await pagePool.shutdown(); @@ -1038,4 +1157,4 @@ async function shutdown() { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); -init(); \ No newline at end of file +init(); diff --git a/views.db b/views.db index 6f69db196f2ab23bc339fba5ce69dc9a2accebbf..fb45d9e5cfb04d5cc334934d208a13fcd80a2bda 100644 GIT binary patch delta 557 zcmZojXh@hK&B!@X##xY)L9h20FaHk)Cf=P4ygT{NZY=cVf-}ZSXz`* zOhBQ6%VZ0F9x1T7xv3=?`RMML?9DF=mrjf?N=++DEzXEf%}Y$mNlj6hT+1&5kxoub z&Pa_fDana1PECf{3Y3FdPWIE z5k!rF1yoIPQEFmIYD#<}R2r-Zq-OFyeqJUng~_M+E!i0P;~4nkHVZ0*@LM*@GjfMY zHoA&)GBPo-7^a#TB`2pD=o%R&8tR&u8Kmka8K#)%rX^Zh8m1bUrKB2Lgt!KWC>usk zJ}0j+`K-JkS4wGeHVCKZPY#rqkjTv{NKI#AZ(y;=V&T2X#lXP8$bW-@{|3;ydHfR_ N3^df4Lph1D2LK7iVmao_v^B zMNc6!MIppBB19n|$kW#`C{n@OHBv_*FEKY2BJAfMqTm