- 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
1751 lines
57 KiB
TypeScript
1751 lines
57 KiB
TypeScript
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();
|