#!/usr/bin/env npx tsx /** * snapshot.ts — Production-grade Puppeteer-based full-page HTML snapshot * * Captures the fully rendered DOM from a running web application and produces * a self-contained HTML file with all CSS inlined and images converted to * base64 data URIs. Works with any framework (React, Vue, Svelte, Angular, * plain HTML, etc.) — no MockPage.jsx needed. * * Usage: * npx tsx snapshot.ts --url http://localhost:5173 --output .stitch/home.html * npx tsx snapshot.ts --url http://localhost:3000/pricing --output .stitch/pricing.html --html-class dark * npx tsx snapshot.ts --url http://localhost:5173 --output .stitch/page.html --wait 5000 --viewport 1440x900 * * Flags: * --url URL to capture (required) * --output Output file path (required) * --wait Extra wait time in ms after network idle (default: 1000) * --viewport Viewport size as WIDTHxHEIGHT (default: 1280x800) * --html-class Class(es) to add to element (e.g., "dark") * --remove-fixed Remove fixed/sticky positioned elements (e.g., cookie banners) * --full-height Capture full scrollable content by resizing viewport to scrollHeight * --title Override the page title * --timeout Global timeout in ms (default: 60000) * --concurrency Max concurrent resource fetches (default: 6) * --json Output machine-readable JSON stats to stdout */ import puppeteer, { type Browser } from 'puppeteer'; import path from 'node:path'; import fs from 'node:fs'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface Opts { url: string | null; output: string | null; wait: number; viewport: string; htmlClass: string | null; removeFixed: boolean; fullHeight: boolean; title: string | null; timeout: number; concurrency: number; json: boolean; inlineFonts: boolean; removeSelectors: string | null; click: string | null; } interface Stats { url: string | null; output: string | null; sizeBytes: number; stylesheets: number; images: number; cssUrls: number; svgImages: number; videoPoster: number; favicons: number; scriptsRemoved: number; warnings: string[]; durationMs: number; error?: string; } // --------------------------------------------------------------------------- // Argument parsing // --------------------------------------------------------------------------- function parseArgs(): Opts { const args = process.argv.slice(2); const opts: Opts = { url: null, output: null, wait: 1000, viewport: '1280x800', htmlClass: null, removeFixed: false, fullHeight: false, title: null, timeout: 60000, concurrency: 6, json: false, inlineFonts: false, removeSelectors: null, click: null, }; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--url': opts.url = args[++i]; break; case '--output': opts.output = args[++i]; break; case '--wait': opts.wait = parseInt(args[++i], 10); break; case '--viewport': opts.viewport = args[++i]; break; case '--html-class': opts.htmlClass = args[++i]; break; case '--remove-fixed': opts.removeFixed = true; break; case '--full-height': opts.fullHeight = true; break; case '--title': opts.title = args[++i]; break; case '--timeout': opts.timeout = parseInt(args[++i], 10); break; case '--concurrency': opts.concurrency = parseInt(args[++i], 10); break; case '--json': opts.json = true; break; case '--inline-fonts': opts.inlineFonts = true; break; case '--remove-selectors': opts.removeSelectors = args[++i]; break; case '--click': opts.click = args[++i]; break; case '--help': console.log(` Usage: npx tsx snapshot.ts --url --output [options] Options: --url URL to capture (required) --output Output file path (required) --wait Extra wait time in ms after network idle (default: 1000) --viewport Viewport size as WIDTHxHEIGHT (default: 1280x800) --html-class Class(es) to add to element (e.g., "dark") --remove-fixed Remove fixed/sticky positioned elements (cookie banners, etc.) --full-height Resize viewport to capture full scrollable content --title Override the page title --timeout Global timeout in ms (default: 60000) --concurrency Max concurrent resource fetches (default: 6) --json Output machine-readable JSON stats `); process.exit(0); default: console.error(`Unknown argument: ${args[i]}`); process.exit(1); } } return opts; } // --------------------------------------------------------------------------- // Input validation // --------------------------------------------------------------------------- function validateOpts(opts: Opts): void { const errors: string[] = []; if (!opts.url) errors.push('--url is required'); if (!opts.output) errors.push('--output is required'); if (opts.url) { try { new URL(opts.url); } catch { errors.push( `Invalid URL: "${opts.url}". Must be a valid URL (e.g., http://localhost:5173)`, ); } } if (opts.viewport) { const vpMatch = opts.viewport.match(/^(\d+)x(\d+)$/); if (!vpMatch) { errors.push( `Invalid viewport: "${opts.viewport}". Must be WIDTHxHEIGHT (e.g., 1280x800)`, ); } else { const w = Number(vpMatch[1]); const h = Number(vpMatch[2]); if (w < 1 || h < 1) { errors.push('Viewport dimensions must be positive integers'); } if (w > 7680 || h > 4320) { errors.push('Viewport too large: max 7680x4320'); } } } if (isNaN(opts.wait) || opts.wait < 0) { errors.push('--wait must be a non-negative integer'); } if (isNaN(opts.timeout) || opts.timeout < 1000) { errors.push('--timeout must be at least 1000ms'); } if (isNaN(opts.concurrency) || opts.concurrency < 1 || opts.concurrency > 20) { errors.push('--concurrency must be between 1 and 20'); } if (opts.output) { const outputDir = path.dirname(path.resolve(opts.output)); try { fs.mkdirSync(outputDir, { recursive: true }); fs.accessSync(outputDir, fs.constants.W_OK); } catch (e: unknown) { errors.push(`Cannot write to output directory: ${(e as Error).message}`); } } if (errors.length > 0) { console.error('❌ Validation errors:'); errors.forEach((e) => console.error(` • ${e}`)); process.exit(1); } } // --------------------------------------------------------------------------- // Main snapshot logic // --------------------------------------------------------------------------- async function snapshot(opts: Opts): Promise { const [, widthStr, heightStr] = opts.viewport.match(/^(\d+)x(\d+)$/)!; const width = Number(widthStr); const height = Number(heightStr); let browser: Browser | undefined; let globalTimer: ReturnType | undefined; // Stats tracking const stats: Stats = { url: opts.url, output: null, sizeBytes: 0, stylesheets: 0, images: 0, cssUrls: 0, svgImages: 0, videoPoster: 0, favicons: 0, scriptsRemoved: 0, warnings: [], durationMs: 0, }; const startTime = Date.now(); try { // Global timeout safety net — prevents zombie browser processes globalTimer = setTimeout(() => { const msg = `Global timeout of ${opts.timeout}ms exceeded — aborting`; console.error(`⏰ ${msg}`); stats.warnings.push(msg); if (browser) browser.close().catch(() => {}); if (opts.json) { stats.durationMs = Date.now() - startTime; stats.error = msg; console.log(JSON.stringify(stats, null, 2)); } process.exit(2); }, opts.timeout); // ----- Launch browser ----- console.log('🚀 Launching browser...'); browser = await puppeteer.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', `--window-size=${width},${height}`, ], }); const page = await browser.newPage(); await page.setViewport({ width, height }); // Forward browser console logs to Node.js page.on('console', (msg) => { const type = msg.type().toString(); if (type === 'warning' || type === 'error') { console.log(` [Browser ${type.toUpperCase()}] ${msg.text()}`); } }); // ----- Navigate and wait for network idle ----- console.log(`📄 Navigating to ${opts.url}...`); try { await page.goto(opts.url!, { waitUntil: 'networkidle0', timeout: Math.min(30000, opts.timeout - 5000), }); } catch { const msg = 'networkidle0 timed out, falling back to networkidle2'; console.warn(`⚠️ ${msg}...`); stats.warnings.push(msg); await page.goto(opts.url!, { waitUntil: 'networkidle2', timeout: Math.min(30000, opts.timeout - 5000), }); } // Extra wait for JS-rendered content (animations, lazy loading, etc.) if (opts.wait > 0) { console.log(`⏳ Waiting ${opts.wait}ms for rendering to settle...`); await new Promise((r) => setTimeout(r, opts.wait)); } // Perform click interaction if specified if (opts.click) { console.log(`🖱️ Clicking element "${opts.click}"...`); try { let element = await page.$(opts.click); if (!element) { // Search in child frames recursively! for (const frame of page.frames()) { const childElement = await frame.$(opts.click); if (childElement) { element = childElement; console.log(` Found element inside child frame: ${frame.url()}`); break; } } } if (element) { await element.click(); // wait an extra 2 seconds for animation or modal loading to settle console.log(` Click succeeded! Waiting 2000ms for click action to settle...`); await new Promise((r) => setTimeout(r, 2000)); } else { throw new Error(`Selector "${opts.click}" not found in main document or child frames.`); } } catch (clickErr: any) { console.error(`⚠️ Click action failed:`, clickErr); stats.warnings.push(`Click action failed: ${clickErr.message || clickErr}`); } } // ----- Pre-processing options ----- // Add class to (e.g., dark mode) if (opts.htmlClass) { console.log(`🎨 Adding class "${opts.htmlClass}" to ...`); await page.evaluate((cls: string) => { document.documentElement.classList.add(...cls.split(/\s+/)); if (cls.includes('dark')) { document.documentElement.setAttribute('data-theme', 'dark'); } else if (cls.includes('light')) { document.documentElement.setAttribute('data-theme', 'light'); } }, opts.htmlClass); await new Promise((r) => setTimeout(r, 500)); } // Remove fixed/sticky elements if (opts.removeFixed) { console.log('🧹 Removing fixed/sticky positioned elements...'); await page.evaluate(() => { const all = document.querySelectorAll('*'); for (const el of all) { const style = getComputedStyle(el); if (style.position === 'fixed' || style.position === 'sticky') { const rect = el.getBoundingClientRect(); if (rect.top > 100 || rect.height < 50) { el.remove(); } } } }); } // Remove custom selectors if (opts.removeSelectors) { console.log(`🧹 Removing custom selectors: "${opts.removeSelectors}"...`); await page.evaluate((selectors: string) => { const items = selectors.split(',').map((s) => s.trim()).filter(Boolean); for (const selector of items) { try { document.querySelectorAll(selector).forEach((el) => el.remove()); } catch (e) { console.warn(`Invalid selector "${selector}":`, e); } } }, opts.removeSelectors); } // Override title if (opts.title) { await page.evaluate((t: string) => { document.title = t; }, opts.title); } // ----- Inject shared browser-side helpers (deduplication) ----- // Mock __name to prevent esbuild generated code from failing in browser await page.evaluate(() => { (window as any).__name = (fn: any, name: string) => fn; }); await page.evaluate((concurrency: number) => { (window as any).__snapshot = { CONCURRENCY: concurrency, toDataUri: async (url: string): Promise => { try { const resp = await fetch(url, { mode: 'cors', credentials: 'same-origin', }); if (!resp.ok) return null; const blob = await resp.blob(); return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = () => resolve(null); reader.readAsDataURL(blob); }); } catch { return null; } }, processInBatches: async ( items: T[], batchSize: number, fn: (item: T) => Promise, ): Promise<(R | null)[]> => { const results: (R | null)[] = []; for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize); const batchResults = await Promise.allSettled(batch.map(fn)); results.push( ...batchResults.map((r) => r.status === 'fulfilled' ? r.value : null, ), ); } return results; }, /** * Robust CSS url() parser — character-by-character parsing instead of regex. * Handles: quoted/unquoted values, escaped characters, whitespace, * data URIs, and malformed url() tokens. * * Returns: Array of { url, fullMatch, start, end } */ extractCssUrls: (cssText: string) => { const results: Array<{ url: string; fullMatch: string; start: number; end: number }> = []; let i = 0; const len = cssText.length; while (i < len) { // Look for 'url(' — case insensitive if ( i + 3 < len && cssText[i].toLowerCase() === 'u' && cssText[i + 1].toLowerCase() === 'r' && cssText[i + 2].toLowerCase() === 'l' && cssText[i + 3] === '(' ) { const urlStart = i; i += 4; // skip 'url(' // Skip whitespace while ( i < len && (cssText[i] === ' ' || cssText[i] === '\t' || cssText[i] === '\n' || cssText[i] === '\r') ) { i++; } // Check for quote let quote: string | null = null; if (i < len && (cssText[i] === '"' || cssText[i] === "'")) { quote = cssText[i]; i++; } // Read the URL value let url = ''; if (quote) { // Quoted: read until matching unescaped quote while (i < len && cssText[i] !== quote) { if (cssText[i] === '\\' && i + 1 < len) { i++; // skip backslash url += cssText[i]; // include next char literally } else { url += cssText[i]; } i++; } if (i < len) i++; // skip closing quote } else { // Unquoted: stop at ) or whitespace (per CSS spec) while ( i < len && cssText[i] !== ')' && cssText[i] !== ' ' && cssText[i] !== '\t' && cssText[i] !== '\n' && cssText[i] !== '\r' ) { url += cssText[i]; i++; } } // Skip trailing whitespace before ')' while ( i < len && (cssText[i] === ' ' || cssText[i] === '\t' || cssText[i] === '\n' || cssText[i] === '\r') ) { i++; } if (i < len && cssText[i] === ')') { const fullMatch = cssText.substring(urlStart, i + 1); results.push({ url: url.trim(), fullMatch, start: urlStart, end: i + 1, }); i++; } else { // Malformed url() — skip past 'url(' and try again i = urlStart + 1; } } else { i++; } } return results; }, /** * Replace CSS url() references using pre-computed positions. * Replaces from end-to-start to preserve earlier indices. */ replaceCssUrls: ( cssText: string, replacements: Array<{ start: number; end: number; dataUri: string }>, ): string => { const sorted = [...replacements].sort((a, b) => b.start - a.start); for (const r of sorted) { cssText = cssText.substring(0, r.start) + "url('" + r.dataUri + "')" + cssText.substring(r.end); } return cssText; }, }; }, opts.concurrency); // ----------------------------------------------------------------------- // -1. Inline local iframes (e.g., companion-app test iframe) // ----------------------------------------------------------------------- const iframesCount = await page.evaluate(() => document.querySelectorAll('iframe').length); if (iframesCount > 0) { console.log(`🔍 Found ${iframesCount} iframe(s) in the main page. Extracting content natively...`); // First, recursively inline all same-origin and srcDoc iframes browser-side console.log('🔍 Inlining same-origin and srcDoc iframes recursively...'); await page.evaluate(() => { const inlineSameOriginIframes = (root: Document | HTMLElement) => { const iframes = Array.from(root.querySelectorAll('iframe')); for (const iframe of iframes) { try { const doc = iframe.contentDocument || iframe.contentWindow?.document; if (doc && doc.body) { // Recursively inline same-origin iframes inside this child frame first inlineSameOriginIframes(doc); const bodyHtml = doc.body.innerHTML; const styles: string[] = []; doc.querySelectorAll('style').forEach(s => styles.push(s.outerHTML)); doc.querySelectorAll('link[rel="stylesheet"]').forEach(l => styles.push((l as HTMLLinkElement).outerHTML)); styles.forEach(styleHtml => { const temp = document.createElement('div'); temp.innerHTML = styleHtml; document.head.appendChild(temp.firstChild!); }); const wrapper = document.createElement('div'); wrapper.className = 'ac-iframe-inlined-wrapper'; // Apply child body's classes and attributes to same-origin wrapper for (const attr of Array.from(doc.body.attributes)) { if (attr.name === 'class') { wrapper.classList.add(...attr.value.split(/\s+/).filter(Boolean)); } else if (attr.name !== 'style') { wrapper.setAttribute(attr.name, attr.value); } } wrapper.style.position = 'absolute'; wrapper.style.top = '0'; wrapper.style.left = '0'; wrapper.style.width = '100%'; wrapper.style.height = '100%'; wrapper.style.overflow = 'hidden'; wrapper.innerHTML = bodyHtml; iframe.parentNode!.replaceChild(wrapper, iframe); } } catch (e) { // Ignore cross-origin iframes; the Puppeteer frame loop will process them } } }; inlineSameOriginIframes(document); }); const childFrames = page.frames() .filter(f => f !== page.mainFrame()) .map(f => { let depth = 0; let p = f.parentFrame(); while (p) { depth++; p = p.parentFrame(); } return { frame: f, depth }; }) .sort((a, b) => b.depth - a.depth); for (const { frame } of childFrames) { try { const frameUrl = frame.url(); const cleanUrl = frameUrl.split('?')[0].split('#')[0]; console.log(`📦 Extracting frame content from: ${cleanUrl} (depth: ${frame.parentFrame() ? 'nested' : 'root'})`); // Inject __name mock to prevent esbuild helper ReferenceError in child frame await frame.evaluate(() => { (window as any).__name = (fn: any) => fn; }); // Resolve all relative assets inside the frame to absolute URLs relative to the frame's URL await frame.evaluate((base) => { const resolveAttr = (el: Element, attr: string) => { const val = el.getAttribute(attr); if (val && !val.startsWith('data:') && !val.startsWith('http:') && !val.startsWith('https:') && !val.startsWith('//')) { try { const abs = new URL(val, base).href; el.setAttribute(attr, abs); } catch (e) { } } }; document.querySelectorAll('img[src]').forEach(img => resolveAttr(img, 'src')); document.querySelectorAll('img[srcset]').forEach(img => resolveAttr(img, 'srcset')); document.querySelectorAll('source[srcset]').forEach(src => resolveAttr(src, 'srcset')); document.querySelectorAll('link[rel="stylesheet"]').forEach(link => resolveAttr(link, 'href')); // Resolve relative url() references in inline