diff --git a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java index c913a16ce82..f2af79ab2ca 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java @@ -22,7 +22,6 @@ import org.jspecify.annotations.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; -import org.springframework.util.StringUtils; /** * Representation for a Server-Sent Event for use with Spring's reactive Web support. @@ -112,7 +111,9 @@ public final class ServerSentEvent { appendAttribute("retry", this.retry.toMillis(), sb); } if (this.comment != null) { - sb.append(':').append(StringUtils.replace(this.comment, "\n", "\n:")).append('\n'); + sb.append(':'); + appendEscaped(this.comment, "\n:", sb); + sb.append('\n'); } if (this.data != null) { sb.append("data:"); @@ -124,6 +125,30 @@ public final class ServerSentEvent { sb.append(fieldName).append(':').append(fieldValue).append('\n'); } + private void appendEscaped(String input, String replacement, StringBuilder sb) { + if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) { + sb.append(input); + } + else { + int length = input.length(); + for (int i = 0; i < length; i++) { + char c = input.charAt(i); + if (c == '\r') { + if (i + 1 < length && input.charAt(i + 1) == '\n') { + i++; + } + sb.append(replacement); + } + else if (c == '\n') { + sb.append(replacement); + } + else { + sb.append(c); + } + } + } + } + @Override public boolean equals(@Nullable Object other) { return (this == other || (other instanceof ServerSentEvent that && diff --git a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventTests.java b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventTests.java index a106ea07337..9e1a1d7c76f 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventTests.java @@ -22,6 +22,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; /** @@ -44,6 +45,15 @@ class ServerSentEventTests { ServerSentEvent.builder().event("first" + newLine + "second").build()); } + + @ParameterizedTest(name = "{1}") + @MethodSource("newLineCharacters") + void supportMultiLineComments(String newLine, String description) { + ServerSentEvent event = ServerSentEvent.builder() + .comment("foo" + newLine + "bar" + newLine + "baz").data("payload").build(); + assertThat(event.format()).isEqualTo(":foo\n:bar\n:baz\ndata:"); + } + private static Stream newLineCharacters() { return Stream.of( Arguments.of("\n", "LF"), diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java index c302db9f830..f0bbe9cd9b4 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java @@ -224,7 +224,9 @@ public class SseEmitter extends ResponseBodyEmitter { @Override public SseEventBuilder comment(String comment) { - append(':').append(StringUtils.replace(comment, "\n", "\n:")).append('\n'); + append(':'); + appendEscaped(comment, "\n:"); + append('\n'); return this; } @@ -259,6 +261,16 @@ public class SseEmitter extends ResponseBodyEmitter { if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) { this.dataToSend.add(new DataWithMediaType(input, mediaType)); } + else { + appendEscaped(input, "\ndata:"); + saveAppendedText(mediaType); + } + } + + private void appendEscaped(String input, String replacement) { + if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) { + append(input); + } else { int length = input.length(); for (int i = 0; i < length; i++) { @@ -267,16 +279,15 @@ public class SseEmitter extends ResponseBodyEmitter { if (i + 1 < length && input.charAt(i + 1) == '\n') { i++; } - this.sb.append("\ndata:"); + append(replacement); } else if (c == '\n') { - this.sb.append("\ndata:"); + append(replacement); } else { - this.sb.append(c); + append(c); } } - saveAppendedText(mediaType); } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java index 1b4d8ecaeb9..2ff5bfebd50 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java @@ -168,6 +168,17 @@ class SseEmitterTests { .send(event().name("first" + newLineChars + "second"))); } + @ParameterizedTest(name = "{1}") + @MethodSource("newLineCharacters") + void supportMultiLineComments(String newLineChars, String description) throws Exception { + this.emitter.send(event().comment("foo" + newLineChars + "bar" + newLineChars + "baz").data("payload")); + this.handler.assertSentObjectCount(3); + this.handler.assertObject(0, ":foo\n:bar\n:baz\ndata:", TEXT_PLAIN_UTF8); + this.handler.assertObject(1, "payload"); + this.handler.assertObject(2, "\n\n", TEXT_PLAIN_UTF8); + this.handler.assertWriteCount(1); + } + private static Stream newLineCharacters() { return Stream.of( Arguments.of("\n", "LF"),