Skip to main content

crawlkit_engine/
playwright.rs

1use std::path::PathBuf;
2use std::process::Command;
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use parking_lot::RwLock;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10/// Errors from Playwright operations.
11#[derive(Debug, Error)]
12pub enum PlaywrightError {
13    /// Playwright binary is not installed or not found.
14    #[error("playwright not available: {0}")]
15    NotAvailable(String),
16
17    /// Page navigation failed.
18    #[error("page navigation failed: {0}")]
19    NavigationFailed(String),
20
21    /// Page rendering timed out.
22    #[error("page timeout after {0:?}")]
23    Timeout(Duration),
24
25    /// JavaScript evaluation on the page failed.
26    #[error("JavaScript evaluation failed: {0}")]
27    JsEvaluationFailed(String),
28
29    /// Browser launch failed.
30    #[error("browser launch failed: {0}")]
31    BrowserLaunchFailed(String),
32
33    /// Browser context creation failed.
34    #[error("context creation failed: {0}")]
35    ContextCreationFailed(String),
36
37    /// Resource limit exceeded during rendering.
38    #[error("resource limit exceeded: {0}")]
39    ResourceLimitExceeded(String),
40}
41
42/// Configuration for Playwright browser rendering.
43///
44/// Controls browser type, timeouts, concurrency limits, and resource
45/// constraints. Default is disabled with Chromium headless mode.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct PlaywrightConfig {
48    /// Enable JavaScript rendering.
49    pub enabled: bool,
50    /// Browser type.
51    pub browser_type: BrowserType,
52    /// Page load timeout.
53    pub timeout: Duration,
54    /// Maximum concurrent browser contexts.
55    pub max_concurrent: usize,
56    /// Headless mode.
57    pub headless: bool,
58    /// Extra arguments to pass to the browser.
59    pub args: Vec<String>,
60    /// Maximum memory per context (bytes).
61    pub max_memory_per_context: u64,
62    /// Maximum CPU time per page (seconds).
63    pub max_cpu_seconds: u64,
64}
65
66impl Default for PlaywrightConfig {
67    fn default() -> Self {
68        Self {
69            enabled: false,
70            browser_type: BrowserType::Chromium,
71            timeout: Duration::from_secs(30),
72            max_concurrent: 5,
73            headless: true,
74            args: vec![
75                "--no-sandbox".to_string(),
76                "--disable-setuid-sandbox".to_string(),
77                "--disable-dev-shm-usage".to_string(),
78                "--disable-gpu".to_string(),
79                "--disable-extensions".to_string(),
80                "--disable-background-networking".to_string(),
81            ],
82            max_memory_per_context: 512 * 1024 * 1024, // 512 MB
83            max_cpu_seconds: 30,
84        }
85    }
86}
87
88/// Browser type for rendering.
89#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
90pub enum BrowserType {
91    /// Google Chrome / Chromium.
92    Chromium,
93    /// Mozilla Firefox.
94    Firefox,
95    /// Apple WebKit (Safari engine).
96    WebKit,
97}
98
99impl BrowserType {
100    /// Get the Playwright browser name.
101    #[must_use]
102    pub fn as_str(&self) -> &'static str {
103        match self {
104            BrowserType::Chromium => "chromium",
105            BrowserType::Firefox => "firefox",
106            BrowserType::WebKit => "webkit",
107        }
108    }
109}
110
111/// Result of JavaScript rendering.
112///
113/// Contains the rendered HTML, console messages, network requests,
114/// and WASM errors detected during page rendering.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct RenderedPage {
117    /// Final URL after JS rendering and redirects.
118    pub final_url: String,
119    /// Rendered HTML content.
120    pub html: String,
121    /// Console messages from the page.
122    pub console_messages: Vec<ConsoleMessage>,
123    /// Network requests made during rendering.
124    pub network_requests: Vec<NetworkRequest>,
125    /// WASM-related errors detected.
126    pub wasm_errors: Vec<WasmError>,
127    /// Time taken to render the page.
128    pub render_time: Duration,
129    /// Memory used during render (bytes).
130    pub memory_used: u64,
131}
132
133/// Console message from browser.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct ConsoleMessage {
136    /// Message level (log, warn, error, etc.).
137    pub level: String,
138    /// Message text.
139    pub text: String,
140    /// Source file URL (if available).
141    pub source: Option<String>,
142    /// Line number in source (if available).
143    pub line: Option<u32>,
144}
145
146/// Network request during rendering.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct NetworkRequest {
149    /// Request URL.
150    pub url: String,
151    /// HTTP method (GET, POST, etc.).
152    pub method: String,
153    /// Response status code (if received).
154    pub status: Option<u16>,
155    /// Resource type (document, script, stylesheet, etc.).
156    pub resource_type: String,
157    /// Response size in bytes (if available).
158    pub size: Option<u64>,
159}
160
161/// WASM error detected during rendering.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct WasmError {
164    /// Error type (runtime, compilation, etc.).
165    pub error_type: String,
166    /// Error message.
167    pub message: String,
168    /// Source file (if available).
169    pub source: Option<String>,
170    /// Timestamp when error occurred.
171    pub timestamp: u64,
172}
173
174/// Browser context with resource isolation.
175#[derive(Clone)]
176pub struct BrowserContext {
177    /// Context ID.
178    pub id: String,
179    /// Memory limit.
180    pub memory_limit: u64,
181    /// CPU time limit.
182    pub cpu_limit: Duration,
183    /// Creation time.
184    pub created_at: Instant,
185    /// Whether context is active.
186    pub active: bool,
187}
188
189impl BrowserContext {
190    /// Create a new browser context.
191    #[must_use]
192    pub fn new(id: String, memory_limit: u64, cpu_limit: Duration) -> Self {
193        Self {
194            id,
195            memory_limit,
196            cpu_limit,
197            created_at: Instant::now(),
198            active: true,
199        }
200    }
201
202    /// Check if context has exceeded resource limits.
203    #[must_use]
204    pub fn is_over_limit(&self, memory_used: u64) -> bool {
205        let elapsed = self.created_at.elapsed();
206        memory_used > self.memory_limit || elapsed > self.cpu_limit
207    }
208}
209
210/// Playwright binary detection and management.
211pub struct PlaywrightDetector {
212    /// Path to Playwright binary.
213    binary_path: Option<PathBuf>,
214    /// Playwright version.
215    version: Option<String>,
216    /// Available browsers.
217    available_browsers: Vec<BrowserType>,
218}
219
220impl PlaywrightDetector {
221    /// Detect Playwright installation.
222    #[must_use]
223    pub fn detect() -> Self {
224        let binary_path = Self::find_binary();
225        let version = binary_path.as_ref().and_then(Self::get_version);
226        let available_browsers = binary_path
227            .as_ref()
228            .map(Self::get_browsers)
229            .unwrap_or_default();
230
231        Self {
232            binary_path,
233            version,
234            available_browsers,
235        }
236    }
237
238    /// Find Playwright binary in PATH.
239    fn find_binary() -> Option<PathBuf> {
240        // Check common locations
241        let candidates = [
242            "npx playwright",
243            "playwright",
244            "/usr/local/bin/playwright",
245            "/usr/bin/playwright",
246        ];
247
248        for candidate in &candidates {
249            if let Ok(output) = Command::new("which").arg(candidate).output() {
250                if output.status.success() {
251                    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
252                    if !path.is_empty() {
253                        return Some(PathBuf::from(path));
254                    }
255                }
256            }
257        }
258
259        None
260    }
261
262    /// Get Playwright version.
263    fn get_version(binary: &PathBuf) -> Option<String> {
264        Command::new(binary)
265            .arg("--version")
266            .output()
267            .ok()
268            .and_then(|o| {
269                if o.status.success() {
270                    String::from_utf8(o.stdout)
271                        .ok()
272                        .map(|s| s.trim().to_string())
273                } else {
274                    None
275                }
276            })
277    }
278
279    /// Get available browsers.
280    fn get_browsers(binary: &PathBuf) -> Vec<BrowserType> {
281        Command::new(binary)
282            .arg("install")
283            .arg("--dry-run")
284            .output()
285            .ok()
286            .map(|o| {
287                let output = String::from_utf8_lossy(&o.stdout);
288                let mut browsers = Vec::new();
289                if output.contains("chromium") {
290                    browsers.push(BrowserType::Chromium);
291                }
292                if output.contains("firefox") {
293                    browsers.push(BrowserType::Firefox);
294                }
295                if output.contains("webkit") {
296                    browsers.push(BrowserType::WebKit);
297                }
298                browsers
299            })
300            .unwrap_or_default()
301    }
302
303    /// Check if Playwright is available.
304    #[must_use]
305    pub fn is_available(&self) -> bool {
306        self.binary_path.is_some()
307    }
308
309    /// Get binary path.
310    #[must_use]
311    pub fn binary_path(&self) -> Option<&PathBuf> {
312        self.binary_path.as_ref()
313    }
314
315    /// Get version.
316    #[must_use]
317    pub fn version(&self) -> Option<&str> {
318        self.version.as_deref()
319    }
320
321    /// Check if browser is available.
322    #[must_use]
323    pub fn has_browser(&self, browser: BrowserType) -> bool {
324        self.available_browsers.contains(&browser)
325    }
326}
327
328/// Playwright renderer with browser context isolation.
329///
330/// Manages browser contexts with resource limits (memory, CPU) and
331/// provides page rendering via Playwright CLI subprocess. Each render
332/// creates an isolated browser context that is destroyed after use.
333///
334/// # Examples
335///
336/// ```rust
337/// use crawlkit_engine::{PlaywrightRenderer, PlaywrightConfig};
338///
339/// let renderer = PlaywrightRenderer::new(PlaywrightConfig::default());
340/// assert!(!renderer.is_available()); // disabled by default
341/// ```
342pub struct PlaywrightRenderer {
343    config: PlaywrightConfig,
344    detector: PlaywrightDetector,
345    contexts: Arc<RwLock<Vec<BrowserContext>>>,
346}
347
348impl PlaywrightRenderer {
349    /// Create a new Playwright renderer.
350    #[must_use]
351    pub fn new(config: PlaywrightConfig) -> Self {
352        let detector = PlaywrightDetector::detect();
353        Self {
354            config,
355            detector,
356            contexts: Arc::new(RwLock::new(Vec::new())),
357        }
358    }
359
360    /// Create with default config.
361    #[must_use]
362    pub fn with_default_config() -> Self {
363        Self::new(PlaywrightConfig::default())
364    }
365
366    /// Check if Playwright is available.
367    #[must_use]
368    pub fn is_available(&self) -> bool {
369        self.config.enabled && self.detector.is_available()
370    }
371
372    /// Get detector reference.
373    #[must_use]
374    pub fn detector(&self) -> &PlaywrightDetector {
375        &self.detector
376    }
377
378    /// Create a new browser context with resource isolation.
379    ///
380    /// # Errors
381    /// Returns error if context creation fails.
382    pub fn create_context(&self) -> Result<BrowserContext, PlaywrightError> {
383        if !self.is_available() {
384            return Err(PlaywrightError::NotAvailable(
385                "Playwright not available".to_string(),
386            ));
387        }
388
389        let contexts = self.contexts.read();
390        if contexts.len() >= self.config.max_concurrent {
391            return Err(PlaywrightError::ResourceLimitExceeded(
392                "Maximum concurrent contexts reached".to_string(),
393            ));
394        }
395
396        let context = BrowserContext::new(
397            uuid::Uuid::new_v4().to_string(),
398            self.config.max_memory_per_context,
399            Duration::from_secs(self.config.max_cpu_seconds),
400        );
401
402        drop(contexts);
403        self.contexts.write().push(context.clone());
404
405        Ok(context)
406    }
407
408    /// Render a page with JavaScript.
409    ///
410    /// # Errors
411    /// Returns error if rendering fails.
412    pub async fn render(&self, url: &str) -> Result<RenderedPage, PlaywrightError> {
413        if !self.config.enabled {
414            return Err(PlaywrightError::NotAvailable(
415                "JavaScript rendering is disabled".to_string(),
416            ));
417        }
418
419        if !self.is_available() {
420            return Err(PlaywrightError::NotAvailable(
421                "Playwright binary not found".to_string(),
422            ));
423        }
424
425        let start = Instant::now();
426
427        // Create isolated context
428        let context = self.create_context()?;
429
430        // Launch browser and render page via Playwright CLI
431        let rendered = self.render_via_cli(url).await?;
432
433        let render_time = start.elapsed();
434
435        // Remove context
436        self.contexts.write().retain(|c| c.id != context.id);
437
438        Ok(RenderedPage {
439            render_time,
440            ..rendered
441        })
442    }
443
444    /// Render page via Playwright CLI subprocess.
445    ///
446    /// The URL is passed as a separate argument to the script via
447    /// `process.argv[2]` to prevent JavaScript injection.
448    async fn render_via_cli(&self, url: &str) -> Result<RenderedPage, PlaywrightError> {
449        let _binary = self.detector.binary_path().ok_or_else(|| {
450            PlaywrightError::NotAvailable("Playwright binary not found".to_string())
451        })?;
452
453        // SECURITY: URL is NOT interpolated into the JS string.
454        // It is passed via process.argv to prevent code injection.
455        let script = format!(
456            r#"
457const {{ chromium }} = require('playwright');
458const targetUrl = process.argv[2];
459
460(async () => {{
461    const browser = await chromium.launch({{
462        headless: {},
463        args: {}
464    }});
465    
466    const context = await browser.newContext({{
467        viewport: {{ width: 1920, height: 1080 }},
468        userAgent: 'crawlkit/0.4.0'
469    }});
470    
471    const page = await context.newPage();
472    
473    const consoleMessages = [];
474    const networkRequests = [];
475    
476    page.on('console', msg => {{
477        consoleMessages.push({{
478            level: msg.type(),
479            text: msg.text(),
480            source: msg.location().url || null,
481            line: msg.location().lineNumber || null
482        }});
483    }});
484    
485    page.on('request', req => {{
486        networkRequests.push({{
487            url: req.url(),
488            method: req.method(),
489            status: null,
490            resourceType: req.resourceType(),
491            size: null
492        }});
493    }});
494    
495    page.on('response', res => {{
496        const req = networkRequests.find(r => r.url === res.url());
497        if (req) {{
498            req.status = res.status();
499        }}
500    }});
501    
502    try {{
503        await page.goto(targetUrl, {{ waitUntil: 'networkidle', timeout: {} }});
504    }} catch (e) {{
505        console.error('Navigation error:', e.message);
506    }}
507    
508    const html = await page.content();
509    const finalUrl = page.url();
510    
511    // Detect WASM errors
512    const wasmErrors = [];
513    for (const msg of consoleMessages) {{
514        if (msg.level === 'error' && 
515            (msg.text.toLowerCase().includes('webassembly') || 
516             msg.text.toLowerCase().includes('wasm'))) {{
517            wasmErrors.push({{
518                error_type: 'runtime',
519                message: msg.text,
520                source: msg.source,
521                timestamp: Date.now()
522            }});
523        }}
524    }}
525    
526    const result = {{
527        final_url: finalUrl,
528        html: html,
529        console_messages: consoleMessages,
530        network_requests: networkRequests,
531        wasm_errors: wasmErrors,
532        memory_used: process.memoryUsage().heapUsed
533    }};
534    
535    console.log(JSON.stringify(result));
536    
537    await browser.close();
538}})();
539"#,
540            self.config.headless,
541            serde_json::to_string(&self.config.args).unwrap_or_else(|_| "[]".to_string()),
542            self.config.timeout.as_millis()
543        );
544
545        // Write script to temporary file
546        let temp_dir = std::env::temp_dir();
547        let script_path = temp_dir.join(format!("crawlkit_playwright_{}.js", uuid::Uuid::new_v4()));
548        std::fs::write(&script_path, &script)
549            .map_err(|e| PlaywrightError::BrowserLaunchFailed(e.to_string()))?;
550
551        // Execute Playwright script. Pass URL as argument to prevent injection.
552        // Allow NODE_PATH override via environment variable.
553        let node_path = std::env::var("NODE_PATH").unwrap_or_default();
554        let mut cmd = tokio::process::Command::new("node");
555        cmd.arg(&script_path);
556        cmd.arg(url); // URL passed as argv[2], accessed via process.argv[2] in JS
557        if !node_path.is_empty() {
558            cmd.env("NODE_PATH", &node_path);
559        }
560        let output = cmd
561            .output()
562            .await
563            .map_err(|e| PlaywrightError::BrowserLaunchFailed(e.to_string()))?;
564
565        // Clean up temporary script
566        let _ = std::fs::remove_file(&script_path);
567
568        if !output.status.success() {
569            let stderr = String::from_utf8_lossy(&output.stderr);
570            return Err(PlaywrightError::BrowserLaunchFailed(stderr.to_string()));
571        }
572
573        let stdout = String::from_utf8_lossy(&output.stdout);
574        let result: serde_json::Value = serde_json::from_str(&stdout)
575            .map_err(|e| PlaywrightError::JsEvaluationFailed(e.to_string()))?;
576
577        Ok(RenderedPage {
578            final_url: result["final_url"].as_str().unwrap_or(url).to_string(),
579            html: result["html"].as_str().unwrap_or("").to_string(),
580            console_messages: serde_json::from_value(result["console_messages"].clone())
581                .unwrap_or_default(),
582            network_requests: serde_json::from_value(result["network_requests"].clone())
583                .unwrap_or_default(),
584            wasm_errors: serde_json::from_value(result["wasm_errors"].clone()).unwrap_or_default(),
585            render_time: Duration::from_millis(0), // Will be set by caller
586            memory_used: result["memory_used"].as_u64().unwrap_or(0),
587        })
588    }
589
590    /// Get active context count.
591    #[must_use]
592    pub fn active_contexts(&self) -> usize {
593        self.contexts.read().len()
594    }
595
596    /// Get configuration.
597    #[must_use]
598    pub fn config(&self) -> &PlaywrightConfig {
599        &self.config
600    }
601}
602
603impl Default for PlaywrightRenderer {
604    fn default() -> Self {
605        Self::with_default_config()
606    }
607}
608
609// ---------------------------------------------------------------------------
610// Tests
611// ---------------------------------------------------------------------------
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    #[test]
618    fn test_playwright_config_default() {
619        let config = PlaywrightConfig::default();
620        assert!(!config.enabled);
621        assert_eq!(config.max_concurrent, 5);
622        assert!(config.headless);
623        assert_eq!(config.max_memory_per_context, 512 * 1024 * 1024);
624    }
625
626    #[test]
627    fn test_browser_type_as_str() {
628        assert_eq!(BrowserType::Chromium.as_str(), "chromium");
629        assert_eq!(BrowserType::Firefox.as_str(), "firefox");
630        assert_eq!(BrowserType::WebKit.as_str(), "webkit");
631    }
632
633    #[test]
634    fn test_playwright_detector() {
635        let detector = PlaywrightDetector::detect();
636        // May or may not be available depending on environment
637        let _ = detector.is_available();
638    }
639
640    #[test]
641    fn test_browser_context_resource_limits() {
642        let context = BrowserContext::new(
643            "test".to_string(),
644            1024 * 1024, // 1 MB
645            Duration::from_secs(10),
646        );
647
648        assert!(!context.is_over_limit(512 * 1024)); // 512 KB - OK
649        assert!(context.is_over_limit(2 * 1024 * 1024)); // 2 MB - Over limit
650    }
651
652    #[tokio::test]
653    async fn test_playwright_render_disabled() {
654        let renderer = PlaywrightRenderer::with_default_config();
655        let result = renderer.render("https://example.com").await;
656        assert!(result.is_err());
657    }
658
659    #[test]
660    fn test_playwright_renderer_not_available() {
661        let renderer = PlaywrightRenderer::with_default_config();
662        assert!(!renderer.is_available());
663    }
664}