use crate::response::Response;
use crate::upgrade::Upgraded;
use rustlavel_core::Json;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;
pub const KEEPALIVE: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, PartialEq)]
pub struct Event {
pub id: Option<String>,
pub event: Option<String>,
pub data: String,
pub retry: Option<Duration>,
}
impl Event {
pub fn data(data: impl Into<String>) -> Event {
Event { id: None, event: None, data: data.into(), retry: None }
}
pub fn named(event: impl Into<String>, data: impl Into<String>) -> Event {
Event { id: None, event: Some(event.into()), data: data.into(), retry: None }
}
pub fn json(event: impl Into<String>, data: Json) -> Event {
Event::named(event, data.to_string())
}
pub fn id(mut self, id: impl Into<String>) -> Event {
self.id = Some(id.into());
self
}
pub fn retry(mut self, after: Duration) -> Event {
self.retry = Some(after);
self
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = String::new();
if let Some(id) = &self.id {
out.push_str(&format!("id: {}\n", strip_newlines(id)));
}
if let Some(event) = &self.event {
out.push_str(&format!("event: {}\n", strip_newlines(event)));
}
if let Some(retry) = self.retry {
out.push_str(&format!("retry: {}\n", retry.as_millis()));
}
for line in self.data.split('\n') {
out.push_str("data: ");
out.push_str(line.trim_end_matches('\r'));
out.push('\n');
}
out.push('\n');
out.into_bytes()
}
}
fn strip_newlines(text: &str) -> String {
text.replace(['\r', '\n'], " ")
}
pub fn channel(capacity: usize) -> (mpsc::Sender<Event>, mpsc::Receiver<Event>) {
mpsc::channel(capacity.max(1))
}
impl Response {
pub fn events(events: mpsc::Receiver<Event>) -> Response {
let events = std::sync::Mutex::new(Some(events));
Response::ok()
.with_header("content-type", "text/event-stream")
.with_header("cache-control", "no-cache")
.with_header("connection", "close")
.with_header("x-accel-buffering", "no")
.streaming(move |connection: Upgraded| {
let events = events.lock().ok().and_then(|mut held| held.take());
async move {
if let Some(events) = events {
pump(connection, events).await;
}
}
})
}
}
async fn pump(mut connection: Upgraded, mut events: mpsc::Receiver<Event>) {
let mut keepalive = tokio::time::interval(KEEPALIVE);
keepalive.tick().await;
loop {
let bytes = tokio::select! {
event = events.recv() => match event {
Some(event) => event.to_bytes(),
None => break,
},
_ = keepalive.tick() => b": keepalive\n\n".to_vec(),
};
if connection.writer.write_all(&bytes).await.is_err() {
break;
}
if connection.writer.flush().await.is_err() {
break;
}
}
let _ = connection.writer.shutdown().await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_event_is_written_in_the_shape_event_source_reads() {
let bytes = Event::named("progress", "42").id("7").retry(Duration::from_secs(2)).to_bytes();
assert_eq!(
String::from_utf8(bytes).unwrap(),
"id: 7\nevent: progress\nretry: 2000\ndata: 42\n\n"
);
assert_eq!(String::from_utf8(Event::data("hello").to_bytes()).unwrap(), "data: hello\n\n");
}
#[test]
fn multi_line_data_is_split_across_data_lines() {
let text = String::from_utf8(Event::data("line one\nline two\r\nline three").to_bytes()).unwrap();
assert_eq!(text, "data: line one\ndata: line two\ndata: line three\n\n");
}
#[test]
fn a_line_break_cannot_be_smuggled_into_a_field() {
let text = String::from_utf8(Event::named("a\nevent: b", "x").id("1\n2").to_bytes()).unwrap();
assert_eq!(text.lines().filter(|l| l.starts_with("event:")).count(), 1, "{text}");
assert_eq!(text.lines().filter(|l| l.starts_with("id:")).count(), 1, "{text}");
assert!(text.starts_with("id: 1 2\nevent: a event: b\n"), "{text}");
}
#[test]
fn json_events_carry_the_document_on_one_line() {
let text = String::from_utf8(
Event::json("progress", Json::object([("percent", Json::from(50))])).to_bytes(),
)
.unwrap();
assert_eq!(text, "event: progress\ndata: {\"percent\":50}\n\n");
}
#[tokio::test]
async fn events_arrive_as_they_are_sent_and_the_stream_ends_with_the_sender() {
use crate::{Request, Router, Server};
use rustlavel_core::Context;
use tokio::io::AsyncReadExt;
use tokio::net::{TcpListener, TcpStream};
let (hand_over, mut take) = mpsc::channel::<mpsc::Sender<Event>>(1);
let hand_over = std::sync::Arc::new(hand_over);
let mut router = Router::new();
router.get("/events", move |_req: Request| {
let hand_over = std::sync::Arc::clone(&hand_over);
async move {
let (tx, rx) = channel(8);
hand_over.try_send(tx).expect("the test is waiting for the sender");
Response::events(rx)
}
});
let server = std::sync::Arc::new(Server::new(router, Context::default()));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (stream, peer) = listener.accept().await.unwrap();
let _ = server.serve_connection(stream, peer).await;
});
let mut client = TcpStream::connect(addr).await.unwrap();
client.write_all(b"GET /events HTTP/1.1\r\nHost: t\r\n\r\n").await.unwrap();
let mut head = vec![0u8; 512];
let n = client.read(&mut head).await.unwrap();
let head = String::from_utf8_lossy(&head[..n]).to_string();
assert!(head.starts_with("HTTP/1.1 200 OK"), "{head}");
assert!(head.contains("text/event-stream"), "{head}");
assert!(!head.to_ascii_lowercase().contains("content-length"), "{head}");
let tx = take.recv().await.expect("the handler ran");
for step in [10, 50, 100] {
tx.send(Event::json("progress", Json::object([("percent", Json::from(step))]))).await.unwrap();
let mut chunk = vec![0u8; 256];
let n = tokio::time::timeout(Duration::from_secs(2), client.read(&mut chunk))
.await
.expect("an event did not arrive within two seconds — the stream is buffering")
.unwrap();
let text = String::from_utf8_lossy(&chunk[..n]).to_string();
assert!(text.contains(&format!("\"percent\":{step}")), "step {step}: {text}");
}
drop(tx);
let mut rest = Vec::new();
tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut rest))
.await
.expect("the connection stayed open after the sender was dropped")
.unwrap();
}
#[test]
fn the_response_is_a_stream_with_no_length() {
let (_tx, rx) = channel(4);
let response = Response::events(rx);
let head = String::from_utf8(response.to_bytes(false)).unwrap();
assert!(head.starts_with("HTTP/1.1 200 OK\r\n"), "{head}");
assert!(head.contains("content-type: text/event-stream\r\n"), "{head}");
assert!(head.contains("cache-control: no-cache\r\n"), "{head}");
assert!(!head.to_ascii_lowercase().contains("content-length"), "a length would end the stream: {head}");
assert!(response.upgrades(), "the socket is not handed over");
}
}