1use crate::llm::actions::{
5 protocol_trait::{ActionResult, Protocol, Server},
6 ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
7};
8use crate::protocol::log_template::LogTemplate;
9use crate::protocol::{EventType, SpawnContext};
10use crate::state::app_state::AppState;
11use anyhow::{bail, ensure, Context, Result};
12use serde_json::{json, Value};
13use std::sync::LazyLock;
14
15pub const ACCEPT: &str = "sse_accept";
16pub const EVENT: &str = "sse_event";
17pub const COMMENT: &str = "sse_comment";
18pub const CLOSE: &str = "sse_close";
19pub const RESPOND: &str = "sse_respond";
20pub const BROADCAST: &str = "sse_broadcast";
21pub const KEEPALIVE_SECS: u64 = 15;
23pub const MAX_RETRY_MS: u64 = 3_600_000;
25
26#[derive(Default)]
27pub struct SseProtocol;
28impl SseProtocol {
29 pub fn new() -> Self {
30 Self
31 }
32}
33
34pub fn p(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
35 Parameter {
36 name: name.into(),
37 type_hint: kind.into(),
38 description: description.into(),
39 required,
40 }
41}
42
43pub fn action(
44 name: &str,
45 description: &str,
46 parameters: Vec<Parameter>,
47 example: Value,
48) -> ActionDefinition {
49 ActionDefinition {
50 name: name.into(),
51 description: description.into(),
52 parameters,
53 example,
54 log_template: Some(LogTemplate::new().with_info(format!("-> SSE {name}"))),
55 }
56}
57
58fn event_params() -> Vec<Parameter> {
59 vec![
60 p(
61 "data",
62 "string",
63 "The event's data; each line of it becomes one data: line",
64 true,
65 ),
66 p(
67 "event",
68 "string",
69 "The event type (the client's addEventListener name); omitted means message",
70 false,
71 ),
72 p(
73 "id",
74 "string",
75 "The event ID; a reconnecting client sends the last one back as Last-Event-ID",
76 false,
77 ),
78 ]
79}
80
81pub fn actions() -> Vec<ActionDefinition> {
82 vec![
83 action(
84 ACCEPT,
85 "Answer the request with an event stream (200 text/event-stream) and keep it open. Events in the same answer follow at once",
86 vec![p("retry_ms", "number", "Tell the client how long to wait before reconnecting, in milliseconds", false)],
87 json!({"type": ACCEPT, "retry_ms": 3000}),
88 ),
89 action(
90 EVENT,
91 "Send one event on this stream (accepting the request first if it is not a stream yet)",
92 event_params(),
93 json!({"type": EVENT, "event": "update", "id": "1", "data": "hello"}),
94 ),
95 action(
96 COMMENT,
97 "Send a comment line, which clients ignore",
98 vec![p("text", "string", "The comment, one line", true)],
99 json!({"type": COMMENT, "text": "still here"}),
100 ),
101 action(
102 CLOSE,
103 "End this stream. A browser EventSource reconnects after its retry delay with Last-Event-ID",
104 vec![],
105 json!({"type": CLOSE}),
106 ),
107 action(
108 RESPOND,
109 "Answer with an ordinary HTTP response instead of a stream: 204 tells an EventSource to stop reconnecting, 401/404 refuse",
110 vec![
111 p("status", "number", "HTTP status, 200-599", true),
112 p("body", "string", "Response body text (default empty)", false),
113 p("content_type", "string", "Content-Type of the body (default text/plain; charset=utf-8)", false),
114 ],
115 json!({"type": RESPOND, "status": 404, "body": "no such stream"}),
116 ),
117 action(
118 BROADCAST,
119 "Send one event to every open stream (or only those on one path)",
120 {
121 let mut v = event_params();
122 v.push(p("path", "string", "Only streams opened on this path, e.g. /news (default every stream)", false));
123 v
124 },
125 json!({"type": BROADCAST, "path": "/news", "event": "post", "id": "7", "data": "new article"}),
126 ),
127 ]
128}
129
130pub static REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
131 EventType::new(
132 "sse_request",
133 "An HTTP request arrived: answer it with a stream (sse_accept and events), an ordinary response, or a broadcast",
134 json!({"type": ACCEPT}),
135 )
136 .with_parameters(vec![
137 p("method", "string", "The HTTP method (GET or HEAD)", true),
138 p("path", "string", "The request path without the query, e.g. /news", true),
139 p("query", "object", "The query string's parameters", true),
140 p("last_event_id", "string", "Last-Event-ID a reconnecting client sent (the last event ID it saw), empty on a first connection", true),
141 p("accept", "string", "The Accept header (an EventSource sends text/event-stream)", false),
142 p("user_agent", "string", "The User-Agent header", false),
143 p("client_ip", "string", "Where the request came from", true),
144 ])
145 .with_actions(actions())
146});
147
148pub fn check(v: &Value) -> Result<()> {
149 let text = |k: &str, required: bool| -> Result<Option<String>> {
150 match v.get(k).filter(|x| !x.is_null()) {
151 Some(Value::String(s)) => Ok(Some(s.clone())),
152 Some(other) => Ok(Some(other.to_string())),
153 None => {
154 ensure!(!required, "{k} is required");
155 Ok(None)
156 }
157 }
158 };
159 match v["type"].as_str().unwrap_or_default() {
160 ACCEPT => {
161 if let Some(r) = v.get("retry_ms").filter(|x| !x.is_null()) {
162 ensure!(
163 r.as_u64().is_some_and(|r| r <= MAX_RETRY_MS),
164 "retry_ms is 0..={MAX_RETRY_MS}"
165 );
166 }
167 }
168 EVENT | BROADCAST => {
169 let data = text("data", true)?.unwrap_or_default();
170 super::wire::encode_event(
171 text("event", false)?.as_deref(),
172 text("id", false)?.as_deref(),
173 &data,
174 None,
175 )?;
176 if let Some(path) = text("path", false)? {
177 ensure!(
178 path.starts_with('/') && path.len() <= 2048,
179 "path starts with /"
180 );
181 }
182 }
183 COMMENT => {
184 super::wire::encode_comment(&text("text", true)?.unwrap_or_default())?;
185 }
186 CLOSE => {}
187 RESPOND => {
188 let status = v["status"].as_u64().context("status is a number")?;
189 ensure!((200..=599).contains(&status), "status is 200..=599");
190 if let Some(ct) = text("content_type", false)? {
191 ensure!(!ct.contains(['\r', '\n']), "content_type is one line");
192 }
193 ensure!(
194 text("body", false)?.is_none_or(|b| b.len() <= 1024 * 1024),
195 "body is at most 1 MiB"
196 );
197 }
198 other => bail!("Unknown SSE action {other:?}"),
199 }
200 Ok(())
201}
202
203pub fn encode(v: &Value) -> Result<Vec<u8>> {
205 let s = |k: &str| match v.get(k) {
206 Some(Value::String(s)) => Some(s.clone()),
207 Some(Value::Null) | None => None,
208 Some(other) => Some(other.to_string()),
209 };
210 super::wire::encode_event(
211 s("event").as_deref(),
212 s("id").as_deref(),
213 &s("data").unwrap_or_default(),
214 None,
215 )
216}
217
218impl Protocol for SseProtocol {
219 fn protocol_name(&self) -> &'static str {
220 "SSE"
221 }
222 fn stack_name(&self) -> &'static str {
223 "ETH>IP>TCP>HTTP>SSE"
224 }
225 fn keywords(&self) -> Vec<&'static str> {
226 vec![
227 "sse",
228 "server-sent events",
229 "eventsource",
230 "event stream",
231 "text/event-stream",
232 ]
233 }
234 fn description(&self) -> &'static str {
235 "Server-sent events server: streams events to EventSource clients, per stream and by broadcast"
236 }
237 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
238 actions()
239 .into_iter()
240 .filter(|a| a.name == BROADCAST)
241 .collect()
242 }
243 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
244 actions()
245 }
246 fn get_event_types(&self) -> Vec<EventType> {
247 vec![REQUEST_EVENT.clone()]
248 }
249 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
250 vec![ParameterDefinition {
251 name: "keepalive_secs".into(),
252 type_hint: "number".into(),
253 description: "Seconds (1..=3600) an open stream may be silent before a keep-alive comment is sent".into(),
254 required: false,
255 example: json!(15),
256 default: Some(json!(KEEPALIVE_SECS)),
257 }]
258 }
259 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
260 use crate::protocol::metadata::*;
261 ProtocolMetadataV2::builder()
262 .state(DevelopmentState::Experimental)
263 .privilege_requirement(PrivilegeRequirement::None)
264 .implementation("Hand-rolled HTTP/1.1 over Tokio TCP: one request per connection, answered with a text/event-stream kept open (keep-alive comments, retry hints) or an ordinary response; WHATWG event-stream encoding; a per-server subscriber table for broadcasts; each stream registered as a peer for injected events")
265 .llm_control("Which requests get a stream, what each stream says and when it ends, and what is broadcast to every subscriber")
266 .e2e_testing("tests/server/sse: the eventsource npm package (WHATWG EventSource, reconnecting with Last-Event-ID) and Python's httpx-sse read NetGet's streams, broadcasts and injected events")
267 .notes("HTTP/1.1 only, no TLS, no request bodies (GET/HEAD); one request per connection. Request heads are capped at 16 KiB and 10 s; event data at 1 MiB.")
268 .answers_on_failure()
269 .max_inbound_bytes(super::MAX_REQUEST_HEAD_BYTES)
270 .build()
271 }
272 fn example_prompt(&self) -> &'static str {
273 "Event stream on port 8080 that greets each subscriber of /news and broadcasts whatever is posted to /post?msg="
274 }
275 fn get_startup_examples(&self) -> StartupExamples {
276 let llm = json!({"type":"open_server","base_stack":"sse","port":8080,
277 "instruction":"Stream /news: greet each subscriber with an event. A request to /post?msg=X broadcasts X to /news and answers 202"});
278 let mut static_example = llm.clone();
279 static_example["event_handlers"] = json!([{"event_pattern":"sse_request","handler":{"type":"static","actions":[
280 {"type":ACCEPT,"retry_ms":3000},{"type":EVENT,"event":"welcome","id":"1","data":"hello"}]}}]);
281 let mut scripted = llm.clone();
282 scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python",
283 "code":"import json,sys\ne=json.load(sys.stdin)['event']\nif e['path']=='/post':\n a=[{'type':'sse_broadcast','path':'/news','data':e['query'].get('msg','')},{'type':'sse_respond','status':202}]\nelse:\n a=[{'type':'sse_accept'},{'type':'sse_event','data':'welcome'}]\nprint(json.dumps({'actions':a}))"}}]);
284 StartupExamples::new(llm, scripted, static_example)
285 }
286 fn group_name(&self) -> &'static str {
287 "Web & File"
288 }
289}
290
291impl Server for SseProtocol {
292 fn spawn(
293 &self,
294 ctx: SpawnContext,
295 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
296 {
297 Box::pin(super::spawn(ctx))
298 }
299
300 fn execute_action_with_state<'a>(
301 &'a self,
302 action: Value,
303 state: AppState,
304 server_id: Option<crate::state::ServerId>,
305 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ActionResult>> + Send + 'a>>
306 {
307 if action["type"].as_str() != Some(BROADCAST) {
308 let result = self.execute_action(action);
309 return Box::pin(async move { result });
310 }
311 Box::pin(async move {
312 check(&action)?;
313 let id = server_id.context("sse_broadcast needs the running SSE server")?;
314 let bytes = encode(&action)?;
315 let delivered = super::broadcast(&state, id, action["path"].as_str(), &bytes).await;
316 Ok(ActionResult::Custom {
317 name: BROADCAST.into(),
318 data: json!({"delivered": delivered}),
319 })
320 })
321 }
322
323 fn execute_action(&self, action: Value) -> Result<ActionResult> {
324 check(&action)?;
325 Ok(match action["type"].as_str().unwrap_or_default() {
326 EVENT => ActionResult::Output(encode(&action)?),
327 COMMENT => ActionResult::Output(super::wire::encode_comment(
328 action["text"].as_str().unwrap_or_default(),
329 )?),
330 CLOSE => ActionResult::CloseConnection,
331 name => ActionResult::Custom {
332 name: name.into(),
333 data: action,
334 },
335 })
336 }
337}