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