From b0019f44fa1ba7e41b4a2ec1a097cb316f0949b2 Mon Sep 17 00:00:00 2001 From: botre Date: Sat, 8 Aug 2026 11:00:01 +0200 Subject: [PATCH] Add copy-as-HAR buttons for captured requests Getting a captured request out of the browser meant using the section-level headers and body copy links and retyping the method, path and query string by hand. Add a per-card "Copy request" button and a page-level "Copy all (N)" that put the whole request on the clipboard as a HAR-shaped JSON document. Entries follow HAR 1.2 field names and shapes so the output is familiar to HAR tooling, but carry only a request: httphq never observes a response, so response, timings and cache are omitted rather than emitted as stubs that would read as captured data. The global button copies the visible requests, so the method filter and search box double as a way to scope the export; the count in its label makes that scope visible. Claude-Session: https://claude.ai/code/session_01LXy9D2BfSUqqj7BjPWs3fv --- e2e/tests/endpoint-screen.spec.ts | 108 ++++++++++++++++++++++++ public/endpoint.js | 16 ++++ public/har.js | 92 ++++++++++++++++++++ public/render-body.js | 6 +- src/views/endpoint.html | 135 +++++++++++++++++++++--------- src/views/layouts/main.html | 1 + 6 files changed, 318 insertions(+), 40 deletions(-) create mode 100644 public/har.js diff --git a/e2e/tests/endpoint-screen.spec.ts b/e2e/tests/endpoint-screen.spec.ts index 8121a19..1c214c4 100644 --- a/e2e/tests/endpoint-screen.spec.ts +++ b/e2e/tests/endpoint-screen.spec.ts @@ -249,6 +249,114 @@ test.describe("Endpoint screen", () => { expect(parsed["X-Sample"]).toBe("value"); }); + test("request copy button writes a HAR-shaped document to the clipboard", async ({ + page, + request, + }) => { + const raw = '{"hello":"world"}'; + const response = await request.post(`${endpointUrl}?a=1&b=2`, { + data: raw, + headers: { "Content-Type": "application/json", "X-Sample": "value" }, + }); + const uuid = response.headers()["httphq-request-uuid"]; + const card = page.locator(`#request-${uuid}`); + await expect(card).toBeAttached(); + + await card.locator('[data-test="copy-request-har"]').click(); + const clipboard = await page.evaluate(() => + navigator.clipboard.readText(), + ); + const har = JSON.parse(clipboard); + + expect(har.creator.name).toBe("httphq"); + expect(har.entries).toHaveLength(1); + const entry = har.entries[0]; + expect(entry.id).toBe(uuid); + expect(entry.request.method).toBe("POST"); + expect(entry.request.url).toBe(`${endpointUrl}?a=1&b=2`); + expect(entry.request.httpVersion).toBe("HTTP/1.1"); + expect(entry.request.queryString).toEqual([ + { name: "a", value: "1" }, + { name: "b", value: "2" }, + ]); + expect(entry.request.headers).toContainEqual({ + name: "X-Sample", + value: "value", + }); + expect(entry.request.postData).toEqual({ + mimeType: "application/json", + text: raw, + }); + expect(entry.request.bodySize).toBe(raw.length); + }); + + test("request copy button label flips to Copied!", async ({ + page, + request, + }) => { + await post(request, endpointUrl, { data: "x" }); + const card = page.locator('[data-test="request"]').first(); + await card.locator('[data-test="copy-request-har"]').click(); + await expect( + card.locator('[data-test="copy-request-har-label"]'), + ).toContainText("Copied!"); + }); + + test("copy-all writes every visible request, newest first", async ({ + page, + request, + }) => { + await post(request, endpointUrl, { data: "first" }); + await expect(page.locator('[data-test="request"]')).toHaveCount(1); + await post(request, endpointUrl, { data: "second" }); + await expect(page.locator('[data-test="request"]')).toHaveCount(2); + + await page.locator('[data-test="copy-all-har"]').click(); + const clipboard = await page.evaluate(() => + navigator.clipboard.readText(), + ); + const har = JSON.parse(clipboard); + + expect(har.entries).toHaveLength(2); + expect( + har.entries.map( + (e: { request: { postData: { text: string } } }) => + e.request.postData.text, + ), + ).toEqual(["second", "first"]); + }); + + test("copy-all is scoped to the method filter", async ({ + page, + request, + }) => { + await post(request, endpointUrl, { data: "p" }); + await request.fetch(endpointUrl, { method: "PUT", data: "u" }); + await expect(page.locator('[data-test="search-results"]')).toContainText( + "2 results", + ); + + await page.locator('[data-test="method-filter"]').selectOption("PUT"); + await expect( + page.locator('[data-test="copy-all-har-label"]'), + ).toContainText("Copy all (1)"); + + await page.locator('[data-test="copy-all-har"]').click(); + const clipboard = await page.evaluate(() => + navigator.clipboard.readText(), + ); + const har = JSON.parse(clipboard); + + expect(har.entries).toHaveLength(1); + expect(har.entries[0].request.method).toBe("PUT"); + }); + + test("copy-all is disabled while nothing has been captured", async ({ + page, + }) => { + await expect(page.locator('[data-test="copy-all-har"]')).toBeDisabled(); + }); + test("filters by request body via the search box", async ({ page, request, diff --git a/public/endpoint.js b/public/endpoint.js index d9f0057..00bced4 100644 --- a/public/endpoint.js +++ b/public/endpoint.js @@ -94,6 +94,7 @@ sendForm: { method: "POST", body: "", headers: "" }, sendStatus: "", copyLabel: "Copy", + copiedKey: null, _baseTitle: document.title, _unread: 0, @@ -171,6 +172,21 @@ } }, + // Copies requests as a HAR-shaped document. `key` names the button that + // should read "Copied!" — several buttons share this one component + // instance, so a single label field can't tell them apart. + async copyHar(requests, key) { + try { + await window.copyToClipboard(window.buildHarExport(requests)); + this.copiedKey = key; + setTimeout(() => { + if (this.copiedKey === key) this.copiedKey = null; + }, 1500); + } catch (err) { + console.error(err); + } + }, + async sendCustom() { const store = Alpine.store("main"); if (!store.endpointId) return; diff --git a/public/har.js b/public/har.js new file mode 100644 index 0000000..79d757f --- /dev/null +++ b/public/har.js @@ -0,0 +1,92 @@ +/* Serialises captured requests to a HAR-shaped JSON document for the clipboard. + + The field names and value shapes follow HAR 1.2 so the output is familiar to + HAR tooling, but entries carry only a `request`: httphq never observes a + response, so `response`, `timings` and `cache` are omitted rather than + emitted as stubs that would read as captured data. Loaded on every page. */ + +(function () { + const CREATOR = { name: "httphq", version: "1" }; + + // Not captured: the capture handler never records the protocol, so every + // entry claims HTTP/1.1. Revisit if the request model starts storing it. + const ASSUMED_HTTP_VERSION = "HTTP/1.1"; + + /* Absolute URL for a captured request. `path` already carries the + /to/ prefix; the scheme comes from the page because it is not + stored, and the Host header is preferred over the page's so a request + captured through another hostname still round-trips. */ + function entryURL(request) { + const host = window.headerValue(request.headers, "Host") || location.host; + const query = request.queryString ? `?${request.queryString}` : ""; + return `${location.protocol}//${host}${request.path}${query}`; + } + + /* HAR represents a repeated header as one entry per value, so the stored + scalar-or-array value is expanded rather than joined. + + Order is alphabetical, not wire order: the capture handler flattens + headers through a Go map and encoding/json sorts keys on marshal. Nothing + downstream may treat this ordering as meaningful. */ + function entryHeaders(headers) { + const out = []; + for (const [name, value] of Object.entries(headers || {})) { + for (const one of Array.isArray(value) ? value : [value]) { + out.push({ name, value: String(one) }); + } + } + return out; + } + + function entryQueryString(queryString) { + if (!queryString) return []; + return [...new URLSearchParams(queryString)].map(([name, value]) => ({ + name, + value, + })); + } + + function harEntry(request) { + const body = request.body || ""; + // Omitted entirely for bodyless requests, matching HAR, where postData is + // absent rather than empty when there was no payload. + const postData = body + ? { + mimeType: window.headerValue(request.headers, "Content-Type") || "", + // Verbatim, never base64: the body carries the same U+FFFD + // substitutions the request panel displays. + text: body, + } + : null; + + return { + id: request.uuid, + startedDateTime: new Date(request.createdAt).toISOString(), + clientIPAddress: request.ip, + request: { + method: request.method, + url: entryURL(request), + httpVersion: ASSUMED_HTTP_VERSION, + headers: entryHeaders(request.headers), + queryString: entryQueryString(request.queryString), + ...(postData ? { postData } : {}), + // Byte length of the captured body, which httphq stores as a Go + // string — encoding/json replaces invalid UTF-8 with U+FFFD on + // marshal, so for genuinely binary uploads this can differ from the + // true original size. See database.Request.Body. + bodySize: new TextEncoder().encode(body).length, + }, + }; + } + + /* Takes captured requests in display order (newest first) and returns the + document as pretty-printed JSON. A single request and the whole list share + one envelope, so consumers parse the same shape either way. */ + window.buildHarExport = function (requests) { + return JSON.stringify( + { creator: CREATOR, entries: (requests || []).map(harEntry) }, + null, + 2, + ); + }; +})(); diff --git a/public/render-body.js b/public/render-body.js index b86187e..8e40d90 100644 --- a/public/render-body.js +++ b/public/render-body.js @@ -20,7 +20,9 @@ function highlightPrettyJSON(value) { } /* Case-insensitive lookup into a headers object whose values are either a - scalar string or a string[] (see the flattening in application.go). */ + scalar string or a string[] (see the flattening in application.go). Exposed + as window.headerValue because that scalar-or-array contract is shared by + every consumer of a captured request, not just body rendering. */ function headerValue(headers, name) { if (!headers) return undefined; const key = Object.keys(headers).find( @@ -141,3 +143,5 @@ window.renderBody = function (body, headers) { } return htmlEscape(body); }; + +window.headerValue = headerValue; diff --git a/src/views/endpoint.html b/src/views/endpoint.html index 82dbeb7..3011b34 100644 --- a/src/views/endpoint.html +++ b/src/views/endpoint.html @@ -146,27 +146,55 @@ Requests are deleted after 4 hours

- + + + Delete all + + @@ -243,26 +271,55 @@ class="text-xs text-slate-500 font-mono truncate hover:text-indigo-600" > - + + + + +
diff --git a/src/views/layouts/main.html b/src/views/layouts/main.html index 23f2307..85c5e28 100644 --- a/src/views/layouts/main.html +++ b/src/views/layouts/main.html @@ -37,6 +37,7 @@ queueMicrotask at the end of its own script execution. --> +