import { Database } from "bun:sqlite"; import type { Browser, Page } from "puppeteer"; import puppeteer from "puppeteer"; // ═══════════════════════════════════════════════════════════════ // CONFIG // ═══════════════════════════════════════════════════════════════ const CONFIG = { PORT: parseInt(process.env.PORT || "3099"), BROWSER_COUNT: 2, PAGES_PER_BROWSER: 8, PAGE_POOL_WARM: 4, PAGE_ACQUIRE_TIMEOUT_MS: 3000, RENDER_TIMEOUT_MS: 25000, MAX_CONCURRENT_RENDERS: 16, QUEUE_TIMEOUT_MS: 30000, CACHE_CLEANUP_INTERVAL_MS: 30000, WARMUP_INTERVAL_MS: 60000, } as const; // ═══════════════════════════════════════════════════════════════ // TYPES // ═══════════════════════════════════════════════════════════════ interface URLItem { url: string; durationSec: number; forceDarkMode?: boolean; bakeStyles?: boolean; } interface View { id: string; name: string; 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; cache_ttl_sec: number; viewport_width: number; viewport_height: number; created_at: number; } interface CreateViewBody { name: string; urls: URLItem[]; method: "mjpeg" | "ssr"; metaRefreshEnabled?: boolean; cacheTtlSec?: number; viewportWidth?: number; viewportHeight?: number; } type UpdateViewBody = Partial; interface CachedRender { body: string; urlIndex: number; createdAt: number; expiresAt: number; } interface ActiveRender { promise: Promise; 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 // ═══════════════════════════════════════════════════════════════ const db = new Database("/app/data/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, cache_ttl_sec INTEGER NOT NULL DEFAULT 60, viewport_width INTEGER NOT NULL DEFAULT 1920, viewport_height INTEGER NOT NULL DEFAULT 1080, bake_styles INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()) ) `); db.run(` CREATE TABLE IF NOT EXISTS banned_ips ( ip TEXT PRIMARY KEY, banned_at INTEGER NOT NULL DEFAULT (unixepoch()), reason TEXT, banned_by TEXT ) `); // 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 {} try { db.run("ALTER TABLE views ADD COLUMN bake_styles INTEGER NOT NULL DEFAULT 0"); } catch {} function rowToView(row: ViewRow): View { return { 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, }; } // Seed a default view on first run (only when table is empty) const viewCount = (db.query("SELECT COUNT(*) as n FROM views").get() as { n: number }).n; if (viewCount === 0) { db.run( `INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at) VALUES (?,?,?,?,?,?,?,?,?)`, [ crypto.randomUUID(), "Demo View", JSON.stringify([ { url: "https://www.google.com", durationSec: 30, forceDarkMode: false, bakeStyles: false }, { url: "https://duck.ai", durationSec: 30, forceDarkMode: false, bakeStyles: false }, { url: "https://www.msn.com", durationSec: 30, forceDarkMode: false, bakeStyles: false }, ]), "ssr", 0, 60, 1920, 1080, Math.floor(Date.now() / 1000), ], ); } function dbLoadAll(): View[] { 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; return row ? rowToView(row) : null; } function dbInsert(v: View): void { 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=?,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 { return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0; } function dbBanIP(ip: string, reason?: string): void { db.run( "INSERT OR REPLACE INTO banned_ips (ip, banned_at, reason, banned_by) VALUES (?, ?, ?, ?)", [ip, Math.floor(Date.now() / 1000), reason ?? "Manual ban", "admin"], ); } function dbUnbanIP(ip: string): boolean { return db.run("DELETE FROM banned_ips WHERE ip = ?", [ip]).changes > 0; } function dbIsBanned(ip: string): boolean { return db.query("SELECT ip FROM banned_ips WHERE ip = ?").get(ip) != null; } function dbGetBannedIPs(): Array<{ ip: string; banned_at: number; reason: string; banned_by: string; }> { return db.query("SELECT * FROM banned_ips ORDER BY banned_at DESC").all() as Array<{ ip: string; banned_at: number; reason: string; banned_by: string; }>; } // ═══════════════════════════════════════════════════════════════ // IN-MEMORY VIEW CACHE // ═══════════════════════════════════════════════════════════════ const viewCache = new Map(); function viewCacheInit(): void { for (const v of dbLoadAll()) viewCache.set(v.id, v); } function viewCacheGet(id: string): View | null { return viewCache.get(id) ?? null; } function viewCacheAll(): View[] { return [...viewCache.values()].sort((a, b) => b.createdAt - a.createdAt); } // ═══════════════════════════════════════════════════════════════ // DEVICE TRACKING // ═══════════════════════════════════════════════════════════════ const devices = new Map(); const warmedUpViews = new Set(); const DEVICE_TTL_MS = 120_000; function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): boolean { // Check if IP is banned if (dbIsBanned(ip)) { console.warn(`[security] Blocked connection from banned IP: ${ip}`); return false; } 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 }); // 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); }); } } return true; } 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); } // ═══════════════════════════════════════════════════════════════ // DEBUG DATA HELPER (used by both REST and WebSocket) // ═══════════════════════════════════════════════════════════════ function getDebugData() { 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 { 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(), }; } // ═══════════════════════════════════════════════════════════════ // BROWSER + PAGE POOL // ═══════════════════════════════════════════════════════════════ interface BrowserSlot { browser: Browser; available: Page[]; inUse: number; max: number; } class PagePool { private slots: BrowserSlot[] = []; private pageToSlot = new WeakMap(); private waiters: Array<{ resolve: (p: Page) => void; reject: (e: Error) => void; timeout: ReturnType; }> = []; async init(): Promise { for (let i = 0; i < CONFIG.BROWSER_COUNT; i++) { const browser = await puppeteer.launch({ executablePath: "/usr/bin/chromium", headless: true, args: [ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--disable-software-rasterizer", "--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 }; 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`, ); } private async createPage(slot: BrowserSlot): Promise { const page = await slot.browser.newPage(); await page.setViewport({ width: 1920, height: 1080 }); await page.setRequestInterception(true); page.on("request", (req) => { const t = req.resourceType(); if (t === "media" || t === "websocket" || t === "manifest" || t === "font") { req.abort(); } else { req.continue(); } }); this.pageToSlot.set(page, slot); return page; } async resetPage(page: Page): Promise { try { await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 }); await page.emulateMediaFeatures([]); // Clear storage via CDP — avoids opaque-origin SecurityError const client = await page.createCDPSession(); await client.send("Storage.clearDataForOrigin", { origin: "*", storageTypes: "all", }); await client.send("Network.clearBrowserCookies"); await client.detach(); return true; } catch (err) { console.warn(`[page-pool] resetPage failed, discarding page: ${err}`); try { await page.close(); } catch {} return false; } } acquire(): Promise { for (const slot of this.slots) { if (slot.available.length > 0) { slot.inUse++; return Promise.resolve(slot.available.pop()!); } if (slot.inUse < slot.max) { slot.inUse++; return this.createPage(slot); } } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { 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; } slot.inUse--; 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); slot.inUse++; w.resolve(page); return; } if (slot.available.length + slot.inUse < slot.max) { slot.available.push(page); } else { await page.close().catch(() => {}); this.pageToSlot.delete(page); } } get activeRenders(): number { return this.slots.reduce((s, slot) => s + slot.inUse, 0); } async shutdown(): Promise { for (const slot of this.slots) { for (const p of slot.available) await p.close().catch(() => {}); await slot.browser.close().catch(() => {}); } this.slots = []; } } let pagePool: PagePool; // ═══════════════════════════════════════════════════════════════ // WEBSOCKET DEBUG CLIENTS // ═══════════════════════════════════════════════════════════════ const debugClients = new Map }>(); const DEBUG_DEFAULT_INTERVAL_MS = 1000; const DEBUG_MIN_INTERVAL_MS = 100; // ═══════════════════════════════════════════════════════════════ // 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 cachePurgeExpired(): void { const now = Date.now(); for (const [key, entry] of renderCache) { if (now >= entry.expiresAt) renderCache.delete(key); } } 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()) { 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); } } // Allow re-warming if view is recreated warmedUpViews.delete(viewId); } 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) { 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) => { inFlightRenders.delete(next.key); next.resolve(body); }, (err) => { inFlightRenders.delete(next.key); next.reject(err); }, ); } } async function renderViewToCache(view: View, urlIndex: number, key: string): Promise { const urlItem = view.urls[urlIndex]; const forceDarkMode = urlItem.forceDarkMode ?? false; const bakeStyles = urlItem.bakeStyles ?? false; let body: string; if (view.method === "mjpeg") { const jpeg = await renderScreenshotFrame( urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode, ); body = jpeg.toString("base64"); } else { body = await renderSSR( urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode, bakeStyles, ); } const now = Date.now(); const entry: CachedRender = { body, urlIndex, createdAt: now, expiresAt: now + view.cacheTtlSec * 1000, }; renderCache.set(key, entry); drainRenderQueue(); return body; } async function getOrRender(view: View, urlIndex: number): Promise { const key = renderCacheKey(view.id, urlIndex); const now = Date.now(); const cached = renderCache.get(key); if (cached && now < cached.expiresAt) { return cached.body; } const inFlight = inFlightRenders.get(key); if (inFlight) { return inFlight.promise; } 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); } } 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] = 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 // ═══════════════════════════════════════════════════════════════ function getCurrentURLIndex(view: View): number { if (view.urls.length <= 1) return 0; const totalCycle = view.urls.reduce((s, u) => s + u.durationSec, 0); if (totalCycle <= 0) return 0; const elapsed = Math.floor(Date.now() / 1000) % totalCycle; let acc = 0; for (let i = 0; i < view.urls.length; i++) { acc += view.urls[i].durationSec; if (elapsed < acc) return i; } return view.urls.length - 1; } function getRemainingSec(view: View): number { if (view.urls.length <= 1) { return view.urls[0]?.durationSec || 30; } const totalCycle = view.urls.reduce((s, u) => s + u.durationSec, 0); if (totalCycle <= 0) return 30; const elapsed = Math.floor(Date.now() / 1000) % totalCycle; let acc = 0; for (let i = 0; i < view.urls.length; i++) { acc += view.urls[i].durationSec; if (elapsed < acc) return acc - elapsed; } return view.urls[0].durationSec; } // ═══════════════════════════════════════════════════════════════ // SINGLE-PASS INLINING (SSR mode) // ═══════════════════════════════════════════════════════════════ function injectMetaRefresh(html: string, intervalSec: number): string { const sec = Math.max(1, Math.ceil(intervalSec)); const tag = ``; if (html.includes("")) { return html.replace("", ` ${tag}\n`); } if (/]*>/i.test(html)) { return html.replace(/(]*>)/i, `$1\n${tag}`); } return `${tag}${html}`; } function stripScriptTags(html: string): string { return html .replace(/)<[^<]*)*<\/script>/gi, "") .replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "") .replace(/\s+on\w+\s*=\s*'[^']*'/gi, ""); } // ── Recursive @import resolver (max 3 levels deep) ── async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): Promise { if (depth > 3) return cssText; const importRe = /@import\s+(?:url\s*\(\s*["']?([^)"']+)["']?\s*\)|["']([^"']+)["'])\s*([^;]*);/gi; const replacements: Array<{ match: string; resolved: string }> = []; let m: RegExpExecArray | null; while ((m = importRe.exec(cssText)) !== null) { const importUrl = new URL(m[1] || m[2], baseUrl).href; // Skip font services — font files are blocked by request interception if (/fonts\.googleapis\.com|fonts\.gstatic\.com/.test(importUrl)) { replacements.push({ match: m[0], resolved: "/* removed font import: " + importUrl + " */" }); continue; } try { const res = await fetch(importUrl); if (!res.ok) throw new Error("failed"); let imported = await res.text(); imported = await resolveCSSImports(imported, importUrl, depth + 1); replacements.push({ match: m[0], resolved: "/* begin " + importUrl + " */\n" + imported + "\n/* end " + importUrl + " */", }); } catch { replacements.push({ match: m[0], resolved: "/* unresolved import: " + importUrl + " */" }); } } for (const r of replacements) { cssText = cssText.replace(r.match, r.resolved); } return cssText; } // ═══════════════════════════════════════════════════════════════ // COMPUTED STYLE BAKING // ═══════════════════════════════════════════════════════════════ /** * Bake computed styles into inline style attributes. * This ensures styles work on browsers with limited CSS support (e.g., Samsung TV). * Compares each element against a same-tagname fresh element so tag-specific defaults * (e.g. block display on div, inline on span, bold on h1) are accounted for. */ async function bakeComputedStyles(page: Page): Promise { await page.evaluate((): void => { const computedProperties = [ // Layout "display", "position", "top", "right", "bottom", "left", "width", "height", "min-width", "min-height", "max-width", "max-height", "margin", "margin-top", "margin-right", "margin-bottom", "margin-left", "padding", "padding-top", "padding-right", "padding-bottom", "padding-left", // Flex/Grid "flex", "flex-direction", "flex-wrap", "flex-flow", "flex-grow", "flex-shrink", "flex-basis", "justify-content", "align-items", "align-content", "align-self", "gap", "row-gap", "column-gap", "grid", "grid-template", "grid-template-columns", "grid-template-rows", "grid-area", // Box model "box-sizing", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width", "border-top-style", "border-right-style", "border-bottom-style", "border-left-style", "border-top-color", "border-right-color", "border-bottom-color", "border-left-color", "border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius", // Colors & Background "color", "background-color", "background-image", "background-size", "background-position", "background-repeat", "background-attachment", "background-clip", "background-origin", // Typography "font-family", "font-size", "font-weight", "font-style", "font-variant", "line-height", "text-align", "text-decoration-line", "text-decoration-color", "text-transform", "letter-spacing", "word-spacing", "white-space", "overflow-wrap", "word-break", // Visual "opacity", "visibility", "z-index", "overflow-x", "overflow-y", "box-shadow", "text-shadow", "transform", "transform-origin", // List/Table "list-style-type", "list-style-position", "border-collapse", "border-spacing", // Cursor/pointer "cursor", "pointer-events", ]; // Cache of default computed values keyed by tagName const defaultCache = new Map>(); const hiddenContainer = document.createElement("div"); hiddenContainer.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;top:-9999px;"; document.body.appendChild(hiddenContainer); function getTagDefaults(tag: string): Map { let cache = defaultCache.get(tag); if (cache) return cache; cache = new Map(); try { const el = document.createElement(tag); hiddenContainer.appendChild(el); const cs = window.getComputedStyle(el); for (const prop of computedProperties) { cache.set(prop, cs.getPropertyValue(prop)); } el.remove(); } catch { /* unknown tag, cache will be empty */ } defaultCache.set(tag, cache); return cache; } function shouldBake(value: string, defaultValue: string): boolean { if (!value) return false; // Skip browser keywords that cannot be serialized as inline style values if (value === "initial" || value === "inherit" || value === "unset" || value === "revert") return false; return value !== defaultValue; } // Walk all elements and bake computed styles const elements = document.querySelectorAll("*"); for (const el of elements) { if (el === hiddenContainer) continue; // Skip non-visual tags const tag = el.tagName.toLowerCase(); if (["script", "style", "meta", "link", "noscript", "template", "head", "html"].includes(tag)) continue; const computed = window.getComputedStyle(el); const defaults = getTagDefaults(tag); const stylesToBake: string[] = []; for (const prop of computedProperties) { const value = computed.getPropertyValue(prop); const defaultValue = defaults.get(prop) ?? ""; if (shouldBake(value, defaultValue)) { stylesToBake.push(`${prop}:${value}`); } } if (stylesToBake.length > 0) { // Existing inline styles take precedence — put them after baked styles const existingInline = el.getAttribute("style") || ""; const bakedStyles = stylesToBake.join(";"); el.setAttribute("style", existingInline ? `${bakedStyles};${existingInline}` : bakedStyles); } } hiddenContainer.remove(); }); } async function inlineAllResources(page: Page, html: string): Promise { 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 => { function blobToDataUri(blob: Blob): Promise { return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.readAsDataURL(blob); }); } async function collect(): Promise { 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", ) .map((r) => r.value); // Images const imgs = [ ...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"); 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", ) .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(); let m: RegExpExecArray | null; 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 image"); const res = await fetch(url); if (!res.ok) throw new Error("fetch failed"); return { url, dataUri: await blobToDataUri(await res.blob()) }; }), ); result.bgImages = bgResults .filter( (r): r is PromiseFulfilledResult<{ url: string; dataUri: string }> => r.status === "fulfilled", ) .map((r) => r.value); // Favicons const icons = [...document.querySelectorAll('link[rel*="icon"]')]; const iconResults = await Promise.allSettled( icons.map(async (link) => { if (link.href.startsWith("data:")) throw new Error("skip"); const res = await fetch(link.href); if (!res.ok) throw new Error("fetch failed"); 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", ) .map((r) => r.value); return result; } return collect(); }); } catch { return html; } for (const css of payload.css) { const escaped = css.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // Match both double and single-quoted href const linkRegex = new RegExp(`]*\\bhref\\s*=\\s*["']${escaped}["'][^>]*>`, "gi"); const linkMatch = html.match(linkRegex); // Preserve media attribute if present let mediaAttr = ""; if (linkMatch) { const m2 = linkMatch[0].match(/\bmedia\s*=\s*["']([^"']*)["']/i); if (m2 && m2[1]) mediaAttr = ` media="${m2[1]}"`; } // Resolve @import rules recursively const resolvedCSS = await resolveCSSImports(css.text, css.url); html = html.replace( linkRegex, `/* inlined: ${css.url} */\n${resolvedCSS}\n`, ); } 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}"`, ); } 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})`, ); } 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}"`, ); } html = html.replace( /]*\brel\s*=\s*"(?:preload|prefetch|preconnect|dns-prefetch|modulepreload)"[^>]*>/gi, "", ); html = html.replace(/\s+crossorigin\s*=\s*"[^"]*"/gi, ""); html = html.replace(/\s+integrity\s*=\s*"[^"]*"/gi, ""); return html; } async function renderSSR( url: string, viewportWidth = 1920, viewportHeight = 1080, forceDarkMode = false, bakeStyles = false, ): Promise { const page = await pagePool.acquire(); try { await page.setViewport({ width: viewportWidth, height: viewportHeight }); if (forceDarkMode) { await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]); } // Try networkidle0 first; fall back to whatever we have on timeout try { await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS }); } catch (err: any) { if (err.message?.includes("timeout")) { console.warn(`[renderSSR] networkidle0 timeout for ${url}, using current content`); } else { throw err; } } let html = await page.content(); html = await inlineAllResources(page, html); // Bake computed styles into inline attributes (for Samsung TV compatibility) if (bakeStyles) { await bakeComputedStyles(page); html = await page.content(); } html = stripScriptTags(html); return html; } finally { await pagePool.release(page); } } // ═══════════════════════════════════════════════════════════════ // SCREENSHOT RENDERING // ═══════════════════════════════════════════════════════════════ async function renderScreenshotFrame( url: string, viewportWidth = 1920, viewportHeight = 1080, forceDarkMode = false, ): Promise { const page = await pagePool.acquire(); try { await page.setViewport({ width: viewportWidth, height: viewportHeight }); if (forceDarkMode) { await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]); } try { await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS }); } catch (err: any) { if (err.message?.includes("timeout")) { console.warn(`[renderScreenshot] networkidle0 timeout for ${url}, using current content`); } else { throw err; } } return Buffer.from(await page.screenshot({ type: "jpeg", quality: 75, fullPage: false })); } finally { await pagePool.release(page); } } function screenshotHTML(jpegBase64: string, refreshSec: number): string { const sec = Math.max(2, Math.ceil(refreshSec)); return ` `; } // ═══════════════════════════════════════════════════════════════ // ERROR PAGE // ═══════════════════════════════════════════════════════════════ function errorPage(message: string, retrySec = 10): string { return ` Error

