Friday, December 11, 2020

Simple bare bones http server using Kestrel similar to HttpListener

All the examples I found for using the ASP.NET Core Kestrel web server implementation had other features in it. While Visual Studio's templates to get up and running with ASP.NET Core are great and make sense for most developers, I wanted to create a more basic application that just uses the Kestrel server implementation. No MVC, no Razor pages, no IHostBuilder, just a method where you process the http requests, like an HttpListener application.

Surprisingly, I found it difficult to find an example like this on the web, so I created one and put it on github https://github.com/mark-cordell/bare-bones-kestrel-server/

Here's how it works:

The project has <OutputType>Exe</OutputType> just like a normal console application and in Main() it has

  var kestrelServer = new KestrelServer(...);
  kestrelServer.StartAsync(...);

You configure how you want it to listen on the IPs and ports you want. For SSL, you can do

  kestrelServer.Options.Listen(ip, port, 
    (ListenOptions listenOptions) => listenOptions.UseHttps(X509Certificate2));

You also specify a class that implements IHttpApplication. In the ProcessRequestAsync method, do whatever processing you want and send back some http headers and response body.

Note: if you don't specify the Content-Length header before sending back the response body, Kestrel uses Transfer-Encoding: chunked. This is different from what happens with HttpListener / HTTP.SYS, which in many cases will compute the Content-Length header for you.

In my example, I used <Project Sdk="Microsoft.NET.Sdk.Web"> rather than <Project Sdk="Microsoft.NET.Sdk"> in the csproj so I wouldn't have to deal with adding all the package references myself. This works fine, except it forces you to have a launchSettings.json file, and by default Visual Studio tries to launch your app using IIS Express when you do Ctrl+F5, but you can override this and make it launch as a normal console app.

Here's the full code

using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace BareBonesKestrel
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var loggerFactory = new NoOpLoggerFactory();
            var kestrelServer = new KestrelServer(new ConfigureKestrelServerOptions(), new SocketTransportFactory(new ConfigureSocketTransportOptions(), loggerFactory), loggerFactory);
            kestrelServer.Options.ListenLocalhost(8080);
            kestrelServer.StartAsync(new HttpApp(), CancellationToken.None).GetAwaiter().GetResult();

            // todo: you can update this code to do some other processing on this thread, and you can remove the call to StopAsync() if you don't need it.
            Thread.Sleep(60000);
            Console.WriteLine("shutting down");
            kestrelServer.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
        }

        private class HttpApp : IHttpApplication&lt;IFeatureCollection&gt;
        {
            private readonly byte[] helloWorldBytes = Encoding.UTF8.GetBytes("hello world");

            public IFeatureCollection CreateContext(IFeatureCollection contextFeatures)
            {
                return contextFeatures;
            }

            public void DisposeContext(IFeatureCollection context, Exception exception)
            {
            }

            public async Task ProcessRequestAsync(IFeatureCollection features)
            {
                var request = (IHttpRequestFeature)features[typeof(IHttpRequestFeature)];
                var response = (IHttpResponseFeature)features[typeof(IHttpResponseFeature)];
                var responseBody = (IHttpResponseBodyFeature)features[typeof(IHttpResponseBodyFeature)];
                ////var connection = (IHttpConnectionFeature)features[typeof(IHttpConnectionFeature)];
                response.Headers.ContentLength = this.helloWorldBytes.Length;
                response.Headers.Add("Content-Type", "text/plain");
                await responseBody.Stream.WriteAsync(this.helloWorldBytes, 0, this.helloWorldBytes.Length);
            }
        }

        private sealed class NoOpLogger : ILogger, IDisposable
        {
            public IDisposable BeginScope&lt;TState&gt;(TState state)
            {
                return this;
            }

            public bool IsEnabled(LogLevel logLevel)
            {
                return false;
            }

            public void Log&lt;TState&gt;(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func&lt;TState, Exception, string&gt; formatter)
            {
            }

            public void Dispose()
            {
            }
        }

        private sealed class NoOpLoggerFactory : ILoggerFactory
        {
            private readonly ILogger logger = new NoOpLogger();

            public void Dispose()
            {
            }

            public void AddProvider(ILoggerProvider provider)
            {
            }

            public ILogger CreateLogger(string categoryName)
            {
                return this.logger;
            }
        }

        private sealed class ConfigureKestrelServerOptions : IOptions&lt;KestrelServerOptions&gt;
        {
            public ConfigureKestrelServerOptions()
            {
                this.Value = new KestrelServerOptions()
                {
                };
            }

            public KestrelServerOptions Value { get; }
        }

        public sealed class ConfigureSocketTransportOptions : IOptions&lt;SocketTransportOptions&gt;
        {
            public ConfigureSocketTransportOptions()
            {
                this.Value = new SocketTransportOptions()
                {
                };
            }

            public SocketTransportOptions Value { get; }
        }
    }
}