From f7ad9ab702fbba432e36a15649e00bf639358b87 Mon Sep 17 00:00:00 2001 From: pucedoteth <119044801+pucedoteth@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:10:48 +0200 Subject: [PATCH 1/2] fix(translation): accept SSE data fields with no space after the colon The frame parser matched `data: ` with `strip_prefix`, so a `data:` line without the space was dropped. The frame then decoded to `SseFrame::Empty` and the event was silently lost, including a `data:[DONE]` terminator. Per the SSE spec the field name is everything before the first colon and a single leading space is stripped from the value, so the space is optional framing rather than part of the delimiter. Parse the field name and value around the first colon instead, keeping any space beyond the first as payload, and treat a bare `data` line as an empty value. Matching on the field name also stops a line such as `database: {...}` from being read as a `data` field, which the old prefix match already handled correctly and is now covered by a test. Closes #399 Signed-off-by: pucedoteth <119044801+pucedoteth@users.noreply.github.com> --- crates/switchyard-translation/src/sse.rs | 42 +++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/switchyard-translation/src/sse.rs b/crates/switchyard-translation/src/sse.rs index dce5a3611..16a7fd7c8 100644 --- a/crates/switchyard-translation/src/sse.rs +++ b/crates/switchyard-translation/src/sse.rs @@ -36,7 +36,18 @@ pub(crate) fn parse_json_sse_frame( let data = frame .lines() .filter(|line| !line.is_empty() && !line.starts_with(':')) - .filter_map(|line| line.strip_prefix("data: ").map(|l| l.to_string())) + .filter_map(|line| { + // Per the SSE spec the space after the colon is optional framing: + // the field name is everything before the first colon, and a single + // leading space is stripped from the value. A field line with no + // colon at all carries an empty value. + let value = match line.split_once(':') { + Some(("data", value)) => value, + None if line == "data" => "", + _ => return None, + }; + Some(value.strip_prefix(' ').unwrap_or(value).to_string()) + }) .fold(String::new(), |mut a, b| { a.reserve(b.len() + 1); a.push_str(&b); @@ -83,6 +94,35 @@ mod tests { Ok(()) } + #[test] + fn parses_a_data_line_without_a_space_after_the_colon() -> Result<(), BoxError> { + // The space after `data:` is optional framing, not part of the value. + let SseFrame::Data(value) = parse_json_sse_frame("data:{\"text\":\"hi\"}\n", DONE)? else { + return Err("expected a payload".into()); + }; + assert_eq!(value, json!({"text": "hi"})); + Ok(()) + } + + #[test] + fn done_marker_is_recognised_without_a_space() -> Result<(), BoxError> { + assert!(matches!( + parse_json_sse_frame("data:[DONE]\n", DONE)?, + SseFrame::Done + )); + Ok(()) + } + + #[test] + fn field_names_are_matched_exactly() -> Result<(), BoxError> { + // `database:` must not be read as a `data` field. + assert!(matches!( + parse_json_sse_frame("database: {\"n\":1}\n", DONE)?, + SseFrame::Empty + )); + Ok(()) + } + #[test] fn done_marker_yields_no_payload() -> Result<(), BoxError> { assert!(matches!( From f12f632dc70518a046013c6f1f52cbb015f359f5 Mon Sep 17 00:00:00 2001 From: pucedoteth <119044801+pucedoteth@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:23:55 +0200 Subject: [PATCH 2/2] refactor(translation): extract the SSE data field parser into a helper Move the field-name/value parsing out of the `filter_map` closure into `data_field_value` and reduce the explanatory comment to one line, per review feedback. No behaviour change. Signed-off-by: pucedoteth <119044801+pucedoteth@users.noreply.github.com> --- crates/switchyard-translation/src/sse.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/switchyard-translation/src/sse.rs b/crates/switchyard-translation/src/sse.rs index 16a7fd7c8..439df5d45 100644 --- a/crates/switchyard-translation/src/sse.rs +++ b/crates/switchyard-translation/src/sse.rs @@ -29,6 +29,16 @@ pub(crate) fn done_marker(_format: WireFormat) -> Option<&'static str> { Some("[DONE]") } +/// Value of a `data` field line; the space after the colon is optional framing. +fn data_field_value(line: &str) -> Option { + let value = match line.split_once(':') { + Some(("data", value)) => value, + None if line == "data" => "", + _ => return None, + }; + Some(value.strip_prefix(' ').unwrap_or(value).to_string()) +} + pub(crate) fn parse_json_sse_frame( frame: &str, done_marker: Option<&str>, @@ -36,18 +46,7 @@ pub(crate) fn parse_json_sse_frame( let data = frame .lines() .filter(|line| !line.is_empty() && !line.starts_with(':')) - .filter_map(|line| { - // Per the SSE spec the space after the colon is optional framing: - // the field name is everything before the first colon, and a single - // leading space is stripped from the value. A field line with no - // colon at all carries an empty value. - let value = match line.split_once(':') { - Some(("data", value)) => value, - None if line == "data" => "", - _ => return None, - }; - Some(value.strip_prefix(' ').unwrap_or(value).to_string()) - }) + .filter_map(data_field_value) .fold(String::new(), |mut a, b| { a.reserve(b.len() + 1); a.push_str(&b);