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}