refactor: replace MJPEG streaming with screenshot + meta-refresh #1
+79
-12
@@ -345,20 +345,19 @@ class PagePool {
|
|||||||
async resetPage(page: Page): Promise<boolean> {
|
async resetPage(page: Page): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 });
|
await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 });
|
||||||
await page.evaluate(() => {
|
|
||||||
localStorage.clear();
|
// Clear storage via CDP — avoids opaque-origin SecurityError
|
||||||
sessionStorage.clear();
|
|
||||||
});
|
|
||||||
const client = await page.createCDPSession();
|
const client = await page.createCDPSession();
|
||||||
|
await client.send("Storage.clearDataForOrigin", {
|
||||||
|
origin: "*",
|
||||||
|
storageTypes: "all",
|
||||||
|
});
|
||||||
await client.send("Network.clearBrowserCookies");
|
await client.send("Network.clearBrowserCookies");
|
||||||
await client.detach();
|
await client.detach();
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If reset fails (e.g. page already detached), close it and signal failure
|
|
||||||
console.warn(`[page-pool] resetPage failed, discarding page: ${err}`);
|
console.warn(`[page-pool] resetPage failed, discarding page: ${err}`);
|
||||||
try {
|
try { await page.close(); } catch {}
|
||||||
await page.close();
|
|
||||||
} catch {}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -674,6 +673,37 @@ function stripScriptTags(html: string): string {
|
|||||||
.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;
|
||||||
|
}
|
||||||
|
|
||||||
async function inlineAllResources(page: Page, html: string): Promise<string> {
|
async function inlineAllResources(page: Page, html: string): Promise<string> {
|
||||||
interface InlinePayload {
|
interface InlinePayload {
|
||||||
css: Array<{ url: string; text: string }>;
|
css: Array<{ url: string; text: string }>;
|
||||||
@@ -781,9 +811,24 @@ async function inlineAllResources(page: Page, html: string): Promise<string> {
|
|||||||
|
|
||||||
for (const css of payload.css) {
|
for (const css of payload.css) {
|
||||||
const escaped = css.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
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(
|
html = html.replace(
|
||||||
new RegExp(`<link\\b[^>]*\\bhref\\s*=\\s*"${escaped}"[^>]*>`, "gi"),
|
linkRegex,
|
||||||
`<style>/* inlined: ${css.url} */\n${css.text}\n</style>`,
|
`<style${mediaAttr}>/* inlined: ${css.url} */\n${resolvedCSS}\n</style>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const img of payload.images) {
|
for (const img of payload.images) {
|
||||||
@@ -824,7 +869,18 @@ async function renderSSR(
|
|||||||
const page = await pagePool.acquire();
|
const page = await pagePool.acquire();
|
||||||
try {
|
try {
|
||||||
await page.setViewport({ width: viewportWidth, height: viewportHeight });
|
await page.setViewport({ width: viewportWidth, height: viewportHeight });
|
||||||
await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS });
|
|
||||||
|
// Try networkidle0 first; fall back to whatever we have on timeout
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message?.includes("timeout")) {
|
||||||
|
console.warn(`[renderSSR] networkidle0 timeout for ${url}, using current content`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let html = await page.content();
|
let html = await page.content();
|
||||||
html = await inlineAllResources(page, html);
|
html = await inlineAllResources(page, html);
|
||||||
html = stripScriptTags(html);
|
html = stripScriptTags(html);
|
||||||
@@ -846,7 +902,17 @@ async function renderScreenshotFrame(
|
|||||||
const page = await pagePool.acquire();
|
const page = await pagePool.acquire();
|
||||||
try {
|
try {
|
||||||
await page.setViewport({ width: viewportWidth, height: viewportHeight });
|
await page.setViewport({ width: viewportWidth, height: viewportHeight });
|
||||||
await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS });
|
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: "networkidle0", timeout: CONFIG.RENDER_TIMEOUT_MS });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message?.includes("timeout")) {
|
||||||
|
console.warn(`[renderScreenshot] networkidle0 timeout for ${url}, using current content`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Buffer.from(await page.screenshot({ type: "jpeg", quality: 75, fullPage: false }));
|
return Buffer.from(await page.screenshot({ type: "jpeg", quality: 75, fullPage: false }));
|
||||||
} finally {
|
} finally {
|
||||||
await pagePool.release(page);
|
await pagePool.release(page);
|
||||||
@@ -1085,6 +1151,7 @@ async function serveStatic(path: string): Promise<Response | null> {
|
|||||||
|
|
||||||
const server = Bun.serve<{ interval: number }>({
|
const server = Bun.serve<{ interval: number }>({
|
||||||
port: CONFIG.PORT,
|
port: CONFIG.PORT,
|
||||||
|
idleTimeout: (CONFIG.QUEUE_TIMEOUT_MS + 10_000) / 1000, // 40s — enough for worst-case queue wait
|
||||||
websocket: {
|
websocket: {
|
||||||
open(ws) {
|
open(ws) {
|
||||||
const interval = ws.data?.interval ?? DEBUG_DEFAULT_INTERVAL_MS;
|
const interval = ws.data?.interval ?? DEBUG_DEFAULT_INTERVAL_MS;
|
||||||
|
|||||||
Reference in New Issue
Block a user