fix(server): resolve localStorage SecurityError, request timeout, and navigation timeout issues

- Replace localStorage.clear() with CDP Storage.clearDataForOrigin to avoid SecurityError on about:blank (opaque origin)
- Add idleTimeout to Bun.serve config to prevent 10s timeout during queued renders
- Add timeout fallback for networkidle0 in renderSSR and renderScreenshotFrame to handle sites that never reach idle state
- Add recursive CSS @import resolver with font service filtering
- Preserve media attribute when inlining CSS
This commit is contained in:
2026-05-13 08:52:24 +02:00
parent 1aca866459
commit 53944c44d3
+79 -12
View File
@@ -345,20 +345,19 @@ class PagePool {
async resetPage(page: Page): Promise<boolean> {
try {
await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 });
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
// 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) {
// If reset fails (e.g. page already detached), close it and signal failure
console.warn(`[page-pool] resetPage failed, discarding page: ${err}`);
try {
await page.close();
} catch {}
try { await page.close(); } catch {}
return false;
}
}
@@ -674,6 +673,37 @@ function stripScriptTags(html: string): string {
.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> {
interface InlinePayload {
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) {
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(
new RegExp(`<link\\b[^>]*\\bhref\\s*=\\s*"${escaped}"[^>]*>`, "gi"),
`<style>/* inlined: ${css.url} */\n${css.text}\n</style>`,
linkRegex,
`<style${mediaAttr}>/* inlined: ${css.url} */\n${resolvedCSS}\n</style>`
);
}
for (const img of payload.images) {
@@ -824,7 +869,18 @@ async function renderSSR(
const page = await pagePool.acquire();
try {
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();
html = await inlineAllResources(page, html);
html = stripScriptTags(html);
@@ -846,7 +902,17 @@ async function renderScreenshotFrame(
const page = await pagePool.acquire();
try {
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 }));
} finally {
await pagePool.release(page);
@@ -1085,6 +1151,7 @@ async function serveStatic(path: string): Promise<Response | null> {
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;