MaxLib.WebServer is a full web server written in C#. To use this webserver you only need to add
the .NuGet package to your project, instantiate the server in your code and thats it. No special
configuration files or multiple processes.
This web server is build modular. Any modules can be replaced by you and you decide what the server is capable of. The server can fully configured (including exchanging the modules) during runtime - no need to restart everything.
Some of the current features of the web server are:
- HTTP web server (of course, that's the purpose of the project)
- HTTPS web server with SSL certificates
- HTTP and HTTPS web server on the same port. The server detects automatically what the user intends to use.
- Asynchronous handling of requests. Every part of the pipeline works with awaitable Tasks.
- REST Api builder. You can directly bind your methods to the handlers.
- Chunked transport. The server understands chunked data streams and can produce these on responses. Chunked request bodies are not supported yet.
- Lazy handling of requests. The server allows you to produce the content while you are sending the response. No need to wait.
- Deliver contents from your local drive (e.g. HDD)
- Session keeping. You can identify the user later.
- ...
This will add MaxLib.WebServer to your project and create a basic server with basic functions.
Add the MaxLib.WebServer package.
dotnet add package MaxLib.WebServerWrite somewhere in your code (e.g.) in your Main method of your programm the following snippet:
using MaxLib.WebServer;
using MaxLib.WebServer.Builder; // this using is used later in this tutorial
// ...
using var server = new Server(new WebServerSettings(
8000, // this will run the server on port 8000
5000 // set the timout to 5 seconds.
));
// now add some services. You can use your own implementations but here we
// will add a basic set of services from MaxLib.WebServer.Services.
server.InitDefault();
// the server can now be started. A basic set of services is defined so a new
// request will be handled and the user gets a response. Right now its a
// 404 NOT FOUND but we will add more.
server.Start();
// if you want to stop the server but don't release any of its resources you can do it with:
server.Stop();Right now you can start the server and open the url http://localhost:8000 in your browser and will get a nice 404 response.
Now we will create our own service, that will responds with a beautiful "Hello World" message.
Create a new class MyServices and put this code in it:
using System;
using System.Threading.Tasks;
using MaxLib.WebServer;
using MaxLib.WebServer.Builder;
// every service that uses the included Builder system needs to be derived from Service
public class MyServices : Service
{
// this method should listen on /hello and return a nice hello
[Path("/hello")]
public string Hello()
{
return "<html><head><title>Hello World</title></head>" +
"<body><h1>Hello World!</h1></body></html>";
}
}Now you need to add this line to add your service to server:
server.AddWebService(Service.Build<MyServices>()!);After that you can run your programm and open the page http://localhost:8000/hello. You will see your hello world message.
More information about the new builder system can be found here
MaxLib.WebServer logs exclusively through the standard
Microsoft.Extensions.Logging.ILogger
abstraction. There is no MaxLib-specific logging API to learn: point it at whatever
logging backend your application already uses (Serilog, NLog, log4net, the built-in
console/debug providers, ...) via that backend's own standard ILoggerFactory setup,
and every log call made inside this library flows into it.
The one-line contract: call
WebServerLog.SetLoggerFactory(...)exactly once, before constructing anything else from this library. Each class resolves its logger once, at first use, so a factory set afterwards will not be picked up by classes that already ran. A second call toSetLoggerFactorythrowsInvalidOperationExceptionrather than silently changing anything.
using Microsoft.Extensions.Logging;
// Wire this library's logging to whatever backend you already use - here plain
// console output, but AddSerilog(), AddNLog(), AddDebug(), etc. work the same way.
WebServerLog.SetLoggerFactory(LoggerFactory.Create(builder => builder.AddSimpleConsole()));
using var server = new Server(new WebServerSettings(8000, 5000));
// ...MaxLib.WebServer.Sessions identifies returning users via a Session cookie, without you
having to manage that cookie yourself. Add a session service - MemorySessionService for a
simple in-process store, or subclass SessionServiceBase for your own backing store - and read
or write per-user data through task.Session:
using MaxLib.WebServer.Sessions;
server.AddWebService(new MemorySessionService());// inside any WebService that runs after the session service (ServerStage.ParseRequest or later)
var session = task.Session!;
var visits = session.TryGetValue("visits", out var v) ? (int)v! : 0;
session["visits"] = visits + 1;The service issues a fresh session id (and its cookie) the first time a client is seen, and reuses it on every later request that presents that same cookie back.
Hardening: call RotateSessionKey(task) right after any change in privilege - most
importantly, right after a successful login - so that a session id an attacker may have set on
the client beforehand (before the user authenticated) becomes worthless afterwards. The session
cookie itself already defaults to HttpOnly, and to Secure/SameSite=Strict whenever this
server observes the connection as encrypted (SameSite=Lax and no Secure otherwise) - if a
reverse proxy (e.g. nginx) terminates TLS in front of this server instead, that auto-detection
never sees an HTTPS connection, so set CookieSecurity = CookieSecurityMode.Strict on your
session service explicitly to still get the stricter, HTTPS-only cookie attributes.
Expiry: MaxAge (default 30 days) already governs the cookie's Expires/Max-Age
attributes; MemorySessionService also enforces it server-side. A session that's expired by
the time it's next looked up is discarded and replaced with a fresh, empty one instead of being
served - so a session id leaked long ago stops giving access to whatever data it used to hold.
This alone doesn't stop Sessions from otherwise growing unboundedly (e.g. from many distinct
or absent cookie values), so call Sweep() periodically to evict every session that's expired
but hasn't been looked up since, or call StartAutomaticSweep() once to have the service run it
for you on its own background timer (stop it with StopAutomaticSweep(), also done by
Dispose()):
var sessions = new MemorySessionService();
sessions.StartAutomaticSweep();
server.AddWebService(sessions);Most HttpDataSource implementations know their length up front, and HttpResponseCreator
computes Content-Length from that. A source that doesn't (e.g. Chunked.HttpChunkedStream,
wrapping a stream whose size isn't known ahead of time) reports Length() == null instead - if
HttpResponseCreator computed Content-Length from that the same way, it would silently
undercount it while still writing the full body, desyncing the connection for any client or
proxy relying on that header for framing.
To actually send such a response correctly, register Chunked.ChunkedResponseCreator and
Chunked.ChunkedSender alongside the default services - typically with onlyWithLazy: true, so
they only take over responses that need it (unknown-length or lazy data sources) and leave
ordinary responses to the default Content-Length path:
using MaxLib.WebServer.Chunked;
server.AddWebService(new ChunkedResponseCreator(onlyWithLazy: true));
server.AddWebService(new ChunkedSender(onlyWithLazy: true));Without a ChunkedSender registered, a response containing an unknown-length data source is
rejected with 500 Internal Server Error (logged) instead of being sent with a wrong
Content-Length.
task.Request.Post.Data (or the awaitable DataAsync) gives you a parsed IPostData for the
request body, chosen by Content-Type:
application/x-www-form-urlencoded→Post.UrlEncodedData(.Parameter, aDictionary<string, string>). A body larger thanUrlEncodedData.MaximumCacheSize(default 50 MB) is parsed into.Overflowinstead - aPost.MultipartFormDatawith one entry per key, individually eligible for the same in-memory-vs-temp-file decision a multipart part gets - soParameteris left empty in that case.multipart/form-data→Post.MultipartFormData(.Entries, a list of parts - each with.Contentor, once uploaded files get large enough, a.TempFileinstead)application/json,application/octet-stream, anything else unrecognized, or a missingContent-Typeentirely →Post.RawPostData, storing the whole body as one nameless entry (.Entry.Contentor.Entry.TempFile, on the same size threshold as a multipart part)
Every one of these actually reads and stores the body - none of them leave it as an unread
reference to the live connection. If you're building a Builder endpoint (see "Create own
service" above), [TextPost] binds a string parameter to a RawPostData body decoded as UTF-8,
without you having to touch Post.Data yourself:
[Path("/echo"), Method("POST")]
public string Echo([TextPost] string body) => body;HttpRequestParser bounds both how large a request body it accepts and how long it waits for
one to arrive, so that an unbounded Content-Length combined with a client that stalls
mid-upload can't pin a thread-pool thread forever (draining an unconsumed body on dispose is
fully asynchronous - see HttpPost.DisposeAsync() - and bounded by the same timeout). It also
bounds how long a client is given to actually send its request in the first place:
-
MaxConnectionDelay(default 5 s) is how long the parser waits for the very first byte of a request to arrive before giving up. Set it to zero or negative to disable this wait. -
MaxHeaderReadTime(default 10 s) bounds the rest of the request-line-and-header phase as a whole, once that first byte has arrived - not a per-line timeout. Without it, a client that trickles its request one byte (or one header line) at a time - the classic "Slowloris" attack - could hold the connection open indefinitely just by satisfyingMaxConnectionDelay's wait first. Set it to zero or negative to disable it. -
MaxContentLength(default 100 MB) is the body size every request is accepted under without further checks. A request whoseContent-Lengthexceeds it is rejected with413 Request Entity Too Large- unless you setContentLengthLimitExceeded. -
ContentLengthLimitExceededis an optional callback invoked with the current request and its declaredContent-LengthwheneverMaxContentLengthis exceeded. Return a higher limit to apply for just that request (e.g. based on the route or authenticated user), ornullto allow any size. If the callback isn't set, or the limit it returns is still exceeded, the request is rejected the same way. -
ContentReadBaseTimeout(default 5 s) andMinimumContentTransferRate(default 16 000 byte/s) together bound how long a request's body is given to arrive: the full timeout isContentReadBaseTimeout + Content-Length / MinimumContentTransferRate, so larger (but still accepted) bodies get proportionally more time, while a stalled client is still bounded rather than blocking the reader indefinitely. Unlike the size limit, this always applies uniformly - there is no per-request override.
Whenever a request is rejected for being too large, the connection is closed instead of kept
alive, since the client's bytes are left completely unread. A body read that instead times out
mid-request never closes the connection by itself - that could pre-empt a response the server was
about to send on it, e.g. one reporting that very timeout - it only ever does once the response
has actually been transmitted and any leftover POST data is being discarded to ready the
connection for reuse (in HttpSender, right after sending); if that final drain times out, the
connection is closed there instead of kept alive, since its position in the byte stream is by then
unknown.
WebServerSettings.MaxConcurrentConnections bounds how many connections (Server.AllConnections)
the server accepts at once - a new connection beyond that limit is rejected outright (its socket
closed immediately, without being processed at all) instead of being left to compete for
thread-pool/memory resources indefinitely alongside every other admitted connection. Defaults to
-1 (no limit, the previous behavior); applies to both the plain and TLS-terminating listeners
(SecureWebServer, DualSecureWebServer), since they share the same connection bookkeeping.
using var server = new Server(new WebServerSettings(8000, 5000)
{
MaxConcurrentConnections = 1000,
});MaxLib.WebServer.WebSocket includes EventBase/EventFactory, a small typed-message
layer on top of raw WebSocket frames. Events are plain classes, and
System.Text.Json's native polymorphic serialization takes care of reading/writing
the "$type" discriminator and the rest of the payload - no custom converters needed.
Subclass EventBase with public properties, create an EventFactory, and register
your event types before using it:
using MaxLib.WebServer.WebSocket;
public class ChatMessage : EventBase
{
public string Text { get; set; } = "";
}
var factory = new EventFactory();
factory.Add<ChatMessage>(); // wire "$type" defaults to the class name
// factory.Add<ChatMessage>("chat.msg"); // or pick an explicit wire name
// pass `factory` into your EventConnection subclass, then:
// await SendFrame(new ChatMessage { Text = "hi" });All event types must be registered before the factory's first use (its registry is sealed on first serialize/deserialize) - register everything once at startup.
There is no attribute-scanning or assembly discovery: declaring an EventBase
subclass alone does not register it. Every event type must be added explicitly via
Add<T>()/Add<T>(string)/Add(string, Type).
Use standard System.Text.Json attributes directly on your event's properties:
public class ChatMessage : EventBase
{
[JsonPropertyName("text")]
public string Text { get; set; } = "";
[JsonIgnore]
public DateTime ReceivedAt { get; set; }
}For settings that should apply to every event on a factory (a naming policy, a
converter for a shared type), pass a JsonSerializerOptions into the constructor:
var factory = new EventFactory(new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
});See Microsoft's System.Text.Json property customization documentation for the full set of supported attributes and options.
Each EventFactory instance has its own independent type registry and
JsonSerializerOptions - nothing is shared statically between instances. Create one
factory per protocol/endpoint that needs a different set of event types (or different
wire settings), and give each EventConnection subclass the factory instance
appropriate to it.
A client can send a frame that isn't a valid event: malformed JSON, an
unregistered "$type", or JSON that doesn't match the shape of the type it
names. EventFactory.Parse(Frame) reports these as one of three exception types
(MalformedEventJsonException, UnknownEventTypeException,
InvalidEventPayloadException, all deriving from EventParseException), and
EventConnection routes each to its own overridable handler instead of dropping
the frame silently:
public class Connection : EventConnection
{
// ...
protected override Task ReceivedUnknownEvent(Frame frame, UnknownEventTypeException exception)
{
// e.g. tell the client what went wrong instead of just logging it
return SendFrame(new ErrorEvent { Message = exception.Message });
}
}The default implementation of each handler (ReceivedMalformedEvent,
ReceivedUnknownEvent, ReceivedInvalidEventPayload) just logs the error, so
overriding only the ones you care about is safe - the rest keep their previous
behavior.
- example/MaxLib.WebServer.Example
- create a basic webserver
- example/MaxLib.WebServer.Builder.Debugger.Example
- a mix of working and deliberately misconfigured
Builder.Servicetypes, an index page linking to every endpoint, and theBuilder.Debugger.DebuggerServicewired up to explain what got built (or skipped/failed) and why a given request would or wouldn't reach a service
- a mix of working and deliberately misconfigured
Please read CONTRIBUTING.md for details on our code of conduct, and the process for submitting pull requests to us.
We use SemVer for versioning. For the versions available, see the tags on this repository.
- Max Brauer - Initial work - Garados007
See also the list of contributors who participated in this project.
This project is licensed under the MIT License - see the LICENSE.md file for details
- StackOverflow for the help
- Wikipedia, SelfHTML and Mozilla for their documentation
- PurpleBooth for her README.md template
This project was a free time project of mine and I have done it because why not. The source code was a long time a part of MaxLib (a collection of other fun projects and code) but got its own repository for better maintenance.
Some of the documentation inside the code is still in German and other things needs to be optimized.
I have used this for some projects with my friends. It can handle some TB of traffic over a long period without any problems or crashes. I am a little proud of this.
I will further maintain and extend this library if I have new cool features in mind (this was the case for the full WebSocket support and easy Builder System).