${message}

Samsung TV Proxy — retrying in ${retrySec}s

`; } // ═══════════════════════════════════════════════════════════════ // VIEW SERVING — /v/:id // ═══════════════════════════════════════════════════════════════ async function serveView(view: View): Promise { const idx = getCurrentURLIndex(view); let body: string; try { 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}`), { status: 502, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } let html: string; if (view.method === "mjpeg") { html = screenshotHTML(body, getRemainingSec(view)); } else { html = view.metaRefreshEnabled ? injectMetaRefresh(body, getRemainingSec(view)) : body; } return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate", }, }); } // ═══════════════════════════════════════════════════════════════ // HTTP HELPERS // ═══════════════════════════════════════════════════════════════ function json(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" }, }); } // ═══════════════════════════════════════════════════════════════ // 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") { return json(getDebugData()); } // GET /api/banned if (path === "/api/banned" && method === "GET") { return json(dbGetBannedIPs()); } // POST /api/views if (path === "/api/views" && method === "POST") { try { const body: CreateViewBody = JSON.parse(await req.text()); if (!body.name?.trim()) return json({ error: "Name is required" }, 400); if (!body.urls?.length) return json({ error: "At least one URL is required" }, 400); for (const u of body.urls) { if (!u.url?.trim()) return json({ error: "Each URL must have a url" }, 400); if (typeof u.durationSec !== "number" || u.durationSec <= 0) return json({ error: "Each URL must have positive durationSec" }, 400); } if (!["mjpeg", "ssr"].includes(body.method)) return json({ error: 'method must be "mjpeg" or "ssr"' }, 400); const view: View = { id: crypto.randomUUID(), name: body.name.trim(), urls: body.urls.map((u) => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), forceDarkMode: u.forceDarkMode ?? false, bakeStyles: u.bakeStyles ?? false, })), 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); viewCache.set(view.id, view); console.log(`[api] Created "${view.name}" (${view.id}) method=${view.method}`); return json(view, 201); } catch { return json({ error: "Invalid JSON body" }, 400); } } // /api/views/:id const viewMatch = path.match(/^\/api\/views\/([a-f0-9-]+)$/); if (viewMatch) { const viewId = viewMatch[1]; if (method === "GET") { const view = viewCacheGet(viewId); if (!view) return json({ error: "View not found" }, 404); return json(view); } if (method === "PUT") { const view = viewCacheGet(viewId); if (!view) return json({ error: "View not found" }, 404); try { const body: UpdateViewBody = JSON.parse(await req.text()); 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) => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), forceDarkMode: u.forceDarkMode ?? false, bakeStyles: u.bakeStyles ?? false, })); } if (body.method !== undefined) { if (!["mjpeg", "ssr"].includes(body.method)) return json({ error: 'method must be "mjpeg" or "ssr"' }, 400); view.method = body.method; } 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); cacheInvalidate(viewId); viewCache.set(view.id, view); console.log(`[api] Updated "${view.name}" (${view.id})`); return json(view); } catch { return json({ error: "Invalid JSON body" }, 400); } } if (method === "DELETE") { if (!dbDelete(viewId)) return json({ error: "View not found" }, 404); cacheInvalidate(viewId); viewCache.delete(viewId); console.log(`[api] Deleted ${viewId}`); return json({ ok: true }); } } // POST /api/admin/disconnect/:ip const disconnectMatch = path.match(/^\/api\/admin\/disconnect\/(.+)$/); if (disconnectMatch && method === "POST") { const ip = decodeURIComponent(disconnectMatch[1]); devices.delete(ip); console.log(`[admin] Disconnected device: ${ip}`); return json({ ok: true, message: `Device ${ip} disconnected` }); } // POST /api/admin/ban/:ip const banMatch = path.match(/^\/api\/admin\/ban\/(.+)$/); if (banMatch && method === "POST") { const ip = decodeURIComponent(banMatch[1]); try { const body = JSON.parse(await req.text()); const reason = body.reason || "Banned by administrator"; const bannedDevice = devices.get(ip); dbBanIP(ip, reason); devices.delete(ip); if (bannedDevice) cacheInvalidate(bannedDevice.viewId); console.log(`[admin] Banned IP: ${ip}, reason: ${reason}`); return json({ ok: true, message: `IP ${ip} banned` }); } catch { return json({ error: "Invalid JSON body" }, 400); } } // POST /api/admin/unban/:ip const unbanMatch = path.match(/^\/api\/admin\/unban\/(.+)$/); if (unbanMatch && method === "POST") { const ip = decodeURIComponent(unbanMatch[1]); const success = dbUnbanIP(ip); if (success) { console.log(`[admin] Unbanned IP: ${ip}`); return json({ ok: true, message: `IP ${ip} unbanned` }); } else { return json({ error: "IP not found in ban list" }, 404); } } // POST /api/admin/refresh/:ip const refreshMatch = path.match(/^\/api\/admin\/refresh\/(.+)$/); if (refreshMatch && method === "POST") { const ip = decodeURIComponent(refreshMatch[1]); const device = devices.get(ip); if (device) { cacheInvalidate(device.viewId); console.log(`[admin] Forced cache refresh for view: ${device.viewId}`); return json({ ok: true, message: `Cache refreshed for view ${device.viewId}` }); } else { return json({ error: "Device not found" }, 404); } } return json({ error: "Not found" }, 404); } // ═══════════════════════════════════════════════════════════════ // STATIC // ═══════════════════════════════════════════════════════════════ const STATIC_DIR = new URL("../public", import.meta.url).pathname; async function serveStatic(path: string): Promise { const filePath = path === "/" ? "/index.html" : path; if (!filePath.startsWith("/") || filePath.includes("..")) return null; try { const file = Bun.file(STATIC_DIR + filePath); await file.slice(0, 0).text(); return new Response(file); } catch { try { return new Response(Bun.file(STATIC_DIR + "/index.html")); } catch { return null; } } } // ═══════════════════════════════════════════════════════════════ // SERVER // ═══════════════════════════════════════════════════════════════ const server = Bun.serve<{ interval: number }>({ port: CONFIG.PORT, idleTimeout: (CONFIG.QUEUE_TIMEOUT_MS + 10_000) / 1000, // 40s — enough for worst-case queue wait websocket: { open(ws) { const interval = ws.data?.interval ?? DEBUG_DEFAULT_INTERVAL_MS; const timer = setInterval(() => { try { ws.send(JSON.stringify(getDebugData())); } catch { clearInterval(timer); debugClients.delete(ws); } }, interval); debugClients.set(ws, { interval, timer }); // Send initial payload immediately try { ws.send(JSON.stringify(getDebugData())); } catch {} }, message(ws, msg) { try { const data = JSON.parse(msg as string); if (data.type === "debug-interval" && typeof data.interval === "number") { const client = debugClients.get(ws); if (!client) return; const interval = Math.max(DEBUG_MIN_INTERVAL_MS, data.interval); clearInterval(client.timer); client.interval = interval; client.timer = setInterval(() => { try { ws.send(JSON.stringify(getDebugData())); } catch { clearInterval(client.timer); debugClients.delete(ws); } }, interval); // Push immediately after rate change try { ws.send(JSON.stringify(getDebugData())); } catch {} } } catch { /* ignore malformed messages */ } }, close(ws) { const client = debugClients.get(ws); if (client) { clearInterval(client.timer); debugClients.delete(ws); } }, }, async fetch(req) { const url = new URL(req.url); const path = url.pathname; // ── WebSocket upgrade for debug panel ── if (path === "/ws") { const qInterval = parseInt(url.searchParams.get("interval") ?? ""); const wsInterval = Math.max( DEBUG_MIN_INTERVAL_MS, Number.isFinite(qInterval) ? qInterval : DEBUG_DEFAULT_INTERVAL_MS, ); if (server.upgrade(req, { data: { interval: wsInterval } })) { return; // upgraded — Bun will call websocket.open } return new Response("WebSocket upgrade failed", { status: 500 }); } // 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"), { status: 404, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } if (view.urls.length === 0) { return new Response(errorPage("No URLs configured"), { status: 500, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } const tracked = deviceTrack( ip, view.id, view.name, req.headers.get("User-Agent") ?? "unknown", ); if (!tracked) { return new Response(errorPage("Access denied"), { status: 403, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } return serveView(view); } // API if (path.startsWith("/api/")) { return handleAPI(req, path); } // Health if (path === "/health") { return json({ status: "ok", uptime: process.uptime(), views: viewCacheAll().length, cacheEntries: renderCache.size, activeRenders: pagePool?.activeRenders ?? 0, queueLength: renderQueue.length, }); } // Static const staticResponse = await serveStatic(path); if (staticResponse) return staticResponse; return new Response("Not found", { status: 404 }); }, error(err) { console.error("[server]", err); return new Response("Internal Server Error", { status: 500 }); }, }); // ═══════════════════════════════════════════════════════════════ // INIT & SHUTDOWN // ═══════════════════════════════════════════════════════════════ async function init() { viewCacheInit(); pagePool = new PagePool(); await pagePool.init(); console.log(`\n🚀 Samsung TV Proxy Server`); 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 TTL cleanup: ${CONFIG.CACHE_CLEANUP_INTERVAL_MS / 1000}s intervals\n`); } async function shutdown() { console.log("\n[shutdown] Closing…"); 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 pagePool.shutdown(); server.stop(); process.exit(0); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); init();