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/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"); 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; + } +}