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:
@@ -713,7 +713,40 @@ agent-browser --executable-path /path/to/chromium open example.com
|
||||
AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
|
||||
```
|
||||
|
||||
### Serverless Example (Vercel/AWS Lambda)
|
||||
### Serverless (Vercel)
|
||||
|
||||
Two patterns for using agent-browser from Next.js server actions on Vercel:
|
||||
|
||||
**Vercel Sandbox** -- run agent-browser + Chrome in an ephemeral microVM. No external server needed:
|
||||
|
||||
```typescript
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
|
||||
const sandbox = await Sandbox.create({ runtime: "node24" });
|
||||
await sandbox.runCommand("agent-browser", ["open", "https://example.com"]);
|
||||
const result = await sandbox.runCommand("agent-browser", ["screenshot", "--json"]);
|
||||
await sandbox.stop();
|
||||
```
|
||||
|
||||
**External API server** -- host agent-browser on a server, call it from Vercel via HTTP:
|
||||
|
||||
```typescript
|
||||
const res = await fetch(`${process.env.AGENT_BROWSER_API_URL}/api/run`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
commands: [
|
||||
{ action: "launch", headless: true },
|
||||
{ action: "navigate", url: "https://example.com" },
|
||||
{ action: "screenshot" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
See the [demo app](examples/demo/) for a visual demo of agent-browser's core capabilities across different compute environments.
|
||||
|
||||
### Serverless (AWS Lambda)
|
||||
|
||||
```typescript
|
||||
import chromium from '@sparticuz/chromium';
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("next")
|
||||
|
||||
# Next.js + Vercel
|
||||
|
||||
Two patterns for running agent-browser from Next.js on Vercel.
|
||||
|
||||
## Pattern 1: Serverless Function
|
||||
|
||||
Run `@sparticuz/chromium` + `puppeteer-core` directly inside a Vercel
|
||||
serverless function. No external server needed.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
pnpm add @sparticuz/chromium puppeteer-core
|
||||
```
|
||||
|
||||
### Browser launcher
|
||||
|
||||
```ts
|
||||
import puppeteer from "puppeteer-core";
|
||||
import chromium from "@sparticuz/chromium";
|
||||
import fs from "node:fs";
|
||||
|
||||
const CHROME_PATHS = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
];
|
||||
|
||||
function findLocalChrome(): string {
|
||||
for (const p of CHROME_PATHS) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
throw new Error("Chrome not found. Set CHROMIUM_PATH.");
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
const isLambda =
|
||||
!!process.env.VERCEL || !!process.env.AWS_LAMBDA_FUNCTION_NAME;
|
||||
|
||||
return puppeteer.launch({
|
||||
args: isLambda ? chromium.args : ["--no-sandbox"],
|
||||
executablePath: isLambda
|
||||
? await chromium.executablePath()
|
||||
: process.env.CHROMIUM_PATH || findLocalChrome(),
|
||||
headless: true,
|
||||
defaultViewport: { width: 1280, height: 720 },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
On Vercel, `@sparticuz/chromium` provides the binary automatically.
|
||||
Locally, the launcher finds your system Chrome or uses `CHROMIUM_PATH`.
|
||||
|
||||
### Server action
|
||||
|
||||
```ts
|
||||
"use server";
|
||||
|
||||
export async function screenshotUrl(url: 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({ encoding: "base64" });
|
||||
return { ok: true, title, screenshot };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Next.js config
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
const nextConfig = {
|
||||
serverExternalPackages: ["@sparticuz/chromium"],
|
||||
};
|
||||
export default nextConfig;
|
||||
```
|
||||
|
||||
## Pattern 2: Vercel Sandbox
|
||||
|
||||
A Linux microVM spins up on demand, runs agent-browser + Chrome, and
|
||||
shuts down. No binary size limits. Best for multi-step workflows or
|
||||
when you need full Chrome capabilities.
|
||||
|
||||
### Server action
|
||||
|
||||
```ts
|
||||
"use server";
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
|
||||
export async function screenshotUrl(url: string) {
|
||||
const snapshotId = process.env.AGENT_BROWSER_SNAPSHOT_ID;
|
||||
|
||||
const sandbox = snapshotId
|
||||
? await Sandbox.create({
|
||||
source: { type: "snapshot", snapshotId },
|
||||
timeout: 120_000,
|
||||
})
|
||||
: await Sandbox.create({ runtime: "node24", timeout: 120_000 });
|
||||
|
||||
try {
|
||||
if (!snapshotId) {
|
||||
await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]);
|
||||
await sandbox.runCommand("npx", ["agent-browser", "install"]);
|
||||
}
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["open", url]);
|
||||
|
||||
const result = await sandbox.runCommand("agent-browser", [
|
||||
"screenshot", "--json",
|
||||
]);
|
||||
const stdout = await result.stdout();
|
||||
const data = JSON.parse(stdout);
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["close"]);
|
||||
return { ok: true, screenshot: data.data.base64 };
|
||||
} finally {
|
||||
await sandbox.stop();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Optimize with snapshots
|
||||
|
||||
Installing agent-browser + Chromium takes ~30 seconds. Create a
|
||||
snapshot to make sandbox creation sub-second:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-snapshot.ts
|
||||
# Output: AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
Add the snapshot ID to your Vercel environment variables or `.env.local`.
|
||||
|
||||
## Scheduled workflows (cron)
|
||||
|
||||
For recurring tasks like daily monitoring, use Vercel Cron Jobs with
|
||||
either pattern:
|
||||
|
||||
```ts
|
||||
// app/api/cron/monitor/route.ts
|
||||
export async function GET() {
|
||||
const browser = await launchBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto("https://example.com/pricing", {
|
||||
waitUntil: "networkidle2",
|
||||
});
|
||||
const snapshot = await page.accessibility.snapshot();
|
||||
// Process results, send alerts, store in database...
|
||||
return Response.json({ ok: true, snapshot });
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// vercel.json
|
||||
{
|
||||
"crons": [
|
||||
{ "path": "/api/cron/monitor", "schedule": "0 9 * * *" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## When to use which pattern
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th></th><th>Serverless Function</th><th>Vercel Sandbox</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Infrastructure</td><td>None (runs in-function)</td><td>None (Vercel manages VMs)</td></tr>
|
||||
<tr><td>Startup time</td><td>~2-3 seconds</td><td>Sub-second with snapshot</td></tr>
|
||||
<tr><td>Binary size limit</td><td>50MB compressed</td><td>None</td></tr>
|
||||
<tr><td>Multi-step workflows</td><td>Single request only</td><td>Yes (persistent session)</td></tr>
|
||||
<tr><td>Task duration</td><td>Up to 300s (Pro)</td><td>Up to ~2 minutes</td></tr>
|
||||
<tr><td>Best for</td><td>Fast single-request screenshots</td><td>Complex workflows, full Chrome</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Environment variables
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Variable</th><th>Pattern</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>CHROMIUM_PATH</code></td><td>Serverless</td><td>Path to local Chrome/Chromium (auto-detected on Vercel)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_SNAPSHOT_ID</code></td><td>Sandbox</td><td>Pre-built snapshot for fast startup</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Demo app
|
||||
|
||||
A working demo with both patterns, a UI, and a deploy-to-Vercel button is at
|
||||
[`examples/demo/`](https://github.com/agent-browser/agent-browser/tree/main/examples/demo).
|
||||
@@ -66,7 +66,7 @@ export function Header() {
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<span>16k</span>
|
||||
<span>20k</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.npmjs.com/package/agent-browser"
|
||||
|
||||
@@ -37,6 +37,7 @@ export const navigation: NavSection[] = [
|
||||
{ name: "Profiler", href: "/profiler" },
|
||||
{ name: "iOS Simulator", href: "/ios" },
|
||||
{ name: "Security", href: "/security" },
|
||||
{ name: "Next.js + Vercel", href: "/next" },
|
||||
{ name: "Native Mode (Experimental)", href: "/native-mode" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
security: "Security",
|
||||
"engines/chrome": "Chrome",
|
||||
"engines/lightpanda": "Lightpanda",
|
||||
next: "Next.js + Vercel",
|
||||
"native-mode": "Native Mode (Experimental)",
|
||||
changelog: "Changelog",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# --- Serverless function mode (@sparticuz/chromium) ---
|
||||
# Optional: path to Chromium binary for local development.
|
||||
# On Vercel, @sparticuz/chromium provides this automatically.
|
||||
# CHROMIUM_PATH=/usr/bin/chromium-browser
|
||||
|
||||
# --- Vercel Sandbox mode ---
|
||||
# Snapshot ID with agent-browser + Chromium pre-installed (optional, speeds up startup)
|
||||
# Create one with: npx tsx scripts/create-snapshot.ts
|
||||
# AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
@@ -0,0 +1,53 @@
|
||||
# agent-browser Demo
|
||||
|
||||
A visual demo of agent-browser's core capabilities. Enter a URL, pick a compute environment, and take a screenshot or accessibility snapshot.
|
||||
|
||||
## Environments
|
||||
|
||||
- **Serverless Function** -- `@sparticuz/chromium` + `puppeteer-core` running directly inside a Vercel serverless function
|
||||
- **Vercel Sandbox** -- agent-browser + Chrome in an ephemeral Linux microVM
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
cd examples/demo
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Serverless Function
|
||||
|
||||
Runs headless Chrome directly in the serverless function. On Vercel, `@sparticuz/chromium` provides the binary automatically. Locally, the app finds your system Chrome or uses `CHROMIUM_PATH`.
|
||||
|
||||
## Vercel Sandbox
|
||||
|
||||
Spins up a Linux microVM on demand, installs agent-browser + Chrome, runs the commands, and shuts down. No binary size limits. Create a snapshot to make startup sub-second:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-snapshot.ts
|
||||
# Output: AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
Add the snapshot ID to your Vercel environment variables or `.env.local`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Environment | Description |
|
||||
|---|---|---|
|
||||
| `CHROMIUM_PATH` | Serverless | Path to local Chrome/Chromium binary (auto-detected on Vercel) |
|
||||
| `AGENT_BROWSER_SNAPSHOT_ID` | Sandbox | Pre-built snapshot ID for fast startup |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
examples/demo/
|
||||
app/
|
||||
page.tsx # Demo UI
|
||||
actions/browse.ts # Server actions (all environments)
|
||||
api/browse/route.ts # API route for programmatic access
|
||||
lib/
|
||||
agent-browser.ts # Serverless: @sparticuz/chromium + puppeteer-core
|
||||
agent-browser-sandbox.ts # Sandbox: Vercel Sandbox client
|
||||
scripts/
|
||||
create-snapshot.ts # Create sandbox snapshot
|
||||
```
|
||||
@@ -0,0 +1,98 @@
|
||||
"use server";
|
||||
|
||||
import * as serverless from "@/lib/agent-browser";
|
||||
import * as sandbox from "@/lib/agent-browser-sandbox";
|
||||
|
||||
export type EnvStatus = {
|
||||
serverless: {
|
||||
hasChromiumPath: boolean;
|
||||
isVercel: boolean;
|
||||
};
|
||||
sandbox: {
|
||||
hasSnapshot: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getEnvStatus(): Promise<EnvStatus> {
|
||||
return {
|
||||
serverless: {
|
||||
hasChromiumPath: !!process.env.CHROMIUM_PATH,
|
||||
isVercel:
|
||||
!!process.env.VERCEL || !!process.env.AWS_LAMBDA_FUNCTION_NAME,
|
||||
},
|
||||
sandbox: {
|
||||
hasSnapshot: !!process.env.AGENT_BROWSER_SNAPSHOT_ID,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ScreenshotResult = {
|
||||
ok: boolean;
|
||||
screenshot?: string;
|
||||
title?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type SnapshotResult = {
|
||||
ok: boolean;
|
||||
snapshot?: string;
|
||||
title?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type Mode = "serverless" | "sandbox";
|
||||
|
||||
/**
|
||||
* Server action: screenshot a URL.
|
||||
*
|
||||
* mode="serverless" -- runs @sparticuz/chromium + puppeteer-core in the function
|
||||
* mode="sandbox" -- runs agent-browser inside a Vercel Sandbox microVM
|
||||
*/
|
||||
export async function takeScreenshot(
|
||||
url: string,
|
||||
mode: Mode = "serverless",
|
||||
): Promise<ScreenshotResult> {
|
||||
try {
|
||||
if (mode === "sandbox") {
|
||||
const { screenshot, title } = await sandbox.screenshotUrl(url);
|
||||
return { ok: true, screenshot, title };
|
||||
}
|
||||
|
||||
const { screenshot, title } = await serverless.screenshotUrl(url);
|
||||
return { ok: true, screenshot, title };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action: snapshot a URL (accessibility tree).
|
||||
*
|
||||
* mode="serverless" -- runs @sparticuz/chromium + puppeteer-core in the function
|
||||
* mode="sandbox" -- runs agent-browser inside a Vercel Sandbox microVM
|
||||
*/
|
||||
export async function takeSnapshot(
|
||||
url: string,
|
||||
mode: Mode = "serverless",
|
||||
): Promise<SnapshotResult> {
|
||||
try {
|
||||
if (mode === "sandbox") {
|
||||
const { snapshot, title } = await sandbox.snapshotUrl(url, {
|
||||
interactive: true,
|
||||
compact: true,
|
||||
});
|
||||
return { ok: true, snapshot, title };
|
||||
}
|
||||
|
||||
const { snapshot, title } = await serverless.snapshotUrl(url);
|
||||
return { ok: true, snapshot, title };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as ab from "@/lib/agent-browser";
|
||||
|
||||
/**
|
||||
* POST /api/browse
|
||||
*
|
||||
* Programmatic API route for browser automation.
|
||||
* Uses @sparticuz/chromium + puppeteer-core directly in the function.
|
||||
*
|
||||
* Body: { "action": "screenshot", "url": "https://example.com" }
|
||||
* Or: { "action": "snapshot", "url": "https://example.com" }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const url = body.url;
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "Provide a 'url'" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.action === "screenshot") {
|
||||
const result = await ab.screenshotUrl(url, {
|
||||
fullPage: body.fullPage,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
if (body.action === "snapshot") {
|
||||
const result = await ab.snapshotUrl(url, {
|
||||
selector: body.selector,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Provide 'action' as 'screenshot' or 'snapshot'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-surface: #fafafa;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.58 0.22 27);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--surface: oklch(0.985 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.87 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--surface: oklch(0.205 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, "SFMono-Regular",
|
||||
"Roboto Mono", monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--color-surface: var(--surface);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "agent-browser Demo",
|
||||
description: "A visual demo of agent-browser's core capabilities",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
|
||||
<body className="min-h-screen font-sans antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useSyncExternalStore } from "react";
|
||||
import { takeScreenshot, takeSnapshot, getEnvStatus } from "./actions/browse";
|
||||
import type {
|
||||
ScreenshotResult,
|
||||
SnapshotResult,
|
||||
Mode,
|
||||
EnvStatus,
|
||||
} from "./actions/browse";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, Monitor, TriangleAlert, CircleX } from "lucide-react";
|
||||
|
||||
const MOBILE_QUERY = "(max-width: 767px)";
|
||||
const subscribe = (cb: () => void) => {
|
||||
const mql = window.matchMedia(MOBILE_QUERY);
|
||||
mql.addEventListener("change", cb);
|
||||
return () => mql.removeEventListener("change", cb);
|
||||
};
|
||||
const getSnapshot = () => window.matchMedia(MOBILE_QUERY).matches;
|
||||
const getServerSnapshot = () => false;
|
||||
|
||||
function useIsMobile() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
type Action = "screenshot" | "snapshot";
|
||||
|
||||
function formatError(raw: string): string {
|
||||
let cleaned = raw.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
const match = cleaned.match(/(?:error|Error)[:\s]*(.{1,200})/);
|
||||
if (match) cleaned = match[1].trim();
|
||||
if (cleaned.length > 300) cleaned = cleaned.slice(0, 300) + "...";
|
||||
return cleaned || raw.slice(0, 300);
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: { value: T; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded-lg border border-input bg-muted p-0.5 w-full">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`
|
||||
flex-1 px-3 py-1.5 text-[13px] font-medium rounded-md transition-all cursor-pointer
|
||||
${
|
||||
value === opt.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvBadge({
|
||||
label,
|
||||
value,
|
||||
status,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
status: "ok" | "warn" | "missing";
|
||||
}) {
|
||||
const variant =
|
||||
status === "ok"
|
||||
? "outline"
|
||||
: status === "warn"
|
||||
? "secondary"
|
||||
: "destructive";
|
||||
const icon =
|
||||
status === "ok" ? "\u2713" : status === "warn" ? "\u26A0" : "\u2717";
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className="gap-1 font-mono text-[10px]">
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
{value && <span className="opacity-60">{value}</span>}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorDisplay({ error }: { error: string }) {
|
||||
const isHtml = /<[a-z][\s\S]*>/i.test(error);
|
||||
const message = isHtml ? formatError(error) : error;
|
||||
const showRaw = isHtml && error.length > 100;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl space-y-0">
|
||||
<Alert variant="destructive">
|
||||
<CircleX className="size-4" />
|
||||
<AlertTitle>Request failed</AlertTitle>
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
{showRaw && (
|
||||
<details className="border border-t-0 border-border rounded-b-lg overflow-hidden">
|
||||
<summary className="px-4 py-2 text-[11px] font-medium text-muted-foreground cursor-pointer hover:bg-muted transition-colors">
|
||||
Show raw response
|
||||
</summary>
|
||||
<pre className="px-4 py-3 text-[11px] leading-relaxed text-muted-foreground font-mono overflow-auto max-h-[200px] bg-muted/50">
|
||||
{error}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
selected,
|
||||
onSelect,
|
||||
title,
|
||||
description,
|
||||
badges,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
badges?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`
|
||||
w-full text-left rounded-lg border p-3 transition-all cursor-pointer
|
||||
${
|
||||
selected
|
||||
? "border-ring bg-background ring-1 ring-ring/20"
|
||||
: "border-input bg-background hover:border-ring/50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<div
|
||||
className={`
|
||||
size-3.5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors
|
||||
${selected ? "border-foreground" : "border-muted-foreground/30"}
|
||||
`}
|
||||
>
|
||||
{selected && (
|
||||
<div className="size-1.5 rounded-full bg-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold">{title}</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-muted-foreground leading-relaxed pl-[22px] mb-2">
|
||||
{description}
|
||||
</p>
|
||||
{badges && (
|
||||
<div className="flex flex-wrap gap-1.5 pl-[22px]">{badges}</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const isMobile = useIsMobile();
|
||||
const [url, setUrl] = useState("https://example.com");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [action, setAction] = useState<Action>("screenshot");
|
||||
const [mode, setMode] = useState<Mode>("serverless");
|
||||
const [screenshotResult, setScreenshotResult] =
|
||||
useState<ScreenshotResult | null>(null);
|
||||
const [snapshotResult, setSnapshotResult] =
|
||||
useState<SnapshotResult | null>(null);
|
||||
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getEnvStatus().then(setEnvStatus);
|
||||
}, []);
|
||||
|
||||
function clearResults() {
|
||||
setScreenshotResult(null);
|
||||
setSnapshotResult(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setScreenshotResult(null);
|
||||
setSnapshotResult(null);
|
||||
|
||||
try {
|
||||
if (action === "screenshot") {
|
||||
const result = await takeScreenshot(url, mode);
|
||||
setScreenshotResult(result);
|
||||
} else {
|
||||
const result = await takeSnapshot(url, mode);
|
||||
setSnapshotResult(result);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (action === "screenshot") {
|
||||
setScreenshotResult({ ok: false, error: message });
|
||||
} else {
|
||||
setSnapshotResult({ ok: false, error: message });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasResult = screenshotResult || snapshotResult;
|
||||
|
||||
const envWarning =
|
||||
envStatus &&
|
||||
mode === "serverless" &&
|
||||
!envStatus.serverless.isVercel &&
|
||||
!envStatus.serverless.hasChromiumPath
|
||||
? "Running locally without CHROMIUM_PATH. The app will try to use your system Chrome. Set CHROMIUM_PATH if Chrome is not in the default location."
|
||||
: null;
|
||||
|
||||
const controlsForm = (
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="url-input"
|
||||
className="text-[11px] text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
URL
|
||||
</Label>
|
||||
<Input
|
||||
id="url-input"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
clearResults();
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
Action
|
||||
</Label>
|
||||
<SegmentedControl<Action>
|
||||
value={action}
|
||||
onChange={(v) => {
|
||||
setAction(v);
|
||||
clearResults();
|
||||
}}
|
||||
options={[
|
||||
{ value: "screenshot", label: "Screenshot" },
|
||||
{ value: "snapshot", label: "Snapshot" },
|
||||
]}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{action === "screenshot"
|
||||
? "Captures a full-page PNG image"
|
||||
: "Returns the accessibility tree"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
Environment
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
<ModeCard
|
||||
selected={mode === "serverless"}
|
||||
onSelect={() => {
|
||||
setMode("serverless");
|
||||
clearResults();
|
||||
}}
|
||||
title="Serverless Function"
|
||||
description="Runs @sparticuz/chromium + puppeteer-core directly in a Vercel function."
|
||||
badges={
|
||||
envStatus && (
|
||||
<EnvBadge
|
||||
label="@sparticuz/chromium"
|
||||
status={
|
||||
envStatus.serverless.isVercel
|
||||
? "ok"
|
||||
: envStatus.serverless.hasChromiumPath
|
||||
? "ok"
|
||||
: "warn"
|
||||
}
|
||||
value={
|
||||
envStatus.serverless.isVercel
|
||||
? "auto"
|
||||
: envStatus.serverless.hasChromiumPath
|
||||
? "CHROMIUM_PATH"
|
||||
: "system Chrome"
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ModeCard
|
||||
selected={mode === "sandbox"}
|
||||
onSelect={() => {
|
||||
setMode("sandbox");
|
||||
clearResults();
|
||||
}}
|
||||
title="Vercel Sandbox"
|
||||
description="Ephemeral microVM with agent-browser + Chrome. No binary size limits."
|
||||
badges={
|
||||
envStatus && (
|
||||
<EnvBadge
|
||||
label="AGENT_BROWSER_SNAPSHOT_ID"
|
||||
status={
|
||||
envStatus.sandbox.hasSnapshot ? "ok" : "warn"
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{envWarning && (
|
||||
<Alert>
|
||||
<TriangleAlert className="size-4" />
|
||||
<AlertTitle>Local development</AlertTitle>
|
||||
<AlertDescription>{envWarning}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
{loading ? "Running..." : "Run"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
const resultContent = loading ? (
|
||||
<div className="min-h-[300px] md:h-full flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<p className="text-sm">Taking {action}...</p>
|
||||
</div>
|
||||
) : hasResult ? (
|
||||
<div className="flex flex-col items-center p-6 lg:p-10">
|
||||
{screenshotResult &&
|
||||
(screenshotResult.ok ? (
|
||||
<div className="w-full max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold truncate mr-3">
|
||||
{screenshotResult.title}
|
||||
</h2>
|
||||
<Badge variant="outline" className="font-mono text-[11px] shrink-0">
|
||||
screenshot
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
|
||||
<img
|
||||
src={`data:image/png;base64,${screenshotResult.screenshot}`}
|
||||
alt={screenshotResult.title}
|
||||
className="w-full block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ErrorDisplay
|
||||
error={screenshotResult.error ?? "Unknown error"}
|
||||
/>
|
||||
))}
|
||||
|
||||
{snapshotResult &&
|
||||
(snapshotResult.ok ? (
|
||||
<div className="w-full max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold truncate mr-3">
|
||||
{snapshotResult.title}
|
||||
</h2>
|
||||
<Badge variant="outline" className="font-mono text-[11px] shrink-0">
|
||||
snapshot
|
||||
</Badge>
|
||||
</div>
|
||||
<pre className="bg-card rounded-xl border border-border p-5 overflow-auto text-[13px] leading-relaxed font-mono max-h-[calc(100vh-12rem)]">
|
||||
{snapshotResult.snapshot}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<ErrorDisplay
|
||||
error={snapshotResult.error ?? "Unknown error"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-[300px] md:h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Monitor className="size-12 mb-4 opacity-30" strokeWidth={1} />
|
||||
<p className="text-sm font-medium mb-1">No result yet</p>
|
||||
<p className="text-[13px]">Enter a URL and click Run</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<header className="border-b border-border shrink-0">
|
||||
<div className="px-4 md:px-6 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold tracking-tight">
|
||||
agent-browser
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm hidden sm:inline">/</span>
|
||||
<span className="text-sm text-muted-foreground hidden sm:inline">
|
||||
Demo
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fdemo&env=CHROMIUM_PATH&envDescription=Optional%20path%20to%20Chromium%20binary.%20Not%20needed%20on%20Vercel.&envLink=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fdemo%23environment-variables&project-name=agent-browser-demo&repository-name=agent-browser-demo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="https://vercel.com/button"
|
||||
alt="Deploy with Vercel"
|
||||
className="h-8"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isMobile ? (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="border-b border-border">{controlsForm}</div>
|
||||
<div className="bg-surface">{resultContent}</div>
|
||||
</div>
|
||||
) : (
|
||||
<ResizablePanelGroup orientation="horizontal" className="flex-1">
|
||||
<ResizablePanel defaultSize="30%" minSize="20%" maxSize="50%">
|
||||
<aside className="h-full overflow-y-auto">{controlsForm}</aside>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel defaultSize="70%">
|
||||
<main className="h-full overflow-auto bg-surface">
|
||||
{resultContent}
|
||||
</main>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
orientation: "horizontal",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
...props
|
||||
}: ToggleGroupPrimitive.Props &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
data-orientation={orientation}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider
|
||||
value={{ variant, size, spacing, orientation }}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TogglePrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 min-w-8 px-2",
|
||||
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]",
|
||||
lg: "h-9 min-w-9 px-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "node:path";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
outputFileTracingRoot: path.resolve(import.meta.dirname, "../../"),
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "agent-browser-demo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@sparticuz/chromium": "^143.0.4",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@vercel/sandbox": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.7.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "^16.1.6",
|
||||
"postcss": "^8.5.8",
|
||||
"puppeteer-core": "^24.38.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-resizable-panels": "^4.7.2",
|
||||
"shadcn": "^4.0.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
Generated
+4411
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Create a Vercel Sandbox snapshot with agent-browser + Chromium pre-installed.
|
||||
*
|
||||
* Run once: npx tsx scripts/create-snapshot.ts
|
||||
* Then set: AGENT_BROWSER_SNAPSHOT_ID=<output id>
|
||||
*
|
||||
* This makes sandbox creation sub-second instead of ~30s.
|
||||
*/
|
||||
|
||||
import { createSnapshot } from "../lib/agent-browser-sandbox";
|
||||
|
||||
async function main() {
|
||||
console.log("Creating Vercel Sandbox with agent-browser + Chromium...");
|
||||
console.log("This takes ~30-60 seconds on first run.\n");
|
||||
|
||||
const snapshotId = await createSnapshot();
|
||||
|
||||
console.log("\nSnapshot created successfully!");
|
||||
console.log(`\n AGENT_BROWSER_SNAPSHOT_ID=${snapshotId}\n`);
|
||||
console.log("Add this to your .env.local or Vercel environment variables.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed to create snapshot:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"server"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
name: next
|
||||
description: Run headless Chrome in Next.js serverless functions using @sparticuz/chromium + puppeteer-core. Use when the user needs browser automation from a Next.js app, wants to take screenshots or snapshots from server actions or API routes, or is building a Next.js app that needs headless Chrome. Triggers include "screenshot from Next.js", "headless Chrome in serverless", "browser automation in Next.js", "puppeteer on Vercel", or any task requiring Chrome in a Next.js server context.
|
||||
---
|
||||
|
||||
# Browser Automation in Next.js Serverless Functions
|
||||
|
||||
Run headless Chrome directly inside Next.js server actions and API routes using `@sparticuz/chromium` + `puppeteer-core`. No external server needed -- Chrome runs in the same serverless function.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
pnpm add @sparticuz/chromium puppeteer-core
|
||||
```
|
||||
|
||||
## Core Pattern
|
||||
|
||||
```ts
|
||||
import puppeteer from "puppeteer-core";
|
||||
import chromium from "@sparticuz/chromium";
|
||||
import fs from "node:fs";
|
||||
|
||||
const CHROME_PATHS = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
];
|
||||
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
On Vercel, `@sparticuz/chromium` bundles a compatible Chromium binary automatically. Locally, the launcher falls back to the system Chrome installation or `CHROMIUM_PATH`.
|
||||
|
||||
## Server Actions
|
||||
|
||||
### Screenshot
|
||||
|
||||
```ts
|
||||
"use server";
|
||||
|
||||
export async function takeScreenshot(url: 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: true,
|
||||
encoding: "base64",
|
||||
});
|
||||
return { ok: true, title, screenshot: screenshot as string };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Accessibility Snapshot
|
||||
|
||||
```ts
|
||||
"use server";
|
||||
|
||||
export async function takeSnapshot(url: 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();
|
||||
return { ok: true, title, snapshot: JSON.stringify(snapshot, null, 2) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
```ts
|
||||
// app/api/browse/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { url, action } = await req.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "Provide a 'url'" }, { status: 400 });
|
||||
}
|
||||
|
||||
const browser = await launchBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 });
|
||||
|
||||
if (action === "screenshot") {
|
||||
const screenshot = await page.screenshot({ encoding: "base64" });
|
||||
return NextResponse.json({ screenshot });
|
||||
}
|
||||
|
||||
if (action === "snapshot") {
|
||||
const snapshot = await page.accessibility.snapshot();
|
||||
return NextResponse.json({ snapshot });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "action must be 'screenshot' or 'snapshot'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `CHROMIUM_PATH` | Local dev only | Path to Chrome/Chromium binary. Not needed on Vercel. |
|
||||
|
||||
On Vercel, `@sparticuz/chromium` auto-detects the bundled binary. Locally, if Chrome is not in a standard location, set `CHROMIUM_PATH`.
|
||||
|
||||
## Vercel Configuration
|
||||
|
||||
The `@sparticuz/chromium` binary is large (~50MB). Increase the serverless function's memory and timeout if needed:
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
const nextConfig = {
|
||||
serverExternalPackages: ["@sparticuz/chromium"],
|
||||
};
|
||||
export default nextConfig;
|
||||
```
|
||||
|
||||
If the project lives in a monorepo subdirectory, set `outputFileTracingRoot` so the Chromium binary is included in the deployment:
|
||||
|
||||
```ts
|
||||
import path from "node:path";
|
||||
|
||||
const nextConfig = {
|
||||
outputFileTracingRoot: path.join(import.meta.dirname, "../../"),
|
||||
serverExternalPackages: ["@sparticuz/chromium"],
|
||||
};
|
||||
export default nextConfig;
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Vercel serverless functions have a 50MB compressed size limit. `@sparticuz/chromium` fits within this but leaves limited room for other large dependencies.
|
||||
- Function execution timeout is 10s on Hobby, 300s on Pro. Complex page loads may need the Pro plan.
|
||||
- Each invocation launches a fresh browser. There is no session persistence between requests.
|
||||
- For workflows that need persistent sessions, longer timeouts, or full Chrome (no size limits), use the Vercel Sandbox pattern instead (see the `vercel-sandbox` skill).
|
||||
|
||||
## Example
|
||||
|
||||
See `examples/demo/` in the agent-browser repo for a working app with both serverless and sandbox patterns, and a deploy-to-Vercel button.
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
name: vercel-sandbox
|
||||
description: Run agent-browser + Chrome inside Vercel Sandbox microVMs for browser automation from any Vercel-deployed app. Use when the user needs browser automation in a Vercel app (Next.js, SvelteKit, Nuxt, Remix, Astro, etc.), wants to run headless Chrome without binary size limits, needs persistent browser sessions across commands, or wants ephemeral isolated browser environments. Triggers include "Vercel Sandbox browser", "microVM Chrome", "agent-browser in sandbox", "browser automation on Vercel", or any task requiring Chrome in a Vercel Sandbox.
|
||||
---
|
||||
|
||||
# Browser Automation with Vercel Sandbox
|
||||
|
||||
Run agent-browser + headless Chrome inside ephemeral Vercel Sandbox microVMs. A Linux VM spins up on demand, executes browser commands, and shuts down. Works with any Vercel-deployed framework (Next.js, SvelteKit, Nuxt, Remix, Astro, etc.).
|
||||
|
||||
## When to Use Sandbox vs Serverless
|
||||
|
||||
| | Vercel Sandbox | Serverless (`@sparticuz/chromium`) |
|
||||
|---|---|---|
|
||||
| Binary size limit | None | 50MB compressed |
|
||||
| Session persistence | Yes, within a sandbox lifetime | No, fresh browser per request |
|
||||
| Multi-step workflows | Yes, run sequences of commands | Single request only |
|
||||
| Startup time | ~30s cold, sub-second with snapshot | ~2-3s |
|
||||
| Framework support | Any (Next.js, SvelteKit, Nuxt, etc.) | Next.js (or any Node.js serverless) |
|
||||
|
||||
Use Sandbox when you need full Chrome, multi-step workflows, or longer execution times. Use serverless when you need fast single-request screenshots/snapshots.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
pnpm add @vercel/sandbox
|
||||
```
|
||||
|
||||
The sandbox VM installs agent-browser and Chrome on first run. Use snapshots (below) to skip this step.
|
||||
|
||||
## Core Pattern
|
||||
|
||||
```ts
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
|
||||
async function withBrowser<T>(
|
||||
fn: (sandbox: InstanceType<typeof Sandbox>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const snapshotId = process.env.AGENT_BROWSER_SNAPSHOT_ID;
|
||||
|
||||
const sandbox = snapshotId
|
||||
? await Sandbox.create({
|
||||
source: { type: "snapshot", snapshotId },
|
||||
timeout: 120_000,
|
||||
})
|
||||
: await Sandbox.create({ runtime: "node24", timeout: 120_000 });
|
||||
|
||||
if (!snapshotId) {
|
||||
await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]);
|
||||
await sandbox.runCommand("npx", ["agent-browser", "install"]);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn(sandbox);
|
||||
} finally {
|
||||
await sandbox.stop();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Screenshot
|
||||
|
||||
```ts
|
||||
export async function screenshotUrl(url: string) {
|
||||
return withBrowser(async (sandbox) => {
|
||||
await sandbox.runCommand("agent-browser", ["open", url]);
|
||||
|
||||
const titleResult = await sandbox.runCommand("agent-browser", [
|
||||
"get", "title", "--json",
|
||||
]);
|
||||
const title = JSON.parse(await titleResult.stdout())?.data?.title || url;
|
||||
|
||||
const ssResult = await sandbox.runCommand("agent-browser", [
|
||||
"screenshot", "--json",
|
||||
]);
|
||||
const screenshot = JSON.parse(await ssResult.stdout())?.data?.base64 || "";
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["close"]);
|
||||
|
||||
return { title, screenshot };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility Snapshot
|
||||
|
||||
```ts
|
||||
export async function snapshotUrl(url: string) {
|
||||
return withBrowser(async (sandbox) => {
|
||||
await sandbox.runCommand("agent-browser", ["open", url]);
|
||||
|
||||
const titleResult = await sandbox.runCommand("agent-browser", [
|
||||
"get", "title", "--json",
|
||||
]);
|
||||
const title = JSON.parse(await titleResult.stdout())?.data?.title || url;
|
||||
|
||||
const snapResult = await sandbox.runCommand("agent-browser", [
|
||||
"snapshot", "-i", "-c",
|
||||
]);
|
||||
const snapshot = await snapResult.stdout();
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["close"]);
|
||||
|
||||
return { title, snapshot };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Step Workflows
|
||||
|
||||
The sandbox persists between commands, so you can run full automation sequences:
|
||||
|
||||
```ts
|
||||
export async function fillAndSubmitForm(url: string, data: Record<string, string>) {
|
||||
return withBrowser(async (sandbox) => {
|
||||
await sandbox.runCommand("agent-browser", ["open", url]);
|
||||
|
||||
const snapResult = await sandbox.runCommand("agent-browser", [
|
||||
"snapshot", "-i",
|
||||
]);
|
||||
const snapshot = await snapResult.stdout();
|
||||
// Parse snapshot to find element refs...
|
||||
|
||||
for (const [ref, value] of Object.entries(data)) {
|
||||
await sandbox.runCommand("agent-browser", ["fill", ref, value]);
|
||||
}
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["click", "@e5"]);
|
||||
await sandbox.runCommand("agent-browser", ["wait", "--load", "networkidle"]);
|
||||
|
||||
const ssResult = await sandbox.runCommand("agent-browser", [
|
||||
"screenshot", "--json",
|
||||
]);
|
||||
const screenshot = JSON.parse(await ssResult.stdout())?.data?.base64 || "";
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["close"]);
|
||||
|
||||
return { screenshot };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Snapshots (Fast Startup)
|
||||
|
||||
Without a snapshot, the first sandbox run installs agent-browser + Chromium (~30s). Create a snapshot to make startup sub-second:
|
||||
|
||||
```ts
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
Run this once, then set the environment variable:
|
||||
|
||||
```bash
|
||||
AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
A helper script is available in the demo app:
|
||||
|
||||
```bash
|
||||
npx tsx examples/demo/scripts/create-snapshot.ts
|
||||
```
|
||||
|
||||
## Scheduled Workflows (Cron)
|
||||
|
||||
Combine with Vercel Cron Jobs for recurring browser tasks:
|
||||
|
||||
```ts
|
||||
// app/api/cron/route.ts (or equivalent in your framework)
|
||||
export async function GET() {
|
||||
const result = await withBrowser(async (sandbox) => {
|
||||
await sandbox.runCommand("agent-browser", ["open", "https://example.com/pricing"]);
|
||||
const snap = await sandbox.runCommand("agent-browser", ["snapshot", "-i", "-c"]);
|
||||
await sandbox.runCommand("agent-browser", ["close"]);
|
||||
return await snap.stdout();
|
||||
});
|
||||
|
||||
// Process results, send alerts, store data...
|
||||
return Response.json({ ok: true, snapshot: result });
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// vercel.json
|
||||
{ "crons": [{ "path": "/api/cron", "schedule": "0 9 * * *" }] }
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `AGENT_BROWSER_SNAPSHOT_ID` | No (but recommended) | Pre-built snapshot ID for sub-second startup |
|
||||
|
||||
The Vercel Sandbox SDK handles OIDC authentication automatically when deployed on Vercel. For local development, run `vercel link` and `vercel env pull` to get the required tokens.
|
||||
|
||||
## Framework Examples
|
||||
|
||||
The pattern works identically across frameworks. The only difference is where you put the server-side code:
|
||||
|
||||
| Framework | Server code location |
|
||||
|---|---|
|
||||
| Next.js | Server actions, API routes, route handlers |
|
||||
| SvelteKit | `+page.server.ts`, `+server.ts` |
|
||||
| Nuxt | `server/api/`, `server/routes/` |
|
||||
| Remix | `loader`, `action` functions |
|
||||
| Astro | `.astro` frontmatter, API routes |
|
||||
|
||||
## Example
|
||||
|
||||
See `examples/demo/` in the agent-browser repo for a working app with the Vercel Sandbox pattern, including a snapshot creation script and demo UI.
|
||||
Reference in New Issue
Block a user