#!/usr/bin/env node /** * Interactive Browser Server * * A long-running HTTP server that keeps a visible Playwright browser open * and accepts commands via HTTP. Designed for human-in-the-loop interaction. * * Usage: * node browser-server.js [port] * * Default port: 9876 * * API Endpoints: * POST /launch?url= - Launch browser and navigate * POST /go?url= - Navigate to URL * POST /reload - Reload current page * POST /screenshot - Take screenshot * POST /click?selector= - Click element * POST /dblclick?selector= - Double-click element * POST /fill?selector=&value= - Fill form field * POST /select?selector=&value= - Select dropdown option * POST /hover?selector= - Hover over element * POST /evaluate?expr= - Evaluate JS expression * POST /get_text?selector= - Get element text * POST /close - Close browser * * All responses are JSON with screenshot paths. * Screenshots are saved to the specified output directory. */ const { chromium } = require('playwright'); const http = require('http'); const { URL } = require('url'); const fs = require('fs'); const path = require('path'); const os = require('os'); const PORT = parseInt(process.argv[2]) || 9876; const OUTPUT_DIR = process.argv[3] || path.join(os.tmpdir(), 'pi-browser-screenshots'); const STATE_FILE = path.join(os.tmpdir(), 'pi-browser-state.json'); let browser = null; let context = null; let page = null; let wasClosed = false; // Ensure output directory exists fs.mkdirSync(OUTPUT_DIR, { recursive: true }); // Never let an unhandled async error kill the server — log it instead. process.on('unhandledRejection', (err) => { console.error('[unhandledRejection]', err); }); process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err); }); async function ensureBrowser() { if (!browser) { browser = await chromium.launch({ headless: false, slowMo: 100, args: [ '--start-maximized', '--disable-blink-features=AutomationControlled', ], }); context = await browser.newContext({ viewport: { width: 1280, height: 900 }, locale: 'en-US', timezoneId: 'America/New_York', }); page = await context.newPage(); page.on('console', (msg) => { console.error(`[Browser Console] ${msg.type()}: ${msg.text()}`); }); page.on('pageerror', (err) => { console.error(`[Browser Page Error] ${err.message}`); }); wasClosed = false; } } async function saveScreenshot(page, suffix = 'screenshot') { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `${suffix}-${timestamp}.png`; const filepath = path.join(OUTPUT_DIR, filename); await page.screenshot({ path: filepath, fullPage: false }); return filepath; } async function getPageInfo(page) { try { return { url: page.url(), title: await page.title(), }; } catch { return { url: 'about:blank', title: '' }; } } function jsonResponse(res, statusCode, data) { res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data, null, 2)); } function errorResponse(res, statusCode, message, details) { console.error(`[Error] ${message}`); if (details) console.error(details); jsonResponse(res, statusCode, { status: 'error', message, details }); } function parseBody(bodyStr) { const params = new URLSearchParams(); if (bodyStr) { try { // Try JSON first const obj = JSON.parse(bodyStr); for (const [k, v] of Object.entries(obj)) { params.set(k, v); } } catch { // Fall back to URL-encoded const sp = new URLSearchParams(bodyStr); for (const [k, v] of sp) params.set(k, v); } } return params; } async function handleRequest(req, res) { const parsedUrl = new URL(req.url, `http://localhost:${PORT}`); const pathname = parsedUrl.pathname; const urlParams = parsedUrl.searchParams; // CORS support res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } if (req.method !== 'POST') { errorResponse(res, 405, 'Method not allowed. Use POST.'); return; } let body = ''; for await (const chunk of req) { body += chunk; } // Merge URL params and body params (body takes precedence) const bodyParams = parseBody(body); const params = new URLSearchParams(); for (const [k, v] of urlParams) params.set(k, v); for (const [k, v] of bodyParams) params.set(k, v); try { switch (pathname) { case '/launch': { const url = params.get('url'); if (!url) { errorResponse(res, 400, 'Missing required parameter: url'); return; } await ensureBrowser(); console.error(`[Server] Navigating to: ${url}`); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(1000); const info = await getPageInfo(page); const screenshotPath = await saveScreenshot(page, 'launch'); jsonResponse(res, 200, { status: 'ok', command: 'launch', url: info.url, title: info.title, screenshot: screenshotPath, }); break; } case '/go': { const url = params.get('url'); if (!url) { errorResponse(res, 400, 'Missing required parameter: url'); return; } await ensureBrowser(); console.error(`[Server] Navigating to: ${url}`); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(1000); const info = await getPageInfo(page); const screenshotPath = await saveScreenshot(page, 'go'); jsonResponse(res, 200, { status: 'ok', command: 'go', url: info.url, title: info.title, screenshot: screenshotPath, }); break; } case '/reload': { await ensureBrowser(); console.error('[Server] Reloading page...'); await page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(1000); const info = await getPageInfo(page); const screenshotPath = await saveScreenshot(page, 'reload'); jsonResponse(res, 200, { status: 'ok', command: 'reload', url: info.url, title: info.title, screenshot: screenshotPath, }); break; } case '/screenshot': { await ensureBrowser(); const screenshotPath = await saveScreenshot(page, 'screenshot'); const info = await getPageInfo(page); jsonResponse(res, 200, { status: 'ok', command: 'screenshot', url: info.url, title: info.title, screenshot: screenshotPath, }); break; } case '/click': { const selector = params.get('selector'); if (!selector) { errorResponse(res, 400, 'Missing required parameter: selector'); return; } await ensureBrowser(); const force = params.get('force') === 'true'; console.error(`[Server] Clicking: ${selector}${force ? ' (force)' : ''}`); await page.click(selector, { timeout: 8000, force }); await page.waitForTimeout(500); const screenshotPath = await saveScreenshot(page, 'click'); const info = await getPageInfo(page); jsonResponse(res, 200, { status: 'ok', command: 'click', selector, url: info.url, title: info.title, screenshot: screenshotPath, }); break; } case '/dblclick': { const selector = params.get('selector'); if (!selector) { errorResponse(res, 400, 'Missing required parameter: selector'); return; } await ensureBrowser(); console.error(`[Server] Double-clicking: ${selector}`); await page.dblclick(selector, { timeout: 8000 }); await page.waitForTimeout(500); const screenshotPath = await saveScreenshot(page, 'dblclick'); const info = await getPageInfo(page); jsonResponse(res, 200, { status: 'ok', command: 'dblclick', selector, url: info.url, title: info.title, screenshot: screenshotPath, }); break; } case '/fill': { const selector = params.get('selector'); const value = params.get('value'); if (!selector || value === null) { errorResponse(res, 400, 'Missing required parameters: selector, value'); return; } await ensureBrowser(); console.error(`[Server] Filling "${value}" into: ${selector}`); await page.fill(selector, value, { timeout: 8000 }); await page.waitForTimeout(300); const screenshotPath = await saveScreenshot(page, 'fill'); const info = await getPageInfo(page); jsonResponse(res, 200, { status: 'ok', command: 'fill', selector, value, url: info.url, title: info.title, screenshot: screenshotPath, }); break; } case '/select': { const selector = params.get('selector'); const value = params.get('value'); if (!selector || !value) { errorResponse(res, 400, 'Missing required parameters: selector, value'); return; } await ensureBrowser(); console.error(`[Server] Selecting "${value}" in: ${selector}`); await page.selectOption(selector, value, { timeout: 8000 }); await page.waitForTimeout(300); const screenshotPath = await saveScreenshot(page, 'select'); const info = await getPageInfo(page); jsonResponse(res, 200, { status: 'ok', command: 'select', selector, value, url: info.url, title: info.title, screenshot: screenshotPath, }); break; } case '/hover': { const selector = params.get('selector'); if (!selector) { errorResponse(res, 400, 'Missing required parameter: selector'); return; } await ensureBrowser(); console.error(`[Server] Hovering: ${selector}`); await page.hover(selector, { timeout: 8000 }); await page.waitForTimeout(300); const screenshotPath = await saveScreenshot(page, 'hover'); const info = await getPageInfo(page); jsonResponse(res, 200, { status: 'ok', command: 'hover', selector, url: info.url, title: info.title, screenshot: screenshotPath, }); break; } case '/evaluate': { const expr = params.get('expr'); if (!expr) { errorResponse(res, 400, 'Missing required parameter: expr'); return; } await ensureBrowser(); console.error(`[Server] Evaluating: ${expr}`); const result = await page.evaluate(expr); const screenshotPath = await saveScreenshot(page, 'evaluate'); const info = await getPageInfo(page); jsonResponse(res, 200, { status: 'ok', command: 'evaluate', expression: expr, result: String(result), url: info.url, title: info.title, screenshot: screenshotPath, }); break; } case '/get_text': { const selector = params.get('selector'); if (!selector) { errorResponse(res, 400, 'Missing required parameter: selector'); return; } await ensureBrowser(); console.error(`[Server] Getting text from: ${selector}`); const text = await page.textContent(selector); const screenshotPath = await saveScreenshot(page, 'get_text'); const info = await getPageInfo(page); jsonResponse(res, 200, { status: 'ok', command: 'get_text', selector, text: text || '', url: info.url, title: info.title, screenshot: screenshotPath, }); break; } case '/close': { console.error('[Server] Closing browser...'); if (browser) { await browser.close(); } wasClosed = true; browser = null; context = null; page = null; jsonResponse(res, 200, { status: 'ok', command: 'close', message: 'Browser closed', }); break; } case '/status': { if (wasClosed || !browser) { jsonResponse(res, 200, { status: 'ok', state: 'idle', message: 'Browser is not running', }); } else { const info = await getPageInfo(page); jsonResponse(res, 200, { status: 'ok', state: 'running', url: info.url, title: info.title, }); } break; } default: errorResponse(res, 404, `Unknown endpoint: ${pathname}`); } } catch (err) { errorResponse(res, 500, `Server error`, err.message); } } const server = http.createServer(handleRequest); server.listen(PORT, () => { console.error(`Interactive Browser Server running on http://localhost:${PORT}`); console.error(`Screenshots saved to: ${OUTPUT_DIR}`); console.error('Press Ctrl+C to stop'); }); // Graceful shutdown process.on('SIGINT', async () => { console.error('\n[Server] Shutting down...'); if (browser) await browser.close(); server.close(); process.exit(0); }); process.on('SIGTERM', async () => { console.error('\n[Server] Shutting down...'); if (browser) await browser.close(); server.close(); process.exit(0); });