Skip to main content

io_jmap/rfc8620/event_source/
subscribe.rs

1//! I/O-free streaming coroutine that subscribes to a JMAP Event Source channel
2//! (RFC 8620 ยง7.2) and yields one [`JmapStateChange`] per push frame.
3//!
4//! Composes [`Http11ReadHeaders`] + [`Http11ReadChunksStream`] +
5//! [`SseFrameParser`] + [`parse_state_change`] into one state machine.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use std::{
11//!     io::{Read, Write},
12//!     net::TcpStream,
13//!     sync::{Arc, atomic::AtomicBool},
14//! };
15//!
16//! use io_jmap::{
17//!     coroutine::{JmapCoroutine, JmapCoroutineState},
18//!     rfc8620::{
19//!         JmapSession,
20//!         event_source::{
21//!             JmapCloseAfter,
22//!             subscribe::{JmapEventSource, JmapEventSourceYield},
23//!         },
24//!     },
25//! };
26//! use secrecy::SecretString;
27//!
28//! // Ready stream needed (TCP-connected, TLS-negociated)
29//! let mut stream = TcpStream::connect("api.example.com:443").unwrap();
30//! let mut buf = [0u8; 4096];
31//!
32//! let session: JmapSession = serde_json::from_str(r#"{
33//!     "username": "",
34//!     "accounts": {},
35//!     "primaryAccounts": {"urn:ietf:params:jmap:mail": "a1"},
36//!     "capabilities": {},
37//!     "apiUrl": "https://api.example.com/jmap/",
38//!     "downloadUrl": "",
39//!     "uploadUrl": "",
40//!     "eventSourceUrl": "https://api.example.com/jmap/eventsource/",
41//!     "state": ""
42//! }"#).unwrap();
43//! let auth = SecretString::from("Bearer xyz");
44//! let shutdown = Arc::new(AtomicBool::new(false));
45//! let mut coroutine =
46//!     JmapEventSource::new(&session, &auth, &["Email"], 30, JmapCloseAfter::State, shutdown)
47//!         .unwrap();
48//! let mut arg = None;
49//!
50//! loop {
51//!     match coroutine.resume(arg.take()) {
52//!         JmapCoroutineState::Yielded(JmapEventSourceYield::WantsWrite(bytes)) => {
53//!             stream.write_all(&bytes).unwrap();
54//!         }
55//!         JmapCoroutineState::Yielded(JmapEventSourceYield::WantsRead) => {
56//!             let n = stream.read(&mut buf).unwrap();
57//!             arg = Some(&buf[..n]);
58//!         }
59//!         JmapCoroutineState::Yielded(JmapEventSourceYield::Frame(change)) => {
60//!             println!("{change:?}");
61//!         }
62//!         JmapCoroutineState::Complete(Ok(())) => break,
63//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
64//!     }
65//! }
66//! ```
67
68use core::{
69    fmt, mem,
70    sync::atomic::{AtomicBool, Ordering},
71};
72
73use alloc::{string::String, sync::Arc, vec::Vec};
74
75use io_http::{
76    coroutine::*,
77    rfc9110::{headers::TRANSFER_ENCODING, request::HttpRequest},
78    rfc9112::{
79        chunk_stream::{
80            Http11ReadChunksStream, Http11ReadChunksStreamError, Http11ReadChunksStreamYield,
81        },
82        read_headers::{Http11ReadHeaders, Http11ReadHeadersError, Http11ReadHeadersOutput},
83    },
84    sse::frame::{SseFrameParser, SseFrameParserYield},
85};
86use log::trace;
87use secrecy::{ExposeSecret, SecretString};
88use thiserror::Error;
89use url::Url;
90
91use crate::{coroutine::*, rfc8620::JmapSession};
92
93use super::{
94    types::{JmapCloseAfter, JmapStateChange, JmapStateChangeParseError},
95    utils::{parse_state_change, subscribe_url},
96};
97
98/// Per-step yield for [`JmapEventSource`].
99#[derive(Debug)]
100pub enum JmapEventSourceYield {
101    /// One decoded push notification. Empty-data SSE frames (pings) surface as
102    /// the default [`JmapStateChange`] with an empty `changed` map: keep-alive.
103    Frame(JmapStateChange),
104    /// Driver should read more bytes and feed them back on the next resume.
105    WantsRead,
106    /// Driver should write these bytes; the next resume typically takes `None`.
107    WantsWrite(Vec<u8>),
108}
109
110/// Failure causes during the JMAP event-source flow.
111#[derive(Debug, Error)]
112pub enum JmapEventSourceError {
113    #[error("JMAP event-source failed: HTTP {0}")]
114    HttpStatus(u16),
115    #[error("JMAP event-source failed: response must be Transfer-Encoding: chunked")]
116    NotChunked,
117    #[error("JMAP event-source failed: invalid URL {0}")]
118    InvalidUrl(String),
119    #[error("JMAP event-source failed: {0}")]
120    ReadHeaders(#[from] Http11ReadHeadersError),
121    #[error("JMAP event-source failed: {0}")]
122    ReadChunks(#[from] Http11ReadChunksStreamError),
123    #[error("JMAP event-source failed: {0}")]
124    DecodeFrame(#[from] JmapStateChangeParseError),
125}
126
127/// I/O-free streaming coroutine for the JMAP `EventSource` push channel.
128///
129/// Cooperative shutdown: the coroutine polls the shared [`AtomicBool`] at the
130/// top of each [`Self::resume`] and terminates with `Complete(Ok(()))` when
131/// set. The caller's I/O driver must honour the flag too to interrupt a
132/// blocking socket read in flight.
133pub struct JmapEventSource {
134    state: State,
135    shutdown: Arc<AtomicBool>,
136}
137
138impl JmapEventSource {
139    /// Builds the subscription URL from the session and prepares the initial
140    /// `GET` request bytes.
141    ///
142    /// `types` filters JMAP data types (empty = all). `ping_seconds` sets the
143    /// server heartbeat cadence. `close_after` picks the lifecycle (see
144    /// [`JmapCloseAfter`]). Flip `shutdown` to wind the coroutine down.
145    pub fn new(
146        session: &JmapSession,
147        http_auth: &SecretString,
148        types: &[&str],
149        ping_seconds: u64,
150        close_after: JmapCloseAfter,
151        shutdown: Arc<AtomicBool>,
152    ) -> Result<Self, JmapEventSourceError> {
153        let url_str = subscribe_url(session, types, ping_seconds, close_after);
154        let url = Url::parse(&url_str).map_err(|_| JmapEventSourceError::InvalidUrl(url_str))?;
155
156        let host = url.host_str().unwrap_or("localhost");
157        let request = HttpRequest::get(url.clone())
158            .header("Host", host)
159            .header("Accept", "text/event-stream")
160            .header("Authorization", http_auth.expose_secret());
161
162        trace!("prepare JMAP event source subscription to {url}");
163
164        Ok(Self {
165            state: State::SendingRequest(request.to_http_11_vec()),
166            shutdown,
167        })
168    }
169}
170
171impl JmapCoroutine for JmapEventSource {
172    type Yield = JmapEventSourceYield;
173    type Return = Result<(), JmapEventSourceError>;
174
175    /// Advances the coroutine.
176    ///
177    /// `None` on the initial call; `Some(data)` after a
178    /// [`JmapEventSourceYield::WantsRead`]. `Some(&[])` is EOF: it's an error
179    /// during the head stage, a clean `Complete(Ok(()))` during streaming.
180    fn resume(&mut self, mut arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
181        if self.shutdown.load(Ordering::SeqCst) {
182            self.state = State::Done;
183            return JmapCoroutineState::Complete(Ok(()));
184        }
185
186        loop {
187            trace!("event-source: {}", self.state);
188            match &mut self.state {
189                State::SendingRequest(_) => {
190                    let State::SendingRequest(bytes) = mem::replace(
191                        &mut self.state,
192                        State::ReadingHead(Http11ReadHeaders::default()),
193                    ) else {
194                        unreachable!()
195                    };
196                    return JmapCoroutineState::Yielded(JmapEventSourceYield::WantsWrite(bytes));
197                }
198                State::ReadingHead(head) => match head.resume(arg.take()) {
199                    HttpCoroutineState::Yielded(HttpYield::WantsRead) => {
200                        return JmapCoroutineState::Yielded(JmapEventSourceYield::WantsRead);
201                    }
202                    HttpCoroutineState::Yielded(HttpYield::WantsWrite(_)) => {
203                        unreachable!("Http11ReadHeaders never writes");
204                    }
205                    HttpCoroutineState::Complete(Err(err)) => {
206                        return JmapCoroutineState::Complete(Err(err.into()));
207                    }
208                    HttpCoroutineState::Complete(Ok(Http11ReadHeadersOutput {
209                        response,
210                        remaining,
211                        keep_alive: _,
212                    })) => {
213                        if !response.status.is_success() {
214                            return JmapCoroutineState::Complete(Err(
215                                JmapEventSourceError::HttpStatus(*response.status),
216                            ));
217                        }
218                        let chunked = response
219                            .header(TRANSFER_ENCODING)
220                            .is_some_and(|enc| enc.eq_ignore_ascii_case("chunked"));
221                        if !chunked {
222                            return JmapCoroutineState::Complete(Err(
223                                JmapEventSourceError::NotChunked,
224                            ));
225                        }
226                        // NOTE: head reader may over-read past `\r\n\r\n`
227                        // into the chunked body; prime the chunk decoder so
228                        // the SSE parser never sees `<hex>\r\n` size headers.
229                        let mut chunks = Http11ReadChunksStream::default();
230                        let pending = if remaining.is_empty() {
231                            None
232                        } else {
233                            match chunks.resume(Some(&remaining)) {
234                                HttpCoroutineState::Yielded(
235                                    Http11ReadChunksStreamYield::Frame { body },
236                                ) => Some(body),
237                                HttpCoroutineState::Yielded(
238                                    Http11ReadChunksStreamYield::WantsRead,
239                                ) => None,
240                                HttpCoroutineState::Complete(Ok(_)) => {
241                                    self.state = State::Done;
242                                    return JmapCoroutineState::Complete(Ok(()));
243                                }
244                                HttpCoroutineState::Complete(Err(err)) => {
245                                    return JmapCoroutineState::Complete(Err(err.into()));
246                                }
247                            }
248                        };
249                        self.state = State::Streaming {
250                            chunks,
251                            parser: SseFrameParser::default(),
252                            pending,
253                        };
254                        // NOTE: fall into the streaming arm so the parser
255                        // drains the primed buffer before asking for bytes.
256                    }
257                },
258                State::Streaming {
259                    chunks,
260                    parser,
261                    pending,
262                } => {
263                    let parser_input = pending.take();
264                    match parser.resume(parser_input.as_deref()) {
265                        HttpCoroutineState::Yielded(SseFrameParserYield::Frame(frame)) => {
266                            return match parse_state_change(&frame.data) {
267                                Ok(change) => {
268                                    JmapCoroutineState::Yielded(JmapEventSourceYield::Frame(change))
269                                }
270                                Err(err) => JmapCoroutineState::Complete(Err(err.into())),
271                            };
272                        }
273                        HttpCoroutineState::Yielded(SseFrameParserYield::WantsBytes) => {
274                            match chunks.resume(arg.take()) {
275                                HttpCoroutineState::Yielded(
276                                    Http11ReadChunksStreamYield::Frame { body },
277                                ) => {
278                                    *pending = Some(body);
279                                }
280                                HttpCoroutineState::Complete(Ok(_)) => {
281                                    self.state = State::Done;
282                                    return JmapCoroutineState::Complete(Ok(()));
283                                }
284                                HttpCoroutineState::Yielded(
285                                    Http11ReadChunksStreamYield::WantsRead,
286                                ) => {
287                                    return JmapCoroutineState::Yielded(
288                                        JmapEventSourceYield::WantsRead,
289                                    );
290                                }
291                                HttpCoroutineState::Complete(Err(err)) => {
292                                    return JmapCoroutineState::Complete(Err(err.into()));
293                                }
294                            }
295                        }
296                        HttpCoroutineState::Complete(never) => match never {},
297                    }
298                }
299                State::Done => return JmapCoroutineState::Complete(Ok(())),
300            }
301        }
302    }
303}
304
305/// Internal progression state of [`JmapEventSource`].
306enum State {
307    /// Initial: yield the GET request bytes once, then transition to head.
308    SendingRequest(Vec<u8>),
309    /// Driving [`Http11ReadHeaders`] on the response.
310    ReadingHead(Http11ReadHeaders),
311    /// Pumping chunks into the SSE parser, decoding each frame as a
312    /// `JmapStateChange`.
313    Streaming {
314        chunks: Http11ReadChunksStream,
315        parser: SseFrameParser,
316        /// Decoded chunk body waiting to be fed to the SSE parser.
317        pending: Option<Vec<u8>>,
318    },
319    /// Terminal: shutdown flipped, or stream finished.
320    Done,
321}
322
323impl fmt::Display for State {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        match self {
326            Self::SendingRequest(_) => f.write_str("send request"),
327            Self::ReadingHead(_) => f.write_str("read head"),
328            Self::Streaming { .. } => f.write_str("stream frames"),
329            Self::Done => f.write_str("done"),
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use alloc::{
337        collections::BTreeMap,
338        format,
339        string::{String, ToString},
340        vec::Vec,
341    };
342
343    use super::*;
344    use crate::rfc8620::JmapSession;
345    use crate::rfc8620::event_source::utils::DEFAULT_TYPE_TAG;
346
347    fn synthetic_session() -> JmapSession {
348        JmapSession {
349            username: String::new(),
350            accounts: BTreeMap::new(),
351            primary_accounts: BTreeMap::new(),
352            capabilities: BTreeMap::new(),
353            api_url: "https://example.org/api".parse().unwrap(),
354            download_url: String::new(),
355            upload_url: String::new(),
356            event_source_url: String::new(),
357            state: String::new(),
358        }
359    }
360
361    // NOTE: regression guard. Head reader over-reads into the chunked body;
362    // those leftover bytes must go through the chunk decoder, not straight
363    // to the SSE parser. This body splits `data:` across two chunks so the
364    // broken path produces invalid JSON.
365    #[test]
366    fn streaming_head_leftover_is_chunk_decoded() {
367        let session = JmapSession {
368            event_source_url: "https://example.org/sse".into(),
369            ..synthetic_session()
370        };
371        let auth = SecretString::from("Bearer t".to_string());
372        let shutdown = Arc::new(AtomicBool::new(false));
373        let mut es = JmapEventSource::new(
374            &session,
375            &auth,
376            &["Email"],
377            30,
378            JmapCloseAfter::State,
379            shutdown,
380        )
381        .unwrap();
382
383        // NOTE: drain the initial GET-request write so the next resume
384        // enters the ReadingHead arm.
385        let JmapCoroutineState::Yielded(JmapEventSourceYield::WantsWrite(_)) = es.resume(None)
386        else {
387            panic!("expected initial WantsWrite");
388        };
389
390        let part1 = "event: state\ndata: {\"@type\":\"StateChange\",\"changed\":{\"u1\":";
391        let part2 = "{\"Email\":\"s1\"}}}\n\n";
392        let chunked = format!(
393            "{:x}\r\n{part1}\r\n{:x}\r\n{part2}\r\n0\r\n\r\n",
394            part1.len(),
395            part2.len(),
396        );
397        let head = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: text/event-stream\r\n\r\n";
398        let mut wire: Vec<u8> = head.as_bytes().to_vec();
399        wire.extend_from_slice(chunked.as_bytes());
400
401        // NOTE: head + body arrive in one socket read; matches the Fastmail
402        // trace shape that triggered the bug.
403        match es.resume(Some(&wire)) {
404            JmapCoroutineState::Yielded(JmapEventSourceYield::Frame(change)) => {
405                assert_eq!(change.r#type, DEFAULT_TYPE_TAG);
406                assert_eq!(change.changed["u1"]["Email"], "s1");
407            }
408            other => panic!("expected Frame yield, got {other:?}"),
409        }
410    }
411}