September 29th, 2022

Experimental WebTransport over HTTP/3 support in Kestrel

Chris R
Principal Software Engineer

We’re excited to announce experimental support for the WebTransport over HTTP/3 protocol as part of .NET 7 RC1.

This feature and blog post were written by our excellent intern Daniel Genkin!

What is WebTransport

WebTransport is a new draft specification for a transport protocol similar to WebSockets that allows the usage of multiple streams per connection.

WebSockets allowed upgrading a whole HTTP TCP/TLS connection to a bidirectional data stream. If you needed to open more streams you’d spend additional time and resources establishing new TCP and TLS sessions. WebSockets over HTTP/2 streamlined this by allowing multiple WebSocket streams to be established over one HTTP/2 TCP/TLS session. The downside here is that because this was still based on TCP, any packets lost from one stream would cause delays for every stream on the connection.

With the introduction of HTTP/3 and QUIC, which uses UDP rather than TCP, WebTransport can be used to establish multiple streams on one connection without them blocking each other. For example, consider an online game where the game state is transmitted on one bidirectional stream, the players’ voices for the game’s voice chat feature on another bidirectional stream, and the player’s controls are transmitted on a unidirectional stream. With WebSockets, these would all need to be put on separate connections or squashed into a single stream. With WebTransport, you can keep all the traffic on one connection but separate them into their own streams and, if one stream were to drop network packets, the others could continue uninterrupted.

Enabling WebTransport

You can enable WebTransport support in ASP.NET Core by setting EnablePreviewFeatures to True and adding the following RuntimeHostConfigurationOption item in your project’s .csproj file:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <EnablePreviewFeatures>True</EnablePreviewFeatures>
  </PropertyGroup>

  <ItemGroup>
    <RuntimeHostConfigurationOption Include="Microsoft.AspNetCore.Server.Kestrel.Experimental.WebTransportAndH3Datagrams" Value="true" />
  </ItemGroup>
</Project>

Setting up a Server

To setup a WebTransport connection, you first need to configure a web host to listen on a port over HTTP/3:

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel((context, options) =>
{
    // Port configured for WebTransport
    options.ListenAnyIP([SOME PORT], listenOptions =>
    {
        listenOptions.UseHttps(GenerateManualCertificate());
        listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
    });
});
var app = builder.Build();

WebTransport uses HTTP/3, so you must select the listenOptions.UseHttps setting as well as set the listenOptions.Protocols to include HTTP/3.

The default Kestrel development certificate cannot be used for WebTransport connections. For local testing you can use the workaround described in the Obtaining a test certificate section.

Next, we define the code that will run when Kestrel receives a connection.

app.Run(async (context) =>
{
    var feature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>();
    if (!feature.IsWebTransportRequest)
    {
        return;
    }
    var session = await feature.AcceptAsync(CancellationToken.None);

    // Use WebTransport via the newly established session.
});

await app.RunAsync();

The Run method is triggered every time there is a connection request. The IsWebTransportRequest property on the IHttpWebTransportFeature indicates if the current request is a WebTransport request. Once a WebTransport request is received, calling IHttpWebTransportFeature.AcceptAsync() accepts the WebTransport session so you can interact with the client. This original request must be kept alive for the durration of the session or else all streams associated with that session will be closed.

Calling await app.RunAsync() starts the server, which can then start accepting connections.

Interacting with a WebTransport Session

Once the session has been established both the client and server can create streams for that session.

This example accepts a session, waits for a bidirectional stream, reads some data, reverse it, and then writes it back to the stream.

app.Run(async (context) =>
{
    var feature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>();
    if (!feature.IsWebTransportRequest)
    {
        return;
    }
    var session = await feature.AcceptAsync(CancellationToken.None);

    ConnectionContext? stream = null;
    IStreamDirectionFeature? direction = null;
    while (true)
    {
        // wait until we get a stream
        stream = await session.AcceptStreamAsync(CancellationToken.None);
        if (stream is null)
        {
            // if a stream is null, this means that the session failed to get the next one.
            // Thus, the session has ended, or some other issue has occurred. We end the
            // connection in this case.
            return;
        }

        // check that the stream is bidirectional. If yes, keep going, otherwise
        // dispose its resources and keep waiting.
        direction = stream.Features.GetRequiredFeature<IStreamDirectionFeature>();
        if (direction.CanRead && direction.CanWrite)
        {
            break;
        }
        else
        {
            await stream.DisposeAsync();
        }
    }

    var inputPipe = stream!.Transport.Input;
    var outputPipe = stream!.Transport.Output;

    // read some data from the stream into the memory
    var length = await inputPipe.AsStream().ReadAsync(memory);

    // slice to only keep the relevant parts of the memory
    var outputMemory = memory[..length];

    // do some operations on the contents of the data
    outputMemory.Span.Reverse();

    // write back the data to the stream
    await outputPipe.WriteAsync(outputMemory);
});

await app.RunAsync();

This example opens a new stream from the server side and then sends data.

