This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
FeProxy is a Fiddler-like capture proxy (Node + Koa) that uses the Chrome DevTools frontend as its capture UI and can rewrite/forward HTTP(S)/WebSocket requests according to rules. Published as a global npm CLI (bin/feproxy.js → lib/cli.js).
npm run dev # tsc -w + webpack -w + nodemon
npm run build # build:ts (tsc → lib/) + build:web (webpack → lib/public/)
npm run typecheck # must run twice: tsc --noEmit (node side) + tsc -p tsconfig.web.json (frontend)
npm test # lint + build + jest; this is what CI runs
# a single test file / case
npx cross-env NODE_ENV=testing npx jest test/proxy.test.ts -t 'case name' --coverage=falseTesting gotchas:
NODE_ENV=testingis mandatory..babelrconly enables the commonjs transform under that env; without it you get "Cannot use import statement outside a module".- jest collects coverage by default and has global thresholds, so pass
--coverage=falsewhen running a single file. - Tests run against
src/directly, butsrc/util/paths.tspoints atlib/public, so any case touching static assets / devtools / the admin page needsnpm run buildfirst. - Every suite uses
startApp()fromtest/util/util.tsto boot a real proxy server (random port,RC_DIRundertest/.tmp/). Cases that need an upstream site usestartUpstream()to boot a local http/https upstream — do not reintroduce public internet addresses (CI can't reach them, which makes every https case time out, including the pre-MITM upstream certificate probe). - Frontend cases switch environments via the file-header
@jest-environment <rootDir>/test/util/jsdomEnvironment.js(jsdom plus polyfilledReadableStream/Response/TextEncoder, otherwise koa 3 throwsReferenceError).
The entry point is net.createServer; it reads the first data packet and dispatches:
CONNECT host:port→ reply 200, then read one more packet:GET /is treated as a ws tunnel (rewritten toGET ws://host:port/path); otherwise, ifconfig.httpsis truthy, decrypt with a self-signed certificate (MITM), and if falsy, pass through as raw TCP.- First byte
0x16(TLS handshake) → the TLS server for the local hostname. - Printable bytes → the HTTP server (plain request / http proxy / Upgrade).
Dispatch relies on socket.pause()/unshift()/resume(); unshift must be called synchronously inside the data callback, with no await in between. Proxy authentication runs on the first packet before dispatch (see §7).
ServerFactory LRU-caches https servers by group:hostname:port (it caches the promise so concurrent callers share a single creation). It first probes the upstream's real certificate: if validation passes (or ignoreCertError is on) it signs with the trusted root, otherwise with the untrust root — so sites that already had certificate problems keep erroring in the browser. The root certificate is feproxy-<user>.crt, stored in RC_DIR (default ~/.feproxy).
After MITM the request line is only /path, so ctx.url becomes a getter that rebuilds the absolute URL from socket.server.proxy (the {hostname, port} marker ProxyServer attaches). All routing and rule matching depends on ctx.url being an absolute URL.
@koa/router picks the path to match in the order opts.routerPath || ctx.newRouterPath || ctx.path || ctx.routerPath. Since ctx.path — pathname only — comes before routerPath, absolute-URL routes would all fail to match, so we define an extra newRouterPath getter with the highest priority. The router also writes back to ctx.routerPath after matching, so that property has an empty setter to swallow the write. Re-check both when upgrading the router.
@koa/router uses regexes on the protocol prefix to separate "proxied traffic" from "feproxy's own site":
^https?://.*→middleware/inspect+middleware/proxy^wss?://.*→middleware/wsInspect+middleware/proxy- plain paths →
/feproxy.crt,/log,/getConfig,/setConfig,/ws(CDP),/devtools/*
The same routes are registered on both app.use() and app.ws.use(). Anonymous capture groups in regex routes do not land in ctx.params — read them from ctx.captures.
The verifyClient hook of ws builds a Koa ctx and runs the middleware chain; ctx.accept() completes the handshake and resolves the socket (this is not Koa's content negotiation). Not calling accept yields an automatic 404/500.
There is exactly one global ws.Server (noServer mode, clientTracking disabled); attach(server) hooks it onto the upgrade event of every http/https server. Under MITM the https servers are created per domain, so don't go back to "one ws.Server per server".
- Rules come from
config.getRules(inspector.getBlockedURLs()):status404 rules derived from devtools blockedURLs come first, then the user's project rules. Each is regex-matched againstctx.url, and only the first hit per type is used. After that, plugins' own defaultmatchfills in, and everything is ordered by descendingpriorityinto a Koa-likenext()chain. http/websocket(10) are terminal plugins and don't callnext();header(50) modifies response headers afternext();host(50) works by rewriting the param of thehttpplugin on the chain; thendelay(80),status(30),file(20).- The
httpplugin attaches a PassThrough as the forwarded response body (to avoid keep-alive "socket hang up"), but responses without a body (HEAD and 204/205/304) must never get a body: koa setsctx.body = nullfor those statuses, the already-registeredonFinish(res, destroy)destroys the stream, the capture side'sreadStreampromise never settles, and devtools never receivesloadingFinished. You also can't setctx.body = ''(that rewritescontent-length, making HEAD's response headers differ from GET's); justproxy.res.resume()to drain the upstream response, otherwise the keep-alive socket is never returned to the pool. - Strings in rule params support
$1,$2back-references to the match regex's capture groups (seematchReg). - Adding a plugin = a new file in
src/proxy/+ registration inproxyPlugins.ts(priority / default match) + a new entry inRULE_TYPESof the frontend'scomponent/RuleInput.tsx(protocol name + the param fields it reads).
We implement a subset of CDP ourselves: Inspector owns multiple Clients (one per devtools connected to /ws); the network/page/websocket modules register methods to answer requests and push events via sendAll. Response bodies are cached in an LRU pool keyed by requestId; SSE goes through eventSourceMessageReceived.
Unimplemented methods must return a protocol error ({ error: { code: -32601, message: "'X' wasn't found" } }). The frontend sends 40+ commands at startup; the only implemented ones are Network.enable, Network.getResponseBody, Network.streamResourceContent, Network.setBlockedURLs, Network.replayXHR, Page.getResourceTree, Page.getResourceContent. Answering an unknown method with "success but empty result" makes the newer frontend deref into a TypeError and destroy the UI. FEPROXY_CDP_DEBUG=1 prints every command and whether it is MISSING.
Network.streamResourceContent is the exception — it must never return an error. The frontend picks between getResponseBody and streamResourceContent based on whether the request has finished transferring, and the result is cached — returning an error means that request's Preview/Response stays empty forever (the SSE message view always goes through this path). gzip has to be decoded as a whole so it can't be delivered chunk by chunk; bodyWaiters parks a waiter until the response body is fully read and then answers in one shot, waiting at most STREAM_TIMEOUT (30s, unref'd). Likewise Network.dataReceived must carry encodedDataLength — the frontend only accumulates when it is !== -1, and omitting it produces NaN.
config.inspect (default true) is the master capture switch: when falsy the inspect middleware just calls next(), emitting no events and reading no bodies, so the devtools UI still works but shows no requests. It is a startup-only setting: --no-inspect or inspect in config.json. ProxyConfig.update() strips the field, so /setConfig can't change it, and the admin panel renders its switch disabled (read-only, still showing the effective value).
The official chrome-devtools-frontend npm package ships only TS sources (building it requires depot_tools), so we use @chrome-devtools/inspector — upstream's weekly build artifacts (unpatched, not a chii-style fork). The version must be pinned (install with -E for an exact version).
inspector.html is rewritten by controller/devtools.ts before being served: an external feproxy-entry.js is inserted after <body> (the html carries script-src 'self', so it has to be an external script, and it must come before the module chunk), and CSP's connect-src gets ws: wss: appended (some versions only allow ws://127.0.0.1:*, which breaks phones connecting over a LAN IP). feproxy-entry.js exists solely to seed devtools settings into localStorage before the frontend boots: screencast-enabled=false, disable-locale-info-bar=true.
src/frontend/asset/devtools/ is a local override directory (it takes precedence over same-named files inside the package) and holds the injected script and the formatter worker. We only use the Network panel, so the devtoolsURL served by controller/site.ts carries ?panel=network. The admin UI entry point (.open-settings in component/App.tsx) is a sibling of the devtools iframe, floating above it via position: fixed + z-index — no postMessage involved.
Basic proxy auth with credentials hardcoded in auth in defaultConfig.ts (default feproxy/feproxy, enable: false). ProxyConfig.update() strips the auth field, so it can't be changed through /setConfig. Both config endpoints answer through publicConfig() in controller/site.ts, a whitelist of what the admin page reads (projects/https/ignoreCertError/inspect plus auth: { enable }, and the derived devtoolsURL/crtURL on getConfig) — so credentials, RC_DIR, hostname, port and the rule cache stay on the server, and the page shows auth as a disabled switch. Extend that whitelist when the frontend starts reading a new field.
Auth applies only to proxied traffic; direct requests to feproxy's own site are never checked, so enabling auth doesn't affect the devtools UI. Verification happens in ProxyServer.onSocket, before protocol dispatch, once per connection on the first packet (after which the whole tunnel is trusted): isProxyRaw decides whether the first packet is a proxy request (CONNECT host:port / GET http://host/path), and if so scans the raw message for Proxy-Authorization, replying 407 + Proxy-Authenticate and end()ing on failure. TLS first packets are naturally skipped (writing plaintext into a TLS connection would just make the peer hang up). proxy/http.ts and proxy/websocket.ts strip the hop-by-hop proxy-authorization header when forwarding.
Precedence: defaultConfig.ts ← ~/.feproxy/config.json ← the createApp(config) argument. App.ts merges with pickDefined() to drop undefined values (the auth sub-object is merged once more on its own). ProxyConfig.update() persists projects/https/ignoreCertError to disk and rebuilds the rules; it strips auth and inspect from the payload, and doesn't write inspect back either (a hand-written one in config.json survives through the read-merge-write), so a one-off --no-inspect run never becomes permanent. A built-in rule forwards http(s)://feproxy.org/* to the local server — that's why src/frontend/asset/log.js (piping page console output to the terminal) works.
The CLI uses yargs to map arguments one-to-one onto the fields of defaultConfig.ts and passes them to createApp(). When adding a config field, remember to add the matching CLI argument.
Multi-page webpack: each src/frontend/page/*/index.tsx directory is one chunk with a sibling index.html template; output goes to lib/public/, and all of src/frontend/asset/ is copied over. There is currently only the admin page: a full-screen iframe embedding devtools plus a settings overlay, redux + thunk, with setConfig debounced by 1s.
Rules are edited in the backend's own {type, param} shape — no display/wire conversion. component/RuleInput.tsx owns one rule row: the match regex, a <select> for the protocol (= plugin type), then the fields declared for that protocol in RULE_TYPES (header and any unknown type fall back to a free-form name/value list, so a hand-written config.json stays editable). New rules start out as http, and the select's empty option is rendered only while type is empty — a protocol-less rule is a no-op (updateRules() drops it), so it exists just to represent what a hand-written config.json may contain. Changing the protocol clears param, since the old fields mean nothing to the new plugin. Text fields keep a local draft and commit on blur/Enter: every store update schedules a debounced setConfig, so committing per keystroke would spam the server. The free-form list is local state too and only syncs back from props when param's identity differs from the object it last committed, i.e. when the change came from outside.
The site icon is a devtools-waterfall badge: src/frontend/asset/favicon.svg is the master (also the logo shown on the landing page and in the settings dialog title, referenced by URL rather than imported through webpack, since asset/ is served from the site root). favicon.ico is generated from it by npm run build:favicon, which packs 16/32/48 PNGs into an ICO; the 16px entry is drawn separately in scripts/favicon-16.svg because the master's detail doesn't survive the downscale. That script needs rsvg-convert (brew install librsvg) and so is deliberately outside npm run build — run it by hand and commit the .ico after editing either svg.
- Write everything in English: code comments, UI copy, log/error messages, docs (including this file), and commit messages. Some older code still has Chinese comments — translate them as you touch that code, but don't do a standalone sweep.
- TS runs in loose mode (
strict: false).tsconfig.jsonexcludessrc/frontend; the frontend is typechecked separately bytsconfig.web.json, so don't forget that path when changing frontend code. lib/,coverage/, andtest/.tmp/are build artifacts — don't commit them and don't hand-edit them.- Commits follow the angular convention (
standard-versiongenerates the CHANGELOG,npm run releasepublishes).