From c2a65ada309fef2430c94566e1808093bc9edaeb Mon Sep 17 00:00:00 2001 From: Sam Newby Date: Fri, 21 Nov 2025 00:11:13 +0000 Subject: [PATCH] chore: major progress on request captures --- scripts/test-requests.ts | 17 ++++ src/components/AppSidebar.tsx | 11 ++- src/components/ui/badge.tsx | 46 +++++++++ src/dist.css | 150 +++++++++++++++++++++++------ src/frontend.tsx | 3 +- src/index.tsx | 16 ++-- src/lib/capture/capture.ts | 175 +++++++++++++++++++++++++++++++++ src/lib/capture/utils.ts | 63 ++++++++++++ src/views/requests.tsx | 176 ++++++++++++++++++---------------- 9 files changed, 529 insertions(+), 128 deletions(-) create mode 100644 scripts/test-requests.ts create mode 100644 src/components/ui/badge.tsx create mode 100644 src/lib/capture/capture.ts create mode 100644 src/lib/capture/utils.ts diff --git a/scripts/test-requests.ts b/scripts/test-requests.ts new file mode 100644 index 0000000..63cb988 --- /dev/null +++ b/scripts/test-requests.ts @@ -0,0 +1,17 @@ +const endpoints = [ + '/hello', + '/demo', + '/test', + '/message' +]; + +await Bun.sleep(5000); + +let count = 0; +while (count < 10) { + const endpoint = endpoints[count % endpoints.length]; + const response = await fetch(`http://localhost:8787${endpoint}`); + console.log(response); + await Bun.sleep(1000); + count++; +} \ No newline at end of file diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 4d90735..53b608b 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -31,11 +31,12 @@ const items = [ identifier: "dashboard", icon: LayoutDashboard, }, - // { - // title: "Requests", - // identifier: "requests", - // icon: Inbox, - // }, + { + title: "Requests", + href: "/requests", + identifier: "requests", + icon: Inbox, + }, { title: "D1", href: "/d1", diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..fd3a406 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/dist.css b/src/dist.css index 26db7b4..193cabe 100644 --- a/src/dist.css +++ b/src/dist.css @@ -7,8 +7,13 @@ "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --color-red-500: oklch(63.7% 0.237 25.331); --color-orange-500: oklch(70.5% 0.213 47.604); --color-orange-600: oklch(64.6% 0.222 41.116); + --color-yellow-500: oklch(79.5% 0.184 86.047); + --color-green-500: oklch(72.3% 0.219 149.579); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-gray-500: oklch(55.1% 0.027 264.364); --color-black: #000; --color-white: #fff; --spacing: 0.25rem; @@ -281,15 +286,15 @@ .mt-4 { margin-top: calc(var(--spacing) * 4); } + .mt-20 { + margin-top: calc(var(--spacing) * 20); + } .mt-auto { margin-top: auto; } .mr-auto { margin-right: auto; } - .mb-1 { - margin-bottom: calc(var(--spacing) * 1); - } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } @@ -533,20 +538,6 @@ .gap-4 { gap: calc(var(--spacing) * 4); } - .space-y-2 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); - } - } - .space-y-4 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); - } - } .gap-x-2 { column-gap: calc(var(--spacing) * 2); } @@ -571,6 +562,9 @@ .rounded-\[2px\] { border-radius: 2px; } + .rounded-full { + border-radius: calc(infinity * 1px); + } .rounded-lg { border-radius: var(--radius); } @@ -609,6 +603,9 @@ .border-input { border-color: var(--input); } + .border-muted-foreground { + border-color: var(--muted-foreground); + } .border-sidebar-border { border-color: var(--sidebar-border); } @@ -627,6 +624,12 @@ background-color: color-mix(in oklab, var(--color-black) 50%, transparent); } } + .bg-blue-500\/10 { + background-color: color-mix(in srgb, oklch(62.3% 0.214 259.815) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-blue-500) 10%, transparent); + } + } .bg-border { background-color: var(--border); } @@ -639,6 +642,15 @@ .bg-foreground { background-color: var(--foreground); } + .bg-gray-500 { + background-color: var(--color-gray-500); + } + .bg-green-500\/10 { + background-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-green-500) 10%, transparent); + } + } .bg-muted { background-color: var(--muted); } @@ -657,6 +669,12 @@ .bg-primary { background-color: var(--primary); } + .bg-red-500\/10 { + background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-500) 10%, transparent); + } + } .bg-secondary { background-color: var(--secondary); } @@ -669,6 +687,12 @@ .bg-transparent { background-color: transparent; } + .bg-yellow-500\/10 { + background-color: color-mix(in srgb, oklch(79.5% 0.184 86.047) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-yellow-500) 10%, transparent); + } + } .fill-foreground { fill: var(--foreground); } @@ -784,18 +808,15 @@ .text-balance { text-wrap: balance; } - .wrap-break-word { - overflow-wrap: break-word; - } .whitespace-nowrap { white-space: nowrap; } - .whitespace-pre-wrap { - white-space: pre-wrap; - } .text-background { color: var(--background); } + .text-blue-500 { + color: var(--color-blue-500); + } .text-card-foreground { color: var(--card-foreground); } @@ -805,6 +826,12 @@ .text-foreground { color: var(--foreground); } + .text-gray-500 { + color: var(--color-gray-500); + } + .text-green-500 { + color: var(--color-green-500); + } .text-muted-foreground { color: var(--muted-foreground); } @@ -820,6 +847,9 @@ .text-primary-foreground { color: var(--primary-foreground); } + .text-red-500 { + color: var(--color-red-500); + } .text-secondary-foreground { color: var(--secondary-foreground); } @@ -835,6 +865,9 @@ .text-white { color: var(--color-white); } + .text-yellow-500 { + color: var(--color-yellow-500); + } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); @@ -2118,6 +2151,17 @@ white-space: nowrap; } } + .\[\&\>svg\]\:pointer-events-none { + &>svg { + pointer-events: none; + } + } + .\[\&\>svg\]\:size-3 { + &>svg { + width: calc(var(--spacing) * 3); + height: calc(var(--spacing) * 3); + } + } .\[\&\>svg\]\:size-4 { &>svg { width: calc(var(--spacing) * 4); @@ -2167,6 +2211,60 @@ cursor: w-resize; } } + .\[a\&\]\:hover\:bg-accent { + a& { + &:hover { + @media (hover: hover) { + background-color: var(--accent); + } + } + } + } + .\[a\&\]\:hover\:bg-destructive\/90 { + a& { + &:hover { + @media (hover: hover) { + background-color: var(--destructive); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--destructive) 90%, transparent); + } + } + } + } + } + .\[a\&\]\:hover\:bg-primary\/90 { + a& { + &:hover { + @media (hover: hover) { + background-color: var(--primary); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--primary) 90%, transparent); + } + } + } + } + } + .\[a\&\]\:hover\:bg-secondary\/90 { + a& { + &:hover { + @media (hover: hover) { + background-color: var(--secondary); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--secondary) 90%, transparent); + } + } + } + } + } + .\[a\&\]\:hover\:text-accent-foreground { + a& { + &:hover { + @media (hover: hover) { + color: var(--accent-foreground); + } + } + } + } } :root { --background: hsl(0 0% 100%); @@ -2294,11 +2392,6 @@ inherits: false; initial-value: 0; } -@property --tw-space-y-reverse { - syntax: "*"; - inherits: false; - initial-value: 0; -} @property --tw-border-style { syntax: "*"; inherits: false; @@ -2446,7 +2539,6 @@ --tw-translate-x: 0; --tw-translate-y: 0; --tw-translate-z: 0; - --tw-space-y-reverse: 0; --tw-border-style: solid; --tw-leading: initial; --tw-font-weight: initial; diff --git a/src/frontend.tsx b/src/frontend.tsx index ad9e81a..cbb331f 100644 --- a/src/frontend.tsx +++ b/src/frontend.tsx @@ -13,8 +13,9 @@ import { kvRoute } from "./views/kv"; import { dashboardRoute } from "./views/dashboard"; import { d1Route } from "./views/d1"; import { durableObjectsRoute } from "./views/durable-objects"; +import { requestsRoute } from "./views/requests"; -const routeTree = rootRoute.addChildren([dashboardRoute, kvRoute, d1Route, durableObjectsRoute]) +const routeTree = rootRoute.addChildren([dashboardRoute, kvRoute, d1Route, durableObjectsRoute, requestsRoute]) const router = createRouter({ routeTree }) diff --git a/src/index.tsx b/src/index.tsx index a32b530..1ce9a06 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,7 @@ import { getProjects } from "./lib/cf/projects"; import { getD1, queryD1Db, type D1Response } from "./lib/cf/d1"; import { getDurableObjectsSql, queryDoDb, type DurableObjectResponse } from "./lib/cf/durable-objects"; import { extractRpcFromDirectory } from "./lib/rpc-extract"; +import { Capture } from "./lib/capture/capture"; const { values, positionals } = parseArgs({ args: Bun.argv, @@ -23,14 +24,8 @@ const { values, positionals } = parseArgs({ }); if (positionals.length == 2) { - - - const requests: RequestResponse = { - requests: [], - total: 0 - }; - const wsClients = new Set>(); + const capture = new Capture(wsClients); const server = serve({ routes: { @@ -43,7 +38,8 @@ if (positionals.length == 2) { }, "/api/requests": async req => { - return Response.json(requests); + const captures = await capture.getCaptures(); + return Response.json(captures); }, "/api/kv": async req => { @@ -143,6 +139,10 @@ if (positionals.length == 2) { return Response.json({ error: String(error) }, { status: 500 }); } }, + "/api/capture/start": async req => { + capture.start(8787); + return Response.json({ message: "Capture started" }); + }, "/api/ws": async req => { // upgrade the request to a WebSocket if (server.upgrade(req)) { diff --git a/src/lib/capture/capture.ts b/src/lib/capture/capture.ts new file mode 100644 index 0000000..21a046a --- /dev/null +++ b/src/lib/capture/capture.ts @@ -0,0 +1,175 @@ +import { sleep, type ServerWebSocket } from "bun"; +import { detectCaptureTool } from "./utils"; + +export interface Captures { + id: string; + request: any + response: any +} + +interface PendingRequest { + id: string; + stream: string; + request: any; + timestamp: number; +} + +export class Capture { + private process: Bun.Subprocess | null = null; + private wsClients: Set>; + private captures: Captures[] = []; + // Map of stream number -> queue of pending requests (FIFO) + private pendingRequests: Map = new Map(); + private readonly REQUEST_TIMEOUT_MS = 30000; // 30 seconds + + constructor(wsClients: Set>) { + this.wsClients = wsClients; + } + + async start(port: number): Promise { + const config = await detectCaptureTool(); + if (!config) { + throw new Error("No capture tool found"); + } + + this.process = Bun.spawn([config.command, ...config.args], { + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = this.process.stdout; + if (!stdout || typeof stdout === "number") { + throw new Error("Failed to open stdout pipe"); + } + + // Bun's ReadableStream is async iterable at runtime + for await (const chunk of stdout as any) { + const decoder = new TextDecoder(); + const str = decoder.decode(chunk); + const removed = str.substring(1) + const trimmed = removed.trim(); + const tidied = trimmed.replace(/\\n\\r/g, ""); + const json = JSON.parse(tidied); + + const http = json._source.layers.http; + const tcp = json._source.layers.tcp; + const dataText = json._source.layers["data-text-lines"]; + + if (!http || !tcp) { + continue; + } + + // Extract TCP stream number - this is the key to matching requests/responses + const streamNumber = tcp["tcp.stream"]?.[0]; + if (!streamNumber) { + continue; + } + + const unknownKey = Object.keys(http).find( + key => key.includes(" ") || key.includes("\r") || key.includes("\n") + ); + + if (!unknownKey) { + continue; + } + + const unknownValue = http[unknownKey]; + if (!unknownValue) { + continue; + } + + // Clean up stale pending requests periodically + this.cleanupStaleRequests(); + + const httpClone = { ...http }; + const clonedWeirdValues = httpClone[unknownKey]; + delete http[unknownKey] + http.details = clonedWeirdValues; + + // Check if it's a request + if ("http.request.version" in unknownValue) { + console.log(`Request detected - stream: ${streamNumber}`); + + + const id = Bun.randomUUIDv7(); + const pendingRequest: PendingRequest = { + id, + stream: streamNumber, + request: http, + timestamp: Date.now(), + }; + + // Add to queue for this stream (FIFO) + if (!this.pendingRequests.has(streamNumber)) { + this.pendingRequests.set(streamNumber, []); + } + this.pendingRequests.get(streamNumber)!.push(pendingRequest); + } + // Check if it's a response + else if ("http.response.version" in unknownValue) { + console.log(`Response detected - stream: ${streamNumber}`); + + const requestQueue = this.pendingRequests.get(streamNumber); + + if (requestQueue && requestQueue.length > 0) { + // Match with the oldest pending request (FIFO) + const pendingRequest = requestQueue.shift()!; + + // Match found! Create the complete capture + const capture: Captures = { + id: pendingRequest.id, + request: pendingRequest.request, + response: { response: http, data: dataText }, + }; + + this.wsClients.forEach(ws => { + ws.send(JSON.stringify({ + type: "capture", + capture: capture, + })); + }); + + this.captures.push(capture); + console.log(`Matched request/response pair: ${pendingRequest.id}`); + } else { + // Response without a matching request (might be from before we started capturing) + console.log(`Response without matching request - stream: ${streamNumber}`); + } + } + } + + const stderr = this.process.stderr; + if (!stderr || typeof stderr === "number") { + throw new Error("Failed to open stderr pipe"); + } + + this.process.exited.then((code) => { + console.log(`Capture process exited with code ${code}`); + }); + } + + private cleanupStaleRequests(): void { + const now = Date.now(); + + for (const [streamNumber, requestQueue] of this.pendingRequests.entries()) { + // Remove stale requests from the front of the queue + while (requestQueue.length > 0) { + const firstRequest = requestQueue[0]; + if (!firstRequest || now - firstRequest.timestamp <= this.REQUEST_TIMEOUT_MS) { + break; + } + const stale = requestQueue.shift()!; + console.log(`Cleaning up stale request - stream: ${stale.stream}, id: ${stale.id}, age: ${now - stale.timestamp}ms`); + } + + // Remove empty queues + if (requestQueue.length === 0) { + this.pendingRequests.delete(streamNumber); + } + } + } + + async getCaptures(): Promise { + return this.captures; + } +} \ No newline at end of file diff --git a/src/lib/capture/utils.ts b/src/lib/capture/utils.ts new file mode 100644 index 0000000..7dab295 --- /dev/null +++ b/src/lib/capture/utils.ts @@ -0,0 +1,63 @@ +export interface CaptureConfig { + command: string; + interface: string; + args: string[]; +} + +export async function detectCaptureTool(): Promise { + const platform = process.platform; + let interfaceName: string; + let command: string | null = null; + + // Determine network interface based on platform + if (platform === "darwin") { + // macOS + interfaceName = "lo0"; + } else if (platform === "linux") { + // Linux + interfaceName = "lo"; + } else { + throw new Error(`Unsupported platform: ${platform}`); + } + + // Check for tshark first (common on Mac and many Linux distros) + const tsharkAvailable = await checkCommandAvailable("tshark"); + if (tsharkAvailable) { + command = "tshark"; + } else { + // Fallback to wireshark-cli (some Linux distros) + const wiresharkCliAvailable = await checkCommandAvailable("wireshark-cli"); + if (wiresharkCliAvailable) { + command = "wireshark-cli"; + } + } + + if (!command) { + return null; + } + + return { + command, + interface: interfaceName, + args: [ + "-i", interfaceName, + "-l", // Flush output after each packet + "-Y", "http", + "-T", "json", + "tcp", "port", "8787" + ], + }; +} + +async function checkCommandAvailable(command: string): Promise { + try { + const process = Bun.spawn(["which", command], { + stdout: "pipe", + stderr: "pipe", + }); + await process.exited; + return process.exitCode === 0; + } catch { + return false; + } +} \ No newline at end of file diff --git a/src/views/requests.tsx b/src/views/requests.tsx index 60aaacf..7015add 100644 --- a/src/views/requests.tsx +++ b/src/views/requests.tsx @@ -1,10 +1,23 @@ +import { rootRoute } from "@/App"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import type { RequestResponse, StoredRequest } from "@/lib/cf/request"; +import type { Captures } from "@/lib/capture/capture"; +import { createRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Pause, Play } from "lucide-react"; + +export const requestsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/requests', + component: Requests, +}) + export function Requests() { - const [requests, setRequests] = useState(null); + const [requests, setRequests] = useState([]); const socket = new WebSocket("ws://localhost:3000/api/ws"); + const [live, setLive] = useState(false); useEffect(() => { fetch("/api/requests") @@ -22,104 +35,97 @@ export function Requests() { socket.onmessage = (event) => { const data = JSON.parse(event.data); - if (data.type === "new_request") { + if (data.type === "capture") { // @ts-ignore - console.log("New request received", data.request); - setRequests(prev => ({ - requests: [data.request, ...(prev?.requests || [])], - total: (prev?.total || 0) + 1 - })); - } else if (data.type === "response_received") { - console.log("Response received for request:", data); - setRequests(prev => { - if (!prev) return prev; - const updatedRequests = prev.requests.map(req => { - if (req.id === data.requestId) { - return { - ...req, - responseStatus: data.response.status, - responseHeaders: data.response.headers, - responseBody: data.response.body, - }; - } - return req; - }); - return { - ...prev, - requests: updatedRequests - }; - }); + console.log("New request received", data.capture); + setRequests(prev => [data.capture, ...prev]); } }; }, []); - const parseWholeRequest = (wholeRequest: string) => { - return JSON.parse(wholeRequest); + const startLive = async () => { + try { + const response = await fetch("/api/capture/start"); + if (response.ok) { + setLive(true); + } + } catch (error) { + console.error("Error starting live capture", error); + } } - return (

