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