use core::{
mem,
sync::atomic::{AtomicBool, Ordering},
};
use alloc::{format, string::String, sync::Arc, vec::Vec};
use io_http::{
coroutine::*,
rfc9110::{headers::HTTP_TRANSFER_ENCODING, request::HttpRequest},
rfc9112::{
chunk_stream::{
Http11ChunksReadStream, Http11ChunksReadStreamError, Http11ChunksReadStreamYield,
},
read_headers::{Http11HeadersRead, Http11HeadersReadError, Http11HeadersReadOutput},
},
sse::frame::{SseFrameParser, SseFrameParserYield},
};
use log::{debug, trace};
use secrecy::{ExposeSecret, SecretString};
use thiserror::Error;
use url::Url;
use crate::{
coroutine::*,
rfc8620::{
event_source::{JmapCloseAfter, JmapStateChange, JmapStateChangeParseError},
session::JmapSession,
},
};
#[derive(Debug)]
pub enum JmapEventSourceYield {
Frame(JmapStateChange),
WantsRead,
WantsWrite(Vec<u8>),
}
#[derive(Debug, Error)]
pub enum JmapEventSourceError {
#[error("JMAP event-source failed: HTTP {0}")]
HttpStatus(u16),
#[error("JMAP event-source failed: response must be Transfer-Encoding: chunked")]
NotChunked,
#[error("JMAP event-source failed: invalid URL {0}")]
InvalidUrl(String),
#[error("JMAP event-source failed: {0}")]
ReadHeaders(#[from] Http11HeadersReadError),
#[error("JMAP event-source failed: {0}")]
ReadChunks(#[from] Http11ChunksReadStreamError),
#[error("JMAP event-source failed: {0}")]
DecodeFrame(#[from] JmapStateChangeParseError),
}
pub struct JmapEventSource {
state: State,
shutdown: Arc<AtomicBool>,
}
impl JmapEventSource {
pub fn subscribe_url(
session: &JmapSession,
types: &[&str],
ping_seconds: u64,
close_after: JmapCloseAfter,
) -> String {
let base = &session.event_source_url;
let types = types.join(",");
let sep = if base.contains('?') { '&' } else { '?' };
let close_after = close_after.as_str();
format!("{base}{sep}types={types}&closeafter={close_after}&ping={ping_seconds}")
}
pub fn new(
session: &JmapSession,
http_auth: &SecretString,
types: &[&str],
ping_seconds: u64,
close_after: JmapCloseAfter,
shutdown: Arc<AtomicBool>,
) -> Result<Self, JmapEventSourceError> {
let url_str = Self::subscribe_url(session, types, ping_seconds, close_after);
let url = Url::parse(&url_str).map_err(|_| JmapEventSourceError::InvalidUrl(url_str))?;
let host = url.host_str().unwrap_or("localhost");
let request = HttpRequest::get(url.clone())
.header("Host", host)
.header("Accept", "text/event-stream")
.header("Authorization", http_auth.expose_secret());
debug!("prepare event source subscription request");
trace!("subscription url: {url}");
Ok(Self {
state: State::SendingRequest(request.to_http_11_vec()),
shutdown,
})
}
}
impl JmapCoroutine for JmapEventSource {
type Yield = JmapEventSourceYield;
type Return = Result<(), JmapEventSourceError>;
fn resume(&mut self, mut arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
if self.shutdown.load(Ordering::SeqCst) {
self.state = State::Done;
return JmapCoroutineState::Complete(Ok(()));
}
loop {
match &mut self.state {
State::SendingRequest(_) => {
let State::SendingRequest(bytes) = mem::replace(
&mut self.state,
State::ReadingHead(Http11HeadersRead::default()),
) else {
unreachable!()
};
return JmapCoroutineState::Yielded(JmapEventSourceYield::WantsWrite(bytes));
}
State::ReadingHead(head) => match head.resume(arg.take()) {
HttpCoroutineState::Yielded(HttpYield::WantsRead) => {
return JmapCoroutineState::Yielded(JmapEventSourceYield::WantsRead);
}
HttpCoroutineState::Yielded(HttpYield::WantsWrite(_)) => {
unreachable!("Http11HeadersRead never writes");
}
HttpCoroutineState::Complete(Err(err)) => {
return JmapCoroutineState::Complete(Err(err.into()));
}
HttpCoroutineState::Complete(Ok(Http11HeadersReadOutput {
response,
remaining,
keep_alive: _,
})) => {
if !response.status.is_success() {
return JmapCoroutineState::Complete(Err(
JmapEventSourceError::HttpStatus(*response.status),
));
}
let chunked = response
.header(HTTP_TRANSFER_ENCODING)
.is_some_and(|enc| enc.eq_ignore_ascii_case("chunked"));
if !chunked {
return JmapCoroutineState::Complete(Err(
JmapEventSourceError::NotChunked,
));
}
let mut chunks = Http11ChunksReadStream::default();
let pending = if remaining.is_empty() {
None
} else {
match chunks.resume(Some(&remaining)) {
HttpCoroutineState::Yielded(
Http11ChunksReadStreamYield::Frame { body },
) => Some(body),
HttpCoroutineState::Yielded(
Http11ChunksReadStreamYield::WantsRead,
) => None,
HttpCoroutineState::Complete(Ok(_)) => {
self.state = State::Done;
return JmapCoroutineState::Complete(Ok(()));
}
HttpCoroutineState::Complete(Err(err)) => {
return JmapCoroutineState::Complete(Err(err.into()));
}
}
};
self.state = State::Streaming {
chunks,
parser: SseFrameParser::default(),
pending,
};
}
},
State::Streaming {
chunks,
parser,
pending,
} => {
let parser_input = pending.take();
match parser.resume(parser_input.as_deref()) {
HttpCoroutineState::Yielded(SseFrameParserYield::Frame(frame)) => {
return match JmapStateChange::parse(&frame.data) {
Ok(change) => {
JmapCoroutineState::Yielded(JmapEventSourceYield::Frame(change))
}
Err(err) => JmapCoroutineState::Complete(Err(err.into())),
};
}
HttpCoroutineState::Yielded(SseFrameParserYield::WantsBytes) => {
match chunks.resume(arg.take()) {
HttpCoroutineState::Yielded(
Http11ChunksReadStreamYield::Frame { body },
) => {
*pending = Some(body);
}
HttpCoroutineState::Complete(Ok(_)) => {
self.state = State::Done;
return JmapCoroutineState::Complete(Ok(()));
}
HttpCoroutineState::Yielded(
Http11ChunksReadStreamYield::WantsRead,
) => {
return JmapCoroutineState::Yielded(
JmapEventSourceYield::WantsRead,
);
}
HttpCoroutineState::Complete(Err(err)) => {
return JmapCoroutineState::Complete(Err(err.into()));
}
}
}
HttpCoroutineState::Complete(never) => match never {},
}
}
State::Done => return JmapCoroutineState::Complete(Ok(())),
}
}
}
}
enum State {
SendingRequest(Vec<u8>),
ReadingHead(Http11HeadersRead),
Streaming {
chunks: Http11ChunksReadStream,
parser: SseFrameParser,
pending: Option<Vec<u8>>,
},
Done,
}
#[cfg(test)]
mod tests {
use core::sync::atomic::AtomicBool;
use alloc::{
collections::BTreeMap,
format,
string::{String, ToString},
sync::Arc,
vec::Vec,
};
use secrecy::SecretString;
use crate::{
coroutine::*,
rfc8620::{event_source::subscribe::*, event_source::*, session::JmapSession},
};
fn synthetic_session() -> JmapSession {
JmapSession {
username: String::new(),
accounts: BTreeMap::new(),
primary_accounts: BTreeMap::new(),
capabilities: BTreeMap::new(),
api_url: "https://example.org/api".parse().unwrap(),
download_url: String::new(),
upload_url: String::new(),
event_source_url: String::new(),
state: String::new(),
}
}
#[test]
fn subscribe_url_appends_query_params() {
let session = JmapSession {
event_source_url: "https://jmap.example.org/events".into(),
..synthetic_session()
};
let url = JmapEventSource::subscribe_url(
&session,
&["Email", "EmailDelivery"],
30,
JmapCloseAfter::No,
);
assert_eq!(
url,
"https://jmap.example.org/events?types=Email,EmailDelivery&closeafter=no&ping=30"
);
}
#[test]
fn subscribe_url_preserves_existing_query() {
let session = JmapSession {
event_source_url: "https://jmap.example.org/events?token=abc".into(),
..synthetic_session()
};
let url = JmapEventSource::subscribe_url(&session, &[], 15, JmapCloseAfter::State);
assert_eq!(
url,
"https://jmap.example.org/events?token=abc&types=&closeafter=state&ping=15"
);
}
#[test]
fn streaming_head_leftover_is_chunk_decoded() {
let session = JmapSession {
event_source_url: "https://example.org/sse".into(),
..synthetic_session()
};
let auth = SecretString::from("Bearer t".to_string());
let shutdown = Arc::new(AtomicBool::new(false));
let mut es = JmapEventSource::new(
&session,
&auth,
&["Email"],
30,
JmapCloseAfter::State,
shutdown,
)
.unwrap();
let JmapCoroutineState::Yielded(JmapEventSourceYield::WantsWrite(_)) = es.resume(None)
else {
panic!("expected initial WantsWrite");
};
let part1 = "event: state\ndata: {\"@type\":\"StateChange\",\"changed\":{\"u1\":";
let part2 = "{\"Email\":\"s1\"}}}\n\n";
let chunked = format!(
"{:x}\r\n{part1}\r\n{:x}\r\n{part2}\r\n0\r\n\r\n",
part1.len(),
part2.len(),
);
let head = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: text/event-stream\r\n\r\n";
let mut wire: Vec<u8> = head.as_bytes().to_vec();
wire.extend_from_slice(chunked.as_bytes());
match es.resume(Some(&wire)) {
JmapCoroutineState::Yielded(JmapEventSourceYield::Frame(change)) => {
assert_eq!(change.r#type, "StateChange");
assert_eq!(change.changed["u1"]["Email"], "s1");
}
other => panic!("expected Frame yield, got {other:?}"),
}
}
}