From 86b75598fd2817db49bafac06a718c680639f7b7 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 07:07:34 +0200 Subject: [PATCH] feat: add multi-browser page pooling, render caching, and rate limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement browser pool with 2 browsers × 8 pages each - Add page pool with acquire/release lifecycle and page reset between uses - Add render cache with TTL-based expiry and pre-warming before rotation boundaries - Add in-memory view cache to avoid repeated DB reads - Add per-IP rate limiting (2s cooldown) - Add concurrent render limit (16 max) - Refactor database functions with `db` prefix for clarity - Add CONFIG object for all tunable parameters - Add In --- src/server.ts | 1189 ++++++++++++++++++++++++++++++------------------- 1 file changed, 724 insertions(+), 465 deletions(-) diff --git a/src/server.ts b/src/server.ts index 5e2702a..da7900a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,14 +2,28 @@ 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, + PREWARM_MARGIN_SEC: 5, + RATE_LIMIT_MS: 2000, + MAX_CONCURRENT_RENDERS: 16, + STATIC_CACHE_TTL_SEC: 60, +} as const; + // ═══════════════════════════════════════════════════════════════ // TYPES // ═══════════════════════════════════════════════════════════════ -interface URLItem { - url: string; - durationSec: number; -} +interface URLItem { url: string; durationSec: number } interface View { id: string; @@ -21,22 +35,35 @@ interface View { } interface ViewRow { - id: string; - name: string; - urls: string; - method: string; - meta_refresh_enabled: number; - created_at: number; + id: string; name: string; urls: string; method: string; + meta_refresh_enabled: number; created_at: number; } interface CreateViewBody { - name: string; - urls: URLItem[]; - method: "mjpeg" | "ssr"; + name: string; urls: URLItem[]; method: "mjpeg" | "ssr"; metaRefreshEnabled?: boolean; } +type UpdateViewBody = Partial; -interface UpdateViewBody extends Partial {} +interface InlinePayload { + css: Array<{ url: string; text: string }>; + images: Array<{ src: string; dataUri: string }>; + favicons: Array<{ href: string; dataUri: string }>; + bgImages: Array<{ url: string; dataUri: string }>; +} + +interface CachedRender { + body: string; + urlIndex: number; + expiresAt: number; // unix ms + nextBoundary: number; // unix ms — when rotation advances + prewarmTimer?: ReturnType; +} + +interface ActiveRender { + promise: Promise; + urlIndex: number; +} // ═══════════════════════════════════════════════════════════════ // DATABASE @@ -57,116 +84,370 @@ db.run(` function rowToView(row: ViewRow): View { return { - id: row.id, - name: row.name, - urls: JSON.parse(row.urls), - method: row.method as "mjpeg" | "ssr", + id: row.id, name: row.name, urls: JSON.parse(row.urls), + method: row.method as View["method"], metaRefreshEnabled: row.meta_refresh_enabled === 1, createdAt: row.created_at, }; } -function loadView(id: string): View | null { - const row = db - .query("SELECT * FROM views WHERE id = ?") +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 loadAllViews(): View[] { - const rows = db - .query("SELECT * FROM views ORDER BY created_at DESC") - .all() as ViewRow[]; - return rows.map(rowToView); +function dbInsert(v: View): void { + db.run(`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,created_at) + VALUES (?,?,?,?,?,?)`, + [v.id, v.name, JSON.stringify(v.urls), v.method, v.metaRefreshEnabled ? 1 : 0, v.createdAt]); } -function insertView(v: View): void { - db.run( - "INSERT INTO views (id, name, urls, method, meta_refresh_enabled, created_at) VALUES (?, ?, ?, ?, ?, ?)", - [ - v.id, - v.name, - JSON.stringify(v.urls), - v.method, - v.metaRefreshEnabled ? 1 : 0, - v.createdAt, - ] - ); +function dbUpdate(v: View): void { + db.run("UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=? WHERE id=?", + [v.name, JSON.stringify(v.urls), v.method, v.metaRefreshEnabled ? 1 : 0, v.id]); } -function updateView(v: View): void { - db.run( - "UPDATE views SET name=?, urls=?, method=?, meta_refresh_enabled=? WHERE id=?", - [ - v.name, - JSON.stringify(v.urls), - v.method, - v.metaRefreshEnabled ? 1 : 0, - v.id, - ] - ); -} - -function deleteViewById(id: string): boolean { - const result = db.run("DELETE FROM views WHERE id = ?", [id]); - return result.changes > 0; +function dbDelete(id: string): boolean { + return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0; } // ═══════════════════════════════════════════════════════════════ -// BROWSER MANAGER (single browser, pages created per-request) +// OPT #3: IN-MEMORY VIEW CACHE // ═══════════════════════════════════════════════════════════════ -let browser: Browser | null = null; +const viewCache = new Map(); -async function getBrowser(): Promise { - if (browser?.isConnected()) return browser; - - console.log("[browser] Launching headless Chrome…"); - 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.log("[browser] Disconnected — will restart on next request"); - browser = null; - }); - - console.log("[browser] Ready"); - return browser; +function viewCacheInit(): void { + for (const v of dbLoadAll()) viewCache.set(v.id, v); } -async function createPage(): Promise { - const b = await getBrowser(); - const page = await b.newPage(); - await page.setViewport({ width: 1920, height: 1080 }); +function viewCacheGet(id: string): View | null { + return viewCache.get(id) ?? null; +} - // Block heavy/font/media resources for speed - await page.setRequestInterception(true); - page.on("request", (req) => { - const type = req.resourceType(); - if ( - type === "media" || - type === "websocket" || - type === "manifest" || - type === "font" - ) { - req.abort(); - } else { - req.continue(); +function viewCacheAll(): View[] { + return [...viewCache.values()].sort((a, b) => b.createdAt - a.createdAt); +} + +function viewCachePut(v: View): void { viewCache.set(v.id, v); } + +function viewCacheDelete(id: string): void { + viewCache.delete(id); + renderCacheInvalidate(id); +} + +// ═══════════════════════════════════════════════════════════════ +// OPT #8: BROWSER POOL + OPT #4: 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, + }; + + // Pre-warm pages + 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`); + } - return page; + 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; + } + + private async resetPage(page: Page): Promise { + try { + await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 }); + // Clear storage to prevent cross-view contamination + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + // Clear cookies for the blank origin + const client = await page.createCDPSession(); + await client.send("Network.clearBrowserCookies"); + await client.detach(); + } catch { + // Page may be broken — close and don't return to pool + try { await page.close(); } catch { } + } + } + + acquire(): Promise { + // Try available page first + for (const slot of this.slots) { + if (slot.available.length > 0) { + const page = slot.available.pop()!; + slot.inUse++; + return Promise.resolve(page); + } + if (slot.inUse < slot.max) { + slot.inUse++; + return this.createPage(slot); + } + } + + // All full — wait with timeout + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const idx = this.waiters.findIndex(w => w.timeout === timeout); + 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--; + await this.resetPage(page); + + // Wake a waiter + if (this.waiters.length > 0) { + const w = this.waiters.shift()!; + clearTimeout(w.timeout); + slot.inUse++; + w.resolve(page); + return; + } + + // Keep for reuse (don't exceed max) + if (slot.available.length + slot.inUse < slot.max) { + slot.available.push(page); + } else { + await page.close().catch(() => { }); + } + } + + 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; + +// ═══════════════════════════════════════════════════════════════ +// OPT #6: RATE LIMITER +// ═══════════════════════════════════════════════════════════════ + +const rateLimitMap = new Map(); + +function rateLimitCheck(ip: string): boolean { + const now = Date.now(); + const last = rateLimitMap.get(ip); + if (last && now - last < CONFIG.RATE_LIMIT_MS) return false; + rateLimitMap.set(ip, now); + + // Periodic cleanup + if (rateLimitMap.size > 10000) { + const cutoff = now - CONFIG.RATE_LIMIT_MS * 2; + for (const [k, v] of rateLimitMap) { + if (v < cutoff) rateLimitMap.delete(k); + } + } + return true; +} + +// ═══════════════════════════════════════════════════════════════ +// OPT #1 + #5: RENDER CACHE WITH PRE-WARMING +// ═══════════════════════════════════════════════════════════════ + +const renderCache = new Map(); +const inFlightRenders = new Map(); + +function renderCacheKey(viewId: string, urlIndex: number): string { + return `${viewId}:${urlIndex}`; +} + +function computeExpiry(view: View, urlIndex: number): { expiresAt: number; nextBoundary: number } { + const now = Date.now(); + if (view.urls.length <= 1) { + return { + expiresAt: now + CONFIG.STATIC_CACHE_TTL_SEC * 1000, + nextBoundary: now + CONFIG.STATIC_CACHE_TTL_SEC * 1000, + }; + } + const remaining = getRemainingSec(view) * 1000; + const nextBoundary = now + remaining; + return { expiresAt: nextBoundary, nextBoundary }; +} + +function schedulePrewarm(key: string, viewId: string, urlIndex: number, nextBoundary: number): void { + const entry = renderCache.get(key); + if (!entry) return; + + const marginMs = CONFIG.PREWARM_MARGIN_SEC * 1000; + const fireAt = nextBoundary - marginMs; + const delay = fireAt - Date.now(); + + if (delay <= 0) return; // Too close to boundary + + // Clear stale prewarm + if (entry.prewarmTimer) clearTimeout(entry.prewarmTimer); + + entry.prewarmTimer = setTimeout(() => { + // Check view still exists + const view = viewCacheGet(viewId); + if (!view) return; + + const idx = getCurrentURLIndex(view); + const nextIdx = (idx + 1) % view.urls.length; + const nextKey = renderCacheKey(viewId, nextIdx); + + // Don't prewarm if already cached or in flight + if (renderCache.has(nextKey) || inFlightRenders.has(nextKey)) return; + + // If no one is using this view right now, skip prewarm + // (check: is current cache entry still being hit? crude heuristic — if it exists, prewarm) + console.log(`[prewarm] view=${viewId} nextUrl=${nextIdx}`); + + // Fire background render + renderViewToCache(view, nextIdx, nextKey).catch(() => { }); + }, delay); +} + +function renderCacheInvalidate(viewId: string): void { + const prefix = `${viewId}:`; + for (const [key, entry] of renderCache) { + if (key.startsWith(prefix)) { + if (entry.prewarmTimer) clearTimeout(entry.prewarmTimer); + renderCache.delete(key); + } + } +} + +async function renderViewToCache(view: View, urlIndex: number, key: string): Promise { + const urlItem = view.urls[urlIndex]; + + let body: string; + if (view.method === "mjpeg") { + const jpeg = await renderScreenshotFrame(urlItem.url); + body = jpeg.toString("base64"); + } else { + body = await renderSSR(urlItem.url); + } + + const { expiresAt, nextBoundary } = computeExpiry(view, urlIndex); + + const entry: CachedRender = { body, urlIndex, expiresAt, nextBoundary }; + renderCache.set(key, entry); + + // Schedule prewarm for next rotation boundary + if (view.urls.length > 1) { + // Also prewarm next URL index + const nextIdx = (urlIndex + 1) % view.urls.length; + const nextKey = renderCacheKey(view.id, nextIdx); + if (!renderCache.has(nextKey)) { + schedulePrewarm(key, view.id, urlIndex, nextBoundary); + } + } + + return body; +} + +async function getOrRender(view: View, urlIndex: number): Promise { + const key = renderCacheKey(view.id, urlIndex); + + // Cache hit + const cached = renderCache.get(key); + if (cached && Date.now() < cached.expiresAt && cached.urlIndex === urlIndex) { + return cached.body; + } + + // Deduplicate in-flight renders + const inFlight = inFlightRenders.get(key); + if (inFlight && inFlight.urlIndex === urlIndex) { + return inFlight.promise; + } + + // Check global concurrency + if (pagePool.activeRenders >= CONFIG.MAX_CONCURRENT_RENDERS) { + // Serve stale if possible + if (cached) { + console.log(`[overload] Serving stale for view=${view.id}`); + // Trigger async refresh but don't wait + renderViewToCache(view, urlIndex, key).catch(() => { }); + return cached.body; + } + throw new Error("Server overloaded — retry"); + } + + const promise = renderViewToCache(view, urlIndex, key); + inFlightRenders.set(key, { promise, urlIndex }); + try { + return await promise; + } finally { + inFlightRenders.delete(key); + } } // ═══════════════════════════════════════════════════════════════ @@ -175,15 +456,13 @@ async function createPage(): Promise { function getCurrentURLIndex(view: View): number { if (view.urls.length <= 1) return 0; - - const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 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 accumulated = 0; + let acc = 0; for (let i = 0; i < view.urls.length; i++) { - accumulated += view.urls[i].durationSec; - if (elapsed < accumulated) return i; + acc += view.urls[i].durationSec; + if (elapsed < acc) return i; } return view.urls.length - 1; } @@ -192,23 +471,19 @@ function getRemainingSec(view: View): number { if (view.urls.length <= 1) { return view.urls[0]?.durationSec || 30; } - - const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0); + 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 accumulated = 0; + let acc = 0; for (let i = 0; i < view.urls.length; i++) { - accumulated += view.urls[i].durationSec; - if (elapsed < accumulated) { - return accumulated - elapsed; - } + acc += view.urls[i].durationSec; + if (elapsed < acc) return acc - elapsed; } return view.urls[0].durationSec; } // ═══════════════════════════════════════════════════════════════ -// SSR RENDERING +// OPT #2: SINGLE-PASS INLINING // ═══════════════════════════════════════════════════════════════ function injectMetaRefresh(html: string, intervalSec: number): string { @@ -230,170 +505,178 @@ function stripScriptTags(html: string): string { .replace(/\s+on\w+\s*=\s*'[^']*'/gi, ""); } -// ── Inline external CSS ` - ); - } - } catch { - // Leave as-is if fetch fails - } - } - - return html; -} - -// ── Inline images → base64 data URIs ── - -async function inlineImages(page: Page, html: string): Promise { - const imgRegex = /]*\bsrc\s*=\s*"([^"]*)"([^>]*)>/gi; - const imgs: { full: string; src: string; rest: string }[] = []; - let m; - while ((m = imgRegex.exec(html)) !== null) { - imgs.push({ full: m[0], src: m[1], rest: m[2] }); - } - - for (const img of imgs) { - // Skip already-inlined - if (img.src.startsWith("data:")) continue; - try { - const resolved = new URL(img.src, page.url()).href; - const base64 = await page.evaluate(async (url) => { - const res = await fetch(url); - if (!res.ok) return null; - const blob = await res.blob(); - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => { - const result = reader.result as string; - resolve(result); - }; - reader.readAsDataURL(blob); - }); - }, resolved); - - if (base64) { - html = html.replace( - img.full, - `` - ); - } - } catch { - // Leave as-is - } - } - - // Also handle CSS background-image: url(...) — inline style blocks - const bgRegex = /url\s*\(\s*["']?([^)"'\s]+)["']?\s*\)/gi; - const bgs: { full: string; url: string }[] = []; - while ((m = bgRegex.exec(html)) !== null) { - if (m[1].startsWith("data:") || m[1].startsWith("#")) continue; - bgs.push({ full: m[0], url: m[1] }); - } - - for (const bg of bgs) { - try { - const resolved = new URL(bg.url, page.url()).href; - // Only process if it looks like an image URL - if (!/\.(png|jpe?g|gif|svg|webp|ico)/i.test(resolved)) continue; - - const base64 = await page.evaluate(async (url) => { - const res = await fetch(url); - if (!res.ok) return null; - const blob = await res.blob(); - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => { - const result = reader.result as string; - resolve(result); - }; - reader.readAsDataURL(blob); - }); - }, resolved); - - if (base64) { - html = html.replace(bg.full, `url(${base64})`); - } - } catch { - // Leave as-is - } - } - - return html; -} - -// ── Inline favicon / other icons ── - -async function inlineFavicons(page: Page, html: string): Promise { - const iconRegex = /]*\brel\s*=\s*"(?:icon|shortcut icon|apple-touch-icon)"[^>]*\bhref\s*=\s*"([^"]*)"[^>]*>/gi; - const icons: { full: string; href: string }[] = []; - let m; - while ((m = iconRegex.exec(html)) !== null) { - icons.push({ full: m[0], href: m[1] }); - } - - for (const icon of icons) { - if (icon.href.startsWith("data:")) continue; - try { - const resolved = new URL(icon.href, page.url()).href; - const base64 = await page.evaluate(async (url) => { - const res = await fetch(url); - if (!res.ok) return null; - const blob = await res.blob(); - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => { - const result = reader.result as string; - resolve(result); - }; - reader.readAsDataURL(blob); - }); - }, resolved); - - if (base64) { - html = html.replace(icon.full, icon.full.replace(icon.href, base64)); - } - } catch { - // Leave as-is - } - } - - return html; -} - -// ── Master inliner ── - async function inlineAllResources(page: Page, html: string): Promise { - html = await inlineCSS(page, html); - html = await inlineImages(page, html); - html = await inlineFavicons(page, html); + // Single page.evaluate — parallel fetches inside browser, one IPC round-trip + let payload: InlinePayload; + try { + payload = await page.evaluate((): Promise => { + // Declare FileReader type for browser context + function blobToDataUri(blob: Blob): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + } - // Strip remaining external resource hints that the TV can't use - html = html.replace(/]*\brel\s*=\s*"(?:preload|prefetch|preconnect|dns-prefetch|modulepreload)"[^>]*>/gi, ""); + async function collect(): Promise { + const result: InlinePayload = { + css: [], + images: [], + favicons: [], + bgImages: [], + }; - // Remove crossorigin/integrity attributes that break without external context + // ── 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"); + const blob = await res.blob(); + return { src: img.src, dataUri: await blobToDataUri(blob) }; + }) + ); + result.images = imgResults + .filter((r): r is PromiseFulfilledResult<{ src: string; dataUri: string }> => + r.status === "fulfilled" + ) + .map(r => r.value); + + // ── CSS background-images ── + const styleNodes = [ + ...document.querySelectorAll("style"), + ]; + const urlRegex = /url\s*\(\s*["']?([^)"'\s]+)["']?\s*\)/gi; + const bgUrls = new Set(); + // Also scan inline styles on elements + const allElems = [...document.querySelectorAll("[style]")]; + const allStyleText = [ + ...styleNodes.map(s => s.textContent ?? ""), + ...allElems.map(e => e.getAttribute("style") ?? ""), + ].join("\n"); + let m: RegExpExecArray | null; + while ((m = urlRegex.exec(allStyleText)) !== null) { + const u = m[1]; + if (!u.startsWith("data:") && !u.startsWith("#")) { + bgUrls.add(u); + } + } + const bgResults = await Promise.allSettled( + [...bgUrls].map(async (url) => { + if (!/\.(png|jpe?g|gif|svg|webp|ico)/i.test(url)) { + throw new Error("not an image"); + } + const res = await fetch(url); + if (!res.ok) throw new Error("fetch failed"); + const blob = await res.blob(); + return { url, dataUri: await blobToDataUri(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) => { + const href = link.href; + if (href.startsWith("data:")) throw new Error("already inline"); + const res = await fetch(href); + if (!res.ok) throw new Error("fetch failed"); + const blob = await res.blob(); + return { href, dataUri: await blobToDataUri(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 { + // evaluate() may fail if page context is gone — return html as-is + return html; + } + + // ── Apply inlining on Node side (fast string replacement) ── + + // CSS + for (const css of payload.css) { + const escaped = css.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + html = html.replace( + new RegExp(`]*\\bhref\\s*=\\s*"${escaped}"[^>]*>`, "gi"), + `` + ); + } + + // Images + for (const img of payload.images) { + const escaped = img.src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + html = html.replace( + new RegExp(`(]*\\bsrc\\s*=\\s*)"${escaped}"`, "gi"), + `$1"${img.dataUri}"` + ); + } + + // Background images + for (const bg of payload.bgImages) { + const escaped = bg.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + html = html.replace( + new RegExp(`url\\s*\\(\\s*["']?${escaped}["']?\\s*\\)`, "gi"), + `url(${bg.dataUri})` + ); + } + + // Favicons + for (const fav of payload.favicons) { + const escaped = fav.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + html = html.replace( + new RegExp(`(]*\\bhref\\s*=\\s*)"${escaped}"`, "gi"), + `$1"${fav.dataUri}"` + ); + } + + // Strip resource hints that need external network + html = html.replace( + /]*\brel\s*=\s*"(?:preload|prefetch|preconnect|dns-prefetch|modulepreload)"[^>]*>/gi, + "" + ); + + // Remove attributes that break offline rendering html = html.replace(/\s+crossorigin\s*=\s*"[^"]*"/gi, ""); html = html.replace(/\s+integrity\s*=\s*"[^"]*"/gi, ""); @@ -401,67 +684,37 @@ async function inlineAllResources(page: Page, html: string): Promise { } async function renderSSR(url: string): Promise { - const page = await createPage(); + const page = await pagePool.acquire(); try { - await page.goto(url, { waitUntil: "networkidle0", timeout: 25000 }); + await page.goto(url, { + waitUntil: "networkidle0", + timeout: CONFIG.RENDER_TIMEOUT_MS, + }); let html = await page.content(); - - // Inline all external resources so TV makes zero extra requests html = await inlineAllResources(page, html); - - // Strip JS last — after inlining (fetch in evaluate needs JS) html = stripScriptTags(html); - return html; } finally { - await page.close().catch(() => {}); + await pagePool.release(page); } } -function errorPage(message: string, status: number): string { - return ` - -Error - - - - -
-

