Skip to main content
Glama

Screenshot Website Fast

by just-every
screenshotCapture.tsโ€ข35.7 kB
import puppeteer, { Browser, Page } from 'puppeteer'; import { ScreenshotOptions, ScreenshotResult, TiledScreenshotResult, ScreencastOptions, ScreencastResult, ConsoleCaptureOptions, ConsoleCaptureResult, ConsoleMessage, } from '../types.js'; import { logger } from '../utils/logger.js'; logger.debug('Screenshot module loaded'); let browser: Browser | null = null; let browserLaunchPromise: Promise<Browser> | null = null; let lastActivityTime: number = Date.now(); let inactivityTimer: NodeJS.Timeout | null = null; // Configuration const BROWSER_IDLE_TIMEOUT_MS = 60000; // Close browser after 1 minute of inactivity const MIN_BROWSER_LIFETIME_MS = 5000; // Keep browser alive for at least 5 seconds // Browser lifecycle management function updateActivityTime() { lastActivityTime = Date.now(); resetInactivityTimer(); } function resetInactivityTimer() { // Clear existing timer if (inactivityTimer) { clearTimeout(inactivityTimer); inactivityTimer = null; } // Don't set a new timer if browser is not running if (!browser || !browser.isConnected()) { return; } // Set new timer inactivityTimer = setTimeout(async () => { const timeSinceLastActivity = Date.now() - lastActivityTime; const browserAge = Date.now() - lastActivityTime; // Only close if browser has been idle long enough and alive long enough if ( timeSinceLastActivity >= BROWSER_IDLE_TIMEOUT_MS && browserAge >= MIN_BROWSER_LIFETIME_MS ) { logger.info( `Browser idle for ${timeSinceLastActivity}ms, closing to save resources...` ); await closeBrowser(); } else { // If not ready to close yet, reset the timer resetInactivityTimer(); } }, BROWSER_IDLE_TIMEOUT_MS); // Allow process to exit if this is the only thing keeping it alive inactivityTimer.unref(); } async function launchBrowser(): Promise<Browser> { logger.info('Launching new browser instance...'); logger.debug('Puppeteer executable path:', puppeteer.executablePath()); logger.debug('Browser launch options:', { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '...etc'], }); const newBrowser = await puppeteer.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--disable-gpu', '--no-first-run', '--no-zygote', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-features=TranslateUI', '--disable-ipc-flooding-protection', '--disable-default-apps', '--no-default-browser-check', ], ignoreDefaultArgs: ['--enable-automation'], }); logger.info('Browser launched successfully'); logger.debug('Browser process PID:', newBrowser.process()?.pid); // Handle browser disconnection newBrowser.on('disconnected', () => { logger.warn('Browser disconnected event received'); logger.debug('Browser instance:', { isConnected: newBrowser.isConnected(), }); if (browser === newBrowser) { browser = null; browserLaunchPromise = null; } }); // Start health checking when browser is launched startHealthCheck(); logger.debug('Health check started'); // Initialize activity tracking updateActivityTime(); logger.debug('Activity tracking initialized'); return newBrowser; } async function getBrowser(forceRestart: boolean = false): Promise<Browser> { logger.debug('getBrowser called', { forceRestart, hasBrowser: !!browser }); // Force restart if requested if (forceRestart && browser) { logger.info('Force restarting browser...'); await closeBrowser(); } // Check if we have a connected browser if (browser && browser.isConnected()) { return browser; } // If a launch is already in progress, wait for it if (browserLaunchPromise) { try { browser = await browserLaunchPromise; if (browser && browser.isConnected()) { return browser; } } catch (error) { logger.error('Failed to wait for browser launch:', error); browserLaunchPromise = null; } } // Launch a new browser try { logger.debug('Starting browser launch...'); browserLaunchPromise = launchBrowser(); browser = await browserLaunchPromise; browserLaunchPromise = null; logger.info('Browser obtained successfully'); return browser; } catch (error: any) { logger.error('Failed to launch browser:', error.message); logger.debug('Browser launch error details:', { name: error.name, stack: error.stack, code: error.code, }); browserLaunchPromise = null; browser = null; throw error; } } export async function closeBrowser(): Promise<void> { logger.debug('closeBrowser called'); stopHealthCheck(); // Always stop health check when closing browser // Clear inactivity timer if (inactivityTimer) { clearTimeout(inactivityTimer); inactivityTimer = null; } if (browser && browser.isConnected()) { logger.info('Closing browser...'); try { await browser.close(); logger.info('Browser closed successfully'); } catch (error) { logger.error('Error closing browser:', error); } browser = null; browserLaunchPromise = null; } else { logger.debug('No browser to close or already disconnected'); } } // Periodically check browser health let healthCheckInterval: NodeJS.Timeout | null = null; function startHealthCheck() { if (healthCheckInterval) return; healthCheckInterval = setInterval(async () => { if (browser && !browser.isConnected()) { logger.warn('Browser health check failed - browser disconnected'); browser = null; browserLaunchPromise = null; } else if (browser && browser.isConnected()) { try { // Check memory usage const pages = await browser.pages(); logger.debug(`Health check: ${pages.length} pages open`); // Close any extra pages (keep only the initial blank page) if (pages.length > 1) { logger.info( `Closing ${pages.length - 1} unused pages to free memory` ); for (let i = 1; i < pages.length; i++) { await pages[i].close().catch(() => {}); } } } catch (error) { logger.error('Error during health check:', error); } } }, 30000); // Check every 30 seconds // Allow process to exit if this is the only thing keeping it alive healthCheckInterval.unref(); } function stopHealthCheck() { if (healthCheckInterval) { clearInterval(healthCheckInterval); healthCheckInterval = null; } } async function setupPage(browser: Browser): Promise<Page> { logger.debug('Creating new page...'); const page = await browser.newPage(); logger.debug('Page created successfully'); // Configure page settings with longer timeout for problematic sites await page.setDefaultNavigationTimeout(60000); await page.setDefaultTimeout(60000); // Disable unnecessary features that might cause issues await page.setJavaScriptEnabled(true); await page.setOfflineMode(false); // Block unnecessary resources that might cause navigation issues await page.setRequestInterception(true); page.on('request', request => { const resourceType = request.resourceType(); // Allow navigation and essential resources, block potential problematic ones if (['font', 'media'].includes(resourceType)) { request.abort(); } else { request.continue(); } }); // Set up error handlers page.on('error', error => { logger.error('Page crashed:', error.message); logger.debug('Page crash details:', error); }); page.on('pageerror', error => { logger.warn('Page JavaScript error:', error.message || error); }); // Handle frame lifecycle events page.on('frameattached', frame => { logger.debug(`Frame attached: ${frame.url()}`); }); page.on('framedetached', frame => { logger.debug(`Frame detached: ${frame.url()}`); }); page.on('framenavigated', frame => { logger.debug(`Frame navigated: ${frame.url()}`); }); return page; } async function navigateWithRetry( page: Page, url: string, options: ScreenshotOptions, browserRestartCallback?: () => Promise<Page> ): Promise<Page> { const maxRetries = 3; let lastError; let currentPage = page; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // Check if page is still valid before attempting navigation if (currentPage.isClosed()) { logger.warn('Page is closed, attempting to create new page...'); if (browserRestartCallback) { currentPage = await browserRestartCallback(); } else { throw new Error( 'Page is closed and no recovery callback provided' ); } } logger.info( `Navigating to ${url} (attempt ${attempt}/${maxRetries})...` ); // Create a promise that rejects if frame gets detached const frameDetachedPromise = new Promise<never>((_, reject) => { const handler = () => reject(new Error('Frame detached during navigation')); currentPage.once('framedetached', handler); // Clean up handler if navigation succeeds currentPage.once('load', () => currentPage.off('framedetached', handler) ); }); // Race between navigation and frame detachment await Promise.race([ currentPage.goto(url, { waitUntil: options.waitUntil || 'domcontentloaded', timeout: 60000, }), frameDetachedPromise, ]); // Check if page is still valid after navigation if (currentPage.isClosed()) { throw new Error('Page was closed after navigation'); } // Wait a bit to ensure page is stable await currentPage.evaluate( () => new Promise(resolve => setTimeout(resolve, 100)) ); return currentPage; // Success } catch (error: any) { lastError = error; logger.warn(`Navigation attempt ${attempt} failed:`, error.message); // Determine if we need a browser restart const needsBrowserRestart = error.message.includes('Protocol error') || error.message.includes('Target closed') || error.message.includes('Session closed') || error.message.includes('Browser disconnected') || error.message.includes( 'Navigation failed because browser has disconnected' ); if (attempt < maxRetries) { // Wait before retrying await new Promise(resolve => setTimeout(resolve, 1000 * attempt) ); if (needsBrowserRestart && browserRestartCallback) { logger.info( 'Critical error detected, restarting browser...' ); try { currentPage = await browserRestartCallback(); } catch (restartError) { logger.error( 'Failed to restart browser:', restartError ); throw restartError; } } else if (currentPage.isClosed() && browserRestartCallback) { logger.info('Page closed, creating new page...'); currentPage = await browserRestartCallback(); } } } } throw lastError || new Error('Navigation failed after retries'); } export async function captureScreenshot( options: ScreenshotOptions ): Promise<ScreenshotResult | TiledScreenshotResult> { logger.info('captureScreenshot called with options:', { url: options.url, fullPage: options.fullPage, viewport: options.viewport, waitUntil: options.waitUntil, waitFor: options.waitFor, }); // Update activity time when screenshot is requested updateActivityTime(); // Always capture full page with tiling if (options.fullPage !== false) { logger.debug('Delegating to captureTiledScreenshot'); return captureTiledScreenshot(options); } // Viewport-only capture logger.info(`Taking viewport screenshot of ${options.url}`); let browser: Browser | null = null; let page: Page | null = null; let attemptCount = 0; const maxAttempts = 2; while (attemptCount < maxAttempts) { try { attemptCount++; // Get or restart browser browser = await getBrowser(attemptCount > 1); page = await setupPage(browser); // Set viewport const viewport = { width: options.viewport?.width || 1072, height: options.viewport?.height || 1072, }; await page.setViewport(viewport); // Create recovery callback const recoveryCallback = async (): Promise<Page> => { logger.info('Recovering from error, creating new page...'); browser = await getBrowser(true); const newPage = await setupPage(browser); await newPage.setViewport(viewport); return newPage; }; // Navigate to the page with recovery page = await navigateWithRetry( page, options.url, options, recoveryCallback ); // Wait additional time if specified if (options.waitFor) { await page.evaluate( ms => new Promise(resolve => setTimeout(resolve, ms)), options.waitFor ); } // Take screenshot const screenshot = (await page.screenshot({ type: 'png', fullPage: false, encoding: 'binary', })) as Buffer; const result: ScreenshotResult = { url: options.url, screenshot, timestamp: new Date(), viewport, format: 'png', }; // Clean up the page after successful capture if (page && !page.isClosed()) { await page.close().catch(() => {}); } return result; } catch (error: any) { logger.error( `Error taking screenshot (attempt ${attemptCount}/${maxAttempts}):`, error ); // Clean up the page if (page && !page.isClosed()) { await page.close().catch(() => {}); } // If this was our last attempt, throw the error if (attemptCount >= maxAttempts) { throw error; } // Otherwise, wait a bit before retrying logger.info('Retrying with fresh browser...'); await new Promise(resolve => setTimeout(resolve, 2000)); } } throw new Error('Failed to capture screenshot after all attempts'); } // Export browser statistics for monitoring export function getBrowserStats() { return { hasBrowser: !!browser, isConnected: browser?.isConnected() || false, lastActivityTime: new Date(lastActivityTime).toISOString(), timeSinceLastActivity: Date.now() - lastActivityTime, hasInactivityTimer: !!inactivityTimer, idleTimeoutMs: BROWSER_IDLE_TIMEOUT_MS, }; } export async function warmupBrowser(): Promise<void> { logger.info('Warming up browser for faster first requests...'); try { // Pre-launch browser to avoid startup delay on first request const warmupBrowser = await getBrowser(); logger.info('Browser warmed up successfully'); // Optionally create a page and navigate to a simple page to fully warm up const page = await warmupBrowser.newPage(); await page.goto('data:text/html,<html><body>Warmup</body></html>', { waitUntil: 'load', timeout: 5000, }); await page.close(); logger.debug('Browser warmup page test completed'); } catch (error) { logger.warn( 'Browser warmup failed (first request may be slower):', error ); // Don't throw - let the server start anyway } } // Clean up on process exit process.on('SIGINT', async () => { logger.debug('SIGINT received in screenshot module'); stopHealthCheck(); await closeBrowser(); process.exit(0); }); process.on('SIGTERM', async () => { logger.debug('SIGTERM received in screenshot module'); stopHealthCheck(); await closeBrowser(); process.exit(0); }); process.on('exit', async () => { logger.debug('Process exit in screenshot module'); stopHealthCheck(); await closeBrowser(); }); // Handle uncaught errors process.on('uncaughtException', async error => { logger.error('Uncaught exception in screenshot module:', error); stopHealthCheck(); await closeBrowser(); process.exit(1); }); process.on('unhandledRejection', async error => { logger.error('Unhandled rejection in screenshot module:', error); stopHealthCheck(); await closeBrowser(); process.exit(1); }); async function captureTiledScreenshot( options: ScreenshotOptions ): Promise<TiledScreenshotResult> { const tileSize = options.viewport?.width || 1072; logger.info(`Taking tiled screenshot of ${options.url}`); let browser: Browser | null = null; let page: Page | null = null; let attemptCount = 0; const maxAttempts = 2; while (attemptCount < maxAttempts) { try { attemptCount++; // Get or restart browser browser = await getBrowser(attemptCount > 1); page = await setupPage(browser); // Set viewport to capture full width in tile size await page.setViewport({ width: tileSize, height: tileSize, }); // Create recovery callback const recoveryCallback = async (): Promise<Page> => { logger.info('Recovering from error, creating new page...'); browser = await getBrowser(true); const newPage = await setupPage(browser); await newPage.setViewport({ width: tileSize, height: tileSize, }); return newPage; }; // Navigate to the page with recovery page = await navigateWithRetry( page, options.url, options, recoveryCallback ); // Wait additional time if specified if (options.waitFor) { await page.evaluate( ms => new Promise(resolve => setTimeout(resolve, ms)), options.waitFor ); } // Get the full page height const fullPageHeight = await page.evaluate(() => { // This runs in the browser context where document is available return (globalThis as any).document.documentElement .scrollHeight; }); // Take a screenshot with explicit dimensions to ensure width is constrained logger.info('Capturing full page screenshot...'); const fullPageScreenshot = (await page.screenshot({ type: 'png', fullPage: false, encoding: 'binary', clip: { x: 0, y: 0, width: tileSize, height: fullPageHeight, }, })) as Buffer; // Import sharp dynamically to process the image const sharp = await import('sharp'); const metadata = await sharp.default(fullPageScreenshot).metadata(); const dimensions = { width: Math.min(metadata.width!, tileSize), height: metadata.height!, }; logger.info( `Full page dimensions: ${dimensions.width}x${dimensions.height} (viewport width: ${tileSize})` ); // Calculate number of tiles needed const cols = Math.ceil(dimensions.width / tileSize); const rows = Math.ceil(dimensions.height / tileSize); const tiles = []; logger.info( `Creating ${rows}x${cols} tiles (${rows * cols} total)` ); // Cut the full page screenshot into tiles for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const x = col * tileSize; const y = row * tileSize; const width = Math.min(tileSize, dimensions.width - x); const height = Math.min(tileSize, dimensions.height - y); // Extract tile from full page screenshot const tileBuffer = await sharp .default(fullPageScreenshot) .extract({ left: x, top: y, width, height, }) .png() .toBuffer(); tiles.push({ screenshot: tileBuffer, index: row * cols + col, row, col, x, y, width, height, }); logger.debug( `Created tile ${row},${col} at ${x},${y} (${width}x${height})` ); } } const result: TiledScreenshotResult = { url: options.url, tiles, timestamp: new Date(), fullWidth: dimensions.width, fullHeight: dimensions.height, tileSize, format: 'png', }; // Clean up the page after successful capture if (page && !page.isClosed()) { await page.close().catch(() => {}); } return result; } catch (error: any) { logger.error( `Error taking tiled screenshot (attempt ${attemptCount}/${maxAttempts}):`, error ); // Clean up the page if (page && !page.isClosed()) { await page.close().catch(() => {}); } // If this was our last attempt, throw the error if (attemptCount >= maxAttempts) { throw error; } // Otherwise, wait a bit before retrying logger.info('Retrying with fresh browser...'); await new Promise(resolve => setTimeout(resolve, 2000)); } } throw new Error('Failed to capture tiled screenshot after all attempts'); } export async function captureScreencast( options: ScreencastOptions ): Promise<ScreencastResult> { logger.info('captureScreencast called with options:', { url: options.url, duration: options.duration, interval: options.interval, viewport: options.viewport, waitUntil: options.waitUntil, waitFor: options.waitFor, hasJsEvaluate: !!options.jsEvaluate, }); // Update activity time when screencast is requested updateActivityTime(); const frames: ScreencastResult['frames'] = []; const startTime = new Date(); let browser: Browser | null = null; let page: Page | null = null; try { // Get browser instance browser = await getBrowser(); page = await setupPage(browser); // Set viewport (only capture top tile - 1072x1072) const viewport = { width: options.viewport?.width || 1072, height: options.viewport?.height || 1072, }; await page.setViewport(viewport); logger.info(`Starting screencast of ${options.url}`); // Navigate to the page await page.goto(options.url, { waitUntil: options.waitUntil || 'domcontentloaded', timeout: 60000, }); // Wait additional time if specified if (options.waitFor) { await page.evaluate( ms => new Promise(resolve => setTimeout(resolve, ms)), options.waitFor ); } // Handle JavaScript execution with configurable screenshot intervals let jsInstructionCount = 0; const screenshotInterval = options.interval * 1000; // Convert seconds to milliseconds const jsExecutionInterval = 1000; // Execute JS instructions every 1 second if (options.jsEvaluate) { const jsInstructions = Array.isArray(options.jsEvaluate) ? options.jsEvaluate : [options.jsEvaluate]; jsInstructionCount = jsInstructions.length; logger.info( `Processing ${jsInstructionCount} JavaScript instruction(s) with ${screenshotInterval}ms screenshot intervals` ); const startTime = Date.now(); let nextJsIndex = 0; let frameIndex = 0; // Run for the duration needed to execute all JS instructions const jsDuration = jsInstructions.length * jsExecutionInterval; while (Date.now() - startTime < jsDuration) { const elapsed = Date.now() - startTime; // Check if it's time to execute the next JS instruction if ( nextJsIndex < jsInstructions.length && elapsed >= nextJsIndex * jsExecutionInterval ) { logger.info( `Executing JavaScript instruction ${nextJsIndex + 1}/${jsInstructions.length}: ${jsInstructions[nextJsIndex].substring(0, 50)}...` ); try { await page.evaluate(jsInstructions[nextJsIndex]); logger.debug( `JavaScript instruction ${nextJsIndex + 1} completed` ); } catch (error) { logger.error( `JavaScript instruction ${nextJsIndex + 1} failed:`, error ); throw new Error( `Failed to execute JavaScript instruction ${nextJsIndex + 1}: ${error}` ); } nextJsIndex++; } // Take screenshot const screenshot = (await page.screenshot({ type: 'png', fullPage: false, encoding: 'binary', })) as Buffer; frames.push({ screenshot, timestamp: new Date(), index: frameIndex, }); frameIndex++; logger.debug( `Captured high-frequency frame ${frameIndex} at ${elapsed}ms` ); // Wait for next screenshot interval const nextScreenshotTime = startTime + frameIndex * screenshotInterval; const waitTime = Math.max(0, nextScreenshotTime - Date.now()); if (waitTime > 0) { await new Promise(resolve => setTimeout(resolve, waitTime)); } } // Update jsInstructionCount to reflect actual frames captured during JS execution jsInstructionCount = frameIndex; } // Calculate remaining time and frames needed at configured intervals const remainingDuration = options.duration * 1000 - jsInstructionCount * screenshotInterval; // Remaining time in ms const remainingFrames = Math.max( 0, Math.floor(remainingDuration / screenshotInterval) ); logger.info( `Captured ${jsInstructionCount} frames during JS execution. Capturing ${remainingFrames} additional frames at ${screenshotInterval}ms intervals for remaining ${remainingDuration}ms` ); // Capture remaining frames at configured intervals for (let i = 0; i < remainingFrames; i++) { const frameStart = Date.now(); // Take screenshot of viewport (top tile only) const screenshot = (await page.screenshot({ type: 'png', fullPage: false, encoding: 'binary', })) as Buffer; const frameIndex = jsInstructionCount + i; frames.push({ screenshot, timestamp: new Date(), index: frameIndex, }); logger.debug( `Captured duration frame ${frameIndex + 1} (${i + 1}/${remainingFrames})` ); // Wait for next interval (if not the last frame) if (i < remainingFrames - 1) { const elapsed = Date.now() - frameStart; const waitTime = Math.max(0, screenshotInterval - elapsed); if (waitTime > 0) { await new Promise(resolve => setTimeout(resolve, waitTime)); } } } const endTime = new Date(); const result: ScreencastResult = { url: options.url, frames, startTime, endTime, duration: options.duration, interval: options.interval, viewport, format: 'png', }; logger.info(`Screencast completed: ${frames.length} frames captured`); // Clean up the page after successful capture if (page && !page.isClosed()) { await page.close().catch(() => {}); } return result; } catch (error: any) { logger.error('Error capturing screencast:', error); // Clean up the page if (page && !page.isClosed()) { await page.close().catch(() => {}); } throw error; } } export async function captureConsole( options: ConsoleCaptureOptions ): Promise<ConsoleCaptureResult> { logger.info('captureConsole called with options:', { url: options.url, jsCommand: options.jsCommand, duration: options.duration, waitUntil: options.waitUntil, }); // Update activity time when console capture is requested updateActivityTime(); const messages: ConsoleMessage[] = []; const startTime = new Date(); const duration = options.duration || 4; // Default 4 seconds let browser: Browser | null = null; let page: Page | null = null; try { // Get browser instance browser = await getBrowser(); page = await setupPage(browser); // Set up console message listener page.on('console', msg => { const type = msg.type() as ConsoleMessage['type']; const text = msg.text(); const timestamp = new Date(); // Try to get the actual arguments const args: any[] = []; msg.args().forEach(arg => { args.push(arg.toString()); }); messages.push({ type, text, timestamp, args: args.length > 0 ? args : undefined, }); logger.debug(`Console ${type}: ${text}`); }); // Set up page error listener page.on('pageerror', error => { messages.push({ type: 'error', text: error.toString(), timestamp: new Date(), }); logger.debug(`Page error: ${error}`); }); logger.info(`Starting console capture for ${options.url}`); // Navigate to the page await page.goto(options.url, { waitUntil: options.waitUntil || 'domcontentloaded', timeout: 60000, }); // Execute JS command if provided if (options.jsCommand) { logger.info(`Executing JS command: ${options.jsCommand}`); try { await page.evaluate(options.jsCommand); logger.debug('JS command executed successfully'); } catch (error) { logger.error('Failed to execute JS command:', error); messages.push({ type: 'error', text: `Failed to execute JS command: ${error}`, timestamp: new Date(), }); } } // Wait for the specified duration logger.info(`Capturing console for ${duration} seconds...`); await new Promise(resolve => setTimeout(resolve, duration * 1000)); const endTime = new Date(); const result: ConsoleCaptureResult = { url: options.url, messages, startTime, endTime, duration, executedCommand: options.jsCommand, }; logger.info( `Console capture completed: ${messages.length} messages captured` ); // Clean up the page after successful capture if (page && !page.isClosed()) { await page.close().catch(() => {}); } return result; } catch (error: any) { logger.error('Error capturing console:', error); // Clean up the page if (page && !page.isClosed()) { await page.close().catch(() => {}); } throw error; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/just-every/mcp-screenshot-website-fast'

If you have feedback or need assistance with the MCP directory API, please join our Discord server