rate limits for demo (#695)

This commit is contained in:
Chris Tate
2026-03-09 15:43:25 -05:00
committed by GitHub
parent cc3c70dc86
commit c0a525c9e4
8 changed files with 188 additions and 18 deletions
+9
View File
@@ -7,3 +7,12 @@
# 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
# --- Rate Limiting (Upstash / Vercel KV) ---
# Automatically populated when you add Vercel KV to your project
KV_REST_API_URL=
KV_REST_API_TOKEN=
# Optional: override default limits
# RATE_LIMIT_PER_MINUTE=10
# RATE_LIMIT_PER_DAY=100
+42
View File
@@ -1,7 +1,31 @@
"use server";
import { headers } from "next/headers";
import * as serverless from "@/lib/agent-browser";
import * as sandbox from "@/lib/agent-browser-sandbox";
import { ALLOWED_URLS } from "@/lib/constants";
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
async function checkRateLimit(): Promise<string | null> {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
const minute = await minuteRateLimit.limit(ip);
if (!minute.success) {
return "Too many requests. Please wait a moment before trying again.";
}
const daily = await dailyRateLimit.limit(ip);
if (!daily.success) {
return "Daily limit reached. Please try again tomorrow.";
}
return null;
}
function isAllowedUrl(url: string): boolean {
return (ALLOWED_URLS as readonly string[]).includes(url);
}
export type EnvStatus = {
serverless: {
@@ -52,6 +76,15 @@ export async function takeScreenshot(
url: string,
mode: Mode = "serverless",
): Promise<ScreenshotResult> {
if (!isAllowedUrl(url)) {
return { ok: false, error: "URL not allowed" };
}
const rateLimitError = await checkRateLimit();
if (rateLimitError) {
return { ok: false, error: rateLimitError };
}
try {
if (mode === "sandbox") {
const { screenshot, title } = await sandbox.screenshotUrl(url);
@@ -78,6 +111,15 @@ export async function takeSnapshot(
url: string,
mode: Mode = "serverless",
): Promise<SnapshotResult> {
if (!isAllowedUrl(url)) {
return { ok: false, error: "URL not allowed" };
}
const rateLimitError = await checkRateLimit();
if (rateLimitError) {
return { ok: false, error: rateLimitError };
}
try {
if (mode === "sandbox") {
const { snapshot, title } = await sandbox.snapshotUrl(url, {
+25 -9
View File
@@ -1,17 +1,29 @@
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";
/**
* POST /api/browse
*
* Programmatic API route for browser automation.
* Uses @sparticuz/chromium + puppeteer-core directly in the function.
*
* Body: { "action": "screenshot", "url": "https://example.com" }
* Or: { "action": "snapshot", "url": "https://example.com" }
*/
export async function POST(req: NextRequest) {
try {
const 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;
@@ -19,6 +31,10 @@ export async function POST(req: NextRequest) {
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,
+13 -9
View File
@@ -13,8 +13,8 @@ import {
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { ALLOWED_URLS } from "@/lib/constants";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
@@ -179,7 +179,7 @@ function ModeCard({
export default function Home() {
const isMobile = useIsMobile();
const [url, setUrl] = useState("https://example.com");
const [url, setUrl] = useState<string>(ALLOWED_URLS[0]);
const [loading, setLoading] = useState(false);
const [action, setAction] = useState<Action>("screenshot");
const [mode, setMode] = useState<Mode>("serverless");
@@ -238,22 +238,26 @@ export default function Home() {
<form onSubmit={handleSubmit} className="p-5 space-y-5">
<div className="space-y-1.5">
<Label
htmlFor="url-input"
htmlFor="url-select"
className="text-[11px] text-muted-foreground uppercase tracking-wider"
>
URL
</Label>
<Input
id="url-input"
type="url"
<select
id="url-select"
value={url}
onChange={(e) => {
setUrl(e.target.value);
clearResults();
}}
placeholder="https://example.com"
required
/>
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{ALLOWED_URLS.map((u) => (
<option key={u} value={u}>
{u.replace("https://", "")}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
+8
View File
@@ -0,0 +1,8 @@
export const ALLOWED_URLS = [
"https://example.com",
"https://ai-sdk.dev",
"https://useworkflow.dev",
"https://vercel.com",
] as const;
export type AllowedUrl = (typeof ALLOWED_URLS)[number];
+53
View File
@@ -0,0 +1,53 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
let _minuteRateLimit: Ratelimit | null = null;
let _dailyRateLimit: Ratelimit | null = null;
function getRedis(): Redis | null {
const url = process.env.KV_REST_API_URL;
const token = process.env.KV_REST_API_TOKEN;
if (!url || !token) {
return null;
}
return new Redis({ url, token });
}
const noopRateLimiter = {
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
};
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
export const minuteRateLimit = {
limit: async (identifier: string) => {
if (!_minuteRateLimit) {
const redis = getRedis();
if (!redis) return noopRateLimiter.limit();
_minuteRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
prefix: "ratelimit:minute",
});
}
return _minuteRateLimit.limit(identifier);
},
};
export const dailyRateLimit = {
limit: async (identifier: string) => {
if (!_dailyRateLimit) {
const redis = getRedis();
if (!redis) return noopRateLimiter.limit();
_dailyRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
prefix: "ratelimit:daily",
});
}
return _dailyRateLimit.limit(identifier);
},
};
+2
View File
@@ -12,6 +12,8 @@
"@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",
+36
View File
@@ -17,6 +17,12 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.2.1
version: 4.2.1
'@upstash/ratelimit':
specifier: ^2.0.8
version: 2.0.8(@upstash/redis@1.36.4)
'@upstash/redis':
specifier: ^1.36.4
version: 1.36.4
'@vercel/sandbox':
specifier: ^1.0.0
version: 1.8.0
@@ -693,6 +699,18 @@ packages:
'@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'}
'@upstash/ratelimit@2.0.8':
resolution: {integrity: sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==}
peerDependencies:
'@upstash/redis': ^1.34.3
'@upstash/redis@1.36.4':
resolution: {integrity: sha512-w4s/msmyMqxOxaVhC8TQ2whJ77+Zd8YaSFokXL4mULQopaYb4xNJcm/PedtFQyLJn65nneySw9IwYnlMBBmFHg==}
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
@@ -2122,6 +2140,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -2878,6 +2899,19 @@ snapshots:
'@types/node': 22.19.15
optional: true
'@upstash/core-analytics@0.0.10':
dependencies:
'@upstash/redis': 1.36.4
'@upstash/ratelimit@2.0.8(@upstash/redis@1.36.4)':
dependencies:
'@upstash/core-analytics': 0.0.10
'@upstash/redis': 1.36.4
'@upstash/redis@1.36.4':
dependencies:
uncrypto: 0.1.3
'@vercel/oidc@3.2.0': {}
'@vercel/sandbox@1.8.0':
@@ -4310,6 +4344,8 @@ snapshots:
typescript@5.9.3: {}
uncrypto@0.1.3: {}
undici-types@6.21.0: {}
undici@7.22.0: {}