-

${message}

-

Samsung TV Proxy — retrying in 10s

-
- -`; -} - // ═══════════════════════════════════════════════════════════════ -// MJPEG/SCREENSHOT MODE — single frame via meta-refresh -// -// Instead of multipart/x-mixed-replace (broken on Chromium since -// ~2014), we take a Puppeteer screenshot, embed it as a base64 data -// URI in an HTML page, and use meta-refresh to reload. Every browser -// since HTML 2.0 handles this. Frame rate = 1 / refresh interval. +// SCREENSHOT (MJPEG replacement) RENDERING // ═══════════════════════════════════════════════════════════════ async function renderScreenshotFrame(url: string): Promise { - const page = await createPage(); + const page = await pagePool.acquire(); try { - await page.goto(url, { waitUntil: "networkidle0", timeout: 20000 }); - const buf = await page.screenshot({ - type: "jpeg", - quality: 75, - fullPage: false, + await page.goto(url, { + waitUntil: "networkidle0", + timeout: CONFIG.RENDER_TIMEOUT_MS, }); - return Buffer.from(buf); + return Buffer.from( + await page.screenshot({ type: "jpeg", quality: 75, fullPage: false }) + ); } finally { - await page.close().catch(() => {}); + await pagePool.release(page); } } @@ -484,23 +737,31 @@ function screenshotHTML(jpegBase64: string, refreshSec: number): string { `; } -// ── Per-view render lock: prevent overlapping Puppeteer renders -// when meta-refresh fires faster than render completes ── +// ═══════════════════════════════════════════════════════════════ +// ERROR / OVERLOAD PAGES +// ═══════════════════════════════════════════════════════════════ -const renderLocks = new Map>(); - -async function withRenderLock( - viewId: string, - fn: () => Promise -): Promise { - // Wait for any in-flight render to finish - while (renderLocks.has(viewId)) { - await renderLocks.get(viewId)!.catch(() => {}); - } - - const promise = fn().finally(() => renderLocks.delete(viewId)); - renderLocks.set(viewId, promise.then(() => {})); - return promise; +function errorPage(message: string, status: number, retrySec = 10): string { + return ` + +Error + + + + +
+

