///|
/// A fully-framed Server-Sent Event.
///
/// Per the SSE spec, an event is terminated by a blank line and may carry
/// multiple `data:` lines (concatenated with newlines), an `event:` type,
/// an `id:`, and a `retry:` value.
pub(all) struct SSEEvent {
event : String?
data : String
id : String?
retry : Int?
} derive(Eq, Debug)
///|
/// A stateful Server-Sent Events framer.
///
/// Feed it lines (without worrying about trailing CR/LF) via `push_line`;
/// it returns a complete `SSEEvent` whenever a blank line terminates the
/// current event. This correctly handles multi-line `data:` payloads, which
/// the naive one-line-per-event approach cannot.
pub struct SSEParser {
mut event : String?
data : StringBuilder
mut id : String?
mut retry : Int?
mut has_data : Bool
}
///|
/// Create a fresh SSE parser.
pub fn SSEParser::new() -> SSEParser {
{
event: None,
data: StringBuilder::new(),
id: None,
retry: None,
has_data: false,
}
}
///|
/// Reset internal state after emitting an event.
fn SSEParser::reset(self : SSEParser) -> Unit {
self.event = None
self.data.reset()
self.id = None
self.retry = None
self.has_data = false
}
///|
/// Feed one line into the parser (the trailing newline may be included or not).
///
/// Returns `Some(event)` when this line (a blank line) terminates an event,
/// otherwise `None`.
pub fn SSEParser::push_line(self : SSEParser, raw : String) -> SSEEvent? {
// Strip a single trailing "\r" and/or "\n".
let mut end = raw.length()
if end > 0 && raw[end - 1] == '\n' {
end = end - 1
}
if end > 0 && raw[end - 1] == '\r' {
end = end - 1
}
let line = raw[:end].to_owned()
// A blank line dispatches the accumulated event.
if line.length() == 0 {
if !self.has_data &&
self.event is None &&
self.id is None &&
self.retry is None {
// Nothing accumulated; ignore stray blank lines.
return None
}
let ev = {
event: self.event,
data: self.data.to_string(),
id: self.id,
retry: self.retry,
}
self.reset()
return Some(ev)
}
// Comment line.
if line.has_prefix(":") {
return None
}
// Split into field / value at the first colon.
let (field, value) = match line.find(":") {
Some(idx) => {
let f = line[:idx].to_owned()
let mut v = line[idx + 1:].to_owned()
if v.has_prefix(" ") {
v = v[1:].to_owned()
}
(f, v)
}
None => (line, "")
}
match field {
"event" => self.event = Some(value)
"data" => {
if self.has_data {
self.data.write_string("\n")
}
self.data.write_string(value)
self.has_data = true
}
"id" => self.id = Some(value)
"retry" => self.retry = Some(@string.parse_int(value)) catch { _ => None }
_ => () // unknown field: ignore per spec
}
None
}