1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use serde::{Deserialize, Serialize};
8use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
9use tokio::process::{Child, Command};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Viewport {
14 pub width: u32,
15 pub height: u32,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum ExtractionPreset {
22 Links,
23 Headings,
24 Tables,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ExtractedElement {
30 pub tag: String,
31 pub text: String,
32 pub attributes: HashMap<String, String>,
33}
34
35#[derive(Debug, Serialize)]
37#[serde(tag = "action", rename_all = "lowercase")]
38pub enum PlaywrightRequest {
39 Screenshot {
40 id: u64,
41 url: String,
42 viewport: Option<Viewport>,
43 },
44 Extract {
45 id: u64,
46 url: String,
47 selector: Option<String>,
48 preset: Option<ExtractionPreset>,
49 },
50 Ping {
51 id: u64,
52 },
53}
54
55#[derive(Debug, Deserialize)]
57pub struct PlaywrightResponse {
58 pub id: u64,
59 pub ok: bool,
60 pub data: Option<serde_json::Value>,
61 pub error: Option<String>,
62}
63
64#[derive(Debug, thiserror::Error)]
66pub enum PlaywrightError {
67 #[error(
68 "Playwright/Node.js not found. Install with: npm install -g playwright && npx playwright install chromium"
69 )]
70 NotInstalled,
71 #[error("Bridge communication error: {0}")]
72 Communication(String),
73 #[error("Bridge returned error: {0}")]
74 BridgeError(String),
75 #[error("Operation timed out after {0:?}")]
76 Timeout(Duration),
77 #[error("IO error: {0}")]
78 Io(#[from] std::io::Error),
79}
80
81const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
83const BRIDGE_SCRIPT: &str = include_str!("playwright_bridge.js");
84static BRIDGE_SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
85
86pub struct PlaywrightBridge {
91 _child: Child,
92 stdin: BufWriter<tokio::process::ChildStdin>,
93 stdout: BufReader<tokio::process::ChildStdout>,
94 next_id: AtomicU64,
95 _bridge_script: PathBuf,
97}
98
99impl PlaywrightBridge {
100 pub async fn start(playwright_path: Option<&Path>) -> Result<Self, PlaywrightError> {
107 let script_path = write_bridge_script_temp_file().await?;
108
109 let node_path = resolve_node_path(playwright_path);
111
112 let mut child = Command::new(&node_path)
114 .arg(&script_path)
115 .stdin(Stdio::piped())
116 .stdout(Stdio::piped())
117 .stderr(Stdio::null())
118 .kill_on_drop(true)
119 .spawn()
120 .map_err(|e| {
121 if e.kind() == std::io::ErrorKind::NotFound {
122 PlaywrightError::NotInstalled
123 } else {
124 PlaywrightError::Io(e)
125 }
126 })?;
127
128 let stdin = child
129 .stdin
130 .take()
131 .ok_or_else(|| PlaywrightError::Communication("failed to open stdin".into()))?;
132 let stdout = child
133 .stdout
134 .take()
135 .ok_or_else(|| PlaywrightError::Communication("failed to open stdout".into()))?;
136
137 let mut bridge = Self {
138 _child: child,
139 stdin: BufWriter::new(stdin),
140 stdout: BufReader::new(stdout),
141 next_id: AtomicU64::new(1),
142 _bridge_script: script_path,
143 };
144
145 let id = bridge.next_id();
147 let resp = bridge.send_request(PlaywrightRequest::Ping { id }).await?;
148 if !resp.ok {
149 return Err(PlaywrightError::Communication(
150 resp.error.unwrap_or_else(|| "ping failed".into()),
151 ));
152 }
153
154 Ok(bridge)
155 }
156
157 pub async fn send_request(
161 &mut self,
162 request: PlaywrightRequest,
163 ) -> Result<PlaywrightResponse, PlaywrightError> {
164 let result = tokio::time::timeout(DEFAULT_TIMEOUT, self.send_request_inner(request)).await;
165 match result {
166 Ok(inner) => inner,
167 Err(_) => Err(PlaywrightError::Timeout(DEFAULT_TIMEOUT)),
168 }
169 }
170
171 pub async fn screenshot(
173 &mut self,
174 url: &str,
175 viewport: Option<Viewport>,
176 ) -> Result<String, PlaywrightError> {
177 let id = self.next_id();
178 let resp = self
179 .send_request(PlaywrightRequest::Screenshot {
180 id,
181 url: url.to_owned(),
182 viewport,
183 })
184 .await?;
185
186 if !resp.ok {
187 return Err(PlaywrightError::BridgeError(
188 resp.error.unwrap_or_else(|| "screenshot failed".into()),
189 ));
190 }
191
192 resp.data
193 .and_then(|v| v.as_str().map(String::from))
194 .ok_or_else(|| PlaywrightError::Communication("missing screenshot data".into()))
195 }
196
197 pub async fn extract(
199 &mut self,
200 url: &str,
201 selector: Option<&str>,
202 preset: Option<ExtractionPreset>,
203 ) -> Result<Vec<ExtractedElement>, PlaywrightError> {
204 let id = self.next_id();
205 let resp = self
206 .send_request(PlaywrightRequest::Extract {
207 id,
208 url: url.to_owned(),
209 selector: selector.map(String::from),
210 preset,
211 })
212 .await?;
213
214 if !resp.ok {
215 return Err(PlaywrightError::BridgeError(
216 resp.error.unwrap_or_else(|| "extraction failed".into()),
217 ));
218 }
219
220 let data = resp
221 .data
222 .ok_or_else(|| PlaywrightError::Communication("missing extraction data".into()))?;
223
224 serde_json::from_value(data)
225 .map_err(|e| PlaywrightError::Communication(format!("failed to parse elements: {e}")))
226 }
227
228 fn next_id(&self) -> u64 {
231 self.next_id.fetch_add(1, Ordering::Relaxed)
232 }
233
234 async fn send_request_inner(
235 &mut self,
236 request: PlaywrightRequest,
237 ) -> Result<PlaywrightResponse, PlaywrightError> {
238 let mut line = serde_json::to_string(&request)
239 .map_err(|e| PlaywrightError::Communication(format!("serialize error: {e}")))?;
240 line.push('\n');
241
242 self.stdin
243 .write_all(line.as_bytes())
244 .await
245 .map_err(|e| PlaywrightError::Communication(format!("write error: {e}")))?;
246 self.stdin
247 .flush()
248 .await
249 .map_err(|e| PlaywrightError::Communication(format!("flush error: {e}")))?;
250
251 let mut response_line = String::new();
252 let bytes_read = self
253 .stdout
254 .read_line(&mut response_line)
255 .await
256 .map_err(|e| PlaywrightError::Communication(format!("read error: {e}")))?;
257
258 if bytes_read == 0 {
259 return Err(PlaywrightError::Communication(
260 "bridge process closed stdout".into(),
261 ));
262 }
263
264 let response: PlaywrightResponse = serde_json::from_str(&response_line)
265 .map_err(|e| PlaywrightError::Communication(format!("deserialize error: {e}")))?;
266
267 let expected_id = match &request {
268 PlaywrightRequest::Screenshot { id, .. }
269 | PlaywrightRequest::Extract { id, .. }
270 | PlaywrightRequest::Ping { id } => *id,
271 };
272
273 if response.id != expected_id {
274 return Err(PlaywrightError::Communication(format!(
275 "response id mismatch: expected {expected_id}, got {}",
276 response.id
277 )));
278 }
279
280 Ok(response)
281 }
282}
283
284async fn write_bridge_script_temp_file() -> Result<PathBuf, PlaywrightError> {
285 let sequence = BRIDGE_SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
286 let timestamp = SystemTime::now()
287 .duration_since(UNIX_EPOCH)
288 .map_or(0, |duration| duration.as_nanos());
289 let script_path = std::env::temp_dir().join(format!(
290 "swink_playwright_bridge_{}_{}_{}.js",
291 std::process::id(),
292 timestamp,
293 sequence
294 ));
295
296 tokio::fs::write(&script_path, BRIDGE_SCRIPT).await?;
297 Ok(script_path)
298}
299
300fn resolve_node_path(explicit: Option<&Path>) -> PathBuf {
304 if let Some(p) = explicit {
305 return p.to_path_buf();
306 }
307
308 if let Ok(output) = std::process::Command::new("which").arg("node").output()
310 && output.status.success()
311 {
312 let path_str = String::from_utf8_lossy(&output.stdout).trim().to_owned();
313 if !path_str.is_empty() {
314 return PathBuf::from(path_str);
315 }
316 }
317
318 PathBuf::from("node")
319}
320
321#[cfg(test)]
322mod tests {
323 use std::collections::HashSet;
324 use std::path::Path;
325 use std::process::Command as StdCommand;
326
327 use super::{BRIDGE_SCRIPT, resolve_node_path, write_bridge_script_temp_file};
328
329 #[tokio::test]
330 async fn writes_unique_bridge_scripts_for_concurrent_startups() {
331 let handles = (0..8).map(|_| tokio::spawn(write_bridge_script_temp_file()));
332 let mut paths = Vec::new();
333
334 for handle in handles {
335 let path = handle
336 .await
337 .expect("task should complete")
338 .expect("temp script creation should succeed");
339 paths.push(path);
340 }
341
342 let unique_paths: HashSet<_> = paths.iter().cloned().collect();
343 assert_eq!(unique_paths.len(), paths.len());
344
345 for path in &paths {
346 let contents = tokio::fs::read_to_string(path)
347 .await
348 .expect("script contents should be readable");
349 assert_eq!(contents, BRIDGE_SCRIPT);
350 tokio::fs::remove_file(path)
351 .await
352 .expect("temp script cleanup should succeed");
353 }
354 }
355
356 #[test]
357 fn bridge_script_exports_data_only_extract_helpers() {
358 assert!(!BRIDGE_SCRIPT.contains("eval("));
359
360 let bridge_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/playwright_bridge.js");
361 let node_script = format!(
362 r"
363const bridge = require({bridge_path});
364
365const linksPlan = bridge.buildExtractionPlan({{ preset: 'links' }});
366if (linksPlan.selector !== 'a[href]' || linksPlan.preset !== 'links') {{
367 throw new Error('unexpected links plan: ' + JSON.stringify(linksPlan));
368}}
369
370const selectorPlan = bridge.buildExtractionPlan({{ selector: '.card' }});
371if (selectorPlan.selector !== '.card' || selectorPlan.preset !== null) {{
372 throw new Error('unexpected selector plan: ' + JSON.stringify(selectorPlan));
373}}
374
375const customElement = bridge.extractElementData(
376 {{
377 tagName: 'DIV',
378 textContent: ' Hello world ',
379 attributes: [{{ name: 'data-id', value: '42' }}],
380 getAttribute(name) {{
381 return name === 'data-id' ? '42' : null;
382 }},
383 innerHTML: '<span>Hello world</span>',
384 }},
385 null
386);
387if (customElement.tag !== 'div' || customElement.text !== 'Hello world' || customElement.attributes['data-id'] !== '42') {{
388 throw new Error('unexpected custom element: ' + JSON.stringify(customElement));
389}}
390
391const linkElement = bridge.extractElementData(
392 {{
393 tagName: 'A',
394 textContent: ' Docs ',
395 attributes: [{{ name: 'href', value: '/docs' }}],
396 getAttribute(name) {{
397 return name === 'href' ? '/docs' : null;
398 }},
399 innerHTML: 'Docs',
400 }},
401 'links'
402);
403if (linkElement.attributes.href !== '/docs' || Object.keys(linkElement.attributes).length !== 1) {{
404 throw new Error('unexpected link element: ' + JSON.stringify(linkElement));
405}}
406
407const headingElement = bridge.extractElementData(
408 {{
409 tagName: 'H2',
410 textContent: ' Section ',
411 attributes: [{{ name: 'id', value: 'section' }}],
412 getAttribute() {{
413 return null;
414 }},
415 innerHTML: 'Section',
416 }},
417 'headings'
418);
419if (headingElement.tag !== 'h2' || headingElement.text !== 'Section' || Object.keys(headingElement.attributes).length !== 0) {{
420 throw new Error('unexpected heading element: ' + JSON.stringify(headingElement));
421}}
422
423const tableElement = bridge.extractElementData(
424 {{
425 tagName: 'TABLE',
426 textContent: '',
427 attributes: [],
428 getAttribute() {{
429 return null;
430 }},
431 innerHTML: '<tbody><tr><td>value</td></tr></tbody>',
432 }},
433 'tables'
434);
435if (tableElement.tag !== 'table' || !tableElement.text.includes('<tbody>')) {{
436 throw new Error('unexpected table element: ' + JSON.stringify(tableElement));
437}}
438",
439 bridge_path = serde_json::to_string(&bridge_path.display().to_string())
440 .expect("path should serialize"),
441 );
442
443 let output = StdCommand::new(resolve_node_path(None))
444 .arg("-e")
445 .arg(node_script)
446 .output()
447 .expect("node should run bridge helper assertions");
448
449 assert!(
450 output.status.success(),
451 "node assertions failed: {}",
452 String::from_utf8_lossy(&output.stderr)
453 );
454 }
455}