From 722a3143dc414558a3639345d3bc9e4db54ef560 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Tue, 12 May 2026 23:31:33 +0200 Subject: [PATCH 01/12] refactor: replace MJPEG streaming with screenshot + meta-refresh - Remove multipart/x-mixed-replace streaming (broken on Chromium) - Add per-view screenshot rendering with base64 HTML embedding - Implement resource inlining for CSS, images, and favicons - Add render locks to prevent overlapping Puppeteer operations - Simplify UpdateViewBody type to extend Partial - Update browser args for better stability - Standardize formatting (tabs to spaces) --- package.json | 2 +- src/server.ts | 1013 +++++++++++++++++++++++++++++-------------------- views.db | Bin 12288 -> 12288 bytes 3 files changed, 605 insertions(+), 410 deletions(-) diff --git a/package.json b/package.json index 58b56c8..b6c887e 100644 --- a/package.json +++ b/package.json @@ -12,4 +12,4 @@ "devDependencies": { "@types/bun": "^1.3.13" } -} +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 3007eed..5e2702a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,408 +5,604 @@ import puppeteer from "puppeteer"; // ═══════════════════════════════════════════════════════════════ // TYPES // ═══════════════════════════════════════════════════════════════ + interface URLItem { - url: string; - durationSec: number; + url: string; + durationSec: number; } + interface View { - id: string; - name: string; - urls: URLItem[]; - method: "mjpeg" | "ssr"; - metaRefreshEnabled: boolean; - createdAt: number; + id: string; + name: string; + urls: URLItem[]; + method: "mjpeg" | "ssr"; + metaRefreshEnabled: boolean; + createdAt: number; } + interface ViewRow { - id: string; - name: string; - urls: string; // JSON text - 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"; - metaRefreshEnabled?: boolean; -} -interface UpdateViewBody { - name?: string; - urls?: URLItem[]; - method?: "mjpeg" | "ssr"; - metaRefreshEnabled?: boolean; + name: string; + urls: URLItem[]; + method: "mjpeg" | "ssr"; + metaRefreshEnabled?: boolean; } + +interface UpdateViewBody extends Partial {} + // ═══════════════════════════════════════════════════════════════ // DATABASE // ═══════════════════════════════════════════════════════════════ + const db = new Database("views.db", { create: true }); -db.run( - `CREATE TABLE IF NOT EXISTS views ( id TEXT PRIMARY KEY, name TEXT NOT NULL, urls TEXT NOT NULL DEFAULT '[]', method TEXT NOT NULL DEFAULT 'ssr', meta_refresh_enabled INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()) )`, -); + +db.run(` + CREATE TABLE IF NOT EXISTS views ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + urls TEXT NOT NULL DEFAULT '[]', + method TEXT NOT NULL DEFAULT 'ssr', + meta_refresh_enabled INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) +`); + function rowToView(row: ViewRow): View { - return { - id: row.id, - name: row.name, - urls: JSON.parse(row.urls), - method: row.method as "mjpeg" | "ssr", - metaRefreshEnabled: row.meta_refresh_enabled === 1, - createdAt: row.created_at, - }; + return { + id: row.id, + name: row.name, + urls: JSON.parse(row.urls), + method: row.method as "mjpeg" | "ssr", + 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 = ?").get(id) as - | ViewRow - | undefined; - return row ? rowToView(row) : 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); + const rows = db + .query("SELECT * FROM views ORDER BY created_at DESC") + .all() as ViewRow[]; + return rows.map(rowToView); } + 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, - ], - ); + 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 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, - ], - ); + 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; + const result = db.run("DELETE FROM views WHERE id = ?", [id]); + return result.changes > 0; } + // ═══════════════════════════════════════════════════════════════ -// BROWSER MANAGER +// BROWSER MANAGER (single browser, pages created per-request) // ═══════════════════════════════════════════════════════════════ + let browser: Browser | null = null; + async function getBrowser(): Promise { - if (browser && browser.isConnected()) return browser; - console.log("[browser] Launching headless Chromium…"); - browser = await puppeteer.launch({ + 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", - ], - }); - // Handle browser crash/disconnect - browser.on("disconnected", () => { - console.log( - "[browser] Browser disconnected — will restart on next request", - ); - browser = null; - }); - console.log("[browser] Ready"); - return browser; + 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; } + async function createPage(): Promise { - const b = await getBrowser(); - const page = await b.newPage(); - await page.setViewport({ width: 1920, height: 1080 }); - // Block unnecessary resource types for faster loads on TV-targeted renders - await page.setRequestInterception(true); - page.on("request", (req) => { - const type = req.resourceType(); - if (type === "media" || type === "websocket" || type === "manifest") { - req.abort(); - } else { - req.continue(); - } - }); - return page; + const b = await getBrowser(); + const page = await b.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + + // 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(); + } + }); + + return page; } + // ═══════════════════════════════════════════════════════════════ -// ROTATION LOGIC +// ROTATION LOGIC (wall-clock based, stateless) // ═══════════════════════════════════════════════════════════════ -/** -* Returns which URL index is "current" based on wall-clock time - -* and the cumulative durations of all URLs in the view. - */ function getCurrentURLIndex(view: View): number { - if (view.urls.length <= 1) return 0; - const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0); - if (totalCycle <= 0) return 0; - const elapsed = Math.floor(Date.now() / 1000) % totalCycle; - let accumulated = 0; - for (let i = 0; i < view.urls.length; i++) { - accumulated += view.urls[i].durationSec; - if (elapsed < accumulated) return i; - } - return view.urls.length - 1; + if (view.urls.length <= 1) return 0; + + const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0); + if (totalCycle <= 0) return 0; + + const elapsed = Math.floor(Date.now() / 1000) % totalCycle; + let accumulated = 0; + for (let i = 0; i < view.urls.length; i++) { + accumulated += view.urls[i].durationSec; + if (elapsed < accumulated) return i; + } + return view.urls.length - 1; } -/** - -* Returns how many seconds remain before the _next_ URL switch. - -* Used for meta-refresh interval so the TV reloads exactly when - -* the rotation advances. - */ 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); - if (totalCycle <= 0) return 30; - const elapsed = Math.floor(Date.now() / 1000) % totalCycle; - let accumulated = 0; - for (let i = 0; i < view.urls.length; i++) { - accumulated += view.urls[i].durationSec; - if (elapsed < accumulated) { - return accumulated - elapsed; - } - } - // Shouldn't reach here — wrap around - return view.urls[0].durationSec; + if (view.urls.length <= 1) { + return view.urls[0]?.durationSec || 30; + } + + const totalCycle = view.urls.reduce((sum, u) => sum + u.durationSec, 0); + if (totalCycle <= 0) return 30; + + const elapsed = Math.floor(Date.now() / 1000) % totalCycle; + let accumulated = 0; + for (let i = 0; i < view.urls.length; i++) { + accumulated += view.urls[i].durationSec; + if (elapsed < accumulated) { + return accumulated - elapsed; + } + } + return view.urls[0].durationSec; } // ═══════════════════════════════════════════════════════════════ // SSR RENDERING // ═══════════════════════════════════════════════════════════════ + function injectMetaRefresh(html: string, intervalSec: number): string { - // Use integer seconds for meta refresh - 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}`; + 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, ""); + return html + .replace(/)<[^<]*)*<\/script>/gi, "") + .replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "") + .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); + + // Strip remaining external resource hints that the TV can't use + html = html.replace(/]*\brel\s*=\s*"(?:preload|prefetch|preconnect|dns-prefetch|modulepreload)"[^>]*>/gi, ""); + + // Remove crossorigin/integrity attributes that break without external context + html = html.replace(/\s+crossorigin\s*=\s*"[^"]*"/gi, ""); + html = html.replace(/\s+integrity\s*=\s*"[^"]*"/gi, ""); + + return html; +} + async function renderSSR(url: string): Promise { - const page = await createPage(); - try { - await page.goto(url, { waitUntil: "networkidle0", timeout: 25000 }); - const html = await page.content(); - return stripScriptTags(html); - } finally { - await page.close().catch(() => {}); - } + const page = await createPage(); + try { + await page.goto(url, { waitUntil: "networkidle0", timeout: 25000 }); + 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(() => {}); + } } -function errorPage(message: string): string { - return ` -⚠ -${message} -Samsung TV Proxy -`; + +function errorPage(message: string, status: number): string { + return ` + +Error + + + + +
+

+

${message}

+

Samsung TV Proxy — retrying in 10s

+
+ +`; } + // ═══════════════════════════════════════════════════════════════ -// MJPEG STREAMING +// 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. // ═══════════════════════════════════════════════════════════════ -interface ActiveStream { - aborted: boolean; - page: Page | null; + +async function renderScreenshotFrame(url: string): Promise { + const page = await createPage(); + try { + await page.goto(url, { waitUntil: "networkidle0", timeout: 20000 }); + const buf = await page.screenshot({ + type: "jpeg", + quality: 75, + fullPage: false, + }); + return Buffer.from(buf); + } finally { + await page.close().catch(() => {}); + } } -const activeStreams = new Map(); -async function serveMJPEG(view: View): Promise { - const b = await getBrowser(); - const page = await b.newPage(); - await page.setViewport({ width: 1920, height: 1080 }); - // Block heavy resources during MJPEG capture - 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(); - } - }); - // Per-connection key so multiple viewers of the same view don't collide - const connKey = `${view.id}:${crypto.randomUUID()}`; - const boundary = "MJPEG_FRAME"; - let currentIdx = -1; - const streamRecord: ActiveStream = { aborted: false, page }; - activeStreams.set(connKey, streamRecord); - - // Navigate to the first URL before returning the response so the first - // frame is ready immediately and Chrome has something to display. - const firstIdx = getCurrentURLIndex(view); - try { - console.log(`[mjpeg:${connKey}] Initial navigation to URL ${firstIdx}: ${view.urls[firstIdx].url}`); - await page.goto(view.urls[firstIdx].url, { - waitUntil: "networkidle2", - timeout: 20000, - }); - currentIdx = firstIdx; - } catch (err: any) { - console.error(`[mjpeg:${connKey}] Initial navigation failed: ${err.message}`); - } - - const enc = new TextEncoder(); - const stream = new ReadableStream({ - async start(controller) { - const FRAME_MS = 200; // ~5 fps - - const enqueueFrame = async () => { - if (streamRecord.aborted) return; - - const idx = getCurrentURLIndex(view); - if (idx !== currentIdx) { - try { - console.log(`[mjpeg:${connKey}] Switching to URL ${idx}: ${view.urls[idx].url}`); - await page.goto(view.urls[idx].url, { - waitUntil: "networkidle2", - timeout: 20000, - }); - currentIdx = idx; - } catch (err: any) { - console.error(`[mjpeg:${connKey}] Navigation failed: ${err.message}`); - // Stay on current page, will retry next cycle - } - } - - try { - const screenshot = await page.screenshot({ - type: "jpeg", - quality: 70, - }); - const part = - `--${boundary}\r\n` + - `Content-Type: image/jpeg\r\n` + - `Content-Length: ${screenshot.length}\r\n` + - `\r\n`; - controller.enqueue(enc.encode(part)); - controller.enqueue(new Uint8Array(screenshot)); - controller.enqueue(enc.encode("\r\n")); - } catch (err: any) { - if (!streamRecord.aborted) { - console.error(`[mjpeg:${connKey}] Screenshot failed: ${err.message}`); - } - } - }; - - while (!streamRecord.aborted) { - const start = Date.now(); - await enqueueFrame(); - const elapsed = Date.now() - start; - const delay = Math.max(0, FRAME_MS - elapsed); - if (delay > 0) await new Promise((r) => setTimeout(r, delay)); - } - - controller.close(); - }, - cancel() { - streamRecord.aborted = true; - if (streamRecord.page) { - streamRecord.page.close().catch(() => {}); - streamRecord.page = null; - } - activeStreams.delete(connKey); - console.log(`[mjpeg:${connKey}] Stream closed`); - }, - }); - return new Response(stream, { - headers: { - "Content-Type": `multipart/x-mixed-replace; boundary=${boundary}`, - "Cache-Control": "no-cache, no-store, must-revalidate", - "Connection": "keep-alive", - "X-Content-Type-Options": "nosniff", - }, - }); +function screenshotHTML(jpegBase64: string, refreshSec: number): string { + const sec = Math.max(2, Math.ceil(refreshSec)); + return ` + + + + + + + + + +`; } -// ═══════════════════════════════════════════════════════════════ -// VIEW SERVING — /v/:id + +// ── Per-view render lock: prevent overlapping Puppeteer renders +// when meta-refresh fires faster than render completes ── + +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; +} + // ═══════════════════════════════════════════════════════════════ +// VIEW SERVING — /v/:id +// ═══════════════════════════════════════════════════════════════ + async function serveView(view: View): Promise { - if (view.method === "mjpeg") { - return serveMJPEG(view); - } - // SSR method - const idx = getCurrentURLIndex(view); - const urlItem = view.urls[idx]; - 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 to load: ${urlItem.url}`), { - status: 502, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - 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", - }, - }); + const idx = getCurrentURLIndex(view); + const urlItem = view.urls[idx]; + + 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" }, + }); + } + }); + } + + // 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" }, + }); + } + + 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", + }, + }); + }); } + // ═══════════════════════════════════════════════════════════════ // HTTP SERVER // ═══════════════════════════════════════════════════════════════ + 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, - headers: { "Content-Type": "application/json" }, - }); + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); } -function readBody(req: Request): Promise { - return req.text(); + +async function readBody(req: Request): Promise { + return req.text(); } + async function handleAPI(req: Request, path: string): Promise { const method = req.method; - // GET /api/views — list all + + // GET /api/views if (path === "/api/views" && method === "GET") { return json(loadAllViews()); } - // POST /api/views — create + + // POST /api/views if (path === "/api/views" && method === "POST") { try { const body: CreateViewBody = JSON.parse(await readBody(req)); - if (!body.name || !body.name.trim()) { + + if (!body.name?.trim()) { return json({ error: "Name is required" }, 400); } - if (!body.urls || body.urls.length === 0) { + if (!body.urls?.length) { return json({ error: "At least one URL is required" }, 400); } for (const u of body.urls) { - if (!u.url || !u.url.trim()) { + if (!u.url?.trim()) { return json({ error: "Each URL entry must have a url" }, 400); } if (typeof u.durationSec !== "number" || u.durationSec <= 0) { @@ -414,7 +610,7 @@ async function handleAPI(req: Request, path: string): Promise { } } if (!["mjpeg", "ssr"].includes(body.method)) { - return json({ error: "method must be 'mjpeg' or 'ssr'" }, 400); + return json({ error: 'method must be "mjpeg" or "ssr"' }, 400); } const view: View = { @@ -430,25 +626,24 @@ async function handleAPI(req: Request, path: string): Promise { }; insertView(view); - console.log(`[api] Created view "${view.name}" (${view.id}) method=${view.method}`); + console.log(`[api] Created "${view.name}" (${view.id}) method=${view.method}`); return json(view, 201); } catch { return json({ error: "Invalid JSON body" }, 400); } } - // Match /api/views/:id + + // /api/views/:id const viewMatch = path.match(/^\/api\/views\/([a-f0-9-]+)$/); if (viewMatch) { const viewId = viewMatch[1]; - // GET /api/views/:id if (method === "GET") { const view = loadView(viewId); if (!view) return json({ error: "View not found" }, 404); return json(view); } - // PUT /api/views/:id if (method === "PUT") { const view = loadView(viewId); if (!view) return json({ error: "View not found" }, 404); @@ -468,7 +663,7 @@ async function handleAPI(req: Request, path: string): Promise { } if (body.method !== undefined) { if (!["mjpeg", "ssr"].includes(body.method)) { - return json({ error: "method must be 'mjpeg' or 'ssr'" }, 400); + return json({ error: 'method must be "mjpeg" or "ssr"' }, 400); } view.method = body.method; } @@ -477,111 +672,111 @@ async function handleAPI(req: Request, path: string): Promise { } updateView(view); - console.log(`[api] Updated view "${view.name}" (${view.id})`); + console.log(`[api] Updated "${view.name}" (${view.id})`); return json(view); } catch { return json({ error: "Invalid JSON body" }, 400); } } - // DELETE /api/views/:id if (method === "DELETE") { - // Close all active MJPEG streams for this view (one per connected client) - for (const [key, active] of activeStreams) { - if (key.startsWith(viewId + ":")) { - active.aborted = true; - if (active.page) active.page.close().catch(() => {}); - activeStreams.delete(key); - } + // 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); - console.log(`[api] Deleted view ${viewId}`); + console.log(`[api] Deleted ${viewId}`); return json({ ok: true }); } } + return json({ error: "Not found" }, 404); } + const server = Bun.serve({ - port: PORT, - async fetch(req) { - const url = new URL(req.url); - const path = url.pathname; + port: PORT, - // API routes - if (path.startsWith("/api/")) { - return handleAPI(req, path); + async fetch(req) { + const url = new URL(req.url); + const path = url.pathname; + + // API + if (path.startsWith("/api/")) { + return handleAPI(req, path); + } + + // View serving + const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/); + if (viewMatch) { + const view = loadView(viewMatch[1]); + if (!view) { + return new Response(errorPage("View not found", 404), { + status: 404, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); } - - // View serving — /v/:id - const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/); - if (viewMatch) { - const view = loadView(viewMatch[1]); - 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("View has no URLs configured"), { - status: 500, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - return serveView(view); + if (view.urls.length === 0) { + return new Response(errorPage("No URLs configured", 500), { + status: 500, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); } + return serveView(view); + } - // Health check - if (path === "/health") { - return json({ status: "ok", uptime: process.uptime() }); - } - - // Static files from public/ - const filePath = path === "/" ? "/index.html" : path; - const fullPath = STATIC_DIR + filePath; + // Health + if (path === "/health") { + return json({ status: "ok", uptime: process.uptime() }); + } + // 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 { - const file = Bun.file(fullPath); - // Check if file exists (will throw if not) - await file.slice(0, 0).text(); // probe - return new Response(file); + return new Response(Bun.file(STATIC_DIR + "/index.html")); } catch { - // SPA fallback: serve index.html for non-API routes - try { - return new Response(Bun.file(STATIC_DIR + "/index.html")); - } catch { - return new Response("Not found", { status: 404 }); - } + return new Response("Not found", { status: 404 }); } + } + }, - }, - error(err) { - console.error("[server]", err); - return new Response("Internal Server Error", { status: 500 }); - }, - }); - console.log(`\n🚀 Samsung TV Proxy Server`); - console.log(`http://localhost:${PORT}`); - console.log(`Static: ${STATIC_DIR}`); - console.log(`Views: http://localhost:${PORT}/v/\n`); - // ── Graceful shutdown ── - async function shutdown() { - console.log("\n[shutdown] Closing…"); - // Close all active MJPEG streams - for (const [, stream] of activeStreams) { - stream.aborted = true; - if (stream.page) await stream.page.close().catch(() => { }); - } - activeStreams.clear(); - // Close browser - if (browser) { - await browser.close().catch(() => { }); - browser = null; - } - server.stop(); - process.exit(0); + error(err) { + console.error("[server]", err); + return new Response("Internal Server Error", { status: 500 }); + }, +}); + +console.log(`\n🚀 Samsung TV Proxy Server`); +console.log(` http://localhost:${PORT}`); +console.log(` Views: http://localhost:${PORT}/v/\n`); + +// ── Graceful shutdown ── + +async function shutdown() { + console.log("\n[shutdown] Closing…"); + + // 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); } - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); + + if (browser) { + await browser.close().catch(() => {}); + browser = null; + } + + server.stop(); + process.exit(0); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); \ No newline at end of file diff --git a/views.db b/views.db index 7db43a8cbcf702bf3b57a5a29b67215a9d57dc32..6f69db196f2ab23bc339fba5ce69dc9a2accebbf 100644 GIT binary patch delta 285 zcmZojXh@hK&B!rP#+i|0W5P;)<^sMOn*|ll@R>AROms8mA^1q?(!<8%~}tFV38oS~B^8yx8Qk@`h&9SQ~{IK_=LX zbFw6vo0*%WSeWWsnwT5wni!=S=q8#Nn&}!Pr=}*QCYdItnHdEKIeI%z=8>0X)StXd Y-i2vO{U(LY{Jz9k&ZN#9%1MkJ0M2zlhX4Qo -- 2.54.0 From 86b75598fd2817db49bafac06a718c680639f7b7 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 07:07:34 +0200 Subject: [PATCH 02/12] 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 -- 2.54.0 From a1a1446ed1fe78c2979b32ed1a6bafe8fb3c6f58 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 08:04:55 +0200 Subject: [PATCH 03/12] build: add linting and formatting tooling --- bun.lock | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++-- package.json | 13 ++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 0fe8e53..100d541 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,13 @@ "": { "name": "samsung-tv-server", "dependencies": { - "puppeteer": "latest", + "puppeteer": "^24.43.1", }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.13", + "add": "^2.0.6", + "oxfmt": "^0.49.0", + "oxlint": "^1.64.0", }, }, }, @@ -17,6 +20,82 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-HbifJ84prIh9+55CTPAU35JdRQrwg47y16cGerCC+iejSKOuHXYo2WDql6l7cQlzrYVtc3f4UWY+dBj2lRmOeA=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ef7SKJqAaH2d7E6eXZZa2OffIShbhFMxnGK0zd93p4qiyTJr75B0qf7lrPD+qQOwcf04BrjYJ0JUxq8d5+yZwg=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.49.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8x5DN9CsFfb432sHa9NyqX5XisGUdA53LPEGSdv/VniS+v4uEOR8Orv7A9QSB98Xxgp0t6r31DzQA/wpIobGqQ=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.49.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-e0+DSVzk4ewhMVKNYDaRTmP81jNMBWR1X9al0cVKWS+hDM/dElNqD5zjTOCuLOZc4oOdp2Gx2ldrVL+yYo9TZQ=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.49.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-W+mjtYtrQvFbXT/uNT+221OBhGRZ8UqNsLxjTWsjZ4GsQnRdvRC/N2NCK86BcamWr7lsTxwpwN3PULnr78sgcQ=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Rtv6UevV7czDlLqil+NZUe4d8gs8jQo/zScSpumwyf7I+fSdLc+hc8AF3MQC7ymxSMMD9+vfiqQlsIf7wOAzXA=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-sBi+8C/Q/MdKa5FL8ibAUCdhFBGFH7HFN/Qoyd5xQbZ/0ky3NMPpKfIBpaH0lhK2dXkGLczVQUoZ+xuNSerCdQ=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JIfWenFhlzx+O8YygyZhoHFzTsdgDhxhbDRnE2iJLnnM5pWKScFvPECO2vOlA7JqJ/9S1g3uzEKuRCkHFwTjvA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iNzkMPG18jPkwBOZ4/HEjwqfzAjq4RrUQ0CgId/fC1ENvYD5jLVAaU/gWgpiqP1ys07kxSsSggDd1fp3E7mQHw=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.49.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BPHA/NN3LvoIXiid+iz3BHt5V0Rzx0tXAqRUovwE1NsbDaLG9e8mtv7evDGRIkVQacqTDBv0XL25THHsxSJosQ=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-3Eroshe+s69htC9JIL0+zLGQczLtRKezkMhwqQC21VC5Z/fuLvzLfbAOLgJLUq601H8gDYjy7deYycfOBjCvWg=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-fnaERGgsxGm0lKAmO72EYR4BA3qBnzBTJBTi6EtUMq1D4R7EexRBMU4voXnx4TXla3SEDl9x4uNp/18SbkPjGg=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.49.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-rBwasMl1Uul1MCCeTGEFKnOTL7VUxHf+634jWStrQAbzpBJgd5Yz5m4F7exVCsoI8PHn57dNjssXagXLCLB5yA=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BoC/F9xHe2y/deuBGA5Aw7bes07OD2gcL2wlpzTrfImR92vPP7S/k3LBTyspQZCNIVNdagkELcqKELwMLGIfAg=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-umY6jFADAo/oztFKl8D/S6vSrG6oBpEskcentiRuz42kZVU2kfDXMWCYavxyZR2bwPjqkHpcHZ6EZFiH3Qj9ZA=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.49.0", "", { "os": "none", "cpu": "arm64" }, "sha512-J85zQMiw2pXiGPK+OusmDvSnJ/dgpgN7VgmB2zOBtgS8F+nsOUfSg9ZEBrwbQscjZ7tkPbm38CG4VF5f53MsiA=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.49.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-38K67XR++CoFFORDd4sMFwUVAnD6msYBdGTei+qvKGrRPO6S2PbrYPNL/eQQ1RgnnxOegNba0YQwg6uRkNcw6A=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.49.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-rXVe0HICwQF0dBgbQtBCoYf8x/SidPIdhyQl+iPuJlV7suV+qDv7yUEB3wQ4qC3nOeNxz287SwFXKzyr0kWgEg=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.49.0", "", { "os": "win32", "cpu": "x64" }, "sha512-gwWLwSEmBBfIK/Wh7GGd658161o4RKAvHWRaRQbJm571iQXGKfyr7UKsI1vsWvDlNLc30CxJDc8mMmCvJ/kczQ=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-2r6Nq3XXGLHEXKkSj8JtmJ6N4gDw431DPFOg0ZoJHlNjnG6HVMm/ksQ10m0HJ8WBvwgMe1L50UHPaYZutCRPCw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.64.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ePJMpePgg7fBv+L/hVx1xXRU5/5gd5m0obLA6hPEfLXF3GjpR8idIDbY1dhQYhyz1ms2wdTccSboo6KEd2Oxtg=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.64.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-U4DMLQd10gJLuoSTLSGbfv3bGjTlUNsScm9Dgb8wwBqmCzidf1pE1pXV4doGNxqwH3KtVng1AGTINA0NvkGLvQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.64.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-GoRIL48QWm4/TAvjN8pB1nAG+1/uqc9EdnWT9zqHeb6wsmjZtywj8VRe5aGW47Fdb64YtLOsdLqVxOvQuz98Wg=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.64.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5dFkv4tkg7PxJJGS9/OjrJwjhuHczrd3OQOkRE0wHcLM+ncUnULtzEPWjqGOxTXxZnLWcB91bGiIznx89TVXyQ=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jsBqMLl/uOL5+Kq/+BtK9FrmiNGUbx8SiyZXv+WlUxA45KuwcLu9BfiSIL3I3DBDgWM3yZizDITnTK9BcqNBQg=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-1lrj8At/Uuc9GhjrVFBQo0NEjfBrTkzpmtHIGAhNnIXqn1CAyGL+qrztUsXb2GIluJrpl9Q7qRLJOb/NqydacQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HpSQbubwh03mMhAdy2BYtad/fsY8vDFHDAb6bUwuCYg2VD3xCQgn6ArKcO0oZyLCheacKTv4PrF3Mfu5hgoE2g=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-00QQ0h0Y7u0G69BgiH3+ky2aaq/QvkDL6DYok8htIuJHxybiux5aQ8jwmg8qIk9wha6UagUP2BAwAzbemcJbpg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.64.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2GaimTV6EMW+s5HS0An3oGbQme3BgHswvfVdGk3EB57Xe9+/gyT+Qd7lNVzb3rtir52vbIPzXfaYArzs5b5zcw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-H46AtFb9wypjoVwGdlxrm0DsD809NGmtiK9HiyPKTxkSte2YjhC4S+00rOIrwCaxcyPiGid3Y3OMXp5KMAkGZw=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-HEgsidjjvvyzdg82icYkuFCf7REDV7B9JFwbIMbVwrKLBY0MrXX+bku3POn/hduZ2yW91IyVDUMq0Bf02KwXQw=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.64.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Axvm8qryotmKN00P5w4JapaSjvP2LOSbdbBJiX+2SuHd3QzhW7TUc8skqgw+ahQZ5DmzEYeHCqauvW8f32Ns6Q=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cR60vSd7+m+KRZ3GQGfDxWwahW5RMXg0qlGvAluZr0fTUYvw0H9N9AXAF/M/PMqgytyqvVNmBAkJG9l7U30Y1g=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2u/aPZ9pEg7HnvZPDsHxUGNnrpr4qaHi+mCgLgpt+LYRzPrS4Px4wPfkIdRdr2GvKnaYyt+XSlto0Vm5sbStTg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.64.0", "", { "os": "none", "cpu": "arm64" }, "sha512-kfhkGfCdoXLSxEkrhDlJrvBYajGmq+ma4EMc53dsOWTq+rIBOlI0vTBmpZNnM5oH2LY/K/w1HAK+UQEgjgpVUg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.64.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-r/cNKBFieONoVu2bb1KkVouq9W+edDUgHumXJGphCRRj+U0xaD4nanrw8ZOqo0IsutPkEM4vCcGBpak6x5aXMg=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.64.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-tUw0xUUwEFVZbpJoeCblkv8SJA4Xz3CdXCJbAnBsiNLyxDrk2tLcxEAS6M73Q7hHHDg3OtwI8vZVK3t5RJt4Gw=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-9CBR+LO0JVST87fNTzzNxS5I29jIUO5gxT9i9+M3SDHHALElj9sY1Prf12tad3vIRC6OD7Ehtvvh+sn13vSwHw=="], + "@puppeteer/browsers": ["@puppeteer/browsers@2.13.2", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw=="], "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], @@ -27,6 +106,8 @@ "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "add": ["add@2.0.6", "", {}, "sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -139,6 +220,10 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "oxfmt": ["oxfmt@0.49.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.49.0", "@oxfmt/binding-android-arm64": "0.49.0", "@oxfmt/binding-darwin-arm64": "0.49.0", "@oxfmt/binding-darwin-x64": "0.49.0", "@oxfmt/binding-freebsd-x64": "0.49.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.49.0", "@oxfmt/binding-linux-arm-musleabihf": "0.49.0", "@oxfmt/binding-linux-arm64-gnu": "0.49.0", "@oxfmt/binding-linux-arm64-musl": "0.49.0", "@oxfmt/binding-linux-ppc64-gnu": "0.49.0", "@oxfmt/binding-linux-riscv64-gnu": "0.49.0", "@oxfmt/binding-linux-riscv64-musl": "0.49.0", "@oxfmt/binding-linux-s390x-gnu": "0.49.0", "@oxfmt/binding-linux-x64-gnu": "0.49.0", "@oxfmt/binding-linux-x64-musl": "0.49.0", "@oxfmt/binding-openharmony-arm64": "0.49.0", "@oxfmt/binding-win32-arm64-msvc": "0.49.0", "@oxfmt/binding-win32-ia32-msvc": "0.49.0", "@oxfmt/binding-win32-x64-msvc": "0.49.0" }, "peerDependencies": { "svelte": "^5.0.0" }, "optionalPeers": ["svelte"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IAHFMdlJSWe+oAr65dx22UvjCtV9DBMisAuLnKpDqMQrctzCkGnj3QRwNHm0d+uwSWPalsDF8ZYLz9rh6nH2IQ=="], + + "oxlint": ["oxlint@1.64.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.64.0", "@oxlint/binding-android-arm64": "1.64.0", "@oxlint/binding-darwin-arm64": "1.64.0", "@oxlint/binding-darwin-x64": "1.64.0", "@oxlint/binding-freebsd-x64": "1.64.0", "@oxlint/binding-linux-arm-gnueabihf": "1.64.0", "@oxlint/binding-linux-arm-musleabihf": "1.64.0", "@oxlint/binding-linux-arm64-gnu": "1.64.0", "@oxlint/binding-linux-arm64-musl": "1.64.0", "@oxlint/binding-linux-ppc64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-musl": "1.64.0", "@oxlint/binding-linux-s390x-gnu": "1.64.0", "@oxlint/binding-linux-x64-gnu": "1.64.0", "@oxlint/binding-linux-x64-musl": "1.64.0", "@oxlint/binding-openharmony-arm64": "1.64.0", "@oxlint/binding-win32-arm64-msvc": "1.64.0", "@oxlint/binding-win32-ia32-msvc": "1.64.0", "@oxlint/binding-win32-x64-msvc": "1.64.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Star3SNpWPeWFPw7kRXIhXUSn6fdiAl25q15CQzH/9WaOtG6e9CWTc25vNZOCr4PE1yEP1GtKJKIKglhj3OmEQ=="], + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], @@ -191,6 +276,8 @@ "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], diff --git a/package.json b/package.json index b6c887e..c7ca70a 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,19 @@ "description": "Bun web server for serving dynamic content to Samsung TVs via SSR and MJPEG streaming", "scripts": { "start": "bun run src/server.ts", - "dev": "bun --watch run src/server.ts" + "dev": "bun --watch run src/server.ts", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check" }, "dependencies": { "puppeteer": "^24.43.1" }, "devDependencies": { - "@types/bun": "^1.3.13" + "@types/bun": "^1.3.13", + "add": "^2.0.6", + "oxfmt": "^0.49.0", + "oxlint": "^1.64.0" } -} \ No newline at end of file +} -- 2.54.0 From 94141e928cd0c09a4bfd872877152897cfca4362 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 08:04:59 +0200 Subject: [PATCH 04/12] feat(api, ui): add debug panel and device tracking Add real-time debug panel with server metrics, view status, cache visualization, and device tracking. Add viewport/cache TTL config to views. Simplify cache management and remove rate limiting. --- public/index.html | 1378 ++++++++++++++++++++++++++++++++------------- src/server.ts | 789 +++++++++++++++----------- views.db | Bin 12288 -> 12288 bytes 3 files changed, 1447 insertions(+), 720 deletions(-) diff --git a/public/index.html b/public/index.html index 818a436..e59e495 100644 --- a/public/index.html +++ b/public/index.html @@ -1,252 +1,713 @@ - + - - - -Samsung TV Proxy - - - + @media (max-width: 640px) { + .app { + padding: 16px 10px; + } + .card { + padding: 14px; + } + .debug-grid { + grid-template-columns: repeat(2, 1fr); + } + .debug-table { + font-size: 11px; + } + .debug-table td, + .debug-table th { + padding: 6px 6px; + } + } + + + +
+
+

