DEV Community

Cover image for I Built an ngrok-Style Request Inspector for Cloudflare Tunnels
Aniket Jadhav
Aniket Jadhav

Posted on

I Built an ngrok-Style Request Inspector for Cloudflare Tunnels

If you've ever used ngrok for local development, you know how good the built-in request inspector is. You open localhost:4040, and there's a live dashboard showing every request hitting your tunnel — full headers, request and response bodies, status codes, timing. It's one of those tools that becomes indispensable the moment you use it.

Then you switch to Cloudflare Tunnels.

cloudflared is fantastic — it's free, it's fast, and it integrates beautifully with Cloudflare's ecosystem. But it has one glaring gap: there's no request inspector. You're flying blind. A webhook fires and you have no idea what payload it sent. A request fails and you can't see the headers. You're left sprinkling console.log statements everywhere and hoping for the best.

So I built one.


Introducing Tunnel Inspector

Tunnel Inspector is a lightweight, open source tool that gives cloudflared the inspector it always should have had.

It works by placing a small proxy between cloudflared and your app. The proxy captures every request and response, then streams them in real time to a polished local dashboard built with Next.js.

Internet → cloudflared → Proxy (:8080) → Your App (:3000)
                              ↓
                         SSE (:4040)
                              ↓
                      Inspector UI (/inspector)
Enter fullscreen mode Exit fullscreen mode

All traffic is inspected locally and never leaves your machine.


The Problem in Detail

Here's a typical debugging session before Tunnel Inspector:

  1. You're building a Stripe webhook handler
  2. Stripe fires an event to your cloudflared tunnel
  3. Something goes wrong — your handler throws an error
  4. You have no idea if the payload arrived malformed, if a header was missing, or if the signature verification failed
  5. You add logging, redeploy, trigger the event again, check your terminal, repeat

With Tunnel Inspector, step 4 becomes: open the dashboard, click the request, see exactly what Stripe sent.


How It Works

The proxy server

The core of the tool is a single file — server/proxy.mjs — built with zero npm dependencies. Just Node.js's built-in http module.

Every incoming request goes through this flow:

1. Request arrives at :8080
2. Body is buffered (chunks collected as they stream in)
3. Request forwarded to your app at :3000
4. Response captured (status, headers, body)
5. Entry stored in circular buffer (max 200)
6. Entry broadcast to all connected SSE clients
7. UI updates instantly
Enter fullscreen mode Exit fullscreen mode

Here's the core of the capture logic:

