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

> Discover how Lightpanda's Zig WebSocket server upgrades HTTP to WebSocket and routes CDP client messages using `WsConnection.upgrade` and `handleMessage` for efficient protocol handling.

- Repository: [Lightpanda/browser](https://github.com/lightpanda-io/browser)
- Tags: internals
- Published: 2026-03-14

---

**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.

```zig
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.

```zig
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:

```zig
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.