Requests

+
- {requests && requests.requests.length > 0 && ( - - {requests.requests.map((request: StoredRequest) => ( - - - {request.method} {request.path} - {request.responseStatus && ( - - ({request.responseStatus}) - - )} - - -
-
-

Request

- {Object.entries(parseWholeRequest(request.wholeRequest)).map(([key, value]) => ( -
-

{key as string}:

-

{typeof value === "string" ? value : JSON.stringify(value)}

-
- ))} + {requests.length > 0 && ( +
+ {requests.map(request => ( + + + +
+

{request.request.details["http.request.method"]} {request.request.details["http.request.uri"]}

+
- {request.responseStatus && ( -
-

Response

-
-
-

Status:

-

{request.responseStatus}

-
- {request.responseHeaders && Object.keys(request.responseHeaders).length > 0 && ( -
-

Headers:

- {Object.entries(request.responseHeaders).map(([key, value]) => ( -
-

{key}:

-

{value}

-
- ))} -
- )} - {request.responseBody && ( -
-

Body:

-

{request.responseBody}

-
- )} -
-
- )} -
- - + + + Yes. It adheres to the WAI-ARIA design pattern. + + + ))} - +
)} + {requests.length === 0 && ( +
+

No requests yet

+
+ )} +
+ ) +} + +function ResponseBadge({ responseCode, responseStatus }: { responseCode: string, responseStatus: string }) { + + const calculateBgStyling = () => { + const code = parseInt(responseCode); + if (code >= 100 && code < 200) { + return "bg-yellow-500/10"; + } else if (code >= 200 && code < 300) { + return "bg-green-500/10"; + } else if (code >= 300 && code < 400) { + return "bg-blue-500/10"; + } else if (code >= 400 && code < 500) { + return "bg-red-500/10"; + } else { + return "bg-gray-500"; + } + } + + const calculateTextStyling = () => { + const code = parseInt(responseCode); + if (code >= 100 && code < 200) { + return "text-yellow-500"; + } else if (code >= 200 && code < 300) { + return "text-green-500"; + } else if (code >= 300 && code < 400) { + return "text-blue-500"; + } else if (code >= 400 && code < 500) { + return "text-red-500"; + } else { + return "text-gray-500"; + } + } + + return ( +
+

{responseCode}

+

{responseStatus}

) -} \ No newline at end of file +}