How Lightpanda's WebSocket Server Handles CDP Client Connections: Protocol Upgrade and Message Routing in Zig

Lightpanda upgrades incoming HTTP connections to WebSocket protocol via WsConnection.upgrade, creates a CDP instance with blocking-read callbacks, and routes parsed JSON-RPC frames to domain handlers through handleMessage.

Lightpanda is a headless browser written in Zig that implements the Chrome DevTools Protocol (CDP) for automation and debugging. According to the lightpanda-io/browser source code, its WebSocket server uses a minimal, single-threaded-per-connection architecture that handles protocol upgrades, frame fragmentation, and CDP message routing with minimal memory overhead.

Server Initialization and TCP Acceptance

The server startup sequence begins in src/Server.zig at lines 46‑55. The Server.init function builds a JSON version response used by CDP clients to discover the WebSocket endpoint, then registers a TCP listener on the configured address.

const self = try allocator.create(Server);
…
const json_version_response = try buildJSONVersionResponse(allocator, address);

When a client connects, Server.onAccept (lines 85‑92) hands the socket to spawnWorker, which checks the global thread limit and launches runWorker on a new OS thread. This design isolates each CDP client to its own thread, preventing head-of-line blocking between connections.

Per-Client State Management

Each connection is wrapped in a Client struct defined in src/Server.zig. At lines 15‑40, Client.init creates two primary objects: a WsConnection for low-level WebSocket frame handling and an HttpClient for the initial HTTP-only phase.

var client = try server.getClient();
client.* = try Server.Client.init(socket, allocator, app,
                                 server.json_version_response,
                                 timeout_ms);
defer client.deinit();

The client maintains a mode flag that transitions from .http to .cdp once the WebSocket upgrade completes.

The WebSocket Upgrade Handshake

Lightpanda handles the discovery endpoint and upgrade sequence sequentially. Requests to GET /json/version receive the pre-built JSON reply generated during server initialization and the connection closes immediately.

The actual protocol upgrade occurs in Client.upgradeConnection (lines 433‑456). This method invokes WsConnection.upgrade (located in src/network/websocket.zig, lines 74‑86), which parses the required headers, computes the Sec-WebSocket-Accept value using the standard SHA-1 hash, and transmits the 101 Switching Protocols response.

Instantiating the CDP Session

After a successful upgrade, the client’s mode switches to .cdp and a CDP instance is created (lines 52‑58). The HttpClient receives a cdp_client structure containing the socket, a context pointer, and three critical callbacks for blocking I/O: blockingReadStart, blockingRead, and blockingReadStop.

These callbacks allow the CDP engine to pause script execution and wait for additional socket data without busy-looping. When triggered, they toggle the socket between non-blocking and blocking modes using setBlocking(true) before reads and setBlocking(false) afterward (lines 37‑44 and 51‑58).

Message Loop and Frame Processing

The entry point for client I/O is Client.httpLoop. Initially, it drains pending HTTP traffic to service /json/version requests. Once self.mode becomes .cdp, control transfers to processWebsocketMessage (lines 61‑67), which delegates to WsConnection.processMessages (lines 35‑43 in src/network/websocket.zig).

Frame parsing happens inside WsConnection.Reader (lines 23‑30). The next iterator assembles fragmented messages, enforces size limits against CDP_MAX_MESSAGE_SIZE defined in the configuration, and yields complete Message structs with variants for .text, .binary, .close, .ping, and .pong (lines 103‑131).

Dispatching to CDP Domains

For each complete text or binary frame, processMessages invokes handler.handleMessage(msg.data) (lines 52‑63). The CDP object implements this interface, parsing the JSON-RPC payload and routing commands to the appropriate domain handlers (Page, Network, Runtime, etc.).

To emit events back to the client, CDP domains call sendEvent, which ultimately uses Client.sendJSON (lines 69‑71) or WsConnection.sendJSON (lines 407‑416) to frame the JSON as a WebSocket text message:

pub fn sendEvent(self: *Self, method: []const u8, payload: anytype) !void {
    try self.cdp.sendJSON(.{
        .method = method,
        .params = payload,
        .id = null,
    }, .{ .emit_null_optional_fields = false });
}

Connection Teardown

When the client transmits a close frame or the underlying socket encounters an error, WsConnection.send(&CLOSE_NORMAL) emits a graceful close handshake. The socket shuts down, and the client’s resources—including the WsConnection, HttpClient, and any CDP state—are de-initialized via client.deinit().

Summary

  • Single-threaded isolation: Each CDP client runs on a dedicated OS thread spawned by spawnWorker to prevent cross-connection latency.
  • Explicit upgrade flow: WsConnection.upgrade handles the Sec-WebSocket-Accept computation and 101 Switching Protocols response at src/network/websocket.zig lines 74‑86.
  • Mode-based state machine: Clients transition from .http to .cdp after upgrade, with blockingReadStart/Stop callbacks enabling synchronous socket reads during request interception.
  • Fragmentation support: WsConnection.Reader reassembles fragmented frames and enforces CDP_MAX_MESSAGE_SIZE limits before dispatching to handleMessage.
  • Zero-copy routing: Parsed JSON-RPC messages route directly to domain handlers without intermediate buffering, minimizing allocations in the Zig implementation.

Frequently Asked Questions

How does Lightpanda handle multiple simultaneous CDP clients?

Each incoming TCP connection spawns a dedicated worker thread via spawnWorker (lines 85‑92 in src/Server.zig). The global thread limit is checked before spawning, ensuring the browser does not exhaust system resources under heavy load.

What happens if a WebSocket message exceeds the size limit?

The WsConnection.Reader enforces CDP_MAX_MESSAGE_SIZE during frame assembly (lines 103‑131 in src/network/websocket.zig). If a message exceeds this limit, the connection typically receives a close frame or error response, depending on the specific error handling path triggered.

Why does Lightpanda use blocking reads for CDP?

Blocking reads are required when CDP pauses script execution to intercept network requests or debugger breakpoints. The blockingReadStart callback switches the socket to blocking mode (setBlocking(true)) so the CDP engine can wait safely for additional data, then blockingReadStop restores non-blocking mode to resume the event loop.

Where is the JSON version response generated?

Server.init pre-builds the JSON response at lines 46‑55 in src/Server.zig using buildJSONVersionResponse. This static payload is served immediately to GET /json/version requests without re-computation, allowing CDP clients like Chrome DevTools to discover the WebSocket endpoint before initiating the upgrade handshake.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →