use core::{
fmt, mem,
sync::atomic::{AtomicBool, Ordering},
};
use alloc::{string::String, sync::Arc, vec::Vec};
use io_http::{
coroutine::*,
rfc9110::{headers::TRANSFER_ENCODING, request::HttpRequest},
rfc9112::{
chunk_stream::{
Http11ReadChunksStream, Http11ReadChunksStreamError, Http11ReadChunksStreamYield,
},
read_headers::{Http11ReadHeaders, Http11ReadHeadersError, Http11ReadHeadersOutput},
},
sse::frame::{SseFrameParser, SseFrameParserYield},
};
use log::trace;
use secrecy::{ExposeSecret, SecretString};
use thiserror::Error;
use url::Url;
use crate::{coroutine::*, rfc8620::JmapSession};
use super::{
types::{JmapCloseAfter, JmapStateChange, JmapStateChangeParseError},
utils::{parse_state_change, subscribe_url},
};
#[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] Http11ReadHeadersError),
#[error("JMAP event-source failed: {0}")]
ReadChunks(#[from] Http11ReadChunksStreamError),
#[error("JMAP event-source failed: {0}")]
DecodeFrame(#[from] JmapStateChangeParseError),
}
pub struct JmapEventSource {
state: State,
shutdown: Arc<AtomicBool>,
}
impl JmapEventSource {
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 = 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());
trace!("prepare JMAP event source subscription to {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 {
trace!("event-source: {}", self.state);
match &mut self.state {
State::SendingRequest(_) => {
let State::SendingRequest(bytes) = mem::replace(
&mut self.state,
State::ReadingHead(Http11ReadHeaders::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!("Http11ReadHeaders never writes");
}
HttpCoroutineState::Complete(Err(err)) => {
return JmapCoroutineState::Complete(Err(err.into()));
}
HttpCoroutineState::Complete(Ok(Http11ReadHeadersOutput {
response,
remaining,
keep_alive: _,
})) => {
if !response.status.is_success() {
return JmapCoroutineState::Complete(Err(
JmapEventSourceError::HttpStatus(*response.status),
));
}
let chunked = response
.header(TRANSFER_ENCODING)
.is_some_and(|enc| enc.eq_ignore_ascii_case("chunked"));
if !chunked {
return JmapCoroutineState::Complete(Err(
JmapEventSourceError::NotChunked,
));
}
let mut chunks = Http11ReadChunksStream::default();
let pending = if remaining.is_empty() {
None
} else {
match chunks.resume(Some(&remaining)) {
HttpCoroutineState::Yielded(
Http11ReadChunksStreamYield::Frame { body },
) => Some(body),
HttpCoroutineState::Yielded(
Http11ReadChunksStreamYield::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 parse_state_change(&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(
Http11ReadChunksStreamYield::Frame { body },
) => {
*pending = Some(body);
}
HttpCoroutineState::Complete(Ok(_)) => {
self.state = State::Done;
return JmapCoroutineState::Complete(Ok(()));
}
HttpCoroutineState::Yielded(
Http11ReadChunksStreamYield::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(Http11ReadHeaders),
Streaming {
chunks: Http11ReadChunksStream,
parser: SseFrameParser,
pending: Option<Vec<u8>>,
},
Done,
}
impl fmt::Display for State {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SendingRequest(_) => f.write_str("send request"),
Self::ReadingHead(_) => f.write_str("read head"),
Self::Streaming { .. } => f.write_str("stream frames"),
Self::Done => f.write_str("done"),
}
}
}
#[cfg(test)]
mod tests {
use alloc::{
collections::BTreeMap,
format,
string::{String, ToString},
vec::Vec,
};
use super::*;
use crate::rfc8620::JmapSession;
use crate::rfc8620::event_source::utils::DEFAULT_TYPE_TAG;
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 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, DEFAULT_TYPE_TAG);
assert_eq!(change.changed["u1"]["Email"], "s1");
}
other => panic!("expected Frame yield, got {other:?}"),
}
}
}