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
saveAsmight 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 viawaitForBrowserEventinsrc/browser-runtime.ts -
Temporary sandboxing creates isolated download directories per call, avoiding file collisions and system folder pollution
-
page.waitForEventcurrently supports only the'download'event, validated insrc/helpers.tsbefore delegating towaitForDownloadinsrc/driver/downloads.ts -
The download façade exposes
suggestedFilename(),url(),path(), andsaveAs()for script-friendly file handling without manual path management -
Timeout defaults come from
state.defaultTimeoutinsrc/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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →