Skip to content

Add support for emitting ServerSentEvents from minimal APIs #56172

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

Closed
captainsafia opened this issue Jun 10, 2024 · 11 comments
Closed

Add support for emitting ServerSentEvents from minimal APIs #56172

captainsafia opened this issue Jun 10, 2024 · 11 comments
Assignees
Labels
api-approved API was approved in API review, it can be implemented area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc
Milestone

Comments

@captainsafia
Copy link
Member

captainsafia commented Jun 10, 2024

Background and Motivation

This proposal adds first-class support for SSE responses through the IResult pattern, making it consistent with other response types in minimal APIs. While ASP.NET Core has supported SSE through manual response writing, there hasn't been a built-in IResult implementation to return SSE streams from minimal API endpoints.

Proposed API

// Assembly: Microsoft.AspNetCore.Http.Results
namespace Microsoft.AspNetCore.Http;

public static class TypedResults 
{
+    public static ServerSentEventResult<string> ServerSentEvents<T>(
+        IAsyncEnumerable<string> value, 
+        string? eventType = null);
+
+    public static ServerSentEventResult<T> ServerSentEvents<T>(
+        IAsyncEnumerable<T> value, 
+        string? eventType = null);
+
+    public static ServerSentEventResult<T> ServerSentEvents<T>(
+        IAsyncEnumerable<SseItem<T>> value);
}

+ public sealed class ServerSentEventResult<T> : IResult, IEndpointMetadataProvider
+ {
+    static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder)
+ }

Usage Examples

Basic usage with simple values:

app.MapGet("/sse", () =>
{
    async IAsyncEnumerable<string> GenerateEvents(
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        var counter = 0;
        while (!cancellationToken.IsCancellationRequested)
        {
            yield return $"Event {counter++} at {DateTime.UtcNow}";
            await Task.Delay(1000, cancellationToken);
        }
    }

    return TypedResults.ServerSentEvents(GenerateEvents());
});

Usage with custom event types:

app.MapGet("/notifications", () =>
{
    async IAsyncEnumerable<Notification> GetNotifications(
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            yield return new Notification("New message received");
            await Task.Delay(5000, cancellationToken);
        }
    }

    return TypedResults.ServerSentEvents(GetNotifications(), eventType: "notification");
});

Raw string processing with string overload:

app.MapGet("/mixed-events", () =>
{
    async IAsyncEnumerable<string> GetMixedEvents(
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        yield return "Starting";
        yield return "Going";
        yield return "Finished";
    }

    return TypedResults.ServerSentEvents(GetMixedEvents());
});

Fine-grained control with the SseItem<T> overload:

app.MapGet("/mixed-events", () =>
{
    async IAsyncEnumerable<SseItem<string>> GetMixedEvents(
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        yield return new SseItem<string>("System starting", "init");
        yield return new SseItem<string>("Processing data", "progress") { EventId = "some-guid" };
        yield return new SseItem<string>("Complete", "done");
    }

    return TypedResults.ServerSentEvents(GetMixedEvents());
});

Alternative Designs

  • The TypedResults extension methods do not expose an overload that takes an event ID. Instead, the user must use the SseItem<T> based overload of the extension method if they want to control the event ID at a more granular level.

Risks

N/A

@captainsafia captainsafia added the area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc label Jun 10, 2024
@captainsafia captainsafia added this to the 9.0.0 milestone Jun 10, 2024
@captainsafia captainsafia self-assigned this Jun 10, 2024
@BrennanConroy
Copy link
Member

FYI; SSE in firefox has had a long standing bug where EventSource in Javascript doesn't fire the Open event until the server has started sending data.
See https://source.dot.net/#Microsoft.AspNetCore.Http.Connections/Internal/Transports/ServerSentEventsServerTransport.cs,45 for what we do in SignalR. It might not be relevant to Minimal since when we return the SSE IResult we'll be writing SSE frames at that point? Unless we want bi-directional SSE in which case we'd somehow need to send a comment frame when app code wants to read SSE data.

We can also steal (share) the writing side of SSE from SignalR https://source.dot.net/#Microsoft.AspNetCore.Http.Connections/ServerSentEventsMessageFormatter.cs,c4a21cef091f2b21

@eiriktsarpalis
Copy link
Member

Given that System.Net.ServerSentEvents is only exposed an OOB NuGet package, does it create any challenges when it comes to adding it as a dependency in the aspnetcore shared framework?

cc @halter73 @stephentoub

@stephentoub
Copy link
Member

Given that System.Net.ServerSentEvents is only exposed an OOB NuGet package, does it create any challenges when it comes to adding it as a dependency in the aspnetcore shared framework?

cc @halter73 @stephentoub

It would end up being pulled into the aspnetcore shared framework. That happens with several other runtime packages.

@captainsafia captainsafia added the api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews label Feb 19, 2025
@captainsafia captainsafia modified the milestones: 9.0.0, 10.0-preview3 Feb 19, 2025
Copy link
Contributor

Thank you for submitting this for API review. This will be reviewed by @dotnet/aspnet-api-review at the next meeting of the ASP.NET Core API Review group. Please ensure you take a look at the API review process documentation and ensure that:

  • The PR contains changes to the reference-assembly that describe the API change. Or, you have included a snippet of reference-assembly-style code that illustrates the API change.
  • The PR describes the impact to users, both positive (useful new APIs) and negative (breaking changes).
  • Someone is assigned to "champion" this change in the meeting, and they understand the impact and design of the change.

@eiriktsarpalis
Copy link
Member

