// server/handlers/browser.ts — Browser automation via Playwright
// Supports: navigate, screenshot, extract, click, fill, evaluate, pdf
import type { SupabaseClient } from "@supabase/supabase-js";
import { chromium, type Browser, type Page } from "playwright-core";
// ============================================================================
// BROWSER INSTANCE LIFECYCLE
// ============================================================================
let browserInstance: Browser | null = null;
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const PAGE_TIMEOUT_MS = 30_000; // 30 seconds per page operation
const MAX_RESULT_BYTES = 100 * 1024; // 100KB max result size
const MAX_TEXT_CHARS = 50_000; // 50K chars for text content
function resetIdleTimer(): void {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(async () => {
if (browserInstance) {
try {
await browserInstance.close();
} catch { /* browser may already be closed */ }
browserInstance = null;
console.log("[browser] Closed after idle timeout");
}
}, IDLE_TIMEOUT_MS);
}
async function getBrowser(): Promise<Browser> {
if (browserInstance && browserInstance.isConnected()) {
resetIdleTimer();
return browserInstance;
}
browserInstance = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
],
});
browserInstance.on("disconnected", () => {
browserInstance = null;
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
});
resetIdleTimer();
console.log("[browser] Launched new instance");
return browserInstance;
}
async function withPage<T>(url: string, waitFor: string | undefined, fn: (page: Page) => Promise<T>): Promise<T> {
const browser = await getBrowser();
const context = await browser.newContext({
userAgent: "WhaleBot/1.0 (https://whale.app)",
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
});
context.setDefaultTimeout(PAGE_TIMEOUT_MS);
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: PAGE_TIMEOUT_MS });
if (waitFor) {
await page.waitForSelector(waitFor, { timeout: PAGE_TIMEOUT_MS });
}
return await fn(page);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
}
// ============================================================================
// SSRF PROTECTION
// ============================================================================
function isBlockedUrl(url: string): string | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return "Invalid URL";
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return `Blocked protocol: ${parsed.protocol}`;
}
const host = parsed.hostname.toLowerCase();
// Block localhost variants
if (host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" ||
host === "::1" || host === "[::1]") {
return "Blocked: localhost";
}
// Block private/internal domains
if (host.endsWith(".internal") || host.endsWith(".local") ||
host === "metadata.google.internal") {
return "Blocked: internal/local domain";
}
// Block private IP ranges
if (/^10\./.test(host) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
/^192\.168\./.test(host) ||
/^169\.254\./.test(host)) {
return "Blocked: private IP range";
}
// Block IPv6 private
if (host.startsWith("fd") || host.startsWith("fc00:") ||
host.startsWith("fe80:") || host.includes("::ffff:")) {
return "Blocked: IPv6 private range";
}
return null;
}
// ============================================================================
// TEXT CLEANUP
// ============================================================================
function stripScriptsAndStyles(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function truncate(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return text.substring(0, maxChars) + "\n...[truncated]";
}
function truncateResult(data: unknown): unknown {
const json = JSON.stringify(data);
if (json.length <= MAX_RESULT_BYTES) return data;
// If too large, stringify and truncate
return JSON.parse(truncate(json, MAX_RESULT_BYTES));
}
// ============================================================================
// ACTION HANDLERS
// ============================================================================
async function actionNavigate(url: string, waitFor?: string): Promise<{ success: boolean; data?: unknown; error?: string }> {
return withPage(url, waitFor, async (page) => {
const title = await page.title();
const html = await page.content();
const textContent = stripScriptsAndStyles(html);
return {
success: true,
data: {
url: page.url(),
title,
text: truncate(textContent, MAX_TEXT_CHARS),
text_length: textContent.length,
},
};
});
}
async function actionScreenshot(url: string, waitFor?: string): Promise<{ success: boolean; data?: unknown; error?: string }> {
return withPage(url, waitFor, async (page) => {
const buffer = await page.screenshot({ type: "png", fullPage: false });
const base64 = buffer.toString("base64");
const title = await page.title();
return {
success: true,
data: {
url: page.url(),
title,
screenshot_base64: base64,
format: "png",
size_bytes: buffer.length,
},
};
});
}
async function actionExtract(url: string, selector: string, waitFor?: string): Promise<{ success: boolean; data?: unknown; error?: string }> {
if (!selector) {
return { success: false, error: "selector is required for extract action" };
}
return withPage(url, waitFor, async (page) => {
const elements = await page.$$eval(selector, (els) =>
els.map((el) => ({
tag: el.tagName.toLowerCase(),
text: el.textContent?.trim() || "",
html: el.innerHTML.substring(0, 2000),
attributes: Object.fromEntries(
Array.from(el.attributes).map((a) => [a.name, a.value])
),
}))
);
return {
success: true,
data: {
url: page.url(),
selector,
count: elements.length,
elements: elements.slice(0, 50), // Cap at 50 elements
},
};
});
}
async function actionClick(url: string, selector: string, waitFor?: string): Promise<{ success: boolean; data?: unknown; error?: string }> {
if (!selector) {
return { success: false, error: "selector is required for click action" };
}
return withPage(url, waitFor, async (page) => {
await page.click(selector, { timeout: PAGE_TIMEOUT_MS });
// Wait a moment for any navigation or dynamic content
await page.waitForTimeout(1000);
const title = await page.title();
const html = await page.content();
const textContent = stripScriptsAndStyles(html);
return {
success: true,
data: {
url: page.url(),
title,
clicked: selector,
text: truncate(textContent, MAX_TEXT_CHARS),
},
};
});
}
async function actionFill(url: string, selector: string, value: string, waitFor?: string): Promise<{ success: boolean; data?: unknown; error?: string }> {
if (!selector) {
return { success: false, error: "selector is required for fill action" };
}
if (value === undefined || value === null) {
return { success: false, error: "value is required for fill action" };
}
return withPage(url, waitFor, async (page) => {
await page.fill(selector, value, { timeout: PAGE_TIMEOUT_MS });
return {
success: true,
data: {
url: page.url(),
filled: { selector, value },
},
};
});
}
async function actionEvaluate(url: string, script: string, waitFor?: string): Promise<{ success: boolean; data?: unknown; error?: string }> {
if (!script) {
return { success: false, error: "script is required for evaluate action" };
}
return withPage(url, waitFor, async (page) => {
const result = await page.evaluate(script);
return {
success: true,
data: {
url: page.url(),
result: truncateResult(result),
},
};
});
}
async function actionPdf(url: string, waitFor?: string): Promise<{ success: boolean; data?: unknown; error?: string }> {
return withPage(url, waitFor, async (page) => {
const buffer = await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "1cm", bottom: "1cm", left: "1cm", right: "1cm" },
});
const base64 = buffer.toString("base64");
const title = await page.title();
return {
success: true,
data: {
url: page.url(),
title,
pdf_base64: base64,
format: "A4",
size_bytes: buffer.length,
},
};
});
}
// ============================================================================
// PDF FROM HTML (reusable by documents handler)
// ============================================================================
export async function generatePdfFromHtml(html: string, options?: { format?: string; landscape?: boolean }): Promise<Buffer> {
const browser = await getBrowser();
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
try {
await page.setContent(html, { waitUntil: "networkidle", timeout: PAGE_TIMEOUT_MS });
const buffer = await page.pdf({
format: (options?.format as "A4" | "Letter") || "A4",
printBackground: true,
landscape: options?.landscape || false,
margin: { top: "1cm", bottom: "1cm", left: "1cm", right: "1cm" },
});
return Buffer.from(buffer);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
}
// ============================================================================
// MAIN HANDLER
// ============================================================================
export async function handleBrowser(
_sb: SupabaseClient,
args: Record<string, unknown>,
_storeId?: string
): Promise<{ success: boolean; data?: unknown; error?: string }> {
const action = args.action as string;
const url = args.url as string;
const selector = args.selector as string | undefined;
const value = args.value as string | undefined;
const script = args.script as string | undefined;
const waitFor = args.wait_for as string | undefined;
if (!action) {
return { success: false, error: "action is required" };
}
if (!url) {
return { success: false, error: "url is required" };
}
// SSRF check
const blocked = isBlockedUrl(url);
if (blocked) {
return { success: false, error: blocked };
}
try {
switch (action) {
case "navigate":
return await actionNavigate(url, waitFor);
case "screenshot":
return await actionScreenshot(url, waitFor);
case "extract":
return await actionExtract(url, selector!, waitFor);
case "click":
return await actionClick(url, selector!, waitFor);
case "fill":
return await actionFill(url, selector!, value!, waitFor);
case "evaluate":
return await actionEvaluate(url, script!, waitFor);
case "pdf":
return await actionPdf(url, waitFor);
default:
return { success: false, error: `Unknown browser action: ${action}. Valid: navigate, screenshot, extract, click, fill, evaluate, pdf` };
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
// Clean up common Playwright error noise
const cleanMessage = message
.replace(/=========================== logs ===========================[^]*?============================================================/gs, "")
.trim();
return { success: false, error: `Browser error: ${cleanMessage.substring(0, 500)}` };
}
}