Skip to content
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

[API Proposal]: Add formatting support to System.Net.ServerSentEvents #109294

Closed
eiriktsarpalis opened this issue Oct 28, 2024 · 13 comments · Fixed by #109832
Closed

[API Proposal]: Add formatting support to System.Net.ServerSentEvents #109294

eiriktsarpalis opened this issue Oct 28, 2024 · 13 comments · Fixed by #109832
Assignees
Labels
api-approved API was approved in API review, it can be implemented area-System.Net in-pr There is an active PR which will close this issue when it is merged
Milestone

Comments

@eiriktsarpalis
Copy link
Member

eiriktsarpalis commented Oct 28, 2024

Background and motivation

The System.Net.ServerSentEvents library added support for parsing SSE events. We should also add support for formatting SSE events on the server side, in a way that unlocks work for dotnet/aspnetcore#56172.

API Proposal

namespace System.Net.ServerSentEvents;

// Extends the existing type to support writing
public readonly struct SseItem<T>
{
-   public SseItem(T data, string eventType);
+   public SseItem(T data, string? eventType);

    public T Data { get; }

-   public string EventType { get; }
+   public string? EventType { get; } 

+   public string? EventId { get; init; }
}

public delegate T SseItemParser<out T>(string eventType, ReadOnlySpan<byte> data);
+public delegate byte[] SseItemFormatter<T>(T value);

+public sealed class SseFormatter
+{
+    public static SseFormatter Create<T>(IAsyncEnumerable<SseItem<string>> source);
+    public static SseFormatter Create<T>(IAsyncEnumerable<SseItem<T>> source, SseItemFormatter<T> itemFormatter);
+
+    public Task WriteToStreamAsync(Stream stream, CancellationToken cancellationToken = default);
+    public void WriteToStream(Stream stream, CancellationToken cancellationToken = default);
+}

API Usage

IAsyncEnumerable<SseItem<int>> source = ...;
SseFormatter formatter = SseFormatter.Create<int>(source, value => JsonSerializer.SerializeToBytes(value));
await formatter.WriteToStreamAsync(targetStream, cancellationToken);

Alternative Designs

The current SseItemFormatter delegate uses an allocating design, but which is simpler to use. Alternative designs would involve writing either to a Stream:

public delegate ValueTask SseItemFormatter<T>(Stream stream, T value);

But this would require wrapping the underlying stream in order to intercept potential line breaks. Alternatively, we could use an IBufferWriter<byte> which also composes better with PipeWriter targets:

public delegate void SseItemFormatter<T>(IBufferWriter<byte> writer, T value);

The downside of either approach is that it becomes harder to define formatters from the user's perspective:

SseFormatter formatter = SseFormatter.Create<int>(source, (bufferWriter, value) => 
{ 
    var writer = new Utf8JsonWriter(bufferWriter);
    return JsonSerializer.SerializeToBytes(bufferWriter, value); 
});

Risks

No response

@eiriktsarpalis eiriktsarpalis added the api-suggestion Early API idea and discussion, it is NOT ready for implementation label Oct 28, 2024
@dotnet-issue-labeler dotnet-issue-labeler bot added the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Oct 28, 2024
@dotnet-policy-service dotnet-policy-service bot added the untriaged New issue has not been triaged by the area owner label Oct 28, 2024
@eiriktsarpalis
Copy link
Member Author

cc @captainsafia @stephentoub

@eiriktsarpalis eiriktsarpalis added area-System.Net and removed untriaged New issue has not been triaged by the area owner labels Oct 28, 2024
@eiriktsarpalis eiriktsarpalis added this to the 10.0.0 milestone Oct 28, 2024
@vcsjones vcsjones removed the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Oct 28, 2024
@stephentoub
Copy link
Member

  • public string EventType { get; }
  • public string? EventType { get; }

This will introduce warnings into folks' code. Why is it necessary?

@stephentoub
Copy link
Member

  • public static SseFormatter Create(IAsyncEnumerable<SseItem> source, SseItemFormatter itemFormatter);

Seems like this design forces a byte[] allocation for every item. What about writing it directly to the destination stream?

@captainsafia
Copy link
Member

I noticed that our SseItem struct doesn't define a retry field. Should it since the underlying spec supports it as an optional property?

This will introduce warnings into folks' code. Why is it necessary?

It seems like according to the spec, the event type is actually optional. We may introduce new warnings here but it strikes me as valid given the it's possible for the underlying value to be null. We're a lot more permissive about making non-nullable properties nullable in ASP.NET Core reviews so I tend to be more relax about introducing new warnings if they actually make sense. 😅

@eiriktsarpalis
Copy link
Member Author

eiriktsarpalis commented Oct 29, 2024

Seems like this design forces a byte[] allocation for every item.

I initially experimented with a signature matching that of IUtf8SpanFormattable but realized that it doesn't compose well with how most serializers work. A typical implementation would have to allocate its own byte[] which it then needs to compare against the destination buffer. If it doesn't fit, it would need to be rerun up until the point a matching destination has been provided.