Samsung TV Proxy

+
+ + +
+
-
-
-

Samsung TV Proxy

- -
+
-
-
+ +
+
+

🔍 Live Debug

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

📺 Views & Rotation

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

🔍 Live Debug

- polling every - + + + - -
@@ -636,7 +636,8 @@ // ═══════════════════════════════════════════════ let views = []; - let debugPollTimer = null; + let debugSocket = null; + let debugReconnectTimer = null; let debugVisible = false; let debugIntervalMs = 1000; const { origin } = location; @@ -970,9 +971,54 @@ } // ═══════════════════════════════════════════════ - // DEBUG PANEL + // DEBUG PANEL (WebSocket) // ═══════════════════════════════════════════════ + function connectDebugSocket() { + if (debugSocket) return; + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${location.host}/ws?interval=${debugIntervalMs}`; + debugSocket = new WebSocket(wsUrl); + + debugSocket.onopen = () => { + if (debugReconnectTimer) { clearTimeout(debugReconnectTimer); debugReconnectTimer = null; } + }; + + debugSocket.onmessage = (event) => { + try { + lastDebugData = JSON.parse(event.data); + renderDebug(lastDebugData); + } catch { /* ignore */ } + }; + + debugSocket.onclose = () => { + debugSocket = null; + if (debugVisible) { + debugReconnectTimer = setTimeout(connectDebugSocket, 2000); + } + }; + + debugSocket.onerror = () => { + debugSocket?.close(); + }; + } + + function disconnectDebugSocket() { + if (debugReconnectTimer) { clearTimeout(debugReconnectTimer); debugReconnectTimer = null; } + if (debugSocket) { + debugSocket.onclose = null; // prevent auto-reconnect + debugSocket.close(); + debugSocket = null; + } + } + + function setDebugInterval(ms) { + debugIntervalMs = ms; + if (debugSocket && debugSocket.readyState === WebSocket.OPEN) { + debugSocket.send(JSON.stringify({ type: "debug-interval", interval: ms })); + } + } + function toggleDebug() { debugVisible = !debugVisible; const panel = document.getElementById("debug-panel"); @@ -980,39 +1026,15 @@ if (debugVisible) { panel.classList.add("visible"); btn.textContent = "🔍 Hide Debug"; - pollDebug(); - debugPollTimer = setInterval(pollDebug, debugIntervalMs); + connectDebugSocket(); } else { panel.classList.remove("visible"); btn.textContent = "🔍 Debug"; - if (debugPollTimer) { - clearInterval(debugPollTimer); - debugPollTimer = null; - } - } - } - - function onDebugIntervalChange() { - debugIntervalMs = parseInt(document.getElementById("debug-interval").value); - if (debugPollTimer) { - clearInterval(debugPollTimer); - debugPollTimer = setInterval(pollDebug, debugIntervalMs); + disconnectDebugSocket(); } } let lastDebugData = null; - - async function pollDebug() { - if (!debugVisible) return; - try { - const res = await fetch(origin + "/api/debug"); - lastDebugData = await res.json(); - renderDebug(lastDebugData); - } catch { - /* silent */ - } - } - let debugTickInterval = null; function renderDebug(data) { diff --git a/src/server.ts b/src/server.ts index 7875a94..035dd87 100644 --- a/src/server.ts +++ b/src/server.ts @@ -236,6 +236,45 @@ function devicesSnapshot(): DeviceEntry[] { 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 // ═══════════════════════════════════════════════════════════════ @@ -390,6 +429,17 @@ class PagePool { let pagePool: PagePool; +// ═══════════════════════════════════════════════════════════════ +// WEBSOCKET DEBUG CLIENTS +// ═══════════════════════════════════════════════════════════════ + +const debugClients = new Map< + any, + { interval: number; timer: ReturnType } +>(); +const DEBUG_DEFAULT_INTERVAL_MS = 1000; +const DEBUG_MIN_INTERVAL_MS = 100; + // ═══════════════════════════════════════════════════════════════ // RENDER CACHE + QUEUE // ═══════════════════════════════════════════════════════════════ @@ -907,38 +957,7 @@ async function handleAPI(req: Request, path: string): Promise { // GET /api/debug if (path === "/api/debug" && method === "GET") { - const viewsDebug = viewCacheAll().map((v) => { - const idx = getCurrentURLIndex(v); - return { - id: v.id, - name: v.name, - urls: v.urls, - method: v.method, - metaRefreshEnabled: v.metaRefreshEnabled, - cacheTtlSec: v.cacheTtlSec, - viewportWidth: v.viewportWidth, - viewportHeight: v.viewportHeight, - currentUrlIndex: idx, - currentUrl: v.urls[idx]?.url ?? "", - remainingSec: getRemainingSec(v), - totalCycleSec: v.urls.reduce((s, u) => s + u.durationSec, 0), - cacheEntries: cacheStats().filter((c) => c.viewId === v.id), - }; - }); - - return json({ - server: { - uptime: Math.round(process.uptime()), - activeRenders: pagePool?.activeRenders ?? 0, - queueLength: renderQueue.length, - cacheEntries: renderCache.size, - browsers: CONFIG.BROWSER_COUNT, - pagesPerBrowser: CONFIG.PAGES_PER_BROWSER, - maxConcurrentRenders: CONFIG.MAX_CONCURRENT_RENDERS, - }, - views: viewsDebug, - devices: devicesSnapshot(), - }); + return json(getDebugData()); } // POST /api/views @@ -1064,13 +1083,69 @@ async function serveStatic(path: string): Promise { // SERVER // ═══════════════════════════════════════════════════════════════ -const server = Bun.serve({ +const server = Bun.serve<{ interval: number }>({ port: CONFIG.PORT, + 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) { @@ -1089,7 +1164,12 @@ const server = Bun.serve({ }); } - deviceTrack(ip, view.id, view.name, req.headers.get("User-Agent") ?? "unknown"); + deviceTrack( + ip, + view.id, + view.name, + req.headers.get("User-Agent") ?? "unknown", + ); return serveView(view); } -- 2.54.0 From 53944c44d3195c37a97f65e71d4039376eb134ce Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 08:52:24 +0200 Subject: [PATCH 06/12] fix(server): resolve localStorage SecurityError, request timeout, and navigation timeout issues - Replace localStorage.clear() with CDP Storage.clearDataForOrigin to avoid SecurityError on about:blank (opaque origin) - Add idleTimeout to Bun.serve config to prevent 10s timeout during queued renders - Add timeout fallback for networkidle0 in renderSSR and renderScreenshotFrame to handle sites that never reach idle state - Add recursive CSS @import resolver with font service filtering - Preserve media attribute when inlining CSS --- src/server.ts | 91 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/src/server.ts b/src/server.ts index 035dd87..81dd532 100644 --- a/src/server.ts +++ b/src/server.ts @@ -345,20 +345,19 @@ class PagePool { async resetPage(page: Page): Promise { try { await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 }); - await page.evaluate(() => { - localStorage.clear(); - sessionStorage.clear(); - }); + + // 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) { - // If reset fails (e.g. page already detached), close it and signal failure console.warn(`[page-pool] resetPage failed, discarding page: ${err}`); - try { - await page.close(); - } catch {} + try { await page.close(); } catch {} return false; } } @@ -674,6 +673,37 @@ function stripScriptTags(html: string): string { .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; +} + async function inlineAllResources(page: Page, html: string): Promise { interface InlinePayload { css: Array<{ url: string; text: string }>; @@ -781,9 +811,24 @@ async function inlineAllResources(page: Page, html: string): Promise { 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( - new RegExp(`]*\\bhref\\s*=\\s*"${escaped}"[^>]*>`, "gi"), - ``, + linkRegex, + `/* inlined: ${css.url} */\n${resolvedCSS}\n` ); } for (const img of payload.images) { @@ -824,7 +869,18 @@ async function renderSSR( const page = await pagePool.acquire(); try { await page.setViewport({ width: viewportWidth, height: viewportHeight }); - await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS }); + + // 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); html = stripScriptTags(html); @@ -846,7 +902,17 @@ async function renderScreenshotFrame( const page = await pagePool.acquire(); try { await page.setViewport({ width: viewportWidth, height: viewportHeight }); - await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS }); + + 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); @@ -1085,6 +1151,7 @@ async function serveStatic(path: string): Promise { 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; -- 2.54.0 From aadb241986836c5361f486de0c7429e60e17ba51 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 08:52:25 +0200 Subject: [PATCH 07/12] ops: add Docker deployment configuration --- Dockerfile | 12 ++++++++++++ compose.yaml | 0 2 files changed, 12 insertions(+) create mode 100644 Dockerfile create mode 100644 compose.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0492728 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM bun:latest + +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --production + +COPY . . + +EXPOSE 3099 + +CMD ["bun", "run", "start"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..e69de29 -- 2.54.0 From 7c07454e7ab0696fb3d50c823cbc8093de63ccb7 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 08:53:01 +0200 Subject: [PATCH 08/12] chore: ignore views.db database file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3c3629e..5d98851 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +views.db -- 2.54.0 From b69d830fc893353ca720bf47a4229c26db1d8613 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 10:31:43 +0200 Subject: [PATCH 09/12] build: update Docker configuration with chromium and compose setup --- Dockerfile | 9 ++++++++- compose.yaml | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0492728..5482fba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,14 @@ -FROM bun:latest +FROM oven/bun:latest + +LABEL org.opencontainers.image.source="https://gitea.bigoscloud.com/biggy/samsung-tv-server" \ + org.opencontainers.image.title="Samsung TV Server" \ + org.opencontainers.image.description="Samsung TV Server — Bun runtime" \ + org.opencontainers.image.licenses="MIT" WORKDIR /app +RUN apt-get update && apt-get install -y chromium && rm -rf /var/lib/apt/lists/* + COPY package.json bun.lock ./ RUN bun install --production diff --git a/compose.yaml b/compose.yaml index e69de29..fbc7ab5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -0,0 +1,12 @@ +services: + samsung-tv-server: + image: gitea.bigoscloud.com/biggy/samsung-tv-server:latest + build: . + ports: + - "3099:3099" + volumes: + - views_db:/app/data + restart: unless-stopped + +volumes: + views_db: \ No newline at end of file -- 2.54.0 From c294c8c85e583b877c0914ca690ac913a12c8070 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 10:31:46 +0200 Subject: [PATCH 10/12] feat: add dark mode and style baking support for Samsung TV compatibility --- public/index.html | 49 ++++++++++++++-- src/server.ts | 138 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 176 insertions(+), 11 deletions(-) diff --git a/public/index.html b/public/index.html index ed7d68a..bc97ca1 100644 --- a/public/index.html +++ b/public/index.html @@ -299,6 +299,21 @@ letter-spacing: 0; color: var(--text); } + .dark-toggle { + display: flex; + align-items: center; + gap: 3px; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + user-select: none; + } + .dark-toggle input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: var(--accent); + } .hint { font-size: 12px; color: var(--muted); @@ -700,7 +715,9 @@ container.innerHTML = views .map((v) => { const viewUrl = origin + "/v/" + v.id; - const urlPreview = v.urls.map((u, i) => `${u.url} (${u.durationSec}s)`).join(" → "); + const urlPreview = v.urls.map((u, i) => + `${u.url} (${u.durationSec}s)${u.forceDarkMode ? " 🌙" : ""}` + ).join(" → "); const badges = [ `${v.method.toUpperCase()}`, v.metaRefreshEnabled ? 'META-REFRESH' : "", @@ -795,7 +812,7 @@ const idAttr = isEdit ? ` data-id="${existing.id}"` : ""; const urls = existing ? existing.urls : [{ url: "", durationSec: 30 }]; let urlRowsHTML = urls - .map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length)) + .map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length, u.forceDarkMode)) .join(""); const method = existing ? existing.method : "ssr"; const metaChecked = existing ? existing.metaRefreshEnabled : false; @@ -803,6 +820,8 @@ const cacheTtl = existing ? existing.cacheTtlSec : 60; const vpW = existing ? existing.viewportWidth : 1920; const vpH = existing ? existing.viewportHeight : 1080; + const bakeStylesChecked = existing ? existing.bakeStyles : false; + const bakeSectionStyle = method === "ssr" ? "" : "display:none;"; return `

