From 5bf9fedd586e9560f6fd636b478e8162ec567f0f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 9 Mar 2026 17:00:30 -0500 Subject: [PATCH] fix environments demo (#696) * fix * fixes * fixes * update docs * fixes * fixes * sandbox tokens * better logging --- README.md | 22 +- docs/src/app/next/page.mdx | 213 +++---- examples/demo/README.md | 53 -- examples/demo/app/actions/browse.ts | 140 ----- examples/demo/app/api/browse/route.ts | 60 -- examples/demo/app/page.tsx | 472 ---------------- examples/demo/lib/agent-browser-sandbox.ts | 164 ------ examples/demo/lib/agent-browser.ts | 105 ---- examples/demo/scripts/create-snapshot.ts | 26 - examples/{demo => environments}/.env.example | 14 +- examples/{demo => environments}/.gitignore | 0 examples/environments/README.md | 54 ++ examples/environments/app/actions/browse.ts | 15 + examples/environments/app/api/browse/route.ts | 90 +++ examples/environments/app/favicon.ico | Bin 0 -> 25931 bytes .../{demo => environments}/app/globals.css | 0 .../{demo => environments}/app/layout.tsx | 4 +- examples/environments/app/page.tsx | 518 ++++++++++++++++++ .../{demo => environments}/components.json | 0 .../components/ui/alert.tsx | 0 .../components/ui/badge.tsx | 0 .../components/ui/button.tsx | 0 .../components/ui/input.tsx | 0 .../components/ui/label.tsx | 0 .../components/ui/resizable.tsx | 0 .../environments/components/ui/select.tsx | 201 +++++++ .../components/ui/separator.tsx | 0 .../components/ui/toggle-group.tsx | 0 .../components/ui/toggle.tsx | 0 .../environments/lib/agent-browser-sandbox.ts | 323 +++++++++++ .../{demo => environments}/lib/constants.ts | 0 .../{demo => environments}/lib/rate-limit.ts | 0 examples/{demo => environments}/lib/utils.ts | 0 examples/{demo => environments}/next-env.d.ts | 0 .../{demo => environments}/next.config.ts | 0 examples/{demo => environments}/package.json | 5 +- .../{demo => environments}/pnpm-lock.yaml | 462 +--------------- .../{demo => environments}/postcss.config.mjs | 0 .../environments/scripts/create-snapshot.ts | 54 ++ examples/{demo => environments}/tsconfig.json | 0 skills/next/SKILL.md | 189 ------- skills/vercel-sandbox/SKILL.md | 32 +- 42 files changed, 1366 insertions(+), 1850 deletions(-) delete mode 100644 examples/demo/README.md delete mode 100644 examples/demo/app/actions/browse.ts delete mode 100644 examples/demo/app/api/browse/route.ts delete mode 100644 examples/demo/app/page.tsx delete mode 100644 examples/demo/lib/agent-browser-sandbox.ts delete mode 100644 examples/demo/lib/agent-browser.ts delete mode 100644 examples/demo/scripts/create-snapshot.ts rename examples/{demo => environments}/.env.example (63%) rename examples/{demo => environments}/.gitignore (100%) create mode 100644 examples/environments/README.md create mode 100644 examples/environments/app/actions/browse.ts create mode 100644 examples/environments/app/api/browse/route.ts create mode 100644 examples/environments/app/favicon.ico rename examples/{demo => environments}/app/globals.css (100%) rename examples/{demo => environments}/app/layout.tsx (81%) create mode 100644 examples/environments/app/page.tsx rename examples/{demo => environments}/components.json (100%) rename examples/{demo => environments}/components/ui/alert.tsx (100%) rename examples/{demo => environments}/components/ui/badge.tsx (100%) rename examples/{demo => environments}/components/ui/button.tsx (100%) rename examples/{demo => environments}/components/ui/input.tsx (100%) rename examples/{demo => environments}/components/ui/label.tsx (100%) rename examples/{demo => environments}/components/ui/resizable.tsx (100%) create mode 100644 examples/environments/components/ui/select.tsx rename examples/{demo => environments}/components/ui/separator.tsx (100%) rename examples/{demo => environments}/components/ui/toggle-group.tsx (100%) rename examples/{demo => environments}/components/ui/toggle.tsx (100%) create mode 100644 examples/environments/lib/agent-browser-sandbox.ts rename examples/{demo => environments}/lib/constants.ts (100%) rename examples/{demo => environments}/lib/rate-limit.ts (100%) rename examples/{demo => environments}/lib/utils.ts (100%) rename examples/{demo => environments}/next-env.d.ts (100%) rename examples/{demo => environments}/next.config.ts (100%) rename examples/{demo => environments}/package.json (89%) rename examples/{demo => environments}/pnpm-lock.yaml (90%) rename examples/{demo => environments}/postcss.config.mjs (100%) create mode 100644 examples/environments/scripts/create-snapshot.ts rename examples/{demo => environments}/tsconfig.json (100%) delete mode 100644 skills/next/SKILL.md diff --git a/README.md b/README.md index 2ffd043..e8ae669 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/src/app/next/page.mdx b/docs/src/app/next/page.mdx index bf974ad..85d1a6f 100644 --- a/docs/src/app/next/page.mdx +++ b/docs/src/app/next/page.mdx @@ -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( + fn: (sandbox: InstanceType) => Promise, +): Promise { 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 - - - - - - - - - - - - - -
Serverless FunctionVercel Sandbox
InfrastructureNone (runs in-function)None (Vercel manages VMs)
Startup time~2-3 secondsSub-second with snapshot
Binary size limit50MB compressedNone
Multi-step workflowsSingle request onlyYes (persistent session)
Task durationUp to 300s (Pro)Up to ~2 minutes
Best forFast single-request screenshotsComplex workflows, full Chrome
- ## Environment variables - + - - +
VariablePatternDescription
VariableDescription
CHROMIUM_PATHServerlessPath to local Chrome/Chromium (auto-detected on Vercel)
AGENT_BROWSER_SNAPSHOT_IDSandboxPre-built snapshot for fast startup
AGENT_BROWSER_SNAPSHOT_IDSandbox snapshot ID for sub-second startup (see above)
## 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). diff --git a/examples/demo/README.md b/examples/demo/README.md deleted file mode 100644 index 8d3b37c..0000000 --- a/examples/demo/README.md +++ /dev/null @@ -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 -``` diff --git a/examples/demo/app/actions/browse.ts b/examples/demo/app/actions/browse.ts deleted file mode 100644 index 8837b5a..0000000 --- a/examples/demo/app/actions/browse.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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), - }; - } -} diff --git a/examples/demo/app/api/browse/route.ts b/examples/demo/app/api/browse/route.ts deleted file mode 100644 index c87b507..0000000 --- a/examples/demo/app/api/browse/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/examples/demo/app/page.tsx b/examples/demo/app/page.tsx deleted file mode 100644 index 39c2d7a..0000000 --- a/examples/demo/app/page.tsx +++ /dev/null @@ -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({ - value, - onChange, - options, -}: { - value: T; - onChange: (v: T) => void; - options: { value: T; label: string }[]; -}) { - return ( -
- {options.map((opt) => ( - - ))} -
- ); -} - -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 ( - - {icon} - {label} - {value && {value}} - - ); -} - -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 ( -
- - - Request failed - {message} - - {showRaw && ( -
- - Show raw response - -
-            {error}
-          
-
- )} -
- ); -} - -function ModeCard({ - selected, - onSelect, - title, - description, - badges, -}: { - selected: boolean; - onSelect: () => void; - title: string; - description: string; - badges?: React.ReactNode; -}) { - return ( - - ); -} - -export default function Home() { - const isMobile = useIsMobile(); - const [url, setUrl] = useState(ALLOWED_URLS[0]); - const [loading, setLoading] = useState(false); - const [action, setAction] = useState("screenshot"); - const [mode, setMode] = useState("serverless"); - const [screenshotResult, setScreenshotResult] = - useState(null); - const [snapshotResult, setSnapshotResult] = - useState(null); - const [envStatus, setEnvStatus] = useState(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 = ( -
-
- - -
- -
- - - value={action} - onChange={(v) => { - setAction(v); - clearResults(); - }} - options={[ - { value: "screenshot", label: "Screenshot" }, - { value: "snapshot", label: "Snapshot" }, - ]} - /> -

- {action === "screenshot" - ? "Captures a full-page PNG image" - : "Returns the accessibility tree"} -

-
- -
- -
- { - setMode("serverless"); - clearResults(); - }} - title="Serverless Function" - description="Runs @sparticuz/chromium + puppeteer-core directly in a Vercel function." - badges={ - envStatus && ( - - ) - } - /> - { - setMode("sandbox"); - clearResults(); - }} - title="Vercel Sandbox" - description="Ephemeral microVM with agent-browser + Chrome. No binary size limits." - badges={ - envStatus && ( - - ) - } - /> -
-
- - {envWarning && ( - - - Local development - {envWarning} - - )} - - -
- ); - - const resultContent = loading ? ( -
- -

Taking {action}...

-
- ) : hasResult ? ( -
- {screenshotResult && - (screenshotResult.ok ? ( -
-
-

- {screenshotResult.title} -

- - screenshot - -
-
- {screenshotResult.title} -
-
- ) : ( - - ))} - - {snapshotResult && - (snapshotResult.ok ? ( -
-
-

- {snapshotResult.title} -

- - snapshot - -
-
-              {snapshotResult.snapshot}
-            
-
- ) : ( - - ))} -
- ) : ( -
- -

No result yet

-

Enter a URL and click Run

-
- ); - - return ( -
-
-
-
- - agent-browser - - / - - Demo - -
- - Deploy with Vercel - -
-
- - {isMobile ? ( -
-
{controlsForm}
-
{resultContent}
-
- ) : ( - - - - - - - - -
- {resultContent} -
-
-
- )} -
- ); -} diff --git a/examples/demo/lib/agent-browser-sandbox.ts b/examples/demo/lib/agent-browser-sandbox.ts deleted file mode 100644 index 4c99e66..0000000 --- a/examples/demo/lib/agent-browser-sandbox.ts +++ /dev/null @@ -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> { - 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, - cmd: string, - args: string[], -): Promise { - 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 { - 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 { - 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; - } -} diff --git a/examples/demo/lib/agent-browser.ts b/examples/demo/lib/agent-browser.ts deleted file mode 100644 index 9a9c06d..0000000 --- a/examples/demo/lib/agent-browser.ts +++ /dev/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(); - } -} diff --git a/examples/demo/scripts/create-snapshot.ts b/examples/demo/scripts/create-snapshot.ts deleted file mode 100644 index b5241a4..0000000 --- a/examples/demo/scripts/create-snapshot.ts +++ /dev/null @@ -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= - * - * 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); -}); diff --git a/examples/demo/.env.example b/examples/environments/.env.example similarity index 63% rename from examples/demo/.env.example rename to examples/environments/.env.example index b413934..c03f649 100644 --- a/examples/demo/.env.example +++ b/examples/environments/.env.example @@ -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= diff --git a/examples/demo/.gitignore b/examples/environments/.gitignore similarity index 100% rename from examples/demo/.gitignore rename to examples/environments/.gitignore diff --git a/examples/environments/README.md b/examples/environments/README.md new file mode 100644 index 0000000..ea3ef54 --- /dev/null +++ b/examples/environments/README.md @@ -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 +``` diff --git a/examples/environments/app/actions/browse.ts b/examples/environments/app/actions/browse.ts new file mode 100644 index 0000000..261d976 --- /dev/null +++ b/examples/environments/app/actions/browse.ts @@ -0,0 +1,15 @@ +"use server"; + +export type EnvStatus = { + sandbox: { + hasSnapshot: boolean; + }; +}; + +export async function getEnvStatus(): Promise { + return { + sandbox: { + hasSnapshot: !!process.env.AGENT_BROWSER_SNAPSHOT_ID, + }, + }; +} diff --git a/examples/environments/app/api/browse/route.ts b/examples/environments/app/api/browse/route.ts new file mode 100644 index 0000000..5d7feac --- /dev/null +++ b/examples/environments/app/api/browse/route.ts @@ -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", + }, + }); +} diff --git a/examples/environments/app/favicon.ico b/examples/environments/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/examples/demo/app/globals.css b/examples/environments/app/globals.css similarity index 100% rename from examples/demo/app/globals.css rename to examples/environments/app/globals.css diff --git a/examples/demo/app/layout.tsx b/examples/environments/app/layout.tsx similarity index 81% rename from examples/demo/app/layout.tsx rename to examples/environments/app/layout.tsx index 9ae3292..d649c0d 100644 --- a/examples/demo/app/layout.tsx +++ b/examples/environments/app/layout.tsx @@ -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({ diff --git a/examples/environments/app/page.tsx b/examples/environments/app/page.tsx new file mode 100644 index 0000000..90e994f --- /dev/null +++ b/examples/environments/app/page.tsx @@ -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({ + value, + onChange, + options, +}: { + value: T; + onChange: (v: T) => void; + options: { value: T; label: string }[]; +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +function StepIndicator({ step }: { step: StepInfo }) { + return ( +
+
+ {step.status === "running" ? ( + + ) : step.status === "done" ? ( + + ) : ( + + )} +
+ + {step.step} + + {step.elapsed != null && step.status !== "running" && ( + + {(step.elapsed / 1000).toFixed(1)}s + + )} +
+ ); +} + +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 ( +
+ + + Request failed + {message} + + {showRaw && ( +
+ + Show raw response + +
+            {error}
+          
+
+ )} +
+ ); +} + +async function streamBrowse( + url: string, + action: Action, + onStep: (step: StepInfo) => void, +): Promise { + 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(ALLOWED_URLS[0]); + const [loading, setLoading] = useState(false); + const [action, setAction] = useState("screenshot"); + const [result, setResult] = useState(null); + const [steps, setSteps] = useState([]); + const [envStatus, setEnvStatus] = useState(null); + const stepsEndRef = useRef(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 = ( +
+
+ + +
+ +
+ + + value={action} + onChange={(v) => { + setAction(v); + clearResults(); + }} + options={[ + { value: "screenshot", label: "Screenshot" }, + { value: "snapshot", label: "Snapshot" }, + ]} + /> +

+ {action === "screenshot" + ? "Captures a full-page PNG image" + : "Returns the accessibility tree"} +

+
+ + {envStatus && !envStatus.sandbox.hasSnapshot && ( + + + Sandbox snapshot not configured + + + Without a sandbox snapshot, the VM installs agent-browser + + Chromium on every request (~30s). Create one with{" "} + + npx tsx scripts/create-snapshot.ts + {" "} + and set{" "} + + AGENT_BROWSER_SNAPSHOT_ID + {" "} + for sub-second startup. + + + )} + + +
+ ); + + const showSteps = loading || (steps.length > 0 && !result); + const hasResult = result && !loading; + + const resultContent = showSteps ? ( +
+
+
+ {steps.map((s, i) => ( + + ))} +
+
+
+
+ ) : hasResult ? ( +
+ {result.ok && result.screenshot && ( +
+
+

+ {result.title} +

+ + screenshot + +
+
+ {result.title} +
+
+ + Show steps ({steps.length}) + +
+ {steps.map((s, i) => ( + + ))} +
+
+
+ )} + + {result.ok && result.snapshot && ( +
+
+

+ {result.title} +

+ + snapshot + +
+
+            {result.snapshot}
+          
+
+ + Show steps ({steps.length}) + +
+ {steps.map((s, i) => ( + + ))} +
+
+
+ )} + + {!result.ok && ( +
+ + {steps.length > 0 && ( +
+ {steps.map((s, i) => ( + + ))} +
+ )} +
+ )} +
+ ) : ( +
+ +

No result yet

+

Pick a URL and click Run

+
+ ); + + return ( +
+
+
+
+ + agent-browser + +
+
+ + + Deploy with Vercel + +
+
+
+ + {isMobile ? ( +
+
{controlsForm}
+
{resultContent}
+
+ ) : ( + + + + + + + + +
+ {resultContent} +
+
+
+ )} +
+ ); +} diff --git a/examples/demo/components.json b/examples/environments/components.json similarity index 100% rename from examples/demo/components.json rename to examples/environments/components.json diff --git a/examples/demo/components/ui/alert.tsx b/examples/environments/components/ui/alert.tsx similarity index 100% rename from examples/demo/components/ui/alert.tsx rename to examples/environments/components/ui/alert.tsx diff --git a/examples/demo/components/ui/badge.tsx b/examples/environments/components/ui/badge.tsx similarity index 100% rename from examples/demo/components/ui/badge.tsx rename to examples/environments/components/ui/badge.tsx diff --git a/examples/demo/components/ui/button.tsx b/examples/environments/components/ui/button.tsx similarity index 100% rename from examples/demo/components/ui/button.tsx rename to examples/environments/components/ui/button.tsx diff --git a/examples/demo/components/ui/input.tsx b/examples/environments/components/ui/input.tsx similarity index 100% rename from examples/demo/components/ui/input.tsx rename to examples/environments/components/ui/input.tsx diff --git a/examples/demo/components/ui/label.tsx b/examples/environments/components/ui/label.tsx similarity index 100% rename from examples/demo/components/ui/label.tsx rename to examples/environments/components/ui/label.tsx diff --git a/examples/demo/components/ui/resizable.tsx b/examples/environments/components/ui/resizable.tsx similarity index 100% rename from examples/demo/components/ui/resizable.tsx rename to examples/environments/components/ui/resizable.tsx diff --git a/examples/environments/components/ui/select.tsx b/examples/environments/components/ui/select.tsx new file mode 100644 index 0000000..e8021f5 --- /dev/null +++ b/examples/environments/components/ui/select.tsx @@ -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 ( + + ) +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ) +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: SelectPrimitive.Trigger.Props & { + size?: "sm" | "default" +}) { + return ( + + {children} + + } + /> + + ) +} + +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 ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: SelectPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: SelectPrimitive.Item.Props) { + return ( + + + {children} + + + } + > + + + + ) +} + +function SelectSeparator({ + className, + ...props +}: SelectPrimitive.Separator.Props) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/examples/demo/components/ui/separator.tsx b/examples/environments/components/ui/separator.tsx similarity index 100% rename from examples/demo/components/ui/separator.tsx rename to examples/environments/components/ui/separator.tsx diff --git a/examples/demo/components/ui/toggle-group.tsx b/examples/environments/components/ui/toggle-group.tsx similarity index 100% rename from examples/demo/components/ui/toggle-group.tsx rename to examples/environments/components/ui/toggle-group.tsx diff --git a/examples/demo/components/ui/toggle.tsx b/examples/environments/components/ui/toggle.tsx similarity index 100% rename from examples/demo/components/ui/toggle.tsx rename to examples/environments/components/ui/toggle.tsx diff --git a/examples/environments/lib/agent-browser-sandbox.ts b/examples/environments/lib/agent-browser-sandbox.ts new file mode 100644 index 0000000..24934c0 --- /dev/null +++ b/examples/environments/lib/agent-browser-sandbox.ts @@ -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 { + 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( + step: string, + fn: () => Promise, + onStep?: OnStep, +): Promise { + 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, + onStep?: OnStep, +): Promise { + 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> { + 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, + cmd: string, + args: string[], + onStep?: OnStep, + stepLabel?: string, +): Promise { + 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 { + 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 { + 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; + } +} diff --git a/examples/demo/lib/constants.ts b/examples/environments/lib/constants.ts similarity index 100% rename from examples/demo/lib/constants.ts rename to examples/environments/lib/constants.ts diff --git a/examples/demo/lib/rate-limit.ts b/examples/environments/lib/rate-limit.ts similarity index 100% rename from examples/demo/lib/rate-limit.ts rename to examples/environments/lib/rate-limit.ts diff --git a/examples/demo/lib/utils.ts b/examples/environments/lib/utils.ts similarity index 100% rename from examples/demo/lib/utils.ts rename to examples/environments/lib/utils.ts diff --git a/examples/demo/next-env.d.ts b/examples/environments/next-env.d.ts similarity index 100% rename from examples/demo/next-env.d.ts rename to examples/environments/next-env.d.ts diff --git a/examples/demo/next.config.ts b/examples/environments/next.config.ts similarity index 100% rename from examples/demo/next.config.ts rename to examples/environments/next.config.ts diff --git a/examples/demo/package.json b/examples/environments/package.json similarity index 89% rename from examples/demo/package.json rename to examples/environments/package.json index f4c012e..26b7771 100644 --- a/examples/demo/package.json +++ b/examples/environments/package.json @@ -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", diff --git a/examples/demo/pnpm-lock.yaml b/examples/environments/pnpm-lock.yaml similarity index 90% rename from examples/demo/pnpm-lock.yaml rename to examples/environments/pnpm-lock.yaml index 11b21c6..9694dd0 100644 --- a/examples/demo/pnpm-lock.yaml +++ b/examples/environments/pnpm-lock.yaml @@ -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: {} diff --git a/examples/demo/postcss.config.mjs b/examples/environments/postcss.config.mjs similarity index 100% rename from examples/demo/postcss.config.mjs rename to examples/environments/postcss.config.mjs diff --git a/examples/environments/scripts/create-snapshot.ts b/examples/environments/scripts/create-snapshot.ts new file mode 100644 index 0000000..43cd453 --- /dev/null +++ b/examples/environments/scripts/create-snapshot.ts @@ -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= + * + * 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); +}); diff --git a/examples/demo/tsconfig.json b/examples/environments/tsconfig.json similarity index 100% rename from examples/demo/tsconfig.json rename to examples/environments/tsconfig.json diff --git a/skills/next/SKILL.md b/skills/next/SKILL.md deleted file mode 100644 index 3c17490..0000000 --- a/skills/next/SKILL.md +++ /dev/null @@ -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. diff --git a/skills/vercel-sandbox/SKILL.md b/skills/vercel-sandbox/SKILL.md index 28df60a..b29451f 100644 --- a/skills/vercel-sandbox/SKILL.md +++ b/skills/vercel-sandbox/SKILL.md @@ -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