From 53944c44d3195c37a97f65e71d4039376eb134ce Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 08:52:24 +0200 Subject: [PATCH] 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 --- src/server.ts | 91 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/src/server.ts b/src/server.ts index 035dd87..81dd532 100644 --- a/src/server.ts +++ b/src/server.ts @@ -345,20 +345,19 @@ class PagePool { async resetPage(page: Page): Promise { 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 { + 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 { interface InlinePayload { css: Array<{ url: string; text: string }>; @@ -781,9 +811,24 @@ async function inlineAllResources(page: Page, html: string): Promise { for (const css of payload.css) { const escaped = css.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + // Match both double and single-quoted href + const linkRegex = new RegExp(`]*\\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(`]*\\bhref\\s*=\\s*"${escaped}"[^>]*>`, "gi"), - ``, + linkRegex, + `/* inlined: ${css.url} */\n${resolvedCSS}\n` ); } 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 { 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;