Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/knut

crates/knut-wasm-host/src/sse.rs

//! Server-sent events: a stream of already-encoded frame strings → a
//! `text/event-stream` body.
//!
//! What goes in is a `String` the caller made. This crate does not encode it, does
//! not parse it, and does not know that `{"done":true}` means the run finished —
//! only that the caller sent it last. The *terminal* frames are the caller's too:
//! [`FrameSender::finish`] exists to say "this one is the last", not to invent one.
//!
//! The framing itself is the only thing owned here, and it is the part that is easy
//! to get subtly wrong: SSE ends an event at a blank line, so a payload containing
//! a newline must carry a `data:` prefix on EVERY line or one frame silently
//! becomes two.
//!
//! The body ends when every [`FrameSender`] is dropped. No runtime is started here;
//! the caller's does the polling.

use std::convert::Infallible;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;

use bytes::{BufMut, Bytes, BytesMut};
use hyper::body::{Body, Frame, SizeHint};
use hyper::http::{Response, header};
use tokio::sync::mpsc;

/// The content type an SSE body must be served with.
pub const SSE_CONTENT_TYPE: &str = "text/event-stream";

/// A comment line — bytes that keep an idle connection (and any proxy in front of
/// it) alive without being an event. See [`SseBody::with_keep_alive`].
const KEEP_ALIVE_COMMENT: &[u8] = b":\n\n";

/// Frame `payload` as ONE server-sent event: a `data:` line per line of the
/// payload, then the blank line that ends the event.
#[must_use]
pub fn event_bytes(payload: &str) -> Bytes {
    // `data: ` + payload + a newline per line + the terminating blank line.
    let mut out = BytesMut::with_capacity(payload.len() + 16);
    for line in payload.split('\n') {
        out.put_slice(b"data: ");
        // A trailing `\r` would ride into the payload the browser decodes.
        out.put_slice(line.strip_suffix('\r').unwrap_or(line).as_bytes());
        out.put_u8(b'\n');
    }
    out.put_u8(b'\n');
    out.freeze()
}

/// Open a frame channel: a sender for the caller's producer task, and the body to
/// hand back as the response.
#[must_use]
pub fn sse_channel() -> (FrameSender, SseBody) {
    let (tx, rx) = mpsc::unbounded_channel::<String>();
    (
        FrameSender { tx },
        SseBody {
            rx,
            keep_alive: None,
        },
    )
}

/// The producing half of [`sse_channel`]. Cloneable, and the body lives until the
/// last clone is dropped.
#[derive(Clone, Debug)]
pub struct FrameSender {
    tx: mpsc::UnboundedSender<String>,
}

impl FrameSender {
    /// Queue one already-encoded frame. `false` means the client is gone — a
    /// producer that cares can stop; one that does not can ignore it, which is why
    /// this returns a bool instead of an error nobody handles.
    pub fn frame(&self, encoded: impl Into<String>) -> bool {
        self.tx.send(encoded.into()).is_ok()
    }

    /// Queue the caller's TERMINAL frame — whatever string the caller's own
    /// protocol ends a stream with. Identical machinery to [`FrameSender::frame`];
    /// separate so the intent is in the call site rather than in a comment.
    pub fn finish(&self, terminal: impl Into<String>) -> bool {
        self.frame(terminal)
    }

    /// Whether the receiving body has been dropped (the browser went away).
    #[must_use]
    pub fn is_closed(&self) -> bool {
        self.tx.is_closed()
    }
}

/// The consuming half: an [`http_body::Body`] of `text/event-stream` bytes.
///
/// [`http_body::Body`]: hyper::body::Body
#[derive(Debug)]
pub struct SseBody {
    rx: mpsc::UnboundedReceiver<String>,
    keep_alive: Option<tokio::time::Interval>,
}

impl SseBody {
    /// Emit a comment line every `every` while no frame is ready, so an idle stream
    /// (and any proxy between) does not time the connection out. Needs a tokio
    /// timer on the polling runtime; without this the body is pure `sync`.
    #[must_use]
    pub fn with_keep_alive(mut self, every: Duration) -> Self {
        let mut interval = tokio::time::interval(every);
        // The first tick of a tokio interval fires immediately; a keep-alive
        // comment before the first frame would be noise.
        interval.reset();
        self.keep_alive = Some(interval);
        self
    }

    /// The response this body belongs in: `200` + [`SSE_CONTENT_TYPE`] + no caching.
    #[must_use]
    pub fn into_response(self) -> Response<Self> {
        Response::builder()
            .header(header::CONTENT_TYPE, SSE_CONTENT_TYPE)
            .header(header::CACHE_CONTROL, "no-store")
            .body(self)
            .expect("a content type always builds a response")
    }
}

impl Body for SseBody {
    type Data = Bytes;
    type Error = Infallible;

    fn poll_frame(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
        let this = self.get_mut();
        match this.rx.poll_recv(cx) {
            Poll::Ready(Some(payload)) => Poll::Ready(Some(Ok(Frame::data(event_bytes(&payload))))),
            // Every sender dropped: the stream is over.
            Poll::Ready(None) => Poll::Ready(None),
            Poll::Pending => {
                let ticked = this
                    .keep_alive
                    .as_mut()
                    .is_some_and(|interval| interval.poll_tick(cx).is_ready());
                if ticked {
                    Poll::Ready(Some(Ok(Frame::data(Bytes::from_static(KEEP_ALIVE_COMMENT)))))
                } else {
                    Poll::Pending
                }
            }
        }
    }

    fn is_end_stream(&self) -> bool {
        false
    }

    fn size_hint(&self) -> SizeHint {
        SizeHint::default()
    }
}