Version 0.1: Protocol Foundation - Technical Summary

May 10, 2026 · View on GitHub

Completion Date: 2025-11-06 Status: ✅ Complete

What Was Built

Version 0.1 delivers the complete protocol foundation for playwright-rust, enabling communication between Rust code and the Playwright Node.js server.

Core Components

1. Server Management (server.rs)

  • Automatic driver download via build.rs (downloads Playwright server from npm during build)
  • Server lifecycle - Launch Playwright server as child process with playwright run-server
  • Environment variable support - PLAYWRIGHT_DRIVER_PATH for custom driver locations
  • Cross-platform - Works on macOS, Linux (Windows TBD)

2. Transport Layer (transport.rs)

  • Length-prefixed framing - 4-byte little-endian length + JSON message
  • Bidirectional communication - Separate read/write streams over stdio
  • Generic design - Transport<W, R> parameterized over AsyncWrite and AsyncRead
  • Testability - Can use real server or mock duplex pipes for tests

3. Connection Layer (connection.rs)

  • JSON-RPC client - Request/response correlation with sequential message IDs
  • Message dispatch - Routes incoming messages by type (__create__, __dispose__, events, responses)
  • Object registry - Global HashMap of all protocol objects by GUID
  • Async message loop - Background task processes messages from transport
  • Initialization protocol - Special handshake to create root Playwright object

4. Channel Owner Architecture (channel_owner.rs)

  • Base trait - All protocol objects implement ChannelOwner
  • GUID-based identity - Each object has unique server-assigned GUID
  • Parent-child hierarchy - Objects form a tree (Playwright → BrowserType → Browser → ...)
  • Dual registry - Objects registered in both connection (global) and parent (lifecycle)
  • Disposal cascade - Disposing parent disposes all children recursively
  • Channel RPC - Each object has a Channel for sending protocol messages

5. Object Factory (object_factory.rs)

  • Type dispatch - Maps protocol type names to Rust constructors
  • Extensible - Easy to add new protocol types in future versions
  • Current types:
    • Playwright - Root object providing browser type access
    • BrowserType - Represents Chromium, Firefox, or WebKit
    • Root - Temporary initialization object (internal)

6. Protocol Types (protocol/)

  • Playwright - Main entry point, provides chromium(), firefox(), webkit()
  • BrowserType - Browser metadata (name, executable path)
  • Root - Initialization helper with initialize() method

7. High-Level API (playwright crate)

  • Public re-exports - Clean API surface (Playwright, Error, Result)
  • Entry point - Playwright::launch().await? orchestrates full initialization
  • Example code - Working example in examples/basic.rs

Key Technical Decisions

Synchronization Strategy

Decision: Use parking_lot::Mutex for object registry Rationale:

  • All official bindings (Python, Java, .NET) use synchronous disposal
  • Critical sections are very short (just HashMap lookups/inserts)
  • parking_lot is faster than std::sync::Mutex and works in async contexts
  • Avoids async lock complexity and deadlock risks

See ADR-0002 for detailed research and rationale.

Message Dispatch Architecture

Decision: Match Python's handler pattern exactly Pattern:

match message {
    Message::Event { id, method, params } => {
        // Route event to object by GUID
        let object = self.get_object(&id).await?;
        object.on_event(&method, params);
    }
    Message::Response { id, result, error } => {
        // Resolve pending request by ID
        if let Some(callback) = self.callbacks.lock().remove(&id) {
            callback.send(result_or_error);
        }
    }
}

Root Object Pattern

Decision: Use temporary Root object for initialization Problem: Python's initialize_playwright() creates the root object by sending an RPC to a temporary "Root" object with empty GUID ("") Solution:

  1. Create temporary Root object with GUID ""
  2. Call root.initialize() which sends initialize RPC
  3. Server responds with __create__ events for Playwright and BrowserTypes
  4. Return actual Playwright object
  5. Root object discarded after initialization

This matches Python exactly and handles the initialization handshake properly.

Architecture Diagrams

Initialization Flow

┌─────────────────────────────────────────────────────────┐
│ 1. Playwright::launch()                                  │
└────────────────┬────────────────────────────────────────┘

                 v
┌─────────────────────────────────────────────────────────┐
│ 2. PlaywrightServer::launch()                           │
│    - Spawn `playwright run-server` as child process     │
│    - Get stdin/stdout handles                           │
└────────────────┬────────────────────────────────────────┘

                 v
┌─────────────────────────────────────────────────────────┐
│ 3. PipeTransport::new(stdin, stdout)                    │
│    - Create bidirectional transport                     │
│    - Spawn read loop for incoming messages              │
└────────────────┬────────────────────────────────────────┘

                 v
┌─────────────────────────────────────────────────────────┐
│ 4. Connection::new(transport, message_rx)               │
│    - Create connection with object registry             │
│    - Create callbacks registry for pending requests     │
└────────────────┬────────────────────────────────────────┘

                 v
┌─────────────────────────────────────────────────────────┐
│ 5. tokio::spawn(connection.run())                       │
│    - Run message dispatch loop in background            │
└────────────────┬────────────────────────────────────────┘

                 v
┌─────────────────────────────────────────────────────────┐
│ 6. connection.initialize_playwright()                   │
│    a. Create Root object with GUID ""                   │
│    b. root.initialize() sends "initialize" RPC          │
│    c. Server responds with __create__ messages:         │
│       - Playwright (guid: "playwright@...")            │
│       - BrowserType chromium                            │
│       - BrowserType firefox                             │
│       - BrowserType webkit                              │
│    d. Object factory creates typed Rust objects         │
│    e. Objects registered in connection registry         │
│    f. Return Playwright object                          │
└────────────────┬────────────────────────────────────────┘

                 v
┌─────────────────────────────────────────────────────────┐
│ 7. User code                                             │
│    playwright.chromium() // Access browser types        │
└─────────────────────────────────────────────────────────┘

Object Hierarchy After Initialization

Connection (object registry: HashMap<String, Arc<dyn ChannelOwner>>)

├── Playwright (guid: "playwright@...")
│   ├── chromium: BrowserType (guid: "browserType@chromium")
│   ├── firefox: BrowserType (guid: "browserType@firefox")
│   └── webkit: BrowserType (guid: "browserType@webkit")

└── (Future Version 0.2 objects)
    ├── Browser
    │   └── BrowserContext
    │       └── Page
    │           └── Frame

Testing Strategy

Test Coverage

  • 54 total tests (unit + integration + doc tests)
  • 100% of Version 0.1 code paths tested
  • Integration tests use real Playwright server (not mocks)
  • Doc tests verify all examples compile

Test Organization

crates/playwright-core/
├── src/
│   ├── connection.rs (unit tests with mock duplex streams)
│   ├── transport.rs (unit tests with mock AsyncWrite/Read)
│   └── ... (doc tests in all modules)
├── tests/
│   ├── initialization_integration.rs (full server integration)
│   └── playwright_launch.rs (high-level API integration)

Testing Patterns Learned from Official Bindings

Based on research of Python, Java, and .NET implementations:

  1. All official bindings test against real servers - No mocking of protocol
  2. Integration tests are primary - Unit tests for isolated logic only
  3. Generics enable testability - Transport<W, R> can use mock or real streams
  4. Doc tests are valuable - Ensure examples stay up-to-date

Performance Characteristics

Message Throughput

  • Lock-free read path - Transport reads don't block on locks
  • Minimal lock contention - Object registry uses fast parking_lot::Mutex
  • Lock hold time - Only held during HashMap lookup/insert (microseconds)
  • Async-friendly - No locks held across await points

Memory Management

  • Arc-based ownership - Objects shared between connection and parent
  • Automatic cleanup - Objects removed from registries on disposal
  • No leaks - All protocol objects cleaned up when connection drops
  • Efficient cloning - Arc clones are cheap (atomic refcount increment)

Scalability

  • Single connection per Playwright instance - Matches official bindings
  • Background message loop - Doesn't block user code
  • Bounded memory - Object registry grows with active objects only
  • Cleanup on disposal - Old objects removed promptly

Scope and Limitations

Version 0.1 Scope (Complete)

