Files
samsung-tv-server/src/server.ts
T
biggy 3a21070d62 chore: static analysis baseline
Pre-analysis checkpoint before implementing fixes for:
- bakeStyles field mapping
- error logging improvements
- HTML sanitization hardening
2026-06-24 07:46:05 +02:00

1877 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ╔══════════════════════════════════════════════════════════════════════╗
* ║ SAMSUNG TV PROXY — Bun + Puppeteer SSR Server ║
* ╚══════════════════════════════════════════════════════════════════════╝
*
* Renders web pages to SSR HTML (with full CSS/resource inlining) or MJPEG
* screenshots, served to Samsung TVs (Tizen browser) and other limited
* clients. Multi-browser page-pool, render-cache, URL-rotation views,
* WebSocket debug dashboard, IP ban management, and device tracking.
*
* ─────────────────────────────────────────────────────────────────────────
* QUICK-SEARCH INDEX — jump by section name or semantic topic:
* ─────────────────────────────────────────────────────────────────────────
*
* SEC | ~LINE | FLAG / TOPIC | WHAT IT DOES
* ────┼───────┼───────────────────────────────────┼───────────────────────
* 01 | 35 | CONFIG | All tunables (pool, timeouts, intervals)
* 02 | 57 | TYPES / interfaces | View, URLItem, CachedRender, DeviceEntry…
* 03 | 115 | DATABASE / SQLite / schema | views + banned_ips tables, migrations
* 04 | 158 | DB HELPERS / dbLoad / dbInsert | CRUD: dbLoadAll, dbInsert, dbUpdate, dbDelete
* 05 | 202 | BAN SYSTEM / dbBanIP / dbIsBanned | IP ban persistence layer
* 06 | 219 | VIEW CACHE / viewCache | In-memory mirror of DB views table
* 07 | 230 | DEVICE TRACKING / devices | Per-IP tracking, ban gate, warmup trigger
* 08 | 252 | DEBUG DATA / getDebugData | Aggregates server+views+devices for API/WS
* 09 | 285 | PAGE POOL / PagePool / acquire | Multi-browser page lifecycle (acquire/release/reset)
* 10 | 383 | WS DEBUG / debugClients | Real-time debug-over-WebSocket push
* 11 | 393 | RENDER CACHE / renderCache | URL→HTML cache with TTL, inFlight tracking
* 12 | 417 | WARMUP / warmupView / warmupCache | Pre-cache active-device views on interval
* 13 | 461 | CACHE INVALIDATION / invalidate | Purge all cache entries for a view
* 14 | 475 | RENDER QUEUE / renderQueue | FIFO queue with timeout; drains into PagePool
* 15 | 506 | RENDER ENGINE / renderViewToCache | Core: SSR or MJPEG render → cache write
* 16 | 549 | GET-OR-RENDER / getOrRender | Cache-hit → inFlight → queue → render decision tree
* 17 | 590 | CACHE STATS / cacheStats | Telemetry: per-cache-entry TTL/URL info
* 18 | 620 | ROTATION / getCurrentURLIndex | Time-based URL cycling (+ remainingSec)
* 19 | 645 | SSR INLINING / injectMetaRefresh | Meta-refresh injection, script stripping
* 20 | 663 | CSS IMPORT RESOLVER / @import | Recursive @import resolver (max 3 levels)
* 21 | 694 | COMPUTED STYLE BAKING / bake | Inline computed styles for Tizen compat
* 22 | 777 | RESOURCE INLINING / inlineAll | CSS/images/favicons/bg→data: URIs in-page
* 23 | 887 | SSR RENDER / renderSSR | Full SSR pipeline: acquire→goto→inline→strip
* 24 | 932 | SCREENSHOT / renderScreenshotFrame| JPEG screenshot renderer (MJPEG mode)
* 25 | 961 | SCREENSHOT HTML / screenshotHTML | Wrap JPEG base64 in refresh-page HTML
* 26 | 981 | ERROR PAGE / errorPage | Styled error HTML with auto-retry meta-refresh
* 27 | 1002 | VIEW SERVING / serveView | /v/:id → rotation→render→response pipeline
* 28 | 1032 | API / handleAPI | REST: CRUD views, ban/unban, disconnect, refresh
* 29 | 1181 | STATIC FILES / serveStatic | Serves ../public (SPA fallback to index.html)
* 30 | 1198 | HTTP SERVER / Bun.serve | Main fetch handler: /ws, /v/:id, /api, static
* 31 | 1293 | INIT+SHUTDOWN / init / shutdown | Bootstrap + graceful drain + signal handlers
*
* ─────────────────────────────────────────────────────────────────────────
* DATA-FLOW MAP (how a GET /v/:id request travels):
* ─────────────────────────────────────────────────────────────────────────
*
* Client GET /v/:id
* │
* ▼
* Bun.serve.fetch() [ SEC 30 ]
* │
* ├─ deviceTrack(ip…) [ SEC 07 ] ← blocks banned IPs
* │
* ▼
* serveView(view) [ SEC 27 ]
* │
* ├─ getCurrentURLIndex(view) [ SEC 18 ] ← time-based rotation
* │
* ▼
* getOrRender(view, idx) [ SEC 16 ]
* │
* ├── cache HIT? ──▶ return cached body
* ├── inFlight? ──▶ await existing promise
* ├── pool free? ──▶ renderViewToCache() [ SEC 15 ]
* │ │
* │ ├─ renderSSR() [ SEC 23 ]
* │ │ ├─ pagePool.acquire() [ SEC 09 ]
* │ │ ├─ page.goto(url)
* │ │ ├─ inlineAllResources() [ SEC 22 ]
* │ │ │ └─ resolveCSSImports() [ SEC 20 ]
* │ │ ├─ [opt] bakeComputedStyles() [ SEC 21 ]
* │ │ ├─ stripScriptTags()
* │ │ └─ pagePool.release()
* │ │
* │ └─ renderScreenshotFrame() [ SEC 24 ]
* │ └─ screenshotHTML() [ SEC 25 ]
* │
* └── pool full? ──▶ enqueue → drainRenderQueue() [ SEC 14 ]
*
* ─────────────────────────────────────────────────────────────────────────
* KEY DATA STRUCTURES (grep these to find type definitions & usage):
* ─────────────────────────────────────────────────────────────────────────
*
* View — id, name, urls(URLItem[]), method(ssr|mjpeg), cacheTtl, viewport
* CachedRender — body(string), urlIndex, createdAt, expiresAt
* ActiveRender — promise(Promise<string>), urlIndex (in-flight dedup)
* QueuedRender — resolve/reject callbacks, key, queuedAt (FIFO overflow)
* DeviceEntry — ip, viewId, userAgent, lastSeen, requestCount
* BrowserSlot — browser, available(Page[]), inUse, max
*
* ─────────────────────────────────────────────────────────────────────────
* HOW TO SEARCH THIS FILE (for humans & AIs):
* ─────────────────────────────────────────────────────────────────────────
*
* There is no need to read the file line-by-line. Use grep / text-search
* on any of the keywords below to jump directly to what you need:
*
* "CONFIG" → tunables (port, timeouts, pool sizing)
* "interface View" → View type definition
* "CREATE TABLE" → DB schema
* "dbLoadAll|dbInsert" → DB CRUD operations
* "dbBanIP|banned" → IP banning
* "viewCache" → in-memory view mirror
* "deviceTrack" → device tracking & ban gate
* "class PagePool" → browser/page pool management
* "acquire|release" → page lifecycle
* "renderCache|CachedRender" → render cache
* "getOrRender" → cache/inflight/queue decision
* "renderQueue|drainRenderQueue" → overflow queue
* "renderViewToCache" → actual render execution
* "warmupView|warmupCache" → pre-caching
* "cacheInvalidate" → cache purging
* "getCurrentURLIndex" → URL rotation logic
* "renderSSR" → SSR rendering
* "renderScreenshotFrame" → MJPEG rendering
* "inlineAllResources" → CSS/image inlining
* "bakeComputedStyles" → style baking for Tizen
* "resolveCSSImports" → @import recursion
* "serveView" → /v/:id response builder
* "handleAPI" → REST API handler
* "Bun.serve" → HTTP server entry
* "shutdown" → graceful shutdown
* "getDebugData" → debug telemetry
* "WebSocket|ws" → debug WS
* "errorPage" → error HTML template
* "screenshotHTML" → MJPEG wrapper HTML
*
* ─────────────────────────────────────────────────────────────────────────
* MODIFICATION GUIDE (what to touch when adding/changing features):
* ─────────────────────────────────────────────────────────────────────────
*
* To add a new View field:
* 1. Add to `interface View` [ SEC 02 ]
* 2. Add column migration [ SEC 03 ]
* 3. Update `rowToView()` [ SEC 03 ]
* 4. Update `dbInsert()` / `dbUpdate()` [ SEC 04 ]
* 5. Update `CreateViewBody` [ SEC 02 ]
* 6. Update API handler [ SEC 28 ]
* 7. Update `getDebugData()` [ SEC 08 ] (if visible in debug)
*
* To add a new API endpoint:
* 1. Add route in `handleAPI()` [ SEC 28 ]
* 2. If real-time: add to `getDebugData()` [ SEC 08 ]
* 3. If admin: consider ban/auth gate [ SEC 07 ]
*
* To change rendering behavior:
* - SSR pipeline: `renderSSR()` [ SEC 23 ]
* - MJPEG pipeline: `renderScreenshotFrame()` [ SEC 24 ]
* - Inlining logic: `inlineAllResources()` [ SEC 22 ]
* - Style baking: `bakeComputedStyles()` [ SEC 21 ]
* - Script removal: `stripScriptTags()` [ SEC 19 ]
*
* ─────────────────────────────────────────────────────────────────────────
* EXTERNAL DEPENDENCIES:
* ─────────────────────────────────────────────────────────────────────────
*
* bun:sqlite — embedded SQLite (views.db, banned_ips.db)
* puppeteer — headless Chromium (launched via /usr/bin/chromium)
* Bun — runtime: Bun.file(), Bun.serve(), WebSocket upgrade
* crypto — randomUUID() for view IDs
*
* ═══════════════════════════════════════════════════════════════════════
*/
import { Database } from "bun:sqlite";
import type { Browser, Page } from "puppeteer";
import puppeteer from "puppeteer";
// ═══════════════════════════════════════════════════════════════
// CONFIG
// ═══════════════════════════════════════════════════════════════
const CONFIG = {
PORT: parseInt(process.env.PORT || "3099"),
BROWSER_COUNT: 2,
PAGES_PER_BROWSER: 8,
PAGE_POOL_WARM: 4,
PAGE_ACQUIRE_TIMEOUT_MS: 3000,
RENDER_TIMEOUT_MS: 25000,
MAX_CONCURRENT_RENDERS: 16,
QUEUE_TIMEOUT_MS: 30000,
CACHE_CLEANUP_INTERVAL_MS: 30000,
WARMUP_INTERVAL_MS: 60000,
} as const;
// ═══════════════════════════════════════════════════════════════
// TYPES
// ═══════════════════════════════════════════════════════════════
interface URLItem {
url: string;
durationSec: number;
waitBeforeCaptureMs?: number;
forceDarkMode?: boolean;
bakeStyles?: boolean;
}
interface View {
id: string;
name: string;
urls: URLItem[];
method: "mjpeg" | "ssr";
metaRefreshEnabled: boolean;
cacheTtlSec: number;
viewportWidth: number;
viewportHeight: number;
createdAt: number;
}
interface ViewRow {
id: string;
name: string;
urls: string;
method: string;
meta_refresh_enabled: number;
cache_ttl_sec: number;
viewport_width: number;
viewport_height: number;
created_at: number;
}
interface CreateViewBody {
name: string;
urls: URLItem[];
method: "mjpeg" | "ssr";
metaRefreshEnabled?: boolean;
cacheTtlSec?: number;
viewportWidth?: number;
viewportHeight?: number;
}
type UpdateViewBody = Partial<CreateViewBody>;
interface CachedRender {
body: string;
urlIndex: number;
createdAt: number;
expiresAt: number;
}
interface ActiveRender {
promise: Promise<string>;
urlIndex: number;
}
interface QueuedRender {
resolve: (body: string) => void;
reject: (err: Error) => void;
key: string;
viewId: string;
urlIndex: number;
queuedAt: number;
}
interface DeviceEntry {
ip: string;
viewId: string;
viewName: string;
userAgent: string;
lastSeen: number;
requestCount: number;
}
// ═══════════════════════════════════════════════════════════════
// DATABASE
// ═══════════════════════════════════════════════════════════════
const db = new Database("/app/data/views.db", { create: true });
db.run(`
CREATE TABLE IF NOT EXISTS views (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
urls TEXT NOT NULL DEFAULT '[]',
method TEXT NOT NULL DEFAULT 'ssr',
meta_refresh_enabled INTEGER NOT NULL DEFAULT 0,
cache_ttl_sec INTEGER NOT NULL DEFAULT 60,
viewport_width INTEGER NOT NULL DEFAULT 1920,
viewport_height INTEGER NOT NULL DEFAULT 1080,
bake_styles INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS banned_ips (
ip TEXT PRIMARY KEY,
banned_at INTEGER NOT NULL DEFAULT (unixepoch()),
reason TEXT,
banned_by TEXT
)
`);
// Migrations for upgrading from older schema
try {
db.run("ALTER TABLE views ADD COLUMN cache_ttl_sec INTEGER NOT NULL DEFAULT 60");
} catch {}
try {
db.run("ALTER TABLE views ADD COLUMN viewport_width INTEGER NOT NULL DEFAULT 1920");
} catch {}
try {
db.run("ALTER TABLE views ADD COLUMN viewport_height INTEGER NOT NULL DEFAULT 1080");
} catch {}
try {
db.run("ALTER TABLE views ADD COLUMN bake_styles INTEGER NOT NULL DEFAULT 0");
} catch {}
function rowToView(row: ViewRow): View {
return {
id: row.id,
name: row.name,
urls: JSON.parse(row.urls),
method: row.method as View["method"],
metaRefreshEnabled: row.meta_refresh_enabled === 1,
cacheTtlSec: row.cache_ttl_sec ?? 60,
viewportWidth: row.viewport_width ?? 1920,
viewportHeight: row.viewport_height ?? 1080,
createdAt: row.created_at,
};
}
// Seed a default view on first run (only when table is empty)
const viewCount = (db.query("SELECT COUNT(*) as n FROM views").get() as { n: number }).n;
if (viewCount === 0) {
db.run(
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
[
crypto.randomUUID(),
"Demo View",
JSON.stringify([
{ url: "https://www.google.com", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
{ url: "https://duck.ai", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
{ url: "https://www.msn.com", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
]),
"ssr",
0,
60,
1920,
1080,
Math.floor(Date.now() / 1000),
],
);
}
function dbLoadAll(): View[] {
return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map(
rowToView,
);
}
function dbLoad(id: string): View | null {
const row = db.query("SELECT * FROM views WHERE id = ?").get(id) as ViewRow | undefined;
return row ? rowToView(row) : null;
}
function dbInsert(v: View): void {
db.run(
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
[
v.id,
v.name,
JSON.stringify(v.urls),
v.method,
v.metaRefreshEnabled ? 1 : 0,
v.cacheTtlSec,
v.viewportWidth,
v.viewportHeight,
v.createdAt,
],
);
}
function dbUpdate(v: View): void {
db.run(
"UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=? WHERE id=?",
[
v.name,
JSON.stringify(v.urls),
v.method,
v.metaRefreshEnabled ? 1 : 0,
v.cacheTtlSec,
v.viewportWidth,
v.viewportHeight,
v.id,
],
);
}
function dbDelete(id: string): boolean {
return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0;
}
function dbBanIP(ip: string, reason?: string): void {
db.run(
"INSERT OR REPLACE INTO banned_ips (ip, banned_at, reason, banned_by) VALUES (?, ?, ?, ?)",
[ip, Math.floor(Date.now() / 1000), reason ?? "Manual ban", "admin"],
);
}
function dbUnbanIP(ip: string): boolean {
return db.run("DELETE FROM banned_ips WHERE ip = ?", [ip]).changes > 0;
}
function dbIsBanned(ip: string): boolean {
return db.query("SELECT ip FROM banned_ips WHERE ip = ?").get(ip) != null;
}
function dbGetBannedIPs(): Array<{
ip: string;
banned_at: number;
reason: string;
banned_by: string;
}> {
return db.query("SELECT * FROM banned_ips ORDER BY banned_at DESC").all() as Array<{
ip: string;
banned_at: number;
reason: string;
banned_by: string;
}>;
}
// ═══════════════════════════════════════════════════════════════
// IN-MEMORY VIEW CACHE
// ═══════════════════════════════════════════════════════════════
const viewCache = new Map<string, View>();
function viewCacheInit(): void {
for (const v of dbLoadAll()) viewCache.set(v.id, v);
}
function viewCacheGet(id: string): View | null {
return viewCache.get(id) ?? null;
}
function viewCacheAll(): View[] {
return [...viewCache.values()].sort((a, b) => b.createdAt - a.createdAt);
}
// ═══════════════════════════════════════════════════════════════
// DEVICE TRACKING
// ═══════════════════════════════════════════════════════════════
const devices = new Map<string, DeviceEntry>();
const warmedUpViews = new Set<string>();
const DEVICE_TTL_MS = 120_000;
function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): boolean {
// Check if IP is banned
if (dbIsBanned(ip)) {
console.warn(`[security] Blocked connection from banned IP: ${ip}`);
return false;
}
const existing = devices.get(ip);
if (existing) {
existing.viewId = viewId;
existing.viewName = viewName;
existing.userAgent = userAgent;
existing.lastSeen = Date.now();
existing.requestCount++;
} else {
devices.set(ip, { ip, viewId, viewName, userAgent, lastSeen: Date.now(), requestCount: 1 });
// First user connecting to this view - trigger warmup
if (!warmedUpViews.has(viewId)) {
warmedUpViews.add(viewId);
warmupView(viewId).catch((err) => {
console.error(`[warmup] Failed to warm up view ${viewId}:`, err);
});
}
}
return true;
}
function devicesPrune(): void {
const cutoff = Date.now() - DEVICE_TTL_MS;
for (const [ip, d] of devices) {
if (d.lastSeen < cutoff) devices.delete(ip);
}
}
function devicesSnapshot(): DeviceEntry[] {
devicesPrune();
return [...devices.values()].sort((a, b) => b.lastSeen - a.lastSeen);
}
// ═══════════════════════════════════════════════════════════════
// DEBUG DATA HELPER (used by both REST and WebSocket)
// ═══════════════════════════════════════════════════════════════
function getDebugData() {
const viewsDebug = viewCacheAll().map((v) => {
const idx = getCurrentURLIndex(v);
return {
id: v.id,
name: v.name,
urls: v.urls,
method: v.method,
metaRefreshEnabled: v.metaRefreshEnabled,
cacheTtlSec: v.cacheTtlSec,
viewportWidth: v.viewportWidth,
viewportHeight: v.viewportHeight,
currentUrlIndex: idx,
currentUrl: v.urls[idx]?.url ?? "",
remainingSec: getRemainingSec(v),
totalCycleSec: v.urls.reduce((s, u) => s + u.durationSec, 0),
cacheEntries: cacheStats().filter((c) => c.viewId === v.id),
};
});
return {
server: {
uptime: Math.round(process.uptime()),
activeRenders: pagePool?.activeRenders ?? 0,
queueLength: renderQueue.length,
cacheEntries: renderCache.size,
browsers: CONFIG.BROWSER_COUNT,
pagesPerBrowser: CONFIG.PAGES_PER_BROWSER,
maxConcurrentRenders: CONFIG.MAX_CONCURRENT_RENDERS,
},
views: viewsDebug,
devices: devicesSnapshot(),
};
}
// ═══════════════════════════════════════════════════════════════
// BROWSER + PAGE POOL
// ═══════════════════════════════════════════════════════════════
interface BrowserSlot {
browser: Browser;
available: Page[];
inUse: number;
max: number;
}
class PagePool {
private slots: BrowserSlot[] = [];
private pageToSlot = new WeakMap<Page, BrowserSlot>();
private waiters: Array<{
resolve: (p: Page) => void;
reject: (e: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}> = [];
async init(): Promise<void> {
for (let i = 0; i < CONFIG.BROWSER_COUNT; i++) {
const browser = await puppeteer.launch({
executablePath: "/usr/bin/chromium",
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-software-rasterizer",
"--disable-extensions",
"--mute-audio",
],
});
browser.on("disconnected", () => {
console.error(`[browser-pool] Browser ${i} disconnected`);
});
const slot: BrowserSlot = { browser, available: [], inUse: 0, max: CONFIG.PAGES_PER_BROWSER };
for (let j = 0; j < CONFIG.PAGE_POOL_WARM; j++) {
const page = await this.createPage(slot);
slot.available.push(page);
}
this.slots.push(slot);
}
console.log(
`[browser-pool] ${CONFIG.BROWSER_COUNT} browsers × ${CONFIG.PAGES_PER_BROWSER} pages ready`,
);
}
private async createPage(slot: BrowserSlot): Promise<Page> {
const page = await slot.browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.setRequestInterception(true);
page.on("request", (req) => {
const t = req.resourceType();
if (t === "media" || t === "websocket" || t === "manifest" || t === "font") {
req.abort();
} else {
req.continue();
}
});
this.pageToSlot.set(page, slot);
return page;
}
async resetPage(page: Page): Promise<boolean> {
try {
await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 });
await page.emulateMediaFeatures([]);
// Clear storage via CDP — avoids opaque-origin SecurityError
const client = await page.createCDPSession();
await client.send("Storage.clearDataForOrigin", {
origin: "*",
storageTypes: "all",
});
await client.send("Network.clearBrowserCookies");
await client.detach();
return true;
} catch (err) {
console.warn(`[page-pool] resetPage failed, discarding page: ${err}`);
try {
await page.close();
} catch {}
return false;
}
}
acquire(): Promise<Page> {
for (const slot of this.slots) {
if (slot.available.length > 0) {
slot.inUse++;
return Promise.resolve(slot.available.pop()!);
}
if (slot.inUse < slot.max) {
slot.inUse++;
return this.createPage(slot);
}
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
const idx = this.waiters.findIndex((w) => w.timeout === timeout);
if (idx >= 0) this.waiters.splice(idx, 1);
reject(new Error("Page pool exhausted"));
}, CONFIG.PAGE_ACQUIRE_TIMEOUT_MS);
this.waiters.push({ resolve, reject, timeout });
});
}
async release(page: Page): Promise<void> {
const slot = this.pageToSlot.get(page);
if (!slot) {
await page.close().catch(() => {});
return;
}
slot.inUse--;
const ok = await this.resetPage(page);
if (!ok) {
// Page was closed during reset — don't recycle it
this.pageToSlot.delete(page);
return;
}
if (this.waiters.length > 0) {
const w = this.waiters.shift()!;
clearTimeout(w.timeout);
slot.inUse++;
w.resolve(page);
return;
}
if (slot.available.length + slot.inUse < slot.max) {
slot.available.push(page);
} else {
await page.close().catch(() => {});
this.pageToSlot.delete(page);
}
}
get activeRenders(): number {
return this.slots.reduce((s, slot) => s + slot.inUse, 0);
}
async shutdown(): Promise<void> {
for (const slot of this.slots) {
for (const p of slot.available) await p.close().catch(() => {});
await slot.browser.close().catch(() => {});
}
this.slots = [];
}
}
let pagePool: PagePool;
// ═══════════════════════════════════════════════════════════════
// WEBSOCKET DEBUG CLIENTS
// ═══════════════════════════════════════════════════════════════
const debugClients = new Map<any, { interval: number; timer: ReturnType<typeof setInterval> }>();
const DEBUG_DEFAULT_INTERVAL_MS = 1000;
const DEBUG_MIN_INTERVAL_MS = 100;
// ═══════════════════════════════════════════════════════════════
// RENDER CACHE + QUEUE
// ═══════════════════════════════════════════════════════════════
const renderCache = new Map<string, CachedRender>();
const inFlightRenders = new Map<string, ActiveRender>();
const renderQueue: QueuedRender[] = [];
function renderCacheKey(viewId: string, urlIndex: number): string {
return `${viewId}:${urlIndex}`;
}
function cachePurgeExpired(): void {
const now = Date.now();
for (const [key, entry] of renderCache) {
if (now >= entry.expiresAt) renderCache.delete(key);
}
}
setInterval(cachePurgeExpired, CONFIG.CACHE_CLEANUP_INTERVAL_MS);
async function warmupView(viewId: string): Promise<void> {
const view = viewCacheGet(viewId);
if (!view) {
console.warn(`[warmup] View ${viewId} not found`);
return;
}
console.log(`[warmup] Starting precache for view "${view.name}" (${view.urls.length} URLs)`);
for (let i = 0; i < view.urls.length; i++) {
const key = renderCacheKey(view.id, i);
const cached = renderCache.get(key);
const now = Date.now();
if (!cached || now >= cached.expiresAt) {
try {
await getOrRender(view, i);
console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`);
} catch (err: any) {
console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`);
}
}
}
console.log(`[warmup] Precache complete for view "${view.name}"`);
}
async function warmupCache(): Promise<void> {
const activeDevices = devicesSnapshot();
if (activeDevices.length === 0) {
return;
}
console.log(`[warmup] Starting periodic precache for ${activeDevices.length} active device(s)`);
const views = viewCacheAll();
for (const view of views) {
for (let i = 0; i < view.urls.length; i++) {
const key = renderCacheKey(view.id, i);
const cached = renderCache.get(key);
const now = Date.now();
if (!cached || now >= cached.expiresAt) {
try {
await getOrRender(view, i);
console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`);
} catch (err: any) {
console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`);
}
}
}
}
console.log(`[warmup] Periodic precache complete`);
}
setInterval(warmupCache, CONFIG.WARMUP_INTERVAL_MS);
function cacheInvalidate(viewId: string): void {
const prefix = `${viewId}:`;
for (const key of renderCache.keys()) {
if (key.startsWith(prefix)) renderCache.delete(key);
}
for (let i = renderQueue.length - 1; i >= 0; i--) {
if (renderQueue[i].viewId === viewId) {
renderQueue[i].reject(new Error("View deleted"));
renderQueue.splice(i, 1);
}
}
// Allow re-warming if view is recreated
warmedUpViews.delete(viewId);
}
function drainRenderQueue(): void {
const now = Date.now();
for (let i = renderQueue.length - 1; i >= 0; i--) {
if (now - renderQueue[i].queuedAt > CONFIG.QUEUE_TIMEOUT_MS) {
const q = renderQueue[i];
q.reject(new Error("Render queue timeout"));
console.log(
`[queue] Dropped waiter for ${q.key} (timeout ${Math.round((now - q.queuedAt) / 1000)}s)`,
);
renderQueue.splice(i, 1);
}
}
while (renderQueue.length > 0 && pagePool.activeRenders < CONFIG.MAX_CONCURRENT_RENDERS) {
const next = renderQueue.shift()!;
const view = viewCacheGet(next.viewId);
if (!view) {
next.reject(new Error("View deleted while queued"));
continue;
}
const cached = renderCache.get(next.key);
if (cached && Date.now() < cached.expiresAt) {
next.resolve(cached.body);
continue;
}
console.log(
`[queue] Dequeued ${next.key} (waited ${Math.round((now - next.queuedAt) / 1000)}s)`,
);
const renderPromise = renderViewToCache(view, next.urlIndex, next.key);
inFlightRenders.set(next.key, { promise: renderPromise, urlIndex: next.urlIndex });
renderPromise.then(
(body) => {
inFlightRenders.delete(next.key);
next.resolve(body);
},
(err) => {
inFlightRenders.delete(next.key);
next.reject(err);
},
);
}
}
async function renderViewToCache(view: View, urlIndex: number, key: string): Promise<string> {
const urlItem = view.urls[urlIndex];
const forceDarkMode = urlItem.forceDarkMode ?? false;
const bakeStyles = urlItem.bakeStyles ?? false;
const waitBeforeCaptureMs = urlItem.waitBeforeCaptureMs ?? 0;
let body: string;
if (view.method === "mjpeg") {
const jpeg = await renderScreenshotFrame(
urlItem.url,
view.viewportWidth,
view.viewportHeight,
forceDarkMode,
waitBeforeCaptureMs,
);
body = jpeg.toString("base64");
} else {
body = await renderSSR(
urlItem.url,
view.viewportWidth,
view.viewportHeight,
forceDarkMode,
bakeStyles,
waitBeforeCaptureMs,
);
}
const now = Date.now();
const entry: CachedRender = {
body,
urlIndex,
createdAt: now,
expiresAt: now + view.cacheTtlSec * 1000,
};
renderCache.set(key, entry);
drainRenderQueue();
return body;
}
async function getOrRender(view: View, urlIndex: number): Promise<string> {
const key = renderCacheKey(view.id, urlIndex);
const now = Date.now();
const cached = renderCache.get(key);
if (cached && now < cached.expiresAt) {
return cached.body;
}
const inFlight = inFlightRenders.get(key);
if (inFlight) {
return inFlight.promise;
}
if (pagePool.activeRenders < CONFIG.MAX_CONCURRENT_RENDERS) {
const promise = renderViewToCache(view, urlIndex, key);
inFlightRenders.set(key, { promise, urlIndex });
try {
return await promise;
} finally {
inFlightRenders.delete(key);
}
}
console.log(`[queue] Enqueuing ${key} (position=${renderQueue.length})`);
return new Promise((resolve, reject) => {
renderQueue.push({
resolve,
reject,
key,
viewId: view.id,
urlIndex,
queuedAt: Date.now(),
});
});
}
function cacheStats(): Array<{
key: string;
viewId: string;
urlIndex: number;
url: string;
createdAt: number;
expiresAt: number;
ttlRemainingSec: number;
}> {
const now = Date.now();
const result: Array<{
key: string;
viewId: string;
urlIndex: number;
url: string;
createdAt: number;
expiresAt: number;
ttlRemainingSec: number;
}> = [];
for (const [key, entry] of renderCache) {
const [viewId] = key.split(":");
const view = viewCacheGet(viewId);
result.push({
key,
viewId,
urlIndex: entry.urlIndex,
url: view?.urls[entry.urlIndex]?.url ?? "?",
createdAt: entry.createdAt,
expiresAt: entry.expiresAt,
ttlRemainingSec: Math.max(0, Math.round((entry.expiresAt - now) / 1000)),
});
}
return result.sort((a, b) => a.ttlRemainingSec - b.ttlRemainingSec);
}
// ═══════════════════════════════════════════════════════════════
// ROTATION LOGIC
// ═══════════════════════════════════════════════════════════════
function getCurrentURLIndex(view: View): number {
if (view.urls.length <= 1) return 0;
const totalCycle = view.urls.reduce((s, u) => s + u.durationSec, 0);
if (totalCycle <= 0) return 0;
const elapsed = Math.floor(Date.now() / 1000) % totalCycle;
let acc = 0;
for (let i = 0; i < view.urls.length; i++) {
acc += view.urls[i].durationSec;
if (elapsed < acc) return i;
}
return view.urls.length - 1;
}
function getRemainingSec(view: View): number {
if (view.urls.length <= 1) {
return view.urls[0]?.durationSec || 30;
}
const totalCycle = view.urls.reduce((s, u) => s + u.durationSec, 0);
if (totalCycle <= 0) return 30;
const elapsed = Math.floor(Date.now() / 1000) % totalCycle;
let acc = 0;
for (let i = 0; i < view.urls.length; i++) {
acc += view.urls[i].durationSec;
if (elapsed < acc) return acc - elapsed;
}
return view.urls[0].durationSec;
}
// ═══════════════════════════════════════════════════════════════
// SINGLE-PASS INLINING (SSR mode)
// ═══════════════════════════════════════════════════════════════
function injectMetaRefresh(html: string, intervalSec: number): string {
const sec = Math.max(1, Math.ceil(intervalSec));
const tag = `<meta http-equiv="refresh" content="${sec}">`;
if (html.includes("</head>")) {
return html.replace("</head>", ` ${tag}\n</head>`);
}
if (/<html[^>]*>/i.test(html)) {
return html.replace(/(<html[^>]*>)/i, `$1\n<head>${tag}</head>`);
}
return `<html><head>${tag}</head><body>${html}</body></html>`;
}
function stripScriptTags(html: string): string {
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "")
.replace(/\s+on\w+\s*=\s*'[^']*'/gi, "");
}
// ── Recursive @import resolver (max 3 levels deep) ──
async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): Promise<string> {
if (depth > 3) return cssText;
const importRe =
/@import\s+(?:url\s*\(\s*["']?([^)"']+)["']?\s*\)|["']([^"']+)["'])\s*([^;]*);/gi;
const replacements: Array<{ match: string; resolved: string }> = [];
let m: RegExpExecArray | null;
while ((m = importRe.exec(cssText)) !== null) {
const importUrl = new URL(m[1] || m[2], baseUrl).href;
// Skip font services — font files are blocked by request interception
if (/fonts\.googleapis\.com|fonts\.gstatic\.com/.test(importUrl)) {
replacements.push({ match: m[0], resolved: "/* removed font import: " + importUrl + " */" });
continue;
}
try {
const res = await fetch(importUrl);
if (!res.ok) throw new Error("failed");
let imported = await res.text();
imported = await resolveCSSImports(imported, importUrl, depth + 1);
replacements.push({
match: m[0],
resolved: "/* begin " + importUrl + " */\n" + imported + "\n/* end " + importUrl + " */",
});
} catch {
replacements.push({ match: m[0], resolved: "/* unresolved import: " + importUrl + " */" });
}
}
for (const r of replacements) {
cssText = cssText.replace(r.match, r.resolved);
}
return cssText;
}
// ═══════════════════════════════════════════════════════════════
// CSS COLLECTION (simplified — all CSS into single style tag)
// ═══════════════════════════════════════════════════════════════
/**
* Collect all CSS from loaded stylesheets and inject into a single <style> tag in head.
* Simplified alternative to per-element computed style baking.
* Toggle via URLItem.bakeStyles.
*/
async function bakeComputedStyles(page: Page): Promise<void> {
await page.evaluate((): void => {
const cssTexts: string[] = [];
// Collect from all stylesheets (external and inline)
for (const sheet of document.styleSheets) {
try {
const rules = sheet.cssRules || sheet.rules;
if (!rules) continue;
let cssText = "";
for (const rule of rules) {
cssText += rule.cssText + "\n";
}
if (cssText.trim()) {
cssTexts.push(`/* ${sheet.href || "inline"} */\n${cssText}`);
}
} catch {
// Cross-origin stylesheets — skip (already inlined by inlineAllResources)
}
}
// Also grab any inline <style> tags content directly
const styleTags = document.querySelectorAll("style");
for (const tag of styleTags) {
const content = tag.textContent?.trim();
if (content) {
cssTexts.push(`/* inline style tag */\n${content}`);
}
}
// Create single consolidated style tag in head
if (cssTexts.length > 0) {
const consolidated = document.createElement("style");
consolidated.textContent = cssTexts.join("\n\n");
// Insert as first element in head so it can be overridden
const head = document.head;
if (head.firstChild) {
head.insertBefore(consolidated, head.firstChild);
} else {
head.appendChild(consolidated);
}
}
});
}
async function inlineAllResources(page: Page, html: string): Promise<string> {
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<InlinePayload> => {
function blobToDataUri(blob: Blob): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
}
async function collect(): Promise<InlinePayload> {
const result: InlinePayload = { css: [], images: [], favicons: [], bgImages: [] };
// CSS
const cssLinks = [...document.querySelectorAll<HTMLLinkElement>('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<HTMLImageElement>('img[src]:not([src^="data:"])'),
];
const imgResults = await Promise.allSettled(
imgs.map(async (img) => {
const res = await fetch(img.src);
if (!res.ok) throw new Error("fetch failed");
return { src: img.src, dataUri: await blobToDataUri(await res.blob()) };
}),
);
result.images = imgResults
.filter(
(r): r is PromiseFulfilledResult<{ src: string; dataUri: string }> =>
r.status === "fulfilled",
)
.map((r) => r.value);
// Background images
const styleText = [...document.querySelectorAll<HTMLStyleElement>("style")]
.map((s) => s.textContent ?? "")
.join("\n");
const elemStyle = [...document.querySelectorAll<HTMLElement>("[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<string>();
let m: RegExpExecArray | null;
while ((m = urlRegex.exec(allStyle)) !== null) {
if (!m[1].startsWith("data:") && !m[1].startsWith("#")) bgUrls.add(m[1]);
}
const bgResults = await Promise.allSettled(
[...bgUrls].map(async (url) => {
if (!/\.(png|jpe?g|gif|svg|webp|ico)/i.test(url)) throw new Error("not image");
const res = await fetch(url);
if (!res.ok) throw new Error("fetch failed");
return { url, dataUri: await blobToDataUri(await res.blob()) };
}),
);
result.bgImages = bgResults
.filter(
(r): r is PromiseFulfilledResult<{ url: string; dataUri: string }> =>
r.status === "fulfilled",
)
.map((r) => r.value);
// Favicons
const icons = [...document.querySelectorAll<HTMLLinkElement>('link[rel*="icon"]')];
const iconResults = await Promise.allSettled(
icons.map(async (link) => {
if (link.href.startsWith("data:")) throw new Error("skip");
const res = await fetch(link.href);
if (!res.ok) throw new Error("fetch failed");
return { href: link.href, dataUri: await blobToDataUri(await res.blob()) };
}),
);
result.favicons = iconResults
.filter(
(r): r is PromiseFulfilledResult<{ href: string; dataUri: string }> =>
r.status === "fulfilled",
)
.map((r) => r.value);
return result;
}
return collect();
});
} catch {
return html;
}
for (const css of payload.css) {
const escaped = css.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match both double and single-quoted href
const linkRegex = new RegExp(`<link\\b[^>]*\\bhref\\s*=\\s*["']${escaped}["'][^>]*>`, "gi");
const linkMatch = html.match(linkRegex);
// Preserve media attribute if present
let mediaAttr = "";
if (linkMatch) {
const m2 = linkMatch[0].match(/\bmedia\s*=\s*["']([^"']*)["']/i);
if (m2 && m2[1]) mediaAttr = ` media="${m2[1]}"`;
}
// Resolve @import rules recursively
const resolvedCSS = await resolveCSSImports(css.text, css.url);
html = html.replace(
linkRegex,
`<style${mediaAttr}>/* inlined: ${css.url} */\n${resolvedCSS}\n</style>`,
);
}
for (const img of payload.images) {
const escaped = img.src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
html = html.replace(
new RegExp(`(<img\\b[^>]*\\bsrc\\s*=\\s*)"${escaped}"`, "gi"),
`$1"${img.dataUri}"`,
);
}
for (const bg of payload.bgImages) {
const escaped = bg.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
html = html.replace(
new RegExp(`url\\s*\\(\\s*["']?${escaped}["']?\\s*\\)`, "gi"),
`url(${bg.dataUri})`,
);
}
for (const fav of payload.favicons) {
const escaped = fav.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
html = html.replace(
new RegExp(`(<link\\b[^>]*\\bhref\\s*=\\s*)"${escaped}"`, "gi"),
`$1"${fav.dataUri}"`,
);
}
html = html.replace(
/<link\b[^>]*\brel\s*=\s*"(?:preload|prefetch|preconnect|dns-prefetch|modulepreload)"[^>]*>/gi,
"",
);
html = html.replace(/\s+crossorigin\s*=\s*"[^"]*"/gi, "");
html = html.replace(/\s+integrity\s*=\s*"[^"]*"/gi, "");
return html;
}
async function renderSSR(
url: string,
viewportWidth = 1920,
viewportHeight = 1080,
forceDarkMode = false,
bakeStyles = false,
waitBeforeCaptureMs = 0,
): Promise<string> {
const page = await pagePool.acquire();
try {
await page.setViewport({ width: viewportWidth, height: viewportHeight });
if (forceDarkMode) {
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]);
}
// Try networkidle0 first; fall back to whatever we have on timeout
try {
await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS });
} catch (err: any) {
if (err.message?.includes("timeout")) {
console.warn(`[renderSSR] networkidle0 timeout for ${url}, using current content`);
} else {
throw err;
}
}
// Wait before capture (for animations, dynamic content, etc.)
if (waitBeforeCaptureMs > 0) {
await new Promise((r) => setTimeout(r, waitBeforeCaptureMs));
}
let html = await page.content();
html = await inlineAllResources(page, html);
// Set inlined HTML back to page so bake can collect all CSS
await page.setContent(html, { waitUntil: "domcontentloaded" });
// Bake: collect all CSS into single style tag
if (bakeStyles) {
await bakeComputedStyles(page);
html = await page.content();
}
html = stripScriptTags(html);
return html;
} finally {
await pagePool.release(page);
}
}
// ═══════════════════════════════════════════════════════════════
// SCREENSHOT RENDERING
// ═══════════════════════════════════════════════════════════════
async function renderScreenshotFrame(
url: string,
viewportWidth = 1920,
viewportHeight = 1080,
forceDarkMode = false,
waitBeforeCaptureMs = 0,
): Promise<Buffer> {
const page = await pagePool.acquire();
try {
await page.setViewport({ width: viewportWidth, height: viewportHeight });
if (forceDarkMode) {
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]);
}
try {
await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS });
} catch (err: any) {
if (err.message?.includes("timeout")) {
console.warn(`[renderScreenshot] networkidle0 timeout for ${url}, using current content`);
} else {
throw err;
}
}
// Wait before capture (for animations, dynamic content, etc.)
if (waitBeforeCaptureMs > 0) {
await new Promise((r) => setTimeout(r, waitBeforeCaptureMs));
}
return Buffer.from(await page.screenshot({ type: "jpeg", quality: 75, fullPage: false }));
} finally {
await pagePool.release(page);
}
}
function screenshotHTML(jpegBase64: string, refreshSec: number): string {
const sec = Math.max(2, Math.ceil(refreshSec));
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="${sec}">
<style>
*{margin:0;padding:0;box-sizing:border-box;}
html,body{width:100%;height:100%;background:#000;}
img{display:block;width:100%;height:100%;object-fit:contain;}
</style>
</head>
<body>
<img src="data:image/jpeg;base64,${jpegBase64}" alt="">
</body>
</html>`;
}
// ═══════════════════════════════════════════════════════════════
// ERROR PAGE
// ═══════════════════════════════════════════════════════════════
function errorPage(message: string, retrySec = 10): string {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Error</title>
<meta http-equiv="refresh" content="${retrySec}">
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{background:#1a1a2e;color:#e94560;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;font-size:clamp(14px,3vw,24px);}
.box{text-align:center;padding:40px;}
.icon{font-size:clamp(32px,8vw,64px);margin:0;}
.tag{font-size:clamp(10px,2vw,14px);color:#888;}
</style>
</head>
<body>
<div class="box">
<p class="icon">⚠</p>
<p>${message}</p>
<p class="tag">Samsung TV Proxy — retrying in ${retrySec}s</p>
</div>
</body>
</html>`;
}
// ═══════════════════════════════════════════════════════════════
// VIEW SERVING — /v/:id
// ═══════════════════════════════════════════════════════════════
async function serveView(view: View): Promise<Response> {
const idx = getCurrentURLIndex(view);
let body: string;
try {
body = await getOrRender(view, idx);
} catch (err: any) {
console.error(`[serve:${view.id}] Render failed: ${err.message}`);
return new Response(errorPage(`Failed: ${view.urls[idx]?.url}`), {
status: 502,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
let html: string;
if (view.method === "mjpeg") {
html = screenshotHTML(body, getRemainingSec(view));
} else {
html = view.metaRefreshEnabled ? injectMetaRefresh(body, getRemainingSec(view)) : body;
}
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
// ═══════════════════════════════════════════════════════════════
// HTTP HELPERS
// ═══════════════════════════════════════════════════════════════
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
// ═══════════════════════════════════════════════════════════════
// API
// ═══════════════════════════════════════════════════════════════
async function handleAPI(req: Request, path: string): Promise<Response> {
const method = req.method;
const url = new URL(req.url);
// GET /api/views
if (path === "/api/views" && method === "GET") {
return json(viewCacheAll());
}
// GET /api/debug
if (path === "/api/debug" && method === "GET") {
return json(getDebugData());
}
// GET /api/banned
if (path === "/api/banned" && method === "GET") {
return json(dbGetBannedIPs());
}
// POST /api/views
if (path === "/api/views" && method === "POST") {
try {
const body: CreateViewBody = JSON.parse(await req.text());
if (!body.name?.trim()) return json({ error: "Name is required" }, 400);
if (!body.urls?.length) return json({ error: "At least one URL is required" }, 400);
for (const u of body.urls) {
if (!u.url?.trim()) return json({ error: "Each URL must have a url" }, 400);
if (typeof u.durationSec !== "number" || u.durationSec <= 0)
return json({ error: "Each URL must have positive durationSec" }, 400);
}
if (!["mjpeg", "ssr"].includes(body.method))
return json({ error: 'method must be "mjpeg" or "ssr"' }, 400);
const view: View = {
id: crypto.randomUUID(),
name: body.name.trim(),
urls: body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
forceDarkMode: u.forceDarkMode ?? false,
bakeStyles: u.bakeStyles ?? false,
})),
method: body.method,
metaRefreshEnabled: body.metaRefreshEnabled ?? false,
cacheTtlSec: Math.max(0, body.cacheTtlSec ?? 60),
viewportWidth: Math.max(320, Math.min(7680, body.viewportWidth ?? 1920)),
viewportHeight: Math.max(240, Math.min(4320, body.viewportHeight ?? 1080)),
createdAt: Math.floor(Date.now() / 1000),
};
dbInsert(view);
viewCache.set(view.id, view);
console.log(`[api] Created "${view.name}" (${view.id}) method=${view.method}`);
return json(view, 201);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// /api/views/:id
const viewMatch = path.match(/^\/api\/views\/([a-f0-9-]+)$/);
if (viewMatch) {
const viewId = viewMatch[1];
if (method === "GET") {
const view = viewCacheGet(viewId);
if (!view) return json({ error: "View not found" }, 404);
return json(view);
}
if (method === "PUT") {
const view = viewCacheGet(viewId);
if (!view) return json({ error: "View not found" }, 404);
try {
const body: UpdateViewBody = JSON.parse(await req.text());
if (body.name !== undefined) view.name = body.name.trim();
if (body.urls !== undefined) {
if (body.urls.length === 0) return json({ error: "At least one URL is required" }, 400);
view.urls = body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
forceDarkMode: u.forceDarkMode ?? false,
bakeStyles: u.bakeStyles ?? false,
}));
}
if (body.method !== undefined) {
if (!["mjpeg", "ssr"].includes(body.method))
return json({ error: 'method must be "mjpeg" or "ssr"' }, 400);
view.method = body.method;
}
if (body.metaRefreshEnabled !== undefined)
view.metaRefreshEnabled = body.metaRefreshEnabled;
if (body.cacheTtlSec !== undefined) view.cacheTtlSec = Math.max(0, body.cacheTtlSec);
if (body.viewportWidth !== undefined)
view.viewportWidth = Math.max(320, Math.min(7680, body.viewportWidth));
if (body.viewportHeight !== undefined)
view.viewportHeight = Math.max(240, Math.min(4320, body.viewportHeight));
dbUpdate(view);
cacheInvalidate(viewId);
viewCache.set(view.id, view);
console.log(`[api] Updated "${view.name}" (${view.id})`);
return json(view);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
if (method === "DELETE") {
if (!dbDelete(viewId)) return json({ error: "View not found" }, 404);
cacheInvalidate(viewId);
viewCache.delete(viewId);
console.log(`[api] Deleted ${viewId}`);
return json({ ok: true });
}
// GET /api/views/:id/export - Export single view as JSON
if (method === "GET" && url.searchParams.get("export") === "true") {
const view = viewCacheGet(viewId);
if (!view) return json({ error: "View not found" }, 404);
const exportData = {
name: view.name,
urls: view.urls,
method: view.method,
metaRefreshEnabled: view.metaRefreshEnabled,
cacheTtlSec: view.cacheTtlSec,
viewportWidth: view.viewportWidth,
viewportHeight: view.viewportHeight,
};
return new Response(JSON.stringify(exportData, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="view-${viewId}.json"`,
},
});
}
}
// POST /api/views/import - Import a view from JSON
if (path === "/api/views/import" && method === "POST") {
try {
const importData = JSON.parse(await req.text());
if (!importData.name?.trim()) return json({ error: "Name is required" }, 400);
if (!importData.urls?.length) return json({ error: "At least one URL is required" }, 400);
for (const u of importData.urls) {
if (!u.url?.trim()) return json({ error: "Each URL must have a url" }, 400);
if (typeof u.durationSec !== "number" || u.durationSec <= 0)
return json({ error: "Each URL must have positive durationSec" }, 400);
}
const method = importData.method ?? "ssr";
if (!["mjpeg", "ssr"].includes(method))
return json({ error: 'method must be "mjpeg" or "ssr"' }, 400);
const view: View = {
id: crypto.randomUUID(),
name: importData.name.trim(),
urls: importData.urls.map((u: URLItem) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
forceDarkMode: u.forceDarkMode ?? false,
bakeStyles: u.bakeStyles ?? false,
})),
method: method,
metaRefreshEnabled: importData.metaRefreshEnabled ?? false,
cacheTtlSec: Math.max(0, importData.cacheTtlSec ?? 60),
viewportWidth: Math.max(320, Math.min(7680, importData.viewportWidth ?? 1920)),
viewportHeight: Math.max(240, Math.min(4320, importData.viewportHeight ?? 1080)),
createdAt: Math.floor(Date.now() / 1000),
};
dbInsert(view);
viewCache.set(view.id, view);
console.log(`[api] Imported "${view.name}" (${view.id}) method=${view.method}`);
return json(view, 201);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// POST /api/admin/disconnect/:ip
const disconnectMatch = path.match(/^\/api\/admin\/disconnect\/(.+)$/);
if (disconnectMatch && method === "POST") {
const ip = decodeURIComponent(disconnectMatch[1]);
devices.delete(ip);
console.log(`[admin] Disconnected device: ${ip}`);
return json({ ok: true, message: `Device ${ip} disconnected` });
}
// POST /api/admin/ban/:ip
const banMatch = path.match(/^\/api\/admin\/ban\/(.+)$/);
if (banMatch && method === "POST") {
const ip = decodeURIComponent(banMatch[1]);
try {
const body = JSON.parse(await req.text());
const reason = body.reason || "Banned by administrator";
const bannedDevice = devices.get(ip);
dbBanIP(ip, reason);
devices.delete(ip);
if (bannedDevice) cacheInvalidate(bannedDevice.viewId);
console.log(`[admin] Banned IP: ${ip}, reason: ${reason}`);
return json({ ok: true, message: `IP ${ip} banned` });
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// POST /api/admin/unban/:ip
const unbanMatch = path.match(/^\/api\/admin\/unban\/(.+)$/);
if (unbanMatch && method === "POST") {
const ip = decodeURIComponent(unbanMatch[1]);
const success = dbUnbanIP(ip);
if (success) {
console.log(`[admin] Unbanned IP: ${ip}`);
return json({ ok: true, message: `IP ${ip} unbanned` });
} else {
return json({ error: "IP not found in ban list" }, 404);
}
}
// POST /api/admin/refresh/:ip
const refreshMatch = path.match(/^\/api\/admin\/refresh\/(.+)$/);
if (refreshMatch && method === "POST") {
const ip = decodeURIComponent(refreshMatch[1]);
const device = devices.get(ip);
if (device) {
cacheInvalidate(device.viewId);
console.log(`[admin] Forced cache refresh for view: ${device.viewId}`);
return json({ ok: true, message: `Cache refreshed for view ${device.viewId}` });
} else {
return json({ error: "Device not found" }, 404);
}
}
return json({ error: "Not found" }, 404);
}
// ═══════════════════════════════════════════════════════════════
// STATIC
// ═══════════════════════════════════════════════════════════════
const STATIC_DIR = new URL("../public", import.meta.url).pathname;
async function serveStatic(path: string): Promise<Response | null> {
const filePath = path === "/" ? "/index.html" : path;
if (!filePath.startsWith("/") || filePath.includes("..")) return null;
try {
const file = Bun.file(STATIC_DIR + filePath);
await file.slice(0, 0).text();
return new Response(file);
} catch {
try {
return new Response(Bun.file(STATIC_DIR + "/index.html"));
} catch {
return null;
}
}
}
// ═══════════════════════════════════════════════════════════════
// SERVER
// ═══════════════════════════════════════════════════════════════
const server = Bun.serve<{ interval: number }>({
port: CONFIG.PORT,
idleTimeout: (CONFIG.QUEUE_TIMEOUT_MS + 10_000) / 1000, // 40s — enough for worst-case queue wait
websocket: {
open(ws) {
const interval = ws.data?.interval ?? DEBUG_DEFAULT_INTERVAL_MS;
const timer = setInterval(() => {
try {
ws.send(JSON.stringify(getDebugData()));
} catch {
clearInterval(timer);
debugClients.delete(ws);
}
}, interval);
debugClients.set(ws, { interval, timer });
// Send initial payload immediately
try {
ws.send(JSON.stringify(getDebugData()));
} catch {}
},
message(ws, msg) {
try {
const data = JSON.parse(msg as string);
if (data.type === "debug-interval" && typeof data.interval === "number") {
const client = debugClients.get(ws);
if (!client) return;
const interval = Math.max(DEBUG_MIN_INTERVAL_MS, data.interval);
clearInterval(client.timer);
client.interval = interval;
client.timer = setInterval(() => {
try {
ws.send(JSON.stringify(getDebugData()));
} catch {
clearInterval(client.timer);
debugClients.delete(ws);
}
}, interval);
// Push immediately after rate change
try {
ws.send(JSON.stringify(getDebugData()));
} catch {}
}
} catch {
/* ignore malformed messages */
}
},
close(ws) {
const client = debugClients.get(ws);
if (client) {
clearInterval(client.timer);
debugClients.delete(ws);
}
},
},
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname;
// ── WebSocket upgrade for debug panel ──
if (path === "/ws") {
const qInterval = parseInt(url.searchParams.get("interval") ?? "");
const wsInterval = Math.max(
DEBUG_MIN_INTERVAL_MS,
Number.isFinite(qInterval) ? qInterval : DEBUG_DEFAULT_INTERVAL_MS,
);
if (server.upgrade(req, { data: { interval: wsInterval } })) {
return; // upgraded — Bun will call websocket.open
}
return new Response("WebSocket upgrade failed", { status: 500 });
}
// View serving — /v/:id
const viewMatch = path.match(/^\/v\/([a-f0-9-]+)$/);
if (viewMatch) {
const view = viewCacheGet(viewMatch[1]);
const ip = server.requestIP(req)?.address ?? "unknown";
if (!view) {
return new Response(errorPage("View not found"), {
status: 404,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
if (view.urls.length === 0) {
return new Response(errorPage("No URLs configured"), {
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
const tracked = deviceTrack(
ip,
view.id,
view.name,
req.headers.get("User-Agent") ?? "unknown",
);
if (!tracked) {
return new Response(errorPage("Access denied"), {
status: 403,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return serveView(view);
}
// API
if (path.startsWith("/api/")) {
return handleAPI(req, path);
}
// Health
if (path === "/health") {
return json({
status: "ok",
uptime: process.uptime(),
views: viewCacheAll().length,
cacheEntries: renderCache.size,
activeRenders: pagePool?.activeRenders ?? 0,
queueLength: renderQueue.length,
});
}
// Static
const staticResponse = await serveStatic(path);
if (staticResponse) return staticResponse;
return new Response("Not found", { status: 404 });
},
error(err) {
console.error("[server]", err);
return new Response("Internal Server Error", { status: 500 });
},
});
// ═══════════════════════════════════════════════════════════════
// INIT & SHUTDOWN
// ═══════════════════════════════════════════════════════════════
async function init() {
viewCacheInit();
pagePool = new PagePool();
await pagePool.init();
console.log(`\n🚀 Samsung TV Proxy Server`);
console.log(` http://localhost:${CONFIG.PORT}`);
console.log(` Views: http://localhost:${CONFIG.PORT}/v/<view-id>`);
console.log(` Health: http://localhost:${CONFIG.PORT}/health`);
console.log(` Debug: http://localhost:${CONFIG.PORT}/api/debug`);
console.log(` Browsers: ${CONFIG.BROWSER_COUNT} × ${CONFIG.PAGES_PER_BROWSER} pages`);
console.log(` Cache TTL cleanup: ${CONFIG.CACHE_CLEANUP_INTERVAL_MS / 1000}s intervals\n`);
}
async function shutdown() {
console.log("\n[shutdown] Closing…");
if (inFlightRenders.size > 0) {
console.log(`[shutdown] Waiting for ${inFlightRenders.size} in-flight render(s)…`);
await Promise.allSettled([...inFlightRenders.values()].map((r) => r.promise));
}
await pagePool.shutdown();
server.stop();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
init();