app.Run(async (context) =>
{
    var feature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>();
    if (!feature.IsWebTransportRequest)
    {
        return;
    }
    var session = await feature.AcceptAsync(CancellationToken.None);

    // open a new stream from the server to the client
    var stream = await session.OpenUnidirectionalStreamAsync(CancellationToken.None);

    if (stream is not null)
    {
        // write data to the stream
        var outputPipe = stream.Transport.Output;
        await outputPipe.WriteAsync(new Memory<byte>(new byte[] { 65, 66, 67, 68, 69 }), CancellationToken.None);
        await outputPipe.FlushAsync(CancellationToken.None);
    }
});

await app.RunAsync();

Sample Apps

To showcase the functionality of the WebTransport support in Kestrel, we also created two sample apps: an interactive one and a console-based one. You can run them in Visual Studio via the F5 run options.

Obtaining a test certificate

The current Kestrel development certificate cannot be used for WebTransport connections as it does not meet the requirements needed for WebTransport over HTTP/3. You can generate a new certificate for testing via the following C# (this function will also automatically handle certificate rotation every time one expires):

static X509Certificate2 GenerateManualCertificate()
{
    X509Certificate2 cert = null;
    var store = new X509Store("KestrelWebTransportCertificates", StoreLocation.CurrentUser);
    store.Open(OpenFlags.ReadWrite);
    if (store.Certificates.Count > 0)
    {
        cert = store.Certificates[^1];

        // rotate key after it expires
        if (DateTime.Parse(cert.GetExpirationDateString(), null) < DateTimeOffset.UtcNow)
        {
            cert = null;
        }
    }
    if (cert == null)
    {
        // generate a new cert
        var now = DateTimeOffset.UtcNow;
        SubjectAlternativeNameBuilder sanBuilder = new();
        sanBuilder.AddDnsName("localhost");
        using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        CertificateRequest req = new("CN=localhost", ec, HashAlgorithmName.SHA256);
        // Adds purpose
        req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection
        {
            new("1.3.6.1.5.5.7.3.1") // serverAuth
        }, false));
        // Adds usage
        req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
        // Adds subject alternate names
        req.CertificateExtensions.Add(sanBuilder.Build());
        // Sign
        using var crt = req.CreateSelfSigned(now, now.AddDays(14)); // 14 days is the max duration of a certificate for this
        cert = new(crt.Export(X509ContentType.Pfx));

        // Save
        store.Add(cert);
    }
    store.Close();

    var hash = SHA256.HashData(cert.RawData);
    var certStr = Convert.ToBase64String(hash);
    Console.WriteLine($"\n\n\n\n\nCertificate: {certStr}\n\n\n\n"); // <-- you will need to put this output into the JS API call to allow the connection
    return cert;
}
// Adapted from: https://github.com/wegylexy/webtransport

Going Forward

WebTransport offers exciting new possibilities for real-time app developers, making it easier and more efficient to stream multiple kinds of data back and forth with the client.

Please send us feedback about how this works for your scenarios. We’ll use that feedback to continue the development of this feature in .NET 8, explore integrations with frameworks like SignalR and determine when to take it out of preview.

Author

Chris R
Principal Software Engineer

I've been a Washington local since middle school. I worked IT support from middle school through college. I've worked on .NET at Microsoft since college, starting with .NET 4.0 and System.Net, then ASP.NET, Microsoft.Owin, ASP.NET Core, and YARP. Outside of work I have a family that likes to play all sorts of games and puzzles.

10 comments

Discussion is closed. Login to edit/delete existing comments.

  • Þorgeir Auðunn Karlsson

    Can this be used to enable a single signalR socket connection for multiple hub connections?

    • Chris RMicrosoft employee Author

      It will still be a single connection to a single hub.

      In theory, the .NET client should be able to share the connection if you pass the same HttpMessageHandler around (assuming it’s similar to the WebSockets over HTTP/2 support).

      And the browser would ideally share the connection automatically.

      But the user would still need to create a separate HubConnection per SignalR connection, even if it uses the same socket underneath.

      Read more
  • Scott Kane

    Am I right in thinking that if this requires HTTP3 that it can’t be used from Blazor WASM? Bidirectional streaming would be great to have in the browser but without SignalR it’s not something we’ve been able to do

    • Chris RMicrosoft employee Author

      I don’t see why not, though you’d need a fallback plan when the client or network doesn’t support HTTP/3.

  • Felipe PessotoMicrosoft employee

    You mentioned an example of using WebTransport for voice, I’m wondering if it would allow to keep UDP behavior, and allow to bypass any retransmission handled by http3 layer.

  • Joel Mandell

    Will this make it possibly to use SignInManager in a Blazor component?
    So we can get rid of this error in future? 🥳

    SignInManager.SignInAsync(.......), it throws:
    
    System.InvalidOperationException: The response headers cannot be modified because the response has already started.
    • Chris RMicrosoft employee Author

      Not directly 😁

      • Joel Mandell

        But will it be possible to implement SignInManager and it’s extension methods to work on the HttpContext for SignIn? And use it in a Blazor component, by using WebTransport?

      • Chris RMicrosoft employee Author

        I don’t think WebTransport will change that situation.