Skip to content

Add support for SSE comments - #478

Open
magdzikk wants to merge 8 commits into
masterfrom
sse-comments
Open

Add support for SSE comments#478
magdzikk wants to merge 8 commits into
masterfrom
sse-comments

Conversation

@magdzikk

@magdzikk magdzikk commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #379.

ServerSentEvent gains a comments: List[String] field, so comment lines (those starting with :) round-trip through both parse and toString. ServerSentEvent.comment("ping") builds the keep-alive frame the WhatWG specification recommends sending every 15 seconds or so, to stop legacy proxies from dropping an idle connection.

Design notes

List[String] rather than Option[String], because a single event block may carry several comment lines.

There is deliberately no assertion that a comment and data aren't both set (as considered in the issue discussion). The specification ignores comments per line, not per event, so a block such as

: keep-alive
data: abc

is valid and still dispatches abc. A guard would reject valid streams.

Binary compatibility

Preserved with the same approach already used for ContentTypeRange: the old-arity constructor, copy and apply are kept alongside the new ones.

Note one source-level (not binary) change: downstream positional patterns like case ServerSentEvent(d, e, i, r) now need a fifth _. This is inherent to adding a field and is not visible to MiMa.

Migration

Comment-only blocks previously parsed to ServerSentEvent(). A keep-alive : ping now parses to ServerSentEvent(comments = List("ping")), so code that skipped such events by comparing against an empty event silently stops skipping them:

events.filter(_ != ServerSentEvent())  // no longer filters out keep-alives
events.filter(!_.isCommentOnly)        // use this instead

isCommentOnly is true when an event carries no data, event type, id or retry — only comments, if any.

Equality checks are the case to watch, because they keep compiling. The positional-pattern equivalent, case ServerSentEvent(None, None, None, None), cannot regress silently: it fails to compile until a fifth _ is added.

`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) <noreply@anthropic.com>
@magdzikk
magdzikk marked this pull request as ready for review August 28, 2026 14:44

@adamw adamw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by Claude (posted by @adamw). Main finding: comments are not split on newlines in toString. Details inline.

Comment thread core/src/main/scala/sttp/model/sse/ServerSentEvent.scala Outdated
Comment thread core/src/main/scala/sttp/model/sse/ServerSentEvent.scala
Comment thread core/src/main/scala/sttp/model/sse/ServerSentEvent.scala Outdated
Comment thread core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala
Comment thread core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala Outdated
Comment thread core/src/main/scala/sttp/model/sse/ServerSentEvent.scala Outdated
Comment thread core/src/main/scala/sttp/model/sse/ServerSentEvent.scala Outdated
id: Option[String] = None,
retry: Option[Int] = None,
comments: List[String] = Nil
): ServerSentEvent = new ServerSentEvent(data, eventType, id, retry, comments.flatMap(_.split(LineTerminators)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the invariant be that comments never contains line terminators? Right now only this apply enforces it: new ServerSentEvent(...) — and Scala 3 codecs derived via Mirror, which construct through fromProduct — can still create comments = List("a\nb"). toString output stays safe either way, but such an instance doesn't round-trip (parse gives the split form) and isn't equal to the same event built via apply.

So: do we enforce the invariant everywhere, or allow both forms?

Also, maybe we should have property-based tests for the parse/serialise round-trip — they'd catch cases like this.

@magdzikk magdzikk Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I missed fromProduct. In this case I think allowing both forms is better. comments would then be a plain record of what was passed, and toString would keep splitting when writing.

Enforcing it everywhere would make the model throw, which doesn't fit safeApply/unsafeApply here, and would turn a newline in a comment into an exception inside derived decoders.

So I'd remove the split from apply and keep it in comment(...)`, so the factory still hands back one entry per line.

That would also remove the re-splitting in parse.

On property tests: currently there's no scalacheck in the dependencies, so we can consider just adding a table-driven round-trip test over the special shapes ("", "\n", "\r", "\r\n", "a\nb", "ping\n\n", multi-item lists). But happy to add property tests if you don't mind adding that dependency :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If needed, no problem in adding a test dep

/** 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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment that is only line terminators disappears entirely: "\n".split("\r\n|\r|\n") is an empty array, so comment("\n") equals ServerSentEvent() and serializes to "" — a keep-alive built from such a string sends nothing. Meanwhile comment("") gives ": ". Worth a test pinning what should happen here.

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)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every branch here calls copy, which goes through the 5-arg apply — so the whole accumulated comments list is re-split on every parsed line. That's always a no-op (parse input is already split into lines), and with the :+ append it's quadratic in comment count per event. A copy that forwards to the constructor would avoid it.

}

object ServerSentEvent {
private val LineTerminators = "\r\n|\r|\n"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String.split with a multi-char pattern recompiles the regex on every call (master's split("\n") hit the single-char fast path). Since this runs per comment in apply and again in toString, consider precompiling: private val LineTerminators = java.util.regex.Pattern.compile("\r\n|\r|\n"), then LineTerminators.split(s).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could solve it the same way as in ab31bfe - if #481 gets merged first, we can rebase this PR onto it, and use it here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ServerSentEvent type does not match the WhatWG specification WRT comments

2 participants