diff --git a/internal/gui/bindings.go b/internal/gui/bindings.go index ee9b411..9302375 100644 --- a/internal/gui/bindings.go +++ b/internal/gui/bindings.go @@ -440,9 +440,11 @@ func captureSummary(rows []store.TrafficCaptureRow) []CaptureView { return out } -// captureDetail converts one capture row into the full DTO. Text fields remain -// for compatibility and convenient inspection; Base64 fields preserve exact -// bytes for binary viewers and downloads. +// captureDetail converts one capture row into the full DTO. Bodies travel as +// Base64 only: shipping the same bytes as UTF-8 as well doubled every +// response (a 5 MB body became 12+ MB of JSON), and the webview's JSON.parse +// of that payload was a large part of the traffic-tab freezes. The body +// viewer already decodes Base64 byte-exactly. func captureDetail(c *store.TrafficCaptureRow) CaptureView { return CaptureView{ ID: c.ID, @@ -452,11 +454,9 @@ func captureDetail(c *store.TrafficCaptureRow) CaptureView { URL: c.URL, Status: c.Status, ReqHeaders: parseHeaders(c.ReqHeadersJSON), - ReqBody: string(c.ReqBody), ReqBodyBase64: base64.StdEncoding.EncodeToString(c.ReqBody), ReqBodyLen: len(c.ReqBody), RespHeaders: parseHeaders(c.RespHeadersJSON), - RespBody: string(c.RespBody), RespBodyBase64: base64.StdEncoding.EncodeToString(c.RespBody), RespBodyLen: len(c.RespBody), } diff --git a/ui/app.js b/ui/app.js index 2c9631f..e77b8ec 100644 --- a/ui/app.js +++ b/ui/app.js @@ -53,6 +53,16 @@ function CommandDeck({ activeTab, onChange, counts, onSearch, project, filtered, `; } +// Row fingerprints. Every poll calls setState with a fresh array identity, +// which re-renders the whole tree — including any open detail pane, whose +// body viewer is expensive on large payloads. Returning the previous state +// identity when the rows did not change lets Preact skip that re-render. +// Webhook (project, seq) pairs and capture ids are immutable once stored, +// so these fingerprints cannot miss a real change. +const webhooksFingerprint = (rows) => rows.map((w) => `${w.project}/${w.seq}`).join("|"); +const capturesFingerprint = (rows) => rows.map((c) => `${c.id}:${c.status}`).join("|"); +const sessionsFingerprint = (rows) => rows.map((s) => `${s.id}:${s.captures}:${s.ended_at || ""}`).join("|"); + function App() { const [activeTab, setActiveTab] = useState("webhooks"); const [status, setStatus] = useState(null); @@ -92,21 +102,24 @@ function App() { }; const loadWebhooks = async () => { try { - setWebhooks((await api.listWebhooks(project)) || []); + const rows = (await api.listWebhooks(project)) || []; + setWebhooks((prev) => (webhooksFingerprint(prev) === webhooksFingerprint(rows) ? prev : rows)); } catch (e) { showToast("list webhooks: " + e); } }; const loadCaptures = async () => { try { - setCaptures((await api.listCaptures(sessionFilter)) || []); + const rows = (await api.listCaptures(sessionFilter)) || []; + setCaptures((prev) => (capturesFingerprint(prev) === capturesFingerprint(rows) ? prev : rows)); } catch (e) { showToast("list captures: " + e); } }; const loadSessions = async () => { try { - setSessions((await api.listSessions()) || []); + const rows = (await api.listSessions()) || []; + setSessions((prev) => (sessionsFingerprint(prev) === sessionsFingerprint(rows) ? prev : rows)); } catch (e) { showToast("list sessions: " + e); } @@ -173,20 +186,38 @@ function App() { }, [webhooks, captures, activeTab, follow]); // --- selection handlers ------------------------------------------------ + // Rapid row clicking must not pile work onto the webview: each detail + // fetch ships the full body payload and each render decodes it, so every + // queued click used to add another multi-MB render on arrival. In-flight + // requests are deduplicated per row, and responses that were superseded + // by a newer click are dropped instead of rendered. + const latestDetailReq = useRef(0); + const inflightDetails = useRef(new Map()); + const fetchDetail = (key, load) => { + const pending = inflightDetails.current; + let p = pending.get(key); + if (!p) { + p = load().finally(() => pending.delete(key)); + pending.set(key, p); + } + return p; + }; const openWebhook = async (proj, seq) => { + const req = ++latestDetailReq.current; try { - const w = await api.getWebhook(proj, seq); - setSelection({ kind: "webhook", data: w }); + const w = await fetchDetail(`${proj}/${seq}`, () => api.getWebhook(proj, seq)); + if (req === latestDetailReq.current) setSelection({ kind: "webhook", data: w }); } catch (e) { - showToast("get webhook: " + e); + if (req === latestDetailReq.current) showToast("get webhook: " + e); } }; const openCapture = async (id) => { + const req = ++latestDetailReq.current; try { - const c = await api.getCapture(id); - setSelection({ kind: "traffic", data: c }); + const c = await fetchDetail(`c/${id}`, () => api.getCapture(id)); + if (req === latestDetailReq.current) setSelection({ kind: "traffic", data: c }); } catch (e) { - showToast("get capture: " + e); + if (req === latestDetailReq.current) showToast("get capture: " + e); } }; const openScript = async (s) => { diff --git a/ui/components/code-block.js b/ui/components/code-block.js index d9cf5a7..8c783be 100644 --- a/ui/components/code-block.js +++ b/ui/components/code-block.js @@ -1,11 +1,27 @@ // BodyViewer inspects captured request/response bodies without assuming they are // UTF-8. Auto follows Content-Type; explicit modes handle incorrect headers. +// +// Bodies are rendered through a window: only the first DISPLAY_LIMIT bytes are +// decoded and put into the DOM, regardless of payload size. Materializing a +// multi-megabyte body as DOM text cost 0.5–2s of blocked webview main thread +// per open (and piled up fatally under rapid row switching), while the full +// bytes remain available for Copy, Save, Image, and an explicit +// "show entire body" escalation. import { html } from "../vendor/preact/index.js"; import { useEffect, useMemo, useState } from "../vendor/preact/index.js"; import { prettyBody, highlightJSON, escapeHTML } from "../lib/format.js"; import { copyText } from "../lib/clipboard.js"; import { Dropdown } from "./dropdown.js"; +// How much of a body is decoded and rendered up front. Above this the viewer +// shows a slice plus an escalation button. 256 KiB keeps decode + escape + +// DOM work in the tens of milliseconds even when the payload is megabytes. +const DISPLAY_LIMIT = 256 * 1024; +// Pretty-print and JSON highlighting are only attempted below this size. +// Both are synchronous O(body) passes that build several multiples of the +// body size in strings and DOM. +const PRETTY_LIMIT = 1024 * 1024; +// Preview is refused outright above this size; Save still works. const LARGE_BODY_LIMIT = 15 * 1024 * 1024; function mediaType(contentType) { @@ -27,6 +43,13 @@ function decodeBase64(encoded) { return bytes; } +// sliceBase64 cuts a byte-prefix of a base64 string. Only whole 4-char +// blocks decode to bytes, so the cut is aligned down to one. +function sliceBase64(encoded, maxBytes) { + const chars = Math.floor(maxBytes / 3) * 4; + return encoded.length <= chars ? encoded : encoded.slice(0, chars); +} + function bytesToText(bytes) { return new TextDecoder("utf-8", { fatal: false }).decode(bytes); } @@ -42,34 +65,82 @@ function hexDump(bytes) { return lines.join("\n"); } +function fmtMB(n) { + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + export function BodyViewer({ body, bodyBase64, bodyLength, contentType, maxHeightClass = "max-h-72" }) { const [mode, setMode] = useState("auto"); const [copyState, setCopyState] = useState("idle"); + const [showAll, setShowAll] = useState(false); const type = mediaType(contentType); - const bytes = useMemo(() => bodyBase64 ? decodeBase64(bodyBase64) : new TextEncoder().encode(body || ""), [body, bodyBase64]); const effectiveMode = mode === "auto" ? autoMode(type) : mode; + + const totalLength = bodyLength || (bodyBase64 ? Math.floor(bodyBase64.length * 3 / 4) : (body || "").length); + const truncated = !showAll && totalLength > DISPLAY_LIMIT; + + // A new body resets the escalation so a big row never opens expanded. + useEffect(() => { + setShowAll(false); + }, [bodyBase64, body]); + + // The display window: at most DISPLAY_LIMIT bytes are ever decoded for + // rendering. Copy, Save, and Image decode the full payload on demand. + const displayB64 = useMemo( + () => (bodyBase64 && truncated ? sliceBase64(bodyBase64, DISPLAY_LIMIT) : bodyBase64 || ""), + [bodyBase64, truncated], + ); + const displayText = useMemo( + () => (!bodyBase64 && body ? (truncated ? body.slice(0, DISPLAY_LIMIT) : body) : ""), + [body, bodyBase64, truncated], + ); + const bytes = useMemo( + () => (displayB64 ? decodeBase64(displayB64) : new TextEncoder().encode(displayText || "")), + [displayB64, displayText], + ); const text = useMemo(() => bytesToText(bytes), [bytes]); - const isLarge = (bodyLength || bytes.length) > LARGE_BODY_LIMIT; + const isLarge = totalLength > LARGE_BODY_LIMIT; const imageURL = useMemo(() => { - if (effectiveMode !== "image" || !bytes.length || !/^image\//.test(type)) return ""; - return URL.createObjectURL(new Blob([bytes], { type: type || "application/octet-stream" })); - }, [bytes, effectiveMode, type]); + if (effectiveMode !== "image" || !totalLength || !/^image\//.test(type)) return ""; + // Images need the complete bytes, however large; they are the one mode + // that cannot render from a prefix. + const full = bodyBase64 ? decodeBase64(bodyBase64) : new TextEncoder().encode(body || ""); + if (!full.length) return ""; + return URL.createObjectURL(new Blob([full], { type: type || "application/octet-stream" })); + }, [body, bodyBase64, effectiveMode, type, totalLength]); useEffect(() => () => { if (imageURL) URL.revokeObjectURL(imageURL); }, [imageURL]); - if (!bytes.length) return html`
(empty)
`; + if (!totalLength && !bytes.length) return html`(empty)
`; + + // Heavy transforms are memoized and only attempted within the display + // window; the poll loop re-renders this component every cycle for free. + const oversizePretty = totalLength > PRETTY_LIMIT; + const pretty = useMemo(() => { + if (effectiveMode !== "pretty" || oversizePretty || truncated) return { text, isJSON: false }; + return prettyBody(text, contentType); + }, [text, contentType, effectiveMode, oversizePretty, truncated]); + const renderedText = pretty.isJSON ? pretty.text : text; + const inner = useMemo(() => { + if (pretty.isJSON) return highlightJSON(renderedText); + return escapeHTML(effectiveMode === "hex" ? hexDump(bytes) : renderedText); + }, [pretty, renderedText, effectiveMode, bytes]); + // Stable object identity so Preact does not re-assign innerHTML when the + // content is unchanged. + const innerHTML = useMemo(() => ({ __html: inner }), [inner]); - const pretty = prettyBody(text, contentType); - const renderedText = effectiveMode === "pretty" && pretty.isJSON ? pretty.text : text; - const inner = effectiveMode === "pretty" && pretty.isJSON - ? highlightJSON(renderedText) - : escapeHTML(effectiveMode === "hex" ? hexDump(bytes) : renderedText); + const fullText = () => (bodyBase64 ? bytesToText(decodeBase64(bodyBase64)) : (body || "")); + const fullBytes = () => (bodyBase64 ? decodeBase64(bodyBase64) : new TextEncoder().encode(body || "")); const copy = async () => { - try { await copyText(renderedText); setCopyState("copied"); } - catch { setCopyState("failed"); } + try { + await copyText(fullText()); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } setTimeout(() => setCopyState("idle"), 1600); }; const download = () => { - const url = URL.createObjectURL(new Blob([bytes], { type: type || "application/octet-stream" })); + const url = URL.createObjectURL(new Blob([fullBytes()], { type: type || "application/octet-stream" })); const a = document.createElement("a"); a.href = url; a.download = `wiretap-body.${type.split("/")[1]?.split("+")[0] || "bin"}`; @@ -97,11 +168,20 @@ export function BodyViewer({ body, bodyBase64, bodyLength, contentType, maxHeigh ${effectiveMode !== "image" ? html`` : null} + ${truncated + ? html` is not re-assigned
+ // innerHTML on unrelated re-renders (the poll loop).
+ const snippetHTML = useMemo(() => ({ __html: escapeHTML(snippet) }), [snippet]);
+
return html`
Export as code
@@ -129,7 +139,7 @@ export function ExportSnippet({ exportKey, convert }) {
`}
`;
diff --git a/ui/components/traffic-detail.js b/ui/components/traffic-detail.js
index c111a81..ccfdaa8 100644
--- a/ui/components/traffic-detail.js
+++ b/ui/components/traffic-detail.js
@@ -46,7 +46,6 @@ export function TrafficDetail({ capture, onExport, onClose }) {
len=${fmtBytes(capture.req_body_len)}
>
<${CodeBlock}
- body=${capture.req_body}
bodyBase64=${capture.req_body_base64}
bodyLength=${capture.req_body_len}
contentType=${reqCT}
@@ -63,7 +62,6 @@ export function TrafficDetail({ capture, onExport, onClose }) {
len=${fmtBytes(capture.resp_body_len)}
>
<${CodeBlock}
- body=${capture.resp_body}
bodyBase64=${capture.resp_body_base64}
bodyLength=${capture.resp_body_len}
contentType=${respCT}
diff --git a/ui/lib/format.js b/ui/lib/format.js
index 47f38a9..f8bcb30 100644
--- a/ui/lib/format.js
+++ b/ui/lib/format.js
@@ -81,14 +81,12 @@ export function prettyBody(body, contentType) {
/**
* Escape HTML-special characters so a string is safe to inject as innerHTML.
+ * Single pass: bodies run through this on every display window change.
* @param {string} s
* @returns {string}
*/
export function escapeHTML(s) {
- return String(s)
- .replace(/&/g, "&")
- .replace(//g, ">");
+ return String(s).replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">"));
}
/**