Merge pull request #52 from RuoJi6/feat/sidebar-repeater

feat(web): 完整 HTTP 请求重发、流量转入与批量管理
This commit is contained in:
RuoJi6 authored and GitHub committed 2026-10-26 02:05:10 -07:00
commit e740334b90
19 files changed
+2369 -453

No files matched your search

+30 -1
View File
@@ -32,6 +32,7 @@ var repeaterHopByHop = map[string]struct{}{
}
type repeaterSendRequest struct {
HTTPVersion string `json:"http_version"`
Method *string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
@@ -89,6 +90,13 @@ func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) {
return
}
switch in.HTTPVersion {
case "", "HTTP/1.1", "HTTP/2.0":
default:
writeErr(w, http.StatusBadRequest, "当前支持 HTTP/1.1 和 HTTP/2.0")
return
}
rawURL := strings.TrimSpace(in.URL)
if rawURL == "" {
writeErr(w, http.StatusBadRequest, "url 必填")
@@ -151,9 +159,30 @@ func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) {
req.Header.Set(name, value)
}
transport := s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify)
if in.HTTPVersion != "" {
tr := transport.(*http.Transport)
// Keep TLS ALPN consistent with the explicit protocol, even when an
// inherited transport previously advertised h2.
if tr.TLSClientConfig == nil {
tr.TLSClientConfig = &tls.Config{}
} else {
tr.TLSClientConfig = tr.TLSClientConfig.Clone()
}
tr.TLSClientConfig.NextProtos = []string{"http/1.1"}
if in.HTTPVersion == "HTTP/2.0" {
tr.TLSClientConfig.NextProtos = []string{"h2"}
}
tr.ForceAttemptHTTP2 = in.HTTPVersion == "HTTP/2.0"
tr.Protocols = new(http.Protocols)
tr.Protocols.SetHTTP1(in.HTTPVersion == "HTTP/1.1")
tr.Protocols.SetHTTP2(in.HTTPVersion == "HTTP/2.0")
tr.Protocols.SetUnencryptedHTTP2(in.HTTPVersion == "HTTP/2.0" && strings.EqualFold(u.Scheme, "http"))
}
client := &http.Client{
Timeout: time.Duration(timeoutMs) * time.Millisecond,
Transport: s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify),
Transport: transport,
CheckRedirect: func(next *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("重定向次数过多")
+38
View File
@@ -402,3 +402,41 @@ func TestRepeaterRedactProxyCredentials(t *testing.T) {
})
}
}
func TestRepeaterHonorsExplicitHTTPVersion(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "1")
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, r.Proto)
}))
srv.EnableHTTP2 = true
srv.StartTLS()
defer srv.Close()
for _, version := range []string{"HTTP/1.1", "HTTP/2.0"} {
t.Run(version, func(t *testing.T) {
code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{
"method": "GET", "url": srv.URL, "http_version": version,
"insecure_skip_verify": true, "use_recording_proxy": false,
})
if code != http.StatusOK || out.Error != "" || out.Body != version {
t.Fatalf("version %s: status=%d response=%s", version, code, raw)
}
})
}
code, _, _ := repeaterTestSend(t, &Server{}, map[string]any{"method": "GET", "url": srv.URL, "http_version": "HTTP/1.0"})
if code != http.StatusBadRequest {
t.Fatalf("unsupported version status=%d", code)
}
}
func TestRepeaterExplicitHTTP2Cleartext(t *testing.T) {
t.Setenv(repeaterAllowLocalEnv, "1")
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, r.Proto) }))
srv.Config.Protocols = new(http.Protocols)
srv.Config.Protocols.SetUnencryptedHTTP2(true)
srv.Start()
defer srv.Close()
code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{"method": "GET", "url": srv.URL, "http_version": "HTTP/2.0", "use_recording_proxy": false})
if code != http.StatusOK || out.Error != "" || out.Body != "HTTP/2.0" {
t.Fatalf("status=%d response=%s", code, raw)
}
}
@@ -0,0 +1,7 @@
"use client";
import { RepeaterWorkspace } from "@/components/repeater/repeater-workspace";
export default function RepeaterPage() {
return <RepeaterWorkspace />;
}
+64 -8
View File
@@ -2,6 +2,8 @@
import * as React from "react";
import { useRouter } from "next/navigation";
import {
ArrowDownWideNarrowIcon,
ArrowUpNarrowWideIcon,
@@ -19,6 +21,8 @@ import { toast } from "sonner";
import { HttpCodeBlock } from "@/components/http-code-block";
import { LinkTrafficDialog } from "@/components/link-traffic-dialog";
import { requestFromTraffic } from "@/components/repeater/repeater-http";
import { seedFromEvidence } from "@/components/repeater/repeater-store";
import {
AlertDialog,
AlertDialogAction,
@@ -112,7 +116,10 @@ const SORT_STORAGE_KEY = "traffic-sort";
const STATUS_BUCKETS = ["2xx", "3xx", "4xx", "5xx"];
export default function TrafficPage() {
const [selectedFlows, setSelectedFlows] = React.useState<Set<string>>(() => new Set());
const [selectedFlows, setSelectedFlows] = React.useState<Map<string, TrafficExchange>>(() => new Map());
const router = useRouter();
const [importing, setImporting] = React.useState(false);
const importLock = React.useRef(false);
const [linking, setLinking] = React.useState(false);
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
@@ -322,6 +329,38 @@ export default function TrafficPage() {
};
}, [selected]);
async function openRepeater(flows: TrafficExchange[]) {
if (!flows.length || importLock.current) return;
importLock.current = true;
setImporting(true);
let imported = 0;
try {
// Sequential loading limits pressure when a whole page is selected. A bad
// capture does not discard drafts imported successfully before it.
for (const flow of flows) {
try {
const raw = await api.trafficExchange(flow.id);
seedFromEvidence(
requestFromTraffic(raw.req, flow),
{ label: `流量 ${flow.id}` },
{ title: `${flow.method} ${flow.host}` },
);
imported += 1;
} catch (error) {
toast.error(`${flow.host}:${(error as Error).message}`);
}
}
if (imported) {
toast.success(`已送入 ${imported} 条请求,可编辑后发送`);
setSelected(null);
router.push("/function/repeater");
}
} finally {
importLock.current = false;
setImporting(false);
}
}
// Toggle direction when re-clicking the active column, else sort the new column
// newest/largest-first.
const toggleSort = (field: SortField) =>
@@ -607,8 +646,16 @@ export default function TrafficPage() {
<Button variant="outline" size="sm" disabled={selectedFlows.size === 0} onClick={() => setLinking(true)}>
关联到漏洞
</Button>
<Button
variant="outline"
size="sm"
disabled={selectedFlows.size === 0 || importing}
onClick={() => void openRepeater([...selectedFlows.values()])}
>
{importing ? "转入中…" : "送入请求重发"}
</Button>
{selectedFlows.size > 0 ? (
<Button variant="ghost" size="sm" onClick={() => setSelectedFlows(new Set())}>
<Button variant="ghost" size="sm" onClick={() => setSelectedFlows(new Map())}>
清空选择
</Button>
) : null}
@@ -626,9 +673,9 @@ export default function TrafficPage() {
checked={exchanges.length > 0 && exchanges.every((e) => selectedFlows.has(e.id))}
onCheckedChange={(checked) =>
setSelectedFlows((previous) => {
const next = new Set(previous);
const next = new Map(previous);
for (const e of exchanges) {
if (checked === true) next.add(e.id);
if (checked === true) next.set(e.id, e);
else next.delete(e.id);
}
return next;
@@ -681,8 +728,8 @@ export default function TrafficPage() {
onClick={(event) => event.stopPropagation()}
onCheckedChange={(checked) =>
setSelectedFlows((previous) => {
const next = new Set(previous);
if (checked === true) next.add(e.id);
const next = new Map(previous);
if (checked === true) next.set(e.id, e);
else next.delete(e.id);
return next;
})
@@ -721,9 +768,9 @@ export default function TrafficPage() {
{linking ? (
<LinkTrafficDialog
trafficIds={[...selectedFlows]}
trafficIds={[...selectedFlows.keys()]}
onClose={() => setLinking(false)}
onBound={() => setSelectedFlows(new Set())}
onBound={() => setSelectedFlows(new Map())}
/>
) : null}
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
@@ -740,6 +787,15 @@ export default function TrafficPage() {
</div>
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
<Button
variant="outline"
size="sm"
className="mt-2 w-fit"
disabled={importing}
onClick={() => void openRepeater([selected])}
>
{importing ? "转入中…" : "送入请求重发"}
</Button>
</SheetHeader>
<Tabs defaultValue="request" className="min-h-0 flex-1 gap-0">
<TabsList className="mx-5 mt-4 grid w-auto grid-cols-2">
@@ -1,336 +0,0 @@
"use client";
import * as React from "react";
import { SendIcon } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { RepeaterSendResponse } from "@/lib/types";
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const;
export type RepeaterInitial = {
method?: string;
url?: string;
headersText?: string;
body?: string;
};
type HistoryEntry = {
id: number;
method: string;
url: string;
headersText: string;
body: string;
useProxy: boolean;
insecure: boolean;
response: RepeaterSendResponse;
};
export function parseHeadersText(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of text.replace(/\r\n?/g, "\n").split("\n")) {
const separator = line.indexOf(":");
if (separator <= 0) continue;
const name = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
if (!name) continue;
// The API accepts one value per name. Cookies use semicolon separators;
// other duplicate headers preserve the common comma-separated semantics.
const existingName = Object.keys(result).find((item) => item.toLowerCase() === name.toLowerCase());
if (!existingName) {
result[name] = value;
} else {
const separator = existingName.toLowerCase() === "cookie" ? "; " : ", ";
result[existingName] = `${result[existingName]}${separator}${value}`;
}
}
return result;
}
export function formatHeaders(headers: Record<string, string[]> | Record<string, string>): string {
return Object.entries(headers)
.flatMap(([name, value]) =>
Array.isArray(value) ? value.map((item) => `${name}: ${item}`) : [`${name}: ${value}`],
)
.join("\n");
}
export function buildRawResponse(response: RepeaterSendResponse): string {
if (!response.status) return "";
const statusLine = `${response.proto || "HTTP/1.1"} ${response.status_text || response.status}`.trim();
const headers = formatHeaders(response.headers);
return `${statusLine}\n${headers}\n\n${response.body}`;
}
export function headersFromReqHead(reqHead?: string): string {
if (!reqHead) return "";
const lines = reqHead.replace(/\r\n?/g, "\n").split("\n");
// Evidence heads normally start with `METHOD path HTTP/x.y`, but tolerate
// leading blank lines and CR-only captures from older traffic records.
let requestLine = -1;
for (let index = 0; index < lines.length; index += 1) {
const candidate = lines[index].trim();
if (!candidate) continue;
if (/^(?:[A-Z]+)\s+\S+\s+HTTP\/\d(?:\.\d)?$/i.test(candidate)) requestLine = index;
break;
}
const start = requestLine >= 0 ? requestLine + 1 : 0;
return lines.slice(start).join("\n").replace(/\n+$/, "");
}
export function FindingRepeaterDialog({
open,
onOpenChange,
initial,
findingLabel,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
initial?: RepeaterInitial;
findingLabel?: string;
}) {
const [method, setMethod] = React.useState("GET");
const [url, setURL] = React.useState("");
const [headersText, setHeadersText] = React.useState("");
const [body, setBody] = React.useState("");
const [useProxy, setUseProxy] = React.useState(true);
const [insecure, setInsecure] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const [response, setResponse] = React.useState<RepeaterSendResponse | null>(null);
const [history, setHistory] = React.useState<HistoryEntry[]>([]);
const sequence = React.useRef(0);
const generation = React.useRef(0);
const sending = React.useRef(false);
React.useEffect(() => {
sending.current = false;
setBusy(false);
if (!open) return;
const nextMethod = (initial?.method ?? "GET").toUpperCase();
setMethod(METHODS.includes(nextMethod as (typeof METHODS)[number]) ? nextMethod : "GET");
setURL(initial?.url ?? "");
setHeadersText(initial?.headersText ?? "");
setBody(initial?.body ?? "");
setUseProxy(true);
setInsecure(false);
setResponse(null);
return () => {
// Closing, reseeding, or unmounting makes any pending result obsolete.
generation.current += 1;
sending.current = false;
};
}, [open, initial?.method, initial?.url, initial?.headersText, initial?.body]);
async function send() {
// State alone cannot stop a second click before React disables the button.
if (sending.current || !open) return;
const target = url.trim();
if (!target) {
toast.error("请填写 URL");
return;
}
const requestGeneration = ++generation.current;
sending.current = true;
setBusy(true);
setResponse(null);
try {
const result = await api.repeaterSend({
method,
url: target,
headers: parseHeadersText(headersText),
body,
use_recording_proxy: useProxy,
insecure_skip_verify: insecure,
});
if (requestGeneration !== generation.current) return;
setResponse(result);
const id = ++sequence.current;
setHistory((previous) =>
[
{ id, method, url: target, headersText, body, useProxy, insecure, response: result },
...previous,
].slice(0, 20),
);
} catch (error) {
if (requestGeneration === generation.current) toast.error((error as Error).message);
} finally {
if (requestGeneration === generation.current) {
sending.current = false;
setBusy(false);
}
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
// Invalidate immediately, before the parent's close render/effect.
generation.current += 1;
sending.current = false;
setBusy(false);
}
onOpenChange(nextOpen);
}}
>
<DialogContent className="flex max-h-[92vh] w-[min(1150px,96vw)] max-w-none flex-col gap-3 overflow-hidden sm:max-w-none">
<DialogHeader>
<DialogTitle>Repeater · 请求重放</DialogTitle>
<DialogDescription>
手工编辑并发送 HTTP 请求,便于验证漏洞与撰写报告{findingLabel ? ` · ${findingLabel}` : ""}
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap items-end gap-2">
<div className="w-28">
<Label className="mb-1 block text-xs text-muted-foreground">方法</Label>
<Select value={method} onValueChange={setMethod} disabled={busy}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{METHODS.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="min-w-[240px] flex-1">
<Label className="mb-1 block text-xs text-muted-foreground">URL</Label>
<Input
disabled={busy}
value={url}
onChange={(event) => setURL(event.target.value)}
placeholder="https://target.example/path"
spellCheck={false}
/>
</div>
<Button disabled={busy} onClick={() => void send()}>
<SendIcon data-icon="inline-start" />
发送
</Button>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm">
<Label className="flex items-center gap-2" htmlFor="repeater-use-proxy">
<Checkbox
id="repeater-use-proxy"
disabled={busy}
checked={useProxy}
onCheckedChange={(value) => setUseProxy(value === true)}
/>
经流量代理
</Label>
<Label className="flex items-center gap-2" htmlFor="repeater-insecure">
<Checkbox
id="repeater-insecure"
disabled={busy}
checked={insecure}
onCheckedChange={(value) => setInsecure(value === true)}
/>
跳过 TLS 校验
</Label>
{response ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{response.status ? <Badge variant="secondary">{response.status_text || response.status}</Badge> : null}
<span>{response.elapsed_ms} ms</span>
<span>{response.body_bytes.toLocaleString()} 字节</span>
{response.body_truncated ? <Badge variant="outline">正文已截断</Badge> : null}
</div>
) : null}
</div>
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[180px_1fr_1fr]">
<div className="flex min-h-0 flex-col gap-2 overflow-hidden rounded-md border p-2">
<div className="text-xs font-medium text-muted-foreground">会话历史</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto">
{history.length === 0 ? (
<p className="text-xs text-muted-foreground">发送后会出现在这里</p>
) : (
history.map((entry) => (
<button
key={entry.id}
type="button"
disabled={busy}
className="block w-full rounded-md px-2 py-1.5 text-left hover:bg-muted"
onClick={() => {
setMethod(entry.method);
setURL(entry.url);
setHeadersText(entry.headersText);
setBody(entry.body);
setUseProxy(entry.useProxy);
setInsecure(entry.insecure);
setResponse(entry.response);
}}
>
<div className="truncate font-mono text-xs">
{entry.method} {entry.response.status || "ERR"}
</div>
<div className="truncate text-[11px] text-muted-foreground">{entry.url}</div>
</button>
))
)}
</div>
</div>
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
<div className="text-xs font-medium">请求</div>
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="flex min-h-0 flex-1 flex-col gap-1">
<Label className="text-xs text-muted-foreground">请求头</Label>
<Textarea
className="min-h-[120px] flex-1 font-mono text-xs"
disabled={busy}
value={headersText}
onChange={(event) => setHeadersText(event.target.value)}
placeholder="Header: value"
spellCheck={false}
/>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1">
<Label className="text-xs text-muted-foreground">请求体</Label>
<Textarea
className="min-h-[120px] flex-1 font-mono text-xs"
disabled={busy}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder="请求正文(可选)"
spellCheck={false}
/>
</div>
</div>
</div>
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
<div className="text-xs font-medium">响应</div>
{response?.error ? (
<Alert variant={response.status ? "default" : "destructive"}>
<AlertDescription className="break-all font-mono text-xs">{response.error}</AlertDescription>
</Alert>
) : null}
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{response ? (
<pre className="whitespace-pre-wrap break-words p-3 font-mono text-xs">
{buildRawResponse(response)}
</pre>
) : (
<p className="p-3 text-xs text-muted-foreground">发送后显示原始响应</p>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+26 -24
View File
@@ -2,10 +2,13 @@
import * as React from "react";
import { useRouter } from "next/navigation";
import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from "lucide-react";
import { toast } from "sonner";
import { FindingRepeaterDialog, headersFromReqHead, type RepeaterInitial } from "@/components/finding-repeater-dialog";
import { headersFromReqHead, requestFromEvidence } from "@/components/repeater/repeater-http";
import { seedFromEvidence } from "@/components/repeater/repeater-store";
import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer";
import { TrafficPickerDialog } from "@/components/traffic-picker-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -50,8 +53,7 @@ export function FindingTrafficPanel({
const [error, setError] = React.useState("");
const [adding, setAdding] = React.useState(false);
const [preview, setPreview] = React.useState<string | null>(null);
const [repeaterOpen, setRepeaterOpen] = React.useState(false);
const [repeaterInitial, setRepeaterInitial] = React.useState<RepeaterInitial>();
const router = useRouter();
const [repeaterBusy, setRepeaterBusy] = React.useState(false);
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
@@ -104,13 +106,19 @@ export function FindingTrafficPanel({
const detail = await api.findingTrafficDetail(findingId, bindingId, contextTask);
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载");
setRepeaterInitial({
method: detail.binding.snapshot.method || "GET",
url: detail.binding.snapshot.url || "",
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
body: detail.request.binary ? "" : detail.request.content || "",
});
setRepeaterOpen(true);
seedFromEvidence(
requestFromEvidence(
{
method: detail.binding.snapshot.method || "GET",
url: detail.binding.snapshot.url || "",
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
body: detail.request.binary ? "" : detail.request.content || "",
},
detail.binding.snapshot.req_head,
),
{ findingId, bindingId },
);
router.push("/function/repeater");
} catch (e) {
toast.error((e as Error).message);
} finally {
@@ -129,11 +137,11 @@ export function FindingTrafficPanel({
variant="outline"
size="sm"
onClick={() => {
setRepeaterInitial({ method: "GET", url: "", headersText: "", body: "" });
setRepeaterOpen(true);
seedFromEvidence({ method: "GET", url: "", headersText: "", body: "" });
router.push("/function/repeater");
}}
>
打开 Repeater
打开请求重发
</Button>
{!readOnly ? (
<Button variant="outline" size="sm" disabled={!data || busy} onClick={() => setAdding(true)}>
@@ -177,18 +185,18 @@ export function FindingTrafficPanel({
<Badge variant="secondary">
{b.snapshot.method} · {b.snapshot.status}
</Badge>
<span className="text-xs text-muted-foreground">证据 #{b.id}</span>
<span className="text-muted-foreground text-xs">证据 #{b.id}</span>
</div>
<Button
variant="link"
className="h-auto justify-start p-0 text-left whitespace-normal"
className="h-auto justify-start whitespace-normal p-0 text-left"
onClick={() => setPreview(b.id)}
>
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
</Button>
{b.note ? <p className="text-sm whitespace-pre-wrap">{b.note}</p> : null}
{b.note ? <p className="whitespace-pre-wrap text-sm">{b.note}</p> : null}
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{new Date(b.snapshot.captured_at * 1000).toLocaleString("zh-CN")}
</span>
<div className="flex flex-wrap gap-1">
@@ -196,7 +204,7 @@ export function FindingTrafficPanel({
查看报文
</Button>
<Button variant="outline" size="sm" disabled={repeaterBusy} onClick={() => void seedRepeater(b.id)}>
送入 Repeater
送入请求重发
</Button>
{!readOnly ? (
<>
@@ -267,12 +275,6 @@ export function FindingTrafficPanel({
contextTask={contextTask}
onClose={() => setPreview(null)}
/>
<FindingRepeaterDialog
open={repeaterOpen}
onOpenChange={setRepeaterOpen}
initial={repeaterInitial}
findingLabel={`发现 #${findingId}`}
/>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
@@ -0,0 +1,218 @@
"use client";
import * as React from "react";
import { SendIcon } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/api";
import {
buildRawRequest,
buildRawResponse,
connectionOrigin,
parseHeadersText,
parseRawRequest,
} from "./repeater-http";
import { newId, type RepeaterTab } from "./repeater-state";
import { useRepeaterStore } from "./repeater-store";
import { RepeaterTextPane } from "./repeater-text-pane";
export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
const { useProxy, insecure, response } = tab;
const updateTab = useRepeaterStore((state) => state.updateTab);
const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined);
const [targetOpen, setTargetOpen] = React.useState(false);
const [targetDraft, setTargetDraft] = React.useState("");
const targetOrigin = tab.targetOrigin ?? (tab.rawRequest === undefined ? connectionOrigin(tab.url) : "");
const requestText = buildRawRequest(tab);
function changeRequest(rawRequest: string) {
try {
const { httpVersion: _version, ...parts } = parseRawRequest(rawRequest, targetOrigin);
updateTab(tab.id, { ...parts, rawRequest, targetOrigin });
} catch {
// Preserve incomplete first lines and headers as drafts without rewriting
// the text or moving the caret. Validate only when the user sends.
updateTab(tab.id, { rawRequest, targetOrigin });
}
}
const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy });
const setInsecure = (insecure: boolean) => updateTab(tab.id, { insecure });
async function send() {
const store = useRepeaterStore.getState();
if (store.inflight[tab.id] !== undefined) return;
let parsed: ReturnType<typeof parseRawRequest>;
try {
parsed = parseRawRequest(requestText, targetOrigin);
} catch (error) {
toast.error((error as Error).message);
return;
}
const { method, url: target, headersText, body, httpVersion } = parsed;
const requestGeneration = store.beginSend(tab.id);
if (requestGeneration === undefined) return;
try {
const result = await api.repeaterSend({
method,
http_version: httpVersion,
url: target,
headers: parseHeadersText(headersText),
body,
use_recording_proxy: useProxy,
insecure_skip_verify: insecure,
});
store.completeSend(tab.id, requestGeneration, {
id: newId(),
method,
url: target,
headersText,
body,
rawRequest: requestText,
targetOrigin,
useProxy,
insecure,
response: result,
at: new Date().toISOString(),
});
} catch (error) {
if (store.isCurrentSend(tab.id, requestGeneration)) toast.error((error as Error).message);
} finally {
store.clearInflight(tab.id, requestGeneration);
}
}
return (
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs" title={targetOrigin || tab.url}>
发送目标:{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"}
</span>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => {
setTargetDraft(targetOrigin || connectionOrigin(tab.url));
setTargetOpen(true);
}}
>
修改目标
</Button>
<Button disabled={busy} onClick={() => void send()}>
<SendIcon data-icon="inline-start" />
{busy ? "发送中…" : "发送"}
</Button>
</div>
<FieldSet className="flex-row flex-wrap items-center gap-4 text-sm">
<FieldLegend className="sr-only">发送选项</FieldLegend>
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-use-proxy">
<Checkbox
id="repeater-use-proxy"
disabled={busy}
checked={useProxy}
onCheckedChange={(value) => setUseProxy(value === true)}
/>
经流量代理
</FieldLabel>
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-insecure">
<Checkbox
id="repeater-insecure"
disabled={busy}
checked={insecure}
onCheckedChange={(value) => setInsecure(value === true)}
/>
跳过 TLS 校验
</FieldLabel>
{response ? (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
{response.status ? <Badge variant="secondary">{response.status_text || response.status}</Badge> : null}
<span>{response.elapsed_ms} ms</span>
<span>{response.body_bytes.toLocaleString()} 字节</span>
{response.body_truncated ? <Badge variant="outline">正文已截断</Badge> : null}
</div>
) : null}
</FieldSet>
<div className="grid min-h-0 flex-1 auto-rows-[minmax(300px,1fr)] gap-3 overflow-y-auto xl:auto-rows-auto xl:grid-cols-2">
<RepeaterTextPane
id="repeater-request"
label="请求"
value={requestText}
onChange={changeRequest}
disabled={busy}
placeholder="POST /path HTTP/1.1"
/>
<div className="flex min-h-[300px] min-w-0 flex-col gap-2 xl:min-h-0">
{response?.error ? (
<Alert variant={response.status ? "default" : "destructive"}>
<AlertDescription className="break-all font-mono text-xs">{response.error}</AlertDescription>
</Alert>
) : null}
<RepeaterTextPane
id="repeater-response"
label="响应"
value={response ? buildRawResponse(response) : ""}
placeholder="发送后显示原始响应"
/>
</div>
</div>
<Dialog open={targetOpen} onOpenChange={setTargetOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>修改发送目标</DialogTitle>
<DialogDescription>
设置连接地址和 HTTP/HTTPS。方法、路径和 Host 均在左侧报文中编辑;Host 可以与连接目标不同。
</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
const origin = targetDraft.trim() ? connectionOrigin(targetDraft.trim()) : "";
if (targetDraft.trim() && !origin) {
toast.error("请填写 http:// 或 https:// 开头的地址");
return;
}
try {
const { httpVersion: _version, ...parts } = parseRawRequest(requestText, origin);
updateTab(tab.id, { ...parts, rawRequest: requestText, targetOrigin: origin });
} catch {
updateTab(tab.id, { rawRequest: requestText, targetOrigin: origin });
}
setTargetOpen(false);
}}
>
<Field>
<FieldLabel htmlFor="repeater-target">发送目标</FieldLabel>
<Input
id="repeater-target"
placeholder="https://example.com:443,留空按 Host 连接"
value={targetDraft}
onChange={(event) => setTargetDraft(event.target.value)}
/>
</Field>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setTargetOpen(false)}>
取消
</Button>
<Button type="submit">保存目标</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</section>
);
}
@@ -0,0 +1,28 @@
import { buildRawResponse } from "./repeater-http";
import type { RepeaterTab } from "./repeater-state";
export type RepeaterFilters = { query: string; method: string; path: string; response: string; status: string };
export const DEFAULT_FILTERS: RepeaterFilters = { query: "", method: "all", path: "", response: "", status: "all" };
export function matchesRepeaterFilters(tab: RepeaterTab, filters: RepeaterFilters): boolean {
const includes = (text: string, query: string) => text.toLowerCase().includes(query.trim().toLowerCase());
if (filters.method !== "all" && tab.method.toUpperCase() !== filters.method) return false;
let path = tab.url;
try {
const url = new URL(tab.url);
path = url.pathname + url.search;
} catch {
/* Incomplete drafts remain searchable. */
}
if (!includes(path, filters.path)) return false;
const rawResponse = tab.response ? `${buildRawResponse(tab.response)}\n${tab.response.error || ""}` : "";
if (!includes(rawResponse, filters.response)) return false;
if (filters.status === "pending" && tab.response) return false;
if (filters.status === "error" && (!tab.response?.error || tab.response.status)) return false;
if (/^[1-5]xx$/.test(filters.status) && Math.floor((tab.response?.status || 0) / 100) !== Number(filters.status[0]))
return false;
return includes(
[tab.title, tab.url, tab.rawRequest || "", tab.headersText, tab.body, rawResponse].join("\n"),
filters.query,
);
}
@@ -0,0 +1,57 @@
"use client";
import { Button } from "@/components/ui/button";
import type { RepeaterTab } from "./repeater-state";
import { useRepeaterStore } from "./repeater-store";
export function RepeaterHistory({ tab }: { tab?: RepeaterTab }) {
const updateTab = useRepeaterStore((state) => state.updateTab);
const busy = useRepeaterStore((state) => (tab ? state.inflight[tab.id] !== undefined : false));
return (
<section aria-label="标签历史" className="flex min-h-20 flex-1 flex-col gap-2 overflow-hidden border-t pt-3">
<div className="flex items-center justify-between px-1 text-muted-foreground text-xs">
<h3 className="font-medium">标签历史</h3>
<span>{tab?.history.length ?? 0} 条</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto">
{!tab?.history.length ? (
<p className="px-1 text-muted-foreground text-xs">发送后会出现在这里</p>
) : (
tab.history.map((entry) => (
<Button
key={entry.id}
variant="ghost"
disabled={busy}
className="h-auto w-full shrink-0 flex-col items-start gap-1 px-2 py-2 text-left"
onClick={() =>
updateTab(tab.id, {
method: entry.method,
url: entry.url,
headersText: entry.headersText,
body: entry.body,
rawRequest: entry.rawRequest,
targetOrigin: entry.targetOrigin,
useProxy: entry.useProxy,
insecure: entry.insecure,
response: entry.response,
})
}
title={entry.url}
>
<span className="flex w-full items-center justify-between gap-2 font-mono text-xs">
<span>
{entry.method} {entry.response.status || "ERR"}
</span>
<time className="text-muted-foreground" dateTime={entry.at}>
{new Date(entry.at).toLocaleTimeString("zh-CN", { hour12: false })}
</time>
</span>
<span className="w-full truncate text-muted-foreground text-xs">{entry.url}</span>
</Button>
))
)}
</div>
</section>
);
}
@@ -0,0 +1,182 @@
import type { RepeaterSendResponse } from "@/lib/types";
export type RepeaterInitial = {
method?: string;
url?: string;
headersText?: string;
body?: string;
rawRequest?: string;
targetOrigin?: string;
};
export function parseHeadersText(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of text.replace(/\r\n?/g, "\n").split("\n")) {
const separator = line.indexOf(":");
if (separator <= 0) continue;
const name = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
if (!name) continue;
// The API accepts one value per name. Cookies use semicolon separators;
// other duplicate headers preserve the common comma-separated semantics.
const existingName = Object.keys(result).find((item) => item.toLowerCase() === name.toLowerCase());
if (!existingName) {
result[name] = value;
} else {
const separator = existingName.toLowerCase() === "cookie" ? "; " : ", ";
result[existingName] = `${result[existingName]}${separator}${value}`;
}
}
return result;
}
export function formatHeaders(headers: Record<string, string[]> | Record<string, string>): string {
return Object.entries(headers)
.flatMap(([name, value]) =>
Array.isArray(value) ? value.map((item) => `${name}: ${item}`) : [`${name}: ${value}`],
)
.join("\n");
}
export function buildRawResponse(response: RepeaterSendResponse): string {
if (!response.status) return "";
const statusLine = `${response.proto || "HTTP/1.1"} ${response.status_text || response.status}`.trim();
const headers = formatHeaders(response.headers);
return `${statusLine}\n${headers}\n\n${response.body}`;
}
export function headersFromReqHead(reqHead?: string): string {
if (!reqHead) return "";
const lines = reqHead.replace(/\r\n?/g, "\n").split("\n");
// Evidence heads normally start with `METHOD path HTTP/x.y`, but tolerate
// leading blank lines and CR-only captures from older traffic records.
let requestLine = -1;
for (let index = 0; index < lines.length; index += 1) {
const candidate = lines[index].trim();
if (!candidate) continue;
if (/^(?:[A-Z]+)\s+\S+\s+HTTP\/\d(?:\.\d)?$/i.test(candidate)) requestLine = index;
break;
}
const start = requestLine >= 0 ? requestLine + 1 : 0;
return lines.slice(start).join("\n").replace(/\n+$/, "");
}
export function getHostHeader(headersText: string): string {
const headers = parseHeadersText(headersText);
const name = Object.keys(headers).find((key) => key.toLowerCase() === "host");
return name ? headers[name] : "";
}
export function setHostHeader(headersText: string, host: string): string {
const lines = headersText.replace(/\r\n?/g, "\n").split("\n");
const first = lines.findIndex((line) => /^\s*host\s*:/i.test(line));
const value = host.replace(/[\r\n]/g, "").trim();
const result = lines.flatMap((line, index) => {
if (!/^\s*host\s*:/i.test(line)) return [line];
return index === first && value ? [`Host: ${value}`] : [];
});
if (first < 0 && value) result.unshift(`Host: ${value}`);
return result.join("\n");
}
// Split only the HTTP separator; preserve all body bytes represented by the text.
export function splitRequestText(text: string): { headersText: string; body: string } {
const separator = /\r?\n\r?\n/.exec(text);
if (!separator) return { headersText: text, body: "" };
return { headersText: text.slice(0, separator.index), body: text.slice(separator.index + separator[0].length) };
}
export function joinRequestText(headersText: string, body: string): string {
if (!headersText && !body) return "";
return `${headersText.replace(/(?:\r?\n)+$/, "")}\n\n${body}`;
}
export function requestFromTraffic(
raw: string,
exchange: { method: string; url: string; host: string },
): RepeaterInitial {
const firstLine = /^[^\r\n]+(?:\r?\n|$)/.exec(raw);
if (!firstLine || !/^[A-Z]+\s+\S+\s+HTTP\/\d(?:\.\d)?\r?\n?$/i.test(firstLine[0])) {
throw new Error("请求报文不完整,无法送入请求重发");
}
const request = splitRequestText(raw.slice(firstLine[0].length));
if (/…\[truncated\] @blob sha256:[a-f0-9]{64}/.test(request.body)) {
throw new Error("请求正文为截断预览,请下载完整正文后再导入");
}
let host = exchange.host;
try {
host = new URL(exchange.url).host || host;
} catch {
/* Legacy URL uses the indexed host. */
}
const headersText = getHostHeader(request.headersText)
? request.headersText
: setHostHeader(request.headersText, host);
const rawRequest = `${firstLine[0].trimEnd()}\n${joinRequestText(headersText, request.body)}`;
const parsed = parseRawRequest(rawRequest, connectionOrigin(exchange.url));
return {
method: parsed.method,
url: parsed.url,
headersText: parsed.headersText,
body: parsed.body,
rawRequest,
targetOrigin: connectionOrigin(exchange.url),
};
}
export function connectionOrigin(url: string): string {
try {
const parsed = new URL(url);
return /^https?:$/.test(parsed.protocol) ? parsed.origin : "";
} catch {
return "";
}
}
export function buildRawRequest(request: RepeaterInitial): string {
if (request.rawRequest !== undefined) return request.rawRequest;
let target = "/";
let host = "";
try {
const url = new URL(request.url ?? "");
target = url.pathname + url.search;
host = url.host;
} catch {
/* New empty draft. */
}
const headersText = request.headersText?.trim() ? request.headersText : `Host: ${host}`;
return `${request.method ?? "GET"} ${target} HTTP/1.1\n${joinRequestText(headersText, request.body ?? "")}`;
}
export function parseRawRequest(
text: string,
targetOrigin = "",
): { method: string; url: string; headersText: string; body: string; httpVersion: string } {
const newline = text.indexOf("\n");
const line = (newline < 0 ? text : text.slice(0, newline)).trim();
const match = /^([A-Za-z]+)\s+(\S+)\s+(HTTP\/(?:1\.[01]|2(?:\.0)?))$/.exec(line);
if (!match) throw new Error("请求首行应为:POST /path HTTP/1.1");
const parts = splitRequestText(newline < 0 ? "" : text.slice(newline + 1));
let url: URL;
if (/^https?:\/\//i.test(match[2])) url = new URL(match[2]);
else {
if (!match[2].startsWith("/")) throw new Error("请求路径必须以 / 开头,或填写完整 http/https URL");
const origin = connectionOrigin(targetOrigin) || connectionOrigin(`https://${getHostHeader(parts.headersText)}`);
if (!origin) throw new Error("请填写 Host 请求头或设置发送目标");
url = new URL(origin + match[2]);
}
if (url.hash) throw new Error("请求路径不能包含 URL 片段,请使用 %23 表示 #");
return {
...parts,
method: match[1].toUpperCase(),
url: url.href,
httpVersion: match[3] === "HTTP/2" ? "HTTP/2.0" : match[3],
};
}
export function requestFromEvidence(initial: RepeaterInitial, requestHead?: string): RepeaterInitial {
const firstLine = requestHead?.trimStart().split(/\r?\n/, 1)[0] || "";
if (!/^[A-Za-z]+\s+\S+\s+HTTP\/\d(?:\.\d)?$/.test(firstLine)) return initial;
const rawRequest = `${firstLine}\n${joinRequestText(initial.headersText ?? "", initial.body ?? "")}`;
return { ...initial, rawRequest, targetOrigin: connectionOrigin(initial.url ?? "") };
}
@@ -0,0 +1,350 @@
"use client";
import * as React from "react";
import { ChevronDown, ChevronRight, ListFilter, Pencil, Plus, X } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { DEFAULT_FILTERS, matchesRepeaterFilters } from "./repeater-filter";
import { RepeaterHistory } from "./repeater-history";
import type { RepeaterWorkspace } from "./repeater-state";
import { useRepeaterStore } from "./repeater-store";
type Rename = { kind: "tab" | "group"; id: string; name: string };
export function RepeaterNavigator({
workspace,
onRename,
}: {
workspace: RepeaterWorkspace;
onRename: (value: Rename) => void;
}) {
const store = useRepeaterStore();
const [filters, setFilters] = React.useState(DEFAULT_FILTERS);
const [expanded, setExpanded] = React.useState(false);
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
const [deleting, setDeleting] = React.useState(false);
const visible = React.useMemo(
() => workspace.tabs.filter((tab) => matchesRepeaterFilters(tab, filters)),
[workspace.tabs, filters],
);
const selectedIds = workspace.tabs.filter((tab) => selected.has(tab.id)).map((tab) => tab.id);
const visibleIds = new Set(visible.map((tab) => tab.id));
const hiddenSelected = selectedIds.filter((id) => !visibleIds.has(id)).length;
const filtering = Object.entries(filters).some(
([key, value]) => value !== DEFAULT_FILTERS[key as keyof typeof filters],
);
const allSelected = visible.length > 0 && visible.every((tab) => selected.has(tab.id));
const active = workspace.tabs.find((tab) => tab.id === workspace.activeTabId);
const methods = [
...new Set([
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS",
...workspace.tabs.map((tab) => tab.method.toUpperCase()),
]),
];
function toggle(id: string, checked: boolean) {
setSelected((current) => {
const next = new Set(current);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}
return (
<nav
aria-label="请求重发分组和标签"
className="flex h-[28rem] min-h-0 w-full shrink-0 flex-col gap-3 overflow-y-auto rounded-md border p-3 lg:h-[calc(100dvh-14rem)] lg:min-h-[480px] lg:w-80 xl:h-auto xl:min-h-0"
>
<div className="flex shrink-0 flex-col gap-2">
<Input
aria-label="全局搜索重发请求"
placeholder="搜索全部标题、请求或响应…"
value={filters.query}
onChange={(event) => setFilters({ ...filters, query: event.target.value })}
/>
<div className="flex gap-2">
<Select value={filters.method} onValueChange={(method) => setFilters({ ...filters, method })}>
<SelectTrigger aria-label="筛选请求方法" className="min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部方法</SelectItem>
{methods.map((method) => (
<SelectItem key={method} value={method}>
{method}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
variant={expanded ? "secondary" : "outline"}
size="sm"
aria-expanded={expanded}
aria-controls="repeater-extra-filters"
onClick={() => setExpanded(!expanded)}
>
<ListFilter />
筛选
</Button>
</div>
{expanded ? (
<div id="repeater-extra-filters" className="flex flex-col gap-2">
<Field className="gap-1">
<FieldLabel htmlFor="repeater-filter-path" className="text-xs">
请求路径
</FieldLabel>
<Input
id="repeater-filter-path"
placeholder="例如 /api/upload"
value={filters.path}
onChange={(event) => setFilters({ ...filters, path: event.target.value })}
/>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="repeater-filter-response" className="text-xs">
响应内容
</FieldLabel>
<Input
id="repeater-filter-response"
placeholder="响应头或正文关键字"
value={filters.response}
onChange={(event) => setFilters({ ...filters, response: event.target.value })}
/>
</Field>
<Select value={filters.status} onValueChange={(status) => setFilters({ ...filters, status })}>
<SelectTrigger aria-label="筛选响应状态" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部响应状态</SelectItem>
<SelectItem value="pending">尚未响应</SelectItem>
<SelectItem value="error">发送失败</SelectItem>
{["1xx", "2xx", "3xx", "4xx", "5xx"].map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
) : null}
<div className="flex items-center justify-between gap-2 text-muted-foreground text-xs">
<span>
{visible.length} / {workspace.tabs.length} 个请求
</span>
{filtering ? (
<Button variant="ghost" size="sm" className="h-6 px-1 text-xs" onClick={() => setFilters(DEFAULT_FILTERS)}>
清除筛选
</Button>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-col gap-2 border-y py-2">
<div className="flex items-center gap-2">
<Checkbox
id="repeater-select-visible"
aria-label="全选筛选结果"
checked={allSelected || (visible.some((tab) => selected.has(tab.id)) ? "indeterminate" : false)}
disabled={!visible.length}
onCheckedChange={(checked) =>
setSelected((current) => {
const next = new Set(current);
for (const tab of visible) {
if (checked === true) next.add(tab.id);
else next.delete(tab.id);
}
return next;
})
}
/>
<label htmlFor="repeater-select-visible" className="text-xs">
全选结果
</label>
<span className="ml-auto text-muted-foreground text-xs">
已选 {selectedIds.length} 条{hiddenSelected ? `(隐藏 ${hiddenSelected})` : ""}
</span>
{selectedIds.length ? (
<Button variant="ghost" size="icon-sm" aria-label="清空勾选" onClick={() => setSelected(new Set())}>
<X />
</Button>
) : null}
</div>
{selectedIds.length ? (
<div className="flex gap-2">
<Select
value=""
onValueChange={(groupId) => {
store.setTabsGroup(selectedIds, groupId);
toast.success(`已移动 ${selectedIds.length} 个请求`);
setSelected(new Set());
}}
>
<SelectTrigger aria-label="批量移动到分组" className="min-w-0 flex-1">
<SelectValue placeholder="移动到分组…" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{workspace.groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button variant="destructive" size="sm" onClick={() => setDeleting(true)}>
删除所选
</Button>
</div>
) : null}
</div>
<section className="min-h-24 flex-1 overflow-y-auto" aria-label="重发请求列表">
{workspace.groups.map((group) => {
const tabs = visible.filter((tab) => tab.groupId === group.id);
if (filtering && !tabs.length) return null;
const collapsed = group.collapsed && !filtering;
return (
<div key={group.id} className="flex flex-col gap-1">
<div className="flex min-w-0 items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`${collapsed ? "展开" : "折叠"}分组 ${group.name}`}
disabled={filtering}
onClick={() => store.toggleGroupCollapsed(group.id)}
>
{collapsed ? <ChevronRight /> : <ChevronDown />}
</Button>
<span className="min-w-0 flex-1 truncate font-medium text-sm" title={group.name}>
{group.name}
</span>
<span className="text-muted-foreground text-xs">{tabs.length}</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={`重命名分组 ${group.name}`}
onClick={() => onRename({ kind: "group", id: group.id, name: group.name })}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`新建标签 · ${group.name}`}
title="新建标签"
onClick={() => store.createTab({}, { groupId: group.id })}
>
<Plus />
</Button>
</div>
{!collapsed
? tabs.map((tab) => (
<div
key={tab.id}
className={cn(
"flex min-w-0 items-center gap-1 rounded-md px-1",
active?.id === tab.id && "bg-accent",
)}
>
<Checkbox
aria-label={`勾选请求 ${tab.title}`}
checked={selected.has(tab.id)}
onCheckedChange={(checked) => toggle(tab.id, checked === true)}
/>
<Button
variant="ghost"
size="sm"
aria-current={active?.id === tab.id ? "page" : undefined}
className="min-w-0 flex-1 justify-start px-1"
onClick={() => store.setActiveTab(tab.id)}
onDoubleClick={() => onRename({ kind: "tab", id: tab.id, name: tab.title })}
title={`${tab.title}\n${tab.url}`}
>
<span className="truncate">{tab.title}</span>
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`重命名标签 ${tab.title}`}
onClick={() => onRename({ kind: "tab", id: tab.id, name: tab.title })}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`关闭标签 ${tab.title}`}
onClick={() => store.closeTab(tab.id)}
>
<X />
</Button>
</div>
))
: null}
</div>
);
})}
{!visible.length ? (
<p className="p-2 text-muted-foreground text-xs">没有匹配的请求,清除筛选后可查看全部。</p>
) : null}
</section>
<div className="flex max-h-[35%] min-h-20 flex-1 flex-col">
<RepeaterHistory tab={active} />
</div>
<AlertDialog open={deleting} onOpenChange={setDeleting}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除所选的 {selectedIds.length} 个请求?</AlertDialogTitle>
<AlertDialogDescription>
这些请求的草稿和历史响应将从当前浏览器删除
{hiddenSelected ? `,其中包含 ${hiddenSelected} 个被筛选隐藏的请求` : ""}
。正在发送的请求将不再保存返回结果。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={!selectedIds.length}
onClick={() => {
store.closeTabs(selectedIds);
toast.success(`已删除 ${selectedIds.length} 个请求`);
setSelected(new Set());
}}
>
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</nav>
);
}
@@ -0,0 +1,251 @@
import type { RepeaterSendResponse } from "@/lib/types";
import type { RepeaterInitial } from "./repeater-http";
export const STORAGE_KEY = "artex_repeater_workspace_v1";
export const MAX_HISTORY = 20;
export const MAX_RESPONSE_CHARS = 256 * 1024;
// Count UTF-16 storage bytes (two per code unit), reserving headroom below 5 MiB
// for the storage key and other browser preferences.
export const MAX_STORAGE_BYTES = 4 * 1024 * 1024;
export const DEFAULT_GROUP_ID = "default";
export type RepeaterHistoryEntry = {
id: string;
method: string;
url: string;
headersText: string;
body: string;
rawRequest?: string;
targetOrigin?: string;
useProxy: boolean;
insecure: boolean;
response: RepeaterSendResponse;
at: string;
};
export type RepeaterTab = Omit<RepeaterHistoryEntry, "at" | "response"> & {
title: string;
groupId: string;
response: RepeaterSendResponse | null;
history: RepeaterHistoryEntry[];
source?: { findingId?: string; bindingId?: string; label?: string };
updatedAt: string;
};
export type RepeaterGroup = { id: string; name: string; collapsed?: boolean };
export type RepeaterWorkspace = {
version: 1;
groups: RepeaterGroup[];
tabs: RepeaterTab[];
activeTabId: string | null;
};
export type TabSeed = Partial<
Pick<
RepeaterTab,
"method" | "url" | "headersText" | "body" | "rawRequest" | "targetOrigin" | "useProxy" | "insecure" | "source"
>
>;
export type TabOptions = { groupId?: string; title?: string; activate?: boolean };
let fallbackIdSequence = 0;
export function newId(): string {
// Local workspace IDs must also work on HTTP LAN origins without randomUUID.
return (
globalThis.crypto?.randomUUID?.() ??
`repeater-${Date.now().toString(36)}-${(++fallbackIdSequence).toString(36)}-${Math.random().toString(36).slice(2, 10)}`
);
}
export function emptyTab(groupId = DEFAULT_GROUP_ID, title = "Tab 1"): RepeaterTab {
return {
id: newId(),
title,
groupId,
method: "GET",
url: "",
headersText: "",
body: "",
useProxy: true,
insecure: false,
response: null,
history: [],
updatedAt: new Date().toISOString(),
};
}
export function defaultWorkspace(): RepeaterWorkspace {
const tab = emptyTab();
return { version: 1, groups: [{ id: DEFAULT_GROUP_ID, name: "默认" }], tabs: [tab], activeTabId: tab.id };
}
export function addTab(workspace: RepeaterWorkspace, seed: TabSeed = {}, opts: TabOptions = {}): RepeaterWorkspace {
const groupId = workspace.groups.some((group) => group.id === opts.groupId) ? opts.groupId : DEFAULT_GROUP_ID;
const definedSeed = Object.fromEntries(Object.entries(seed).filter(([, value]) => value !== undefined));
const tab = { ...emptyTab(groupId, opts.title?.trim() || `Tab ${workspace.tabs.length + 1}`), ...definedSeed };
return {
...workspace,
groups: workspace.groups.map((group) =>
group.id === groupId && opts.activate !== false ? { ...group, collapsed: false } : group,
),
tabs: [...workspace.tabs, tab],
activeTabId: opts.activate === false ? workspace.activeTabId : tab.id,
};
}
export function seedWorkspace(
workspace: RepeaterWorkspace,
initial: RepeaterInitial,
meta: RepeaterTab["source"] = {},
opts: TabOptions = {},
): RepeaterWorkspace {
const method = initial.method?.trim().toUpperCase() || "GET";
let host = "";
try {
host = new URL(initial.url ?? "").host;
} catch {
/* An empty/unfinished URL is a valid draft. */
}
return addTab(
workspace,
{ ...initial, method, source: meta },
{ ...opts, title: opts.title ?? meta.label ?? `${method} ${host}`.trim() },
);
}
export function truncateResponse(response: RepeaterSendResponse, limit = MAX_RESPONSE_CHARS): RepeaterSendResponse {
return {
...response,
body: response.body.slice(0, limit),
body_truncated: response.body_truncated || response.body.length > limit,
};
}
export function serializeWorkspace(workspace: RepeaterWorkspace, maxBytes = MAX_STORAGE_BYTES): string {
const saved: RepeaterWorkspace = {
...workspace,
tabs: workspace.tabs.map((tab) => ({
...tab,
response: tab.response ? truncateResponse(tab.response) : null,
history: tab.history
.slice(0, MAX_HISTORY)
.map((entry) => ({ ...entry, response: truncateResponse(entry.response) })),
})),
};
let json = JSON.stringify(saved);
const size = () => json.length * 2;
// Remove the oldest send globally, including its request snapshot, first.
const oldest = saved.tabs
.flatMap((tab) => tab.history.map((entry) => ({ tab, entry })))
.sort((a, b) => a.entry.at.localeCompare(b.entry.at));
for (const { tab, entry } of oldest) {
if (size() <= maxBytes) return json;
tab.history = tab.history.filter((item) => item !== entry);
json = JSON.stringify(saved);
}
for (const tab of [...saved.tabs].sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))) {
if (size() <= maxBytes) return json;
if (tab.response) tab.response = truncateResponse(tab.response, 0);
json = JSON.stringify(saved);
}
// Drafts are never silently discarded, even when drafts alone exceed the soft budget.
return json;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isResponse(value: unknown): value is RepeaterSendResponse {
if (!isRecord(value)) return false;
return (
["status", "body_bytes", "elapsed_ms"].every((key) => typeof value[key] === "number") &&
["status_text", "proto", "body", "url"].every((key) => typeof value[key] === "string") &&
typeof value.body_truncated === "boolean" &&
isRecord(value.headers) &&
Object.values(value.headers).every(
(items) => Array.isArray(items) && items.every((item) => typeof item === "string"),
) &&
(value.error === undefined || typeof value.error === "string")
);
}
function isRequest(
value: unknown,
): value is Record<string, unknown> &
Pick<RepeaterHistoryEntry, "id" | "method" | "url" | "headersText" | "body" | "useProxy" | "insecure"> {
return (
isRecord(value) &&
["id", "method", "url", "headersText", "body"].every((key) => typeof value[key] === "string") &&
(value.rawRequest === undefined || typeof value.rawRequest === "string") &&
(value.targetOrigin === undefined || typeof value.targetOrigin === "string") &&
typeof value.useProxy === "boolean" &&
typeof value.insecure === "boolean"
);
}
export function deserializeWorkspace(raw: string | null): RepeaterWorkspace {
try {
const value: unknown = JSON.parse(raw ?? "null");
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.groups) || !Array.isArray(value.tabs))
return defaultWorkspace();
const groups: RepeaterGroup[] = [];
for (const group of value.groups) {
if (
!isRecord(group) ||
typeof group.id !== "string" ||
!group.id ||
typeof group.name !== "string" ||
!group.name.trim() ||
groups.some((item) => item.id === group.id)
)
return defaultWorkspace();
groups.push({
id: group.id,
name: group.name,
...(typeof group.collapsed === "boolean" ? { collapsed: group.collapsed } : {}),
});
}
if (!groups.some((group) => group.id === DEFAULT_GROUP_ID)) groups.unshift({ id: DEFAULT_GROUP_ID, name: "默认" });
const tabs: RepeaterTab[] = [];
for (const tab of value.tabs) {
if (
!isRequest(tab) ||
!isRecord(tab) ||
!tab.id ||
typeof tab.title !== "string" ||
typeof tab.groupId !== "string" ||
typeof tab.updatedAt !== "string" ||
!Array.isArray(tab.history) ||
tabs.some((item) => item.id === tab.id) ||
(tab.response !== null && !isResponse(tab.response))
)
return defaultWorkspace();
if (
!tab.history.every(
(entry) => isRequest(entry) && isRecord(entry) && typeof entry.at === "string" && isResponse(entry.response),
)
)
return defaultWorkspace();
const source = isRecord(tab.source)
? {
findingId: typeof tab.source.findingId === "string" ? tab.source.findingId : undefined,
bindingId: typeof tab.source.bindingId === "string" ? tab.source.bindingId : undefined,
label: typeof tab.source.label === "string" ? tab.source.label : undefined,
}
: undefined;
tabs.push({
...tab,
source,
groupId: groups.some((group) => group.id === tab.groupId) ? tab.groupId : DEFAULT_GROUP_ID,
history: tab.history.slice(0, MAX_HISTORY),
} as RepeaterTab);
}
if (!tabs.length) tabs.push(emptyTab());
return {
version: 1,
groups,
tabs,
activeTabId: tabs.some((tab) => tab.id === value.activeTabId) ? (value.activeTabId as string) : tabs[0].id,
};
} catch {
return defaultWorkspace();
}
}
@@ -0,0 +1,584 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import test from "node:test";
// Keep the tests runnable on Node 20 as well as versions with native TS support.
const require = createRequire(import.meta.url);
require("ts-node").register({
transpileOnly: true,
compilerOptions: { module: "CommonJS", moduleResolution: "Node10" },
});
const {
addTab,
defaultWorkspace,
deserializeWorkspace,
MAX_HISTORY,
MAX_RESPONSE_CHARS,
MAX_STORAGE_BYTES,
newId,
seedWorkspace,
serializeWorkspace,
truncateResponse,
} = require("./repeater-state.ts");
const {
requestFromEvidence,
buildRawRequest,
parseRawRequest,
joinRequestText,
splitRequestText,
requestFromTraffic,
buildRawResponse,
getHostHeader,
headersFromReqHead,
parseHeadersText,
setHostHeader,
} = require("./repeater-http.ts");
const { DEFAULT_FILTERS, matchesRepeaterFilters } = require("./repeater-filter.ts");
const { flush, useRepeaterStore } = require("./repeater-store.ts");
const { toast } = require("sonner");
const response = (body = "ok") => ({
status: 200,
status_text: "200 OK",
proto: "HTTP/1.1",
headers: { "Content-Type": ["text/plain"] },
body,
body_truncated: false,
body_bytes: body.length,
elapsed_ms: 12,
url: "https://example.test/",
});
const entry = (id, body = "ok") => ({
id: String(id),
method: "POST",
url: "https://example.test/",
headersText: "Host: example.test",
body: "draft",
useProxy: true,
insecure: false,
response: response(body),
at: new Date(id * 1000).toISOString(),
});
test("response body is capped at 256 Ki characters without mutating the live response", () => {
const original = response("x".repeat(MAX_RESPONSE_CHARS + 1));
const truncated = truncateResponse(original);
assert.equal(truncated.body.length, MAX_RESPONSE_CHARS);
assert.equal(truncated.body_truncated, true);
assert.equal(truncated.body_bytes, MAX_RESPONSE_CHARS + 1);
assert.equal(original.body.length, MAX_RESPONSE_CHARS + 1);
assert.equal(original.body_truncated, false);
assert.equal(truncateResponse(response()).body_truncated, false);
});
test("persisted response bodies and histories are capped; live state is unchanged", () => {
const workspace = defaultWorkspace();
const tab = workspace.tabs[0];
tab.response = response("x".repeat(MAX_RESPONSE_CHARS + 1));
tab.history = Array.from({ length: 25 }, (_, index) => entry(25 - index));
tab.history[0].response = tab.response;
const saved = JSON.parse(serializeWorkspace(workspace));
assert.equal(saved.tabs[0].history.length, MAX_HISTORY);
assert.equal(saved.tabs[0].history[0].id, "25");
assert.equal(saved.tabs[0].response.body.length, MAX_RESPONSE_CHARS);
assert.equal(saved.tabs[0].history[0].response.body.length, MAX_RESPONSE_CHARS);
assert.equal(tab.history.length, 25);
});
test("serialize/deserialize preserves drafts, groups, active tab, source and response", () => {
let workspace = defaultWorkspace();
workspace.groups.push({ id: "other", name: "验证", collapsed: true });
workspace = seedWorkspace(
workspace,
{ method: "post", url: "https://example.test/path", body: "payload", headersText: "Host: override" },
{ findingId: "42", bindingId: "2", label: "proof" },
{ groupId: "other" },
);
workspace.tabs[1].response = response();
workspace.tabs[1].history = [entry(1)];
assert.deepEqual(JSON.parse(JSON.stringify(deserializeWorkspace(serializeWorkspace(workspace)))), workspace);
});
test("missing, corrupt and malformed storage recover a default group and empty tab", () => {
const malformed = defaultWorkspace();
malformed.tabs[0].response = { body: "bad" };
for (const raw of [
null,
"",
"{",
"null",
"[]",
'{"version":2}',
'{"version":1,"groups":[],"tabs":[{}]}',
JSON.stringify(malformed),
]) {
const recovered = deserializeWorkspace(raw);
assert.equal(recovered.version, 1);
assert.equal(recovered.groups[0].name, "默认");
assert.equal(recovered.tabs.length, 1);
assert.equal(recovered.tabs[0].title, "Tab 1");
assert.equal(recovered.tabs[0].url, "");
assert.equal(recovered.activeTabId, recovered.tabs[0].id);
}
});
test("seeding always creates a new active tab in default or specified group", () => {
const initial = defaultWorkspace();
const seeded = seedWorkspace(initial, { method: "post", url: "https://example.test:8443/path" });
assert.equal(initial.tabs.length, 1);
assert.equal(seeded.tabs.length, 2);
assert.equal(seeded.tabs[1].groupId, "default");
assert.equal(seeded.tabs[1].title, "POST example.test:8443");
assert.equal(seeded.activeTabId, seeded.tabs[1].id);
seeded.groups.push({ id: "custom", name: "验证", collapsed: true });
const custom = seedWorkspace(seeded, {}, { label: "证据" }, { groupId: "custom" });
assert.equal(custom.tabs[2].groupId, "custom");
assert.equal(custom.tabs[2].title, "证据");
assert.equal(custom.groups[1].collapsed, false);
const background = addTab(custom, {}, { activate: false, groupId: "missing" });
assert.equal(background.activeTabId, custom.activeTabId);
assert.equal(background.tabs[3].groupId, "default");
});
test("budget removes globally oldest history first then strips responses, preserving drafts", () => {
const workspace = addTab(defaultWorkspace());
workspace.tabs[0].history = [entry(3), entry(1)];
workspace.tabs[1].history = [entry(4), entry(2)];
const limit = (serializeWorkspace(workspace).length - JSON.stringify(entry(1)).length - 1) * 2;
const saved = JSON.parse(serializeWorkspace(workspace, limit));
assert.deepEqual(
saved.tabs.map((tab) => tab.history.map((item) => item.id)),
[["3"], ["4", "2"]],
);
workspace.tabs[0].response = response("中".repeat(MAX_RESPONSE_CHARS));
workspace.tabs[1].response = response("x".repeat(MAX_RESPONSE_CHARS));
workspace.tabs[0].body = "keep this request";
const stripped = JSON.parse(serializeWorkspace(workspace, 3000));
assert.equal(stripped.tabs[0].body, "keep this request");
assert.equal(stripped.tabs[0].response.body, "");
assert.equal(stripped.tabs[0].response.body_truncated, true);
assert.equal(workspace.tabs[0].history.length, 2);
let large = defaultWorkspace();
for (let id = 0; id < 5; id++) large = addTab(large);
for (const tab of large.tabs) {
tab.response = response("中".repeat(MAX_RESPONSE_CHARS));
tab.history = [entry(1, "中".repeat(MAX_RESPONSE_CHARS))];
}
assert.ok(serializeWorkspace(large).length * 2 <= MAX_STORAGE_BYTES);
});
test("storage cap counts UTF-16 bytes for ASCII, Chinese and surrogate pairs", () => {
for (const text of ["x", "中", "😀"]) {
let workspace = defaultWorkspace();
for (let index = 0; index < 5; index++) workspace = addTab(workspace);
for (const tab of workspace.tabs) {
const body = text.repeat(MAX_RESPONSE_CHARS).slice(0, MAX_RESPONSE_CHARS);
tab.response = response(body);
tab.history = [entry(1, body)];
tab.body = `保留草稿 ${text}`;
}
assert.ok(JSON.stringify(workspace).length * 2 > MAX_STORAGE_BYTES);
const json = serializeWorkspace(workspace);
assert.ok(json.length * 2 <= MAX_STORAGE_BYTES);
assert.deepEqual(
JSON.parse(json).tabs.map((tab) => tab.body),
workspace.tabs.map((tab) => tab.body),
);
}
});
test("drafts exceeding the soft UTF-16 budget are never discarded", () => {
const workspace = defaultWorkspace();
workspace.tabs[0].body = "x".repeat(MAX_STORAGE_BYTES / 2);
workspace.tabs[0].response = response("previous");
workspace.tabs[0].history = [entry(1)];
const json = serializeWorkspace(workspace);
const saved = JSON.parse(json);
assert.ok(json.length * 2 > MAX_STORAGE_BYTES);
assert.equal(saved.tabs[0].body, workspace.tabs[0].body);
assert.deepEqual(saved.tabs[0].history, []);
assert.equal(saved.tabs[0].response.body, "");
});
function setupStore(t) {
const previousWindow = globalThis.window;
const writes = [];
globalThis.window = {
localStorage: {
setItem: (key, value) => writes.push({ key, value }),
},
};
useRepeaterStore.setState({ workspace: addTab(defaultWorkspace()), inflight: {} });
t.after(() => {
useRepeaterStore.setState({ workspace: null, inflight: {} });
flush(); // Cancel pending persistence without writing to another test's storage.
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
return { store: useRepeaterStore.getState(), writes };
}
test("a second send invalidates only that tab's first send and stale cleanup cannot clear it", (t) => {
const { store } = setupStore(t);
const [first, other] = store.workspace.tabs;
store.recordSend(first.id, entry(1, "previous"));
const generation1 = store.beginSend(first.id);
const otherGeneration = store.beginSend(other.id);
const generation2 = store.beginSend(first.id);
assert.notEqual(generation1, generation2);
assert.equal(store.isCurrentSend(first.id, generation1), false);
assert.equal(store.isCurrentSend(first.id, generation2), true);
assert.equal(store.isCurrentSend(other.id, otherGeneration), true);
store.completeSend(first.id, generation1, entry(2, "stale"));
store.clearInflight(first.id, generation1);
assert.equal(useRepeaterStore.getState().inflight[first.id], generation2);
assert.equal(useRepeaterStore.getState().workspace.tabs[0].response.body, "previous");
store.completeSend(first.id, generation2, entry(3, "newest"));
const state = useRepeaterStore.getState();
assert.equal(state.workspace.tabs[0].response.body, "newest");
assert.deepEqual(
state.workspace.tabs[0].history.map((item) => item.id),
["3", "1"],
);
assert.equal(state.inflight[first.id], undefined);
assert.equal(state.inflight[other.id], otherGeneration);
});
test("closing a tab or clearing the workspace drops pending results and history", (t) => {
const { store } = setupStore(t);
const [closed, other] = store.workspace.tabs;
const generation = store.beginSend(closed.id);
const otherGeneration = store.beginSend(other.id);
store.closeTab(closed.id);
assert.equal(store.isCurrentSend(closed.id, generation), false);
assert.equal(store.beginSend(closed.id), undefined);
store.completeSend(closed.id, generation, entry(1));
assert.equal(useRepeaterStore.getState().inflight[closed.id], undefined);
assert.equal(useRepeaterStore.getState().workspace.tabs.length, 1);
assert.deepEqual(useRepeaterStore.getState().workspace.tabs[0].history, []);
assert.equal(store.isCurrentSend(other.id, otherGeneration), true);
store.clearWorkspace();
store.completeSend(other.id, otherGeneration, entry(2));
assert.deepEqual(useRepeaterStore.getState().inflight, {});
assert.deepEqual(useRepeaterStore.getState().workspace.tabs[0].history, []);
});
test("busy state survives remount and successful completion records without an editor mounted", async (t) => {
const { store, writes } = setupStore(t);
const [tab, other] = store.workspace.tabs;
store.recordSend(tab.id, entry(1, "previous"));
const observedBusy = [];
const unmount = useRepeaterStore.subscribe((state) => observedBusy.push(state.inflight[tab.id] !== undefined));
const generation = store.beginSend(tab.id);
let resolveResponse;
const pending = new Promise((resolve) => {
resolveResponse = resolve;
}).then((result) => store.completeSend(tab.id, generation, result));
unmount();
store.setActiveTab(other.id);
store.setActiveTab(tab.id);
// A remounted editor reads the same store selector to disable duplicate sends.
assert.equal(useRepeaterStore.getState().inflight[tab.id] !== undefined, true);
assert.equal(useRepeaterStore.getState().workspace.tabs[0].response.body, "previous");
store.setActiveTab(other.id);
resolveResponse(entry(2, "completed offscreen"));
await pending;
const state = useRepeaterStore.getState();
assert.deepEqual(observedBusy, [true]);
assert.equal(state.inflight[tab.id], undefined);
assert.equal(state.workspace.activeTabId, other.id);
assert.equal(state.workspace.tabs[0].response.body, "completed offscreen");
assert.deepEqual(
state.workspace.tabs[0].history.map((item) => item.id),
["2", "1"],
);
assert.equal(JSON.parse(writes.at(-1).value).tabs[0].history[0].id, "2");
});
test("failed send cleanup preserves the previous response and history", (t) => {
const { store } = setupStore(t);
const tab = store.workspace.tabs[0];
store.recordSend(tab.id, entry(1, "previous"));
const generation = store.beginSend(tab.id);
store.clearInflight(tab.id, generation);
const state = useRepeaterStore.getState();
assert.equal(state.inflight[tab.id], undefined);
assert.equal(state.workspace.tabs[0].response.body, "previous");
assert.deepEqual(
state.workspace.tabs[0].history.map((item) => item.id),
["1"],
);
});
test("failed storage write retries with histories and response bodies removed, preserving live data and drafts", (t) => {
const { store } = setupStore(t);
const tab = store.workspace.tabs[0];
store.updateTab(tab.id, { body: "keep this draft" });
store.recordSend(tab.id, entry(1, "previous response"));
const attempts = [];
t.mock.method(globalThis.window.localStorage, "setItem", (_key, value) => {
attempts.push(value);
if (attempts.length === 1) throw new DOMException("full", "QuotaExceededError");
});
const error = t.mock.method(toast, "error", () => "test-toast");
flush();
assert.equal(attempts.length, 2);
assert.ok(attempts[1].length < attempts[0].length);
const saved = JSON.parse(attempts[1]).tabs[0];
assert.equal(saved.body, "keep this draft");
assert.deepEqual(saved.history, []);
assert.equal(saved.response.body, "");
assert.equal(saved.response.body_truncated, true);
assert.equal(useRepeaterStore.getState().workspace.tabs[0].response.body, "previous response");
assert.equal(useRepeaterStore.getState().workspace.tabs[0].history.length, 1);
assert.equal(error.mock.callCount(), 0);
});
test("two failed storage writes report save failure through a Chinese error toast", (t) => {
const { store } = setupStore(t);
const tab = store.workspace.tabs[0];
store.updateTab(tab.id, { body: "unsaved draft" });
const setItem = t.mock.method(globalThis.window.localStorage, "setItem", () => {
throw new DOMException("full", "QuotaExceededError");
});
const error = t.mock.method(toast, "error", () => "test-toast");
flush();
assert.equal(setItem.mock.callCount(), 2);
assert.equal(error.mock.callCount(), 1);
assert.match(error.mock.calls[0].arguments[0], /工作区保存失败/);
assert.equal(useRepeaterStore.getState().workspace.tabs[0].body, "unsaved draft");
});
test("Host shortcut replaces case-insensitive duplicates and preserves other header lines", () => {
const headers = "X-Test: one\r\nhOsT: old.test\r\nCookie: a=1\r\nHost: duplicate.test\r\nX-Test: two";
const updated = setHostHeader(headers, "new.test:8080");
assert.equal(updated, "X-Test: one\nHost: new.test:8080\nCookie: a=1\nX-Test: two");
assert.equal(getHostHeader(updated), "new.test:8080");
assert.equal(getHostHeader("HOST: manual.test"), "manual.test");
assert.equal(setHostHeader(updated, ""), "X-Test: one\nCookie: a=1\nX-Test: two");
assert.equal(setHostHeader("Accept: */*", "new.test"), "Host: new.test\nAccept: */*");
assert.equal(getHostHeader(setHostHeader("", "evil\r\nHeader: injected")), "evilHeader: injected");
assert.deepEqual(parseHeadersText(updated), { "X-Test": "one, two", Host: "new.test:8080", Cookie: "a=1" });
});
test("migrated HTTP helpers retain raw request and response formatting", () => {
assert.equal(headersFromReqHead("\r\nGET / HTTP/1.1\r\nHost: example.test\r\n"), "Host: example.test");
assert.equal(buildRawResponse(response()), "HTTP/1.1 200 OK\nContent-Type: text/plain\n\nok");
});
test("workspace initializes and adds distinct tabs on HTTP origins without randomUUID", (t) => {
const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, "crypto");
Object.defineProperty(globalThis, "crypto", { configurable: true, value: {} });
t.after(() => {
if (originalCrypto) Object.defineProperty(globalThis, "crypto", originalCrypto);
else delete globalThis.crypto;
});
const workspace = defaultWorkspace();
const updated = addTab(workspace, { url: "https://example.test/" });
assert.equal(updated.tabs.length, 2);
assert.notEqual(updated.tabs[0].id, updated.tabs[1].id);
assert.equal(deserializeWorkspace(null).tabs.length, 1);
assert.equal(new Set(Array.from({ length: 100 }, () => newId())).size, 100);
});
test("fallback IDs stay distinct without crypto even when clock and randomness repeat", (t) => {
const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, "crypto");
Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined });
t.after(() => {
if (originalCrypto) Object.defineProperty(globalThis, "crypto", originalCrypto);
else delete globalThis.crypto;
});
t.mock.method(Date, "now", () => 1700000000000);
t.mock.method(Math, "random", () => 0);
const ids = Array.from({ length: 100 }, () => newId());
assert.ok(ids.every((id) => typeof id === "string" && id.length > 0));
assert.equal(new Set(ids).size, ids.length);
});
test("secure origins retain native UUID generation", (t) => {
const native = t.mock.method(globalThis.crypto, "randomUUID", () => "native-uuid");
assert.equal(newId(), "native-uuid");
assert.equal(native.mock.callCount(), 1);
});
test("combined request editing preserves body newlines and splits only the first HTTP separator", () => {
const body = '\n{\r\n "a": 1\r\n}\n\n';
const text = joinRequestText("Host: example.test\nContent-Type: application/json", body);
assert.deepEqual(splitRequestText(text), { headersText: "Host: example.test\nContent-Type: application/json", body });
assert.deepEqual(splitRequestText("Host: example.test\n"), { headersText: "Host: example.test\n", body: "" });
assert.deepEqual(splitRequestText("Host: example.test\r\n\r\nhello\r\n\r\nworld"), {
headersText: "Host: example.test",
body: "hello\r\n\r\nworld",
});
assert.deepEqual(splitRequestText(joinRequestText("", "body only")), { headersText: "", body: "body only" });
assert.equal(joinRequestText("", ""), "");
});
test("traffic import uses captured metadata, preserves Host overrides and the full body", () => {
const flow = { method: "POST", url: "https://example.test:8443/upload?q=1", host: "example.test" };
const raw = "POST /upload?q=1 HTTP/1.1\r\nHost: override.test\r\nCookie: a=b\r\n\r\nfirst\r\n\r\nlast\n";
const imported = requestFromTraffic(raw, flow);
assert.equal(imported.method, "POST");
assert.equal(imported.url, flow.url);
assert.equal(imported.headersText, "Host: override.test\r\nCookie: a=b");
assert.equal(imported.body, "first\r\n\r\nlast\n");
assert.match(imported.rawRequest, /^POST \/upload\?q=1 HTTP\/1.1\n/);
assert.equal(imported.targetOrigin, "https://example.test:8443");
assert.equal(
requestFromTraffic("POST /upload HTTP/1.1\nContent-Type: text/plain\n\nhello", flow).headersText,
"Host: example.test:8443\nContent-Type: text/plain",
);
});
test("traffic import rejects failed loads and blob preview markers rather than sending corrupted bodies", () => {
const flow = { method: "POST", url: "https://example.test/", host: "example.test" };
assert.throws(() => requestFromTraffic("(无法加载报文)", flow), /不完整/);
assert.throws(
() =>
requestFromTraffic(
`POST / HTTP/1.1\nHost: example.test\n\npreview\n…[truncated] @blob sha256:${"a".repeat(64)} (len=9999)`,
flow,
),
/截断预览/,
);
});
test("global replay filters combine title, request, response, method, path and status", () => {
const tab = {
...defaultWorkspace().tabs[0],
title: "上传测试",
method: "POST",
url: "https://example.test/api/upload?q=1",
headersText: "X-Test: RequestSecret",
body: "request-body",
response: response("ResponseSecret"),
};
const match = (patch) => matchesRepeaterFilters(tab, { ...DEFAULT_FILTERS, ...patch });
assert.equal(match({ query: "上传" }), true);
assert.equal(match({ query: "requestsecret" }), true);
assert.equal(match({ query: "responseSECRET" }), true);
assert.equal(match({ method: "POST", path: "/api/upload", response: "ResponseSecret", status: "2xx" }), true);
assert.equal(match({ method: "GET" }), false);
assert.equal(match({ path: "example.test" }), false);
assert.equal(match({ status: "4xx" }), false);
assert.equal(match({ response: "missing" }), false);
assert.equal(match({ status: "pending" }), false);
assert.equal(matchesRepeaterFilters({ ...tab, response: null }, { ...DEFAULT_FILTERS, status: "pending" }), true);
assert.equal(
matchesRepeaterFilters(
{ ...tab, response: { ...response(), status: 0, error: "connection refused" } },
{ ...DEFAULT_FILTERS, status: "error", response: "refused" },
),
true,
);
});
test("bulk move preserves draft, response and inflight requests and persists the whole selection atomically", (t) => {
const { store, writes } = setupStore(t);
const [first, other] = store.workspace.tabs;
store.recordSend(first.id, entry(1, "original response"));
store.createGroup("目标组");
const groupId = useRepeaterStore.getState().workspace.groups[1].id;
store.toggleGroupCollapsed(groupId);
const generation = store.beginSend(first.id);
const beforeWrites = writes.length;
store.setTabsGroup([first.id, other.id], groupId);
const state = useRepeaterStore.getState();
assert.equal(
state.workspace.tabs.every((tab) => tab.groupId === groupId),
true,
);
assert.equal(state.workspace.tabs[0].response.body, "original response");
assert.equal(state.workspace.activeTabId, other.id);
assert.equal(state.workspace.groups[1].collapsed, false);
assert.equal(store.isCurrentSend(first.id, generation), true);
assert.equal(writes.length, beforeWrites + 1);
store.setTabsGroup([first.id], "missing-group");
assert.equal(useRepeaterStore.getState().workspace.tabs[0].groupId, groupId);
});
test("bulk delete invalidates only removed requests and retains an editable tab after deleting everything", (t) => {
const { store } = setupStore(t);
const [first, other] = store.workspace.tabs;
const thirdId = store.createTab({ body: "keep me" });
const firstGeneration = store.beginSend(first.id);
const otherGeneration = store.beginSend(other.id);
const thirdGeneration = store.beginSend(thirdId);
store.setActiveTab(first.id);
store.closeTabs([first.id, other.id]);
store.completeSend(first.id, firstGeneration, entry(1, "late"));
store.completeSend(other.id, otherGeneration, entry(2, "late"));
let state = useRepeaterStore.getState();
assert.deepEqual(
state.workspace.tabs.map((tab) => tab.id),
[thirdId],
);
assert.equal(state.workspace.activeTabId, thirdId);
assert.equal(state.workspace.tabs[0].body, "keep me");
assert.deepEqual(state.inflight, { [thirdId]: thirdGeneration });
store.closeTabs([thirdId]);
state = useRepeaterStore.getState();
assert.equal(state.workspace.tabs.length, 1);
assert.notEqual(state.workspace.tabs[0].id, thirdId);
assert.equal(state.workspace.tabs[0].body, "");
assert.equal(state.workspace.activeTabId, state.workspace.tabs[0].id);
assert.deepEqual(state.inflight, {});
});
test("raw HTTP editing keeps the first line, method, path, protocol, Host and body together", () => {
const tab = {
...defaultWorkspace().tabs[0],
method: "POST",
url: "https://example.test:8443/api/upload?q=1",
headersText: "Host: virtual.test\nContent-Type: text/plain",
body: "first\n\nlast",
};
const raw = buildRawRequest(tab);
assert.equal(raw, "POST /api/upload?q=1 HTTP/1.1\nHost: virtual.test\nContent-Type: text/plain\n\nfirst\n\nlast");
const parsed = parseRawRequest(
raw.replace("POST /api/upload?q=1 HTTP/1.1", "PATCH /api/edited?q=2 HTTP/2.0"),
"https://example.test:8443",
);
assert.deepEqual(parsed, {
method: "PATCH",
url: "https://example.test:8443/api/edited?q=2",
httpVersion: "HTTP/2.0",
headersText: tab.headersText,
body: tab.body,
});
assert.equal(parseRawRequest("GET /new HTTP/1.1\nHost: new.test\n\n").url, "https://new.test/new");
assert.equal(parseRawRequest("GET /new HTTP/1.1\nHost: newer.test\n\n").url, "https://newer.test/new");
assert.equal(
parseRawRequest("GET http://elsewhere.test/path HTTP/1.1\nHost: vhost.test\n\n", "https://old.test").url,
"http://elsewhere.test/path",
);
assert.throws(() => parseRawRequest("POST /missing-version\nHost: test\n\n"), /首行/);
assert.equal(buildRawRequest({ ...tab, rawRequest: "unfinished HTTP draft" }), "unfinished HTTP draft");
});
test("raw request drafts and connection origins survive persistence and history restoration", (t) => {
const { store } = setupStore(t);
const id = store.workspace.tabs[0].id;
const rawRequest = "POST /unfinished HTTP/1.";
store.updateTab(id, { rawRequest, targetOrigin: "http://example.test:8080" });
const loaded = deserializeWorkspace(serializeWorkspace(useRepeaterStore.getState().workspace));
assert.equal(loaded.tabs[0].rawRequest, rawRequest);
assert.equal(loaded.tabs[0].targetOrigin, "http://example.test:8080");
store.recordSend(id, {
...entry(1),
rawRequest: "POST /old HTTP/1.1\nHost: example.test\n\nbody",
targetOrigin: "https://example.test",
});
assert.equal(
useRepeaterStore.getState().workspace.tabs[0].history[0].rawRequest.startsWith("POST /old HTTP/1.1"),
true,
);
});
test("evidence import preserves captured request line and protocol rather than removing it", () => {
const initial = { method: "GET", url: "https://example.test/path", headersText: "Host: example.test", body: "" };
const imported = requestFromEvidence(initial, "GET /path HTTP/2.0\r\nHost: example.test\r\n");
assert.equal(imported.rawRequest, "GET /path HTTP/2.0\nHost: example.test\n\n");
assert.equal(imported.targetOrigin, "https://example.test");
assert.equal(buildRawRequest(imported), imported.rawRequest);
});
@@ -0,0 +1,228 @@
"use client";
import { toast } from "sonner";
import { create } from "zustand";
import { getLocalStorageValue } from "../../lib/local-storage.client";
import type { RepeaterInitial } from "./repeater-http";
import {
addTab,
DEFAULT_GROUP_ID,
defaultWorkspace,
deserializeWorkspace,
emptyTab,
MAX_HISTORY,
newId,
type RepeaterHistoryEntry,
type RepeaterTab,
type RepeaterWorkspace,
STORAGE_KEY,
seedWorkspace,
serializeWorkspace,
type TabOptions,
type TabSeed,
} from "./repeater-state";
export type TabPatch = Partial<
Pick<
RepeaterTab,
"method" | "url" | "headersText" | "body" | "rawRequest" | "targetOrigin" | "useProxy" | "insecure" | "response"
>
>;
type Store = {
workspace: RepeaterWorkspace | null;
inflight: Record<string, number>;
beginSend: (id: string) => number | undefined;
isCurrentSend: (id: string, generation: number) => boolean;
clearInflight: (id: string, generation: number) => void;
completeSend: (id: string, generation: number, entry: RepeaterHistoryEntry) => void;
hydrate: () => void;
createTab: (seed?: TabSeed, opts?: TabOptions) => string;
closeTab: (id: string) => void;
closeTabs: (ids: string[]) => void;
setTabsGroup: (ids: string[], groupId: string) => void;
renameTab: (id: string, title: string) => void;
setActiveTab: (id: string) => void;
updateTab: (id: string, patch: TabPatch) => void;
recordSend: (id: string, entry: RepeaterHistoryEntry) => void;
createGroup: (name?: string) => void;
renameGroup: (id: string, name: string) => void;
setTabGroup: (tabId: string, groupId: string) => void;
toggleGroupCollapsed: (id: string) => void;
clearWorkspace: () => void;
seedFromEvidence: (initial: RepeaterInitial, meta?: RepeaterTab["source"], opts?: TabOptions) => string;
};
let timer: ReturnType<typeof setTimeout> | undefined;
let nextGeneration = 0;
export function flush() {
if (timer) clearTimeout(timer);
timer = undefined;
const workspace = useRepeaterStore.getState().workspace;
if (!workspace) return;
try {
window.localStorage.setItem(STORAGE_KEY, serializeWorkspace(workspace));
} catch {
try {
// Retry with only drafts and response metadata: evict all history, then
// response bodies. Never shrink the request drafts, even if they exceed quota.
window.localStorage.setItem(STORAGE_KEY, serializeWorkspace(workspace, 0));
} catch {
toast.error("工作区保存失败,浏览器存储空间不足或不可用,请备份请求草稿");
}
}
}
export const useRepeaterStore = create<Store>((set, get) => {
function current() {
get().hydrate();
return get().workspace as RepeaterWorkspace;
}
function change(workspace: RepeaterWorkspace) {
set({ workspace });
if (timer) clearTimeout(timer);
timer = setTimeout(flush, 300);
}
function editTab(id: string, patch: Partial<RepeaterTab>) {
const workspace = current();
if (!workspace.tabs.some((tab) => tab.id === id)) return;
change({
...workspace,
tabs: workspace.tabs.map((tab) =>
tab.id === id ? { ...tab, ...patch, updatedAt: new Date().toISOString() } : tab,
),
});
}
return {
workspace: null,
inflight: {},
beginSend: (id) => {
if (!current().tabs.some((tab) => tab.id === id)) return;
const generation = ++nextGeneration;
set({ inflight: { ...get().inflight, [id]: generation } });
return generation;
},
isCurrentSend: (id, generation) =>
get().inflight[id] === generation && !!get().workspace?.tabs.some((tab) => tab.id === id),
clearInflight: (id, generation) => {
if (get().inflight[id] !== generation) return;
const inflight = { ...get().inflight };
delete inflight[id];
set({ inflight });
},
completeSend: (id, generation, entry) => {
if (!get().isCurrentSend(id, generation)) return;
get().recordSend(id, entry);
get().clearInflight(id, generation);
},
hydrate: () => {
if (get().workspace || typeof window === "undefined") return;
set({ workspace: deserializeWorkspace(getLocalStorageValue(STORAGE_KEY)) });
window.addEventListener("pagehide", flush);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flush();
});
},
createTab: (seed, opts) => {
const workspace = addTab(current(), seed, opts);
change(workspace);
return workspace.tabs[workspace.tabs.length - 1].id;
},
closeTab: (id) => get().closeTabs([id]),
closeTabs: (ids) => {
const workspace = current();
const removed = new Set(ids);
if (!workspace.tabs.some((tab) => removed.has(tab.id))) return;
const inflight = { ...get().inflight };
for (const id of removed) delete inflight[id];
set({ inflight });
const tabs = workspace.tabs.filter((tab) => !removed.has(tab.id));
if (!tabs.length) tabs.push(emptyTab());
const activeIndex = workspace.tabs.findIndex((tab) => tab.id === workspace.activeTabId);
const active =
tabs.find((tab) => tab.id === workspace.activeTabId) ??
workspace.tabs.slice(activeIndex + 1).find((tab) => !removed.has(tab.id)) ??
tabs[tabs.length - 1];
change({
...workspace,
tabs,
activeTabId: active.id,
groups: workspace.groups.map((group) => (group.id === active.groupId ? { ...group, collapsed: false } : group)),
});
flush();
},
renameTab: (id, title) => {
if (title.trim()) editTab(id, { title: title.trim() });
},
setActiveTab: (id) => {
const workspace = current();
const tab = workspace.tabs.find((item) => item.id === id);
if (tab)
change({
...workspace,
activeTabId: id,
groups: workspace.groups.map((group) => (group.id === tab.groupId ? { ...group, collapsed: false } : group)),
});
},
updateTab: editTab,
recordSend: (id, entry) => {
const tab = current().tabs.find((item) => item.id === id);
if (tab) {
editTab(id, { response: entry.response, history: [entry, ...tab.history].slice(0, MAX_HISTORY) });
flush();
}
},
createGroup: (name) => {
const workspace = current();
change({
...workspace,
groups: [...workspace.groups, { id: newId(), name: name?.trim() || `分组 ${workspace.groups.length + 1}` }],
});
},
renameGroup: (id, name) => {
if (!name.trim()) return;
const workspace = current();
change({
...workspace,
groups: workspace.groups.map((group) => (group.id === id ? { ...group, name: name.trim() } : group)),
});
},
setTabGroup: (tabId, groupId) => get().setTabsGroup([tabId], groupId),
setTabsGroup: (ids, groupId) => {
const workspace = current();
if (!workspace.groups.some((group) => group.id === groupId)) return;
const selected = new Set(ids);
if (!workspace.tabs.some((tab) => selected.has(tab.id))) return;
change({
...workspace,
tabs: workspace.tabs.map((tab) =>
selected.has(tab.id) ? { ...tab, groupId, updatedAt: new Date().toISOString() } : tab,
),
groups: workspace.groups.map((group) => (group.id === groupId ? { ...group, collapsed: false } : group)),
});
flush();
},
toggleGroupCollapsed: (id) => {
const workspace = current();
change({
...workspace,
groups: workspace.groups.map((group) => (group.id === id ? { ...group, collapsed: !group.collapsed } : group)),
});
},
clearWorkspace: () => {
set({ inflight: {} });
change(defaultWorkspace());
flush();
},
seedFromEvidence: (initial, meta, opts) => {
const workspace = seedWorkspace(current(), initial, meta, { groupId: DEFAULT_GROUP_ID, ...opts });
change(workspace);
flush();
return workspace.tabs[workspace.tabs.length - 1].id;
},
};
});
export function seedFromEvidence(initial: RepeaterInitial, meta?: RepeaterTab["source"], opts?: TabOptions) {
return useRepeaterStore.getState().seedFromEvidence(initial, meta, opts);
}
@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import { CopyButton } from "@/components/copy-button";
import { Textarea } from "@/components/ui/textarea";
export function RepeaterTextPane({
id,
label,
value,
onChange,
disabled,
placeholder,
}: {
id: string;
label: string;
value: string;
onChange?: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}) {
const gutter = React.useRef<HTMLPreElement>(null);
const lines = React.useMemo(() => value.split(/\r\n|\r|\n/).length, [value]);
const numbers = React.useMemo(() => Array.from({ length: lines }, (_, index) => index + 1).join("\n"), [lines]);
return (
<div className="flex min-h-[280px] min-w-0 flex-1 flex-col gap-2 xl:min-h-0">
<div className="flex items-center gap-2">
<label htmlFor={id} className="font-medium text-xs">
{label}
</label>
<span className="text-muted-foreground text-xs tabular-nums">{lines} 行</span>
<CopyButton text={value} label={`复制${label}`} className="ml-auto" />
</div>
<div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-input focus-within:border-ring">
<div
aria-hidden="true"
className="w-12 shrink-0 overflow-hidden border-r bg-muted/30 text-right text-muted-foreground"
>
<pre ref={gutter} className="select-none px-2 py-2 font-mono text-xs leading-5">
{numbers}
</pre>
</div>
<Textarea
id={id}
value={value}
onChange={(event) => onChange?.(event.target.value)}
onScroll={(event) => {
if (gutter.current) gutter.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`;
}}
disabled={disabled}
readOnly={!onChange}
placeholder={placeholder}
wrap="off"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className="field-sizing-fixed h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs"
/>
</div>
</div>
);
}
@@ -0,0 +1,156 @@
"use client";
import * as React from "react";
import { FolderPlus } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { RepeaterEditor } from "./repeater-editor";
import { RepeaterNavigator } from "./repeater-navigator";
import { useRepeaterStore } from "./repeater-store";
export function RepeaterWorkspace() {
const store = useRepeaterStore();
const { workspace, hydrate } = store;
const [rename, setRename] = React.useState<{ kind: "tab" | "group"; id: string; name: string } | null>(null);
React.useEffect(() => {
hydrate();
}, [hydrate]);
if (!workspace) return <Skeleton className="h-[70vh] w-full" />;
const active = workspace.tabs.find((tab) => tab.id === workspace.activeTabId);
function saveName(event: React.FormEvent) {
event.preventDefault();
if (!rename?.name.trim()) return;
if (rename.kind === "tab") store.renameTab(rename.id, rename.name);
else store.renameGroup(rename.id, rename.name);
setRename(null);
}
return (
<div className="flex min-h-[calc(100dvh-7rem)] min-w-0 flex-col gap-4 xl:h-[calc(100dvh-8rem)] xl:min-h-[600px]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-semibold text-xl">请求重发</h1>
<p className="text-muted-foreground text-sm">编辑、发送并比较请求 · 草稿与近期响应保存在此浏览器</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => store.createGroup()}>
<FolderPlus data-icon="inline-start" />
新建分组
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
清空工作区
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清空请求重发工作区?</AlertDialogTitle>
<AlertDialogDescription>所有分组、标签、请求草稿和历史响应都会被删除。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => store.clearWorkspace()}>
确认清空
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
<RepeaterNavigator workspace={workspace} onRename={setRename} />
<main className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
{active ? (
<>
<div className="flex flex-wrap items-center gap-2">
<h2 className="min-w-0 flex-1 truncate font-medium text-sm" title={active.title}>
{active.title}
</h2>
<Field orientation="horizontal" className="w-auto">
<FieldLabel htmlFor="repeater-group">移入分组</FieldLabel>
<Select value={active.groupId} onValueChange={(id) => store.setTabGroup(active.id, id)}>
<SelectTrigger id="repeater-group" className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{workspace.groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
<RepeaterEditor key={active.id} tab={active} />
</>
) : (
<Button onClick={() => store.createTab()}>新建标签</Button>
)}
</main>
</div>
<Dialog
open={rename !== null}
onOpenChange={(open) => {
if (!open) setRename(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{rename?.kind === "group" ? "重命名分组" : "重命名标签"}</DialogTitle>
<DialogDescription>输入便于识别的名称。</DialogDescription>
</DialogHeader>
<form onSubmit={saveName} className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="repeater-name">名称</FieldLabel>
<Input
id="repeater-name"
value={rename?.name ?? ""}
onChange={(event) =>
setRename((current) => (current ? { ...current, name: event.target.value } : current))
}
/>
</Field>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRename(null)}>
取消
</Button>
<Button type="submit" disabled={!rename?.name.trim()}>
保存
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+83 -83
View File
@@ -2,10 +2,13 @@
import * as React from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { FindingRepeaterDialog, headersFromReqHead, type RepeaterInitial } from "@/components/finding-repeater-dialog";
import { HttpCodeBlock } from "@/components/http-code-block";
import { headersFromReqHead, requestFromEvidence } from "@/components/repeater/repeater-http";
import { seedFromEvidence } from "@/components/repeater/repeater-store";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -28,8 +31,7 @@ export function TrafficEvidenceViewer({
const [detail, setDetail] = React.useState<FindingTrafficDetail | null>(null);
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [repeaterOpen, setRepeaterOpen] = React.useState(false);
const [repeaterInitial, setRepeaterInitial] = React.useState<RepeaterInitial>();
const router = useRouter();
React.useEffect(() => {
if (!bindingId) return;
let active = true;
@@ -69,90 +71,88 @@ export function TrafficEvidenceViewer({
if (!detail) return;
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载");
setRepeaterInitial({
method: detail.binding.snapshot.method || "GET",
url: detail.binding.snapshot.url || "",
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
body: detail.request.binary ? "" : detail.request.content || "",
});
setRepeaterOpen(true);
seedFromEvidence(
requestFromEvidence(
{
method: detail.binding.snapshot.method || "GET",
url: detail.binding.snapshot.url || "",
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
body: detail.request.binary ? "" : detail.request.content || "",
},
detail.binding.snapshot.req_head,
),
{ findingId, bindingId: detail.binding.id },
);
router.push("/function/repeater");
}
return (
<>
<Dialog
open={bindingId !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<div className="flex flex-wrap items-start justify-between gap-2 pe-8">
<div>
<DialogTitle>流量证据 #{bindingId}</DialogTitle>
<DialogDescription className="break-all">
{detail?.binding.snapshot.url ?? "查看绑定时保存的请求与响应"}
</DialogDescription>
</div>
{detail ? (
<Button variant="outline" size="sm" onClick={openRepeater}>
送入 Repeater
</Button>
) : null}
<Dialog
open={bindingId !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<div className="flex flex-wrap items-start justify-between gap-2 pe-8">
<div>
<DialogTitle>流量证据 #{bindingId}</DialogTitle>
<DialogDescription className="break-all">
{detail?.binding.snapshot.url ?? "查看绑定时保存的请求与响应"}
</DialogDescription>
</div>
</DialogHeader>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : detail ? (
<Tabs defaultValue="request">
<TabsList>
<TabsTrigger value="request">请求 Request</TabsTrigger>
<TabsTrigger value="response">响应 Response</TabsTrigger>
</TabsList>
{(["request", "response"] as const).map((side) => (
<TabsContent key={side} value={side}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
void api
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
.catch((e: Error) => toast.error(e.message))
}
>
下载完整{side === "request" ? "请求" : "响应"}正文
</Button>
</div>
<HttpCodeBlock
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
/>
{detail[side].truncated && !detail[side].binary ? (
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
加载更多正文
</Button>
) : null}
</TabsContent>
))}
</Tabs>
) : (
<Skeleton className="h-56 w-full" />
)}
</DialogContent>
</Dialog>
<FindingRepeaterDialog
open={repeaterOpen}
onOpenChange={setRepeaterOpen}
initial={repeaterInitial}
findingLabel={bindingId ? `证据 #${bindingId}` : undefined}
/>
</>
{detail ? (
<Button variant="outline" size="sm" onClick={openRepeater}>
送入请求重发
</Button>
) : null}
</div>
</DialogHeader>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : detail ? (
<Tabs defaultValue="request">
<TabsList>
<TabsTrigger value="request">请求 Request</TabsTrigger>
<TabsTrigger value="response">响应 Response</TabsTrigger>
</TabsList>
{(["request", "response"] as const).map((side) => (
<TabsContent key={side} value={side}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
void api
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
.catch((e: Error) => toast.error(e.message))
}
>
下载完整{side === "request" ? "请求" : "响应"}正文
</Button>
</div>
<HttpCodeBlock
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
/>
{detail[side].truncated && !detail[side].binary ? (
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
加载更多正文
</Button>
) : null}
</TabsContent>
))}
</Tabs>
) : (
<Skeleton className="h-56 w-full" />
)}
</DialogContent>
</Dialog>
);
}
+1
View File
@@ -1645,6 +1645,7 @@ export interface FindingTrafficDetail {
export interface RepeaterSendRequest {
method: string;
http_version?: string;
url: string;
headers?: Record<string, string>;
body?: string;
+3 -1
View File
@@ -2,10 +2,10 @@ import {
Activity,
Ban,
BellRing,
CalendarClock,
Bot,
Brain,
Bug,
CalendarClock,
ClipboardList,
FolderOpen,
FolderSync,
@@ -15,6 +15,7 @@ import {
Network,
Plug,
Radio,
Repeat,
ScrollText,
Settings2,
ShieldAlert,
@@ -73,6 +74,7 @@ export const sidebarItems: NavGroup[] = [
{ id: "schedules", title: "计划任务", url: "/function/schedules", icon: CalendarClock },
{ id: "findings", title: "发现", url: "/function/findings", icon: Bug },
{ id: "traffic", title: "流量", url: "/function/traffic", icon: Activity },
{ id: "repeater", title: "请求重发", url: "/function/repeater", icon: Repeat },
{ id: "commands", title: "工具执行", url: "/function/commands", icon: Terminal },
{ id: "llm-records", title: "LLM 录制", url: "/function/llm-records", icon: Radio },
{ id: "assets", title: "资产", url: "/function/assets", icon: Network },