SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
21.6 KB · 416 lines tsx
Raw Blame History
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&apos;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