// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// One event on the wire.
///
/// A field left `None` is left out of the frame. `data` carrying newlines goes
/// out as several `data:` lines and comes back joined, which is the format's own
/// rule and the reason a payload with a blank line in it cannot be sent as one.
pub(all) struct Event {
data : String
/// The type an `addEventListener` binds to. Absent means `message`.
kind : String?
/// Sets the client's `lastEventId`, which it sends back on reconnection.
id : String?
/// How long the client should wait before reconnecting, in milliseconds.
retry : Int?
/// A `:`-prefixed line. It dispatches nothing and exists to keep a connection
/// warm through a proxy that would otherwise close it.
comment : String?
} derive(Eq, Debug)
///|
pub extend Event with Eq::{equal, not_equal}
///|
pub extend Event with Debug::{to_repr}
///|
/// An event carrying only data, which is the common case.
pub fn Event::of(data : String) -> Event {
{ data, kind: None, id: None, retry: None, comment: None, }
}
///|
/// An event with whichever fields are wanted.
pub fn Event::new(
data? : String = "",
kind? : String,
id? : String,
retry? : Int,
comment? : String,
) -> Event {
{ data, kind, id, retry, comment, }
}
///|
/// A comment-only frame: nothing is dispatched and the connection stays warm.
pub fn Event::ping(comment? : String = "") -> Event {
{ data: "", kind: None, id: None, retry: None, comment: Some(comment), }
}
///|
/// The frame this event is sent as, ending in the blank line that dispatches it.
///
/// `space` writes the one optional space after each colon, which every stream in
/// the wild carries and which a reader strips again. Turning it off saves a byte
/// per field on a stream where that matters, and changes nothing a client sees.
pub fn Event::encode(self : Event, space? : Bool = true) -> String {
let gap = if space { " " } else { "" }
let out = StringBuilder()
match self.comment {
Some(text) =>
for line in lines(text[:]) {
field(out, ":", line, gap)
}
None => ()
}
match self.id {
Some(id) => field(out, "id:", id, gap)
None => ()
}
match self.kind {
Some(kind) => field(out, "event:", kind, gap)
None => ()
}
match self.retry {
Some(ms) => field(out, "retry:", ms.to_string(), gap)
None => ()
}
// An entirely empty frame would dispatch an event with no data, so a `data:`
// line goes out when there is data, or when there is nothing else at all.
let bare = self.comment is None &&
self.id is None &&
self.kind is None &&
self.retry is None
if self.data != "" || bare {
for line in lines(self.data[:]) {
field(out, "data:", line, gap)
}
}
out.write_char('\n')
out.to_string()
}
///|
/// Read a stream of frames.
///
/// Anything the format does not define is ignored rather than refused — an
/// unknown field name, a line with no colon, a stray carriage return. That is
/// what the specification says to do, and it is what lets the format grow.
///
/// A trailing partial frame, one not yet closed by a blank line, is left out:
/// it has not been dispatched and is not an event yet.
pub fn decode(input : StringView) -> Array[Event] {
let out : Array[Event] = []
let data = StringBuilder()
let comment = StringBuilder()
let mut kind : String? = None
let mut id : String? = None
let mut retry : Int? = None
let mut started = false
for line in lines(input) {
if line == "" {
if started {
let text = data.to_string()
out.push({
// The joined lines end in the separator the last one added.
data: if text.length() > 0 {
text[0:text.length() - 1].to_owned()
} else {
text
},
kind,
id,
retry,
comment: if comment.is_empty() {
None
} else {
let c = comment.to_string()
Some(c[0:c.length() - 1].to_owned())
},
})
}
data.reset()
comment.reset()
kind = None
id = None
retry = None
started = false
continue
}
started = true
let (name, value) = split(line)
match name {
"" => {
comment.write_string(value)
comment.write_char('\n')
}
"data" => {
data.write_string(value)
data.write_char('\n')
}
"event" => kind = Some(value)
// An id containing a NUL is ignored, which the specification says
// explicitly because it is what a broken proxy sends.
"id" => if !value.contains_char('\u{00}') { id = Some(value) }
"retry" =>
if value.length() > 0 && all_digits(value[:]) {
retry = Some(@string.parse_int(value[:]) catch { _ => 0 })
}
_ => ()
}
}
out
}
///|
/// The events an `EventSource` dispatches from a stream: what a browser hands its
/// listeners, where [`decode`] answers the frames as they were written.
///
/// Three rules differ, all from the specification's "Interpreting an event
/// stream". A frame with no `data:` line dispatches nothing, so a comment or a
/// lone `retry:` is not an event. The type is always set, `message` when the
/// frame named none or named the empty string. And `id` and `retry` are the
/// stream's, not the frame's: each holds the last value the stream set until a
/// later line changes it, which is what a client sends back as `Last-Event-ID`
/// and waits before reconnecting.
///
/// A trailing frame not yet closed by a blank line is left out, as in `decode`.
pub fn events(input : StringView) -> Array[Event] {
let out : Array[Event] = []
let data = StringBuilder()
let mut kind = ""
let mut id : String? = None
let mut retry : Int? = None
let mut some = false
for line in lines(input) {
if line == "" {
if some {
let text = data.to_string()
out.push({
data: text[0:text.length() - 1].to_owned(),
kind: Some(if kind == "" { "message" } else { kind }),
id,
retry,
comment: None,
})
}
data.reset()
kind = ""
some = false
continue
}
let (name, value) = split(line)
match name {
"data" => {
data.write_string(value)
data.write_char('\n')
some = true
}
"event" => kind = value
"id" => if !value.contains_char('\u{00}') { id = Some(value) }
"retry" =>
if value.length() > 0 && all_digits(value[:]) {
retry = Some(@string.parse_int(value[:]) catch { _ => 0 })
}
_ => ()
}
}
out
}
///|
/// The media type an event stream is served as.
pub let media_type : String = "text/event-stream"
///|
/// A line as its field name and value. A line with no colon is a name with an
/// empty value; a line starting with one is a comment, whose name is empty.
fn split(line : String) -> (String, String) {
let colon = find(line, ':')
if colon < 0 {
return (line, "")
}
// One optional space after the colon belongs to the format, not the value.
let mut from = colon + 1
if from < line.length() && line.at(from).to_int() == 0x20 {
from += 1
}
(line[0:colon].to_owned(), line[from:].to_owned())
}
///|
/// The space after the colon is the optional one a reader strips again. It is
/// written because every stream in the wild has it.
fn field(
out : StringBuilder,
name : String,
value : String,
gap : String,
) -> Unit {
out.write_string(name)
out.write_string(gap)
out.write_string(value)
out.write_char('\n')
}
///|
/// Split on the line endings the format allows: a line feed, a carriage return,
/// or the two together counting once.
fn lines(s : StringView) -> Array[String] {
let out : Array[String] = []
let piece = StringBuilder()
let mut i = 0
while i < s.length() {
let c = s.at(i).to_int()
if c == 0x0D {
out.push(piece.to_string())
piece.reset()
if i + 1 < s.length() && s.at(i + 1).to_int() == 0x0A {
i += 1
}
} else if c == 0x0A {
out.push(piece.to_string())
piece.reset()
} else {
piece.write_view(s[i:i + 1])
}
i += 1
}
out.push(piece.to_string())
out
}
///|
fn find(s : String, c : Char) -> Int {
for i in 0.. Bool {
for i in 0.. 0x39 {
return false
}
}
true
}