From 337540ebf4ec39b2e2a519db0592bdab82dbdadb Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Fri, 28 Aug 2026 16:24:28 +0200 Subject: [PATCH 01/19] Add support for SSE comments `ServerSentEvent` now carries a `comments` field, so that comment lines (those starting with `:`) can be both parsed and serialised. Per the WhatWG specification such lines are ignored by clients, which makes them the idiomatic keep-alive: they stop proxies from dropping an idle connection without dispatching an event to the application. Binary compatibility with 1.7.18 is preserved in the same way as for `ContentTypeRange`: the old-arity constructor, `copy` and `apply` are kept alongside the new ones. Closes #379 Co-Authored-By: Claude Opus 5 (1M context) --- .../sttp/model/sse/ServerSentEvent.scala | 46 +++++++++++++++++-- .../sttp/model/sse/ServerSentEventTest.scala | 40 +++++++++++++++- 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 70326cd0..2cf25090 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -6,22 +6,58 @@ case class ServerSentEvent( data: Option[String] = None, eventType: Option[String] = None, id: Option[String] = None, - retry: Option[Int] = None + retry: Option[Int] = None, + comments: List[String] = Nil ) { + // required for binary compatibility + def this(data: Option[String], eventType: Option[String], id: Option[String], retry: Option[Int]) = + this(data, eventType, id, retry, Nil) + + def copy( + data: Option[String] = this.data, + eventType: Option[String] = this.eventType, + id: Option[String] = this.id, + retry: Option[Int] = this.retry, + comments: List[String] = this.comments + ): ServerSentEvent = ServerSentEvent(data, eventType, id, retry, comments) + + // required for binary compatibility + def copy( + data: Option[String], + eventType: Option[String], + id: Option[String], + retry: Option[Int] + ): ServerSentEvent = ServerSentEvent(data, eventType, id, retry, this.comments) + override def toString: String = { + val _comments: Array[Option[String]] = comments.map(comment => Some(s": $comment")).toArray val _data = data.map(_.split("\n")).map(_.map(line => Some(s"data: $line"))).getOrElse(Array.empty[Option[String]]) val _event = eventType.map(event => s"event: $event") val _id = id.map(id => s"id: $id") val _retry = retry.map(retryCount => s"retry: $retryCount") - (_data :+ _event :+ _id :+ _retry).flatten.mkString("\n") + ((_comments ++ _data) :+ _event :+ _id :+ _retry).flatten.mkString("\n") } } object ServerSentEvent { + // required for binary compatibility + def apply( + data: Option[String], + eventType: Option[String], + id: Option[String], + retry: Option[Int] + ): ServerSentEvent = new ServerSentEvent(data, eventType, id, retry, Nil) + + /** An event consisting of a single comment line. Such events are ignored by clients, and can be used to keep the + * connection alive, so that it isn't dropped by proxies. + */ + def comment(comment: String): ServerSentEvent = ServerSentEvent(comments = List(comment)) + // https://html.spec.whatwg.org/multipage/server-sent-events.html def parse(event: List[String]): ServerSentEvent = { event.foldLeft(ServerSentEvent()) { (event, line) => - if (line.startsWith("data:")) combineData(event, removeLeadingSpace(line.substring(5))) + if (line.startsWith(":")) event.copy(comments = event.comments :+ removeLeadingSpace(line.substring(1))) + else if (line.startsWith("data:")) combineData(event, removeLeadingSpace(line.substring(5))) else if (line.startsWith("id:")) event.copy(id = Some(removeLeadingSpace(line.substring(3)))) else if (line.startsWith("retry:")) event.copy(retry = ParseUtils.toIntOption(removeLeadingSpace(line.substring(6)))) @@ -35,8 +71,8 @@ object ServerSentEvent { private def combineData(event: ServerSentEvent, newData: String): ServerSentEvent = { event match { - case e @ ServerSentEvent(Some(oldData), _, _, _) => e.copy(data = Some(s"$oldData\n$newData")) - case e @ ServerSentEvent(None, _, _, _) => e.copy(data = Some(newData)) + case e @ ServerSentEvent(Some(oldData), _, _, _, _) => e.copy(data = Some(s"$oldData\n$newData")) + case e @ ServerSentEvent(None, _, _, _, _) => e.copy(data = Some(newData)) } } diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 5071c641..df97e248 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -5,7 +5,7 @@ import org.scalatest.matchers.should.Matchers class ServerSentEventTest extends AnyFlatSpec with Matchers { val data = List( - (List(": this is a test stream"), ServerSentEvent()), + (List(": this is a test stream"), ServerSentEvent(comments = List("this is a test stream"))), (List("data: some text"), ServerSentEvent(Some("some text"))), (List("data: some text"), ServerSentEvent(Some(" some text"))), (List("data: another message", "data: with two lines"), ServerSentEvent(Some("another message\nwith two lines"))), @@ -24,7 +24,17 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { ( List("data: event1 data", "event: event1", "id: id1", "retry: 5"), ServerSentEvent(Some("event1 data"), Some("event1"), Some("id1"), Some(5)) - ) + ), + ( + List(": keep-alive", "data: with a comment"), + ServerSentEvent(Some("with a comment"), comments = List("keep-alive")) + ), + ( + List(": first", "data: x", ": second"), + ServerSentEvent(Some("x"), comments = List("first", "second")) + ), + (List(":no leading space"), ServerSentEvent(comments = List("no leading space"))), + (List(":"), ServerSentEvent(comments = List(""))) ) for ((lines, expected) <- data) { @@ -64,4 +74,30 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { |data: some data info 2 |data: some data info 3""".stripMargin } + + "composeSSE" should "serialise a comment-only event" in { + ServerSentEvent(comments = List("ping")).toString shouldBe ": ping" + } + + "composeSSE" should "serialise comments before the other fields" in { + val sse = ServerSentEvent(Some("d"), comments = List("c1", "c2")) + sse.toString shouldBe + s""": c1 + |: c2 + |data: d""".stripMargin + } + + "parse" should "round-trip an event with comments and all other fields set" in { + val sse = ServerSentEvent(Some("line1\nline2"), Some("evt"), Some("id1"), Some(5), List("c1", "c2")) + ServerSentEvent.parse(sse.toString.split("\n").toList) shouldBe sse + } + + "comment" should "create an event carrying a single comment" in { + ServerSentEvent.comment("ping") shouldBe ServerSentEvent(comments = List("ping")) + } + + "copy" should "preserve comments when another field is changed" in { + ServerSentEvent(comments = List("ping")).copy(data = Some("d")) shouldBe + ServerSentEvent(Some("d"), comments = List("ping")) + } } From a0cd868e0e4ab7810af28d565e48947dc9656541 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 31 Aug 2026 15:20:52 +0200 Subject: [PATCH 02/19] Split multi-line comments --- .../scala/sttp/model/sse/ServerSentEvent.scala | 2 +- .../sttp/model/sse/ServerSentEventTest.scala | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 2cf25090..4ba5bdf3 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -30,7 +30,7 @@ case class ServerSentEvent( ): ServerSentEvent = ServerSentEvent(data, eventType, id, retry, this.comments) override def toString: String = { - val _comments: Array[Option[String]] = comments.map(comment => Some(s": $comment")).toArray + val _comments = comments.flatMap(_.split("\n")).map(comment => Some(s": $comment")).toArray val _data = data.map(_.split("\n")).map(_.map(line => Some(s"data: $line"))).getOrElse(Array.empty[Option[String]]) val _event = eventType.map(event => s"event: $event") val _id = id.map(id => s"id: $id") diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index df97e248..8d2bb482 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -100,4 +100,20 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { ServerSentEvent(comments = List("ping")).copy(data = Some("d")) shouldBe ServerSentEvent(Some("d"), comments = List("ping")) } + + "composeSSE" should "serialise a multi-line comment as multiple comment lines" in { + ServerSentEvent.comment("a\nb").toString shouldBe + s""": a + |: b""".stripMargin + } + + "composeSSE" should "not emit a blank line for a comment ending with newlines" in { + ServerSentEvent.comment("ping\n\n").toString shouldBe ": ping" + } + + "comment" should "not allow a newline to inject other fields" in { + val sse = ServerSentEvent.comment("x\ndata: y") + ServerSentEvent.parse(sse.toString.split("\n").toList) shouldBe + ServerSentEvent(comments = List("x", "data: y")) + } } From cefef985ea6320a8d7398ea1db6c30f710423a92 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 31 Aug 2026 15:30:25 +0200 Subject: [PATCH 03/19] Add isCommentOnly --- .../scala/sttp/model/sse/ServerSentEvent.scala | 5 +++++ .../sttp/model/sse/ServerSentEventTest.scala | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 4ba5bdf3..385fb287 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -29,6 +29,11 @@ case class ServerSentEvent( retry: Option[Int] ): ServerSentEvent = ServerSentEvent(data, eventType, id, retry, this.comments) + /** True if the event carries no data, event type, id or retry - only comments, if any. Clients ignore comments, so + * such events (e.g. keep-alive pings) can usually be skipped. + */ + def isCommentOnly: Boolean = data.isEmpty && eventType.isEmpty && id.isEmpty && retry.isEmpty + override def toString: String = { val _comments = comments.flatMap(_.split("\n")).map(comment => Some(s": $comment")).toArray val _data = data.map(_.split("\n")).map(_.map(line => Some(s"data: $line"))).getOrElse(Array.empty[Option[String]]) diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 8d2bb482..aecc9849 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -116,4 +116,20 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { ServerSentEvent.parse(sse.toString.split("\n").toList) shouldBe ServerSentEvent(comments = List("x", "data: y")) } + + "isCommentOnly" should "be true for a keep-alive event" in { + ServerSentEvent.comment("ping").isCommentOnly shouldBe true + } + + "isCommentOnly" should "be true for an event with no fields set at all" in { + ServerSentEvent().isCommentOnly shouldBe true + } + + "isCommentOnly" should "be false when data is set" in { + ServerSentEvent(Some("d"), comments = List("ping")).isCommentOnly shouldBe false + } + + "isCommentOnly" should "be false when only retry is set" in { + ServerSentEvent(retry = Some(5)).isCommentOnly shouldBe false + } } From 2e09659f753184fff60cdde1dba59523978183bd Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 31 Aug 2026 15:42:19 +0200 Subject: [PATCH 04/19] Remove pattern matching to simplify --- core/src/main/scala/sttp/model/sse/ServerSentEvent.scala | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 385fb287..199eaf3b 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -74,12 +74,8 @@ object ServerSentEvent { } } - private def combineData(event: ServerSentEvent, newData: String): ServerSentEvent = { - event match { - case e @ ServerSentEvent(Some(oldData), _, _, _, _) => e.copy(data = Some(s"$oldData\n$newData")) - case e @ ServerSentEvent(None, _, _, _, _) => e.copy(data = Some(newData)) - } - } + private def combineData(event: ServerSentEvent, newData: String): ServerSentEvent = + event.copy(data = Some(event.data.fold(newData)(oldData => s"$oldData\n$newData"))) private def removeLeadingSpace(s: String): String = if (s.startsWith(" ")) s.substring(1) else s } From dcb34f82a901112171d837e8b5ebf32680b7c6c7 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 31 Aug 2026 15:54:20 +0200 Subject: [PATCH 05/19] Restore test coverage of unknown and bare SSE fields --- core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index aecc9849..0169a046 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -34,7 +34,10 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { ServerSentEvent(Some("x"), comments = List("first", "second")) ), (List(":no leading space"), ServerSentEvent(comments = List("no leading space"))), - (List(":"), ServerSentEvent(comments = List(""))) + (List(":"), ServerSentEvent(comments = List(""))), + (List("foo: bar", "data: x"), ServerSentEvent(Some("x"))), + (List("data"), ServerSentEvent(Some(""))), + (List("event"), ServerSentEvent(eventType = Some(""))) ) for ((lines, expected) <- data) { From bc2ecd1ddbcefaaedcadad9ebfaef03e4e9e11e7 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Mon, 31 Aug 2026 16:03:33 +0200 Subject: [PATCH 06/19] Remove redundant test --- core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 0169a046..2c1ee82b 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -25,10 +25,6 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { List("data: event1 data", "event: event1", "id: id1", "retry: 5"), ServerSentEvent(Some("event1 data"), Some("event1"), Some("id1"), Some(5)) ), - ( - List(": keep-alive", "data: with a comment"), - ServerSentEvent(Some("with a comment"), comments = List("keep-alive")) - ), ( List(": first", "data: x", ": second"), ServerSentEvent(Some("x"), comments = List("first", "second")) From 2dee65ab080a3ac79df18434fe8370cbb3abb7df Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Tue, 1 Sep 2026 10:00:00 +0200 Subject: [PATCH 07/19] Split comments on CR and CRLF too --- .../scala/sttp/model/sse/ServerSentEvent.scala | 2 +- .../sttp/model/sse/ServerSentEventTest.scala | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 199eaf3b..8d645b2f 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -35,7 +35,7 @@ case class ServerSentEvent( def isCommentOnly: Boolean = data.isEmpty && eventType.isEmpty && id.isEmpty && retry.isEmpty override def toString: String = { - val _comments = comments.flatMap(_.split("\n")).map(comment => Some(s": $comment")).toArray + val _comments = comments.flatMap(_.split("\r\n|\r|\n")).map(comment => Some(s": $comment")).toArray val _data = data.map(_.split("\n")).map(_.map(line => Some(s"data: $line"))).getOrElse(Array.empty[Option[String]]) val _event = eventType.map(event => s"event: $event") val _id = id.map(id => s"id: $id") diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 2c1ee82b..f3f1dc10 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -110,6 +110,24 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { ServerSentEvent.comment("ping\n\n").toString shouldBe ": ping" } + "composeSSE" should "serialise a comment containing a carriage return as multiple comment lines" in { + ServerSentEvent.comment("x\rdata: y").toString shouldBe + s""": x + |: data: y""".stripMargin + } + + "composeSSE" should "serialise a comment containing CRLF as multiple comment lines" in { + ServerSentEvent.comment("a\r\nb").toString shouldBe + s""": a + |: b""".stripMargin + } + + "comment" should "not allow a carriage return to inject other fields" in { + val sse = ServerSentEvent.comment("x\rdata: y") + ServerSentEvent.parse(sse.toString.split("\r\n|\r|\n").toList) shouldBe + ServerSentEvent(comments = List("x", "data: y")) + } + "comment" should "not allow a newline to inject other fields" in { val sse = ServerSentEvent.comment("x\ndata: y") ServerSentEvent.parse(sse.toString.split("\n").toList) shouldBe From 86253cccb05e2580382e5ca949869db7c4728e2b Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Tue, 1 Sep 2026 11:33:59 +0200 Subject: [PATCH 08/19] Split comments when the event is created --- .../scala/sttp/model/sse/ServerSentEvent.scala | 13 ++++++++++++- .../sttp/model/sse/ServerSentEventTest.scala | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 8d645b2f..0c7dc990 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -35,7 +35,8 @@ case class ServerSentEvent( def isCommentOnly: Boolean = data.isEmpty && eventType.isEmpty && id.isEmpty && retry.isEmpty override def toString: String = { - val _comments = comments.flatMap(_.split("\r\n|\r|\n")).map(comment => Some(s": $comment")).toArray + val _comments = + comments.flatMap(_.split(ServerSentEvent.LineTerminators)).map(comment => Some(s": $comment")).toArray val _data = data.map(_.split("\n")).map(_.map(line => Some(s"data: $line"))).getOrElse(Array.empty[Option[String]]) val _event = eventType.map(event => s"event: $event") val _id = id.map(id => s"id: $id") @@ -45,6 +46,16 @@ case class ServerSentEvent( } object ServerSentEvent { + private val LineTerminators = "\r\n|\r|\n" + + def apply( + data: Option[String] = None, + eventType: Option[String] = None, + id: Option[String] = None, + retry: Option[Int] = None, + comments: List[String] = Nil + ): ServerSentEvent = new ServerSentEvent(data, eventType, id, retry, comments.flatMap(_.split(LineTerminators))) + // required for binary compatibility def apply( data: Option[String], diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index f3f1dc10..92f49f93 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -134,6 +134,24 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { ServerSentEvent(comments = List("x", "data: y")) } + "apply" should "split comments containing line terminators into separate comments" in { + ServerSentEvent(comments = List("a\nb", "c\r\nd", "e\rf")).comments shouldBe + List("a", "b", "c", "d", "e", "f") + } + + "comment" should "split a multi-line comment into separate comments" in { + ServerSentEvent.comment("a\nb").comments shouldBe List("a", "b") + } + + "copy" should "split comments containing line terminators" in { + ServerSentEvent().copy(comments = List("a\nb")).comments shouldBe List("a", "b") + } + + "parse" should "round-trip an event built from a multi-line comment" in { + val sse = ServerSentEvent.comment("a\nb") + ServerSentEvent.parse(sse.toString.split("\r\n|\r|\n").toList) shouldBe sse + } + "isCommentOnly" should "be true for a keep-alive event" in { ServerSentEvent.comment("ping").isCommentOnly shouldBe true } From 1d5b8358da53e053f9d2a50ba47f489d05b44aed Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 3 Sep 2026 10:54:02 +0200 Subject: [PATCH 09/19] Keep comments as given, split only in comment() --- .../scala/sttp/model/sse/ServerSentEvent.scala | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 0c7dc990..22c38003 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -48,14 +48,6 @@ case class ServerSentEvent( object ServerSentEvent { private val LineTerminators = "\r\n|\r|\n" - def apply( - data: Option[String] = None, - eventType: Option[String] = None, - id: Option[String] = None, - retry: Option[Int] = None, - comments: List[String] = Nil - ): ServerSentEvent = new ServerSentEvent(data, eventType, id, retry, comments.flatMap(_.split(LineTerminators))) - // required for binary compatibility def apply( data: Option[String], @@ -64,10 +56,11 @@ object ServerSentEvent { retry: Option[Int] ): ServerSentEvent = new ServerSentEvent(data, eventType, id, retry, Nil) - /** An event consisting of a single comment line. Such events are ignored by clients, and can be used to keep the - * connection alive, so that it isn't dropped by proxies. + /** An event consisting of comment lines only, one per line of the given text. Such events are ignored by clients, and + * can be used to keep the connection alive, so that it isn't dropped by proxies. */ - def comment(comment: String): ServerSentEvent = ServerSentEvent(comments = List(comment)) + def comment(comment: String): ServerSentEvent = + ServerSentEvent(comments = comment.split(LineTerminators).toList) // https://html.spec.whatwg.org/multipage/server-sent-events.html def parse(event: List[String]): ServerSentEvent = { From 12d66cca3d2838d58f3ec6be522eababcf5b7f29 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 3 Sep 2026 10:54:41 +0200 Subject: [PATCH 10/19] Add round-trip tests for comment shapes --- .../sttp/model/sse/ServerSentEventTest.scala | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 92f49f93..10c47301 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -134,17 +134,16 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { ServerSentEvent(comments = List("x", "data: y")) } - "apply" should "split comments containing line terminators into separate comments" in { - ServerSentEvent(comments = List("a\nb", "c\r\nd", "e\rf")).comments shouldBe - List("a", "b", "c", "d", "e", "f") + "apply" should "keep comments as they were given" in { + ServerSentEvent(comments = List("a\nb")).comments shouldBe List("a\nb") } "comment" should "split a multi-line comment into separate comments" in { - ServerSentEvent.comment("a\nb").comments shouldBe List("a", "b") + ServerSentEvent.comment("a\nb\rc\r\nd").comments shouldBe List("a", "b", "c", "d") } - "copy" should "split comments containing line terminators" in { - ServerSentEvent().copy(comments = List("a\nb")).comments shouldBe List("a", "b") + "copy" should "keep comments as they were given" in { + ServerSentEvent().copy(comments = List("a\nb")).comments shouldBe List("a\nb") } "parse" should "round-trip an event built from a multi-line comment" in { @@ -152,6 +151,31 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { ServerSentEvent.parse(sse.toString.split("\r\n|\r|\n").toList) shouldBe sse } + val roundTripComments = List( + List("ping"), + List(""), + List("\n"), + List("\r"), + List("\r\n"), + List("ping\n\n"), + List("a\nb"), + List("a\r\nb"), + List("x\rdata: y"), + List("a\n\nb"), + List(" spaced"), + List("a", "b"), + List("", "b"), + List("", "") + ) + + for (comments <- roundTripComments) { + it should s"round-trip comments ${comments.map(_.replace("\r", "\\r").replace("\n", "\\n"))}" in { + val sse = ServerSentEvent(Some("d1\nd2"), Some("evt"), Some("id1"), Some(7), comments) + val serialised = sse.toString + ServerSentEvent.parse(serialised.split("\r\n|\r|\n").toList).toString shouldBe serialised + } + } + "isCommentOnly" should "be true for a keep-alive event" in { ServerSentEvent.comment("ping").isCommentOnly shouldBe true } From 66b681a13d4b48c7683830df0888730e506e6d84 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 3 Sep 2026 11:13:22 +0200 Subject: [PATCH 11/19] Split comments with the shared line terminator helper --- .../main/scala/sttp/model/sse/ServerSentEvent.scala | 4 ++-- .../scala/sttp/model/sse/ServerSentEventTest.scala | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 74941fdd..f1e99466 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -36,7 +36,7 @@ case class ServerSentEvent( override def toString: String = { val _comments = - comments.flatMap(_.split(ServerSentEvent.LineTerminators)).map(comment => Some(s": $comment")).toArray + comments.flatMap(ServerSentEvent.splitOnLineTerminators).map(comment => Some(s": $comment")).toArray val _data = data .map(ServerSentEvent.splitOnLineTerminators) .map(_.map(line => Some(s"data: $line"))) @@ -70,7 +70,7 @@ object ServerSentEvent { * can be used to keep the connection alive, so that it isn't dropped by proxies. */ def comment(comment: String): ServerSentEvent = - ServerSentEvent(comments = comment.split(LineTerminators).toList) + ServerSentEvent(comments = splitOnLineTerminators(comment).toList) // https://html.spec.whatwg.org/multipage/server-sent-events.html def parse(event: List[String]): ServerSentEvent = { diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 7157b362..0824cce1 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -106,8 +106,16 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { |: b""".stripMargin } - "composeSSE" should "not emit a blank line for a comment ending with newlines" in { - ServerSentEvent.comment("ping\n\n").toString shouldBe ": ping" + "composeSSE" should "emit empty comment lines, not blank lines, for a comment ending with newlines" in { + ServerSentEvent.comment("ping\n\n").toString shouldBe ": ping\n: \n: " + } + + "composeSSE" should "emit empty comment lines for a comment of line terminators only" in { + ServerSentEvent.comment("\n").toString shouldBe ": \n: " + } + + "composeSSE" should "emit a single empty comment line for an empty comment" in { + ServerSentEvent.comment("").toString shouldBe ": " } "composeSSE" should "serialise a comment containing a carriage return as multiple comment lines" in { From 385bd0181586f6a119d5ddbbfe4a211e04404b90 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 3 Sep 2026 11:49:46 +0200 Subject: [PATCH 12/19] Build the comments list in linear time --- core/src/main/scala/sttp/model/sse/ServerSentEvent.scala | 6 ++++-- .../src/test/scala/sttp/model/sse/ServerSentEventTest.scala | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index f1e99466..881aca56 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -74,8 +74,9 @@ object ServerSentEvent { // https://html.spec.whatwg.org/multipage/server-sent-events.html def parse(event: List[String]): ServerSentEvent = { - event.foldLeft(ServerSentEvent()) { (event, line) => - if (line.startsWith(":")) event.copy(comments = event.comments :+ removeLeadingSpace(line.substring(1))) + // comments are prepended and reversed once at the end for permormance + val parsed = event.foldLeft(ServerSentEvent()) { (event, line) => + if (line.startsWith(":")) event.copy(comments = removeLeadingSpace(line.substring(1)) :: event.comments) else if (line.startsWith("data:")) combineData(event, removeLeadingSpace(line.substring(5))) else if (line.startsWith("id:")) event.copy(id = Some(removeLeadingSpace(line.substring(3)))) else if (line.startsWith("retry:")) @@ -86,6 +87,7 @@ object ServerSentEvent { else if (line == "event") event.copy(eventType = Some("")) else event } + if (parsed.comments.isEmpty) parsed else parsed.copy(comments = parsed.comments.reverse) } private def combineData(event: ServerSentEvent, newData: String): ServerSentEvent = diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 0824cce1..4d0518cf 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -29,6 +29,10 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { List(": first", "data: x", ": second"), ServerSentEvent(Some("x"), comments = List("first", "second")) ), + ( + List(": one", "data: x", ": two", ": three"), + ServerSentEvent(Some("x"), comments = List("one", "two", "three")) + ), (List(":no leading space"), ServerSentEvent(comments = List("no leading space"))), (List(":"), ServerSentEvent(comments = List(""))), (List("foo: bar", "data: x"), ServerSentEvent(Some("x"))), From 91b18db8dc85cd2f27247b54299f1b03b7fd2a4e Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 3 Sep 2026 12:11:19 +0200 Subject: [PATCH 13/19] Add property tests for serialisation --- build.sbt | 10 ++-- .../sttp/model/sse/ServerSentEvent.scala | 2 +- .../sse/ServerSentEventPropertyTest.scala | 52 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 core/src/test/scala/sttp/model/sse/ServerSentEventPropertyTest.scala diff --git a/build.sbt b/build.sbt index 5ff81ed4..8b90f13a 100644 --- a/build.sbt +++ b/build.sbt @@ -8,6 +8,7 @@ val scala2 = List(scala2_12, scala2_13) val scala3 = List("3.3.8") val scalaTestVersion = "3.2.19" +val scalaTestPlusScalaCheckVersion = "3.2.19.0" excludeLintKeys in Global ++= Set(ideSkipProject) @@ -23,7 +24,8 @@ val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq( val commonJvmSettings = commonSettings ++ Seq( ideSkipProject := (scalaVersion.value != scala2_13), libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % scalaTestVersion % Test + "org.scalatest" %% "scalatest" % scalaTestVersion % Test, + "org.scalatestplus" %% "scalacheck-1-18" % scalaTestPlusScalaCheckVersion % Test ), mimaPreviousArtifacts := previousStableVersion.value.map(organization.value %% moduleName.value % _).toSet, mimaReportBinaryIssues := { if ((publish / skip).value) {} else mimaReportBinaryIssues.value } @@ -47,14 +49,16 @@ val commonJsSettings = commonSettings ++ Seq( }, libraryDependencies ++= Seq( "org.scala-js" %%% "scalajs-dom" % "2.8.1", - "org.scalatest" %%% "scalatest" % scalaTestVersion % Test + "org.scalatest" %%% "scalatest" % scalaTestVersion % Test, + "org.scalatestplus" %%% "scalacheck-1-18" % scalaTestPlusScalaCheckVersion % Test ) ) val commonNativeSettings = commonSettings ++ Seq( ideSkipProject := true, libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % scalaTestVersion % Test + "org.scalatest" %%% "scalatest" % scalaTestVersion % Test, + "org.scalatestplus" %%% "scalacheck-1-18" % scalaTestPlusScalaCheckVersion % Test ) ) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 881aca56..c5db476f 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -74,7 +74,7 @@ object ServerSentEvent { // https://html.spec.whatwg.org/multipage/server-sent-events.html def parse(event: List[String]): ServerSentEvent = { - // comments are prepended and reversed once at the end for permormance + // comments are prepended and reversed once at the end for performance val parsed = event.foldLeft(ServerSentEvent()) { (event, line) => if (line.startsWith(":")) event.copy(comments = removeLeadingSpace(line.substring(1)) :: event.comments) else if (line.startsWith("data:")) combineData(event, removeLeadingSpace(line.substring(5))) diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventPropertyTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventPropertyTest.scala new file mode 100644 index 00000000..e174ac93 --- /dev/null +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventPropertyTest.scala @@ -0,0 +1,52 @@ +package sttp.model.sse + +import org.scalacheck.Gen +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +class ServerSentEventPropertyTest extends AnyFlatSpec with ScalaCheckDrivenPropertyChecks with Matchers { + implicit override val generatorDrivenConfig: PropertyCheckConfiguration = + PropertyCheckConfiguration(minSuccessful = 500) + + private val fieldValue: Gen[String] = Gen.listOf(Gen.oneOf('a', 'b', ':', ' ', '\r', '\n')).map(_.mkString) + + private val events: Gen[ServerSentEvent] = for { + data <- Gen.option(fieldValue) + eventType <- Gen.option(fieldValue) + id <- Gen.option(fieldValue) + retry <- Gen.option(Gen.chooseNum(0, 100000)) + comments <- Gen.listOf(fieldValue) + } yield ServerSentEvent(data, eventType, id, retry, comments) + + private val allowedPrefixes = List("data:", "event:", "id:", "retry:", ":") + + private def lines(serialised: String): List[String] = serialised.split("\r\n|\r|\n", -1).toList + + it should "serialise every line as a comment or a known field" in { + forAll(events) { sse => + val serialised = sse.toString + if (serialised.nonEmpty) { + lines(serialised).foreach { line => + withClue(s"line [$line] of [$serialised]: ") { + allowedPrefixes.exists(line.startsWith) shouldBe true + } + } + } + } + } + + it should "never serialise a blank line, which would end the event" in { + forAll(events) { sse => + val serialised = sse.toString + if (serialised.nonEmpty) lines(serialised) should not contain "" + } + } + + it should "serialise, parse and serialise again to the same result" in { + forAll(events) { sse => + val serialised = sse.toString + ServerSentEvent.parse(lines(serialised)).toString shouldBe serialised + } + } +} From a36d6be6a202eb9f27763dceec9c9efacbc47dd2 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 3 Sep 2026 14:48:57 +0200 Subject: [PATCH 14/19] Cover all isCommentOnly fields --- .../test/scala/sttp/model/sse/ServerSentEventTest.scala | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 4d0518cf..ef6531b0 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -200,9 +200,18 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { ServerSentEvent(Some("d"), comments = List("ping")).isCommentOnly shouldBe false } + "isCommentOnly" should "be false when only the event type is set" in { + ServerSentEvent(eventType = Some("e")).isCommentOnly shouldBe false + } + + "isCommentOnly" should "be false when only the id is set" in { + ServerSentEvent(id = Some("i")).isCommentOnly shouldBe false + } + "isCommentOnly" should "be false when only retry is set" in { ServerSentEvent(retry = Some(5)).isCommentOnly shouldBe false } + "composeSSE" should "split data on all line terminators" in { val sse = ServerSentEvent(Some("line 1\r\nline 2\rline 3\nline 4")) From b178031d2c04091235d97dd005e3d84c3503055b Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 3 Sep 2026 14:57:50 +0200 Subject: [PATCH 15/19] Test that the compatibility shims keep comments --- .../test/scala/sttp/model/sse/ServerSentEventTest.scala | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index ef6531b0..7bd914ad 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -104,6 +104,15 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { ServerSentEvent(Some("d"), comments = List("ping")) } + "copy" should "preserve comments when given only the four original fields" in { + ServerSentEvent(comments = List("ping")).copy(Some("d"), None, None, None) shouldBe + ServerSentEvent(Some("d"), comments = List("ping")) + } + + "the constructor taking the four original fields" should "create an event without comments" in { + new ServerSentEvent(Some("d"), None, None, None) shouldBe ServerSentEvent(Some("d")) + } + "composeSSE" should "serialise a multi-line comment as multiple comment lines" in { ServerSentEvent.comment("a\nb").toString shouldBe s""": a From fcff4021d661e2d86be58e59923c1b1d1020c0e6 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 3 Sep 2026 15:04:32 +0200 Subject: [PATCH 16/19] Strengthen the serialisation tests --- .../sse/ServerSentEventPropertyTest.scala | 71 +++++++++++++++---- .../sttp/model/sse/ServerSentEventTest.scala | 9 +-- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventPropertyTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventPropertyTest.scala index e174ac93..b0d72399 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventPropertyTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventPropertyTest.scala @@ -7,21 +7,39 @@ import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks class ServerSentEventPropertyTest extends AnyFlatSpec with ScalaCheckDrivenPropertyChecks with Matchers { implicit override val generatorDrivenConfig: PropertyCheckConfiguration = - PropertyCheckConfiguration(minSuccessful = 500) + PropertyCheckConfiguration(minSuccessful = 100) - private val fieldValue: Gen[String] = Gen.listOf(Gen.oneOf('a', 'b', ':', ' ', '\r', '\n')).map(_.mkString) + private val LineTerminators = "\r\n|\r|\n" - private val events: Gen[ServerSentEvent] = for { - data <- Gen.option(fieldValue) - eventType <- Gen.option(fieldValue) - id <- Gen.option(fieldValue) - retry <- Gen.option(Gen.chooseNum(0, 100000)) - comments <- Gen.listOf(fieldValue) + private val fieldValue: Gen[String] = Gen + .listOf( + Gen.frequency( + 6 -> Gen.oneOf("a", "b", ":", " "), + 2 -> Gen.oneOf("data: ", "event: ", "id: ", "retry: 9", "data", "id", "event", ": "), + 1 -> Gen.oneOf("\n", "\r", "\r\n") + ) + ) + .map(_.mkString) + + private val terminatorFreeValue: Gen[String] = + Gen.listOf(Gen.oneOf("a", "b", ":", " ", "data: ", "event: ", "id: ", "data")).map(_.mkString) + + private def eventsOf(value: Gen[String]): Gen[ServerSentEvent] = for { + data <- Gen.option(value) + eventType <- Gen.option(value) + id <- Gen.option(value) + retry <- Gen.option(Gen.chooseNum(Int.MinValue, Int.MaxValue)) + comments <- Gen.listOf(value) } yield ServerSentEvent(data, eventType, id, retry, comments) + private val events = eventsOf(fieldValue) + private val terminatorFreeEvents = eventsOf(terminatorFreeValue) + private val allowedPrefixes = List("data:", "event:", "id:", "retry:", ":") - private def lines(serialised: String): List[String] = serialised.split("\r\n|\r|\n", -1).toList + private def lines(serialised: String): List[String] = serialised.split(LineTerminators, -1).toList + + private def lineCount(s: String): Int = s.split(LineTerminators, -1).length it should "serialise every line as a comment or a known field" in { forAll(events) { sse => @@ -36,10 +54,11 @@ class ServerSentEventPropertyTest extends AnyFlatSpec with ScalaCheckDrivenPrope } } - it should "never serialise a blank line, which would end the event" in { + it should "serialise one line per comment line, per data line and per other field that is set" in { forAll(events) { sse => - val serialised = sse.toString - if (serialised.nonEmpty) lines(serialised) should not contain "" + val expected = sse.comments.map(lineCount).sum + sse.data.fold(0)(lineCount) + + List(sse.eventType, sse.id, sse.retry).count(_.isDefined) + lines(sse.toString).size shouldBe math.max(expected, 1) } } @@ -49,4 +68,32 @@ class ServerSentEventPropertyTest extends AnyFlatSpec with ScalaCheckDrivenPrope ServerSentEvent.parse(lines(serialised)).toString shouldBe serialised } } + + it should "parse back exactly what was serialised, when no value contains a line terminator" in { + forAll(terminatorFreeEvents) { sse => + ServerSentEvent.parse(lines(sse.toString)) shouldBe sse + } + } + + it should "parse any lines without throwing" in { + val anyLine = Gen.oneOf( + fieldValue, + Gen.oneOf( + "", + ":", + "data:", + "data", + "id:", + "id", + "event:", + "event", + "retry:", + "retry", + "retry: x", + "retry: 99999999999999999999", + "foo: bar" + ) + ) + forAll(Gen.listOf(anyLine)) { ls => noException should be thrownBy ServerSentEvent.parse(ls) } + } } diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 7bd914ad..98c4659c 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -176,21 +176,16 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { List("ping"), List(""), List("\n"), - List("\r"), - List("\r\n"), List("ping\n\n"), List("a\nb"), - List("a\r\nb"), List("x\rdata: y"), List("a\n\nb"), List(" spaced"), - List("a", "b"), - List("", "b"), - List("", "") + List("", "b") ) for (comments <- roundTripComments) { - it should s"round-trip comments ${comments.map(_.replace("\r", "\\r").replace("\n", "\\n"))}" in { + "parse" should s"round-trip comments ${comments.map(_.replace("\r", "\\r").replace("\n", "\\n"))}" in { val sse = ServerSentEvent(Some("d1\nd2"), Some("evt"), Some("id1"), Some(7), comments) val serialised = sse.toString ServerSentEvent.parse(serialised.split("\r\n|\r|\n").toList).toString shouldBe serialised From dd091f595f266ae54c55b2490ef467a49575795d Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 3 Sep 2026 15:10:20 +0200 Subject: [PATCH 17/19] Don't copy the field array when there are no comments --- core/src/main/scala/sttp/model/sse/ServerSentEvent.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index c5db476f..ae37750c 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -44,7 +44,9 @@ case class ServerSentEvent( val _event = eventType.map(event => s"event: ${ServerSentEvent.removeLineTerminators(event)}") val _id = id.map(id => s"id: ${ServerSentEvent.removeLineTerminators(id)}") val _retry = retry.map(retryCount => s"retry: $retryCount") - ((_comments ++ _data) :+ _event :+ _id :+ _retry).flatten.mkString("\n") + val _fields = _data :+ _event :+ _id :+ _retry + val _all = if (_comments.isEmpty) _fields else _comments ++ _fields + _all.flatten.mkString("\n") } } From 31b74cf0011b83f1f3f4b95e609abae4cbbdc9c5 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 3 Sep 2026 15:13:09 +0200 Subject: [PATCH 18/19] Rename the comment parameter for clarity --- core/src/main/scala/sttp/model/sse/ServerSentEvent.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index ae37750c..faf34c7b 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -71,8 +71,8 @@ object ServerSentEvent { /** An event consisting of comment lines only, one per line of the given text. Such events are ignored by clients, and * can be used to keep the connection alive, so that it isn't dropped by proxies. */ - def comment(comment: String): ServerSentEvent = - ServerSentEvent(comments = splitOnLineTerminators(comment).toList) + def comment(text: String): ServerSentEvent = + ServerSentEvent(comments = splitOnLineTerminators(text).toList) // https://html.spec.whatwg.org/multipage/server-sent-events.html def parse(event: List[String]): ServerSentEvent = { From 68d9f0e55dbfbae2ef8d737b2523e8226beff64d Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Thu, 3 Sep 2026 16:53:30 +0200 Subject: [PATCH 19/19] Rename the comment parameter for clarity --- .../sttp/model/sse/ServerSentEvent.scala | 6 ++-- .../sttp/model/sse/ServerSentEventTest.scala | 28 +++++++++++-------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index faf34c7b..b49671c9 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -29,10 +29,10 @@ case class ServerSentEvent( retry: Option[Int] ): ServerSentEvent = ServerSentEvent(data, eventType, id, retry, this.comments) - /** True if the event carries no data, event type, id or retry - only comments, if any. Clients ignore comments, so - * such events (e.g. keep-alive pings) can usually be skipped. + /** True if the event carries no data, event type, id or retry. Clients ignore comments, so such events - keep-alive + * pings, but also blocks made up of unknown fields - carry nothing for the application and can usually be skipped. */ - def isCommentOnly: Boolean = data.isEmpty && eventType.isEmpty && id.isEmpty && retry.isEmpty + def hasNoFields: Boolean = data.isEmpty && eventType.isEmpty && id.isEmpty && retry.isEmpty override def toString: String = { val _comments = diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 98c4659c..bc8cb1a7 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -192,28 +192,32 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { } } - "isCommentOnly" should "be true for a keep-alive event" in { - ServerSentEvent.comment("ping").isCommentOnly shouldBe true + "hasNoFields" should "be true for a keep-alive event" in { + ServerSentEvent.comment("ping").hasNoFields shouldBe true } - "isCommentOnly" should "be true for an event with no fields set at all" in { - ServerSentEvent().isCommentOnly shouldBe true + "hasNoFields" should "be true for an empty event" in { + ServerSentEvent().hasNoFields shouldBe true } - "isCommentOnly" should "be false when data is set" in { - ServerSentEvent(Some("d"), comments = List("ping")).isCommentOnly shouldBe false + "hasNoFields" should "be true for an event of unknown fields only" in { + ServerSentEvent.parse(List("foo: bar")).hasNoFields shouldBe true } - "isCommentOnly" should "be false when only the event type is set" in { - ServerSentEvent(eventType = Some("e")).isCommentOnly shouldBe false + "hasNoFields" should "be false when data is set" in { + ServerSentEvent(Some("d"), comments = List("ping")).hasNoFields shouldBe false } - "isCommentOnly" should "be false when only the id is set" in { - ServerSentEvent(id = Some("i")).isCommentOnly shouldBe false + "hasNoFields" should "be false when only the event type is set" in { + ServerSentEvent(eventType = Some("e")).hasNoFields shouldBe false } - "isCommentOnly" should "be false when only retry is set" in { - ServerSentEvent(retry = Some(5)).isCommentOnly shouldBe false + "hasNoFields" should "be false when only the id is set" in { + ServerSentEvent(id = Some("i")).hasNoFields shouldBe false + } + + "hasNoFields" should "be false when only retry is set" in { + ServerSentEvent(retry = Some(5)).hasNoFields shouldBe false } "composeSSE" should "split data on all line terminators" in {