/** * ╔══════════════════════════════════════════════════════════════════════╗ * ║ SAMSUNG TV PROXY — Bun + Puppeteer SSR Server ║ * ╚══════════════════════════════════════════════════════════════════════╝ * * Renders web pages to SSR HTML (with full CSS/resource inlining) or MJPEG * screenshots, served to Samsung TVs (Tizen browser) and other limited * clients. Multi-browser page-pool, render-cache, URL-rotation views, * WebSocket debug dashboard, IP ban management, and device tracking. * * ───────────────────────────────────────────────────────────────────────── * QUICK-SEARCH INDEX — jump by section name or semantic topic: * ───────────────────────────────────────────────────────────────────────── * * SEC | ~LINE | FLAG / TOPIC | WHAT IT DOES * ────┼───────┼───────────────────────────────────┼─────────────────────── * 01 | 35 | CONFIG | All tunables (pool, timeouts, intervals) * 02 | 57 | TYPES / interfaces | View, URLItem, CachedRender, DeviceEntry… * 03 | 115 | DATABASE / SQLite / schema | views + banned_ips tables, migrations * 04 | 158 | DB HELPERS / dbLoad / dbInsert | CRUD: dbLoadAll, dbInsert, dbUpdate, dbDelete * 05 | 202 | BAN SYSTEM / dbBanIP / dbIsBanned | IP ban persistence layer * 06 | 219 | VIEW CACHE / viewCache | In-memory mirror of DB views table * 07 | 230 | DEVICE TRACKING / devices | Per-IP tracking, ban gate, warmup trigger * 08 | 252 | DEBUG DATA / getDebugData | Aggregates server+views+devices for API/WS * 09 | 285 | PAGE POOL / PagePool / acquire | Multi-browser page lifecycle (acquire/release/reset) * 10 | 383 | WS DEBUG / debugClients | Real-time debug-over-WebSocket push * 11 | 393 | RENDER CACHE / renderCache | URL→HTML cache with TTL, inFlight tracking * 12 | 417 | WARMUP / warmupView / warmupCache | Pre-cache active-device views on interval * 13 | 461 | CACHE INVALIDATION / invalidate | Purge all cache entries for a view * 14 | 475 | RENDER QUEUE / renderQueue | FIFO queue with timeout; drains into PagePool * 15 | 506 | RENDER ENGINE / renderViewToCache | Core: SSR or MJPEG render → cache write * 16 | 549 | GET-OR-RENDER / getOrRender | Cache-hit → inFlight → queue → render decision tree * 17 | 590 | CACHE STATS / cacheStats | Telemetry: per-cache-entry TTL/URL info * 18 | 620 | ROTATION / getCurrentURLIndex | Time-based URL cycling (+ remainingSec) * 19 | 645 | SSR INLINING / injectMetaRefresh | Meta-refresh injection, script stripping * 20 | 663 | CSS IMPORT RESOLVER / @import | Recursive @import resolver (max 3 levels) * 21 | 694 | COMPUTED STYLE BAKING / bake | Inline computed styles for Tizen compat * 22 | 777 | RESOURCE INLINING / inlineAll | CSS/images/favicons/bg→data: URIs in-page * 23 | 887 | SSR RENDER / renderSSR | Full SSR pipeline: acquire→goto→inline→strip * 24 | 932 | SCREENSHOT / renderScreenshotFrame| JPEG screenshot renderer (MJPEG mode) * 25 | 961 | SCREENSHOT HTML / screenshotHTML | Wrap JPEG base64 in refresh-page HTML * 26 | 981 | ERROR PAGE / errorPage | Styled error HTML with auto-retry meta-refresh * 27 | 1002 | VIEW SERVING / serveView | /v/:id → rotation→render→response pipeline * 28 | 1032 | API / handleAPI | REST: CRUD views, ban/unban, disconnect, refresh * 29 | 1181 | STATIC FILES / serveStatic | Serves ../public (SPA fallback to index.html) * 30 | 1198 | HTTP SERVER / Bun.serve | Main fetch handler: /ws, /v/:id, /api, static * 31 | 1293 | INIT+SHUTDOWN / init / shutdown | Bootstrap + graceful drain + signal handlers * * ───────────────────────────────────────────────────────────────────────── * DATA-FLOW MAP (how a GET /v/:id request travels): * ───────────────────────────────────────────────────────────────────────── * * Client GET /v/:id * │ * ▼ * Bun.serve.fetch() [ SEC 30 ] * │ * ├─ deviceTrack(ip…) [ SEC 07 ] ← blocks banned IPs * │ * ▼ * serveView(view) [ SEC 27 ] * │ * ├─ getCurrentURLIndex(view) [ SEC 18 ] ← time-based rotation * │ * ▼ * getOrRender(view, idx) [ SEC 16 ] * │ * ├── cache HIT? ──▶ return cached body * ├── inFlight? ──▶ await existing promise * ├── pool free? ──▶ renderViewToCache() [ SEC 15 ] * │ │ * │ ├─ renderSSR() [ SEC 23 ] * │ │ ├─ pagePool.acquire() [ SEC 09 ] * │ │ ├─ page.goto(url) * │ │ ├─ inlineAllResources() [ SEC 22 ] * │ │ │ └─ resolveCSSImports() [ SEC 20 ] * │ │ ├─ [opt] bakeComputedStyles() [ SEC 21 ] * │ │ ├─ stripScriptTags() * │ │ └─ pagePool.release() * │ │ * │ └─ renderScreenshotFrame() [ SEC 24 ] * │ └─ screenshotHTML() [ SEC 25 ] * │ * └── pool full? ──▶ enqueue → drainRenderQueue() [ SEC 14 ] * * ───────────────────────────────────────────────────────────────────────── * KEY DATA STRUCTURES (grep these to find type definitions & usage): * ───────────────────────────────────────────────────────────────────────── * * View — id, name, urls(URLItem[]), method(ssr|mjpeg), cacheTtl, viewport * CachedRender — body(string), urlIndex, createdAt, expiresAt * ActiveRender — promise(Promise), urlIndex (in-flight dedup) * QueuedRender — resolve/reject callbacks, key, queuedAt (FIFO overflow) * DeviceEntry — ip, viewId, userAgent, lastSeen, requestCount * BrowserSlot — browser, available(Page[]), inUse, max * * ───────────────────────────────────────────────────────────────────────── * HOW TO SEARCH THIS FILE (for humans & AIs): * ───────────────────────────────────────────────────────────────────────── * * There is no need to read the file line-by-line. Use grep / text-search * on any of the keywords below to jump directly to what you need: * * "CONFIG" → tunables (port, timeouts, pool sizing) * "interface View" → View type definition * "CREATE TABLE" → DB schema * "dbLoadAll|dbInsert" → DB CRUD operations * "dbBanIP|banned" → IP banning * "viewCache" → in-memory view mirror * "deviceTrack" → device tracking & ban gate * "class PagePool" → browser/page pool management * "acquire|release" → page lifecycle * "renderCache|CachedRender" → render cache * "getOrRender" → cache/inflight/queue decision * "renderQueue|drainRenderQueue" → overflow queue * "renderViewToCache" → actual render execution * "warmupView|warmupCache" → pre-caching * "cacheInvalidate" → cache purging * "getCurrentURLIndex" → URL rotation logic * "renderSSR" → SSR rendering * "renderScreenshotFrame" → MJPEG rendering * "inlineAllResources" → CSS/image inlining * "bakeComputedStyles" → style baking for Tizen * "resolveCSSImports" → @import recursion * "serveView" → /v/:id response builder * "handleAPI" → REST API handler * "Bun.serve" → HTTP server entry * "shutdown" → graceful shutdown * "getDebugData" → debug telemetry * "WebSocket|ws" → debug WS * "errorPage" → error HTML template * "screenshotHTML" → MJPEG wrapper HTML * * ───────────────────────────────────────────────────────────────────────── * MODIFICATION GUIDE (what to touch when adding/changing features): * ───────────────────────────────────────────────────────────────────────── * * To add a new View field: * 1. Add to `interface View` [ SEC 02 ] * 2. Add column migration [ SEC 03 ] * 3. Update `rowToView()` [ SEC 03 ] * 4. Update `dbInsert()` / `dbUpdate()` [ SEC 04 ] * 5. Update `CreateViewBody` [ SEC 02 ] * 6. Update API handler [ SEC 28 ] * 7. Update `getDebugData()` [ SEC 08 ] (if visible in debug) * * To add a new API endpoint: * 1. Add route in `handleAPI()` [ SEC 28 ] * 2. If real-time: add to `getDebugData()` [ SEC 08 ] * 3. If admin: consider ban/auth gate [ SEC 07 ] * * To change rendering behavior: * - SSR pipeline: `renderSSR()` [ SEC 23 ] * - MJPEG pipeline: `renderScreenshotFrame()` [ SEC 24 ] * - Inlining logic: `inlineAllResources()` [ SEC 22 ] * - Style baking: `bakeComputedStyles()` [ SEC 21 ] * - Script removal: `stripScriptTags()` [ SEC 19 ] * * ───────────────────────────────────────────────────────────────────────── * EXTERNAL DEPENDENCIES: * ───────────────────────────────────────────────────────────────────────── * * bun:sqlite — embedded SQLite (views.db, banned_ips.db) * puppeteer — headless Chromium (launched via /usr/bin/chromium) * Bun — runtime: Bun.file(), Bun.serve(), WebSocket upgrade * crypto — randomUUID() for view IDs * * ═══════════════════════════════════════════════════════════════════════ */ 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; waitBeforeCaptureMs?: 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, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false }, { url: "https://duck.ai", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false }, { url: "https://www.msn.com", durationSec: 30, waitBeforeCaptureMs: 0, 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; const waitBeforeCaptureMs = urlItem.waitBeforeCaptureMs ?? 0; let body: string; if (view.method === "mjpeg") { const jpeg = await renderScreenshotFrame( urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode, waitBeforeCaptureMs, ); body = jpeg.toString("base64"); } else { body = await renderSSR( urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode, bakeStyles, waitBeforeCaptureMs, ); } 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; } // ═══════════════════════════════════════════════════════════════ // CSS COLLECTION (simplified — all CSS into single style tag) // ═══════════════════════════════════════════════════════════════ /** * Collect all CSS from loaded stylesheets and inject into a single `, ); } 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, waitBeforeCaptureMs = 0, ): 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; } } // Wait before capture (for animations, dynamic content, etc.) if (waitBeforeCaptureMs > 0) { await new Promise((r) => setTimeout(r, waitBeforeCaptureMs)); } let html = await page.content(); html = await inlineAllResources(page, html); // Set inlined HTML back to page so bake can collect all CSS await page.setContent(html, { waitUntil: "domcontentloaded" }); // Bake: collect all CSS into single style tag 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, waitBeforeCaptureMs = 0, ): 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; } } // Wait before capture (for animations, dynamic content, etc.) if (waitBeforeCaptureMs > 0) { await new Promise((r) => setTimeout(r, waitBeforeCaptureMs)); } 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; const url = new URL(req.url); // 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)), waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0), 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)), waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0), 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 }); } // GET /api/views/:id/export - Export single view as JSON if (method === "GET" && url.searchParams.get("export") === "true") { const view = viewCacheGet(viewId); if (!view) return json({ error: "View not found" }, 404); const exportData = { name: view.name, urls: view.urls, method: view.method, metaRefreshEnabled: view.metaRefreshEnabled, cacheTtlSec: view.cacheTtlSec, viewportWidth: view.viewportWidth, viewportHeight: view.viewportHeight, }; return new Response(JSON.stringify(exportData, null, 2), { headers: { "Content-Type": "application/json", "Content-Disposition": `attachment; filename="view-${viewId}.json"`, }, }); } } // POST /api/views/import - Import a view from JSON if (path === "/api/views/import" && method === "POST") { try { const importData = JSON.parse(await req.text()); if (!importData.name?.trim()) return json({ error: "Name is required" }, 400); if (!importData.urls?.length) return json({ error: "At least one URL is required" }, 400); for (const u of importData.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); } const method = importData.method ?? "ssr"; if (!["mjpeg", "ssr"].includes(method)) return json({ error: 'method must be "mjpeg" or "ssr"' }, 400); const view: View = { id: crypto.randomUUID(), name: importData.name.trim(), urls: importData.urls.map((u: URLItem) => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0), forceDarkMode: u.forceDarkMode ?? false, bakeStyles: u.bakeStyles ?? false, })), method: method, metaRefreshEnabled: importData.metaRefreshEnabled ?? false, cacheTtlSec: Math.max(0, importData.cacheTtlSec ?? 60), viewportWidth: Math.max(320, Math.min(7680, importData.viewportWidth ?? 1920)), viewportHeight: Math.max(240, Math.min(4320, importData.viewportHeight ?? 1080)), createdAt: Math.floor(Date.now() / 1000), }; dbInsert(view); viewCache.set(view.id, view); console.log(`[api] Imported "${view.name}" (${view.id}) method=${view.method}`); return json(view, 201); } catch { return json({ error: "Invalid JSON body" }, 400); } } // 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();