fix environments demo (#696)
* fix * fixes * fixes * update docs * fixes * fixes * sandbox tokens * better logging
This commit is contained in:
@@ -715,9 +715,7 @@ AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
|
||||
|
||||
### 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:
|
||||
Run agent-browser + Chrome in an ephemeral Vercel Sandbox microVM. No external server needed:
|
||||
|
||||
```typescript
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
@@ -728,23 +726,7 @@ 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.
|
||||
See the [environments example](examples/environments/) for a working demo with a UI and deploy-to-Vercel button.
|
||||
|
||||
### Serverless (AWS Lambda)
|
||||
|
||||
|
||||
+78
-135
@@ -4,102 +4,27 @@ export const metadata = pageMetadata("next")
|
||||
|
||||
# Next.js + Vercel
|
||||
|
||||
Two patterns for running agent-browser from Next.js on Vercel.
|
||||
Run agent-browser from a Next.js app on Vercel using Vercel Sandbox.
|
||||
A Linux microVM spins up on demand, runs agent-browser + Chrome, and
|
||||
shuts down. No binary size limits, no Chromium bundling complexity.
|
||||
|
||||
## Pattern 1: Serverless Function
|
||||
|
||||
Run `@sparticuz/chromium` + `puppeteer-core` directly inside a Vercel
|
||||
serverless function. No external server needed.
|
||||
|
||||
### Setup
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm add @sparticuz/chromium puppeteer-core
|
||||
pnpm add @vercel/sandbox
|
||||
```
|
||||
|
||||
### 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
|
||||
## 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 snapshotId = process.env.AGENT_BROWSER_SNAPSHOT_ID;
|
||||
|
||||
async function withBrowser<T>(
|
||||
fn: (sandbox: InstanceType<typeof Sandbox>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const sandbox = snapshotId
|
||||
? await Sandbox.create({
|
||||
source: { type: "snapshot", snapshotId },
|
||||
@@ -107,60 +32,95 @@ export async function screenshotUrl(url: string) {
|
||||
})
|
||||
: 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"]);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
export async function screenshotUrl(url: string) {
|
||||
return withBrowser(async (sandbox) => {
|
||||
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);
|
||||
const data = JSON.parse(await result.stdout());
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["close"]);
|
||||
return { ok: true, screenshot: data.data.base64 };
|
||||
} finally {
|
||||
await sandbox.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function snapshotUrl(url: string) {
|
||||
return withBrowser(async (sandbox) => {
|
||||
await sandbox.runCommand("agent-browser", ["open", url]);
|
||||
|
||||
const result = await sandbox.runCommand("agent-browser", [
|
||||
"snapshot", "-i", "-c",
|
||||
]);
|
||||
const snapshot = await result.stdout();
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["close"]);
|
||||
return { ok: true, snapshot };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Optimize with snapshots
|
||||
## Sandbox snapshots
|
||||
|
||||
Installing agent-browser + Chromium takes ~30 seconds. Create a
|
||||
snapshot to make sandbox creation sub-second:
|
||||
Without optimization, each Sandbox run installs agent-browser + Chromium
|
||||
from scratch (~30 seconds). A **sandbox snapshot** is a saved VM image
|
||||
with everything pre-installed -- like a Docker image for Vercel Sandbox.
|
||||
When `AGENT_BROWSER_SNAPSHOT_ID` is set, the sandbox boots from that
|
||||
image instead of installing, bringing startup down to sub-second.
|
||||
|
||||
This is different from an agent-browser *accessibility snapshot* (which
|
||||
dumps a page's accessibility tree). A sandbox snapshot is a Vercel
|
||||
infrastructure concept.
|
||||
|
||||
Create a sandbox snapshot by running the helper script once:
|
||||
|
||||
```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`.
|
||||
The script spins up a fresh sandbox, installs agent-browser + Chromium,
|
||||
saves the VM state, and prints the snapshot ID:
|
||||
|
||||
```
|
||||
AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
Add this to your Vercel project environment variables (or `.env.local`
|
||||
for local development). Recommended for any production deployment.
|
||||
|
||||
## Scheduled workflows (cron)
|
||||
|
||||
For recurring tasks like daily monitoring, use Vercel Cron Jobs with
|
||||
either pattern:
|
||||
For recurring tasks like daily monitoring, use Vercel Cron Jobs:
|
||||
|
||||
```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();
|
||||
}
|
||||
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 });
|
||||
}
|
||||
```
|
||||
|
||||
@@ -173,35 +133,18 @@ export async function GET() {
|
||||
}
|
||||
```
|
||||
|
||||
## 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>
|
||||
<tr><th>Variable</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>
|
||||
<tr><td><code>AGENT_BROWSER_SNAPSHOT_ID</code></td><td>Sandbox snapshot ID for sub-second startup (see above)</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).
|
||||
A working demo with a UI and deploy-to-Vercel button is at
|
||||
[`examples/environments/`](https://github.com/agent-browser/agent-browser/tree/main/examples/environments).
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,140 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import * as serverless from "@/lib/agent-browser";
|
||||
import * as sandbox from "@/lib/agent-browser-sandbox";
|
||||
import { ALLOWED_URLS } from "@/lib/constants";
|
||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
async function checkRateLimit(): Promise<string | null> {
|
||||
const h = await headers();
|
||||
const ip = h.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||
|
||||
const minute = await minuteRateLimit.limit(ip);
|
||||
if (!minute.success) {
|
||||
return "Too many requests. Please wait a moment before trying again.";
|
||||
}
|
||||
|
||||
const daily = await dailyRateLimit.limit(ip);
|
||||
if (!daily.success) {
|
||||
return "Daily limit reached. Please try again tomorrow.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAllowedUrl(url: string): boolean {
|
||||
return (ALLOWED_URLS as readonly string[]).includes(url);
|
||||
}
|
||||
|
||||
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> {
|
||||
if (!isAllowedUrl(url)) {
|
||||
return { ok: false, error: "URL not allowed" };
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit();
|
||||
if (rateLimitError) {
|
||||
return { ok: false, error: rateLimitError };
|
||||
}
|
||||
|
||||
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> {
|
||||
if (!isAllowedUrl(url)) {
|
||||
return { ok: false, error: "URL not allowed" };
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit();
|
||||
if (rateLimitError) {
|
||||
return { ok: false, error: rateLimitError };
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as ab from "@/lib/agent-browser";
|
||||
import { ALLOWED_URLS } from "@/lib/constants";
|
||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip =
|
||||
req.headers.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||
|
||||
const minute = await minuteRateLimit.limit(ip);
|
||||
if (!minute.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Please wait a moment before trying again." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const daily = await dailyRateLimit.limit(ip);
|
||||
if (!daily.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Daily limit reached. Please try again tomorrow." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const url = body.url;
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "Provide a 'url'" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(ALLOWED_URLS as readonly string[]).includes(url)) {
|
||||
return NextResponse.json({ error: "URL not allowed" }, { 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 });
|
||||
}
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
"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 { ALLOWED_URLS } from "@/lib/constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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<string>(ALLOWED_URLS[0]);
|
||||
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-select"
|
||||
className="text-[11px] text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
URL
|
||||
</Label>
|
||||
<select
|
||||
id="url-select"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
clearResults();
|
||||
}}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{ALLOWED_URLS.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u.replace("https://", "")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
# --- 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 ---
|
||||
# --- Vercel Sandbox ---
|
||||
# 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
|
||||
|
||||
# --- Sandbox Authentication ---
|
||||
# On Vercel deployments, OIDC auth is automatic. For local development,
|
||||
# provide explicit credentials:
|
||||
# VERCEL_TOKEN=
|
||||
# VERCEL_TEAM_ID=
|
||||
# VERCEL_PROJECT_ID=
|
||||
|
||||
# --- Rate Limiting (Upstash / Vercel KV) ---
|
||||
# Automatically populated when you add Vercel KV to your project
|
||||
KV_REST_API_URL=
|
||||
@@ -0,0 +1,54 @@
|
||||
# agent-browser Environments
|
||||
|
||||
A demo of agent-browser running in a Vercel Sandbox. Enter a URL and take a screenshot or accessibility snapshot.
|
||||
|
||||
## How It Works
|
||||
|
||||
The app runs agent-browser + Chrome inside an ephemeral Vercel Sandbox microVM. A Linux VM spins up on demand, executes agent-browser commands, and shuts down. No binary size limits, no Chromium bundling complexity.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
cd examples/environments
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Sandbox Snapshots
|
||||
|
||||
Without optimization, each Sandbox run installs agent-browser + Chromium from scratch (~30s). A **sandbox snapshot** is a saved VM image with everything pre-installed -- the sandbox boots from the image instead of installing, bringing startup down to sub-second. (This is unrelated to agent-browser's *accessibility snapshot* feature, which dumps a page's accessibility tree.)
|
||||
|
||||
Create a sandbox snapshot by running the helper script once:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-snapshot.ts
|
||||
# Output: AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
Add the ID to your Vercel project environment variables or `.env.local`. Recommended for production.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `AGENT_BROWSER_SNAPSHOT_ID` | Sandbox snapshot ID for sub-second startup (see above) |
|
||||
| `KV_REST_API_URL` | Upstash Redis URL for rate limiting (optional) |
|
||||
| `KV_REST_API_TOKEN` | Upstash Redis token for rate limiting (optional) |
|
||||
| `RATE_LIMIT_PER_MINUTE` | Max requests per minute per IP (default: 10) |
|
||||
| `RATE_LIMIT_PER_DAY` | Max requests per day per IP (default: 100) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
examples/environments/
|
||||
app/
|
||||
page.tsx # Demo UI
|
||||
actions/browse.ts # Server actions
|
||||
api/browse/route.ts # API route for programmatic access
|
||||
lib/
|
||||
agent-browser-sandbox.ts # Vercel Sandbox client
|
||||
constants.ts # Allowed URLs
|
||||
rate-limit.ts # Upstash rate limiting
|
||||
scripts/
|
||||
create-snapshot.ts # Create sandbox snapshot
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
"use server";
|
||||
|
||||
export type EnvStatus = {
|
||||
sandbox: {
|
||||
hasSnapshot: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getEnvStatus(): Promise<EnvStatus> {
|
||||
return {
|
||||
sandbox: {
|
||||
hasSnapshot: !!process.env.AGENT_BROWSER_SNAPSHOT_ID,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as sandbox from "@/lib/agent-browser-sandbox";
|
||||
import type { StepEvent } from "@/lib/agent-browser-sandbox";
|
||||
import { ALLOWED_URLS } from "@/lib/constants";
|
||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const ip =
|
||||
req.headers.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||
|
||||
const minute = await minuteRateLimit.limit(ip);
|
||||
if (!minute.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Please wait a moment before trying again." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const daily = await dailyRateLimit.limit(ip);
|
||||
if (!daily.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Daily limit reached. Please try again tomorrow." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { url, action } = body;
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "Provide a 'url'" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(ALLOWED_URLS as readonly string[]).includes(url)) {
|
||||
return NextResponse.json({ error: "URL not allowed" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (action !== "screenshot" && action !== "snapshot") {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide 'action' as 'screenshot' or 'snapshot'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (event: string, data: unknown) => {
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
};
|
||||
|
||||
const onStep = (step: StepEvent) => {
|
||||
send("step", step);
|
||||
};
|
||||
|
||||
try {
|
||||
if (action === "screenshot") {
|
||||
const result = await sandbox.screenshotUrl(url, {
|
||||
fullPage: body.fullPage,
|
||||
onStep,
|
||||
});
|
||||
send("result", { ok: true, ...result });
|
||||
} else {
|
||||
const result = await sandbox.snapshotUrl(url, {
|
||||
interactive: true,
|
||||
compact: true,
|
||||
onStep,
|
||||
});
|
||||
send("result", { ok: true, ...result });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
send("result", { ok: false, error: message });
|
||||
}
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -4,8 +4,8 @@ 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",
|
||||
title: "agent-browser Environments",
|
||||
description: "Run agent-browser in different compute environments",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -0,0 +1,518 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { getEnvStatus } from "./actions/browse";
|
||||
import type { EnvStatus } from "./actions/browse";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { ALLOWED_URLS } from "@/lib/constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Loader2, Monitor, CircleX, Sun, Moon, Check } 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 getMobileSnapshot = () => window.matchMedia(MOBILE_QUERY).matches;
|
||||
const getServerSnapshot = () => false;
|
||||
|
||||
function useIsMobile() {
|
||||
return useSyncExternalStore(subscribe, getMobileSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const [theme, setThemeState] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("theme");
|
||||
const initial =
|
||||
stored === "dark" ||
|
||||
(!stored && window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
? "dark"
|
||||
: "light";
|
||||
setThemeState(initial);
|
||||
document.documentElement.classList.toggle("dark", initial === "dark");
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const next = theme === "dark" ? "light" : "dark";
|
||||
setThemeState(next);
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
localStorage.setItem("theme", next);
|
||||
};
|
||||
|
||||
return { theme, toggle };
|
||||
}
|
||||
|
||||
type Action = "screenshot" | "snapshot";
|
||||
|
||||
type StepInfo = {
|
||||
step: string;
|
||||
status: "running" | "done" | "error";
|
||||
elapsed?: number;
|
||||
};
|
||||
|
||||
type BrowseResult = {
|
||||
ok: boolean;
|
||||
screenshot?: string;
|
||||
snapshot?: string;
|
||||
title?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
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 StepIndicator({ step }: { step: StepInfo }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 py-1">
|
||||
<div className="size-4 flex items-center justify-center shrink-0">
|
||||
{step.status === "running" ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : step.status === "done" ? (
|
||||
<Check className="size-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<CircleX className="size-3.5 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-[13px] ${
|
||||
step.status === "running"
|
||||
? "text-foreground"
|
||||
: step.status === "done"
|
||||
? "text-muted-foreground"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{step.step}
|
||||
</span>
|
||||
{step.elapsed != null && step.status !== "running" && (
|
||||
<span className="text-[11px] text-muted-foreground/60 tabular-nums ml-auto">
|
||||
{(step.elapsed / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
async function streamBrowse(
|
||||
url: string,
|
||||
action: Action,
|
||||
onStep: (step: StepInfo) => void,
|
||||
): Promise<BrowseResult> {
|
||||
const res = await fetch("/api/browse", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, action }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
return { ok: false, error: body?.error || `HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
return { ok: false, error: "No response stream" };
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let result: BrowseResult = { ok: false, error: "No result received" };
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const parts = buffer.split("\n\n");
|
||||
buffer = parts.pop() || "";
|
||||
|
||||
for (const part of parts) {
|
||||
const eventMatch = part.match(/^event: (\w+)\ndata: ([\s\S]+)$/);
|
||||
if (!eventMatch) continue;
|
||||
|
||||
const [, event, data] = eventMatch;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (event === "step") {
|
||||
onStep(parsed as StepInfo);
|
||||
} else if (event === "result") {
|
||||
result = parsed as BrowseResult;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const isMobile = useIsMobile();
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
const [url, setUrl] = useState<string>(ALLOWED_URLS[0]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [action, setAction] = useState<Action>("screenshot");
|
||||
const [result, setResult] = useState<BrowseResult | null>(null);
|
||||
const [steps, setSteps] = useState<StepInfo[]>([]);
|
||||
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null);
|
||||
const stepsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getEnvStatus().then(setEnvStatus);
|
||||
}, []);
|
||||
|
||||
function clearResults() {
|
||||
setResult(null);
|
||||
setSteps([]);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setSteps([]);
|
||||
|
||||
try {
|
||||
const browseResult = await streamBrowse(url, action, (step) => {
|
||||
setSteps((prev) => {
|
||||
const existing = prev.findIndex((s) => s.step === step.step);
|
||||
if (existing >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[existing] = step;
|
||||
return updated;
|
||||
}
|
||||
return [...prev, step];
|
||||
});
|
||||
});
|
||||
setResult(browseResult);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setResult({ ok: false, error: message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
stepsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [steps]);
|
||||
|
||||
const controlsForm = (
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="url-select"
|
||||
className="text-[11px] text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
URL
|
||||
</Label>
|
||||
<Select
|
||||
value={url}
|
||||
onValueChange={(v) => {
|
||||
if (v) setUrl(v);
|
||||
clearResults();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="url-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ALLOWED_URLS.map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{u.replace("https://", "")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
|
||||
{envStatus && !envStatus.sandbox.hasSnapshot && (
|
||||
<Alert>
|
||||
<AlertTitle className="text-[12px]">
|
||||
Sandbox snapshot not configured
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-[11px]">
|
||||
Without a sandbox snapshot, the VM installs agent-browser +
|
||||
Chromium on every request (~30s). Create one with{" "}
|
||||
<code className="text-[10px] bg-muted px-1 py-0.5 rounded">
|
||||
npx tsx scripts/create-snapshot.ts
|
||||
</code>{" "}
|
||||
and set{" "}
|
||||
<code className="text-[10px] bg-muted px-1 py-0.5 rounded">
|
||||
AGENT_BROWSER_SNAPSHOT_ID
|
||||
</code>{" "}
|
||||
for sub-second startup.
|
||||
</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 showSteps = loading || (steps.length > 0 && !result);
|
||||
const hasResult = result && !loading;
|
||||
|
||||
const resultContent = showSteps ? (
|
||||
<div className="p-6 lg:p-10">
|
||||
<div className="max-w-xl mx-auto">
|
||||
<div className="space-y-0.5">
|
||||
{steps.map((s, i) => (
|
||||
<StepIndicator key={`${s.step}-${i}`} step={s} />
|
||||
))}
|
||||
<div ref={stepsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : hasResult ? (
|
||||
<div className="flex flex-col items-center p-6 lg:p-10">
|
||||
{result.ok && result.screenshot && (
|
||||
<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">
|
||||
{result.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,${result.screenshot}`}
|
||||
alt={result.title}
|
||||
className="w-full block"
|
||||
/>
|
||||
</div>
|
||||
<details className="mt-4">
|
||||
<summary className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
|
||||
Show steps ({steps.length})
|
||||
</summary>
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{steps.map((s, i) => (
|
||||
<StepIndicator key={`${s.step}-${i}`} step={s} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.ok && result.snapshot && (
|
||||
<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">
|
||||
{result.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)]">
|
||||
{result.snapshot}
|
||||
</pre>
|
||||
<details className="mt-4">
|
||||
<summary className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
|
||||
Show steps ({steps.length})
|
||||
</summary>
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{steps.map((s, i) => (
|
||||
<StepIndicator key={`${s.step}-${i}`} step={s} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result.ok && (
|
||||
<div className="w-full max-w-2xl space-y-4">
|
||||
<ErrorDisplay error={result.error ?? "Unknown error"} />
|
||||
{steps.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{steps.map((s, i) => (
|
||||
<StepIndicator key={`${s.step}-${i}`} step={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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]">Pick 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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="size-8 inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="size-4" />
|
||||
) : (
|
||||
<Moon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fenvironments&env=AGENT_BROWSER_SNAPSHOT_ID&envDescription=Sandbox%20snapshot%20ID%20for%20fast%20startup.%20Create%20with%20npx%20tsx%20scripts%2Fcreate-snapshot.ts&envLink=https%3A%2F%2Fgithub.com%2Fagent-browser%2Fagent-browser%2Ftree%2Fmain%2Fexamples%2Fenvironments%23sandbox-snapshots&project-name=agent-browser-environments&repository-name=agent-browser-environments"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="https://vercel.com/button"
|
||||
alt="Deploy with Vercel"
|
||||
className="h-8"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</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,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
export type StepEvent = {
|
||||
step: string;
|
||||
status: "running" | "done" | "error";
|
||||
elapsed?: number;
|
||||
};
|
||||
|
||||
export type OnStep = (event: StepEvent) => void;
|
||||
|
||||
const SNAPSHOT_ID = process.env.AGENT_BROWSER_SNAPSHOT_ID;
|
||||
|
||||
const CHROMIUM_SYSTEM_DEPS = [
|
||||
"nss",
|
||||
"nspr",
|
||||
"libxkbcommon",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"at-spi2-core",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"libXfixes",
|
||||
"libXcursor",
|
||||
"libXi",
|
||||
"libXtst",
|
||||
"libXScrnSaver",
|
||||
"libXext",
|
||||
"mesa-libgbm",
|
||||
"libdrm",
|
||||
"mesa-libGL",
|
||||
"mesa-libEGL",
|
||||
"cups-libs",
|
||||
"alsa-lib",
|
||||
"pango",
|
||||
"cairo",
|
||||
"gtk3",
|
||||
"dbus-libs",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns credentials to spread into Sandbox.create() calls.
|
||||
* When explicit env vars are set they take precedence; otherwise returns
|
||||
* an empty object so the SDK falls back to VERCEL_OIDC_TOKEN automatically.
|
||||
*/
|
||||
export function getSandboxCredentials():
|
||||
| { token: string; teamId: string; projectId: string }
|
||||
| Record<string, never> {
|
||||
if (
|
||||
process.env.VERCEL_TOKEN &&
|
||||
process.env.VERCEL_TEAM_ID &&
|
||||
process.env.VERCEL_PROJECT_ID
|
||||
) {
|
||||
return {
|
||||
token: process.env.VERCEL_TOKEN,
|
||||
teamId: process.env.VERCEL_TEAM_ID,
|
||||
projectId: process.env.VERCEL_PROJECT_ID,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function runStep<T>(
|
||||
step: string,
|
||||
fn: () => Promise<T>,
|
||||
onStep?: OnStep,
|
||||
): Promise<T> {
|
||||
const start = Date.now();
|
||||
onStep?.({ step, status: "running" });
|
||||
try {
|
||||
const result = await fn();
|
||||
onStep?.({ step, status: "done", elapsed: Date.now() - start });
|
||||
return result;
|
||||
} catch (err) {
|
||||
onStep?.({ step, status: "error", elapsed: Date.now() - start });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install system dependencies + agent-browser + Chromium into a fresh sandbox.
|
||||
* The sandbox base image is Amazon Linux (dnf).
|
||||
*/
|
||||
async function bootstrapSandbox(
|
||||
sandbox: InstanceType<typeof Sandbox>,
|
||||
onStep?: OnStep,
|
||||
): Promise<void> {
|
||||
await runStep("Installing system dependencies", async () => {
|
||||
await sandbox.runCommand("sh", [
|
||||
"-c",
|
||||
`sudo dnf clean all 2>&1 && sudo dnf install -y --skip-broken ${CHROMIUM_SYSTEM_DEPS.join(" ")} 2>&1 && sudo ldconfig 2>&1`,
|
||||
]);
|
||||
}, onStep);
|
||||
|
||||
await runStep("Installing agent-browser", async () => {
|
||||
await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]);
|
||||
await sandbox.runCommand("npx", ["agent-browser", "install"]);
|
||||
}, onStep);
|
||||
}
|
||||
|
||||
async function createSandbox(
|
||||
onStep?: OnStep,
|
||||
): Promise<InstanceType<typeof Sandbox>> {
|
||||
const credentials = getSandboxCredentials();
|
||||
|
||||
return runStep(
|
||||
SNAPSHOT_ID ? "Booting sandbox from snapshot" : "Creating sandbox",
|
||||
async () => {
|
||||
if (SNAPSHOT_ID) {
|
||||
return Sandbox.create({
|
||||
...credentials,
|
||||
source: { type: "snapshot", snapshotId: SNAPSHOT_ID },
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
const sb = await Sandbox.create({
|
||||
...credentials,
|
||||
runtime: "node24",
|
||||
timeout: 120_000,
|
||||
});
|
||||
await bootstrapSandbox(sb, onStep);
|
||||
return sb;
|
||||
},
|
||||
onStep,
|
||||
);
|
||||
}
|
||||
|
||||
async function exec(
|
||||
sandbox: InstanceType<typeof Sandbox>,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
onStep?: OnStep,
|
||||
stepLabel?: string,
|
||||
): Promise<SandboxResult> {
|
||||
const label = stepLabel || `${cmd} ${args.join(" ")}`;
|
||||
|
||||
return runStep(label, async () => {
|
||||
const result = await sandbox.runCommand(cmd, args);
|
||||
const stdout = await result.stdout();
|
||||
const stderr = await result.stderr();
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Command "${cmd} ${args.join(" ")}" failed (exit ${result.exitCode}): ${stderr || stdout}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { exitCode: result.exitCode, stdout, stderr };
|
||||
}, onStep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Screenshot a URL using agent-browser inside a Vercel Sandbox.
|
||||
* Returns base64-encoded PNG.
|
||||
*/
|
||||
export async function screenshotUrl(
|
||||
url: string,
|
||||
opts: { fullPage?: boolean; onStep?: OnStep } = {},
|
||||
): Promise<{ screenshot: string; title: string }> {
|
||||
const { onStep } = opts;
|
||||
const sandbox = await createSandbox(onStep);
|
||||
|
||||
try {
|
||||
await exec(sandbox, "agent-browser", ["open", "about:blank"], onStep, "Starting browser");
|
||||
await exec(sandbox, "agent-browser", ["open", url], onStep, `Navigating to ${url}`);
|
||||
|
||||
const titleResult = await exec(
|
||||
sandbox,
|
||||
"agent-browser",
|
||||
["get", "title", "--json"],
|
||||
onStep,
|
||||
"Getting page title",
|
||||
);
|
||||
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,
|
||||
onStep,
|
||||
"Taking screenshot",
|
||||
);
|
||||
const ssData = tryParseJson(ssResult.stdout)?.data;
|
||||
const screenshotPath = ssData?.path;
|
||||
|
||||
if (!screenshotPath) {
|
||||
throw new Error(
|
||||
`Screenshot returned no file path. Raw output: ${ssResult.stdout.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const b64Result = await exec(
|
||||
sandbox,
|
||||
"base64",
|
||||
["-w", "0", screenshotPath],
|
||||
onStep,
|
||||
"Encoding screenshot",
|
||||
);
|
||||
const screenshot = b64Result.stdout.trim();
|
||||
|
||||
if (!screenshot) {
|
||||
throw new Error("Failed to read screenshot file from sandbox");
|
||||
}
|
||||
|
||||
await exec(sandbox, "agent-browser", ["close"], onStep, "Closing browser");
|
||||
|
||||
return { screenshot, title };
|
||||
} finally {
|
||||
await runStep("Stopping sandbox", () => sandbox.stop(), onStep);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot a URL (accessibility tree) using agent-browser inside a Vercel Sandbox.
|
||||
*/
|
||||
export async function snapshotUrl(
|
||||
url: string,
|
||||
opts: { interactive?: boolean; compact?: boolean; onStep?: OnStep } = {},
|
||||
): Promise<{ snapshot: string; title: string }> {
|
||||
const { onStep } = opts;
|
||||
const sandbox = await createSandbox(onStep);
|
||||
|
||||
try {
|
||||
await exec(sandbox, "agent-browser", ["open", "about:blank"], onStep, "Starting browser");
|
||||
await exec(sandbox, "agent-browser", ["open", url], onStep, `Navigating to ${url}`);
|
||||
|
||||
const titleResult = await exec(
|
||||
sandbox,
|
||||
"agent-browser",
|
||||
["get", "title", "--json"],
|
||||
onStep,
|
||||
"Getting page title",
|
||||
);
|
||||
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,
|
||||
onStep,
|
||||
"Taking accessibility snapshot",
|
||||
);
|
||||
|
||||
if (!snapResult.stdout.trim()) {
|
||||
throw new Error("Snapshot returned empty data");
|
||||
}
|
||||
|
||||
await exec(sandbox, "agent-browser", ["close"], onStep, "Closing browser");
|
||||
|
||||
return { snapshot: snapResult.stdout, title };
|
||||
} finally {
|
||||
await runStep("Stopping sandbox", () => sandbox.stop(), onStep);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
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({
|
||||
...getSandboxCredentials(),
|
||||
runtime: "node24",
|
||||
timeout: 300_000,
|
||||
});
|
||||
|
||||
await bootstrapSandbox(sandbox);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "agent-browser-demo",
|
||||
"name": "agent-browser-environments",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -10,18 +10,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@sparticuz/chromium": "^143.0.4",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.36.4",
|
||||
"@vercel/sandbox": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.3.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",
|
||||
+5
-457
@@ -11,9 +11,6 @@ importers:
|
||||
'@base-ui/react':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@sparticuz/chromium':
|
||||
specifier: ^143.0.4
|
||||
version: 143.0.4
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
@@ -32,6 +29,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.3.1
|
||||
geist:
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
@@ -44,9 +44,6 @@ importers:
|
||||
postcss:
|
||||
specifier: ^8.5.8
|
||||
version: 8.5.8
|
||||
puppeteer-core:
|
||||
specifier: ^24.38.0
|
||||
version: 24.38.0
|
||||
react:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.4
|
||||
@@ -566,11 +563,6 @@ packages:
|
||||
'@open-draft/until@2.1.0':
|
||||
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
||||
|
||||
'@puppeteer/browsers@2.13.0':
|
||||
resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
@@ -578,10 +570,6 @@ packages:
|
||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sparticuz/chromium@143.0.4':
|
||||
resolution: {integrity: sha512-/6I7uQTRhRDD2/gGPQ1Gkf+Dqk0RYDACPJDZfSzz0OWk4JmUTonNHPXbrn6UIklOHlnDLf8xAAzkOZKB/cJpLA==}
|
||||
engines: {node: '>=20.11.0'}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
@@ -673,9 +661,6 @@ packages:
|
||||
'@tailwindcss/postcss@4.2.1':
|
||||
resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==}
|
||||
|
||||
'@tootallnate/quickjs-emscripten@0.23.0':
|
||||
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
|
||||
|
||||
@@ -696,9 +681,6 @@ packages:
|
||||
'@types/validate-npm-package-name@4.0.2':
|
||||
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
'@upstash/core-analytics@0.0.10':
|
||||
resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -756,10 +738,6 @@ packages:
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
ast-types@0.13.4:
|
||||
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
ast-types@0.16.1:
|
||||
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -787,45 +765,11 @@ packages:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
|
||||
bare-fs@4.5.5:
|
||||
resolution: {integrity: sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==}
|
||||
engines: {bare: '>=1.16.0'}
|
||||
peerDependencies:
|
||||
bare-buffer: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-buffer:
|
||||
optional: true
|
||||
|
||||
bare-os@3.7.1:
|
||||
resolution: {integrity: sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==}
|
||||
engines: {bare: '>=1.14.0'}
|
||||
|
||||
bare-path@3.0.0:
|
||||
resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==}
|
||||
|
||||
bare-stream@2.8.0:
|
||||
resolution: {integrity: sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==}
|
||||
peerDependencies:
|
||||
bare-buffer: '*'
|
||||
bare-events: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-buffer:
|
||||
optional: true
|
||||
bare-events:
|
||||
optional: true
|
||||
|
||||
bare-url@2.3.2:
|
||||
resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==}
|
||||
|
||||
baseline-browser-mapping@2.10.0:
|
||||
resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
basic-ftp@5.2.0:
|
||||
resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
body-parser@2.2.2:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -843,9 +787,6 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -873,11 +814,6 @@ packages:
|
||||
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
|
||||
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
||||
|
||||
chromium-bidi@14.0.0:
|
||||
resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==}
|
||||
peerDependencies:
|
||||
devtools-protocol: '*'
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
@@ -974,10 +910,6 @@ packages:
|
||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
data-uri-to-buffer@6.0.2:
|
||||
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -1011,10 +943,6 @@ packages:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
degenerator@5.0.1:
|
||||
resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1023,9 +951,6 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
devtools-protocol@0.0.1581282:
|
||||
resolution: {integrity: sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==}
|
||||
|
||||
diff@8.0.3:
|
||||
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
@@ -1058,9 +983,6 @@ packages:
|
||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
enhanced-resolve@5.20.0:
|
||||
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -1091,24 +1013,11 @@ packages:
|
||||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
escodegen@2.1.0:
|
||||
resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
|
||||
engines: {node: '>=6.0'}
|
||||
hasBin: true
|
||||
|
||||
esprima@4.0.1:
|
||||
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
estraverse@5.3.0:
|
||||
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
esutils@2.0.3:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -1142,11 +1051,6 @@ packages:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
extract-zip@2.0.1:
|
||||
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
|
||||
engines: {node: '>= 10.17.0'}
|
||||
hasBin: true
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -1163,9 +1067,6 @@ packages:
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1191,15 +1092,6 @@ packages:
|
||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
|
||||
follow-redirects@1.15.11:
|
||||
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -1254,10 +1146,6 @@ packages:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-stream@5.2.0:
|
||||
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
get-stream@6.0.1:
|
||||
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1266,10 +1154,6 @@ packages:
|
||||
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
get-uri@6.0.5:
|
||||
resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -1304,10 +1188,6 @@ packages:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -1552,10 +1432,6 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lru-cache@7.18.3:
|
||||
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lucide-react@0.577.0:
|
||||
resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==}
|
||||
peerDependencies:
|
||||
@@ -1610,9 +1486,6 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1639,10 +1512,6 @@ packages:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
netmask@2.0.2:
|
||||
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
next@16.1.6:
|
||||
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
@@ -1726,14 +1595,6 @@ packages:
|
||||
outvariant@1.4.3:
|
||||
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
|
||||
|
||||
pac-proxy-agent@7.2.0:
|
||||
resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
pac-resolver@7.0.1:
|
||||
resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
package-manager-detector@1.6.0:
|
||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||
|
||||
@@ -1770,9 +1631,6 @@ packages:
|
||||
path-to-regexp@8.3.0:
|
||||
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
|
||||
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -1808,10 +1666,6 @@ packages:
|
||||
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
progress@2.0.3:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
prompts@2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -1820,20 +1674,6 @@ packages:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
proxy-agent@6.5.0:
|
||||
resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
pump@3.0.4:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
puppeteer-core@24.38.0:
|
||||
resolution: {integrity: sha512-zB3S/tksIhgi2gZRndUe07AudBz5SXOB7hqG0kEa9/YXWrGwlVlYm3tZtwKgfRftBzbmLQl5iwHkQQl04n/mWw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
qs@6.15.0:
|
||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -1977,18 +1817,6 @@ packages:
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
smart-buffer@4.2.0:
|
||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
socks-proxy-agent@8.0.5:
|
||||
resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
socks@2.8.7:
|
||||
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
|
||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2073,15 +1901,9 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-fs@3.1.2:
|
||||
resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==}
|
||||
|
||||
tar-stream@3.1.7:
|
||||
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
|
||||
|
||||
teex@1.0.1:
|
||||
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
|
||||
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
|
||||
@@ -2132,9 +1954,6 @@ packages:
|
||||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typed-query-selector@2.12.1:
|
||||
resolution: {integrity: sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -2191,9 +2010,6 @@ packages:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
webdriver-bidi-protocol@0.4.1:
|
||||
resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2215,18 +2031,6 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.19.0:
|
||||
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2254,9 +2058,6 @@ packages:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yauzl@2.10.0:
|
||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||
|
||||
yoctocolors-cjs@2.1.3:
|
||||
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2768,35 +2569,10 @@ snapshots:
|
||||
|
||||
'@open-draft/until@2.1.0': {}
|
||||
|
||||
'@puppeteer/browsers@2.13.0':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
extract-zip: 2.0.1
|
||||
progress: 2.0.3
|
||||
proxy-agent: 6.5.0
|
||||
semver: 7.7.4
|
||||
tar-fs: 3.1.2
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
- supports-color
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@sparticuz/chromium@143.0.4':
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
tar-fs: 3.1.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- debug
|
||||
- react-native-b4a
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -2870,8 +2646,6 @@ snapshots:
|
||||
postcss: 8.5.8
|
||||
tailwindcss: 4.2.1
|
||||
|
||||
'@tootallnate/quickjs-emscripten@0.23.0': {}
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
dependencies:
|
||||
fast-glob: 3.3.3
|
||||
@@ -2894,11 +2668,6 @@ snapshots:
|
||||
|
||||
'@types/validate-npm-package-name@4.0.2': {}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
optional: true
|
||||
|
||||
'@upstash/core-analytics@0.0.10':
|
||||
dependencies:
|
||||
'@upstash/redis': 1.36.4
|
||||
@@ -2959,10 +2728,6 @@ snapshots:
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
ast-types@0.13.4:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
ast-types@0.16.1:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -2977,46 +2742,8 @@ snapshots:
|
||||
|
||||
bare-events@2.8.2: {}
|
||||
|
||||
bare-fs@4.5.5:
|
||||
dependencies:
|
||||
bare-events: 2.8.2
|
||||
bare-path: 3.0.0
|
||||
bare-stream: 2.8.0(bare-events@2.8.2)
|
||||
bare-url: 2.3.2
|
||||
fast-fifo: 1.3.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
optional: true
|
||||
|
||||
bare-os@3.7.1:
|
||||
optional: true
|
||||
|
||||
bare-path@3.0.0:
|
||||
dependencies:
|
||||
bare-os: 3.7.1
|
||||
optional: true
|
||||
|
||||
bare-stream@2.8.0(bare-events@2.8.2):
|
||||
dependencies:
|
||||
streamx: 2.23.0
|
||||
teex: 1.0.1
|
||||
optionalDependencies:
|
||||
bare-events: 2.8.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
optional: true
|
||||
|
||||
bare-url@2.3.2:
|
||||
dependencies:
|
||||
bare-path: 3.0.0
|
||||
optional: true
|
||||
|
||||
baseline-browser-mapping@2.10.0: {}
|
||||
|
||||
basic-ftp@5.2.0: {}
|
||||
|
||||
body-parser@2.2.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
@@ -3047,8 +2774,6 @@ snapshots:
|
||||
node-releases: 2.0.36
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
dependencies:
|
||||
run-applescript: 7.1.0
|
||||
@@ -3071,12 +2796,6 @@ snapshots:
|
||||
|
||||
chalk@5.6.2: {}
|
||||
|
||||
chromium-bidi@14.0.0(devtools-protocol@0.0.1581282):
|
||||
dependencies:
|
||||
devtools-protocol: 0.0.1581282
|
||||
mitt: 3.0.1
|
||||
zod: 3.24.4
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
@@ -3149,8 +2868,6 @@ snapshots:
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
||||
data-uri-to-buffer@6.0.2: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -3168,18 +2885,10 @@ snapshots:
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
degenerator@5.0.1:
|
||||
dependencies:
|
||||
ast-types: 0.13.4
|
||||
escodegen: 2.1.0
|
||||
esprima: 4.0.1
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
devtools-protocol@0.0.1581282: {}
|
||||
|
||||
diff@8.0.3: {}
|
||||
|
||||
dotenv@17.3.1: {}
|
||||
@@ -3207,10 +2916,6 @@ snapshots:
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
|
||||
enhanced-resolve@5.20.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -3234,20 +2939,8 @@ snapshots:
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
escodegen@2.1.0:
|
||||
dependencies:
|
||||
esprima: 4.0.1
|
||||
estraverse: 5.3.0
|
||||
esutils: 2.0.3
|
||||
optionalDependencies:
|
||||
source-map: 0.6.1
|
||||
|
||||
esprima@4.0.1: {}
|
||||
|
||||
estraverse@5.3.0: {}
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
events-universal@1.0.1:
|
||||
@@ -3327,16 +3020,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
extract-zip@2.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
get-stream: 5.2.0
|
||||
yauzl: 2.10.0
|
||||
optionalDependencies:
|
||||
'@types/yauzl': 2.10.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
@@ -3355,10 +3038,6 @@ snapshots:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
dependencies:
|
||||
pend: 1.2.0
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
@@ -3387,8 +3066,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
follow-redirects@1.15.11: {}
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
dependencies:
|
||||
fetch-blob: 3.2.0
|
||||
@@ -3439,10 +3116,6 @@ snapshots:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
get-stream@5.2.0:
|
||||
dependencies:
|
||||
pump: 3.0.4
|
||||
|
||||
get-stream@6.0.1: {}
|
||||
|
||||
get-stream@9.0.1:
|
||||
@@ -3450,14 +3123,6 @@ snapshots:
|
||||
'@sec-ant/readable-stream': 0.4.1
|
||||
is-stream: 4.0.1
|
||||
|
||||
get-uri@6.0.5:
|
||||
dependencies:
|
||||
basic-ftp: 5.2.0
|
||||
data-uri-to-buffer: 6.0.2
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -3486,13 +3151,6 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -3661,8 +3319,6 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lru-cache@7.18.3: {}
|
||||
|
||||
lucide-react@0.577.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -3702,8 +3358,6 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3):
|
||||
@@ -3737,8 +3391,6 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
netmask@2.0.2: {}
|
||||
|
||||
next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
@@ -3829,24 +3481,6 @@ snapshots:
|
||||
|
||||
outvariant@1.4.3: {}
|
||||
|
||||
pac-proxy-agent@7.2.0:
|
||||
dependencies:
|
||||
'@tootallnate/quickjs-emscripten': 0.23.0
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
get-uri: 6.0.5
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
pac-resolver: 7.0.1
|
||||
socks-proxy-agent: 8.0.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
pac-resolver@7.0.1:
|
||||
dependencies:
|
||||
degenerator: 5.0.1
|
||||
netmask: 2.0.2
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
@@ -3874,8 +3508,6 @@ snapshots:
|
||||
|
||||
path-to-regexp@8.3.0: {}
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
@@ -3907,8 +3539,6 @@ snapshots:
|
||||
dependencies:
|
||||
parse-ms: 4.0.0
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
dependencies:
|
||||
kleur: 3.0.3
|
||||
@@ -3919,43 +3549,6 @@ snapshots:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
proxy-agent@6.5.0:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
lru-cache: 7.18.3
|
||||
pac-proxy-agent: 7.2.0
|
||||
proxy-from-env: 1.1.0
|
||||
socks-proxy-agent: 8.0.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
pump@3.0.4:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
puppeteer-core@24.38.0:
|
||||
dependencies:
|
||||
'@puppeteer/browsers': 2.13.0
|
||||
chromium-bidi: 14.0.0(devtools-protocol@0.0.1581282)
|
||||
debug: 4.4.3
|
||||
devtools-protocol: 0.0.1581282
|
||||
typed-query-selector: 2.12.1
|
||||
webdriver-bidi-protocol: 0.4.1
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- bufferutil
|
||||
- react-native-b4a
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
qs@6.15.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
@@ -4032,7 +3625,8 @@ snapshots:
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.7.4: {}
|
||||
semver@7.7.4:
|
||||
optional: true
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
@@ -4177,21 +3771,6 @@ snapshots:
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
smart-buffer@4.2.0: {}
|
||||
|
||||
socks-proxy-agent@8.0.5:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
socks: 2.8.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
socks@2.8.7:
|
||||
dependencies:
|
||||
ip-address: 10.1.0
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map@0.6.1: {}
|
||||
@@ -4260,18 +3839,6 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tar-fs@3.1.2:
|
||||
dependencies:
|
||||
pump: 3.0.4
|
||||
tar-stream: 3.1.7
|
||||
optionalDependencies:
|
||||
bare-fs: 4.5.5
|
||||
bare-path: 3.0.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
tar-stream@3.1.7:
|
||||
dependencies:
|
||||
b4a: 1.8.0
|
||||
@@ -4281,14 +3848,6 @@ snapshots:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
teex@1.0.1:
|
||||
dependencies:
|
||||
streamx: 2.23.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
optional: true
|
||||
|
||||
text-decoder@1.2.7:
|
||||
dependencies:
|
||||
b4a: 1.8.0
|
||||
@@ -4340,8 +3899,6 @@ snapshots:
|
||||
media-typer: 1.1.0
|
||||
mime-types: 3.0.2
|
||||
|
||||
typed-query-selector@2.12.1: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
uncrypto@0.1.3: {}
|
||||
@@ -4376,8 +3933,6 @@ snapshots:
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
webdriver-bidi-protocol@0.4.1: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -4400,8 +3955,6 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.19.0: {}
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
dependencies:
|
||||
is-wsl: 3.1.1
|
||||
@@ -4431,11 +3984,6 @@ snapshots:
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yauzl@2.10.0:
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
fd-slicer: 1.1.0
|
||||
|
||||
yoctocolors-cjs@2.1.3: {}
|
||||
|
||||
yoctocolors@2.1.2: {}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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>
|
||||
*
|
||||
* Authentication (one of):
|
||||
* - VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID
|
||||
* - VERCEL_OIDC_TOKEN (automatically available on Vercel deployments)
|
||||
*
|
||||
* This makes sandbox creation sub-second instead of ~30s.
|
||||
*/
|
||||
|
||||
import "dotenv/config";
|
||||
import { createSnapshot, getSandboxCredentials } from "../lib/agent-browser-sandbox";
|
||||
|
||||
const hasExplicitCreds = !!(
|
||||
process.env.VERCEL_TOKEN &&
|
||||
process.env.VERCEL_TEAM_ID &&
|
||||
process.env.VERCEL_PROJECT_ID
|
||||
);
|
||||
const hasOidc = !!process.env.VERCEL_OIDC_TOKEN;
|
||||
|
||||
if (!hasExplicitCreds && !hasOidc) {
|
||||
console.error(
|
||||
"Missing sandbox credentials. Provide either:\n" +
|
||||
" 1. VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID\n" +
|
||||
" 2. VERCEL_OIDC_TOKEN",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const creds = getSandboxCredentials();
|
||||
console.log(
|
||||
creds.token
|
||||
? `Authenticating with explicit credentials (team: ${creds.teamId})`
|
||||
: "Authenticating via VERCEL_OIDC_TOKEN",
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -7,25 +7,13 @@ description: Run agent-browser + Chrome inside Vercel Sandbox microVMs for brows
|
||||
|
||||
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.
|
||||
The sandbox VM installs agent-browser and Chrome on first run. Use sandbox snapshots (below) to skip this step.
|
||||
|
||||
## Core Pattern
|
||||
|
||||
@@ -139,9 +127,15 @@ export async function fillAndSubmitForm(url: string, data: Record<string, string
|
||||
}
|
||||
```
|
||||
|
||||
## Snapshots (Fast Startup)
|
||||
## Sandbox Snapshots (Fast Startup)
|
||||
|
||||
Without a snapshot, the first sandbox run installs agent-browser + Chromium (~30s). Create a snapshot to make startup sub-second:
|
||||
A **sandbox snapshot** is a saved VM image of a Vercel Sandbox with agent-browser + Chromium already installed. Think of it like a Docker image -- instead of installing dependencies from scratch every time, the sandbox boots from the pre-built image.
|
||||
|
||||
This is unrelated to agent-browser's *accessibility snapshot* feature (`agent-browser snapshot`), which dumps a page's accessibility tree. A sandbox snapshot is a Vercel infrastructure concept for fast VM startup.
|
||||
|
||||
Without a sandbox snapshot, each run installs agent-browser + Chromium (~30s). With one, startup is sub-second.
|
||||
|
||||
### Creating a sandbox snapshot
|
||||
|
||||
```ts
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
@@ -169,9 +163,11 @@ AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
||||
A helper script is available in the demo app:
|
||||
|
||||
```bash
|
||||
npx tsx examples/demo/scripts/create-snapshot.ts
|
||||
npx tsx examples/environments/scripts/create-snapshot.ts
|
||||
```
|
||||
|
||||
Recommended for any production deployment using the Sandbox pattern.
|
||||
|
||||
## Scheduled Workflows (Cron)
|
||||
|
||||
Combine with Vercel Cron Jobs for recurring browser tasks:
|
||||
@@ -200,7 +196,7 @@ export async function GET() {
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `AGENT_BROWSER_SNAPSHOT_ID` | No (but recommended) | Pre-built snapshot ID for sub-second startup |
|
||||
| `AGENT_BROWSER_SNAPSHOT_ID` | No (but recommended) | Pre-built sandbox snapshot ID for sub-second startup (see above) |
|
||||
|
||||
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.
|
||||
|
||||
@@ -218,4 +214,4 @@ The pattern works identically across frameworks. The only difference is where yo
|
||||
|
||||
## 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.
|
||||
See `examples/environments/` in the agent-browser repo for a working app with the Vercel Sandbox pattern, including a sandbox snapshot creation script and demo UI.
|
||||
|
||||
Reference in New Issue
Block a user