From 5d429de4ed30824b4fa5472f12dc9aa863185788 Mon Sep 17 00:00:00 2001 From: Ciaran Liedeman Date: Sat, 27 Jun 2026 21:22:17 +0200 Subject: [PATCH 1/2] Fix Strawberry Shake SSE subscriptions not streaming on Blazor WebAssembly Subscriptions over the HTTP (Server-Sent Events) transport only delivered events after the connection closed on Blazor WebAssembly. The browser fetch based HttpClient buffers the whole response body unless per-request response streaming is enabled, so the SSE reader never observed an event until the stream ended. HttpConnection now sets the WebAssemblyEnableStreamingResponse request option for subscription operations via GraphQLHttpRequest.OnMessageCreated, so events surface as they arrive. The option is read only by the browser HTTP handler (ignored elsewhere) and is the default on .NET 10 and later, so query and mutation behavior and non-WASM clients are unaffected. Refs ChilliCream/graphql-platform#6944 Refs ChilliCream/graphql-platform#8555 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/Transport.Http/HttpConnection.cs | 19 +++- .../HttpConnectionStreamingTests.cs | 105 ++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/StrawberryShake/Client/test/Transport.Http.Tests/HttpConnectionStreamingTests.cs diff --git a/src/StrawberryShake/Client/src/Transport.Http/HttpConnection.cs b/src/StrawberryShake/Client/src/Transport.Http/HttpConnection.cs index 1fff3518d5c..eeb3bd0156e 100644 --- a/src/StrawberryShake/Client/src/Transport.Http/HttpConnection.cs +++ b/src/StrawberryShake/Client/src/Transport.Http/HttpConnection.cs @@ -12,6 +12,16 @@ public class HttpConnection : IHttpConnection public const string RequestUri = "StrawberryShake.Transport.Http.HttpConnection.RequestUri"; public const string HttpClient = "StrawberryShake.Transport.Http.HttpConnection.HttpClient"; + // Blazor WASM buffers the entire response body by default, so SSE subscription events only + // surface once the connection closes. Setting this per-request option opts into response + // streaming so events surface as they arrive. The option is ignored on non-WASM handlers + // and is the default on .NET 10+. + private static readonly HttpRequestOptionsKey s_enableWasmResponseStreaming = + new("WebAssemblyEnableStreamingResponse"); + + private static readonly OnHttpRequestMessageCreated s_enableResponseStreaming = + static (_, message, _) => message.Options.Set(s_enableWasmResponseStreaming, true); + private readonly Func _createClient; private readonly object? _clientFactoryState; @@ -103,7 +113,14 @@ protected virtual GraphQLHttpRequest CreateHttpRequest(OperationRequest request) operation = new HotChocolate.Transport.OperationRequest(body, null, name, onError: null, variables, extensions); } - return new GraphQLHttpRequest(operation) { EnableFileUploads = hasFiles }; + var httpRequest = new GraphQLHttpRequest(operation) { EnableFileUploads = hasFiles }; + + if (document.Kind == OperationKind.Subscription) + { + httpRequest.OnMessageCreated = s_enableResponseStreaming; + } + + return httpRequest; } protected virtual Response CreateResponse( diff --git a/src/StrawberryShake/Client/test/Transport.Http.Tests/HttpConnectionStreamingTests.cs b/src/StrawberryShake/Client/test/Transport.Http.Tests/HttpConnectionStreamingTests.cs new file mode 100644 index 00000000000..841b90d775b --- /dev/null +++ b/src/StrawberryShake/Client/test/Transport.Http.Tests/HttpConnectionStreamingTests.cs @@ -0,0 +1,105 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace StrawberryShake.Transport.Http; + +public class HttpConnectionStreamingTests +{ + private static readonly HttpRequestOptionsKey s_streamingOption = + new("WebAssemblyEnableStreamingResponse"); + + [Fact] + public async Task CreateHttpRequest_Should_EnableResponseStreaming_When_OperationIsSubscription() + { + // arrange + var handler = new CapturingHandler( + "text/event-stream", + "event: next\ndata: {\"data\":{}}\n\nevent: complete\n\n"); + var client = new HttpClient(handler) { BaseAddress = new Uri("http://localhost/graphql") }; + + var document = new TestDocument(OperationKind.Subscription, "subscription Test { __typename }"); + var request = new OperationRequest("Test", document); + + // act + var connection = new HttpConnection(() => client); + await foreach (var _ in connection.ExecuteAsync(request)) + { + } + + // assert + Assert.NotNull(handler.CapturedRequest); + var found = handler.CapturedRequest!.Options.TryGetValue(s_streamingOption, out var enabled); + Assert.True(found); + Assert.True(enabled); + } + + [Fact] + public async Task CreateHttpRequest_Should_NotEnableResponseStreaming_When_OperationIsQuery() + { + // arrange + var handler = new CapturingHandler( + "application/graphql-response+json", + "{\"data\":{}}"); + var client = new HttpClient(handler) { BaseAddress = new Uri("http://localhost/graphql") }; + + var document = new TestDocument(OperationKind.Query, "query Test { __typename }"); + var request = new OperationRequest("Test", document); + + // act + var connection = new HttpConnection(() => client); + await foreach (var _ in connection.ExecuteAsync(request)) + { + } + + // assert + Assert.NotNull(handler.CapturedRequest); + var found = handler.CapturedRequest!.Options.TryGetValue(s_streamingOption, out _); + Assert.False(found); + } + + private sealed class CapturingHandler : HttpMessageHandler + { + private readonly string _contentType; + private readonly string _body; + + public CapturingHandler(string contentType, string body) + { + _contentType = contentType; + _body = body; + } + + public HttpRequestMessage? CapturedRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + CapturedRequest = request; + + var content = new ByteArrayContent(Encoding.UTF8.GetBytes(_body)); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(_contentType); + + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; + return Task.FromResult(response); + } + } + + private sealed class TestDocument : IDocument + { + private readonly byte[] _query; + + public TestDocument(OperationKind kind, [StringSyntax("graphql")] string query) + { + Kind = kind; + _query = Encoding.UTF8.GetBytes(query); + } + + public OperationKind Kind { get; } + + public ReadOnlySpan Body => _query; + + public DocumentHash Hash { get; } = new("MD5", "ABC"); + } +} From 0311b3b5141c584c98693db493a7e1dfde12ca7b Mon Sep 17 00:00:00 2001 From: Ciaran Liedeman Date: Sat, 27 Jun 2026 21:35:39 +0200 Subject: [PATCH 2/2] Make the SSE subscription test exercise only the SSE transport StarWarsOnReviewSubGraphQLSSETest exercises the HTTP (Server-Sent Events) subscription transport, but it also registered a WebSocket client that was never resolved: the generated client wires the subscription executor on IHttpConnection, so the WebSocket registration was dead and made the test read like a WebSocket test. Remove the unused AddWebSocketClient registration. The StrawberryShake.Transport.WebSockets using is kept because the shared TestServerHelper lives in that namespace, with a comment so it is not mistaken for a WebSocket transport dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Integration/StarWarsOnReviewSubGraphQLSSETest.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/StarWarsOnReviewSubGraphQLSSETest.cs b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/StarWarsOnReviewSubGraphQLSSETest.cs index ebe6319529d..11d8d5153e8 100644 --- a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/StarWarsOnReviewSubGraphQLSSETest.cs +++ b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/StarWarsOnReviewSubGraphQLSSETest.cs @@ -2,6 +2,8 @@ using HotChocolate.StarWars.Models; using HotChocolate.Subscriptions; using Microsoft.Extensions.DependencyInjection; +// TestServerHelper lives in the WebSocket test project's namespace; it only hosts the GraphQL +// server here. This test runs subscriptions over SSE (HTTP), so no WebSocket client is registered. using StrawberryShake.Transport.WebSockets; using static HotChocolate.StarWars.Types.Subscriptions; @@ -26,9 +28,6 @@ public async Task Execute_StarWarsOnReviewSubGraphQLSSE_Test() serviceCollection.AddHttpClient( StarWarsOnReviewSubGraphQLSSEClient.ClientName, c => c.BaseAddress = new Uri("http://localhost:" + port + "/graphql")); - serviceCollection.AddWebSocketClient( - StarWarsOnReviewSubGraphQLSSEClient.ClientName, - c => c.Uri = new Uri("ws://localhost:" + port + "/graphql")); serviceCollection.AddStarWarsOnReviewSubGraphQLSSEClient(); IServiceProvider services = serviceCollection.BuildServiceProvider(); var client = services.GetRequiredService();