Skip to main content

dravr_browser/
capture.rs

1// ABOUTME: Reads the network-capture buffer injected by the stealth hook and parses SSE bodies
2// ABOUTME: Provider-agnostic: returns raw captured text + SSE data payloads; JSON shaping lives in consumers
3//
4// SPDX-License-Identifier: MIT OR Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use chromiumoxide::Page;
8use serde::Deserialize;
9
10use crate::error::{BrowserError, BrowserResult};
11use crate::stealth::CAPTURE_GLOBAL;
12
13/// Snapshot of the most-recently-captured response.
14#[derive(Debug, Clone, Deserialize)]
15pub struct CaptureState {
16    /// HTTP status of the captured response.
17    pub status: u16,
18    /// Body captured so far (streamed chunks joined, or the full body).
19    pub body: String,
20    /// Whether the response has fully arrived (stream closed / body resolved).
21    pub done: bool,
22    /// Whether this capture is a streamed (incremental) body.
23    pub streaming: bool,
24}
25
26/// Read the most-recent capture recorded by the stealth hook, if any.
27///
28/// Returns `Ok(None)` when no matching request has been observed yet.
29pub async fn read_last_capture(page: &Page) -> BrowserResult<Option<CaptureState>> {
30    let js = format!(
31        r"(function() {{
32            var s = window.{CAPTURE_GLOBAL};
33            if (!s || !s.last) return '';
34            var rec = s.byUrl[s.last];
35            if (!rec) return '';
36            return JSON.stringify({{
37                status: rec.status,
38                body: rec.chunks.join(''),
39                done: rec.done,
40                streaming: rec.streaming
41            }});
42        }})()"
43    );
44
45    let result = page.evaluate(js).await.map_err(|e| BrowserError::Browser {
46        reason: format!("Failed to read capture buffer: {e}"),
47    })?;
48
49    let raw = result
50        .value()
51        .and_then(|v| v.as_str().map(String::from))
52        .unwrap_or_default();
53
54    if raw.is_empty() {
55        return Ok(None);
56    }
57
58    let state: CaptureState = serde_json::from_str(&raw).map_err(|e| BrowserError::Browser {
59        reason: format!("Failed to parse capture state: {e}"),
60    })?;
61    Ok(Some(state))
62}
63
64/// Parse a Server-Sent-Events body into its ordered `data:` payloads.
65///
66/// Each returned string is one event's concatenated `data:` lines (SSE allows
67/// multiple `data:` lines per event, joined with `\n`). Comment lines (`:`),
68/// `event:`/`id:`/`retry:` fields, and blank separators are handled per the
69/// SSE spec. The terminal `[DONE]` sentinel, if present, is returned verbatim
70/// so consumers can recognize it.
71#[must_use]
72pub fn parse_sse_data(body: &str) -> Vec<String> {
73    let mut events = Vec::new();
74    let mut current: Vec<String> = Vec::new();
75
76    let flush = |current: &mut Vec<String>, events: &mut Vec<String>| {
77        if !current.is_empty() {
78            events.push(current.join("\n"));
79            current.clear();
80        }
81    };
82
83    for line in body.lines() {
84        if line.is_empty() {
85            flush(&mut current, &mut events);
86            continue;
87        }
88        if line.starts_with(':') {
89            // SSE comment — ignore.
90            continue;
91        }
92        if let Some(rest) = line.strip_prefix("data:") {
93            // A single leading space after the colon is part of the syntax.
94            current.push(rest.strip_prefix(' ').unwrap_or(rest).to_owned());
95        }
96        // Other fields (event:, id:, retry:) are not needed by consumers here.
97    }
98    // Trailing event with no blank-line terminator (common mid-stream).
99    flush(&mut current, &mut events);
100
101    events
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn parses_basic_events() {
110        let body = "data: hello\n\ndata: world\n\n";
111        assert_eq!(parse_sse_data(body), vec!["hello", "world"]);
112    }
113
114    #[test]
115    fn joins_multiline_data() {
116        let body = "data: line1\ndata: line2\n\n";
117        assert_eq!(parse_sse_data(body), vec!["line1\nline2"]);
118    }
119
120    #[test]
121    fn ignores_comments_and_other_fields() {
122        let body = ": keep-alive\nevent: completion\ndata: {\"x\":1}\n\n";
123        assert_eq!(parse_sse_data(body), vec![r#"{"x":1}"#]);
124    }
125
126    #[test]
127    fn handles_trailing_event_without_blank_line() {
128        let body = "data: a\n\ndata: b";
129        assert_eq!(parse_sse_data(body), vec!["a", "b"]);
130    }
131
132    #[test]
133    fn preserves_done_sentinel() {
134        let body = "data: {\"type\":\"x\"}\n\ndata: [DONE]\n\n";
135        let events = parse_sse_data(body);
136        assert_eq!(events.last().map(String::as_str), Some("[DONE]"));
137    }
138
139    #[test]
140    fn empty_body_yields_no_events() {
141        assert!(parse_sse_data("").is_empty());
142    }
143
144    #[test]
145    fn capture_state_deserializes() {
146        let raw = r#"{"status":200,"body":"data: hi\n\n","done":true,"streaming":true}"#;
147        let s: CaptureState = serde_json::from_str(raw).unwrap();
148        assert_eq!(s.status, 200);
149        assert!(s.done);
150        assert!(s.streaming);
151        assert_eq!(parse_sse_data(&s.body), vec!["hi"]);
152    }
153}