@captainsafia under current API proposal what controls how individual T data gets serialized? Am I right to assume that it hardcodes to the configured serializer? That might not work for certain SSE wire formats, for example OpenAI/Copilot Agents require that the stream should end with this event:

data: [DONE]

This is a non-standard extension of the SSE specification, and it can only be achieved using either a custom marshalling delegate as with the System.Net.ServerSentEvents APIs or exposing an overload specifically accepting SseItem<string>.

@captainsafia
Copy link
Member Author

@captainsafia under current API proposal what controls how individual T data gets serialized? Am I right to assume that it hardcodes to the configured serializer?

Correct. It defaults to the built-in serializer.

This is a non-standard extension of the SSE specification, and it can only be achieved using either a custom marshalling delegate as with the System.Net.ServerSentEvents APIs

To this end, I consdiered adding an overload that allows end-users to customize the Action<SseItem<T>, IBufferWriter<byte>> callback in the formatter. That would solve the problem here although I'm not sure how nice it is as an API.

@eiriktsarpalis
Copy link
Member

Am I correct in assuming that the default handling of SseItem<string> would serialize individual payloads as JSON strings? The System.Net.SSE APIs expose overloads for SseItem<string> that emit text as-is into the event data. This is crucial since the SSE spec itself allows unstructured text on individual event data and in fact the OpenAI use case does need us to emit unstructured text.

Exposing an overload that takes a formatting callback would serve to unblock our use case, however it still puts the onus on the application author to create a callback that formats strings as raw strings. One possibility is specializing handling of SseItem<string> specifically so that it always get handled as raw text.

@bartonjs
Copy link
Member

  • ServerSentEventResult got pluralized to ServerSentEventsResult
  • The value parameters all got pluralized to values
  • Should "ServerSentEvents" be reduced to "Sse" to match "SseItem"?
    • It was felt that the scoping of this type does not lead well to "Sse" being known to be ServerSentEvents, so it's better to expand it here.
  • Should ServerSentEventsResult also implement IStatusCodeHttpResult? Yes
  • Should ServerSentEventsResult also implement IValueHttpResult? No
  • The overload accepting SseItems changed its signature. Verify that does not cause an ambiguous invocation.
  • There may be other overloads in the future taking JSON serialization information.
  • The new methods on TypedResults should be mirrored to Results, per the Results types pattern.
// Assembly: Microsoft.AspNetCore.Http.Results
namespace Microsoft.AspNetCore.Http;

public static partial class TypedResults 
{
    public static ServerSentEventsResult<string> ServerSentEvents(
        IAsyncEnumerable<string> values, 
        string? eventType = null);

    public static ServerSentEventsResult<T> ServerSentEvents<T>(
        IAsyncEnumerable<T> values, 
        string? eventType = null);

    public static ServerSentEventsResult<SseItem<T>> ServerSentEvents<T>(
        IAsyncEnumerable<SseItem<T>> values);
}

public static partial class Results 
{
    public static IResult ServerSentEvents(
        IAsyncEnumerable<string> values, 
        string? eventType = null);

    public static IResult ServerSentEvents<T>(
        IAsyncEnumerable<T> values, 
        string? eventType = null);

    public static IResult ServerSentEvents<T>(
        IAsyncEnumerable<SseItem<T>> values);
}

public sealed class ServerSentEventsResult<T> :
    IResult,
    IEndpointMetadataProvider,
    IStatusCodeHttpResult
 {
    static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder);
    public Task ExecuteAsync(HttpContext httpContext);
    public int? StatusCode { get; }
 }

@bartonjs bartonjs added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews labels Feb 24, 2025
captainsafia added a commit to dotnet/runtime that referenced this issue Feb 25, 2025
@captainsafia
Copy link
Member Author

captainsafia commented Mar 3, 2025

The overload accepting SseItems changed its signature. Verify that does not cause an ambiguous invocation.

This does indeed cause ambiguity issues when attempting to determine whether the T provided to the result type is already an SseItem or needs to be wrapped in the SseItem type envelope or to strongly-match against special cases like SseItem<string>. To support this working consistently in all cases, we need the underlying T to always represent the incoming type.

To that end, the following modification was made to the API in implementation.

- public static ServerSentEventsResult<SseItem<T>> ServerSentEvents<T>(
+ public static ServerSentEventsResult<T> ServerSentEvents<T>(
        IAsyncEnumerable<SseItem<T>> values);

Some other considerations from PR review:

  • The implementation doesn't currently implemt a workaround for a bug in Firefox that requires opening data to be sent over an SSE stream before the event source can actually be initialized. We can see if this proves to be a major issue in practice and batch it.
  • The implementation can further be improved by using the new IBufferWriter-overload on the JSON serializer to avoid having to initialize a Utf8 writer instance. See Consider adding IBufferWriter<byte> overloads to JsonSerializer runtime#112943.
  • The implementation doesn't currently do any API-level management of keep-alives. We can consider doing something to support this, although I'd prefer it not be exposed in the API surface. Absent that, we can document strategies for doing application-level keep-alives with these SSEs.

@pholly
Copy link

pholly commented Mar 3, 2025

Very happy to see this will be included in .NET 10. I am using Datastar (framework like HTMX but with frontend signals and SSE) and it's nice to see examples for long-lived connections that send multiple events. The current Datastar .NET SDK works really well to send SSE events specifically for Datastar.

@captainsafia
Copy link
Member Author

Marking this as closed to be released in .NET 10 Preview 3. Docs issue is over at dotnet/AspNetCore.Docs#35089. Sample app is over at https://github.com/captainsafia/minapi-sse.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-approved API was approved in API review, it can be implemented area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc
Projects
None yet
Development

No branches or pull requests

6 participants