TypeScript 97.5%
SQL 1.4%
Python 0.8%
1"use client";2import * as React from "react";3import { ChevronRight, History, MonitorSmartphone, Play, RotateCcw, Trash2 } from "lucide-react";4import { Badge } from "@/components/ui/badge";5import { Button } from "@/components/ui/button";6import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";7import { Input, Textarea } from "@/components/ui/input";8import { Field, Hint, Label } from "@/components/ui/label";9import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";10import { Switch } from "@/components/ui/switch";11import type { UrlCheck } from "@/lib/playground-url";12import { cn } from "@/lib/utils";13import { CountrySelect } from "./country-select";14import { KvRows } from "./kv-rows";15import { DEVICES, FORMATS, HTTP_METHODS, NETWORKS, NETWORK_LABELS, WAIT_UNTIL, inspectBody, methodHasBody, type BuilderState, type CountryOption, type HttpMethod, type NetworkClass, type OutputFormat, type PlanSummary, type RefererMode, type WaitUntil } from "./types";1617const FORMAT_HINTS: Record<OutputFormat, string> = {18 html: "Body as returned by the target",19 text: "Readable text, tags removed",20 markdown: "Main content converted to Markdown",21 json: "Body plus parsed JSON",22 raw: "Untouched body (base64 for binary)",23};2425const WAIT_UNTIL_LABELS: Record<WaitUntil, string> = { domcontentloaded: "DOM content loaded (default)", load: "Load event", networkidle: "Network idle" };2627export interface RequestBuilderProps {28 state: BuilderState;29 onChange: (patch: Partial<BuilderState>) => void;30 plan: PlanSummary;31 availableNetworks: string[];32 countries: CountryOption[];33 urlCheck: UrlCheck;34 urlTouched: boolean;35 onUrlBlur: () => void;36 onRun: () => void;37 onReset: () => void;38 pending: boolean;39 recent: string[];40 onPickRecent: (url: string) => void;41 onClearRecent: () => void;42 isMac: boolean;43}4445function Section({ title, count, open, onToggle, children, hint }: { title: string; count?: number; open: boolean; onToggle: () => void; children: React.ReactNode; hint?: string }) {46 const id = React.useId();47 return (48 <div className="border-t border-border">49 <button type="button" onClick={onToggle} aria-expanded={open} aria-controls={id} className="flex w-full items-center gap-2 py-2.5 text-left text-[13px] font-medium text-fg hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 rounded-sm">50 <ChevronRight className={cn("size-3.5 text-fg-subtle transition-transform", open && "rotate-90")} aria-hidden />51 <span>{title}</span>52 {count ? (53 <Badge variant="default" className="px-1.5 py-0 text-[10.5px] leading-4">54 {count}55 </Badge>56 ) : null}57 {hint ? <span className="ml-auto text-xs font-normal text-fg-subtle">{hint}</span> : null}58 </button>59 {open ? (60 <div id={id} className="pb-4 pl-5.5">61 {children}62 </div>63 ) : null}64 </div>65 );66}6768export function RequestBuilder(p: RequestBuilderProps) {69 const { state: s, onChange, plan, availableNetworks, countries, urlCheck, urlTouched, onUrlBlur, onRun, onReset, pending, recent, onPickRecent, onClearRecent, isMac } = p;70 const [open, setOpen] = React.useState({ headers: s.headers.length > 0, query: s.query.length > 0, body: s.body.length > 0, cookies: s.cookies.length > 0, options: true, browser: s.browser });71 const toggle = (k: keyof typeof open) => setOpen((o) => ({ ...o, [k]: !o[k] }));72 const bodyState = inspectBody(s.body);73 const showUrlMessage = urlTouched && urlCheck.level !== "ok";74 const maxTimeoutS = Math.round(plan.max_timeout_ms / 1000);75 const filledHeaders = s.headers.filter((r) => r.key.trim()).length;76 const filledQuery = s.query.filter((r) => r.key.trim()).length;77 const filledCookies = s.cookies.filter((r) => r.key.trim()).length;7879 return (80 <form81 className="grid gap-4"82 onSubmit={(e) => {83 e.preventDefault();84 onRun();85 }}86 >87 {/* URL + method */}88 <div className="grid gap-2">89 <div className="flex items-center justify-between">90 <Label htmlFor="pg-url">URL</Label>91 {recent.length ? (92 <DropdownMenu>93 <DropdownMenuTrigger asChild>94 <Button type="button" variant="ghost" size="xs" className="-mr-1 text-fg-muted">95 <History className="size-3.5" /> Recent96 </Button>97 </DropdownMenuTrigger>98 <DropdownMenuContent align="end" className="max-w-[min(90vw,28rem)]">99 <DropdownMenuLabel>Recent URLs</DropdownMenuLabel>100 {recent.map((u) => (101 <DropdownMenuItem key={u} onSelect={() => onPickRecent(u)} className="font-mono text-[12px]">102 <span className="truncate">{u}</span>103 </DropdownMenuItem>104 ))}105 <DropdownMenuSeparator />106 <DropdownMenuItem onSelect={onClearRecent} destructive>107 <Trash2 /> Clear recent108 </DropdownMenuItem>109 </DropdownMenuContent>110 </DropdownMenu>111 ) : null}112 </div>113 <div className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2">114 <Select value={s.method} onValueChange={(v) => onChange({ method: v as HttpMethod })}>115 <SelectTrigger className="h-11 font-mono text-[13px]" aria-label="HTTP method">116 <SelectValue />117 </SelectTrigger>118 <SelectContent>119 {HTTP_METHODS.map((m) => (120 <SelectItem key={m} value={m} className="font-mono text-[13px]">121 {m}122 </SelectItem>123 ))}124 </SelectContent>125 </Select>126 <Input127 id="pg-url"128 type="url"129 inputMode="url"130 autoFocus131 autoComplete="off"132 autoCapitalize="off"133 autoCorrect="off"134 spellCheck={false}135 placeholder="https://example.com/page"136 value={s.url}137 onChange={(e) => onChange({ url: e.target.value })}138 onBlur={onUrlBlur}139 aria-invalid={showUrlMessage && urlCheck.level === "error" ? true : undefined}140 aria-describedby={showUrlMessage ? "pg-url-msg" : undefined}141 className="h-11 font-mono text-[14px]"142 />143 </div>144 {showUrlMessage ? (145 <p id="pg-url-msg" className={cn("text-xs leading-relaxed", urlCheck.level === "error" ? "text-danger" : "text-warning")} role={urlCheck.level === "error" ? "alert" : "status"}>146 {urlCheck.message}147 </p>148 ) : null}149 </div>150151 {/* Sections */}152 <div>153 <Section title="Headers" count={filledHeaders} open={open.headers} onToggle={() => toggle("headers")}>154 <KvRows rows={s.headers} onChange={(headers) => onChange({ headers })} label="Header" keyPlaceholder="Header-Name" valuePlaceholder="value" addLabel="Add header" />155 <Hint className="mt-2">Sent to the target as-is. A realistic User-Agent is applied automatically when you don't set one.</Hint>156 </Section>157 <Section title="Query params" count={filledQuery} open={open.query} onToggle={() => toggle("query")}>158 <KvRows rows={s.query} onChange={(query) => onChange({ query })} label="Query parameter" keyPlaceholder="param" valuePlaceholder="value" addLabel="Add parameter" />159 <Hint className="mt-2">Merged into the URL when the request runs.</Hint>160 </Section>161 {methodHasBody(s.method) ? (162 <Section title="Body" open={open.body} onToggle={() => toggle("body")} hint={bodyState === "json" ? "Valid JSON" : bodyState === "text" ? "Plain text" : undefined}>163 <Textarea164 value={s.body}165 onChange={(e) => onChange({ body: e.target.value })}166 placeholder='{"query": "…"}'167 aria-label="Request body"168 spellCheck={false}169 className="min-h-[120px] font-mono text-[12.5px]"170 />171 <div className="mt-1.5 flex items-center gap-2">172 {bodyState === "json" ? (173 <Badge variant="success" dot>174 Valid JSON175 </Badge>176 ) : bodyState === "text" ? (177 <Badge variant="outline">Not JSON — sent as text</Badge>178 ) : null}179 <Hint>Set a Content-Type header to match your body.</Hint>180 </div>181 </Section>182 ) : null}183 <Section title="Cookies" count={filledCookies} open={open.cookies} onToggle={() => toggle("cookies")}>184 <KvRows rows={s.cookies} onChange={(cookies) => onChange({ cookies })} label="Cookie" keyPlaceholder="name" valuePlaceholder="value" addLabel="Add cookie" />185 </Section>186187 <Section title="Options" open={open.options} onToggle={() => toggle("options")}>188 <div className="grid gap-4">189 <div className="grid gap-4 sm:grid-cols-2">190 <Field>191 <Label htmlFor="pg-country">Country</Label>192 <CountrySelect id="pg-country" value={s.country} onChange={(country) => onChange({ country })} countries={countries} />193 </Field>194 <Field>195 <Label htmlFor="pg-network">Network</Label>196 <Select value={s.network} onValueChange={(v) => onChange({ network: v as NetworkClass })}>197 <SelectTrigger id="pg-network" aria-label="Network class">198 <SelectValue />199 </SelectTrigger>200 <SelectContent>201 {NETWORKS.map((n) => {202 const live = n === "auto" || availableNetworks.includes(n);203 const onPlan = n === "auto" || plan.networks.includes(n);204 const disabled = !live || !onPlan;205 const reason = !live ? "Not yet available" : !onPlan ? `Not on the ${plan.label} plan` : null;206 return (207 <SelectItem key={n} value={n} disabled={disabled} title={reason ?? undefined}>208 {NETWORK_LABELS[n]}209 {reason ? <span className="ml-1.5 text-xs text-fg-subtle">— {reason}</span> : null}210 </SelectItem>211 );212 })}213 </SelectContent>214 </Select>215 </Field>216 </div>217 <div className="grid gap-4 sm:grid-cols-2">218 <Field>219 <Label htmlFor="pg-region">Region</Label>220 <Input id="pg-region" value={s.region} onChange={(e) => onChange({ region: e.target.value })} placeholder="e.g. quebec" maxLength={64} autoCapitalize="off" />221 </Field>222 <Field>223 <Label htmlFor="pg-city">City</Label>224 <Input id="pg-city" value={s.city} onChange={(e) => onChange({ city: e.target.value })} placeholder="e.g. montreal" maxLength={128} autoCapitalize="off" />225 </Field>226 </div>227 <div className="grid gap-4 sm:grid-cols-2">228 <Field>229 <Label htmlFor="pg-session">Session</Label>230 <Input id="pg-session" value={s.session} onChange={(e) => onChange({ session: e.target.value })} placeholder="Auto (fresh IP)" className="font-mono text-[12.5px]" maxLength={64} autoCapitalize="off" spellCheck={false} />231 <Hint>Paste a <span className="font-mono">sess_…</span> id to reuse a sticky IP.</Hint>232 </Field>233 <Field>234 <Label htmlFor="pg-timeout">Timeout (ms)</Label>235 <Input236 id="pg-timeout"237 type="number"238 inputMode="numeric"239 min={1000}240 max={plan.max_timeout_ms}241 step={1000}242 value={s.timeout}243 onChange={(e) => onChange({ timeout: Number(e.target.value) })}244 onBlur={() => onChange({ timeout: Math.min(Math.max(1000, Math.round(s.timeout || 30_000)), plan.max_timeout_ms) })}245 className="font-mono tabular text-[12.5px]"246 />247 <Hint>248 Up to {maxTimeoutS} s on the {plan.label} plan.249 </Hint>250 </Field>251 </div>252 <div className="grid gap-4 sm:grid-cols-3">253 <Field>254 <Label htmlFor="pg-format">Output format</Label>255 <Select value={s.format} onValueChange={(v) => onChange({ format: v as OutputFormat })}>256 <SelectTrigger id="pg-format" aria-label="Output format">257 <SelectValue />258 </SelectTrigger>259 <SelectContent>260 {FORMATS.map((f) => (261 <SelectItem key={f} value={f} className="font-mono text-[13px]">262 {f}263 <span className="ml-1.5 font-sans text-xs text-fg-subtle">— {FORMAT_HINTS[f]}</span>264 </SelectItem>265 ))}266 </SelectContent>267 </Select>268 </Field>269 <Field>270 <Label htmlFor="pg-device">Device</Label>271 <Select value={s.device || "default"} onValueChange={(v) => onChange({ device: v === "default" ? "" : (v as BuilderState["device"]) })}>272 <SelectTrigger id="pg-device" aria-label="Device">273 <SelectValue />274 </SelectTrigger>275 <SelectContent>276 <SelectItem value="default">Default</SelectItem>277 {DEVICES.map((d) => (278 <SelectItem key={d} value={d}>279 {d.charAt(0).toUpperCase() + d.slice(1)}280 </SelectItem>281 ))}282 </SelectContent>283 </Select>284 </Field>285 <Field>286 <Label htmlFor="pg-locale">Locale</Label>287 <Input id="pg-locale" value={s.locale} onChange={(e) => onChange({ locale: e.target.value })} placeholder="en-CA" maxLength={16} autoCapitalize="off" spellCheck={false} className="font-mono text-[12.5px]" />288 </Field>289 </div>290 <div className="grid gap-4 sm:grid-cols-2">291 <Field>292 <Label htmlFor="pg-referer">Referer</Label>293 <Select value={s.refererMode} onValueChange={(v) => onChange({ refererMode: v as RefererMode })}>294 <SelectTrigger id="pg-referer" aria-label="Referer strategy">295 <SelectValue />296 </SelectTrigger>297 <SelectContent>298 <SelectItem value="auto">Auto (search-engine referer on retries)</SelectItem>299 <SelectItem value="none">None</SelectItem>300 <SelectItem value="custom">Custom URL</SelectItem>301 </SelectContent>302 </Select>303 </Field>304 {s.refererMode === "custom" ? (305 <Field>306 <Label htmlFor="pg-referer-url">Referer URL</Label>307 <Input id="pg-referer-url" type="url" inputMode="url" value={s.refererUrl} onChange={(e) => onChange({ refererUrl: e.target.value })} placeholder="https://www.google.com/" maxLength={2048} autoCapitalize="off" spellCheck={false} className="font-mono text-[12.5px]" />308 </Field>309 ) : null}310 </div>311 <div className="grid gap-3 rounded-md border border-border bg-bg-subtle/50 p-3">312 <ToggleRow id="pg-follow" label="Follow redirects" hint="Each hop is validated against the URL policy." checked={s.followRedirects} onChange={(v) => onChange({ followRedirects: v })} />313 <ToggleRow id="pg-links" label="Include links" hint="Return every hyperlink of the page as absolute URLs in links[]." checked={s.links} onChange={(v) => onChange({ links: v })} />314 <ToggleRow id="pg-debug" label="Debug" hint="Include per-attempt routing details in the response." checked={s.debug} onChange={(v) => onChange({ debug: v })} />315 </div>316 </div>317 </Section>318319 <Section title="Browser rendering" open={open.browser} onToggle={() => toggle("browser")} hint={s.browser ? "on" : s.browserFallback ? "fallback" : "off"}>320 <div className="grid gap-4">321 <div className="grid gap-3 rounded-md border border-border bg-bg-subtle/50 p-3">322 <ToggleRow323 id="pg-browser"324 label={325 <span className="inline-flex items-center gap-1.5">326 <MonitorSmartphone className="size-3.5 text-fg-subtle" aria-hidden /> Render in a managed browser327 </span>328 }329 hint="Real Chromium routed through the same network, geography and session. Returns the DOM after the page settles."330 checked={s.browser}331 onChange={(v) => onChange({ browser: v })}332 />333 <ToggleRow id="pg-browser-fallback" label="Escalate on JavaScript challenges" hint="When an HTTP attempt is blocked by a JS challenge or anti-bot page, retry automatically in the browser." checked={s.browserFallback} onChange={(v) => onChange({ browserFallback: v })} />334 </div>335 <div className="grid gap-4 sm:grid-cols-2">336 <Field>337 <Label htmlFor="pg-wait-for">Wait for selector</Label>338 <Input id="pg-wait-for" value={s.waitFor} onChange={(e) => onChange({ waitFor: e.target.value })} placeholder="e.g. table.results" maxLength={512} autoCapitalize="off" spellCheck={false} className="font-mono text-[12.5px]" />339 <Hint>CSS selector that must be present before capture.</Hint>340 </Field>341 <Field>342 <Label htmlFor="pg-wait-ms">Extra settle time (ms)</Label>343 <Input344 id="pg-wait-ms"345 type="number"346 inputMode="numeric"347 min={0}348 max={30_000}349 step={100}350 value={s.waitMs}351 onChange={(e) => onChange({ waitMs: Number(e.target.value) })}352 onBlur={() => onChange({ waitMs: Math.min(30_000, Math.max(0, Math.round(s.waitMs || 0))) })}353 className="font-mono tabular text-[12.5px]"354 />355 <Hint>0–30,000 ms after load or after the selector appears.</Hint>356 </Field>357 </div>358 <Field>359 <Label htmlFor="pg-wait-until">Navigation wait condition</Label>360 <Select value={s.waitUntil} onValueChange={(v) => onChange({ waitUntil: v as WaitUntil })}>361 <SelectTrigger id="pg-wait-until" aria-label="Navigation wait condition">362 <SelectValue />363 </SelectTrigger>364 <SelectContent>365 {WAIT_UNTIL.map((w) => (366 <SelectItem key={w} value={w}>367 {WAIT_UNTIL_LABELS[w]}368 </SelectItem>369 ))}370 </SelectContent>371 </Select>372 </Field>373 <div className="grid gap-3 rounded-md border border-border bg-bg-subtle/50 p-3">374 <ToggleRow id="pg-block-resources" label="Block images, fonts and media" hint="Saves bandwidth and time; page scripts still run." checked={s.blockResources} onChange={(v) => onChange({ blockResources: v })} />375 <ToggleRow id="pg-screenshot" label="Screenshot" hint="Return a PNG of the viewport (base64) in screenshot." checked={s.screenshot} onChange={(v) => onChange({ screenshot: v })} />376 </div>377 <Hint>378 Up to {plan.browser_concurrency} concurrent renders. A page that does not settle within the timeout returns <span className="font-mono">BROWSER_TIMEOUT</span>.379 </Hint>380 </div>381 </Section>382 </div>383384 {/* Actions (desktop) */}385 <div className="hidden items-center gap-2 border-t border-border pt-4 lg:flex">386 <Button type="submit" variant="primary" loading={pending} disabled={urlCheck.level === "error"} className="min-w-[10rem]">387 {!pending ? <Play className="size-3.5" /> : null}388 {pending ? "Running…" : "Run request"}389 {!pending ? <kbd className="ml-1 rounded-[4px] border border-white/25 bg-white/10 px-1.5 font-mono text-[10.5px] font-normal">{isMac ? "⌘" : "Ctrl"}↵</kbd> : null}390 </Button>391 <Button type="button" variant="ghost" onClick={onReset} disabled={pending}>392 <RotateCcw className="size-3.5" /> Reset393 </Button>394 <p className="ml-auto text-right text-[11.5px] leading-tight text-fg-subtle">395 {plan.label} plan · {plan.concurrency} concurrent · {maxTimeoutS} s max396 </p>397 </div>398 </form>399 );400}401402function ToggleRow({ id, label, hint, checked, onChange, disabled, badge }: { id: string; label: React.ReactNode; hint?: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; badge?: string }) {403 return (404 <div className="flex items-center justify-between gap-3">405 <div className="min-w-0">406 <Label htmlFor={id} className={cn("flex items-center gap-2", disabled && "opacity-70")}>407 {label}408 {badge ? <Badge variant="outline">{badge}</Badge> : null}409 </Label>410 {hint ? <Hint className="mt-1">{hint}</Hint> : null}411 </div>412 <Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} aria-label={typeof label === "string" ? label : undefined} />413 </div>414 );415}416