Skip to main content

zendriver_transport/
frame.rs

1//! CDP frame envelope types. Wraps `chromiumoxide_cdp` typed parameters with
2//! the id / method / session_id envelope CDP requires on the wire.
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// Outbound command frame, wire-encoded JSON sent to Chrome.
8#[derive(Debug, Serialize)]
9pub struct CdpCommand<'a> {
10    /// Monotonic per-connection identifier; Chrome echoes this in the response.
11    pub id: u64,
12    /// Dotted CDP method name, e.g. `"Page.navigate"`.
13    pub method: &'a str,
14    /// JSON-encoded parameters for the command.
15    pub params: Value,
16    /// Optional CDP session identifier; omitted from the wire when `None`.
17    #[serde(skip_serializing_if = "Option::is_none")]
18    #[serde(rename = "sessionId")]
19    pub session_id: Option<&'a str>,
20}
21
22/// Inbound frame from Chrome — either a command response or a domain event.
23#[derive(Debug, Deserialize)]
24#[serde(untagged)]
25pub enum CdpInbound {
26    /// Reply to a command identified by `id`.
27    Response {
28        /// Echoed identifier matching the originating [`CdpCommand::id`].
29        id: u64,
30        /// Successful result payload, when `error` is absent.
31        #[serde(default)]
32        result: Option<Value>,
33        /// Failure payload, when present.
34        #[serde(default)]
35        error: Option<CdpRpcError>,
36        /// Session the response is associated with, if any.
37        #[serde(default, rename = "sessionId")]
38        session_id: Option<String>,
39    },
40    /// Domain event delivered out-of-band.
41    Event {
42        /// Dotted CDP event name, e.g. `"Page.frameStoppedLoading"`.
43        method: String,
44        /// JSON-encoded event parameters.
45        #[serde(default)]
46        params: Value,
47        /// Session the event is associated with, if any.
48        #[serde(default, rename = "sessionId")]
49        session_id: Option<String>,
50    },
51}
52
53/// CDP-style RPC error returned by Chrome for a failing command.
54#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct CdpRpcError {
56    /// JSON-RPC error code (e.g. `-32601` for method-not-found).
57    pub code: i32,
58    /// Human-readable error message from Chrome.
59    pub message: String,
60    /// Optional structured payload providing additional context.
61    #[serde(default)]
62    pub data: Option<Value>,
63}
64
65/// Untyped event as it leaves the actor's broadcast bus.
66///
67/// Subscribers downcast to a `chromiumoxide_cdp` typed event by matching on
68/// `method` and deserializing `params`.
69#[derive(Debug, Clone)]
70pub struct RawEvent {
71    /// Dotted CDP event name, e.g. `"Page.frameStoppedLoading"`.
72    pub method: String,
73    /// JSON-encoded event parameters.
74    pub params: Value,
75    /// Session the event is associated with, if any.
76    pub session_id: Option<String>,
77}
78
79/// Loss-accounted event as delivered by
80/// [`Connection::subscribe_raw_accounted`](crate::connection::Connection::subscribe_raw_accounted).
81///
82/// [`Connection::subscribe_raw`](crate::connection::Connection::subscribe_raw)
83/// silently drops any [`RawEvent`] a lagging subscriber missed and has no way
84/// to signal a reconnect or a dead socket. That is fine for the lenient
85/// default, but a capture/replay/monitor consumer built on top of it is
86/// silently misled about what it actually saw. `AccountedRawEvent` is the
87/// opt-in, honest alternative: every gap, reconnect, and disconnect is
88/// reported explicitly instead of vanishing.
89///
90/// Every variant carries a `generation` — the
91/// [`Connection::connection_generation`](crate::connection::Connection::connection_generation)
92/// active when the variant was produced. `generation` starts at `1` and
93/// bumps by one on every
94/// [`Connection::reconnect`](crate::connection::Connection::reconnect); each
95/// generation gets its own `sequence` counter, restarted at `1`.
96#[derive(Debug, Clone)]
97pub enum AccountedRawEvent {
98    /// A CDP event delivered in order.
99    Event {
100        /// Generation this event belongs to.
101        generation: u64,
102        /// Monotonic, per-generation position of this event, starting at 1.
103        /// Within a single generation, a gap between two observed `sequence`
104        /// values equals the `missed` count of an intervening
105        /// [`AccountedRawEvent::Lagged`] — `sequence` resumes after a loss
106        /// rather than resetting. Do NOT compare `sequence` across a
107        /// [`AccountedRawEvent::Reconnected`]: the counter restarts at 1 for
108        /// the new generation, and a `Lagged`'s `missed` may also count the
109        /// non-sequenced `Reconnected`/`Disconnected` markers, so only
110        /// same-`generation` values are comparable.
111        sequence: u64,
112        /// The underlying raw CDP event.
113        event: RawEvent,
114    },
115    /// This subscriber fell behind the broadcast bus and missed `missed`
116    /// events that were subsequently overwritten. Unlike
117    /// [`Connection::subscribe_raw`](crate::connection::Connection::subscribe_raw),
118    /// which drops lagged frames without a trace, this variant surfaces the
119    /// loss explicitly so a consumer can decide how to react (resync, alert,
120    /// abort) instead of silently missing data.
121    Lagged {
122        /// Generation active when the loss was detected.
123        generation: u64,
124        /// Number of events this subscriber missed.
125        missed: u64,
126    },
127    /// The connection re-established a fresh WebSocket via
128    /// [`Connection::reconnect`](crate::connection::Connection::reconnect).
129    /// `sequence` resets to 1 for the new `generation`.
130    Reconnected {
131        /// Generation of the actor that was replaced.
132        previous: u64,
133        /// Generation of the newly spawned actor.
134        generation: u64,
135    },
136    /// The underlying WebSocket died unexpectedly — a Chrome-sent Close
137    /// frame, a read/write error, or the stream ending — as opposed to a
138    /// caller-requested
139    /// [`Connection::shutdown`](crate::connection::Connection::shutdown) or
140    /// a [`Connection::reconnect`](crate::connection::Connection::reconnect).
141    /// Emitted exactly once per generation's genuine death; a shutdown or a
142    /// reconnect never produces this variant.
143    Disconnected {
144        /// Generation whose WebSocket died.
145        generation: u64,
146    },
147}
148
149#[cfg(test)]
150#[allow(clippy::unwrap_used, clippy::panic)]
151mod tests {
152    use super::*;
153    use serde_json::json;
154
155    #[test]
156    fn command_serialize_omits_session_id_when_none() {
157        let cmd = CdpCommand {
158            id: 1,
159            method: "Page.navigate",
160            params: json!({ "url": "https://example.com" }),
161            session_id: None,
162        };
163        let s = serde_json::to_string(&cmd).expect("ser");
164        assert!(
165            !s.contains("sessionId"),
166            "sessionId should be omitted when None, got: {s}"
167        );
168        assert!(s.contains(r#""id":1"#));
169        assert!(s.contains(r#""method":"Page.navigate""#));
170    }
171
172    #[test]
173    fn command_serialize_includes_session_id_when_some() {
174        let cmd = CdpCommand {
175            id: 7,
176            method: "Page.navigate",
177            params: json!({ "url": "https://example.com" }),
178            session_id: Some("S1"),
179        };
180        let s = serde_json::to_string(&cmd).expect("ser");
181        assert!(s.contains(r#""sessionId":"S1""#), "got: {s}");
182    }
183
184    #[test]
185    fn inbound_deserialize_response_with_result() {
186        let raw = r#"{"id":3,"result":{"frameId":"F1"}}"#;
187        let parsed: CdpInbound = serde_json::from_str(raw).expect("de");
188        match parsed {
189            CdpInbound::Response {
190                id,
191                result,
192                error,
193                session_id,
194            } => {
195                assert_eq!(id, 3);
196                assert_eq!(result.unwrap()["frameId"], "F1");
197                assert!(error.is_none());
198                assert!(session_id.is_none());
199            }
200            CdpInbound::Event { .. } => panic!("expected Response, got Event"),
201        }
202    }
203
204    #[test]
205    fn inbound_deserialize_response_with_error() {
206        let raw = r#"{"id":3,"error":{"code":-32602,"message":"Invalid params"}}"#;
207        let parsed: CdpInbound = serde_json::from_str(raw).expect("de");
208        match parsed {
209            CdpInbound::Response { error: Some(e), .. } => {
210                assert_eq!(e.code, -32602);
211                assert_eq!(e.message, "Invalid params");
212            }
213            _ => panic!("expected Response with error"),
214        }
215    }
216
217    #[test]
218    fn inbound_deserialize_event() {
219        let raw =
220            r#"{"method":"Page.frameStoppedLoading","params":{"frameId":"F1"},"sessionId":"S1"}"#;
221        let parsed: CdpInbound = serde_json::from_str(raw).expect("de");
222        match parsed {
223            CdpInbound::Event {
224                method,
225                params,
226                session_id,
227            } => {
228                assert_eq!(method, "Page.frameStoppedLoading");
229                assert_eq!(params["frameId"], "F1");
230                assert_eq!(session_id.as_deref(), Some("S1"));
231            }
232            _ => panic!("expected Event"),
233        }
234    }
235}