Skip to main content

modo/sse/
event.rs

1use crate::error::Error;
2use serde::Serialize;
3use std::time::Duration;
4
5/// A Server-Sent Event to be delivered to a connected client.
6///
7/// Uses a builder pattern. `id` and `event` name are required at construction
8/// and validated — `\n` and `\r` are rejected.
9///
10/// # Examples
11///
12/// ```
13/// use modo::sse::Event;
14///
15/// # fn example() -> modo::Result<()> {
16/// let event = Event::new("evt_01", "message")?.data("Hello, world!");
17/// # let status = serde_json::json!({"ok": true});
18/// let event = Event::new(modo::id::short(), "status")?.json(&status)?;
19/// let event = Event::new(modo::id::short(), "update")?.html("<div>new</div>");
20/// # Ok(())
21/// # }
22/// ```
23#[must_use]
24#[derive(Debug, Clone)]
25pub struct Event {
26    pub(crate) id: String,
27    pub(crate) event: String,
28    pub(crate) data: Option<String>,
29    pub(crate) retry: Option<Duration>,
30}
31
32fn validate_field(value: &str, field_name: &str) -> Result<(), Error> {
33    if value.contains('\n') || value.contains('\r') {
34        return Err(Error::bad_request(format!(
35            "SSE {field_name} must not contain newline characters"
36        )));
37    }
38    Ok(())
39}
40
41impl Event {
42    /// Create a new event. Both `id` and `event` are required.
43    ///
44    /// - `id` maps to the SSE `id:` field — used by clients for `Last-Event-ID`
45    ///   on reconnection.
46    /// - `event` maps to the SSE `event:` field — clients listen for specific
47    ///   event types (e.g., `eventSource.addEventListener("message", handler)`
48    ///   or HTMX `hx-trigger="sse:message"`).
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if `id` or `event` contain `\n` or `\r`.
53    pub fn new(id: impl Into<String>, event: impl Into<String>) -> Result<Self, Error> {
54        let id = id.into();
55        let event = event.into();
56        validate_field(&id, "id")?;
57        validate_field(&event, "event")?;
58        Ok(Self {
59            id,
60            event,
61            data: None,
62            retry: None,
63        })
64    }
65
66    /// Set the data payload as a plain string.
67    ///
68    /// Multi-line strings are handled automatically per the SSE spec — each
69    /// line gets its own `data:` prefix. The browser reassembles them with `\n`.
70    pub fn data(mut self, data: impl Into<String>) -> Self {
71        self.data = Some(data.into());
72        self
73    }
74
75    /// Set the data payload as JSON-serialized data.
76    ///
77    /// Replaces any previous data.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if JSON serialization fails.
82    pub fn json<T: Serialize>(mut self, data: &T) -> Result<Self, Error> {
83        let json = serde_json::to_string(data)
84            .map_err(|e| Error::internal(format!("SSE JSON serialization failed: {e}")))?;
85        self.data = Some(json);
86        Ok(self)
87    }
88
89    /// Set the data payload as an HTML fragment.
90    ///
91    /// Semantically identical to [`data()`](Self::data). Communicates intent
92    /// for HTMX partial rendering use cases.
93    pub fn html(self, html: impl Into<String>) -> Self {
94        self.data(html)
95    }
96
97    /// Set the reconnection delay hint for the client.
98    ///
99    /// Serialized as milliseconds in the SSE `retry:` field. Tells the browser
100    /// how long to wait before reconnecting after a disconnect.
101    pub fn retry(mut self, duration: Duration) -> Self {
102        self.retry = Some(duration);
103        self
104    }
105
106    /// Returns the event ID.
107    pub fn id(&self) -> &str {
108        &self.id
109    }
110
111    /// Returns the event name.
112    pub fn event_name(&self) -> &str {
113        &self.event
114    }
115
116    /// Returns the data payload, if set.
117    pub fn data_ref(&self) -> Option<&str> {
118        self.data.as_deref()
119    }
120}
121
122impl From<Event> for axum::response::sse::Event {
123    fn from(event: Event) -> Self {
124        let mut axum_event = axum::response::sse::Event::default();
125        axum_event = axum_event.id(event.id);
126        axum_event = axum_event.event(event.event);
127        if let Some(data) = event.data {
128            axum_event = axum_event.data(data);
129        }
130        if let Some(retry) = event.retry {
131            axum_event = axum_event.retry(retry);
132        }
133        axum_event
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn new_with_valid_id_and_event() {
143        let event = Event::new("evt_01", "message").unwrap();
144        assert_eq!(event.id, "evt_01");
145        assert_eq!(event.event, "message");
146        assert!(event.data.is_none());
147        assert!(event.retry.is_none());
148    }
149
150    #[test]
151    fn new_rejects_newline_in_id() {
152        let result = Event::new("evt\n01", "message");
153        assert!(result.is_err());
154        assert!(result.unwrap_err().message().contains("id"));
155    }
156
157    #[test]
158    fn new_rejects_carriage_return_in_event() {
159        let result = Event::new("evt_01", "msg\r");
160        assert!(result.is_err());
161        assert!(result.unwrap_err().message().contains("event"));
162    }
163
164    #[test]
165    fn data_sets_payload() {
166        let event = Event::new("id", "ev").unwrap().data("hello");
167        assert_eq!(event.data.as_deref(), Some("hello"));
168    }
169
170    #[test]
171    fn json_serializes_payload() {
172        #[derive(serde::Serialize)]
173        struct Msg {
174            text: String,
175        }
176        let event = Event::new("id", "ev")
177            .unwrap()
178            .json(&Msg { text: "hi".into() })
179            .unwrap();
180        assert_eq!(event.data.as_deref(), Some(r#"{"text":"hi"}"#));
181    }
182
183    #[test]
184    fn html_sets_payload() {
185        let event = Event::new("id", "ev").unwrap().html("<div>hi</div>");
186        assert_eq!(event.data.as_deref(), Some("<div>hi</div>"));
187    }
188
189    #[test]
190    fn retry_sets_duration() {
191        let event = Event::new("id", "ev")
192            .unwrap()
193            .retry(std::time::Duration::from_secs(5));
194        assert_eq!(event.retry, Some(std::time::Duration::from_secs(5)));
195    }
196
197    #[test]
198    fn from_converts_to_axum_event() {
199        let event = Event::new("id1", "message")
200            .unwrap()
201            .data("hello")
202            .retry(std::time::Duration::from_millis(3000));
203        let axum_event: axum::response::sse::Event = event.into();
204        let _ = axum_event;
205    }
206
207    #[test]
208    fn data_methods_replace_previous() {
209        let event = Event::new("id", "ev").unwrap().data("first").html("second");
210        assert_eq!(event.data.as_deref(), Some("second"));
211    }
212
213    #[test]
214    fn new_with_empty_id_and_event_succeeds() {
215        let event = Event::new("", "").unwrap();
216        assert_eq!(event.id, "");
217        assert_eq!(event.event, "");
218    }
219
220    #[test]
221    fn new_rejects_carriage_return_in_id() {
222        let result = Event::new("evt\r01", "message");
223        assert!(result.is_err());
224        assert!(result.unwrap_err().message().contains("id"));
225    }
226
227    #[test]
228    fn new_rejects_newline_in_event() {
229        let result = Event::new("evt_01", "msg\n");
230        assert!(result.is_err());
231        assert!(result.unwrap_err().message().contains("event"));
232    }
233
234    #[test]
235    fn getter_methods_return_expected_values() {
236        let event = Event::new("id1", "update").unwrap().data("payload");
237        assert_eq!(event.id(), "id1");
238        assert_eq!(event.event_name(), "update");
239        assert_eq!(event.data_ref(), Some("payload"));
240    }
241
242    #[test]
243    fn data_ref_returns_none_when_no_data() {
244        let event = Event::new("id1", "ping").unwrap();
245        assert!(event.data_ref().is_none());
246    }
247}