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.3) and yields one [`JmapStateChange`] per push frame.
3//!
4//! Composes [`Http11HeadersRead`] + [`Http11ChunksReadStream`] +
5//! [`SseFrameParser`] + [`JmapStateChange::parse`] 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//!         session::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    mem,
70    sync::atomic::{AtomicBool, Ordering},
71};
72
73use alloc::{format, string::String, sync::Arc, vec::Vec};
74
75use io_http::{
76    coroutine::*,
77    rfc9110::{headers::HTTP_TRANSFER_ENCODING, request::HttpRequest},
78    rfc9112::{
79        chunk_stream::{
80            Http11ChunksReadStream, Http11ChunksReadStreamError, Http11ChunksReadStreamYield,
81        },
82        read_headers::{Http11HeadersRead, Http11HeadersReadError, Http11HeadersReadOutput},
83    },
84    sse::frame::{SseFrameParser, SseFrameParserYield},
85};
86use log::{debug, trace};
87use secrecy::{ExposeSecret, SecretString};
88use thiserror::Error;
89use url::Url;
90
91use crate::{
92    coroutine::*,
93    rfc8620::{
94        event_source::{JmapCloseAfter, JmapStateChange, JmapStateChangeParseError},
95        session::JmapSession,
96    },
97};
98
99/// Per-step yield for [`JmapEventSource`].
100#[derive(Debug)]
101pub enum JmapEventSourceYield {
102    /// One decoded push notification. Empty-data SSE frames (pings) surface as
103    /// the default [`JmapStateChange`] with an empty `changed` map: keep-alive.
104    Frame(JmapStateChange),
105    /// The caller reads more bytes and feeds them back on the next resume.
106    WantsRead,
107    /// The caller writes these bytes; the next resume typically takes `None`.
108    WantsWrite(Vec<u8>),
109}
110
111/// Failure causes during the JMAP event-source flow.
112#[derive(Debug, Error)]
113pub enum JmapEventSourceError {
114    /// The server answered the subscription with a non-2xx status.
115    #[error("JMAP event-source failed: HTTP {0}")]
116    HttpStatus(u16),
117    /// The streaming response did not use chunked transfer coding.
118    #[error("JMAP event-source failed: response must be Transfer-Encoding: chunked")]
119    NotChunked,
120    /// The subscription URL built from the session could not be parsed.
121    #[error("JMAP event-source failed: invalid URL {0}")]
122    InvalidUrl(String),
123    /// The response head could not be read.
124    #[error("JMAP event-source failed: {0}")]
125    ReadHeaders(#[from] Http11HeadersReadError),
126    /// The chunked-body decoder failed.
127    #[error("JMAP event-source failed: {0}")]
128    ReadChunks(#[from] Http11ChunksReadStreamError),
129    /// An SSE frame could not be decoded as a StateChange.
130    #[error("JMAP event-source failed: {0}")]
131    DecodeFrame(#[from] JmapStateChangeParseError),
132}
133
134/// I/O-free streaming coroutine for the JMAP `EventSource` push channel.
135///
136/// Cooperative shutdown: the coroutine polls the shared [`AtomicBool`] at the
137/// top of each [`Self::resume`] and terminates with `Complete(Ok(()))` when
138/// set. The caller's resume loop must honour the flag too to interrupt a
139/// blocking socket read in flight.
140pub struct JmapEventSource {
141    state: State,
142    shutdown: Arc<AtomicBool>,
143}
144
145impl JmapEventSource {
146    /// Builds the JMAP push subscription URL: `event_source_url` plus
147    /// `types=<csv>`, `closeafter=<v>` (see [`JmapCloseAfter`]) and
148    /// `ping=<seconds>`. `types` may be empty for "all types".
149    pub fn subscribe_url(
150        session: &JmapSession,
151        types: &[&str],
152        ping_seconds: u64,
153        close_after: JmapCloseAfter,
154    ) -> String {
155        let base = &session.event_source_url;
156        let types = types.join(",");
157        let sep = if base.contains('?') { '&' } else { '?' };
158        let close_after = close_after.as_str();
159        format!("{base}{sep}types={types}&closeafter={close_after}&ping={ping_seconds}")
160    }
161
162    /// Builds the subscription URL from the session and prepares the initial
163    /// `GET` request bytes.
164    ///
165    /// `types` filters JMAP data types (empty = all). `ping_seconds` sets the
166    /// server heartbeat cadence. `close_after` picks the lifecycle (see
167    /// [`JmapCloseAfter`]). Flip `shutdown` to wind the coroutine down.
168    pub fn new(
169        session: &JmapSession,
170        http_auth: &SecretString,
171        types: &[&str],
172        ping_seconds: u64,
173        close_after: JmapCloseAfter,
174        shutdown: Arc<AtomicBool>,
175    ) -> Result<Self, JmapEventSourceError> {
176        let url_str = Self::subscribe_url(session, types, ping_seconds, close_after);
177        let url = Url::parse(&url_str).map_err(|_| JmapEventSourceError::InvalidUrl(url_str))?;
178
179        let host = url.host_str().unwrap_or("localhost");
180        let request = HttpRequest::get(url.clone())
181            .header("Host", host)
182            .header("Accept", "text/event-stream")
183            .header("Authorization", http_auth.expose_secret());
184
185        debug!("prepare event source subscription request");
186        trace!("subscription url: {url}");
187
188        Ok(Self {
189            state: State::SendingRequest(request.to_http_11_vec()),
190            shutdown,
191        })
192    }
193}
194
195impl JmapCoroutine for JmapEventSource {
196    type Yield = JmapEventSourceYield;
197    type Return = Result<(), JmapEventSourceError>;
198
199    /// Advances the coroutine.
200    ///
201    /// `None` on the initial call; `Some(data)` after a
202    /// [`JmapEventSourceYield::WantsRead`]. `Some(&[])` is EOF: it's an error
203    /// during the head stage, a clean `Complete(Ok(()))` during streaming.
204    fn resume(&mut self, mut arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
205        if self.shutdown.load(Ordering::SeqCst) {
206            self.state = State::Done;
207            return JmapCoroutineState::Complete(Ok(()));
208        }
209
210        loop {
211            match &mut self.state {
212                State::SendingRequest(_) => {
213                    let State::SendingRequest(bytes) = mem::replace(
214                        &mut self.state,
215                        State::ReadingHead(Http11HeadersRead::default()),
216                    ) else {
217                        unreachable!()
218                    };
219                    return JmapCoroutineState::Yielded(JmapEventSourceYield::WantsWrite(bytes));
220                }
221                State::ReadingHead(head) => match head.resume(arg.take()) {
222                    HttpCoroutineState::Yielded(HttpYield::WantsRead) => {
223                        return JmapCoroutineState::Yielded(JmapEventSourceYield::WantsRead);
224                    }
225                    HttpCoroutineState::Yielded(HttpYield::WantsWrite(_)) => {
226                        unreachable!("Http11HeadersRead never writes");
227                    }
228                    HttpCoroutineState::Complete(Err(err)) => {
229                        return JmapCoroutineState::Complete(Err(err.into()));
230                    }
231                    HttpCoroutineState::Complete(Ok(Http11HeadersReadOutput {
232                        response,
233                        remaining,
234                        keep_alive: _,
235                    })) => {
236                        if !response.status.is_success() {
237                            return JmapCoroutineState::Complete(Err(
238                                JmapEventSourceError::HttpStatus(*response.status),
239                            ));
240                        }
241                        let chunked = response
242                            .header(HTTP_TRANSFER_ENCODING)
243                            .is_some_and(|enc| enc.eq_ignore_ascii_case("chunked"));
244                        if !chunked {
245                            return JmapCoroutineState::Complete(Err(
246                                JmapEventSourceError::NotChunked,
247                            ));
248                        }
249                        // NOTE: head reader may over-read past `\r\n\r\n`
250                        // into the chunked body; prime the chunk decoder so
251                        // the SSE parser never sees `<hex>\r\n` size headers.
252                        let mut chunks = Http11ChunksReadStream::default();
253                        let pending = if remaining.is_empty() {
254                            None
255                        } else {
256                            match chunks.resume(Some(&remaining)) {
257                                HttpCoroutineState::Yielded(
258                                    Http11ChunksReadStreamYield::Frame { body },
259                                ) => Some(body),
260                                HttpCoroutineState::Yielded(
261                                    Http11ChunksReadStreamYield::WantsRead,
262                                ) => None,
263                                HttpCoroutineState::Complete(Ok(_)) => {
264                                    self.state = State::Done;
265                                    return JmapCoroutineState::Complete(Ok(()));
266                                }
267                                HttpCoroutineState::Complete(Err(err)) => {
268                                    return JmapCoroutineState::Complete(Err(err.into()));
269                                }
270                            }
271                        };
272                        self.state = State::Streaming {
273                            chunks,
274                            parser: SseFrameParser::default(),
275                            pending,
276                        };
277                        // NOTE: fall into the streaming arm so the parser
278                        // drains the primed buffer before asking for bytes.
279                    }
280                },
281                State::Streaming {
282                    chunks,
283                    parser,
284                    pending,
285                } => {
286                    let parser_input = pending.take();
287                    match parser.resume(parser_input.as_deref()) {
288                        HttpCoroutineState::Yielded(SseFrameParserYield::Frame(frame)) => {
289                            return match JmapStateChange::parse(&frame.data) {
290                                Ok(change) => {
291                                    JmapCoroutineState::Yielded(JmapEventSourceYield::Frame(change))
292                                }
293                                Err(err) => JmapCoroutineState::Complete(Err(err.into())),
294                            };
295                        }
296                        HttpCoroutineState::Yielded(SseFrameParserYield::WantsBytes) => {
297                            match chunks.resume(arg.take()) {
298                                HttpCoroutineState::Yielded(
299                                    Http11ChunksReadStreamYield::Frame { body },
300                                ) => {
301                                    *pending = Some(body);
302                                }
303                                HttpCoroutineState::Complete(Ok(_)) => {
304                                    self.state = State::Done;
305                                    return JmapCoroutineState::Complete(Ok(()));
306                                }
307                                HttpCoroutineState::Yielded(
308                                    Http11ChunksReadStreamYield::WantsRead,
309                                ) => {
310                                    return JmapCoroutineState::Yielded(
311                                        JmapEventSourceYield::WantsRead,
312                                    );
313                                }
314                                HttpCoroutineState::Complete(Err(err)) => {
315                                    return JmapCoroutineState::Complete(Err(err.into()));
316                                }
317                            }
318                        }
319                        HttpCoroutineState::Complete(never) => match never {},
320                    }
321                }
322                State::Done => return JmapCoroutineState::Complete(Ok(())),
323            }
324        }
325    }
326}
327
328/// Internal progression state of [`JmapEventSource`].
329enum State {
330    /// Initial: yield the GET request bytes once, then transition to head.
331    SendingRequest(Vec<u8>),
332    /// Resuming [`Http11HeadersRead`] on the response.
333    ReadingHead(Http11HeadersRead),
334    /// Pumping chunks into the SSE parser, decoding each frame as a
335    /// `JmapStateChange`.
336    Streaming {
337        chunks: Http11ChunksReadStream,
338        parser: SseFrameParser,
339        /// Decoded chunk body waiting to be fed to the SSE parser.
340        pending: Option<Vec<u8>>,
341    },
342    /// Terminal: shutdown flipped, or stream finished.
343    Done,
344}
345
346#[cfg(test)]
347mod tests {
348    use core::sync::atomic::AtomicBool;
349
350    use alloc::{
351        collections::BTreeMap,
352        format,
353        string::{String, ToString},
354        sync::Arc,
355        vec::Vec,
356    };
357
358    use secrecy::SecretString;
359
360    use crate::{
361        coroutine::*,
362        rfc8620::{event_source::subscribe::*, event_source::*, session::JmapSession},
363    };
364
365    fn synthetic_session() -> JmapSession {
366        JmapSession {
367            username: String::new(),
368            accounts: BTreeMap::new(),
369            primary_accounts: BTreeMap::new(),
370            capabilities: BTreeMap::new(),
371            api_url: "https://example.org/api".parse().unwrap(),
372            download_url: String::new(),
373            upload_url: String::new(),
374            event_source_url: String::new(),
375            state: String::new(),
376        }
377    }
378
379    #[test]
380    fn subscribe_url_appends_query_params() {
381        let session = JmapSession {
382            event_source_url: "https://jmap.example.org/events".into(),
383            ..synthetic_session()
384        };
385        let url = JmapEventSource::subscribe_url(
386            &session,
387            &["Email", "EmailDelivery"],
388            30,
389            JmapCloseAfter::No,
390        );
391        assert_eq!(
392            url,
393            "https://jmap.example.org/events?types=Email,EmailDelivery&closeafter=no&ping=30"
394        );
395    }
396
397    #[test]
398    fn subscribe_url_preserves_existing_query() {
399        let session = JmapSession {
400            event_source_url: "https://jmap.example.org/events?token=abc".into(),
401            ..synthetic_session()
402        };
403        let url = JmapEventSource::subscribe_url(&session, &[], 15, JmapCloseAfter::State);
404        assert_eq!(
405            url,
406            "https://jmap.example.org/events?token=abc&types=&closeafter=state&ping=15"
407        );
408    }
409
410    // NOTE: regression guard. Head reader over-reads into the chunked body;
411    // those leftover bytes must go through the chunk decoder, not straight
412    // to the SSE parser. This body splits `data:` across two chunks so the
413    // broken path produces invalid JSON.
414    #[test]
415    fn streaming_head_leftover_is_chunk_decoded() {
416        let session = JmapSession {
417            event_source_url: "https://example.org/sse".into(),
418            ..synthetic_session()
419        };
420        let auth = SecretString::from("Bearer t".to_string());
421        let shutdown = Arc::new(AtomicBool::new(false));
422        let mut es = JmapEventSource::new(
423            &session,
424            &auth,
425            &["Email"],
426            30,
427            JmapCloseAfter::State,
428            shutdown,
429        )
430        .unwrap();
431
432        // NOTE: drain the initial GET-request write so the next resume
433        // enters the ReadingHead arm.
434        let JmapCoroutineState::Yielded(JmapEventSourceYield::WantsWrite(_)) = es.resume(None)
435        else {
436            panic!("expected initial WantsWrite");
437        };
438
439        let part1 = "event: state\ndata: {\"@type\":\"StateChange\",\"changed\":{\"u1\":";
440        let part2 = "{\"Email\":\"s1\"}}}\n\n";
441        let chunked = format!(
442            "{:x}\r\n{part1}\r\n{:x}\r\n{part2}\r\n0\r\n\r\n",
443            part1.len(),
444            part2.len(),
445        );
446        let head = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: text/event-stream\r\n\r\n";
447        let mut wire: Vec<u8> = head.as_bytes().to_vec();
448        wire.extend_from_slice(chunked.as_bytes());
449
450        // NOTE: head + body arrive in one socket read; matches the Fastmail
451        // trace shape that triggered the bug.
452        match es.resume(Some(&wire)) {
453            JmapCoroutineState::Yielded(JmapEventSourceYield::Frame(change)) => {
454                assert_eq!(change.r#type, "StateChange");
455                assert_eq!(change.changed["u1"]["Email"], "s1");
456            }
457            other => panic!("expected Frame yield, got {other:?}"),
458        }
459    }
460}