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 / devicesEngine (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.
- Unix socket — gRPC control plane (
Electron main (Node.js)
- Owns the engine subprocess lifecycle (spawn, restart, graceful shutdown).
- Hosts the
@grpc/grpc-jsclient that talks to the engine. - Exposes a narrow, typed IPC surface (
window.api.*) to the renderer viacontextBridge. Renderer cannot touch rawipcRenderer.
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:
| Concern | Won by |
|---|---|
| Performance under load (10k flows/sec, gzip decode) | Go |
| Cross-compile to a single static binary | Go |
| Mature TLS / HTTP/2 stack | Go (crypto/tls, golang.org/x/net/http2) |
| Quick UI iteration | React + Vite HMR |
| Massive component ecosystem | npm |
| 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:
- Engine ⇄ main — gRPC, schema in
proto/engine.proto. Service:Enginewith RPCsPing,GetVersion,StreamCaptures,GetRootCA,GetBody,UpdateRules,ReplaceSessionRules,DeleteSessionRules,ListRules,GetLANInfo,GetMobileConfig. - 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 → COMPLETEDEach 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:
- Generates an ECDSA P-256 root CA with 10-year validity.
- Persists
root.pem+root.key.pem(mode 0600) under~/Library/Application Support/ProxyPro/ca/. - 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
| Target | Output |
|---|---|
make engine | engine/bin/proxypro-engine (universal binary, ldflags-stamped version) |
make app | app/out/{main,preload,renderer} (Vite production bundles) |
make dist | app/dist/ProxyPro.app + .dmg (ad-hoc signed via electron-builder) |
make dev | builds engine + runs pnpm dev with HMR |
make test | runs Go unit + integration tests |
pnpm --dir app test | Vitest renderer + main unit tests |
pnpm --dir app test:e2e | Playwright Electron E2E |
Further reading
- Protocol coverage — what we decrypt and what we can’t.
- Contributing — repo layout, testing conventions, plan files.
- Source on GitHub.