+

${message}

+

Samsung TV Proxy — retrying in ${retrySec}s

+
+ +`; } // ═══════════════════════════════════════════════════════════════ @@ -509,68 +770,42 @@ async function withRenderLock( async function serveView(view: View): Promise { const idx = getCurrentURLIndex(view); - const urlItem = view.urls[idx]; + let body: string; - if (view.method === "mjpeg") { - return withRenderLock(view.id, async () => { - try { - const jpeg = await renderScreenshotFrame(urlItem.url); - const b64 = jpeg.toString("base64"); - const interval = view.metaRefreshEnabled - ? getRemainingSec(view) - : Math.max(3, urlItem.durationSec); - const html = screenshotHTML(b64, interval); - - return new Response(html, { - headers: { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-cache, no-store, must-revalidate", - }, - }); - } catch (err: any) { - console.error(`[shot:${view.id}] Failed: ${err.message}`); - return new Response(errorPage(`Failed: ${urlItem.url}`, 502), { - status: 502, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } + 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}`, 502), { + status: 502, + headers: { "Content-Type": "text/html; charset=utf-8" }, }); } - // SSR method - return withRenderLock(view.id, async () => { - let html: string; - try { - html = await renderSSR(urlItem.url); - } catch (err: any) { - console.error(`[ssr:${view.id}] Render failed: ${err.message}`); - return new Response(errorPage(`Failed: ${urlItem.url}`, 502), { - status: 502, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } + // Build final HTML from cached body + let html: string; + if (view.method === "mjpeg") { + // body = base64 JPEG + html = screenshotHTML(body, getRemainingSec(view)); + } else { + // body = raw SSR HTML + html = view.metaRefreshEnabled + ? injectMetaRefresh(body, getRemainingSec(view)) + : body; + } - if (view.metaRefreshEnabled) { - const interval = getRemainingSec(view); - html = injectMetaRefresh(html, interval); - } - - return new Response(html, { - headers: { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-cache, no-store, must-revalidate", - }, - }); + return new Response(html, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-cache, no-store, must-revalidate", + }, }); } // ═══════════════════════════════════════════════════════════════ -// HTTP SERVER +// HTTP HELPERS // ═══════════════════════════════════════════════════════════════ -const PORT = parseInt(process.env.PORT || "3099"); -const STATIC_DIR = new URL("../public", import.meta.url).pathname; - function json(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, @@ -578,45 +813,34 @@ function json(data: unknown, status = 200): Response { }); } -async function readBody(req: Request): Promise { - return req.text(); -} +// ═══════════════════════════════════════════════════════════════ +// API HANDLERS +// ═══════════════════════════════════════════════════════════════ async function handleAPI(req: Request, path: string): Promise { const method = req.method; - // GET /api/views if (path === "/api/views" && method === "GET") { - return json(loadAllViews()); + return json(viewCacheAll()); } - // POST /api/views if (path === "/api/views" && method === "POST") { try { - const body: CreateViewBody = JSON.parse(await readBody(req)); - - 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); - } + 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 entry must have a url" }, 400); - } - if (typeof u.durationSec !== "number" || u.durationSec <= 0) { - return json({ error: "Each URL entry must have a positive durationSec" }, 400); - } + 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)) { + 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) => ({ + urls: body.urls.map(u => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), })), @@ -625,7 +849,8 @@ async function handleAPI(req: Request, path: string): Promise { createdAt: Math.floor(Date.now() / 1000), }; - insertView(view); + dbInsert(view); + viewCachePut(view); console.log(`[api] Created "${view.name}" (${view.id}) method=${view.method}`); return json(view, 201); } catch { @@ -633,45 +858,40 @@ async function handleAPI(req: Request, path: string): Promise { } } - // /api/views/:id const viewMatch = path.match(/^\/api\/views\/([a-f0-9-]+)$/); if (viewMatch) { const viewId = viewMatch[1]; if (method === "GET") { - const view = loadView(viewId); + const view = viewCacheGet(viewId); if (!view) return json({ error: "View not found" }, 404); return json(view); } if (method === "PUT") { - const view = loadView(viewId); + const view = viewCacheGet(viewId); if (!view) return json({ error: "View not found" }, 404); try { - const body: UpdateViewBody = JSON.parse(await readBody(req)); - + 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) => ({ + 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)), })); } if (body.method !== undefined) { - if (!["mjpeg", "ssr"].includes(body.method)) { + 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.metaRefreshEnabled !== undefined) view.metaRefreshEnabled = body.metaRefreshEnabled; - updateView(view); + dbUpdate(view); + renderCacheInvalidate(viewId); + viewCachePut(view); console.log(`[api] Updated "${view.name}" (${view.id})`); return json(view); } catch { @@ -680,13 +900,8 @@ async function handleAPI(req: Request, path: string): Promise { } if (method === "DELETE") { - // Wait for any in-flight render before deleting - while (renderLocks.has(viewId)) { - await renderLocks.get(viewId)!.catch(() => {}); - } - - const deleted = deleteViewById(viewId); - if (!deleted) return json({ error: "View not found" }, 404); + if (!dbDelete(viewId)) return json({ error: "View not found" }, 404); + viewCacheDelete(viewId); console.log(`[api] Deleted ${viewId}`); return json({ ok: true }); } @@ -695,13 +910,45 @@ async function handleAPI(req: Request, path: string): Promise { return json({ error: "Not found" }, 404); } +// ═══════════════════════════════════════════════════════════════ +// STATIC FILE HELPERS +// ═══════════════════════════════════════════════════════════════ + +const STATIC_DIR = new URL("../public", import.meta.url).pathname; + +async function serveStatic(path: string): Promise { + const filePath = path === "/" ? "/index.html" : path; + try { + const file = Bun.file(STATIC_DIR + filePath); + await file.slice(0, 0).text(); // probe + return new Response(file); + } catch { + try { + return new Response(Bun.file(STATIC_DIR + "/index.html")); + } catch { + return null; + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// SERVER BOOTSTRAP +// ═══════════════════════════════════════════════════════════════ + const server = Bun.serve({ - port: PORT, + port: CONFIG.PORT, async fetch(req) { const url = new URL(req.url); const path = url.pathname; + // Rate limit + const ip = server.requestIP(req)?.address ?? "unknown"; + const isLocalhost = ip === "127.0.0.1" || ip === "::1" || ip === "localhost"; + if (!isLocalhost && !rateLimitCheck(ip)) { + return new Response("Too Many Requests", { status: 429 }); + } + // API if (path.startsWith("/api/")) { return handleAPI(req, path); @@ -710,7 +957,7 @@ const server = Bun.serve({ // View serving const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/); if (viewMatch) { - const view = loadView(viewMatch[1]); + const view = viewCacheGet(viewMatch[1]); if (!view) { return new Response(errorPage("View not found", 404), { status: 404, @@ -728,23 +975,22 @@ const server = Bun.serve({ // Health if (path === "/health") { - return json({ status: "ok", uptime: process.uptime() }); + return json({ + status: "ok", + uptime: process.uptime(), + views: viewCacheAll().length, + cacheEntries: renderCache.size, + activeRenders: pagePool?.activeRenders ?? 0, + browsers: CONFIG.BROWSER_COUNT, + pagesPerBrowser: CONFIG.PAGES_PER_BROWSER, + }); } - // Static files - const filePath = path === "/" ? "/index.html" : path; - const fullPath = STATIC_DIR + filePath; - try { - const file = Bun.file(fullPath); - await file.slice(0, 0).text(); - return new Response(file); - } catch { - try { - return new Response(Bun.file(STATIC_DIR + "/index.html")); - } catch { - return new Response("Not found", { status: 404 }); - } - } + // Static + const staticResponse = await serveStatic(path); + if (staticResponse) return staticResponse; + + return new Response("Not found", { status: 404 }); }, error(err) { @@ -753,30 +999,43 @@ const server = Bun.serve({ }, }); -console.log(`\n🚀 Samsung TV Proxy Server`); -console.log(` http://localhost:${PORT}`); -console.log(` Views: http://localhost:${PORT}/v/\n`); +// ═══════════════════════════════════════════════════════════════ +// INIT & SHUTDOWN +// ═══════════════════════════════════════════════════════════════ -// ── Graceful 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(` Browsers: ${CONFIG.BROWSER_COUNT} × ${CONFIG.PAGES_PER_BROWSER} pages`); + console.log(` Cache: ${CONFIG.STATIC_CACHE_TTL_SEC}s static / rotation-boundary dynamic\n`); +} async function shutdown() { console.log("\n[shutdown] Closing…"); + // Cancel all prewarm timers + for (const entry of renderCache.values()) { + if (entry.prewarmTimer) clearTimeout(entry.prewarmTimer); + } + // Wait for in-flight renders - const locks = [...renderLocks.values()]; - if (locks.length) { - console.log(`[shutdown] Waiting for ${locks.length} in-flight render(s)…`); - await Promise.allSettled(locks); - } - - if (browser) { - await browser.close().catch(() => {}); - browser = null; + 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); \ No newline at end of file +process.on("SIGTERM", shutdown); + +init(); \ No newline at end of file