From a582b754cf20f4efdcd9e815705b4ccdcc21efc0 Mon Sep 17 00:00:00 2001 From: mikhail Date: Wed, 16 Sep 2026 09:43:48 +0300 Subject: [PATCH 1/2] fix(push): stop NGINX buffering the server-sent events stream An NGINX that terminates TLS holds the whole event stream in its buffers with its default settings, so no push message reaches the browser at all. Atmosphere never sets X-Accel-Buffering, the header NGINX reads to turn buffering off for a single response, so Flow sets it on the push response itself. Checked against the scenarios in mcollovati/vaadin-reverse-proxy-tests: NGINX over HTTPS went from no push at all to a working stream, and Apache HTTPD over HTTP, AJP and HTTPS as well as NGINX over HTTP are unchanged. --- .../communication/PushRequestHandler.java | 51 +++++++++++ .../communication/PushRequestHandlerTest.java | 84 +++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 flow-server/src/test/java/com/vaadin/flow/server/communication/PushRequestHandlerTest.java diff --git a/flow-server/src/main/java/com/vaadin/flow/server/communication/PushRequestHandler.java b/flow-server/src/main/java/com/vaadin/flow/server/communication/PushRequestHandler.java index 205f4338ba5..916706366fc 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/communication/PushRequestHandler.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/communication/PushRequestHandler.java @@ -32,6 +32,7 @@ import org.atmosphere.cpr.AtmosphereRequestImpl; import org.atmosphere.cpr.AtmosphereResponseImpl; import org.atmosphere.cpr.BroadcasterConfig; +import org.atmosphere.cpr.HeaderConfig; import org.atmosphere.util.VoidAnnotationProcessor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,6 +52,7 @@ import com.vaadin.flow.server.VaadinServletService; import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.shared.communication.PushConstants; +import com.vaadin.flow.shared.ui.Transport; /** * Handles requests to open a push (bidirectional) communication channel between @@ -66,6 +68,14 @@ public class PushRequestHandler implements RequestHandler, SessionExpiredHandler { + /** + * Response header that asks a proxy not to buffer the response. NGINX + * honours it per response, which is what the server-sent events transport + * needs: with buffering left on, an NGINX terminating TLS holds the whole + * event stream and no push message reaches the browser. + */ + private static final String ACCEL_BUFFERING_HEADER = "X-Accel-Buffering"; + private AtmosphereFramework atmosphere; private PushHandler pushHandler; @@ -285,6 +295,7 @@ public boolean handleRequest(VaadinSession session, VaadinRequest request, "Atmosphere initialization failed. No push available."); return true; } + disableProxyBufferingForServerSentEvents(request, response); try { atmosphere.doCometSupport( AtmosphereRequestImpl @@ -303,6 +314,46 @@ public boolean handleRequest(VaadinSession session, VaadinRequest request, return true; } + /** + * Marks a server-sent events push response as one a proxy must not buffer. + *

+ * Must run before Atmosphere writes anything, because the header cannot be + * added once the response is committed. Other transports are left alone: + * they either complete each response, so a proxy flushes it anyway, or are + * not HTTP responses at all. + * + * @param request + * the push request + * @param response + * the response to mark + */ + static void disableProxyBufferingForServerSentEvents(VaadinRequest request, + VaadinResponse response) { + if (isServerSentEventsRequest(request)) { + response.setHeader(ACCEL_BUFFERING_HEADER, "no"); + } + } + + /** + * Checks whether a push request opens a server-sent events connection. + *

+ * The client passes the transport as a query parameter, but an Atmosphere + * client may send it as a header instead, so both are read. + * + * @param request + * the push request + * @return {@code true} if the request uses the server-sent events transport + */ + private static boolean isServerSentEventsRequest(VaadinRequest request) { + String transport = request + .getParameter(HeaderConfig.X_ATMOSPHERE_TRANSPORT); + if (transport == null) { + transport = request.getHeader(HeaderConfig.X_ATMOSPHERE_TRANSPORT); + } + return Transport.SERVER_SENT_EVENTS.getIdentifier() + .equalsIgnoreCase(transport); + } + /** * Frees any resources currently in use. */ diff --git a/flow-server/src/test/java/com/vaadin/flow/server/communication/PushRequestHandlerTest.java b/flow-server/src/test/java/com/vaadin/flow/server/communication/PushRequestHandlerTest.java new file mode 100644 index 00000000000..60314d5994d --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/server/communication/PushRequestHandlerTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * 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 + * + * http://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 com.vaadin.flow.server.communication; + +import org.atmosphere.cpr.HeaderConfig; +import org.junit.jupiter.api.Test; + +import com.vaadin.flow.server.VaadinRequest; +import com.vaadin.flow.server.VaadinResponse; +import com.vaadin.flow.shared.ui.Transport; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PushRequestHandlerTest { + + @Test + void serverSentEventsRequest_proxyBufferingDisabled() { + // The client puts the transport in the query string, an Atmosphere + // client may put it in a header instead. + assertBufferingDisabled(requestWithParameter( + Transport.SERVER_SENT_EVENTS.getIdentifier())); + assertBufferingDisabled(requestWithHeader( + Transport.SERVER_SENT_EVENTS.getIdentifier())); + } + + @Test + void otherTransports_responseNotTouched() { + assertBufferingNotTouched( + requestWithParameter(Transport.WEBSOCKET.getIdentifier())); + assertBufferingNotTouched( + requestWithParameter(Transport.LONG_POLLING.getIdentifier())); + assertBufferingNotTouched(requestWithParameter(null)); + } + + private static void assertBufferingDisabled(VaadinRequest request) { + VaadinResponse response = mock(VaadinResponse.class); + + PushRequestHandler.disableProxyBufferingForServerSentEvents(request, + response); + + verify(response).setHeader("X-Accel-Buffering", "no"); + } + + private static void assertBufferingNotTouched(VaadinRequest request) { + VaadinResponse response = mock(VaadinResponse.class); + + PushRequestHandler.disableProxyBufferingForServerSentEvents(request, + response); + + verify(response, never()).setHeader(anyString(), any()); + } + + private static VaadinRequest requestWithParameter(String transport) { + VaadinRequest request = mock(VaadinRequest.class); + when(request.getParameter(HeaderConfig.X_ATMOSPHERE_TRANSPORT)) + .thenReturn(transport); + return request; + } + + private static VaadinRequest requestWithHeader(String transport) { + VaadinRequest request = mock(VaadinRequest.class); + when(request.getHeader(HeaderConfig.X_ATMOSPHERE_TRANSPORT)) + .thenReturn(transport); + return request; + } +} From 51465dd83e1ad73715408b59dea83e68feaa4320 Mon Sep 17 00:00:00 2001 From: mikhail Date: Wed, 16 Sep 2026 09:43:54 +0300 Subject: [PATCH 2/2] docs: note the per-origin connection limit of server-sent events Over HTTP/1.1 an open event stream occupies one of the six connections a browser allows per origin, so the sixth tab of the same application fails to load rather than only losing push. WebSocket is not affected. --- .../main/java/com/vaadin/flow/shared/ui/Transport.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/flow-server/src/main/java/com/vaadin/flow/shared/ui/Transport.java b/flow-server/src/main/java/com/vaadin/flow/shared/ui/Transport.java index 865add546c3..19ba92da60d 100644 --- a/flow-server/src/main/java/com/vaadin/flow/shared/ui/Transport.java +++ b/flow-server/src/main/java/com/vaadin/flow/shared/ui/Transport.java @@ -47,6 +47,15 @@ public enum Transport { * {@code com.vaadin.experimental.ssePushTransport} feature flag to be * enabled. Selecting it while the feature flag is disabled throws a * {@code DisabledFeatureException}. + *

+ * The event stream is an ordinary HTTP response that stays open, so over + * HTTP/1.1 every open tab holds one of the connections a browser allows per + * origin, six of them in Chrome. With that many tabs of the same + * application open, the next one fails to load at all rather than merely + * losing push. Serving the application over HTTP/2, where requests share a + * single connection, lifts the limit. {@link #WEBSOCKET} and + * {@link #WEBSOCKET_XHR} are unaffected, because browsers pool WebSocket + * connections separately. */ SERVER_SENT_EVENTS("sse");