How the Browser Runtime Module Manages Chrome DevTools Protocol (CDP) Communication in ego‑lite

The browser runtime module in ego‑lite provides a robust, session‑aware CDP transport layer with automatic retry logic, event buffering, and timeout protection—abstracting raw Chrome DevTools Protocol calls into a clean async API.

The ego‑lite browser automation library (see citrolabs/ego-lite) implements its CDP integration through a dedicated runtime module. This article examines how browser-runtime.ts orchestrates protocol communication, from low‑level message transport to higher‑level session management and event subscription.


Runtime Detection and Environment Setup

Before attempting any CDP communication, ego‑lite verifies it's running inside a browser host environment.

isBrowserRuntime() checks for the existence of globalThis.ego.sendCDPMessage (browser-runtime.ts#L25-L29):

export const isBrowserRuntime = (): boolean => {
  return (
    typeof globalThis.ego !== 'undefined' &&
    typeof globalThis.ego.sendCDPMessage === 'function'
  );
};

This guard prevents runtime errors when the code executes outside the expected browser context.


Low‑Level CDP Transport with rawCdp()

The raw CDP transport function handles the complete request‑response lifecycle.

rawCdp() (browser-runtime.ts#L38-L76):

  • Generates a unique requestId using a monotonic counter
  • Structures the JSON payload with id, method, and optional params
  • Registers callbacks for success (onCDPMessage) and failure (onSendCDPMessageError)
  • Sends via ego.sendCDPMessage
  • Sets a 15‑second timeout (RESPONSE_TIMEOUT_MS = 15000) that rejects pending requests if no response arrives (browser-runtime.ts#L55-L58)
// Sending a raw CDP command
const result = await rawCdp(
  'Runtime.evaluate',
  { expression: 'document.title', returnByValue: true },
  'my-session-id'  // optional session ID
);

The pending‑request map stores promise resolvers/rejectors keyed by requestId, enabling asynchronous response correlation.


Session Management with ensureSession()

CDP requires a target session for most operations. The runtime automates session lifecycle to hide this complexity.

ensureSession() (browser-runtime.ts#L7-L37) performs:

  1. Tab enumeration via ego.listTabs()
  2. Target selection (preferred tab or active fallback)
  3. Session attachment via Target.attachToTarget
  4. Domain enablement (e.g., Page.enable for page events)

Sessions are cached for 2 seconds (SESSION_TTL_MS = 2000) to avoid redundant attachment overhead (browser-runtime.ts#L8-L14). An "in‑flight" guard prevents duplicate concurrent session creation attempts.

// Ensure a session exists (creates or reuses cached)
const sessionId = await ensureSession();

// Use the session for subsequent calls
await rawCdp('Page.reload', { ignoreCache: false }, sessionId);

Session Recovery and Retry Logic

When the underlying browser target detaches (e.g., page navigation), CDP returns session‑lost errors. The runtime auto‑detects and recovers.

If rawCdp() encounters a SESSION_LOST error pattern (browser-runtime.ts#L96-L103):

  1. The stale session is invalidated
  2. The request is automatically retried with a fresh session

This transparent recovery eliminates manual session handling for most use cases.


Public API: browserCdp()

browserCdp() serves as the primary entry point for CDP communication throughout ego‑lite (browser-runtime.ts#L79-L89).

Key behaviors:

  • Automatic session injection: Adds the current session ID unless the method is Browser.* or Target.* (top‑level browser/target calls)
  • Test override support: Respects state.cdpOverride for mocking in test environments
  • Delegates to rawCdp for actual transport
// Navigate to a URL (session injected automatically)
await browserCdp('Page.navigate', { url: 'https://example.com' });

// Evaluate JavaScript in the page context
const { result } = await browserCdp('Runtime.evaluate', {
  expression: '1 + 1',
  returnByValue: true
});
console.log(result.value); // 2

Event Buffering and Subscription

CDP is bidirectional: the browser pushes events (Page.loadEventFired, Runtime.consoleAPICalled, etc.) without prior requests. The runtime provides mechanisms to consume these.

Event Buffer

Incoming events are stored in a bounded array (events) with a 10,000‑entry maximum (MAX_BUFFERED_EVENTS) to prevent memory exhaustion (browser-runtime.ts#L6-L8, browser-runtime.ts#L86-L90). Old events are evicted when the limit is reached.

subscribeBrowserEvent()

Create persistent subscriptions for specific CDP methods (browser-runtime.ts#L88-L95):

// Subscribe to all console messages
const unsubscribe = subscribeBrowserEvent(
  'Runtime.consoleAPICalled',
  undefined,           // any session
  (event) => {
    const { type, args } = event.params;
    console.log(`[${type}]`, args.map(a => a.value).join(' '));
  }
);

// Later: clean up
unsubscribe();

waitForBrowserEvent()

Wait for a one‑off event matching a predicate, with timeout protection (browser-runtime.ts#L69-L85):

// Wait for page load completion
const loadEvent = await waitForBrowserEvent(
  (e) => e.method === 'Page.loadEventFired' && e.params.sessionId === sessionId,
  30000  // 30 second timeout
);
console.log('Loaded at:', loadEvent.params.timestamp);

Dialog Tracking

JavaScript dialogs (alert, confirm, prompt) block page execution. The runtime tracks these via dedicated event handlers.

Dialog state is maintained in pendingDialogs per session, updated on Page.javascriptDialogOpening and Page.javascriptDialogClosed events (browser-runtime.ts#L66-L75).

// Navigate to a page that triggers an alert
await browserCdp('Page.navigate', { url: 'https://example.com/dialog' });

// Check for pending dialog
const dialog = pendingDialog();
if (dialog) {
  console.log('Dialog type:', dialog.type); // 'alert' | 'confirm' | 'prompt'
  console.log('Message:', dialog.message);

  // Accept or dismiss
  await browserCdp('Page.handleJavaScriptDialog', {
    accept: true,
    promptText: 'user input here'  // for prompts
  });
}

Error Handling and Cleanup

Transport failures (host process termination, network issues) are centralized through handleSendError() (browser-runtime.ts#L22-L30), which:

  • Rejects all pending requests with a descriptive EgoError
  • Clears the pending‑request map
  • Triggers any registered error callbacks

This ensures no hanging promises when the underlying connection fails.


Complete Working Example

import {
  isBrowserRuntime,
  ensureSession,
  browserCdp,
  subscribeBrowserEvent,
  waitForBrowserEvent,
  pendingDialog
} from './browser-runtime';

async function automationExample() {
  // 1. Verify environment
  if (!isBrowserRuntime()) {
    throw new Error('Not running in ego browser host');
  }

  // 2. Establish session (cached automatically)
  const sessionId = await ensureSession();

  // 3. Subscribe to console events
  const unsubConsole = subscribeBrowserEvent(
    'Runtime.consoleAPICalled',
    sessionId,
    (e) => console.log('Console:', e.params.args)
  );

  // 4. Navigate and wait for load
  await browserCdp('Page.navigate', { url: 'https://example.com' });
  await waitForBrowserEvent(
    (e) => e.method === 'Page.loadEventFired',
    30000
  );

  // 5. Evaluate script
  const { result } = await browserCdp('Runtime.evaluate', {
    expression: 'document.title',
    returnByValue: true
  });
  console.log('Page title:', result.value);

  // 6. Check and handle any dialog
  const dialog = pendingDialog();
  if (dialog) {
    await browserCdp('Page.handleJavaScriptDialog', { accept: true });
  }

  // 7. Cleanup
  unsubConsole();
}

automationExample().catch(console.error);

Key Source Files

File Responsibility Link
browser-runtime.ts Core CDP transport, session management, event buffering, public API (browserCdp, ensureSession, etc.) View source
state.ts Mutable runtime state: current session ID, preferred target, test overrides View source
cdp-eval.ts Higher‑level wrappers (cdp(), js()) that simplify common evaluation patterns View source
helpers.ts Exposes runtime utilities to user scripts through helperContext() View source

Summary

  • Detection: isBrowserRuntime() verifies the ego host environment before CDP operations
  • Transport: rawCdp() implements request correlation, timeouts, and error handling at the protocol level
  • Sessions: ensureSession() automates target attachment with caching and transparent retry on session loss
  • Public API: browserCdp() provides the clean interface used throughout the codebase, with automatic session injection
  • Events: Bounded buffering (MAX_BUFFERED_EVENTS = 10000) plus subscribeBrowserEvent() and waitForBrowserEvent() enable flexible event consumption
  • Dialogs: Automatic tracking of JavaScript dialogs with pendingDialog() for synchronous‑style inspection

Frequently Asked Questions

What happens if a CDP request times out?

The rawCdp() function enforces a 15‑second timeout via RESPONSE_TIMEOUT_MS. When triggered, the pending promise rejects with a timeout error and the request is removed from the internal pending map to prevent memory leaks.

Can I mock CDP responses for testing?

Yes. The browserCdp() function checks state.cdpOverride before proceeding to the real transport. Setting this property to a mock function intercepts all CDP calls, enabling isolated unit tests without a live browser.

How does event subscription handle high‑volume CDP traffic?

Events are buffered in a fixed‑size array capped at 10,000 entries. When the limit is reached, oldest events are discarded. Subscriptions filter this buffer by method name and optional session ID, so consumers receive only relevant events without processing overhead.

Why does session creation have a 2‑second TTL?

The 2000ms cache (SESSION_TTL_MS) balances performance and correctness. CDP session creation involves multiple round‑trips (list tabs → attach → enable domains). Caching avoids this overhead for rapid successive calls, while the short TTL ensures timely recovery if the underlying target changes.

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 →