use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SseEvent {
pub event: Option<String>,
pub data: String,
pub id: Option<String>,
pub retry: Option<u64>,
}
pub struct SseParser {
buffer: String,
}
impl Default for SseParser {
fn default() -> Self {
Self {
buffer: String::with_capacity(8192),
}
}
}
impl SseParser {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, chunk: &str) -> Vec<SseEvent> {
self.buffer.push_str(chunk);
if self.buffer.contains('\r') {
let holdback = self.buffer.ends_with('\r');
let split_at = self.buffer.len() - usize::from(holdback);
let (head, tail) = self.buffer.split_at(split_at);
let normalized = head.replace("\r\n", "\n").replace('\r', "\n") + tail;
self.buffer = normalized;
}
let mut events = Vec::new();
let mut last_pos = 0;
while let Some(pos) = self.buffer[last_pos..].find("\n\n") {
let abs_pos = last_pos + pos;
let block = &self.buffer[last_pos..abs_pos];
if let Some(event) = Self::parse_block(block) {
events.push(event);
}
last_pos = abs_pos + 2;
}
if last_pos > 0 {
self.buffer.drain(..last_pos);
}
events
}
fn parse_block(block: &str) -> Option<SseEvent> {
if block.is_empty() {
return None;
}
let mut event = SseEvent {
data: String::with_capacity(block.len()),
..Default::default()
};
let mut has_data = false;
for line in block.lines() {
if line.starts_with(':') {
continue; }
if let Some(colon_pos) = line.find(':') {
let field = &line[..colon_pos];
let mut value = &line[colon_pos + 1..];
if value.starts_with(' ') {
value = &value[1..];
}
match field {
"event" => event.event = Some(value.to_string()),
"data" => {
if has_data {
event.data.push('\n');
}
event.data.push_str(value);
has_data = true;
}
"id" => event.id = Some(value.to_string()),
"retry" => {
if let Ok(retry) = value.parse() {
event.retry = Some(retry);
}
}
_ => {} }
}
}
if has_data || event.event.is_some() || event.id.is_some() || event.retry.is_some() {
Some(event)
} else {
None
}
}
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
use super::*;
#[test]
fn test_sse_parser_basic() {
let mut parser = SseParser::new();
let events = parser.push("data: hello\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "hello");
}
#[test]
fn test_sse_parser_fragmented() {
let mut parser = SseParser::new();
let mut events = parser.push("data: he");
assert!(events.is_empty());
events = parser.push("llo\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "hello");
}
#[test]
fn test_sse_parser_multiline() {
let mut parser = SseParser::new();
let events = parser.push("data: line1\ndata: line2\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "line1\nline2");
}
#[test]
fn test_sse_parser_all_fields() {
let mut parser = SseParser::new();
let events = parser.push("id: 123\nevent: message\ndata: payload\nretry: 5000\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, Some("123".to_string()));
assert_eq!(events[0].event, Some("message".to_string()));
assert_eq!(events[0].data, "payload");
assert_eq!(events[0].retry, Some(5000));
}
#[test]
fn test_sse_parser_crlf_line_endings() {
let mut parser = SseParser::new();
let events = parser.push("id: 1\r\ndata: hello\r\n\r\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, Some("1".to_string()));
assert_eq!(events[0].data, "hello");
}
#[test]
fn test_sse_parser_crlf_split_across_pushes() {
let mut parser = SseParser::new();
let events = parser.push("data: hello\r");
assert!(events.is_empty());
let events = parser.push("\ndata: world\r\n\r\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "hello\nworld");
}
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod fuzz {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn test_sse_parser_doesnt_crash(s in ".*") {
let mut parser = SseParser::new();
let _ = parser.push(&s);
}
#[test]
fn test_sse_parser_multiple_pushes(chunks in proptest::collection::vec(".*", 1..10)) {
let mut parser = SseParser::new();
for chunk in chunks {
let _ = parser.push(&chunk);
}
}
}
}