${title}

@@ -836,6 +855,14 @@
TV reloads when rotation advances. Required for multi-URL on non-JS browsers.
+
+
+ + +
+
Fixes styling issues on browsers with limited CSS support (e.g., DuckDuckGo). Increases HTML size.
+
+
@@ -862,14 +889,19 @@ `; } - function buildUrlRowHTML(url, durationSec, index, total) { + function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode) { const isSingle = total <= 1; const disabledAttr = isSingle ? "disabled" : ""; const grayStyle = isSingle ? 'style="opacity:0.4;"' : ""; + const darkChecked = forceDarkMode ? "checked" : ""; return `
+
`; } @@ -882,7 +914,7 @@ const container = document.getElementById("url-rows"); const rows = container.querySelectorAll(".url-row"); const div = document.createElement("div"); - div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1); + div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1, false); container.appendChild(div.firstElementChild); updateUrlRowStates(); } @@ -914,6 +946,7 @@ function onMethodChange() { const method = document.getElementById("view-method").value; document.getElementById("meta-section").style.display = method === "ssr" ? "" : "none"; + document.getElementById("bake-section").style.display = method === "ssr" ? "" : "none"; } // ═══════════════════════════════════════════════ @@ -929,6 +962,7 @@ const name = document.getElementById("view-name").value.trim(); const method = document.getElementById("view-method").value; const metaRefreshEnabled = document.getElementById("meta-refresh").checked; + const bakeStyles = document.getElementById("bake-styles")?.checked ?? false; const cacheTtlSec = parseInt(document.getElementById("view-cache-ttl").value) || 60; const viewportWidth = parseInt(document.getElementById("viewport-width").value) || 1920; const viewportHeight = parseInt(document.getElementById("viewport-height").value) || 1080; @@ -937,9 +971,11 @@ for (const row of document.querySelectorAll("#url-rows .url-row")) { const urlVal = row.querySelector(".url-input").value.trim(); if (!urlVal) continue; + const forceDarkMode = row.querySelector(".dark-mode-input")?.checked ?? false; urls.push({ url: urlVal, durationSec: parseInt(row.querySelector(".duration-input").value) || 30, + forceDarkMode, }); } @@ -953,7 +989,10 @@ } const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight }; - if (method === "ssr") body.metaRefreshEnabled = metaRefreshEnabled; + if (method === "ssr") { + body.metaRefreshEnabled = metaRefreshEnabled; + body.bakeStyles = bakeStyles; + } try { if (isEdit) { diff --git a/src/server.ts b/src/server.ts index 81dd532..c8f74a0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,6 +25,7 @@ const CONFIG = { interface URLItem { url: string; durationSec: number; + forceDarkMode?: boolean; } interface View { @@ -36,6 +37,7 @@ interface View { cacheTtlSec: number; viewportWidth: number; viewportHeight: number; + bakeStyles: boolean; createdAt: number; } @@ -48,6 +50,7 @@ interface ViewRow { cache_ttl_sec: number; viewport_width: number; viewport_height: number; + bake_styles: number; created_at: number; } @@ -59,6 +62,7 @@ interface CreateViewBody { cacheTtlSec?: number; viewportWidth?: number; viewportHeight?: number; + bakeStyles?: boolean; } type UpdateViewBody = Partial; @@ -96,7 +100,7 @@ interface DeviceEntry { // DATABASE // ═══════════════════════════════════════════════════════════════ -const db = new Database("views.db", { create: true }); +const db = new Database("/app/data/views.db", { create: true }); db.run(` CREATE TABLE IF NOT EXISTS views ( @@ -108,6 +112,7 @@ db.run(` 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()) ) `); @@ -122,6 +127,9 @@ try { 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 { @@ -133,6 +141,7 @@ function rowToView(row: ViewRow): View { cacheTtlSec: row.cache_ttl_sec ?? 60, viewportWidth: row.viewport_width ?? 1920, viewportHeight: row.viewport_height ?? 1080, + bakeStyles: row.bake_styles === 1, createdAt: row.created_at, }; } @@ -150,8 +159,8 @@ function dbLoad(id: string): View | 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 (?,?,?,?,?,?,?,?,?)`, + `INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,bake_styles,created_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, [ v.id, v.name, @@ -161,6 +170,7 @@ function dbInsert(v: View): void { v.cacheTtlSec, v.viewportWidth, v.viewportHeight, + v.bakeStyles ? 1 : 0, v.createdAt, ], ); @@ -168,7 +178,7 @@ function dbInsert(v: View): void { 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=?", + "UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=?,bake_styles=? WHERE id=?", [ v.name, JSON.stringify(v.urls), @@ -177,6 +187,7 @@ function dbUpdate(v: View): void { v.cacheTtlSec, v.viewportWidth, v.viewportHeight, + v.bakeStyles ? 1 : 0, v.id, ], ); @@ -345,6 +356,7 @@ class PagePool { 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(); @@ -520,13 +532,19 @@ function drainRenderQueue(): void { async function renderViewToCache(view: View, urlIndex: number, key: string): Promise { const urlItem = view.urls[urlIndex]; + const forceDarkMode = urlItem.forceDarkMode ?? false; + const bakeStyles = view.bakeStyles ?? false; let body: string; if (view.method === "mjpeg") { - const jpeg = await renderScreenshotFrame(urlItem.url, view.viewportWidth, view.viewportHeight); + 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); + body = await renderSSR( + urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode, bakeStyles, + ); } const now = Date.now(); @@ -704,6 +722,94 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P 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). + * Only bakes styles that differ from the element's default computed styles. + */ +async function bakeComputedStyles(page: Page): Promise { + await page.evaluate((): void => { + // Create a dummy element to get default styles for comparison + const dummy = document.createElement("div"); + dummy.style.cssText = "position:absolute;visibility:hidden;"; + document.body.appendChild(dummy); + + 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", "border-radius", "border-top", "border-right", "border-bottom", "border-left", + "border-width", "border-style", "border-color", + // Colors & Background + "color", "background", "background-color", "background-image", "background-size", "background-position", + "background-repeat", "background-attachment", + // Typography + "font", "font-family", "font-size", "font-weight", "font-style", "font-variant", + "line-height", "text-align", "text-decoration", "text-transform", "letter-spacing", "word-spacing", + "white-space", "overflow-wrap", "word-break", + // Visual + "opacity", "visibility", "z-index", "overflow", "overflow-x", "overflow-y", + "box-shadow", "text-shadow", "transform", "transform-origin", + // List/Table + "list-style", "border-collapse", "border-spacing", + ]; + + function getDefaultValue(prop: string): string { + return window.getComputedStyle(dummy).getPropertyValue(prop); + } + + function shouldBake(prop: string, value: string): boolean { + if (!value || value === "none" || value === "auto" || value === "normal") return false; + if (value === "0px" || value === "0" || value === "rgba(0, 0, 0, 0)") return false; + if (value.includes("initial") || value.includes("inherit")) return false; + return value !== getDefaultValue(prop); + } + + // Walk all elements and bake computed styles + const elements = document.querySelectorAll("*"); + for (const el of elements) { + // Skip script, style, meta, link tags + const tag = el.tagName.toLowerCase(); + if (["script", "style", "meta", "link", "noscript", "template"].includes(tag)) continue; + + const computed = window.getComputedStyle(el); + const stylesToBake: string[] = []; + + for (const prop of computedProperties) { + const value = computed.getPropertyValue(prop); + if (shouldBake(prop, value)) { + stylesToBake.push(`${prop}:${value}`); + } + } + + if (stylesToBake.length > 0) { + // Merge with existing inline styles, preserving user-set inline styles + const existingInline = el.getAttribute("style") || ""; + const bakedStyles = stylesToBake.join(";"); + if (existingInline) { + // User inline styles take precedence - put them last + el.setAttribute("style", `${bakedStyles};${existingInline}`); + } else { + el.setAttribute("style", bakedStyles); + } + } + } + + dummy.remove(); + }); +} + async function inlineAllResources(page: Page, html: string): Promise { interface InlinePayload { css: Array<{ url: string; text: string }>; @@ -865,10 +971,15 @@ 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 { @@ -883,6 +994,13 @@ async function renderSSR( 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 { @@ -898,10 +1016,14 @@ 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 }); @@ -1046,12 +1168,14 @@ async function handleAPI(req: Request, path: string): Promise { urls: body.urls.map((u) => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), + forceDarkMode: u.forceDarkMode ?? 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)), + bakeStyles: body.bakeStyles ?? false, createdAt: Math.floor(Date.now() / 1000), }; @@ -1087,6 +1211,7 @@ async function handleAPI(req: Request, path: string): Promise { view.urls = body.urls.map((u) => ({ url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), + forceDarkMode: u.forceDarkMode ?? false, })); } if (body.method !== undefined) { @@ -1101,6 +1226,7 @@ async function handleAPI(req: Request, path: string): Promise { view.viewportWidth = Math.max(320, Math.min(7680, body.viewportWidth)); if (body.viewportHeight !== undefined) view.viewportHeight = Math.max(240, Math.min(4320, body.viewportHeight)); + if (body.bakeStyles !== undefined) view.bakeStyles = body.bakeStyles; dbUpdate(view); cacheInvalidate(viewId); -- 2.54.0 From 807ce639a7970f62d698003084ee52d100831003 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 10:31:48 +0200 Subject: [PATCH 11/12] chore: remove obsolete database file --- views.db | Bin 12288 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 views.db diff --git a/views.db b/views.db deleted file mode 100644 index fb45d9e5cfb04d5cc334934d208a13fcd80a2bda..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI$(Qnc~90%~bjyRbi=u_j9m-+xCz*@EtATbfOnngr~lDI6h>DnK(6WZDyvc#CU z#0OvegZ4-GuK$Vg$!i&#q6Fic$?un5%fWri_0zsw`_DTb2R00%iN)CtxlSl0cNrsu zWJTK$ZBbNw$SgYInDp0GmQ+7|&*i@nDR)M4XZg?T0{A-$pa2S>01BW03ZMWApa2S> zz>>g+8+22aW%`kF>u3ybJebAlPP*UI+lJ1J_PvhI(yv%?i;41_WWQX7{=#6-dV5dW zy?ypr-!G?QeQN^CBNyx39fK7P4h!?t1h^agHl1Hi zf0>Ho=rU%R5ey@UT@(D|!o^AUx`zHp@3FSWGkmi-Ti|mH1D!GFY7Y-U)<(?G{TVao0*E z>56nc=^G-tOmw3T0v=o=~Fm7#c&%$arR?3PqjV$g3ejjY5rK#`A%LARM{VHjrplSz` zKO_z($03ZQ&G(tcaVGb5OI#Pte<5PSLID&&0Te(16hHwKKmim$0Te(16!^~s3LAs$ I-@I@50Y|9a2LJ#7 -- 2.54.0 From f86b0411d534bdc8fbcbbd85d7bd424c746bc7e2 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 10:54:59 +0200 Subject: [PATCH 12/12] refactor: move bakeStyles from view-level to per-URL setting --- public/index.html | 55 +++++++++++++++--------- src/server.ts | 106 ++++++++++++++++++++++++++-------------------- 2 files changed, 95 insertions(+), 66 deletions(-) diff --git a/public/index.html b/public/index.html index bc97ca1..ca71948 100644 --- a/public/index.html +++ b/public/index.html @@ -175,6 +175,10 @@ background: rgba(255, 193, 7, 0.12); color: var(--yellow); } + .badge-bake { + background: rgba(46, 204, 113, 0.1); + color: var(--green); + } .card-url-preview { font-size: 13px; color: var(--muted); @@ -314,6 +318,21 @@ cursor: pointer; accent-color: var(--accent); } + .bake-toggle { + display: flex; + align-items: center; + gap: 3px; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + user-select: none; + } + .bake-toggle input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: var(--green); + } .hint { font-size: 12px; color: var(--muted); @@ -716,11 +735,13 @@ .map((v) => { const viewUrl = origin + "/v/" + v.id; const urlPreview = v.urls.map((u, i) => - `${u.url} (${u.durationSec}s)${u.forceDarkMode ? " 🌙" : ""}` + `${u.url} (${u.durationSec}s)${u.forceDarkMode ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}` ).join(" → "); + const anyBake = v.urls.some((u) => u.bakeStyles); const badges = [ `${v.method.toUpperCase()}`, v.metaRefreshEnabled ? 'META-REFRESH' : "", + anyBake ? 'BAKE' : "", ] .filter(Boolean) .join(" "); @@ -812,7 +833,7 @@ const idAttr = isEdit ? ` data-id="${existing.id}"` : ""; const urls = existing ? existing.urls : [{ url: "", durationSec: 30 }]; let urlRowsHTML = urls - .map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length, u.forceDarkMode)) + .map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length, u.forceDarkMode, u.bakeStyles)) .join(""); const method = existing ? existing.method : "ssr"; const metaChecked = existing ? existing.metaRefreshEnabled : false; @@ -820,8 +841,6 @@ const cacheTtl = existing ? existing.cacheTtlSec : 60; const vpW = existing ? existing.viewportWidth : 1920; const vpH = existing ? existing.viewportHeight : 1080; - const bakeStylesChecked = existing ? existing.bakeStyles : false; - const bakeSectionStyle = method === "ssr" ? "" : "display:none;"; return `

${title}

@@ -855,14 +874,6 @@
TV reloads when rotation advances. Required for multi-URL on non-JS browsers.
-
-
- - -
-
Fixes styling issues on browsers with limited CSS support (e.g., DuckDuckGo). Increases HTML size.
-
-
@@ -889,11 +900,12 @@ `; } - function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode) { + function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode, bakeStyles) { const isSingle = total <= 1; const disabledAttr = isSingle ? "disabled" : ""; const grayStyle = isSingle ? 'style="opacity:0.4;"' : ""; const darkChecked = forceDarkMode ? "checked" : ""; + const bakeChecked = bakeStyles ? "checked" : ""; return `
@@ -902,6 +914,10 @@ 🌙 +
`; } @@ -914,7 +930,7 @@ const container = document.getElementById("url-rows"); const rows = container.querySelectorAll(".url-row"); const div = document.createElement("div"); - div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1, false); + div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1, false, false); container.appendChild(div.firstElementChild); updateUrlRowStates(); } @@ -946,7 +962,6 @@ function onMethodChange() { const method = document.getElementById("view-method").value; document.getElementById("meta-section").style.display = method === "ssr" ? "" : "none"; - document.getElementById("bake-section").style.display = method === "ssr" ? "" : "none"; } // ═══════════════════════════════════════════════ @@ -962,7 +977,6 @@ const name = document.getElementById("view-name").value.trim(); const method = document.getElementById("view-method").value; const metaRefreshEnabled = document.getElementById("meta-refresh").checked; - const bakeStyles = document.getElementById("bake-styles")?.checked ?? false; const cacheTtlSec = parseInt(document.getElementById("view-cache-ttl").value) || 60; const viewportWidth = parseInt(document.getElementById("viewport-width").value) || 1920; const viewportHeight = parseInt(document.getElementById("viewport-height").value) || 1080; @@ -972,10 +986,12 @@ const urlVal = row.querySelector(".url-input").value.trim(); if (!urlVal) continue; const forceDarkMode = row.querySelector(".dark-mode-input")?.checked ?? false; + const bakeStyles = row.querySelector(".bake-styles-input")?.checked ?? false; urls.push({ url: urlVal, durationSec: parseInt(row.querySelector(".duration-input").value) || 30, forceDarkMode, + bakeStyles, }); } @@ -988,11 +1004,7 @@ return; } - const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight }; - if (method === "ssr") { - body.metaRefreshEnabled = metaRefreshEnabled; - body.bakeStyles = bakeStyles; - } + const body = { name, urls, method, cacheTtlSec, viewportWidth, viewportHeight, metaRefreshEnabled }; try { if (isEdit) { @@ -1049,6 +1061,7 @@ debugSocket.close(); debugSocket = null; } + if (debugTickInterval) { clearInterval(debugTickInterval); debugTickInterval = null; } } function setDebugInterval(ms) { diff --git a/src/server.ts b/src/server.ts index c8f74a0..8bce0db 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,6 +26,7 @@ interface URLItem { url: string; durationSec: number; forceDarkMode?: boolean; + bakeStyles?: boolean; } interface View { @@ -37,7 +38,6 @@ interface View { cacheTtlSec: number; viewportWidth: number; viewportHeight: number; - bakeStyles: boolean; createdAt: number; } @@ -50,7 +50,6 @@ interface ViewRow { cache_ttl_sec: number; viewport_width: number; viewport_height: number; - bake_styles: number; created_at: number; } @@ -62,7 +61,6 @@ interface CreateViewBody { cacheTtlSec?: number; viewportWidth?: number; viewportHeight?: number; - bakeStyles?: boolean; } type UpdateViewBody = Partial; @@ -141,7 +139,6 @@ function rowToView(row: ViewRow): View { cacheTtlSec: row.cache_ttl_sec ?? 60, viewportWidth: row.viewport_width ?? 1920, viewportHeight: row.viewport_height ?? 1080, - bakeStyles: row.bake_styles === 1, createdAt: row.created_at, }; } @@ -159,8 +156,8 @@ function dbLoad(id: string): View | 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,bake_styles,created_at) - VALUES (?,?,?,?,?,?,?,?,?,?)`, + `INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at) + VALUES (?,?,?,?,?,?,?,?,?)`, [ v.id, v.name, @@ -170,7 +167,6 @@ function dbInsert(v: View): void { v.cacheTtlSec, v.viewportWidth, v.viewportHeight, - v.bakeStyles ? 1 : 0, v.createdAt, ], ); @@ -178,7 +174,7 @@ function dbInsert(v: View): void { function dbUpdate(v: View): void { db.run( - "UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=?,bake_styles=? WHERE id=?", + "UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=? WHERE id=?", [ v.name, JSON.stringify(v.urls), @@ -187,7 +183,6 @@ function dbUpdate(v: View): void { v.cacheTtlSec, v.viewportWidth, v.viewportHeight, - v.bakeStyles ? 1 : 0, v.id, ], ); @@ -521,9 +516,11 @@ function drainRenderQueue(): void { 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); }, ); @@ -533,7 +530,7 @@ function drainRenderQueue(): void { async function renderViewToCache(view: View, urlIndex: number, key: string): Promise { const urlItem = view.urls[urlIndex]; const forceDarkMode = urlItem.forceDarkMode ?? false; - const bakeStyles = view.bakeStyles ?? false; + const bakeStyles = urlItem.bakeStyles ?? false; let body: string; if (view.method === "mjpeg") { @@ -620,7 +617,7 @@ function cacheStats(): Array<{ }> = []; for (const [key, entry] of renderCache) { - const [viewId, idxStr] = key.split(":"); + const [viewId] = key.split(":"); const view = viewCacheGet(viewId); result.push({ key, @@ -729,15 +726,11 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P /** * Bake computed styles into inline style attributes. * This ensures styles work on browsers with limited CSS support (e.g., Samsung TV). - * Only bakes styles that differ from the element's default computed styles. + * 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 => { - // Create a dummy element to get default styles for comparison - const dummy = document.createElement("div"); - dummy.style.cssText = "position:absolute;visibility:hidden;"; - document.body.appendChild(dummy); - const computedProperties = [ // Layout "display", "position", "top", "right", "bottom", "left", @@ -749,64 +742,86 @@ async function bakeComputedStyles(page: Page): Promise { "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", "border-radius", "border-top", "border-right", "border-bottom", "border-left", - "border-width", "border-style", "border-color", + "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", "background-color", "background-image", "background-size", "background-position", - "background-repeat", "background-attachment", + "color", "background-color", "background-image", "background-size", "background-position", + "background-repeat", "background-attachment", "background-clip", "background-origin", // Typography - "font", "font-family", "font-size", "font-weight", "font-style", "font-variant", - "line-height", "text-align", "text-decoration", "text-transform", "letter-spacing", "word-spacing", + "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", "overflow-x", "overflow-y", + "opacity", "visibility", "z-index", "overflow-x", "overflow-y", "box-shadow", "text-shadow", "transform", "transform-origin", // List/Table - "list-style", "border-collapse", "border-spacing", + "list-style-type", "list-style-position", "border-collapse", "border-spacing", + // Cursor/pointer + "cursor", "pointer-events", ]; - function getDefaultValue(prop: string): string { - return window.getComputedStyle(dummy).getPropertyValue(prop); + // 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(prop: string, value: string): boolean { - if (!value || value === "none" || value === "auto" || value === "normal") return false; - if (value === "0px" || value === "0" || value === "rgba(0, 0, 0, 0)") return false; - if (value.includes("initial") || value.includes("inherit")) return false; - return value !== getDefaultValue(prop); + 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) { - // Skip script, style, meta, link tags + if (el === hiddenContainer) continue; + // Skip non-visual tags const tag = el.tagName.toLowerCase(); - if (["script", "style", "meta", "link", "noscript", "template"].includes(tag)) continue; + 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); - if (shouldBake(prop, value)) { + const defaultValue = defaults.get(prop) ?? ""; + if (shouldBake(value, defaultValue)) { stylesToBake.push(`${prop}:${value}`); } } if (stylesToBake.length > 0) { - // Merge with existing inline styles, preserving user-set inline styles + // Existing inline styles take precedence — put them after baked styles const existingInline = el.getAttribute("style") || ""; const bakedStyles = stylesToBake.join(";"); - if (existingInline) { - // User inline styles take precedence - put them last - el.setAttribute("style", `${bakedStyles};${existingInline}`); - } else { - el.setAttribute("style", bakedStyles); - } + el.setAttribute("style", existingInline ? `${bakedStyles};${existingInline}` : bakedStyles); } } - dummy.remove(); + hiddenContainer.remove(); }); } @@ -1169,13 +1184,13 @@ async function handleAPI(req: Request, path: string): Promise { 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)), - bakeStyles: body.bakeStyles ?? false, createdAt: Math.floor(Date.now() / 1000), }; @@ -1212,6 +1227,7 @@ async function handleAPI(req: Request, path: string): Promise { url: u.url.trim(), durationSec: Math.max(1, Math.round(u.durationSec)), forceDarkMode: u.forceDarkMode ?? false, + bakeStyles: u.bakeStyles ?? false, })); } if (body.method !== undefined) { @@ -1226,7 +1242,6 @@ async function handleAPI(req: Request, path: string): Promise { view.viewportWidth = Math.max(320, Math.min(7680, body.viewportWidth)); if (body.viewportHeight !== undefined) view.viewportHeight = Math.max(240, Math.min(4320, body.viewportHeight)); - if (body.bakeStyles !== undefined) view.bakeStyles = body.bakeStyles; dbUpdate(view); cacheInvalidate(viewId); @@ -1258,6 +1273,7 @@ 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(); -- 2.54.0