// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// The event side of a `text/event-stream`: each `send` is one frame, written as its own chunk.
pub struct Events {
priv writer : Writer
priv space : Bool
}
///|
/// Send `event` as one frame. A client dispatches it when it reads the frame's blank line, so
/// it goes out on its own rather than waiting for the next.
pub async fn Events::send(self : Events, event : @sse.Event) -> Unit {
self.writer.write(@utf8.encode(event.encode(space=self.space)[:]))
}
///|
/// Send a comment line, which a client ignores and an idle proxy takes as traffic.
pub async fn Events::ping(self : Events, comment~ : String = "") -> Unit {
self.writer.write_text(": \{comment}\n\n")
}
///|
/// A `text/event-stream` response (← sse-starlette's `EventSourceResponse`). `body` sends
/// frames for as long as it likes; the app cancels it when the client goes.
///
/// Framing an event is `moonhttp/sse`'s job. What belongs here is the response around the
/// frames, and the three headers that stop an intermediary buffering or closing the stream.
/// One named in `headers` too replaces ours rather than joining it, because a response carrying
/// two `content-type` headers is not a response with a choice in it. `wins=Base` keeps ours,
/// and `clash` can abort or take a callback instead — worth setting when overriding
/// `content-type`, which stops the stream being a stream.
pub fn sse(
body : async (Events) -> Unit,
status~ : Int32 = 200,
headers~ : Array[(String, String)] = [],
space~ : Bool = true,
wins~ : @jwt.Wins = Extra,
clash~ : @jwt.OnClash[Array[(String, String)]] = Ignore,
) -> Stream {
let base : Array[(String, String)] = [
("content-type", "\{@sse.media_type}; charset=utf-8"),
("cache-control", "no-cache"),
("connection", "keep-alive"),
]
let merged = base.copy()
let mut clashed = false
for header in headers {
let name = header.0.to_lower()
match merged.search_by(seen => seen.0.to_lower() == name) {
None => merged.push(header)
Some(at) => {
clashed = true
if wins is Extra {
merged[at] = header
}
}
}
}
let hs = if clashed {
match clash {
Ignore => merged
Panic => abort("sse: `headers` names a header the stream already sets")
Handle(decide) => decide(base, merged)
}
} else {
merged
}
Stream::new(status~, headers=hs, writer => body({ writer, space, }))
}
///|
pub using @sse {type Event}