next.js example (#694)

* next.js guide

* better

* shadcn

* fixes

* fix: correct screenshot test assertion to check path instead of base64

The daemon returns { path: savePath } for screenshot commands, not base64.

* fix: cross-platform Chrome detection and gitignore hardening

- Replace hardcoded macOS Chrome path with findLocalChrome() that
  searches common paths on macOS, Linux, and WSL, with a clear error
  message when no Chrome is found.
- Add .env and .env*.local to .gitignore to prevent accidental
  secret commits.

* fix: correct Vercel deploy button repo URL to vercel-labs/agent-browser

* clean up

* demo

* next page

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-03-09 15:24:44 -05:00
committed by GitHub
co-authored by ctate
parent 3649787268
commit cc3c70dc86
35 changed files with 6764 additions and 2 deletions
+164
View File
@@ -0,0 +1,164 @@
/**
* Run agent-browser inside a Vercel Sandbox.
*
* No external server needed -- a Linux microVM spins up on demand,
* runs agent-browser + headless Chrome, and shuts down when done.
*
* For production, create a snapshot with agent-browser and Chromium
* pre-installed so startup is sub-second instead of ~30s.
*/
import { Sandbox } from "@vercel/sandbox";
export type SandboxResult = {
exitCode: number;
stdout: string;
stderr: string;
};
const SNAPSHOT_ID = process.env.AGENT_BROWSER_SNAPSHOT_ID;
async function createSandbox(): Promise<InstanceType<typeof Sandbox>> {
if (SNAPSHOT_ID) {
return Sandbox.create({
source: { type: "snapshot", snapshotId: SNAPSHOT_ID },
timeout: 120_000,
});
}
const sandbox = await Sandbox.create({
runtime: "node24",
timeout: 120_000,
});
await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]);
await sandbox.runCommand("npx", ["agent-browser", "install"]);
return sandbox;
}
async function exec(
sandbox: InstanceType<typeof Sandbox>,
cmd: string,
args: string[],
): Promise<SandboxResult> {
const result = await sandbox.runCommand(cmd, args);
return {
exitCode: result.exitCode,
stdout: await result.stdout(),
stderr: await result.stderr(),
};
}
/**
* Screenshot a URL using agent-browser inside a Vercel Sandbox.
* Returns base64-encoded PNG.
*/
export async function screenshotUrl(
url: string,
opts: { fullPage?: boolean } = {},
): Promise<{ screenshot: string; title: string }> {
const sandbox = await createSandbox();
try {
await exec(sandbox, "agent-browser", ["open", url]);
const titleResult = await exec(sandbox, "agent-browser", [
"get",
"title",
"--json",
]);
const title = tryParseJson(titleResult.stdout)?.data?.title || url;
const screenshotArgs = ["screenshot", "--json"];
if (opts.fullPage) screenshotArgs.push("--full");
const ssResult = await exec(sandbox, "agent-browser", screenshotArgs);
const screenshot = tryParseJson(ssResult.stdout)?.data?.base64 || "";
await exec(sandbox, "agent-browser", ["close"]);
return { screenshot, title };
} finally {
await sandbox.stop();
}
}
/**
* Snapshot a URL (accessibility tree) using agent-browser inside a Vercel Sandbox.
*/
export async function snapshotUrl(
url: string,
opts: { interactive?: boolean; compact?: boolean } = {},
): Promise<{ snapshot: string; title: string }> {
const sandbox = await createSandbox();
try {
await exec(sandbox, "agent-browser", ["open", url]);
const titleResult = await exec(sandbox, "agent-browser", [
"get",
"title",
"--json",
]);
const title = tryParseJson(titleResult.stdout)?.data?.title || url;
const snapshotArgs = ["snapshot"];
if (opts.interactive) snapshotArgs.push("-i");
if (opts.compact) snapshotArgs.push("-c");
const snapResult = await exec(sandbox, "agent-browser", snapshotArgs);
await exec(sandbox, "agent-browser", ["close"]);
return { snapshot: snapResult.stdout, title };
} finally {
await sandbox.stop();
}
}
/**
* Run arbitrary agent-browser commands inside a Vercel Sandbox.
* Each command is a string array like ["open", "https://example.com"].
*/
export async function runCommands(
commands: string[][],
): Promise<SandboxResult[]> {
const sandbox = await createSandbox();
try {
const results: SandboxResult[] = [];
for (const args of commands) {
const result = await exec(sandbox, "agent-browser", args);
results.push(result);
if (result.exitCode !== 0) break;
}
return results;
} finally {
await sandbox.stop();
}
}
/**
* Create a reusable snapshot with agent-browser + Chromium pre-installed.
* Run this once, then set AGENT_BROWSER_SNAPSHOT_ID for fast startup.
*/
export async function createSnapshot(): Promise<string> {
const sandbox = await Sandbox.create({
runtime: "node24",
timeout: 300_000,
});
await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]);
await sandbox.runCommand("npx", ["agent-browser", "install"]);
const snapshot = await sandbox.snapshot();
return snapshot.snapshotId;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function tryParseJson(str: string): any {
try {
return JSON.parse(str);
} catch {
return null;
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Run browser automation directly in a Vercel serverless function
* using @sparticuz/chromium + puppeteer-core.
*
* In development, uses the local Chrome/Chromium installation.
* In production (Vercel), uses @sparticuz/chromium's bundled binary.
*/
import puppeteer from "puppeteer-core";
import chromium from "@sparticuz/chromium";
import fs from "node:fs";
const CHROME_PATHS = [
// macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
// Linux
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
// Windows (WSL / common locations)
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
];
function findLocalChrome(): string {
for (const p of CHROME_PATHS) {
if (fs.existsSync(p)) return p;
}
throw new Error(
`Chrome not found. Set CHROMIUM_PATH to your Chrome/Chromium binary. Searched: ${CHROME_PATHS.join(", ")}`,
);
}
async function launchBrowser() {
const isLambda =
!!process.env.VERCEL || !!process.env.AWS_LAMBDA_FUNCTION_NAME;
const executablePath = isLambda
? await chromium.executablePath()
: process.env.CHROMIUM_PATH || findLocalChrome();
const args = isLambda
? chromium.args
: ["--no-sandbox", "--disable-setuid-sandbox"];
return puppeteer.launch({
args,
executablePath,
headless: true,
defaultViewport: { width: 1280, height: 720 },
});
}
export async function screenshotUrl(
url: string,
opts: { fullPage?: boolean } = {},
): Promise<{ screenshot: string; title: string }> {
const browser = await launchBrowser();
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 });
const title = await page.title();
const screenshot = await page.screenshot({
fullPage: opts.fullPage,
encoding: "base64",
});
return {
title: title || url,
screenshot: screenshot as string,
};
} finally {
await browser.close();
}
}
export async function snapshotUrl(
url: string,
opts: { selector?: string } = {},
): Promise<{ snapshot: string; title: string }> {
const browser = await launchBrowser();
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 });
const title = await page.title();
const snapshot = await page.accessibility.snapshot({
root: opts.selector
? (await page.$(opts.selector)) ?? undefined
: undefined,
});
return {
title: title || url,
snapshot: JSON.stringify(snapshot, null, 2),
};
} finally {
await browser.close();
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}