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#[derive(Debug, Error)]
12pub enum PlaywrightError {
13 #[error("playwright not available: {0}")]
15 NotAvailable(String),
16
17 #[error("page navigation failed: {0}")]
19 NavigationFailed(String),
20
21 #[error("page timeout after {0:?}")]
23 Timeout(Duration),
24
25 #[error("JavaScript evaluation failed: {0}")]
27 JsEvaluationFailed(String),
28
29 #[error("browser launch failed: {0}")]
31 BrowserLaunchFailed(String),
32
33 #[error("context creation failed: {0}")]
35 ContextCreationFailed(String),
36
37 #[error("resource limit exceeded: {0}")]
39 ResourceLimitExceeded(String),
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct PlaywrightConfig {
48 pub enabled: bool,
50 pub browser_type: BrowserType,
52 pub timeout: Duration,
54 pub max_concurrent: usize,
56 pub headless: bool,
58 pub args: Vec<String>,
60 pub max_memory_per_context: u64,
62 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, max_cpu_seconds: 30,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
90pub enum BrowserType {
91 Chromium,
93 Firefox,
95 WebKit,
97}
98
99impl BrowserType {
100 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct RenderedPage {
117 pub final_url: String,
119 pub html: String,
121 pub console_messages: Vec<ConsoleMessage>,
123 pub network_requests: Vec<NetworkRequest>,
125 pub wasm_errors: Vec<WasmError>,
127 pub render_time: Duration,
129 pub memory_used: u64,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct ConsoleMessage {
136 pub level: String,
138 pub text: String,
140 pub source: Option<String>,
142 pub line: Option<u32>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct NetworkRequest {
149 pub url: String,
151 pub method: String,
153 pub status: Option<u16>,
155 pub resource_type: String,
157 pub size: Option<u64>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct WasmError {
164 pub error_type: String,
166 pub message: String,
168 pub source: Option<String>,
170 pub timestamp: u64,
172}
173
174#[derive(Clone)]
176pub struct BrowserContext {
177 pub id: String,
179 pub memory_limit: u64,
181 pub cpu_limit: Duration,
183 pub created_at: Instant,
185 pub active: bool,
187}
188
189impl BrowserContext {
190 #[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 #[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
210pub struct PlaywrightDetector {
212 binary_path: Option<PathBuf>,
214 version: Option<String>,
216 available_browsers: Vec<BrowserType>,
218}
219
220impl PlaywrightDetector {
221 #[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 fn find_binary() -> Option<PathBuf> {
240 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 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 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 #[must_use]
305 pub fn is_available(&self) -> bool {
306 self.binary_path.is_some()
307 }
308
309 #[must_use]
311 pub fn binary_path(&self) -> Option<&PathBuf> {
312 self.binary_path.as_ref()
313 }
314
315 #[must_use]
317 pub fn version(&self) -> Option<&str> {
318 self.version.as_deref()
319 }
320
321 #[must_use]
323 pub fn has_browser(&self, browser: BrowserType) -> bool {
324 self.available_browsers.contains(&browser)
325 }
326}
327
328pub struct PlaywrightRenderer {
343 config: PlaywrightConfig,
344 detector: PlaywrightDetector,
345 contexts: Arc<RwLock<Vec<BrowserContext>>>,
346}
347
348impl PlaywrightRenderer {
349 #[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 #[must_use]
362 pub fn with_default_config() -> Self {
363 Self::new(PlaywrightConfig::default())
364 }
365
366 #[must_use]
368 pub fn is_available(&self) -> bool {
369 self.config.enabled && self.detector.is_available()
370 }
371
372 #[must_use]
374 pub fn detector(&self) -> &PlaywrightDetector {
375 &self.detector
376 }
377
378 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 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 let context = self.create_context()?;
429
430 let rendered = self.render_via_cli(url).await?;
432
433 let render_time = start.elapsed();
434
435 self.contexts.write().retain(|c| c.id != context.id);
437
438 Ok(RenderedPage {
439 render_time,
440 ..rendered
441 })
442 }
443
444 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 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 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 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); 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 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), memory_used: result["memory_used"].as_u64().unwrap_or(0),
587 })
588 }
589
590 #[must_use]
592 pub fn active_contexts(&self) -> usize {
593 self.contexts.read().len()
594 }
595
596 #[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#[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 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, Duration::from_secs(10),
646 );
647
648 assert!(!context.is_over_limit(512 * 1024)); assert!(context.is_over_limit(2 * 1024 * 1024)); }
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}