How Ego-Browser Implements Download Tracking and the waitForEvent System

Ego-browser provides a Playwright-compatible page.waitForEvent API that currently supports only the download event, using CDP messages to track file downloads from start to completion.

The waitForEvent system in ego-browser (from the citrolabs/ego-lite repository) gives automation scripts a reliable way to capture file downloads. This article breaks down the implementation across four core modules, explains the step-by-step download flow, and shows practical code examples.

The Architecture of waitForEvent and Download Tracking

The download tracking and waitForEvent system spans two primary modules with support from runtime utilities. Each layer has a distinct responsibility:

Module File Path Purpose
Download engine src/driver/downloads.ts Orchestrates CDP communication, folder setup, and download lifecycle
Public API src/helpers.ts Exposes page.waitForEvent and validates event names
Event primitive src/browser-runtime.ts Provides waitForBrowserEvent for generic CDP message waiting
Configuration src/state.ts Supplies defaultTimeout and other runtime settings

Key Design Decisions

The implementation favors temporary sandboxing over persistent directories. Each waitForEvent('download') call creates a unique temporary folder rather than polluting system Downloads folders. This isolation prevents collisions when multiple concurrent downloads occur.

CDP protocol version differences are handled gracefully. The code attempts Browser.setDownloadBehavior first, then falls back to Page.setDownloadBehavior if the browser doesn't support the modern command.

How Download Tracking Works Step-by-Step

Understanding the download tracking internals helps debug failures and extend the system. Here's the exact flow in src/driver/downloads.ts:

1. Temporary Directory Creation

