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:
+219
-171
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user