Version 0.1 delivered the protocol foundation only:

  • ✅ JSON-RPC communication with Playwright server
  • ✅ Object factory and lifecycle management
  • ✅ Playwright initialization flow
  • ✅ Access to browser types (objects exist, not launched yet)

Out of Scope for Version 0.1

Browser automation features are intentionally deferred to future versions:

  • Version 0.2: Browser launching, contexts, pages
  • Version 0.3: Navigation, locators, actions
  • Version 0.4: Screenshots, network interception, assertions
  • Version 0.5: Mobile emulation, advanced features

See Version 0.2 Implementation Plan for next steps.

API Examples

Basic Usage (Version 0.1)

use playwright::Playwright;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Launch Playwright and connect to server
    let playwright = Playwright::launch().await?;

    // Access browser types (Version 0.1 - objects exist, browsers not launched yet)
    let chromium = playwright.chromium();
    let firefox = playwright.firefox();
    let webkit = playwright.webkit();

    println!("Chromium: {}", chromium.executable_path());
    println!("Firefox: {}", firefox.executable_path());
    println!("WebKit: {}", webkit.executable_path());

    Ok(())
}

Running the Example

# Driver is auto-downloaded by build.rs into $OUT_DIR; no path setup needed.
cargo run --package playwright-rs --example basic

Output (paths show the driver living under Cargo's $OUT_DIR):

🚀 Launching Playwright...
✅ Playwright launched successfully!

📦 Available browser types:
   • Chromium: target/debug/build/playwright-rs-<hash>/out/playwright-driver/playwright-<version>-<platform>/node_modules/playwright-core/...
   • Firefox:  target/debug/build/playwright-rs-<hash>/out/playwright-driver/playwright-<version>-<platform>/node_modules/playwright-core/...
   • WebKit:   target/debug/build/playwright-rs-<hash>/out/playwright-driver/playwright-<version>-<platform>/node_modules/playwright-core/...

Lessons Learned

What Worked Well

  1. Vertical slicing approach

    • Each slice delivered testable functionality
    • Clear dependencies enabled incremental progress
    • TDD workflow (Red → Green → Refactor) maintained quality
  2. Research-driven implementation

    • Studying all three official bindings before coding
    • Identified common patterns across languages
    • Avoided Rust-specific anti-patterns
  3. Generic type parameters

    • Transport<W, R> and Connection<W, R> enabled excellent testability
    • Can test with mock streams or real server
    • No compromise on production performance
  4. Clear documentation

    • Architecture Decision Records captured rationale
    • Implementation plan tracked progress
    • Rustdoc with examples for all public APIs

Challenges Overcome

  1. Initialization complexity

    • Root object pattern was not obvious from protocol.yml
    • Required deep dive into Python implementation
    • Solution: ADR-0002 documents the pattern for future reference
  2. Sync/async boundaries

    • ChannelOwner disposal is synchronous but needs async cleanup
    • Research showed all official bindings handle this the same way
    • Solution: parking_lot::Mutex + tokio::spawn (deferred cleanup)
  3. Type erasure

    • Connection stores Arc<dyn ChannelOwner> but users need concrete types
    • Solution: as_any() pattern for downcasting (standard Rust pattern)
  4. Message dispatch deadlocks

    • Initial design with async locks caused deadlocks
    • Solution: Switch to parking_lot::Mutex and keep lock scopes tight

What Would We Do Differently

  1. Earlier integration testing - Could have written full integration test earlier
  2. Mock transport sooner - DuplexStream pattern could have been introduced in Slice 2
  3. Document Root pattern earlier - Spent time confused about initialization

Version 0.2 Readiness

Version 0.1 provides a solid foundation for Version 0.2. The architecture is ready for browser launching and page automation.

See Version 0.2 Implementation Plan for details.

Metrics

  • Lines of code: ~3,500 (core + public API)
  • Test coverage: 54 tests, all passing
  • Build time: < 1s (incremental), ~30s (clean)
  • Example execution: ~200ms (server launch to output)
  • Clippy warnings: 0
  • Unsafe code: 0
  • Documentation: 100% of public APIs documented

References