This commit is contained in:
Andras Schmelczer 2026-05-13 12:11:54 +01:00
parent a08b5d2ae0
commit b98f0e3904
38 changed files with 3732 additions and 483 deletions

View file

@ -7,6 +7,10 @@ const NAVIGATION_TIMEOUT = 15_000;
const READY_TIMEOUT = 15_000;
const RENDER_BUFFER_MS = 500;
const POOL_SIZE = 3;
// Abort screenshots whose main frame navigates more than this many times after
// the initial load. Protects against redirect loops triggered by malicious
// invite codes (e.g. client-side `location.href` chains).
const MAX_POST_LOAD_NAVIGATIONS = 5;
let browser: Browser | null = null;
let sharedContext: BrowserContext | null = null;
@ -124,7 +128,7 @@ async function ensureContext(): Promise<BrowserContext> {
body: entry.body,
});
} catch {
await route.continue().catch(() => {});
await route.continue().catch(() => { });
}
});
@ -152,11 +156,11 @@ async function warmPool(): Promise<void> {
async function acquirePage(): Promise<Page> {
const page = pagePool.shift();
if (page && !page.isClosed()) {
warmPool().catch(() => {});
warmPool().catch(() => { });
return page;
}
const newPage = await createPage();
warmPool().catch(() => {});
warmPool().catch(() => { });
return newPage;
}
@ -165,14 +169,14 @@ async function releasePage(page: Page): Promise<void> {
if (page.isClosed()) return;
// Navigate to blank to free rendered page resources (DOM, WebGL context)
// while keeping the page object alive for V8 code cache reuse
await page.goto('about:blank', { timeout: 5000 }).catch(() => {});
await page.goto('about:blank', { timeout: 5000 }).catch(() => { });
if (!page.isClosed() && pagePool.length < POOL_SIZE) {
pagePool.push(page);
} else {
await page.close().catch(() => {});
await page.close().catch(() => { });
}
} catch {
await page.close().catch(() => {});
await page.close().catch(() => { });
}
}
@ -206,11 +210,11 @@ export async function initialize(appUrl: string): Promise<void> {
// Non-fatal — cache will still have JS/CSS/tiles from the partial load
}
console.log(`Pre-warm complete. Cache: ${networkCache.stats()}`);
await page.close().catch(() => {});
await page.close().catch(() => { });
await warmPool();
return;
} catch (err) {
await page.close().catch(() => {});
await page.close().catch(() => { });
if (attempt === MAX_RETRIES) {
console.warn(`Pre-warm failed after ${MAX_RETRIES} attempts (non-fatal):`, err);
} else {
@ -230,6 +234,17 @@ export async function takeScreenshot(url: string, authHeader?: string): Promise<
const page = await acquirePage();
const t0 = performance.now();
let postLoadNavigations = 0;
let navigationLoopDetected = false;
const mainFrame = page.mainFrame();
const onNavigated = (frame: ReturnType<typeof page.mainFrame>) => {
if (frame !== mainFrame) return;
postLoadNavigations += 1;
if (postLoadNavigations > MAX_POST_LOAD_NAVIGATIONS) {
navigationLoopDetected = true;
}
};
try {
// Inject Authorization header on API requests so the headless browser
// is authenticated (required for licensed users outside the free zone).
@ -250,6 +265,10 @@ export async function takeScreenshot(url: string, authHeader?: string): Promise<
if (response) {
console.log(` Navigate: ${(t1 - t0).toFixed(0)}ms (status ${response.status()})`);
}
// Start counting only AFTER the initial navigation completes — the
// initial `goto` itself fires framenavigated, so we'd double-count.
postLoadNavigations = 0;
page.on('framenavigated', onNavigated);
// Wait for the frontend to signal that data is loaded and layers created
try {
@ -259,6 +278,12 @@ export async function takeScreenshot(url: string, authHeader?: string): Promise<
} catch {
console.warn(' Timed out waiting for __screenshot_ready');
}
if (navigationLoopDetected) {
throw new Error(
`Navigation loop detected (${postLoadNavigations} post-load navigations)`,
);
}
const t2 = performance.now();
console.log(` Ready: ${(t2 - t1).toFixed(0)}ms`);
@ -277,10 +302,11 @@ export async function takeScreenshot(url: string, authHeader?: string): Promise<
return Buffer.from(screenshot);
} finally {
page.off('framenavigated', onNavigated);
// Remove page-level auth route before returning page to pool
// so the next screenshot doesn't inherit stale credentials
if (authHeader) {
await page.unrouteAll({ behavior: 'wait' }).catch(() => {});
await page.unrouteAll({ behavior: 'wait' }).catch(() => { });
}
await releasePage(page);
}
@ -311,17 +337,17 @@ export async function checkWebGL(): Promise<Record<string, unknown>> {
export async function closeBrowser(): Promise<void> {
for (const page of pagePool) {
await page.close().catch(() => {});
await page.close().catch(() => { });
}
pagePool.length = 0;
if (sharedContext) {
await sharedContext.close().catch(() => {});
await sharedContext.close().catch(() => { });
sharedContext = null;
}
if (browser) {
await browser.close().catch(() => {});
await browser.close().catch(() => { });
browser = null;
}
}