const downloadDir = path.join(tmpdir(), `ego-download-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(downloadDir, { recursive: true });

The path includes process ID, timestamp, and random suffix to guarantee uniqueness across parallel browser instances.

2. Configuring Browser Download Behavior

async function setDownloadBehavior(downloadDir: string): Promise<void> {
  try {
    await sendCDPCommand('Browser.setDownloadBehavior', {
      behavior: 'allowAndName',
      downloadPath: downloadDir,
      eventsEnabled: true
    });
  } catch {
    // Fallback for older Chrome versions
    await sendCDPCommand('Page.setDownloadBehavior', {
      behavior: 'allow',
      downloadPath: downloadDir
    });
  }
}

The eventsEnabled: true flag is critical — without it, the browser won't emit downloadWillBegin or downloadProgress events.

3. Waiting for CDP Download Events

The download tracker uses waitForBrowserEvent from src/browser-runtime.ts twice:

// First: capture the download GUID and metadata
const willBeginEvent = await waitForBrowserEvent(
  (msg) => msg.method === "Page.downloadWillBegin",
  timeout
);
const { guid, suggestedFilename, url } = willBeginEvent.params;

// Second: track completion status
const progressEvent = await waitForBrowserEvent(
  (msg) => msg.method === "Page.downloadProgress" && 
           msg.params.guid === guid &&
           (msg.params.state === "completed" || msg.params.state === "canceled"),
  timeout
);

The GUID matching ensures correct tracking when multiple downloads overlap.

4. Building the Download Façade

On completion, the system returns an object with four methods:

Method Returns Description
suggestedFilename() string Original filename from HTTP headers or URL
url() string Source URL where download originated
path() Promise<string> Absolute path to temporary file location
saveAs(targetPath) Promise<void> Copies file to permanent destination

If the progress event reports state: "canceled", the function throws: Download canceled: ${suggestedFilename}.

The waitForEvent API Reference

The public page.waitForEvent interface in src/helpers.ts is intentionally minimal:

waitForEvent: (eventName: 'download', options?: { timeout?: number }) => Promise<DownloadFaçade>

Validation logic rejects unsupported events:

if (eventName !== 'download') {
  throw new Error(`waitForEvent: unsupported event "${eventName}". Only "download" is currently supported.`);
}

The timeout parameter defaults to state.defaultTimeout (defined in src/state.ts). This centralizes timeout configuration across all wait operations.

Complete Code Examples

Basic Download Capture

const page = await browser.newPage();

// Trigger download via click or navigation
const [download] = await Promise.all([
  page.waitForEvent('download'),
  page.click('a#export-report')  // element that initiates download
]);

console.log('Filename:', download.suggestedFilename());
console.log('From:', download.url());
console.log('Temp location:', await download.path());

Saving to Permanent Location

const download = await page.waitForEvent('download');

// Copy from temp sandbox to user-chosen destination
await download.saveAs('/home/user/Documents/quarterly-report.pdf');

// Optional: verify file exists
const fs = require('fs');
const stats = fs.statSync('/home/user/Documents/quarterly-report.pdf');
console.log(`Saved ${stats.size} bytes`);

Custom Timeout with Error Handling

try {
  const download = await page.waitForEvent('download', { 
    timeout: 60_000  // 60 second limit for large files
  });
  
  const tempPath = await download.path();
  await download.saveAs('/mnt/secure-storage/archive.zip');
  
} catch (err) {
  if (err.message.includes('timeout')) {
    console.error('Download did not start or complete within 60 seconds');
  } else if (err.message.includes('canceled')) {
    console.error('Download was canceled by user or server');
  } else {
    throw err;
  }
}

Extending waitForEvent for Other Event Types

The current waitForEvent system is architected for expansion. The pattern in src/browser-runtime.ts supports additional CDP events:

// Generic primitive used by downloads and available for extension
function waitForBrowserEvent<T>(
  predicate: (msg: CDPMessage) => boolean,
  timeout: number
): Promise<T>

Potential future events following this pattern:

  • 'request' and 'response' — network interception
  • 'dialog' — JavaScript alerts and confirms
  • 'filechooser' — native file picker dialogs

Each would add a branch in src/helpers.ts validation and a dedicated handler module paralleling src/driver/downloads.ts.

Performance and Resource Considerations

Aspect Behavior
Disk usage Temporary folders persist until saveAs() copies or manual cleanup
Memory footprint Minimal — only GUID and metadata held, not file contents
Concurrent downloads Supported via unique temp directories per waitForEvent call
Timeout handling Uses Promise.race with cleanup to prevent listener leaks

The temporary folder retention is intentional. The façade doesn't auto-delete because:

  • Scripts may want to inspect files before deciding to keep
  • saveAs might fail (permission issues, disk full) and need retry
  • Parallel processing pipelines may handle files asynchronously

Summary

  • Download tracking uses Chrome DevTools Protocol messages (Page.downloadWillBegin, Page.downloadProgress) intercepted via waitForBrowserEvent in src/browser-runtime.ts

  • Temporary sandboxing creates isolated download directories per call, avoiding file collisions and system folder pollution

  • page.waitForEvent currently supports only the 'download' event, validated in src/helpers.ts before delegating to waitForDownload in src/driver/downloads.ts

  • The download façade exposes suggestedFilename(), url(), path(), and saveAs() for script-friendly file handling without manual path management

  • Timeout defaults come from state.defaultTimeout in src/state.ts, overrideable per call

Frequently Asked Questions

What events does page.waitForEvent support in ego-browser?

Currently only the download event. The helper in src/helpers.ts explicitly validates the event name and throws for any other value. The architecture supports future expansion for network, dialog, and file chooser events.

Why does the download go to a temporary folder instead of my Downloads directory?

Each waitForEvent('download') call creates a unique temporary directory to isolate downloads. This prevents filename collisions during parallel execution and gives scripts explicit control via saveAs() for permanent placement. The temporary path is accessible via await download.path().

How do I change the default timeout for all waitForEvent calls?

Modify state.defaultTimeout in src/state.ts or pass { timeout: milliseconds } to individual calls. The default applies across all wait operations including waitForSelector and waitForFunction.

What happens if a download is canceled by the user or server?

The progress listener in src/driver/downloads.ts detects state: "canceled" and throws Download canceled: ${filename}. Wrap waitForEvent in try/catch to handle this case, or check for cancellation explicitly if extending the download tracker.

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 →