diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index b887595b1..14ede1f9f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -242,6 +242,7 @@ const config: UserConfig = { {text: 'Fluent Validation', link: '/guide/http/fluentvalidation'}, {text: 'Problem Details', link: '/guide/http/problemdetails'}, {text: 'Caching', link: '/guide/http/caching'}, + {text: 'Streaming and SSE', link: '/guide/http/streaming'}, {text: 'HTTP Messaging Transport', link: '/guide/http/transport'}, {text: 'Integration Testing with Alba', link: '/guide/http/integration-testing'} ] diff --git a/docs/guide/http/streaming.md b/docs/guide/http/streaming.md new file mode 100644 index 000000000..5c847fe68 --- /dev/null +++ b/docs/guide/http/streaming.md @@ -0,0 +1,82 @@ +# Streaming and Server-Sent Events (SSE) + +Wolverine.HTTP endpoints can return ASP.NET Core's `IResult` type, which means you can take full advantage of the built-in `Results.Stream()` method for streaming responses and Server-Sent Events (SSE). No special Wolverine infrastructure is needed -- this is standard ASP.NET Core functionality that works seamlessly with Wolverine's endpoint model. + +## Server-Sent Events (SSE) + +[Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) allow a server to push real-time updates to the client over a single HTTP connection. To create an SSE endpoint, return `Results.Stream()` with the `text/event-stream` content type: + + + +```cs +[WolverineGet("/api/sse/events")] +public static IResult GetSseEvents() +{ + return Results.Stream(async stream => + { + var writer = new StreamWriter(stream); + for (var i = 0; i < 3; i++) + { + await writer.WriteAsync($"data: Event {i}\n\n"); + await writer.FlushAsync(); + } + }, contentType: "text/event-stream"); +} +``` +snippet source | anchor + + +SSE messages follow the [EventSource format](https://html.spec.whatwg.org/multipage/server-sent-events.html), where each event is prefixed with `data: ` and terminated by two newline characters (`\n\n`). + +## Plain Streaming Responses + +For streaming plain text or other content types without the SSE protocol, you can use the same `Results.Stream()` approach with a different content type: + + + +```cs +[WolverineGet("/api/stream/data")] +public static IResult GetStreamData() +{ + return Results.Stream(async stream => + { + var writer = new StreamWriter(stream); + for (var i = 0; i < 5; i++) + { + await writer.WriteLineAsync($"line {i}"); + await writer.FlushAsync(); + } + }, contentType: "text/plain"); +} +``` +snippet source | anchor + + +## Integration with Wolverine Services + +Since Wolverine HTTP endpoints support dependency injection, you can combine streaming with other Wolverine capabilities. For example, you could stream events from a Wolverine message handler or publish messages during a streaming session: + +```cs +[WolverineGet("/api/sse/orders")] +public static IResult StreamOrderUpdates(IMessageBus bus) +{ + return Results.Stream(async stream => + { + var writer = new StreamWriter(stream); + + // Your streaming logic here, potentially using + // Wolverine's IMessageBus for publishing side effects + await writer.WriteAsync("data: connected\n\n"); + await writer.FlushAsync(); + }, contentType: "text/event-stream"); +} +``` + +## Key Points + +- Wolverine endpoints that return `IResult` work with `Results.Stream()` out of the box +- No special Wolverine middleware or configuration is required for streaming +- SSE endpoints should use the `text/event-stream` content type +- Each SSE event must be formatted as `data: \n\n` +- Always call `FlushAsync()` after writing each event to ensure timely delivery to the client +- The `Results.Stream()` API is part of ASP.NET Core and is available in .NET 8+ diff --git a/src/Http/Wolverine.Http.Tests/Streaming/StreamingTests.cs b/src/Http/Wolverine.Http.Tests/Streaming/StreamingTests.cs new file mode 100644 index 000000000..8fcd8076a --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/Streaming/StreamingTests.cs @@ -0,0 +1,41 @@ +using Alba; +using Shouldly; + +namespace Wolverine.Http.Tests.Streaming; + +public class StreamingTests : IntegrationContext +{ + public StreamingTests(AppFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task can_stream_sse_events() + { + var result = await Scenario(s => + { + s.Get.Url("/api/sse/events"); + s.StatusCodeShouldBeOk(); + s.Header("content-type").SingleValueShouldEqual("text/event-stream"); + }); + + var body = result.ReadAsText(); + body.ShouldContain("data: Event 0"); + body.ShouldContain("data: Event 2"); + } + + [Fact] + public async Task can_stream_plain_text() + { + var result = await Scenario(s => + { + s.Get.Url("/api/stream/data"); + s.StatusCodeShouldBeOk(); + s.Header("content-type").SingleValueShouldEqual("text/plain"); + }); + + var body = result.ReadAsText(); + body.ShouldContain("line 0"); + body.ShouldContain("line 4"); + } +} diff --git a/src/Http/WolverineWebApi/Streaming/StreamingEndpoints.cs b/src/Http/WolverineWebApi/Streaming/StreamingEndpoints.cs new file mode 100644 index 000000000..954a8181a --- /dev/null +++ b/src/Http/WolverineWebApi/Streaming/StreamingEndpoints.cs @@ -0,0 +1,42 @@ +using Wolverine.Http; + +namespace WolverineWebApi.Streaming; + +public static class StreamingEndpoints +{ + #region sample_sse_endpoint + + [WolverineGet("/api/sse/events")] + public static IResult GetSseEvents() + { + return Results.Stream(async stream => + { + var writer = new StreamWriter(stream); + for (var i = 0; i < 3; i++) + { + await writer.WriteAsync($"data: Event {i}\n\n"); + await writer.FlushAsync(); + } + }, contentType: "text/event-stream"); + } + + #endregion + + #region sample_streaming_endpoint + + [WolverineGet("/api/stream/data")] + public static IResult GetStreamData() + { + return Results.Stream(async stream => + { + var writer = new StreamWriter(stream); + for (var i = 0; i < 5; i++) + { + await writer.WriteLineAsync($"line {i}"); + await writer.FlushAsync(); + } + }, contentType: "text/plain"); + } + + #endregion +}