feat(render): add waitBeforeCaptureMs option and refactor CSS baking

Add waitBeforeCaptureMs parameter to URLItem and renderSSR to allow waiting
before capturing page content (useful for animations/dynamic content).

Refactor bakeComputedStyles from per-element computed style baking to a
simplified approach that collects all CSS into a single inline style tag.
This reduces complexity while maintaining compatibility.
This commit is contained in:
2026-06-24 07:46:05 +02:00
parent a6e93564ba
commit e862827d2d
+219 -171
View File
@@ -1,3 +1,173 @@
/**
* ╔══════════════════════════════════════════════════════════════════════╗
* ║ 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";
@@ -26,6 +196,7 @@ const CONFIG = {
interface URLItem {
url: string;
durationSec: number;
waitBeforeCaptureMs?: number;
forceDarkMode?: boolean;
bakeStyles?: boolean;
}
@@ -867,189 +1038,57 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P
}
// ═══════════════════════════════════════════════════════════════
// COMPUTED STYLE BAKING
// CSS COLLECTION (simplified — all CSS into single style tag)
// ═══════════════════════════════════════════════════════════════
/**
* 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.
* 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 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",
];
const cssTexts: string[] = [];
// 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>();
// Collect from all stylesheets (external and inline)
for (const sheet of document.styleSheets) {
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));
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}`);
}
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);
// Cross-origin stylesheets — skip (already inlined by inlineAllResources)
}
}
hiddenContainer.remove();
// 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);
}
}
});
}
@@ -1216,6 +1255,7 @@ async function renderSSR(
viewportHeight = 1080,
forceDarkMode = false,
bakeStyles = false,
waitBeforeCaptureMs = 0,
): Promise<string> {
const page = await pagePool.acquire();
try {
@@ -1235,10 +1275,18 @@ async function renderSSR(
}
}
// 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);
// Bake computed styles into inline attributes (for Samsung TV compatibility)
// 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();