A single-process Zig HTTP server that brokers browser automation through the Chrome DevTools Protocol (CDP).
- Boot Sequence
- High-Level Data Flow
- Module Map
- HTTP API Endpoints
- Threading & Concurrency Model
- Memory & Lifetime Model
- Known Risks & Gaps
main() (main.zig:7)
│
├─ 1. GeneralPurposeAllocator init (main.zig:8)
├─ 2. config.load() — env-based config (main.zig:12)
├─ 3. ChromeLauncher.init() + start() (main.zig:18, :27)
│ └─ fallback: if launch fails, use port 9222
├─ 4. Bridge.init() — shared state (main.zig:34)
└─ 5. router.run() — bind, listen, serve (main.zig:38, router.zig:21)
deferred shutdown (LIFO):
bridge.deinit → chrome.deinit → allocator.deinit
The server can operate in two Chrome modes:
- Managed: launches Chrome itself via
ChromeLauncher - External: connects to an existing Chrome instance via
CDP_URLenv var
HTTP Client
│
▼
┌──────────────────┐
│ router.zig │ thread-per-connection, keep-alive loop
│ (path dispatch) │
│ + auth middleware│
└────────┬─────────┘
│
▼
┌──────────────────┐
│ bridge.zig │ shared state (RwLock-guarded maps)
│ tabs, snapshots, │ tabs · cdp_clients · har_recorders
│ ref_caches │ snapshots · prev_snapshots
└────────┬─────────┘
│
▼
┌──────────────────┐
│ cdp/client.zig │ sync request/response over WebSocket
│ cdp/websocket │ frame-level transport (RFC 6455)
│ cdp/protocol │ CDP method constants & types
└────────┬─────────┘
│
▼
Chrome (CDP)
Role: Entry point — boots config, launcher, bridge, router.
| Symbol | Line | Purpose |
|---|---|---|
main |
7 | Entry; orchestrates init/deinit sequence |
Error handling: launcher failure is non-fatal (falls back to port 9222). Server can start in a degraded state without Chrome.
The largest and most critical file — HTTP server, request dispatcher, and handler implementations all live here.
- Threading:
std.Thread.spawnper accepted connection, detached (router.zig:29) - Keep-alive: per-connection loop processes multiple requests (router.zig:52)
- Auth: global middleware gate before routing — constant-time
Authorizationheader compare (middleware.zig:6) - Routing: path-only dispatch; HTTP method is not checked (any method hits any endpoint) (router.zig:70)
- Per-request arena: each request gets its own
ArenaAllocatorfor zero-leak handler memory
Constant-time auth token comparison. Called by router before dispatch.
HTTP response helpers — sendJson, sendError, status codes.
Central shared state — the glue between router and CDP.
Bridge {
allocator,
config,
mutex: RwLock,
// All guarded by mutex:
tabs: StringHashMap(TabInfo),
cdp_clients: StringHashMap(*CdpClient),
har_recorders: StringHashMap(*HarRecorder),
snapshots: StringHashMap(RefCache),
prev_snapshots: StringHashMap([]const u8),
}Key operations:
putTab/removeTab— tab lifecycle managementgetCdpClient— returns pointer to existing or newly-created CDP client for a tabgetHarRecorder— same pattern for HAR recording
Environment-based configuration: CHROME_PATH, CDP_URL, PORT, AUTH_TOKEN, timeouts.
The CDP stack is layered:
actions.zig ─── high-level CDP actions (click, type, etc.)
│
client.zig ─── sync request/response, id correlation
│
websocket.zig ─── RFC 6455 framing, masking, ping/pong
│
protocol.zig ─── method constants, message types
Full WebSocket client implementation:
- Frame parsing with opcode handling (text, binary, ping, pong, close)
- Client-side masking per RFC 6455
- 10-second socket receive timeout
- No reconnect/backoff strategy
CdpClient.send()(client.zig:49) — synchronous send + receive loop- Correlates responses by
"id"field string scan (client.zig:81) - Drops non-matching messages/events silently
- Atomic request ID counter, but no send/receive mutex
CDP method string constants (Methods.Page.navigate, Methods.DOM.getDocument, etc.) and basic response types.
HAR (HTTP Archive) recording:
- Struct-based HAR 1.2 format builder
startRecording/stopRecording/getHarlifecycle- Event capture pipeline is incomplete —
captureexists but is not fed by CDP network events at runtime
Embeds js/stealth.js via @embedFile. Provides anti-detection script injection. Not wired into runtime command flow.
High-level CDP action helpers (click, type, focus). Thin wrappers over client.send().
Builds accessibility tree snapshots from CDP's Accessibility.getFullAXTree:
- Flattens AX tree into a list of
{ref, role, name, value, ...}nodes - Assigns short refs (
e0,e1, ...) for LLM-friendly token economy - Maps
ref → backend_node_idinto bridge'sRefCache
Snapshot change detection:
- Identity key:
backend_node_id(stable across snapshots) - Compares
role,name,valuefields refanddepthdo not affect change detection- Produces
added/removed/changeddiff output
Reference cache mapping ref string → backend_node_id. Mostly used in test utilities; runtime uses Bridge.RefCache (bridge.zig:15).
⚠️ Status: Scaffolded, not wired into runtime.
| File | Lines | Purpose |
|---|---|---|
pipeline.zig |
22 | Crawl pipeline orchestrator (stub) |
fetcher.zig |
72 | HTTP fetcher with retry/rate-limit config |
extractor.zig |
18 | Content extraction; embeds readability.js |
markdown.zig |
170 | HTML-to-markdown converter |
validator.zig |
106 | URL/content validation utilities |
No module in crawler/ is imported or called from main.zig or router.zig.
⚠️ Status: Config/utility stubs, unwired in runtime.
| File | Lines | Purpose |
|---|---|---|
kafka.zig |
47 | Kafka producer config struct |
local.zig |
45 | Local filesystem storage |
r2.zig |
28 | Cloudflare R2 object storage config |
Note: The /storage/* HTTP endpoints in router.zig operate on browser localStorage/sessionStorage via CDP Runtime.evaluate, not these backend storage modules.
Test utilities: mock allocators, fake CDP responses, test bridge setup.
Integration tests covering:
- ✅ Bridge map semantics (put/get/remove tabs)
- ✅ Snapshot diff/cache logic
- ✅ Crawler markdown/validator utilities
- ✅ Helper/utility code
Gaps:
- ❌ No
main()boot lifecycle tests - ❌ No end-to-end router → CDP flow tests
- ❌ No real WebSocket transport tests
- ❌ No concurrency/race condition tests
Mozilla Readability-based content extraction. Injected into Chrome pages via Runtime.evaluate for article/content parsing.
Anti-bot-detection patches:
- Overrides
navigator.webdriver - Patches
navigator.plugins,navigator.languages - Modifies Chrome runtime fingerprint
Both files are embedded into the Zig binary at compile time via @embedFile.
// build.zig:8 — single executable target
// build.zig:29 — test step- Single executable target (
agentic-browdie) - JS files embedded via
@embedFile(no separate JS build step) - Test step runs all
src/test/*.zigfiles - Dependencies declared in
build.zig.zon
All endpoints are method-agnostic (GET/POST/PUT/DELETE all work).
| Path | Handler | Description |
|---|---|---|
/health |
healthCheck | Server health status |
/tabs |
listTabs | List connected browser tabs |
/discover |
discoverTabs | Discover available Chrome tabs |
/navigate |
navigate | Navigate a tab to a URL |
/snapshot |
getSnapshot | Get accessibility tree snapshot |
/diff/snapshot |
diffSnapshot | Diff two snapshots for changes |
/action |
performAction | Execute action on a snapshot ref (click, type, etc.) |
/evaluate |
evaluate | Execute arbitrary JS in tab |
/har/start |
startHar | Begin HAR recording |
/har/stop |
stopHar | Stop HAR recording |
/har/get |
getHar | Retrieve recorded HAR data |
/cookies |
getCookies | Get browser cookies |
/cookies/set |
setCookies | Set browser cookies |
/storage/local |
getLocalStorage | Get tab's localStorage |
/storage/session |
getSessionStorage | Get tab's sessionStorage |
Main Thread
└─ accept() loop
└─ spawn detached thread per connection
└─ keep-alive request loop
├─ auth check (middleware)
├─ path dispatch (router)
├─ handler: acquires Bridge RwLock
│ └─ sends CDP command (synchronous)
└─ response written
- One RwLock guards all shared state in Bridge
- No connection pooling or work-stealing
- CDP client
send()is synchronous and blocking per-request - No timeout enforcement on CDP responses beyond socket-level 10s
- Per-request arena: each HTTP request handler gets an
ArenaAllocator, freed after response - Bridge-owned state: tabs, CDP clients, HAR recorders persist across requests
- RefCache: snapshot refs are duped into bridge allocator; cleared on next snapshot
- @embedFile: JS scripts are compile-time constants, zero runtime allocation
- Pointer-after-unlock:
getCdpClient()/getHarRecorder()return pointers used after RwLock release. ConcurrentremoveTab()can invalidate them. - CDP client races: atomic request IDs but no send/receive mutex — concurrent use of the same
CdpClientcan interleave messages. - Method-agnostic routing: any HTTP method matches any endpoint, which can produce unintended behavior.
- Snapshot diff lifetime:
prev_snapshotscan receive arena-backed data from request scope (router.zig:1153). If arena frees before next diff, use-after-free. - RefCache key duplication: repeated duped keys across bridge-router lifecycle can leak if not properly freed.
- HAR recording: endpoints exist but CDP network event capture is not implemented — HAR data will be empty.
- Stealth injection: embedded but not called in any runtime path.
- Crawler pipeline: fully scaffolded, zero runtime integration.
- Storage backends: kafka/r2/local exist as types only.
- No end-to-end tests: router → CDP → Chrome flow is untested.
- No concurrency tests: race conditions in bridge/snapshot path are not validated.
- Degraded startup: server can start and serve requests even if Chrome failed to launch.