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:
+79
-12
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user