Skip to content

Add support for SSE comments - #478

Open
magdzikk wants to merge 1 commit into
masterfrom
sse-comments
Open

Add support for SSE comments#478
magdzikk wants to merge 1 commit into
masterfrom
sse-comments

Conversation

@magdzikk

@magdzikk magdzikk commented Aug 28, 2026

Copy link
Copy Markdown

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.

`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.

): ServerSentEvent = ServerSentEvent(data, eventType, id, retry, this.comments)

override def toString: String = {
val _comments: Array[Option[String]] = comments.map(comment => Some(s": $comment")).toArray

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.

Comments are not split on \n, unlike data one line below. comment("a\nb").toString emits a bare b line, which is dropped on re-parse. comment("ping\n\n") emits a blank line, which ends the event. Since comment(...) accepts arbitrary text, a \n can also inject other fields (comment("x\ndata: y")).

Suggestion (also drops the Some/Array wrapping):

val _comments = comments.flatMap(_.split("\n")).map(c => Some(s": $c")).toArray

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.

Behavior change worth a release note: comment-only blocks used to parse to ServerSentEvent(). Code that skips keep-alives via _ != ServerSentEvent() or case ServerSentEvent(None, None, None, None) will now pass them through.

It would help to add a migration target, e.g.:

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

Follow-up: audit tapir & sttp client for ServerSentEvent() comparisons/patterns and update them once this is released.

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

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.

These patterns only needed changing because they depend on the field count. An accessor form avoids the same edit next time a field is added:

event.copy(data = Some(event.data.fold(newData)(old => s"$old\n$newData")))

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

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.

This was the only row reaching the final else in parse (ignore unknown lines). To keep that covered, add e.g. (List("foo: bar", "data: x"), ServerSentEvent(Some("x"))).

)
),
(
List(": keep-alive", "data: with 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.

This row is covered by the next one, which also tests a comment after data. Could be dropped.

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