From 142fd23931e0ad05a1719561890d34c6f6ced10a Mon Sep 17 00:00:00 2001 From: TimChinye <150863066+TimChinye@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:32:30 +0000 Subject: [PATCH 01/12] fix: resolve Browserless.io 429 errors by batching snapshots Refactored the Snapshot API and `useThemeWipe` hook to consolidate multiple theme snapshots into a single request. This ensures that only one WebSocket connection to Browserless.io is used per theme toggle, staying within the concurrency limits of low-tier plans. - Updated `/api/snapshot` to support a `tasks` array for batch processing. - Optimized the API to use a shared persistent browser instance without re-connecting on every request. - Modified `useThemeWipe` to send both original and target theme HTML in a single POST call. - Maintained backward compatibility for single-task requests. --- src/app/api/snapshot/route.ts | 101 ++++++++++++++++++++-------------- src/hooks/useThemeWipe.ts | 23 ++++---- 2 files changed, 71 insertions(+), 53 deletions(-) diff --git a/src/app/api/snapshot/route.ts b/src/app/api/snapshot/route.ts index 74e1c63..c85bafc 100644 --- a/src/app/api/snapshot/route.ts +++ b/src/app/api/snapshot/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from "next/server"; -import puppeteer from "puppeteer-core"; import { puppeteerManager } from "@/utils/puppeteer-manager"; export const maxDuration = 60; @@ -32,66 +31,84 @@ export async function POST(req: Request) { } let browser: any = null; - let page: any = null; + const pages: any[] = []; try { - const { html, width, height, devicePixelRatio = 2 } = await req.json(); + const body = await req.json(); - if (!html) { - return NextResponse.json({ error: "HTML content is required" }, { status: 400 }); + // Support both single task (backward compatibility) and multiple tasks + const isMulti = Array.isArray(body.tasks); + const tasks = isMulti ? body.tasks : [body]; + + if (tasks.length === 0) { + return NextResponse.json({ error: "No tasks provided" }, { status: 400 }); + } + + if (tasks.some((t: any) => !t.html)) { + return NextResponse.json({ error: "HTML content is required for all tasks" }, { status: 400 }); } - // Connect to the persistent browser instance - const wsEndpoint = await puppeteerManager.getWsEndpoint(); - browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint }); + // Get the persistent browser instance (shared across requests) + browser = await puppeteerManager.getBrowser(); - page = await browser.newPage(); + const results = await Promise.all(tasks.map(async (task: any) => { + let page: any = null; + try { + page = await browser.newPage(); + pages.push(page); - // Set viewport correctly for this specific snapshot - const safeWidth = Math.min(Math.max(width || 1280, 100), 3840); - const safeHeight = Math.min(Math.max(height || 720, 100), 2160); - const safeScale = Math.min(Math.max(devicePixelRatio || 2, 1), 3); + const { html, width, height, devicePixelRatio = 2 } = task; - await page.setViewport({ - width: safeWidth, - height: safeHeight, - deviceScaleFactor: safeScale, - }); + // Set viewport correctly for this specific snapshot + const safeWidth = Math.min(Math.max(width || 1280, 100), 3840); + const safeHeight = Math.min(Math.max(height || 720, 100), 2160); + const safeScale = Math.min(Math.max(devicePixelRatio || 2, 1), 3); - // Performance: Disable JS - await page.setJavaScriptEnabled(false); + await page.setViewport({ + width: safeWidth, + height: safeHeight, + deviceScaleFactor: safeScale, + }); - // Wait for full load - await page.setContent(html, { waitUntil: "load" }); + // Performance: Disable JS + await page.setJavaScriptEnabled(false); - // Tiny delay for layout/font rendering - await new Promise(r => setTimeout(r, 100)); + // Wait for full load + await page.setContent(html, { waitUntil: "load" }); - await page.evaluate(() => { - const htmlEl = document.documentElement; - const x = parseInt(htmlEl.getAttribute('data-scroll-x') || '0'); - const y = parseInt(htmlEl.getAttribute('data-scroll-y') || '0'); - window.scrollTo(x, y); - }); + // Tiny delay for layout/font rendering + await new Promise(r => setTimeout(r, 100)); - const buffer = await page.screenshot({ - type: "png", - fullPage: false, - }); + await page.evaluate(() => { + const htmlEl = document.documentElement; + const x = parseInt(htmlEl.getAttribute('data-scroll-x') || '0'); + const y = parseInt(htmlEl.getAttribute('data-scroll-y') || '0'); + window.scrollTo(x, y); + }); - const base64 = `data:image/png;base64,${buffer.toString("base64")}`; - return NextResponse.json({ snapshot: base64 }); + const buffer = await page.screenshot({ + type: "png", + fullPage: false, + }); + + return `data:image/png;base64,${buffer.toString("base64")}`; + } catch (err: any) { + console.error("Task failed:", err); + throw err; + } + })); + + return NextResponse.json(isMulti ? { snapshots: results } : { snapshot: results[0] }); } catch (error: any) { console.error("Snapshot API error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); } finally { - if (page) { - await page.close().catch(() => {}); - } - if (browser) { - // We disconnect from the persistent browser, NOT close it - await browser.disconnect(); + // Close all pages created in this request + for (const page of pages) { + await page.close().catch((err) => console.error("Error closing page:", err)); } + // We do NOT disconnect or close the browser here. + // It's a persistent instance managed by PuppeteerManager. } } diff --git a/src/hooks/useThemeWipe.ts b/src/hooks/useThemeWipe.ts index 9fdb616..5eb009d 100644 --- a/src/hooks/useThemeWipe.ts +++ b/src/hooks/useThemeWipe.ts @@ -88,20 +88,21 @@ export function useThemeWipe({ const direction: WipeDirection = currentTheme === "dark" ? "bottom-up" : "top-down"; - const fetchSnapshot = async (themeOverride?: "light" | "dark") => { - const html = getFullPageHTML(themeOverride); + const fetchSnapshots = async (themes: (Theme | undefined)[]): Promise => { + const tasks = themes.map(theme => ({ + html: getFullPageHTML(theme), + width: window.innerWidth, + height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + })); + const response = await fetch("/api/snapshot", { method: "POST", - body: JSON.stringify({ - html, - width: window.innerWidth, - height: window.innerHeight, - devicePixelRatio: window.devicePixelRatio, - }), + body: JSON.stringify({ tasks }), }); const data = await response.json(); if (data.error) throw new Error(data.error); - return data.snapshot; + return data.snapshots; }; const captureWithModernScreenshot = async (): Promise => { @@ -151,9 +152,9 @@ export function useThemeWipe({ try { // PHASE 1: Try Puppeteer (3s timeout) - console.log("Attempting Puppeteer snapshot..."); + console.log("Attempting Puppeteer snapshots..."); const [snapshotA, snapshotB] = await withTimeout( - Promise.all([fetchSnapshot(), fetchSnapshot(newTheme)]), + fetchSnapshots([undefined, newTheme]), 3000, "Puppeteer timeout" ) as [string, string]; From 23dfcbf921da70b376a0730232a53b7eda3c4ea7 Mon Sep 17 00:00:00 2001 From: TimChinye <150863066+TimChinye@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:38:47 +0000 Subject: [PATCH 02/12] fix: resolve Browserless.io 429 and detached frame errors Refactored the Snapshot API and `useThemeWipe` hook to consolidate multiple theme snapshots into a single request and optimized browser connection management. - Consolidated snapshots: `useThemeWipe` now sends both original and target theme HTML in one POST call, reducing WebSocket concurrency. - API Batching: Updated `/api/snapshot` to process an array of `tasks` using multiple pages on a single persistent browser connection. - Connection Stability: Removed premature `browser.disconnect()` and added `browser.isConnected()` checks to prevent "detached frame" errors. - Performance: Increased client-side timeout to 10s and switched to `domcontentloaded` for faster rendering in constrained environments. - Type Safety: Added proper Puppeteer `Browser` and `Page` types to the API. --- src/app/api/snapshot/route.ts | 21 ++++++++++++++------- src/hooks/useThemeWipe.ts | 4 ++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/app/api/snapshot/route.ts b/src/app/api/snapshot/route.ts index c85bafc..3972bdd 100644 --- a/src/app/api/snapshot/route.ts +++ b/src/app/api/snapshot/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { Browser, Page } from "puppeteer-core"; import { puppeteerManager } from "@/utils/puppeteer-manager"; export const maxDuration = 60; @@ -30,8 +31,8 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - let browser: any = null; - const pages: any[] = []; + let browser: Browser | null = null; + const pages: Page[] = []; try { const body = await req.json(); @@ -73,8 +74,8 @@ export async function POST(req: Request) { // Performance: Disable JS await page.setJavaScriptEnabled(false); - // Wait for full load - await page.setContent(html, { waitUntil: "load" }); + // Wait for DOM to be ready + await page.setContent(html, { waitUntil: "domcontentloaded" }); // Tiny delay for layout/font rendering await new Promise(r => setTimeout(r, 100)); @@ -104,9 +105,15 @@ export async function POST(req: Request) { console.error("Snapshot API error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); } finally { - // Close all pages created in this request - for (const page of pages) { - await page.close().catch((err) => console.error("Error closing page:", err)); + // Check if browser is still connected before closing pages + if (browser && browser.isConnected()) { + for (const page of pages) { + await page.close().catch((err) => { + if (!err.message.includes("Connection closed") && !err.message.includes("Target closed")) { + console.error("Error closing page:", err); + } + }); + } } // We do NOT disconnect or close the browser here. // It's a persistent instance managed by PuppeteerManager. diff --git a/src/hooks/useThemeWipe.ts b/src/hooks/useThemeWipe.ts index 5eb009d..a9b380a 100644 --- a/src/hooks/useThemeWipe.ts +++ b/src/hooks/useThemeWipe.ts @@ -151,11 +151,11 @@ export function useThemeWipe({ }; try { - // PHASE 1: Try Puppeteer (3s timeout) + // PHASE 1: Try Puppeteer (10s timeout) console.log("Attempting Puppeteer snapshots..."); const [snapshotA, snapshotB] = await withTimeout( fetchSnapshots([undefined, newTheme]), - 3000, + 10000, "Puppeteer timeout" ) as [string, string]; From f33eedc3db73f4574bd25de3e2a864478203d4ef Mon Sep 17 00:00:00 2001 From: TimChinye <150863066+TimChinye@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:45:00 +0000 Subject: [PATCH 03/12] fix: resolve Browserless.io 429 errors and TS build issues This commit fixes the `429 Too Many Requests` errors from Browserless.io by consolidating multiple theme snapshots into a single API request and using a single WebSocket connection. - Refactored `/api/snapshot` to support a `tasks` array for batch processing multiple snapshots. - Updated `useThemeWipe` hook to send both snapshots in a single POST call. - Optimized Puppeteer by using `domcontentloaded` and increasing timeout to 10s. - Fixed a TypeScript error where 'browser' could be null inside the task map. - Improved cleanup logic in the API route to verify connection status before closing pages. - Maintained backward compatibility for single-task requests. --- src/app/api/snapshot/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/api/snapshot/route.ts b/src/app/api/snapshot/route.ts index 3972bdd..7f39dfa 100644 --- a/src/app/api/snapshot/route.ts +++ b/src/app/api/snapshot/route.ts @@ -53,6 +53,7 @@ export async function POST(req: Request) { browser = await puppeteerManager.getBrowser(); const results = await Promise.all(tasks.map(async (task: any) => { + if (!browser) throw new Error("Browser not initialized"); let page: any = null; try { page = await browser.newPage(); From 317bb845d04a82adcb6e2afd0d738b89be451950 Mon Sep 17 00:00:00 2001 From: TimChinye <150863066+TimChinye@users.noreply.github.com> Date: Sat, 28 Feb 2026 22:03:27 +0000 Subject: [PATCH 04/12] fix: resolve Browserless.io 429s and missing CSS in snapshots This commit solves two major issues with the theme-wipe snapshot system: 1. Concurrency limits: Batching snapshots into a single request. 2. Style rendering: Inlining CSS directly into the HTML payload. Changes: - API: Supports `tasks` array for multi-snapshot requests via a single WebSocket connection, respecting the 2-concurrency limit. - Hook: Consolidation of snapshot calls into one network request with a 10s timeout. - Serializer: Full CSS inlining by collecting rules from all style sheets and embedding them in a