Centralize Server Sent Event utility methods

Prior to this commit, many classes would support writing Server Sent
Events in some way to the response output stream. This has lead to some
code duplication.

This commit refactors the duplicated code in a shared `SseUtils` class.

Closes gh-37065
This commit is contained in:
Brian Clozel
2026-08-14 09:19:58 +02:00
parent 999f428987
commit 5abe6d3e5f
9 changed files with 230 additions and 146 deletions
@@ -20,8 +20,8 @@ import java.time.Duration;
import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.web.util.SseUtils;
/**
* Representation for a Server-Sent Event for use with Spring's reactive Web support.
@@ -112,7 +112,7 @@ public final class ServerSentEvent<T> {
}
if (this.comment != null) {
sb.append(':');
appendEscaped(this.comment, "\n:", sb);
SseUtils.appendFieldValue("", this.comment, sb);
sb.append('\n');
}
if (this.data != null) {
@@ -125,30 +125,6 @@ public final class ServerSentEvent<T> {
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 &&
@@ -265,23 +241,18 @@ public final class ServerSentEvent<T> {
@Override
public Builder<T> id(String id) {
checkEvent(id);
SseUtils.assertNoLineSeparator(id);
this.id = id;
return this;
}
@Override
public Builder<T> event(String event) {
checkEvent(event);
SseUtils.assertNoLineSeparator(event);
this.event = event;
return this;
}
private static void checkEvent(String content) {
Assert.isTrue(content.indexOf('\n') == -1 && content.indexOf('\r') == -1,
"illegal character '\\n' or '\\r' in event content");
}
@Override
public Builder<T> retry(Duration retry) {
this.retry = retry;
@@ -40,6 +40,7 @@ import org.springframework.http.ReactiveHttpOutputMessage;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.util.Assert;
import org.springframework.web.util.SseUtils;
/**
* {@code HttpMessageWriter} for {@code "text/event-stream"} responses.
@@ -142,27 +143,7 @@ public class ServerSentEventHttpMessageWriter implements HttpMessageWriter<Objec
}
private void writeStringData(String input, 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("\ndata:");
}
else if (c == '\n') {
sb.append("\ndata:");
}
else {
sb.append(c);
}
}
}
SseUtils.appendFieldValue("data", input, sb);
sb.append("\n\n");
}
@@ -0,0 +1,77 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.util;
import org.springframework.util.Assert;
/**
* Utility methods for writing content as
* <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html">Server-Sent Events</a>,
* shared by the Servlet and Reactive SSE support.
*
* @author Brian Clozel
* @since 7.0.9
*/
public abstract class SseUtils {
/**
* Append {@code value} to {@code output}, replacing each line separator
* ({@code "\n"}, {@code "\r"}, or {@code "\r\n"}) it contains with a new
* {@code field} line (that is, {@code "\n" + field + ":"}). This keeps a
* multi-line field value from breaking out of the current SSE field when
* written on the wire.
* @param field the name of the SSE field that {@code value} belongs to
* (for example, {@code "data"}), or an empty string for a comment
* @param value the field value to escape and append
* @param output the {@code StringBuilder} to append the escaped value to
*/
public static void appendFieldValue(String field, String value, StringBuilder output) {
if (value.indexOf('\n') == -1 && value.indexOf('\r') == -1) {
output.append(value);
return;
}
String lineSeparatorReplacement = "\n" + field + ":";
int length = value.length();
for (int i = 0; i < length; i++) {
char c = value.charAt(i);
if (c == '\r') {
if (i + 1 < length && value.charAt(i + 1) == '\n') {
i++;
}
output.append(lineSeparatorReplacement);
}
else if (c == '\n') {
output.append(lineSeparatorReplacement);
}
else {
output.append(c);
}
}
}
/**
* Assert that the given single-line SSE field value, such as an
* {@code id} or {@code event} name, does not contain a line separator.
* @param content the field value to check
* @throws IllegalArgumentException if {@code content} contains {@code "\n"} or {@code "\r"}
*/
public static void assertNoLineSeparator(String content) {
Assert.isTrue(content.indexOf('\n') == -1 && content.indexOf('\r') == -1,
"illegal character '\\n' or '\\r' in event content");
}
}
@@ -0,0 +1,81 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.util;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
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;
/**
* Tests for {@link SseUtils}.
* @author Brian Clozel
*/
class SseUtilsTests {
@Test
void appendFieldValueWithoutLineSeparatorAppendsAsIs() {
StringBuilder sb = new StringBuilder();
sb.append("data:");
SseUtils.appendFieldValue("data", "no newlines here", sb);
assertThat(sb).hasToString("data:no newlines here");
}
@ParameterizedTest(name = "{1}")
@MethodSource("newLineCharacters")
void appendFieldValueReplacesLineSeparatorWithFieldPrefix(String newLine, String description) {
StringBuilder sb = new StringBuilder();
sb.append("data:");
SseUtils.appendFieldValue("data", "first" + newLine + "second", sb);
assertThat(sb).hasToString("data:first\ndata:second");
}
@ParameterizedTest(name = "{1}")
@MethodSource("newLineCharacters")
void appendFieldValueUsesEmptyFieldForComments(String newLine, String description) {
StringBuilder sb = new StringBuilder();
sb.append(":");
SseUtils.appendFieldValue("", "first" + newLine + "second", sb);
assertThat(sb).hasToString(":first\n:second");
}
@Test
void assertNoLineSeparatorAcceptsPlainContent() {
SseUtils.assertNoLineSeparator("no newlines here");
}
@ParameterizedTest(name = "{1}")
@MethodSource("newLineCharacters")
void assertNoLineSeparatorRejectsLineSeparator(String newLine, String description) {
assertThatIllegalArgumentException().isThrownBy(() ->
SseUtils.assertNoLineSeparator("first" + newLine + "second"));
}
static Stream<Arguments> newLineCharacters() {
return Stream.of(
Arguments.of("\n", "LF"),
Arguments.of("\r", "CR"),
Arguments.of("\r\n", "CRLF")
);
}
}