Files
samsung-tv-server/src/server.ts
T
biggy 5020d2c288 style: format code for better readability
- Format arrays and objects on multiple lines for clarity
- Add trailing commas to arrays and objects
- Format function parameters across multiple lines when needed
- Format string concatenation and template literals consistently
- Format try-catch blocks with proper spacing
2026-05-13 13:46:47 +02:00

1751 lines
57 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.
import { Database } from "bun:sqlite";
import type { Browser, Page } from "puppeteer";
import puppeteer from "puppeteer";
// ═══════════════════════════════════════════════════════════════
// CONFIG
// ═══════════════════════════════════════════════════════════════
const CONFIG = {
PORT: parseInt(process.env.PORT || "3099"),
BROWSER_COUNT: 2,
PAGES_PER_BROWSER: 8,
PAGE_POOL_WARM: 4,
PAGE_ACQUIRE_TIMEOUT_MS: 3000,
RENDER_TIMEOUT_MS: 25000,
MAX_CONCURRENT_RENDERS: 16,
QUEUE_TIMEOUT_MS: 30000,
CACHE_CLEANUP_INTERVAL_MS: 30000,
WARMUP_INTERVAL_MS: 60000,
} as const;
// ═══════════════════════════════════════════════════════════════
// TYPES
// ═══════════════════════════════════════════════════════════════
interface URLItem {
url: string;
durationSec: number;
forceDarkMode?: boolean;
bakeStyles?: boolean;
}
interface View {
id: string;
name: string;
urls: URLItem[];
method: "mjpeg" | "ssr";
metaRefreshEnabled: boolean;
cacheTtlSec: number;
viewportWidth: number;
viewportHeight: number;
createdAt: number;
}
interface ViewRow {
id: string;
name: string;
urls: string;
method: string;
meta_refresh_enabled: number;
cache_ttl_sec: number;
viewport_width: number;
viewport_height: number;
created_at: number;
}
interface CreateViewBody {
name: string;
urls: URLItem[];
method: "mjpeg" | "ssr";
metaRefreshEnabled?: boolean;
cacheTtlSec?: number;
viewportWidth?: number;
viewportHeight?: number;
}
type UpdateViewBody = Partial<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, forceDarkMode: false, bakeStyles: false },
{ url: "https://duck.ai", durationSec: 30, forceDarkMode: false, bakeStyles: false },
{ url: "https://www.msn.com", durationSec: 30, forceDarkMode: false, bakeStyles: false },
]),
"ssr",
0,
60,
1920,
1080,
Math.floor(Date.now() / 1000),
],
);
}
function dbLoadAll(): View[] {
return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map(
rowToView,
);
}
function dbLoad(id: string): View | null {
const row = db.query("SELECT * FROM views WHERE id = ?").get(id) as ViewRow | undefined;
return row ? rowToView(row) : null;
}
function dbInsert(v: View): void {
db.run(
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
[
v.id,
v.name,
JSON.stringify(v.urls),
v.method,
v.metaRefreshEnabled ? 1 : 0,
v.cacheTtlSec,
v.viewportWidth,
v.viewportHeight,
v.createdAt,
],
);
}
function dbUpdate(v: View): void {
db.run(
"UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=? WHERE id=?",
[
v.name,
JSON.stringify(v.urls),
v.method,
v.metaRefreshEnabled ? 1 : 0,
v.cacheTtlSec,
v.viewportWidth,
v.viewportHeight,
v.id,
],
);
}
function dbDelete(id: string): boolean {
return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0;
}
function dbBanIP(ip: string, reason?: string): void {
db.run(
"INSERT OR REPLACE INTO banned_ips (ip, banned_at, reason, banned_by) VALUES (?, ?, ?, ?)",
[ip, Math.floor(Date.now() / 1000), reason ?? "Manual ban", "admin"],
);
}
function dbUnbanIP(ip: string): boolean {
return db.run("DELETE FROM banned_ips WHERE ip = ?", [ip]).changes > 0;
}
function dbIsBanned(ip: string): boolean {
return db.query("SELECT ip FROM banned_ips WHERE ip = ?").get(ip) != null;
}
function dbGetBannedIPs(): Array<{
ip: string;
banned_at: number;
reason: string;
banned_by: string;
}> {
return db.query("SELECT * FROM banned_ips ORDER BY banned_at DESC").all() as Array<{
ip: string;
banned_at: number;
reason: string;
banned_by: string;
}>;
}
// ═══════════════════════════════════════════════════════════════
// IN-MEMORY VIEW CACHE
// ═══════════════════════════════════════════════════════════════
const viewCache = new Map<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;
let body: string;
if (view.method === "mjpeg") {
const jpeg = await renderScreenshotFrame(
urlItem.url,
view.viewportWidth,
view.viewportHeight,
forceDarkMode,
);
body = jpeg.toString("base64");
} else {
body = await renderSSR(
urlItem.url,
view.viewportWidth,
view.viewportHeight,
forceDarkMode,
bakeStyles,
);
}
const now = Date.now();
const entry: CachedRender = {
body,
urlIndex,
createdAt: now,
expiresAt: now + view.cacheTtlSec * 1000,
};
renderCache.set(key, entry);
drainRenderQueue();
return body;
}
async function getOrRender(view: View, urlIndex: number): Promise<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;
}
// ═══════════════════════════════════════════════════════════════
// COMPUTED STYLE BAKING
// ═══════════════════════════════════════════════════════════════
/**
* Bake computed styles into inline style attributes.
* This ensures styles work on browsers with limited CSS support (e.g., Samsung TV).
* Compares each element against a same-tagname fresh element so tag-specific defaults
* (e.g. block display on div, inline on span, bold on h1) are accounted for.
*/
async function bakeComputedStyles(page: Page): Promise<void> {
await page.evaluate((): void => {
const computedProperties = [
// Layout
"display",
"position",
"top",
"right",
"bottom",
"left",
"width",
"height",
"min-width",
"min-height",
"max-width",
"max-height",
"margin",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
// Flex/Grid
"flex",
"flex-direction",
"flex-wrap",
"flex-flow",
"flex-grow",
"flex-shrink",
"flex-basis",
"justify-content",
"align-items",
"align-content",
"align-self",
"gap",
"row-gap",
"column-gap",
"grid",
"grid-template",
"grid-template-columns",
"grid-template-rows",
"grid-area",
// Box model
"box-sizing",
"border-top-width",
"border-right-width",
"border-bottom-width",
"border-left-width",
"border-top-style",
"border-right-style",
"border-bottom-style",
"border-left-style",
"border-top-color",
"border-right-color",
"border-bottom-color",
"border-left-color",
"border-top-left-radius",
"border-top-right-radius",
"border-bottom-right-radius",
"border-bottom-left-radius",
// Colors & Background
"color",
"background-color",
"background-image",
"background-size",
"background-position",
"background-repeat",
"background-attachment",
"background-clip",
"background-origin",
// Typography
"font-family",
"font-size",
"font-weight",
"font-style",
"font-variant",
"line-height",
"text-align",
"text-decoration-line",
"text-decoration-color",
"text-transform",
"letter-spacing",
"word-spacing",
"white-space",
"overflow-wrap",
"word-break",
// Visual
"opacity",
"visibility",
"z-index",
"overflow-x",
"overflow-y",
"box-shadow",
"text-shadow",
"transform",
"transform-origin",
// List/Table
"list-style-type",
"list-style-position",
"border-collapse",
"border-spacing",
// Cursor/pointer
"cursor",
"pointer-events",
];
// Cache of default computed values keyed by tagName
const defaultCache = new Map<string, Map<string, string>>();
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<string, string> {
let cache = defaultCache.get(tag);
if (cache) return cache;
cache = new Map<string, string>();
try {
const el = document.createElement(tag);
hiddenContainer.appendChild(el);
const cs = window.getComputedStyle(el);
for (const prop of computedProperties) {
cache.set(prop, cs.getPropertyValue(prop));
}
el.remove();
} catch {
/* unknown tag, cache will be empty */
}
defaultCache.set(tag, cache);
return cache;
}
function shouldBake(value: string, defaultValue: string): boolean {
if (!value) return false;
// Skip browser keywords that cannot be serialized as inline style values
if (value === "initial" || value === "inherit" || value === "unset" || value === "revert")
return false;
return value !== defaultValue;
}
// Walk all elements and bake computed styles
const elements = document.querySelectorAll<HTMLElement>("*");
for (const el of elements) {
if (el === hiddenContainer) continue;
// Skip non-visual tags
const tag = el.tagName.toLowerCase();
if (["script", "style", "meta", "link", "noscript", "template", "head", "html"].includes(tag))
continue;
const computed = window.getComputedStyle(el);
const defaults = getTagDefaults(tag);
const stylesToBake: string[] = [];
for (const prop of computedProperties) {
const value = computed.getPropertyValue(prop);
const defaultValue = defaults.get(prop) ?? "";
if (shouldBake(value, defaultValue)) {
stylesToBake.push(`${prop}:${value}`);
}
}
if (stylesToBake.length > 0) {
// Existing inline styles take precedence — put them after baked styles
const existingInline = el.getAttribute("style") || "";
const bakedStyles = stylesToBake.join(";");
el.setAttribute("style", existingInline ? `${bakedStyles};${existingInline}` : bakedStyles);
}
}
hiddenContainer.remove();
});
}
async function inlineAllResources(page: Page, html: string): Promise<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,
): 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;
}
}
let html = await page.content();
html = await inlineAllResources(page, html);
// Bake computed styles into inline attributes (for Samsung TV compatibility)
if (bakeStyles) {
await bakeComputedStyles(page);
html = await page.content();
}
html = stripScriptTags(html);
return html;
} finally {
await pagePool.release(page);
}
}
// ═══════════════════════════════════════════════════════════════
// SCREENSHOT RENDERING
// ═══════════════════════════════════════════════════════════════
async function renderScreenshotFrame(
url: string,
viewportWidth = 1920,
viewportHeight = 1080,
forceDarkMode = false,
): Promise<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;
}
}
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;
// GET /api/views
if (path === "/api/views" && method === "GET") {
return json(viewCacheAll());
}
// GET /api/debug
if (path === "/api/debug" && method === "GET") {
return json(getDebugData());
}
// GET /api/banned
if (path === "/api/banned" && method === "GET") {
return json(dbGetBannedIPs());
}
// POST /api/views
if (path === "/api/views" && method === "POST") {
try {
const body: CreateViewBody = JSON.parse(await req.text());
if (!body.name?.trim()) return json({ error: "Name is required" }, 400);
if (!body.urls?.length) return json({ error: "At least one URL is required" }, 400);
for (const u of body.urls) {
if (!u.url?.trim()) return json({ error: "Each URL must have a url" }, 400);
if (typeof u.durationSec !== "number" || u.durationSec <= 0)
return json({ error: "Each URL must have positive durationSec" }, 400);
}
if (!["mjpeg", "ssr"].includes(body.method))
return json({ error: 'method must be "mjpeg" or "ssr"' }, 400);
const view: View = {
id: crypto.randomUUID(),
name: body.name.trim(),
urls: body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
forceDarkMode: u.forceDarkMode ?? false,
bakeStyles: u.bakeStyles ?? false,
})),
method: body.method,
metaRefreshEnabled: body.metaRefreshEnabled ?? false,
cacheTtlSec: Math.max(0, body.cacheTtlSec ?? 60),
viewportWidth: Math.max(320, Math.min(7680, body.viewportWidth ?? 1920)),
viewportHeight: Math.max(240, Math.min(4320, body.viewportHeight ?? 1080)),
createdAt: Math.floor(Date.now() / 1000),
};
dbInsert(view);
viewCache.set(view.id, view);
console.log(`[api] Created "${view.name}" (${view.id}) method=${view.method}`);
return json(view, 201);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// /api/views/:id
const viewMatch = path.match(/^\/api\/views\/([a-f0-9-]+)$/);
if (viewMatch) {
const viewId = viewMatch[1];
if (method === "GET") {
const view = viewCacheGet(viewId);
if (!view) return json({ error: "View not found" }, 404);
return json(view);
}
if (method === "PUT") {
const view = viewCacheGet(viewId);
if (!view) return json({ error: "View not found" }, 404);
try {
const body: UpdateViewBody = JSON.parse(await req.text());
if (body.name !== undefined) view.name = body.name.trim();
if (body.urls !== undefined) {
if (body.urls.length === 0) return json({ error: "At least one URL is required" }, 400);
view.urls = body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
forceDarkMode: u.forceDarkMode ?? false,
bakeStyles: u.bakeStyles ?? false,
}));
}
if (body.method !== undefined) {
if (!["mjpeg", "ssr"].includes(body.method))
return json({ error: 'method must be "mjpeg" or "ssr"' }, 400);
view.method = body.method;
}
if (body.metaRefreshEnabled !== undefined)
view.metaRefreshEnabled = body.metaRefreshEnabled;
if (body.cacheTtlSec !== undefined) view.cacheTtlSec = Math.max(0, body.cacheTtlSec);
if (body.viewportWidth !== undefined)
view.viewportWidth = Math.max(320, Math.min(7680, body.viewportWidth));
if (body.viewportHeight !== undefined)
view.viewportHeight = Math.max(240, Math.min(4320, body.viewportHeight));
dbUpdate(view);
cacheInvalidate(viewId);
viewCache.set(view.id, view);
console.log(`[api] Updated "${view.name}" (${view.id})`);
return json(view);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
if (method === "DELETE") {
if (!dbDelete(viewId)) return json({ error: "View not found" }, 404);
cacheInvalidate(viewId);
viewCache.delete(viewId);
console.log(`[api] Deleted ${viewId}`);
return json({ ok: true });
}
}
// POST /api/admin/disconnect/:ip
const disconnectMatch = path.match(/^\/api\/admin\/disconnect\/(.+)$/);
if (disconnectMatch && method === "POST") {
const ip = decodeURIComponent(disconnectMatch[1]);
devices.delete(ip);
console.log(`[admin] Disconnected device: ${ip}`);
return json({ ok: true, message: `Device ${ip} disconnected` });
}
// POST /api/admin/ban/:ip
const banMatch = path.match(/^\/api\/admin\/ban\/(.+)$/);
if (banMatch && method === "POST") {
const ip = decodeURIComponent(banMatch[1]);
try {
const body = JSON.parse(await req.text());
const reason = body.reason || "Banned by administrator";
const bannedDevice = devices.get(ip);
dbBanIP(ip, reason);
devices.delete(ip);
if (bannedDevice) cacheInvalidate(bannedDevice.viewId);
console.log(`[admin] Banned IP: ${ip}, reason: ${reason}`);
return json({ ok: true, message: `IP ${ip} banned` });
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// POST /api/admin/unban/:ip
const unbanMatch = path.match(/^\/api\/admin\/unban\/(.+)$/);
if (unbanMatch && method === "POST") {
const ip = decodeURIComponent(unbanMatch[1]);
const success = dbUnbanIP(ip);
if (success) {
console.log(`[admin] Unbanned IP: ${ip}`);
return json({ ok: true, message: `IP ${ip} unbanned` });
} else {
return json({ error: "IP not found in ban list" }, 404);
}
}
// POST /api/admin/refresh/:ip
const refreshMatch = path.match(/^\/api\/admin\/refresh\/(.+)$/);
if (refreshMatch && method === "POST") {
const ip = decodeURIComponent(refreshMatch[1]);
const device = devices.get(ip);
if (device) {
cacheInvalidate(device.viewId);
console.log(`[admin] Forced cache refresh for view: ${device.viewId}`);
return json({ ok: true, message: `Cache refreshed for view ${device.viewId}` });
} else {
return json({ error: "Device not found" }, 404);
}
}
return json({ error: "Not found" }, 404);
}
// ═══════════════════════════════════════════════════════════════
// STATIC
// ═══════════════════════════════════════════════════════════════
const STATIC_DIR = new URL("../public", import.meta.url).pathname;
async function serveStatic(path: string): Promise<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();