Simple, scalable, fast, async web server for processing RESTful HTTP/HTTPS requests, written in C#.
Package | NuGet Version | Downloads |
---|---|---|
Watson | ||
Watson.Lite | ||
Watson.Core |
Special thanks to @DamienDennehy for allowing us the use of the Watson.Core
package name in NuGet!
This project is part of the .NET Foundation along with other projects like the .NET Runtime.
I’d like to extend a special thanks to those that have helped make Watson Webserver better.
Watson is a webserver that operates on top of the underlying http.sys
within the operating system. Watson.Lite was created by merging HttpServerLite. Watson.Lite does not have a dependency on http.sys
, and is implemented using a TCP implementation provided by CavemanTcp.
The dependency on http.sys
(or lack thereof) creates subtle differences between the two libraries, however, the configuration and management of each should be consistent.
Watson.Lite is generally less performant than Watson, because the HTTP implementation is in user space.
127.0.0.1
or localhost
X509Certificate2
must be suppliedWatson and Watson.Lite always routes in the following order (configure using Webserver.Routes
):
.Preflight
- handling preflight requests (generally with HTTP method OPTIONS
).PreRouting
- always invoked before any routing determination is made.PreAuthentication
- a routing group, comprised of:
.Static
- static routes, e.g. an HTTP method and an explicit URL.Content
- file serving routes, e.g. a directory where files can be read.Parameter
- routes where variables are specified in the path, e.g. /user/{id}
.Dynamic
- routes where the URL is defined by a regular expression.AuthenticateRequest
- demarcation route between unauthenticated and authenticated routes.PostAuthentication
- a routing group with a structure identical to .PreAuthentication
.Default
- the default route; all requests go here if not routed previously.PostRouting
- always invoked, generally for logging and telemetryIf you do not wish to use authentication, you should map your routes in the .PreAuthentication
routing group (though technically they can be placed in .PostAuthentication
or .Default
assuming the AuthenticateRequest
method is null.
As a general rule, never try to send data to an HttpResponse
while in the .PostRouting
route. If a response has already been sent, the attempt inside of .PostRouting
will fail.
It is recommended that you implement authentication in .AuthenticateRequest
. Should a request fail authentication, return a response within that route. The HttpContextBase
class has properties that can hold authentication-related or session-related metadata, specifically, .Metadata
.
By default, Watson and Watson.Lite will permit all inbound connections.
Server.AccessControl.DenyList.Add(ip, netmask)
Server.AccessControl.Mode = AccessControlMode.DefaultDeny
Server.AccessControl.PermitList.Add(ip, netmask)
Refer to Test.Default
for a full example.
using System.IO;
using System.Text;
using WatsonWebserver;
static void Main(string[] args)
{
WebserverSettings settings = new WebserverSettings("127.0.0.1", 9000);
WebserverBase server = new Webserver(settings, DefaultRoute);
server.Start();
Console.ReadLine();
}
static async Task DefaultRoute(HttpContextBase ctx) =>
await ctx.Response.Send("Hello from the default route!");
Then, open your browser to http://127.0.0.1:9000/
.
Refer to Test.Routing
for a full example.
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using WatsonWebserver;
static void Main(string[] args)
{
WebserverSettings settings = new WebserverSettings("127.0.0.1", 9000);
WebserverBase server = new Webserver(settings, DefaultRoute);
// add content routes
server.Routes.PreAuthentication.Content.Add("/html/", true);
server.Routes.PreAuthentication.Content.Add("/img/watson.jpg", false);
// add static routes
server.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/hello/", GetHelloRoute);
server.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/howdy/", async (HttpContextBase ctx) =>
{
await ctx.Response.Send("Hello from the GET /howdy static route!");
return;
});
// add parameter routes
server.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/{version}/bar", GetBarRoute);
// add dynamic routes
server.Routes.PreAuthentication.Dynamic.Add(HttpMethod.GET, new Regex("^/foo/\\d+$"), GetFooWithId);
server.Routes.PreAuthentication.Dynamic.Add(HttpMethod.GET, new Regex("^/foo/?$"), GetFoo);
// start the server
server.Start();
Console.WriteLine("Press ENTER to exit");
Console.ReadLine();
}
static async Task GetHelloRoute(HttpContextBase ctx) =>
await ctx.Response.Send("Hello from the GET /hello static route!");
static async Task GetBarRoute(HttpContextBase ctx) =>
await ctx.Response.Send("Hello from the GET /" + ctx.Request.Url.Parameters["version"] + "/bar route!");
static async Task GetFooWithId(HttpContextBase ctx) =>
await ctx.Response.Send("Hello from the GET /foo/[id] dynamic route!");
static async Task GetFoo(HttpContextBase ctx) =>
await ctx.Response.Send("Hello from the GET /foo/ dynamic route!");
static async Task DefaultRoute(HttpContextBase ctx) =>
await ctx.Response.Send("Hello from the default route!");
server.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/hello/", GetHelloRoute, MyExceptionRoute);
static async Task GetHelloRoute(HttpContextBase ctx) => throw new Exception("Whoops!");
static async Task MyExceptionRoute(HttpContextBase ctx, Exception e)
{
ctx.Response.StatusCode = 500;
await ctx.Response.Send(e.Message);
}
Webserver server = new Webserver("127.0.0.1", 9000, false, DefaultRoute);
// set default permit (permit any) with deny list to block specific IP addresses or networks
server.Settings.AccessControl.Mode = AccessControlMode.DefaultPermit;
server.Settings.AccessControl.DenyList.Add("127.0.0.1", "255.255.255.255");
// set default deny (deny all) with permit list to permit specific IP addresses or networks
server.Settings.AccessControl.Mode = AccessControlMode.DefaultDeny;
server.Settings.AccessControl.PermitList.Add("127.0.0.1", "255.255.255.255");
Watson supports both receiving chunked data and sending chunked data (indicated by the header Transfer-Encoding: chunked
).
Refer to Test.ChunkServer
for a sample implementation.
static async Task UploadData(HttpContextBase ctx)
{
if (ctx.Request.ChunkedTransfer)
{
bool finalChunk = false;
while (!finalChunk)
{
Chunk chunk = await ctx.Request.ReadChunk();
// work with chunk.Length and chunk.Data (byte[])
finalChunk = chunk.IsFinalChunk;
}
}
else
{
// read from ctx.Request.Data stream
}
}
static async Task DownloadChunkedFile(HttpContextBase ctx)
{
using (FileStream fs = new FileStream("./img/watson.jpg", , FileMode.Open, FileAccess.Read))
{
ctx.Response.StatusCode = 200;
ctx.Response.ChunkedTransfer = true;
byte[] buffer = new byte[4096];
while (true)
{
int bytesRead = await fs.ReadAsync(buffer, 0, buffer.Length);
byte[] data = new byte[bytesRead];
Buffer.BlockCopy(buffer, 0, bytesRead, data, 0); // only copy the read data
if (bytesRead > 0)
{
await ctx.Response.SendChunk(data, false);
}
else
{
await ctx.Response.SendChunk(Array.Empty<byte>(), true);
break;
}
}
}
return;
}
Watson supports sending server-sent events. Refer to Test.ServerSentEvents
for a sample implementation. The SendEvent
method handles formatting ServerSentEvent
objects for transmission to the client.
static async Task SendEvents(HttpContextBase ctx)
{
ctx.Response.StatusCode = 200;
ctx.Response.ServerSentEvents = true;
for (int i = 1; i <= 10; i++)
{
ServerSentEvent ev = new ServerSentEvent
{
Id = i.ToString(),
Event = "my-event-type",
Data = $"Event number {i.ToString()}",
};
bool isFinal = (i == 10);
await ctx.Response.SendEvent(ev, isFinal);
}
return;
}
HostBuilder
helps you set up your server much more easily by introducing a chain of settings and routes instead of using the server class directly. Special thanks to @sapurtcomputer30 for producing this fine feature!
Refer to Test.HostBuilder
for a full sample implementation.
using WatsonWebserver.Extensions.HostBuilderExtension;
Webserver server = new HostBuilder("127.0.0.1", 8000, false, DefaultRoute)
.MapStaticRoute(HttpMethod.GET, GetUrlsRoute, "/links")
.MapStaticRoute(HttpMethod.POST, CheckLoginRoute, "/login")
.MapStaticRoute(HttpMethod.POST, TestRoute, "/test")
.Build();
server.Start();
Console.WriteLine("Server started");
Console.ReadKey();
static async Task DefaultRoute(HttpContextBase ctx) =>
await ctx.Response.Send("Hello from default route!");
static async Task GetUrlsRoute(HttpContextBase ctx) =>
await ctx.Response.Send("Here are your links!");
static async Task CheckLoginRoute(HttpContextBase ctx) =>
await ctx.Response.Send("Checking your login!");
static async Task TestRoute(HttpContextBase ctx) =>
await ctx.Response.Send("Hello from the test route!");
To correctly handle the Host
HTTP header, a new boolean property, UseMachineHostname
, has been introduced in WebserverSettings
. This is especially important when using wildcard bindings.
Wildcard Binding Behavior: When Hostname
is set to *
or +
, the server will mandatorily use the machine’s actual hostname for the Host
header in HTTP responses. This prevents UriFormatException
on modern .NET runtimes. In this scenario, UseMachineHostname
is forced to true
.
Default Behavior: For any other hostname (e.g., localhost
or an IP address), this feature is disabled by default. The Host
header will use the value specified in the Hostname
setting.
Manual Activation: You can force the use of the machine’s hostname for any binding by setting UseMachineHostname = true
in the settings.
Example 1: Using a Wildcard (Mandatory Machine Hostname)
// The server detects the wildcard and mandatorily uses the machine's hostname for the Host header.
var server = new Server("*", 9000, false, DefaultRoute);
server.Start();
Example 2: Manually Enabling for a Specific Hostname
// By default, the Host header would be "localhost:9000".
// By setting UseMachineHostname = true, we force it to use the machine's actual hostname.
var settings = new WebserverSettings("localhost", 9000);
settings.UseMachineHostname = true;
var server = new Server(settings, DefaultRoute);
server.Start();
When UseMachineHostname
is active, the retrieved hostname will vary depending on the operating system and network configuration. Here are some typical examples (after sanitization):
desktop-a1b2c3d
marcos-macbook-pro.local
ubuntu-server
pixel-7-pro
marcos-iphone.local
When you configure Watson to listen on 127.0.0.1
or localhost
, it will only respond to requests received from within the local machine.
To configure access from other nodes outside of localhost
, use the following:
*
or +
. You MUST run as administrator (operating system limitation)netsh
command:
netsh http show urlacl
netsh http add urlacl url=http://[hostname]:[port]/ user=everyone listen=yes
hostname
and port
are the values you are using in the constructorWhen you configure Watson.Lite to listen on 127.0.0.1
, it will only respond to requests received from within the local machine.
To configure access from other nodes outside of the local machine, use the following:
*
or +
. You MUST run as administrator (operating system limitation)X509Certificate2
objectPlease refer to the Test.Docker
project and the Docker.md
file therein.
Refer to CHANGELOG.md for version history.