const proxyServer = http.createServer((req, res) => {
  const id = generateId();
  const startedAt = new Date().toISOString();
  const start = performance.now();
  const reqChunks = [];

  req.on("data", (chunk) => reqChunks.push(chunk));
  req.on("end", () => {
    const reqBody = tryParseJson(Buffer.concat(reqChunks).toString("utf-8"));

    const proxyReq = http.request({ ...options }, (proxyRes) => {
      const resChunks = [];
      proxyRes.on("data", (chunk) => resChunks.push(chunk));
      proxyRes.on("end", () => {
        const entry = {
          id, method: req.method, url: req.url,
          status: proxyRes.statusCode,
          duration: Math.round(performance.now() - start),
          startedAt, reqHeaders: req.headers, reqBody,
          resHeaders: proxyRes.headers,
          resBody: tryParseJson(Buffer.concat(resChunks).toString("utf-8")),
        };
        buffer.unshift(entry);
        broadcast("response", entry); // SSE to all clients
        // forward response back to original caller
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

The key insight is that we're collecting the full body before forwarding, which means we can inspect it — but we're also forwarding it faithfully so your app receives it exactly as it arrived.

The SSE server

The second server runs on :4040 and handles two things:

  • GET /events — an SSE stream. New clients immediately receive the full history as a history event, then get response events in real time as new requests come in
  • GET /api/requests — returns the current buffer as JSON, useful for tooling
if (req.url === "/events") {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });
  // Send full history on connect
  res.write(`event: history\ndata: ${JSON.stringify(buffer)}\n\n`);

  sseClients.add(res);
  req.on("close", () => sseClients.delete(res));
}
Enter fullscreen mode Exit fullscreen mode

The React hook

On the frontend, a custom hook manages the SSE connection:

export function useInspectorFeed() {
  const [requests, setRequests] = useState<InspectorRequest[]>([]);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    const es = new EventSource("http://localhost:4040/events");

    es.addEventListener("history", (e) => {
      setRequests(JSON.parse(e.data));
    });

    es.addEventListener("response", (e) => {
      const entry = JSON.parse(e.data);
      setRequests((prev) => [entry, ...prev].slice(0, 200));
    });

    es.onerror = () => {
      setConnected(false);
      es.close();
      setTimeout(connect, 3000); // auto-reconnect
    };
  }, []);

  return { requests, connected, clear };
}
Enter fullscreen mode Exit fullscreen mode

No polling, no WebSocket complexity, no library. Server-Sent Events are a perfect fit here — the data only ever flows one way (server to browser), and they reconnect automatically.


The Inspector UI

The dashboard is a split-panel layout built with Next.js, Tailwind CSS, and React 19.

Left panel — request list

Each row shows a colour-coded method badge (GET in teal, POST in amber, DELETE in red, PUT in blue, PATCH in purple), the URL path, status code badge, and response time. New requests slide in at the top with a subtle fade animation.

The toolbar has a text search (filters by method + URL) and method filter chips.

Right panel — detail view

Three tabs per request:

Overview — metadata grid (method, status, duration, timestamp, full URL) plus a side-by-side view of request and response headers.

Request — full request headers and body.

Response — full response headers and body.

Body viewer

The body viewer is one of the more interesting parts of the project. It auto-detects the content format and applies syntax highlighting:

Format What it highlights
JSON Keys in indigo, strings in emerald, numbers in amber, booleans/null in purple
GraphQL-over-JSON Splits the JSON into Operation, Query (GraphQL syntax highlighted), and Variables
HTML / XML Tags, attributes, attribute values, comments
JavaScript Keywords, strings, types, identifiers, comments
Form data Decoded key-value pairs in a structured table
Multipart Parsed parts with name, filename, content-type
Plain text Monospaced fallback

Every code block has a copy-to-clipboard button.

The GraphQL-over-JSON detection was particularly satisfying to build — it checks if the parsed JSON has a query field that starts with a GraphQL keyword, then splits the display into separate sections rather than treating it as a generic JSON blob.


Getting Started

1. Clone and install

git clone https://github.com/BlockbusterAndy/cloudflare-tunnel-inspector.git
cd cloudflare-tunnel-inspector
npm install
Enter fullscreen mode Exit fullscreen mode

2. Update your cloudflared config

# ~/.cloudflared/config.yml
ingress:
  - hostname: yourdomain.com
    service: http://localhost:8080  # point to proxy, not your app
  - service: http_status:404
Enter fullscreen mode Exit fullscreen mode

3. Start everything

npm run dev:inspect
Enter fullscreen mode Exit fullscreen mode

This runs both the Next.js dev server and the proxy concurrently. Open http://localhost:3000/inspector and send some traffic through your tunnel.


Design system

The UI uses a custom design system called Obsidian Lens — a deep dark aesthetic where hierarchy is expressed through tonal shifts rather than borders.

The surface hierarchy goes:

#131315  →  #201f22  →  #2a2a2c  →  #353437
  base      container    high       highest
Enter fullscreen mode Exit fullscreen mode

No solid borders between sections. Just carefully chosen background shades that create a sense of depth. Combined with JetBrains Mono for all technical data and Inter for UI labels, it gives the inspector a premium developer tool feel rather than a generic dark theme.


What's next

The project is open source and actively looking for contributors. Areas I'd love help with:

  • WebSocket inspection — currently WS upgrade requests are forwarded but not captured
  • Request replay — re-fire any captured request directly from the UI
  • CLI wrappernpx tunnel-inspector that wraps any dev server
  • Export — download the captured buffer as .ndjson
  • Status code filtering — filter the list by 2xx / 4xx / 5xx

If any of these interest you, the repo has good first issue labels and a full CONTRIBUTING.md to help you get started.


Wrapping up

The core insight behind this project is simple: the proxy pattern is extremely powerful for observability. By placing a thin layer between two services, you get full visibility into what's flowing between them with zero changes to either side.

The whole proxy is 120 lines of vanilla Node.js. No framework, no dependencies. The rest is just a nice UI on top.

If you use cloudflared in your development workflow, give it a try. And if you've built something similar or have ideas for making it better, I'd love to hear from you.

GitHub: https://github.com/BlockbusterAndy/cloudflare-tunnel-inspector


Built with Next.js 16, React 19, Tailwind CSS 4, and zero-dependency Node.js.

Top comments (0)