Skip to content

Add support for ServerSentEventsResult and extension methods #60616

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Mar 3, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eng/SharedFramework.External.props
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<ExternalAspNetCoreAppReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="$(MicrosoftExtensionsOptionsDataAnnotationsVersion)" />
<ExternalAspNetCoreAppReference Include="Microsoft.Extensions.Options" Version="$(MicrosoftExtensionsOptionsVersion)" />
<ExternalAspNetCoreAppReference Include="Microsoft.Extensions.Primitives" Version="$(MicrosoftExtensionsPrimitivesVersion)" />
<ExternalAspNetCoreAppReference Include="System.Net.ServerSentEvents" Version="$(SystemNextServerSentEvents)" />
<ExternalAspNetCoreAppReference Include="System.Security.Cryptography.Xml" Version="$(SystemSecurityCryptographyXmlVersion)" />
<ExternalAspNetCoreAppReference Include="System.Threading.RateLimiting" Version="$(SystemThreadingRateLimitingVersion)" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<Reference Include="Microsoft.AspNetCore.Hosting.Abstractions" />
<Reference Include="Microsoft.AspNetCore.Http.Extensions" />
<Reference Include="Microsoft.AspNetCore.Routing" />
<Reference Include="System.Net.ServerSentEvents" />

<InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Results.Tests" />
</ItemGroup>
Expand Down
10 changes: 9 additions & 1 deletion src/Http/Http.Results/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
#nullable enable
Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<T>
Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<T>.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<T>.StatusCode.get -> int?
static Microsoft.AspNetCore.Http.HttpResults.RedirectHttpResult.IsLocalUrl(string? url) -> bool
static Microsoft.AspNetCore.Http.Results.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable<string!>! value, string? eventType = null) -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.Results.ServerSentEvents<T>(System.Collections.Generic.IAsyncEnumerable<System.Net.ServerSentEvents.SseItem<T>>! value) -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.Results.ServerSentEvents<T>(System.Collections.Generic.IAsyncEnumerable<T>! value, string? eventType = null) -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents(System.Collections.Generic.IAsyncEnumerable<string!>! values, string? eventType = null) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<string!>!
static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents<T>(System.Collections.Generic.IAsyncEnumerable<System.Net.ServerSentEvents.SseItem<T>>! values) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<T>!
static Microsoft.AspNetCore.Http.TypedResults.ServerSentEvents<T>(System.Collections.Generic.IAsyncEnumerable<T>! values, string? eventType = null) -> Microsoft.AspNetCore.Http.HttpResults.ServerSentEventsResult<T>!
36 changes: 36 additions & 0 deletions src/Http/Http.Results/src/Results.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Diagnostics.CodeAnalysis;
using System.IO.Pipelines;
using System.Net.ServerSentEvents;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
Expand Down Expand Up @@ -978,6 +979,41 @@ public static IResult AcceptedAtRoute<TValue>(string? routeName, RouteValueDicti
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
=> value is null ? TypedResults.AcceptedAtRoute(routeName, routeValues) : TypedResults.AcceptedAtRoute(value, routeName, routeValues);

/// <summary>
/// Produces a <see cref="ServerSentEventsResult{TValue}"/> response.
/// </summary>
/// <param name="value">The value to be included in the HTTP response body.</param>
/// <param name="eventType">The event type to be included in the HTTP response body.</param>
/// <returns>The created <see cref="ServerSentEventsResult{TValue}"/> for the response.</returns>
/// <remarks>
/// Strings serialized by this result type are serialized as raw strings without any additional formatting.
/// </remarks>
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
public static IResult ServerSentEvents(IAsyncEnumerable<string> value, string? eventType = null)
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
=> new ServerSentEventsResult<string>(value, eventType);

/// <summary>
/// Produces a <see cref="ServerSentEventsResult{T}"/> response.
/// </summary>
/// <typeparam name="T">The type of object that will be serialized to the response body.</typeparam>
/// <param name="value">The value to be included in the HTTP response body.</param>
/// <param name="eventType">The event type to be included in the HTTP response body.</param>
/// <returns>The created <see cref="ServerSentEventsResult{T}"/> for the response.</returns>
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
public static IResult ServerSentEvents<T>(IAsyncEnumerable<T> value, string? eventType = null)
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
=> new ServerSentEventsResult<T>(value, eventType);

/// <summary>
/// Produces a <see cref="ServerSentEventsResult{T}"/> response.
/// </summary>
/// <typeparam name="T">The type of object that will be serialized to the response body.</typeparam>
/// <param name="value">The value to be included in the HTTP response body.</param>
/// <returns>The created <see cref="ServerSentEventsResult{T}"/> for the response.</returns>
public static IResult ServerSentEvents<T>(IAsyncEnumerable<SseItem<T>> value)
=> new ServerSentEventsResult<T>(value);

/// <summary>
/// Produces an empty result response, that when executed will do nothing.
/// </summary>
Expand Down
94 changes: 94 additions & 0 deletions src/Http/Http.Results/src/ServerSentEventsResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Net.ServerSentEvents;
using System.Text;
using Microsoft.AspNetCore.Http.Metadata;
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using System.Text.Json;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Http.HttpResults;

/// <summary>
/// Represents a result that writes a stream of server-sent events to the response.
/// </summary>
/// <typeparam name="T">The underlying type of the events emitted.</typeparam>
public sealed class ServerSentEventsResult<T> : IResult, IEndpointMetadataProvider, IStatusCodeHttpResult
{
private readonly IAsyncEnumerable<SseItem<T>> _events;

/// <inheritdoc/>
public int? StatusCode => StatusCodes.Status200OK;

internal ServerSentEventsResult(IAsyncEnumerable<T> events, string? eventType)
{
_events = WrapEvents(events, eventType);
}

internal ServerSentEventsResult(IAsyncEnumerable<SseItem<T>> events)
{
_events = events;
}

/// <inheritdoc />
public async Task ExecuteAsync(HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(httpContext);

httpContext.Response.ContentType = "text/event-stream";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, and line 47 if that's something we care about.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bleh -- I am torn about adding the Firefox workaround here. That seems like something the SseFormatter should be doing? Although admittedly it's a little gross to hardcode workarounds for buggy clients.

Maybe we leave it out for now and see what kind of feedback we get?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, until we see feedback I think it's less interesting for an API. For SignalR it was important because we want to signal that the connection is active and let the user start sending messages.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to disable buffering?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems like something the SseFormatter should be doing?

What is that?

Copy link
Member

@davidfowl davidfowl Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should do what SignalR is doing, we should not wait for feedback. Disable buffering and do the crazy IIS workaround 😄 (did you test this in IIS?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems like something the SseFormatter should be doing?

SignalR applies a special workaround to send some filler data through the SSE stream before the actual data to force Firefox's EventSource to emit an open event. It's a reaction to this bug in Firefox. The discussion is around whether or not to apply this workaround here in the ServerSentEventResult or have it be part of the default writing beahvior in the SseFormatter.


await SseFormatter.WriteAsync(_events, httpContext.Response.Body,
(item, writer) => FormatSseItem(item, writer, httpContext),
httpContext.RequestAborted);
}

private static void FormatSseItem(SseItem<T> item, IBufferWriter<byte> writer, HttpContext httpContext)
{
// Emit string and null values as-is
if (item.Data is string stringData)
{
writer.Write(Encoding.UTF8.GetBytes(stringData));
return;
}

if (item.Data is null)
{
writer.Write([]);
return;
}

// For non-string types, use JSON serialization with options from DI
var jsonOptions = httpContext.RequestServices.GetService<IOptions<JsonOptions>>()?.Value ?? new JsonOptions();
var runtimeType = item.Data.GetType();
var jsonTypeInfo = jsonOptions.SerializerOptions.GetTypeInfo(typeof(T));

// Use the appropriate JsonTypeInfo based on whether we need polymorphic serialization
var typeInfo = jsonTypeInfo.ShouldUseWith(runtimeType)
? jsonTypeInfo
: jsonOptions.SerializerOptions.GetTypeInfo(typeof(object));

var json = JsonSerializer.Serialize(item.Data, typeInfo);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There must be a more performant way to do this.
@eiriktsarpalis

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JsonSerializer.SerializeToUtf8Bytes?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly better since it avoids the string, but it still allocates a byte[] that we have to copy into IBufferWriter.

Copy link
Member

@lewing lewing Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like SerializeToUtf8Bytes boils down to roughly

        using var jsonWriter = new Utf8JsonWriter(writer, jsonOptions.SerializerOptions);
        typeInfo.Write(jsonWriter, item.Data);

but I'm not familiar enough to be sure

Copy link
Member Author

@captainsafia captainsafia Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like SerializeToUtf8Bytes/initializing a Utf8JsonWriter around the IBufferWriter<byte> might be as good as it gets here but I'd be curious to here what @eiriktsarpalis thinks.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to utilize ReusableUtf8JsonWriter?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would exposing JsonSerializer.Serialize accepting IBufferWriter<byte> overloads help in this case?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would exposing JsonSerializer.Serialize accepting IBufferWriter<byte> overloads help in this case?

Yep! I think that would be a nice solution.

writer.Write(Encoding.UTF8.GetBytes(json));
}

private static async IAsyncEnumerable<SseItem<T>> WrapEvents(IAsyncEnumerable<T> events, string? eventType = null)
{
await foreach (var item in events)
{
yield return new SseItem<T>(item, eventType);
}
}

static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder)
{
ArgumentNullException.ThrowIfNull(method);
ArgumentNullException.ThrowIfNull(builder);

builder.Metadata.Add(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(SseItem<T>), contentTypes: ["text/event-stream"]));
}
}
36 changes: 36 additions & 0 deletions src/Http/Http.Results/src/TypedResults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Diagnostics.CodeAnalysis;
using System.IO.Pipelines;
using System.Net.ServerSentEvents;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
Expand Down Expand Up @@ -1068,6 +1069,41 @@ public static AcceptedAtRoute<TValue> AcceptedAtRoute<TValue>(TValue? value, str
public static AcceptedAtRoute<TValue> AcceptedAtRoute<TValue>(TValue? value, string? routeName, RouteValueDictionary? routeValues)
=> new(routeName, routeValues, value);

/// <summary>
/// Produces a <see cref="ServerSentEventsResult{TValue}"/> response.
/// </summary>
/// <param name="values">The value to be included in the HTTP response body.</param>
/// <param name="eventType">The event type to be included in the HTTP response body.</param>
/// <returns>The created <see cref="ServerSentEventsResult{TValue}"/> for the response.</returns>
/// <remarks>
/// Strings serialized by this result type are serialized as raw strings without any additional formatting.
/// </remarks>
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
public static ServerSentEventsResult<string> ServerSentEvents(IAsyncEnumerable<string> values, string? eventType = null)
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
=> new(values, eventType);

/// <summary>
/// Produces a <see cref="ServerSentEventsResult{T}"/> response.
/// </summary>
/// <typeparam name="T">The type of object that will be serialized to the response body.</typeparam>
/// <param name="values">The value to be included in the HTTP response body.</param>
/// <param name="eventType">The event type to be included in the HTTP response body.</param>
/// <returns>The created <see cref="ServerSentEventsResult{T}"/> for the response.</returns>
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
public static ServerSentEventsResult<T> ServerSentEvents<T>(IAsyncEnumerable<T> values, string? eventType = null)
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
=> new(values, eventType);

/// <summary>
/// Produces a <see cref="ServerSentEventsResult{T}"/> response.
/// </summary>
/// <typeparam name="T">The type of object that will be serialized to the response body.</typeparam>
/// <param name="values">The value to be included in the HTTP response body.</param>
/// <returns>The created <see cref="ServerSentEventsResult{T}"/> for the response.</returns>
public static ServerSentEventsResult<T> ServerSentEvents<T>(IAsyncEnumerable<SseItem<T>> values)
=> new(values);

/// <summary>
/// Produces an empty result response, that when executed will do nothing.
/// </summary>
Expand Down
3 changes: 2 additions & 1 deletion src/Http/Http.Results/test/ResultsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1777,7 +1777,8 @@ private static string GetMemberName(Expression expression)
(() => Results.Unauthorized(), typeof(UnauthorizedHttpResult)),
(() => Results.UnprocessableEntity(null), typeof(UnprocessableEntity)),
(() => Results.UnprocessableEntity(new()), typeof(UnprocessableEntity<object>)),
(() => Results.ValidationProblem(new Dictionary<string, string[]>(), null, null, null, null, null, null), typeof(ProblemHttpResult))
(() => Results.ValidationProblem(new Dictionary<string, string[]>(), null, null, null, null, null, null), typeof(ProblemHttpResult)),
(() => Results.ServerSentEvents(AsyncEnumerable.Empty<string>(), null), typeof(ServerSentEventsResult<string>)),
};

public static IEnumerable<object[]> FactoryMethodsFromTuples() => FactoryMethodsTuples.Select(t => new object[] { t.Item1, t.Item2 });
Expand Down
Loading
Loading