Skip to content

Architecture overview

ProxyPro splits its work across two processes joined by a Unix domain socket. This page describes the shape of the system so you can read the source without getting lost.

Process model

┌───────────────────────────────────────────────────┐ ┌────────────────────────────┐
│ Electron App │ │ proxypro-engine (Go) │
│ │ │ │
│ ┌─────────────┐ contextBridge ┌──────────┐│gRPC│ │
│ │ Renderer │◀──ipcRenderer──────▶│ Main │◀┼─UDS─▶ HTTP MITM proxy on 9090 │
│ │ React+Vite │ (typed) │ Node ││ │ capture stream + bodies │
│ │ shadcn/ui │ │ grpc-js ││ │ rules engine │
│ └─────────────┘ └──────────┘│ │ cert + mobileconfig │
└───────────────────────────────────────────────────┘ └────────────────────────────┘
▼ HTTP proxy :9090
browsers / apps / devices

Engine (Go)

  • Single static binary at engine/bin/proxypro-engine (universal arm64+amd64).
  • Spawned and supervised by Electron’s main process.
  • Two listeners:
    • Unix socket — gRPC control plane (/tmp/proxypro-<pid>.sock)
    • TCP :9090 — HTTP proxy that browsers + apps configure.

Electron main (Node.js)

  • Owns the engine subprocess lifecycle (spawn, restart, graceful shutdown).
  • Hosts the @grpc/grpc-js client that talks to the engine.
  • Exposes a narrow, typed IPC surface (window.api.*) to the renderer via contextBridge. Renderer cannot touch raw ipcRenderer.

Electron renderer (React)

  • Pure React 18 + TanStack Table + Tailwind. No grpc, no Node APIs.
  • Subscribes to the capture stream through the main process.
  • Renders the capture list (virtualized), detail tabs, rules editor, and mobile setup UI.

Why two processes?

We could put everything in the Electron main process via Node addons or re-implementing the MITM in TypeScript. We split because:

ConcernWon by
Performance under load (10k flows/sec, gzip decode)Go
Cross-compile to a single static binaryGo
Mature TLS / HTTP/2 stackGo (crypto/tls, golang.org/x/net/http2)
Quick UI iterationReact + Vite HMR
Massive component ecosystemnpm
Sandboxed by default (contextIsolation)Electron

Keeping them separate also means the engine can ship headless someday — a CLI consumer of the same gRPC API.

IPC contracts

Two layers:

  1. Engine ⇄ main — gRPC, schema in proto/engine.proto. Service: Engine with RPCs Ping, GetVersion, StreamCaptures, GetRootCA, GetBody, UpdateRules, ReplaceSessionRules, DeleteSessionRules, ListRules, GetLANInfo, GetMobileConfig.
  2. Main ⇄ renderer — Electron IPC, typed channels declared in app/src/shared/ipc-channels.ts.

The renderer never speaks gRPC. The main process bridges the two layers, batching capture events at 30fps before pushing to the renderer so the UI applies at most one re-render per frame.

MCP rule sandboxes

The localhost MCP host creates a private identity for every transport. Rule and mock-state tools resolve it through async call context, so it is never accepted from model input. The engine stores sandbox_session_id on ephemeral rules and matches them only when a proxied request carries the same internal x-proxypro-session marker; persistent Desktop rules are the fallback.

ReplaceSessionRules atomically changes one owner without disturbing other agents or global rules. Those rules are filtered from SQLite persistence. Browser, replay, and Lighthouse traffic receive the marker internally, while disconnect/close/idle/shutdown cleanup calls DeleteSessionRules and removes namespaced mock state. Desktop joins that runtime ownership into a live Session Control Center and offers exact-owner browser close, sandbox cleanup, and transport termination. PromoteSessionRules verifies the selected IDs still belong to that owner and atomically moves them to the persistent/global set; namespaced state is copied to a collision-safe persistent ID first.

The same marker remains on the captured request and powers attribution rather than access control. Desktop joins the standard MCP client identity, recent tool calls, and materialized flows into per-session metrics and a time-ordered timeline. Browser tool results expose an action ID; requests carry the matching step marker, so the UI can display both sides with the same number. Captures supports the exact agent:<session-id> query and evidence export contains summaries only—no cloud service is involved.

Capture model

A request through the proxy emits a chain of FlowEvent records:

STARTED → REQUEST_HEADERS → REQUEST_BODY →
RESPONSE_HEADERS → RESPONSE_BODY → COMPLETED

Each event references the same flow_id. The renderer collapses them into a single FlowSummary per flow, upserting fields as events arrive.

The engine keeps a 10k-event in-memory ring buffer + a 100MB LRU for oversized request/response bodies. Bodies ≤ 64KiB ship inline in the event; larger ones live in the LRU and require an explicit GetBody fetch.

Cert + key management

On first run the engine:

  1. Generates an ECDSA P-256 root CA with 10-year validity.
  2. Persists root.pem + root.key.pem (mode 0600) under ~/Library/Application Support/ProxyPro/ca/.
  3. Mints per-SNI leaf certificates on demand, cached for 24h in memory.

The leaf cert advertises both h2 and http/1.1 in ALPN. After the client TLS handshake, the engine branches on the negotiated protocol and dispatches to either http.Server (h1) or http2.Server.ServeConn (h2).

Build artifacts

TargetOutput
make engineengine/bin/proxypro-engine (universal binary, ldflags-stamped version)
make appapp/out/{main,preload,renderer} (Vite production bundles)
make distapp/dist/ProxyPro.app + .dmg (ad-hoc signed via electron-builder)
make devbuilds engine + runs pnpm dev with HMR
make testruns Go unit + integration tests
pnpm --dir app testVitest renderer + main unit tests
pnpm --dir app test:e2ePlaywright Electron E2E

Further reading