What about writing it directly to the destination stream?

We need a way to intercept line breaks. Maybe it's possible to do so by wrapping the underlying stream but I haven't tested this out. Worth a try you think?

public delegate ValueTask SseItemFormatter<T>(Stream stream, T value);

But not sure how well this would compose with a hypothetical future API that targets PipeWriter. Perhaps using IBufferWriter<byte> might be preferable here:

public delegate void SseItemFormatter<T>(IBufferWriter<byte> writer, T value);

@MihaZupan
Copy link
Member

cc: @dotnet/ncl since the bot was lazy

@eiriktsarpalis eiriktsarpalis self-assigned this Nov 7, 2024
@eiriktsarpalis eiriktsarpalis added api-ready-for-review API is ready for review, it is NOT ready for implementation and removed api-suggestion Early API idea and discussion, it is NOT ready for implementation labels Nov 7, 2024
@stephentoub
Copy link
Member

It seems like according to the spec, the event type is actually optional

It is optional, and when it's not supplied, implementations are supposed to infer "message". So we exposed SseParser.EventTypeDefault, so that if it's not there in the wire data, the .NET representation of it can provide it explicitly.

If we want to make the ctor parameter optional and have the constructor supply "message" when the event type is null, I'm fine with that. But making the property be nullable doesn't seem warranted; it's just introducing friction. What's the case where someone is going to behave differently based on whether EventType returns null or returns "message"? Presumably we actually make it harder for consumers with this being nullable, as someone wanting to handle the default case needs to check for both null and "message".

@eiriktsarpalis
Copy link
Member Author

Presumably we actually make it harder for consumers with this being nullable, as someone wanting to handle the default case needs to check for both null and "message".

The downside of keeping it non-nullable is that we'd mandating event types to be written on the formatter side. Unless of course we use a separate type to model written events or decide that "message" is the magic string that results in event types being omitted.

@stephentoub
Copy link
Member

The downside of keeping it non-nullable is that we'd mandating event types to be written on the formatter side.

Why? If it returns "message", the formatter can choose to elide it. That's true whether or not the property can also return null.

@eiriktsarpalis
Copy link
Member Author

Why? If it returns "message", the formatter can choose to elide it. That's true whether or not the property can also return null.

So we make "message" a magic string?

@stephentoub
Copy link
Member

So we make "message" a magic string?

The spec makes it a magic string.

@bartonjs
Copy link
Member

bartonjs commented Nov 12, 2024

Video

  • Removed the custom delegate type in favor of Action
  • SseItem.EventType will stay non-nullable even though the ctor now accepts null
  • Replaced instance SseFormatter with a static class.
namespace System.Net.ServerSentEvents;

public readonly struct SseItem<T>
{
-   public SseItem(T data, string eventType);
+   public SseItem(T data, string? eventType);
namespace System.Net.ServerSentEvents;

// Extends the existing type to support writing
public readonly partial struct SseItem<T>
{
    public string? EventId { get; init; }
}

public static class SseFormatter
{
    public static Task WriteAsync(IAsyncEnumerable<SseItem<string>> source, Stream destination, CancellationToken cancellationToken = default);
    public static Task WriteAsync(IAsyncEnumerable<SseItem<T>> source, Stream destination, Action<T, IBufferWriter<byte>> itemFormatter, CancellationToken cancellationToken = default);
}

@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 review, it is NOT ready for implementation labels Nov 12, 2024
@dotnet-policy-service dotnet-policy-service bot added the in-pr There is an active PR which will close this issue when it is merged label Nov 14, 2024
@eiriktsarpalis
Copy link
Member Author

I want to propose the following amendments to the API as approved above:

  1. The itemFormatter delegate should accept the full SseItem<T> envelope and not just T. This is consistent with the parsing delegate which accepts the event type as input.
  2. During review, we stopped short of including an SseItem<T> property representing the retry field in the specification. We already expose the aggregate value in SseParser<T> via the ReconnectionInterval property so I propose we use the identical name and type for the field.

Thus, the amended design looks as follows:

namespace System.Net.ServerSentEvents;

// Extends the existing type to support writing
public readonly partial struct SseItem<T>
{
    public string? EventId { get; init; }
+    public TimeSpan? ReconnectionInterval { get; init; }
}

public static class SseFormatter
{
    public static Task WriteAsync(IAsyncEnumerable<SseItem<string>> source, Stream destination, CancellationToken cancellationToken = default);
-    public static Task WriteAsync(IAsyncEnumerable<SseItem<T>> source, Stream destination, Action<T, IBufferWriter<byte>> itemFormatter, CancellationToken cancellationToken = default);
+   public static Task WriteAsync(IAsyncEnumerable<SseItem<T>> source, Stream destination, Action<SseItem<T>, IBufferWriter<byte>> itemFormatter, CancellationToken cancellationToken = default); 
}

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-System.Net in-pr There is an active PR which will close this issue when it is merged
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants