Anka 0.0.1-beta.4

This is a prerelease version of Anka.
dotnet add package Anka --version 0.0.1-beta.4
                    
NuGet\Install-Package Anka -Version 0.0.1-beta.4
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Anka" Version="0.0.1-beta.4" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Anka" Version="0.0.1-beta.4" />
                    
Directory.Packages.props
<PackageReference Include="Anka" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Anka --version 0.0.1-beta.4
                    
#r "nuget: Anka, 0.0.1-beta.4"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Anka@0.0.1-beta.4
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Anka&version=0.0.1-beta.4&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Anka&version=0.0.1-beta.4&prerelease
                    
Install as a Cake Tool

Anka

NuGet NuGet Downloads License: MIT .NET 8+ Cold Start: 2.3ms Startup Alloc: 124.5 KB Status: Beta

⚠️ BETA — Research & Experimentation Only

Anka is in early beta. It is intended solely for testing, research, and experimentation. Do not use Anka in production environments. The API is unstable, security hardening is incomplete, and there are no guarantees around correctness, stability, or backwards compatibility. Production use is strongly discouraged.

Minimal HTTP/1.x server library for .NET 8+, built for Native AOT with a focus on minimising cold-start time and keeping steady-state allocation at zero.


Table of Contents

  1. Installation
  2. Why Anka
  3. Quick Start
  4. Examples
  5. RFC Compliance
  6. Architecture Overview
  7. Class Reference — Public API
  8. Class Reference — Internal
  9. Memory Model
  10. Performance Profile
  11. Project Structure
  12. Test Coverage

Installation

dotnet add package Anka

NuGet NuGet Downloads

Requires .NET 8 SDK or later.

Note: Anka is currently in beta. Use only for research and experimentation — not for production workloads.


Why Anka

Modern .NET applications typically pay 100–300 ms of JIT warmup on every cold start. In serverless and container environments — where instances are spun up on demand — every millisecond of startup latency translates directly into cost and tail latency for the first caller.

Anka is designed around one idea:

Publish as a Native AOT binary. Accept the first HTTP request in under 25 ms from process launch.

Cold Start — Measured Results

Environment: Apple M3 Max · macOS · .NET 8.0.25 · Native AOT (osx-arm64)
"Time to ready" = time between process start and the socket becoming ready to accept connections.
"First response" = round-trip time of the very first HTTP request, measured from outside the process.

Anka (Native AOT) Kestrel (JIT) Improvement
Time to listen 411 ms ¹ 203 ms
⚡ Time to ready 2.3 ms 140 ms 61× faster
First response 20 ms 26 ms 1.3× faster
Startup allocation 124.5 KB 2.5 MB 20× less
RSS at steady state ~15 MB ~98 MB 6.5× less

¹ Anka creates a fresh Socket; Kestrel reuses existing OS handles, so it binds the port faster. The JIT warmup cost more than compensates: Kestrel needs 140 ms after binding before it can serve — Anka needs 2.3 ms.

What "2.3 ms ready" means in practice:
From the moment the OS hands control to the process, Anka allocates a socket, binds, and starts accepting connections in 2.3 milliseconds. A Kestrel process in the same environment takes ~140 ms to reach the same point due to JIT compilation. In a serverless or autoscaling context this difference is the gap between a cold start that a user notices and one that goes undetected.

To achieve this, Anka makes deliberate trade-offs:

  • No middleware pipeline — a single RequestHandler delegate handles every request
  • No built-in routing — path dispatch is left to user code (a switch expression is enough)
  • HTTP/1.x only — no HTTP/2, no TLS, no WebSocket
  • Raw socketsSocketAsyncEventArgs + pooled 64 KB sliding receive window, no System.IO.Pipelines

If you need the full ASP.NET Core feature set, use Kestrel. If you need a tiny, fast, zero-allocation HTTP listener with near-instant cold starts for a Native AOT binary — Anka is for you.


Quick Start

using Anka;

var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };

var server = new Server(
    handler: async (req, res, ct) =>
    {
        var body = "Hello from Anka!"u8.ToArray();
        await res.WriteAsync(200, body, contentType: "text/plain; charset=utf-8"u8, cancellationToken: ct);
    },
    port: 8080);

await server.StartAsync(cts.Token);

Graceful shutdown with Ctrl+C.

Quick Start with Options

var options = new ServerOptions
{
    MaxRequestBodySize = 1 * 1024 * 1024, // 1 MB
    MaxRequestTargetSize = 8 * 1024,      // 8 KB
    MaxRequestHeadersSize = 8 * 1024,     // 8 KB
    ReadTimeout = TimeSpan.FromSeconds(15),
    DefaultResponseHeaders =
    [
        new HttpHeader("x-content-type-options"u8.ToArray(), "nosniff"u8.ToArray()),
        new HttpHeader("x-frame-options"u8.ToArray(), "DENY"u8.ToArray()),
    ]
};

var server = new Server(
    handler: async (req, res, ct) =>
    {
        await res.WriteAsync(200, "ok"u8.ToArray(), "text/plain; charset=utf-8"u8, cancellationToken: ct);
    },
    port: 8080,
    options: options);

await server.StartAsync(cts.Token);

Examples

Plain Text Response

var server = new Server(
    handler: async (req, res, ct) =>
    {
        var body = "Hello, World!"u8.ToArray();
        await res.WriteAsync(200, body, contentType: "text/plain; charset=utf-8"u8, cancellationToken: ct);
    },
    port: 8080);

await server.StartAsync(cts.Token);

JSON Response

var server = new Server(
    handler: async (req, res, ct) =>
    {
        var json = """{"message":"ok","version":"0.0.1"}"""u8.ToArray();
        await res.WriteAsync(200, json, contentType: "application/json; charset=utf-8"u8, cancellationToken: ct);
    },
    port: 8080);

Reading Path and Query String

var server = new Server(
    handler: async (req, res, ct) =>
    {
        // req.Path   → "/search"
        // req.Query  → "q=anka&limit=10"
        var path = req.Path.ToString();
        var query = req.Query.ToString();

        var body = System.Text.Encoding.UTF8.GetBytes($"path={path} query={query}");
        await res.WriteAsync(200, body, contentType: "text/plain; charset=utf-8"u8, cancellationToken: ct);
    },
    port: 8080);

Reading Request Headers

var server = new Server(
    handler: async (req, res, ct) =>
    {
        // Header names are normalised to lowercase
        var ua = req.Headers.TryGetValue("user-agent", out var v) ? v.ToString() : "unknown";

        var body = System.Text.Encoding.UTF8.GetBytes($"User-Agent: {ua}");
        await res.WriteAsync(200, body, contentType: "text/plain; charset=utf-8"u8, cancellationToken: ct);
    },
    port: 8080);

Reading POST Body

var server = new Server(
    handler: async (req, res, ct) =>
    {
        // req.Body contains the full body (read via Content-Length)
        var text = System.Text.Encoding.UTF8.GetString(req.Body.Span);

        var echo = System.Text.Encoding.UTF8.GetBytes($"echo: {text}");
        await res.WriteAsync(200, echo, contentType: "text/plain; charset=utf-8"u8, cancellationToken: ct);
    },
    port: 8080);

Conflicting duplicate Content-Length headers are rejected with 400 Bad Request. Malformed Content-Length framing is also rejected with 400 Bad Request.

Responses to HEAD requests suppress payload bytes on the wire while preserving representation headers. 1xx, 204, and 304 responses omit payload bytes and body-describing headers such as Content-Length and Content-Type.

HTTP/1.1 requests must include a valid Host header. Missing, duplicate, invalid, or absolute-form-mismatched Host values are rejected with 400 Bad Request.

Malformed HTTP version tokens are rejected with 400 Bad Request. Well-formed but unsupported versions such as HTTP/2.0 are rejected with 505 HTTP Version Not Supported.

Repeated headers can be enumerated via HttpHeaders.TryGetAllValues(...). Expect: 100-continue is handled automatically, and chunked request bodies are decoded into req.Body before the handler runs.

Simple Path-Based Routing

var server = new Server(
    handler: async (req, res, ct) =>
    {
        var path = req.Path.ToString();
        var method = req.Method.ToString();

        (int status, byte[] body, ReadOnlyMemory<byte> ct2) = (path, method) switch
        {
            ("/", "GET")     => (200, "Welcome!"u8.ToArray(),           (ReadOnlyMemory<byte>)"text/plain; charset=utf-8"u8.ToArray()),
            ("/ping", "GET") => (200, """{"pong":true}"""u8.ToArray(),  (ReadOnlyMemory<byte>)"application/json; charset=utf-8"u8.ToArray()),
            _                => (404, "Not Found"u8.ToArray(),          (ReadOnlyMemory<byte>)"text/plain; charset=utf-8"u8.ToArray())
        };

        await res.WriteAsync(status, body, contentType: ct2, cancellationToken: ct);
    },
    port: 8080);

Adding Per-Request Response Headers (Fluent API)

var server = new Server(
    handler: async (req, res, ct) =>
    {
        var body = """{"status":"ok"}"""u8.ToArray();

        // Fluent AddHeader chains extra headers onto the response.
        // For zero-allocation hot paths, pre-build a static readonly HttpHeader[] instead.
        await res
            .AddHeader(HttpHeaderNames.AccessControlAllowOrigin, "*"u8)
            .AddHeader(HttpHeaderNames.AccessControlAllowMethods, "GET, POST"u8)
            .WriteAsync(200, body, "application/json; charset=utf-8"u8, keepAlive: true, ct);
    },
    port: 8080);

Redirect

var server = new Server(
    handler: async (req, res, ct) =>
    {
        await res
            .AddHeader(HttpHeaderNames.Location, "/new-path"u8)
            .WriteAsync(301, default, default, keepAlive: false, ct);
    },
    port: 8080);

Default Response Headers (Global Security Headers)

var options = new ServerOptions
{
    DefaultResponseHeaders =
    [
        new HttpHeader("x-content-type-options"u8.ToArray(), "nosniff"u8.ToArray()),
        new HttpHeader("x-frame-options"u8.ToArray(),        "DENY"u8.ToArray()),
        new HttpHeader("x-xss-protection"u8.ToArray(),       "1; mode=block"u8.ToArray()),
    ]
};

var server = new Server(handler, port: 8080, options: options);

Enforcing a Request Body Size Limit

// Requests with a body exceeding 512 KB automatically receive 413 Payload Too Large.
var options = new ServerOptions
{
    MaxRequestBodySize = 512 * 1024,
    MaxRequestTargetSize = 8 * 1024,
    MaxRequestHeadersSize = 8 * 1024
};

var server = new Server(handler, port: 8080, options: options);

Enforcing a Request Header Size Limit

// Requests whose header fields exceed 8 KB, or exceed the built-in
// header-count cap, automatically receive 431 Request Header Fields Too Large.
var options = new ServerOptions
{
    MaxRequestHeadersSize = 8 * 1024
};

var server = new Server(handler, port: 8080, options: options);

RFC Compliance

Anka targets HTTP/1.x and implements the following behaviour from the core HTTP RFCs.

Supported (RFC 7230 — Message Syntax & Routing)

Feature Behaviour Reference
HTTP/1.0 and HTTP/1.1 Both versions parsed and handled §2.6
Request-target forms Origin (/path), absolute (http://host/path), authority (host:port for CONNECT), asterisk (* for OPTIONS) §5.3
Host header validation Required for HTTP/1.1; missing / duplicate / mismatched Host → 400 §5.4
Content-Length Parsed and validated; conflicting duplicates → 400; malformed values → 400; missing for POST/PUT/PATCH → 411 §3.3.2
Transfer-Encoding: chunked Chunk-size parsing (hex), chunk data + CRLF validation, trailer headers, body reassembly into req.Body §4.1
Chunked response encoding Supported via response.GetStream() — returns a Stream that sends Transfer-Encoding: chunked; headers + terminating chunk managed automatically §4.1
Response trailer headers Supported via stream.AddTrailer(header) when using response.GetStream() §4.1.2
Expect: 100-continue Automatic 100 Continue interim response before body read §5.1.1
Connection management HTTP/1.1 keep-alive by default; HTTP/1.0 close by default; Connection: close / keep-alive honoured §6.1, §6.3
Header normalisation Names lowercased on ingestion; repeated headers enumerable via TryGetAllValues(...) §3.2.2
Message body suppression HEAD responses and 304 Not Modified suppress payload bytes while preserving representation headers; 1xx / 204 omit body-describing headers §3.3

Supported (RFC 7231 — Semantics & Content)

Feature Behaviour Reference
Methods GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH §4
Status codes Full reason-phrase mapping for common codes (200, 201, 204, 301, 302, 304, 400, 401, 403, 404, 405, 411, 413, 414, 417, 431, 500, 501, 503, 505) §6

Supported (RFC 3986 — URI Syntax)

Feature Behaviour Reference
Absolute-form parsing Scheme detection (http/https), authority extraction, path + query split §3
Authority validation IPv6 literals ([::1]), IPv4 addresses, domain reg-names, port range 0–65535 §3.2
Host ↔ absolute-form consistency Host header must match the authority in an absolute-form request-target §5.4 (7230)

Error Responses

Anka returns the following automatic error responses before the user handler runs:

Status Condition Behaviour
100 Continue Expect: 100-continue header present Sent before reading the request body
400 Bad Request Malformed request line, unrecognised method, invalid headers (e.g. malformed name, obs-fold), conflicting Content-Length, missing/invalid Host, malformed HTTP version token Connection closed
411 Length Required POST, PUT, or PATCH request missing Content-Length or Transfer-Encoding Connection closed
413 Payload Too Large Body exceeds ServerOptions.MaxRequestBodySize Connection closed
414 URI Too Long Request-target exceeds ServerOptions.MaxRequestTargetSize Connection closed
431 Request Header Fields Too Large Headers exceed ServerOptions.MaxRequestHeadersSize (default 8 KB) or header count > 64 Connection closed
505 HTTP Version Not Supported Well-formed but unsupported version (e.g. HTTP/2.0) Connection closed

Configurable Limits

Limit Default ServerOptions Property Over-limit Response
Request body size Unlimited MaxRequestBodySize 413
Request-target size Unlimited MaxRequestTargetSize 414
Header aggregate size 8 KB MaxRequestHeadersSize 431
Header count 64 — (hard limit) 431
Idle read timeout None ReadTimeout Connection closed silently

Not Supported / Out of Scope

Feature Status
HTTP/2, HTTP/3 Not planned — HTTP/1.x only
TLS / HTTPS Not built-in — terminate TLS at a reverse proxy
WebSocket upgrade Not implemented
Content-Encoding (gzip, deflate, br) Not built-in — decompress in user code
HTTP/0.9 Rejected
                        ┌──────────────────────────────────────────┐
                        │               Server (public)            │
                        │  • Validates port & IP                   │
                        │  • Socket.Listen(backlog: 512)           │
                        │  • Accept loop → Connection.RunAsync()   │
                        └────────────────┬─────────────────────────┘
                                         │ fire & forget Task per client
                        ┌────────────────▼─────────────────────────┐
                        │            Connection (internal)         │
                        │                                          │
                        │  SocketReceiver.ReceiveAsync()           │
                        │  → pooled 64 KB receive buffer           │
                        │  → sliding parse window                  │
                        │  → HttpParser.TryParse()                 │
                        │  → RequestHandler()                      │
                        │  → HttpResponseWriter.WriteAsync()       │
                        │  → Socket.SendAsync()                    │
                        └──────────────────────────────────────────┘

Data Flow:

TCP bytes → SocketReceiver.ReceiveAsync()
         → pooled receive buffer + sliding window
         → HttpParser.TryParse()              (request line + headers)
         → 100 Continue (if Expect header)
         → Content-Length body read  OR  ChunkedBodyParser (Transfer-Encoding: chunked)
         → HttpRequest (rented once per connection, reused per request)
         → handler(request, response)
         → HttpResponseWriter.WriteAsync()    (body suppressed for HEAD / 304)
         → Socket.SendAsync()

Class Reference — Public API

Server

Namespace: Anka
Access:    public sealed

Constructor

Server(RequestHandler handler, int port, string host = "127.0.0.1", ServerOptions? options = null)
Parameter Description
handler Delegate called for every HTTP request
port TCP port number (1–65535)
host IPv4 address to listen on (default: 127.0.0.1)
options Optional server configuration (see ServerOptions). Uses sensible defaults when null.

Event

event Action<IPEndPoint>? ListeningStarted

Raised once the listening socket has been bound. Useful for startup instrumentation and readiness probes.

Thrown Exceptions:

  • AnkaOutOfRangeException — port is outside the 1–65535 range
  • AnkaArgumentException — invalid IP address

Method

Task StartAsync(CancellationToken cancellationToken = default)

Starts the server. Does not return until the token is cancelled. Returning = server stopped.


HttpRequest

Namespace: Anka
Access:    public sealed

Represents a parsed HTTP request. Must not be used after the handler completes — the object is returned to the pool via Return().

Member Type Description
Method HttpMethod GET, POST, ...
Version HttpVersion Http10, Http11
Path string Lazy-materialized path string (/api/users)
PathBytes ReadOnlySpan<byte> Zero-copy raw path bytes
QueryString string? Lazy-materialized query (foo=bar). null if no ? present
QueryBytes ReadOnlySpan<byte> Zero-copy raw query bytes
Headers HttpHeaders Header collection (struct, inline)
Body ReadOnlyMemory<byte> Request body. Populated from Content-Length or decoded chunked bodies; empty when no body is present.
IsKeepAlive bool Is the connection persistent?

Note: Path and QueryString call Encoding.ASCII.GetString() on first access and cache the result. PathBytes and QueryBytes never allocate.


HttpResponseWriter

Namespace: Anka
Access:    public sealed

Writes an HTTP/1.1 response. Zero string allocation via ArrayPool + Utf8Formatter.

Methods

// Simple overload — no extra headers
ValueTask WriteAsync(
    int statusCode,
    ReadOnlyMemory<byte> body           = default,
    ReadOnlyMemory<byte> contentType    = default,
    bool keepAlive                      = true,
    CancellationToken cancellationToken = default)

// Full overload — with per-request extra headers
ValueTask WriteAsync(
    int statusCode,
    ReadOnlyMemory<byte> body,
    ReadOnlyMemory<byte> contentType,
    bool keepAlive,
    ReadOnlySpan<HttpHeader> extraHeaders,
    CancellationToken cancellationToken = default)
Parameter Description
statusCode HTTP status code (200, 404, 500, etc.)
body Response body (optional)
contentType Content-Type header value as UTF-8 bytes (e.g., "application/json"u8)
keepAlive Connection: keep-alive or close?
extraHeaders Zero-allocation per-request headers. Pre-build a static readonly HttpHeader[] for hot paths.

Fluent API: Use response.AddHeader(name, value).WriteAsync(...) to attach extra headers without building an array. See HttpResponseWriterExtensions.

// Streaming / chunked response
Stream GetStream(CancellationToken cancellationToken = default)

Returns an HttpResponseStream that writes the response body using Transfer-Encoding: chunked. The 200 status line and chunked headers are sent automatically on the first write; the terminating 0\r\n\r\n chunk is sent when the stream is disposed.

await using var stream = response.GetStream(cancellationToken);
await stream.WriteAsync(chunk1, cancellationToken);
await stream.WriteAsync(chunk2, cancellationToken);
// terminating chunk sent on DisposeAsync

HttpResponseStream is connection-scoped and reused across keep-alive requests — GetStream() reinitialises it without allocating. HEAD requests are handled correctly: headers are sent but chunk data is suppressed.

Supported Status Code Reason Phrases: 100 Continue · 200 OK · 201 Created · 204 No Content · 301 Moved Permanently · 302 Found · 304 Not Modified · 400 Bad Request · 401 Unauthorized · 403 Forbidden · 404 Not Found · 405 Method Not Allowed · 413 Payload Too Large · 414 URI Too Long · 431 Request Header Fields Too Large · 500 Internal Server Error · 501 Not Implemented · 503 Service Unavailable · 505 HTTP Version Not Supported · others → "Unknown"


HttpHeaders

Namespace: Anka
Access:    public struct

Header collection without heap allocation. 64 header entries are embedded in the struct via [InlineArray(64)].

Member Description
Count Number of headers added
TryGetValue(ReadOnlySpan<byte>, out ReadOnlySpan<byte>) Zero-alloc lookup. lowercaseName must already be lowercase.
TryGetValue(string, out ReadOnlySpan<byte>) Lowercase conversion via stackalloc. 128-character limit.
TryGetAllValues(ReadOnlySpan<byte>, out HeaderValues) Enumerate all values for a repeated header name (zero-alloc).

Important: Header names are lowercase-normalised during Add(). Lookup is always done with SequenceEqual.
HttpHeaderNames constants are already lowercase and can be used directly:

if (request.Headers.TryGetValue(HttpHeaderNames.ContentType, out var ct))
{
    // ct = ReadOnlySpan<byte> — zero allocation
}

HttpHeaderNames

Namespace: Anka
Access:    public static

Provides commonly used header names as lowercase ReadOnlySpan<byte>.

Host · Connection · ContentLength · ContentType · TransferEncoding · Expect
Accept · AcceptEncoding · Authorization · UserAgent · CacheControl · Cookie
IfMatch · IfNoneMatch · IfModifiedSince · IfUnmodifiedSince · Origin · Referer
Location · SetCookie · ETag · LastModified · Vary · WwwAuthenticate · Allow · RetryAfter
AccessControlAllowOrigin · AccessControlAllowMethods · AccessControlAllowHeaders
AccessControlMaxAge · AccessControlExposeHeaders

HttpMethod (enum)

public enum HttpMethod : byte
{ Unknown=0, Get, Post, Put, Delete, Head, Options, Patch, Trace, Connect }

HttpVersion (enum)

public enum HttpVersion : byte
{ Unknown=0, Http10=1, Http11=2 }

RequestHandler (delegate)

public delegate ValueTask RequestHandler(
    HttpRequest request,
    HttpResponseWriter response,
    CancellationToken cancellationToken);

The user callback passed to the server. Called for every HTTP request.


AnkaArgumentException

Derives from ArgumentException. Thrown when an invalid argument is provided (e.g. invalid IP).

AnkaOutOfRangeException

Derives from ArgumentOutOfRangeException. Thrown when a numeric argument is outside its valid range — port outside 1–65535, or ServerOptions.MaxRequestBodySize / ServerOptions.MaxRequestTargetSize / ServerOptions.MaxRequestHeadersSize set to a negative value.


ServerOptions

Namespace: Anka
Access:    public sealed

Optional configuration passed to the Server constructor. All properties are optional; when null, the server picks sensible defaults that scale with processor count.

Property Type Default Description
MinThreadPoolThreads int? ProcessorCount * 2 + 2 Minimum worker/IO-completion threads for ThreadPool. Never overrides downward.
AcceptorCount int? max(ProcessorCount / 2, 2) Number of parallel accept loops.
Backlog int 512 Backlog passed to Socket.Listen().
DefaultResponseHeaders IReadOnlyList<HttpHeader> [] Headers appended to every response (e.g., security headers). Allocated once at startup — zero per-request cost.
MaxRequestBodySize int? null (unlimited) Maximum allowed request body in bytes. Requests that exceed this limit automatically receive 413 Payload Too Large.
MaxRequestTargetSize int? null (unlimited) Maximum allowed request-target size in bytes. Requests that exceed this limit automatically receive 414 URI Too Long.
MaxRequestHeadersSize int 8192 Maximum allowed aggregate size of request header names and values. Requests that exceed this limit, or the built-in header-count cap, automatically receive 431 Request Header Fields Too Large.
ReadTimeout TimeSpan? null Optional idle read timeout used to close stalled connections and mitigate Slowloris-style requests.

Example:

var options = new ServerOptions
{
    AcceptorCount        = 4,
    MaxRequestBodySize   = 1 * 1024 * 1024,  // 1 MB
    MaxRequestTargetSize = 8 * 1024,         // 8 KB
    MaxRequestHeadersSize = 8 * 1024,        // 8 KB
    ReadTimeout = TimeSpan.FromSeconds(15),
    DefaultResponseHeaders =
    [
        new HttpHeader("x-content-type-options"u8.ToArray(), "nosniff"u8.ToArray()),
        new HttpHeader("x-frame-options"u8.ToArray(),        "DENY"u8.ToArray()),
    ]
};

HttpHeader

Namespace: Anka
Access:    public readonly struct

A name/value pair for HTTP response headers. Create instances once at startup and store in static readonly arrays for zero per-request allocation.

// Byte-based (zero allocation at call time — preferred for hot paths)
new HttpHeader("x-custom-header"u8.ToArray(), "value"u8.ToArray())

// String-based (allocates — use at startup only)
new HttpHeader("x-custom-header", "value")
Member Type Description
Name ReadOnlyMemory<byte> Header name as lowercase ASCII bytes
Value ReadOnlyMemory<byte> Header value as ASCII/UTF-8 bytes

ResponseContext

Namespace: Anka
Access:    public readonly struct

A fluent builder for attaching extra per-request response headers. Obtained via HttpResponseWriter.AddHeader(...) (see HttpResponseWriterExtensions).

await response
    .AddHeader(HttpHeaderNames.Location, "/new-path"u8)
    .WriteAsync(301, default, default, keepAlive: false, ct);


await response
    .AddHeader(HttpHeaderNames.AccessControlAllowOrigin, "*"u8)
    .AddHeader(HttpHeaderNames.AccessControlAllowMethods, "GET, POST"u8)
    .WriteAsync(200, body, "application/json; charset=utf-8"u8, keepAlive: true, ct);

For zero-allocation hot paths, pass a static readonly HttpHeader[] directly to WriteAsync instead of using the fluent API (which allocates a List<T> per call).


Class Reference — Internal

Connection (internal sealed)

Manages the lifecycle of each TCP connection.

Member Description
static RunAsync(socket, handler, ct) Single public entry point. Sets socket.NoDelay=true, creates a new Connection, runs ProcessAsync().
ProcessAsync() Owns the receive loop, parser loop, handler dispatch, keep-alive lifecycle, and connection-scoped resources
Sliding receive window Avoids compacting unread bytes after every request; compacts only when the receive tail is full
finally cleanup Returns the HttpRequest to HttpRequestPool, returns pooled buffers, closes and disposes the socket

SocketReceiver (internal sealed)

Zero-allocation socket receive wrapper. One instance per connection; must be Dispose()d on close.

Member Description
ReceiveAsync(socket, buffer) Returns a ValueTask<int>. Sync path: data already in kernel buffer → no allocation, no thread switch. Async path: OS I/O thread posts continuation to ThreadPool via IValueTaskSource.
Dispose() Disposes the underlying SocketAsyncEventArgs

RunContinuationsAsynchronously = true on the internal ManualResetValueTaskSourceCore<int> ensures the OS kqueue/epoll I/O thread is never blocked by request-processing work.


HttpParser (internal static)

Parses HTTP/1.x requests in a single pass.

Member Description
TryParse(ref SequenceReader<byte>, out HttpRequest?) Two-phase parse. Returns HttpParseResult (see below).
ScanForComplete(ref reader, out contentLength) Phase 1: zero-allocation scan for complete message
TryExtractContentLength(line, ref contentLength) Extracts and validates the content-length: value
ParseRequestLine(seq, buf, ref writePos, req) Parses method + request-target (all four forms) + version
ParseHeaderLine(seq, ref headers) Parses a single header line
AddHeaderFromSpan(line, ref headers) Splits Name: Value, calls headers.Add()
ComputeKeepAlive(version, ref headers) Computes IsKeepAlive from HTTP version + Connection header

HttpParseResult enum: Success · Incomplete · Invalid · RequestTargetTooLong · HeaderFieldsTooLarge · HttpVersionNotSupported · ConflictingContentLength · MissingHostHeader


ChunkedBodyParser (internal static)

Decodes Transfer-Encoding: chunked request bodies.

Member Description
TryParseChunkSize(ReadOnlySpan<byte>, out int) Parses hex chunk-size line, supports chunk extensions
TryConsumeChunkData(ReadOnlySpan<byte>, int, out ReadOnlySpan<byte>) Extracts chunk data and validates trailing CRLF
TryParseTrailers(ReadOnlySpan<byte>) Detects and skips trailer headers after the final 0\r\n chunk

RequestTargetForm (internal enum)

Identifies the form of the HTTP request-target per RFC 7230 §5.3.

Origin = 0,    // /path?query           (most common)
Absolute = 1,  // http://host/path      (proxy requests)
Authority = 2, // host:port             (CONNECT only)
Asterisk = 3   // *                     (OPTIONS only)

HttpMethodParser (internal static)

Member Description
Parse(ReadOnlySpan<byte>) Byte span → HttpMethod enum. Unknown → Unknown
ToBytes(this HttpMethod) HttpMethod enum → ReadOnlySpan<byte> (extension method)

The parser uses a short length/byte dispatch instead of chaining multiple SequenceEqual calls. For fixed ASCII method tokens this reduces repeated comparisons on the hot path and produces a simpler branch tree for the JIT.


HttpVersionParser (internal static)

Member Description
Parse(ReadOnlySpan<byte>) "HTTP/1.1"Http11, "HTTP/1.0"Http10, other → Unknown
IsMalformed(ReadOnlySpan<byte>) true when the token is not 8 bytes or not in the format HTTP/x.y (digits only)

HttpRequestPool (internal static)

CAS-based single-slot object pool. Lock-free, AOT-safe.

Member Description
Rent() Interlocked.Exchange(ref _slot, null) — returns slot if full, otherwise new HttpRequest()
Return(req) req.Reset() then Interlocked.CompareExchange(ref _slot, req, null) — if slot is full, instance is left for GC

Why not ConcurrentQueue?
ConcurrentQueue<T> allocates a new internal segment (~608 bytes) every 32 enqueue/dequeue operations. This caused 608 B to appear in microbenchmarks. A CAS single-slot achieves 0 B.


Memory Model

Memory movement per connection:

  Connection.ProcessAsync()
  ├── ArrayPool.Rent(64 KB)               ← receive buffer (reused across all requests on the connection)
  ├── HttpRequestPool.Rent()              ← HttpRequest instance (reused across requests on the connection)
  ├── HttpResponseWriter(socket)          ← connection-scoped response buffer
  └── SocketReceiver()                    ← connection-scoped SocketAsyncEventArgs

Memory movement per request:

  HttpParser.TryParse()
  ├── Reuses request.Buffer when large enough
  ├── Reuses request.BodyBuffer when large enough
  └── Copies only path/query/header slices and any Content-Length body

Connection closes:
  ├── HttpRequestPool.Return(request)
  ├── ArrayPool.Return(receive buffer)
  ├── SocketReceiver.Dispose()            ← disposes SocketAsyncEventArgs
  └── HttpResponseWriter.Dispose()        ← returns response buffer

Result: repeated requests on an existing connection stay on an allocation-free fast path in the parser microbenchmarks, while connection startup still pays its one-time pooled buffer rentals.


Performance Profile

Environment: Apple M3 Max · 16 logical cores · macOS 26.3.1 · .NET 8.0.25 · Native AOT (osx-arm64)
Microbenchmarks: dotnet run --project Benchmark/Anka.Benchmark -c Release
End-to-end: Test/LoadTest/Anka.Wrk.LoadTest (wrk, 10 s per level, loopback)
Full results: docs/ — one file per OS per run (e.g. throughput-results-macos-2026-04-08.md)

Running Benchmarks on Linux

Docker or Podman is required. PostgreSQL is started automatically — no manual setup needed.

# linux/amd64 (default)
./scripts/run-linux-benchmark.sh

# linux/arm64 — runs natively on Apple Silicon (much faster, no emulation)
./scripts/run-linux-benchmark.sh linux/arm64

The script spins up PostgreSQL, initialises the schema, runs the full suite (framework + DB tests), then tears everything down. Results are written to docs/throughput-results-linux-{date}.md.


Startup Snapshot

Anka (Native AOT) Kestrel (JIT)
Time to listen 411 ms 203 ms
Time to ready 2.3 ms 140 ms
First response 20 ms 26 ms
Startup alloc 124.5 KB 2.50 MB
RSS after first response 15.1 MB 97.9 MB

Kestrel binds the port faster because it reuses existing OS handles; Anka is slower because it creates a fresh Socket. JIT warmup adds ~140 ms to Kestrel's "ready" time.


Microbenchmarks — zero allocations throughout

Run with dotnet run --project Benchmark/Anka.Benchmark -c Release
BenchmarkDotNet v0.15.8 · .NET 8.0.25 · Arm64 RyuJIT

HTTP Parser

Parses a complete raw HTTP/1.x byte buffer into HttpRequest with no heap allocation.

Benchmark Mean Allocated
SimpleGet 92.9 ns 0 B
GetWithManyHeaders (10 headers) 420.0 ns 0 B
PostWithSmallBody (256 B body) 244.6 ns 0 B
PostWithLargeBody (64 KB body) 1,651 ns 0 B
HTTP Headers

HttpHeaders is an inline-array struct — no heap allocation on add or lookup.

Benchmark Mean Allocated
Add_TenHeaders 75.2 ns 0 B
TryGetValue — byte span, first entry 84.8 ns 0 B
TryGetValue — byte span, last entry 94.3 ns 0 B
TryGetValue — byte span, missing 92.7 ns 0 B
TryGetValue — string, case-insensitive 94.6 ns 0 B
HTTP Method Parser

Byte-span trie — common verbs resolve in sub-nanosecond time.

Method Mean
GET 0.71 ns
POST 0.99 ns
PUT 0.71 ns
HEAD 0.98 ns
PATCH 1.32 ns
DELETE 1.57 ns
OPTIONS 1.83 ns
CONNECT 1.90 ns
HTTP Version Parser
Version Mean
HTTP/1.1 0.09 ns
HTTP/1.0 0.21 ns

End-to-End Throughput — Framework Tests (wrk · c = 400)

No database. Raw HTTP pipeline throughput on loopback.

Scenario Anka (AOT) req/s Kestrel (JIT) req/s
Plain Text GET 133,000 141,700
JSON API GET 131,600 140,400
GET w/ Multiple Headers 133,300 140,900
POST Echo (256 B body) 127,100 131,200
Large Response GET (~2 KB) 129,800 135,700

Anka and Kestrel deliver comparable throughput on loopback. Kestrel's JIT-generated native code edges ahead at high concurrency due to optimisations that are not available to the AOT compiler. Anka's advantage is memory: ~15 MB RSS vs ~98 MB at steady state, and near-zero startup allocation (124.5 KB vs 2.5 MB).


End-to-End Throughput — TechEmpower-Style DB Tests (PostgreSQL · peak req/s)

Scenario Anka (AOT) Kestrel (JIT)
Single DB Query 23,500 24,500
Multiple Queries (20) 1,300 1,300
Fortunes 22,300 23,400
DB Updates (20) 622 629
Cached Queries (100) 107,200 145,600

DB-bound tests are limited by PostgreSQL connection pool saturation, not by the HTTP layer. Per-concurrency-level detail tables are in docs/.


Project Structure

Anka/
├── Anka.slnx
│
├── src/Anka/                      ← Library
│   ├── Anka.csproj                  (AOT, InternalsVisibleTo: Test + Benchmark)
│   ├── anka-logo.png                (package icon)
│   └── src/
│       ├── Core/                  ← Public API
│       │   ├── HttpHeader.cs        (name/value pair struct for response headers)
│       │   ├── HttpHeaderNames.cs   (pre-defined header name constants)
│       │   ├── HttpHeaders.cs       (zero-alloc header struct, InlineArray)
│       │   ├── HttpMethod.cs        (enum: byte)
│       │   ├── HttpRequest.cs       (parsed request + buffer ownership)
│       │   ├── HttpResponseStream.cs(chunked response stream, connection-scoped reuse)
│       │   ├── HttpResponseWriter.cs(response writer, ArrayPool)
│       │   ├── HttpResponseWriterExtensions.cs (fluent AddHeader API)
│       │   ├── HttpVersion.cs       (enum: byte)
│       │   ├── RequestHandler.cs    (delegate definition)
│       │   ├── ResponseContext.cs   (fluent header builder)
│       │   ├── Server.cs            (public entry point)
│       │   └── ServerOptions.cs     (optional server configuration)
│       ├── Extensions/
│       │   └── HttpRequestExtensions.cs (helper extensions for HttpRequest)
│       ├── Internal/              ← Implementation details (internal)
│       │   ├── ChunkedBodyParser.cs  (Transfer-Encoding: chunked decoder)
│       │   ├── Connection.cs        (socket lifecycle + sliding receive window)
│       │   ├── HttpMethodParser.cs  (byte span → HttpMethod enum)
│       │   ├── HttpParseResult.cs   (parse result enum)
│       │   ├── HttpParser.cs        (two-phase HTTP/1.x parser)
│       │   ├── HttpRequestPool.cs   (CAS single-slot object pool)
│       │   ├── HttpVersionParser.cs (byte span → HttpVersion enum + malformed check)
│       │   ├── RequestTargetForm.cs (origin / absolute / authority / asterisk enum)
│       │   └── SocketReceiver.cs    (zero-alloc SocketAsyncEventArgs + IValueTaskSource)
│       └── Exceptions/
│           ├── AnkaArgumentException.cs
│           └── AnkaOutOfRangeException.cs
│
├── Test/Anka.Test/                ← xUnit tests (242 tests)
│   ├── ChunkedBodyParserTests.cs
│   ├── ContentLengthValidationTests.cs
│   ├── CustomResponseHeaderTests.cs
│   ├── HttpHeadersTests.cs
│   ├── HttpMethodParserTests.cs
│   ├── HttpParserTests.cs
│   ├── HttpRequestTests.cs
│   ├── HttpVersionParserTests.cs
│   ├── RequestBodySizeLimitTests.cs
│   ├── RequestHeaderAndVersionValidationTests.cs
│   ├── RequestTargetSizeLimitTests.cs
│   ├── ServerTests.cs
│   └── TransportTests.cs
│
├── Test/LoadTest/
│   ├── Anka.HttpConsole/          ← Native AOT load-test target
│   ├── Kestrel.HttpConsole/       ← Minimal ASP.NET Core comparison target
│   └── Anka.Wrk.LoadTest/         ← wrk-based startup + throughput harness
├── Benchmark/Anka.Benchmark/      ← BenchmarkDotNet micro-benchmarks
│   ├── ChunkedBodyParserBenchmarks.cs
│   ├── HttpHeadersBenchmarks.cs
│   ├── HttpMethodParserBenchmarks.cs
│   ├── HttpParserBenchmarks.cs
│   └── HttpVersionParserBenchmarks.cs
├── scripts/
│   └── run-linux-benchmark.sh     ← Docker helper: run load test on Linux
└── Dockerfile.benchmark           ← Linux load-test image (wrk + .NET SDK + AOT tools)

Test Coverage

254 tests — all passing.

dotnet test Anka.slnx --nologo
Test Suite Tests Coverage Area
HttpParserTests 49 Full request parsing, all target forms, error paths
HttpRequestTests 21 Request object lifecycle, reset, disposal
HttpHeadersTests 13 Header add/lookup, TryGetAllValues, duplicate headers
ServerTests 17 End-to-end server behaviour, graceful shutdown
TransportTests 15 Keep-alive, pipelining, HEAD/304 suppression, 100-continue
ContentLengthValidationTests 15 Content-Length parsing, conflicts, malformed values
CustomResponseHeaderTests 14 Default/extra response headers, security headers
ChunkedBodyParserTests 12 Chunk parsing, trailers, overflow, invalid chunks
RfcComplianceTests 8 RFC-specific edge cases, obs-fold, trailers, skip CRLF
RequestHeaderAndVersionValidationTests 10 Host validation, HTTP version errors, 400/505
RequestBodySizeLimitTests 10 Body size enforcement, 413 responses
HttpVersionParserTests 8 Version parsing, malformed token detection
RequestTargetSizeLimitTests 7 Target size enforcement, 414 responses
HttpMethodParserTests 6 All HTTP method tokens, unknown methods
StreamingTests 4 Chunked response stream, GetStream(), CopyToAsync

Contributing and Development

Running Tests

dotnet test Anka.slnx --nologo

Running Benchmarks

dotnet run --project Benchmark/Anka.Benchmark -c Release

Load Testing

Run the comparison harness:

dotnet run --project Test/LoadTest/Anka.Wrk.LoadTest --configuration Release
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.0.1-beta.4 48 5/6/2026
0.0.1-beta.3 47 5/5/2026
0.0.1-beta.2 70 4/16/2026
0.0.1-beta.1 69 4/10/2026

1 - Added support for Trailer headers in chunked responses via HttpResponseStream.AddTrailer.
  2 - Enhanced RFC compliance: implemented leading CRLF skipping and strict validation for header names/folding.
  3 - Enforced mandatory Content-Length/Transfer-Encoding for POST, PUT, and PATCH requests (411 Length Required).
  4 - Improved framing security by rejecting requests containing both Content-Length and Transfer-Encoding headers.
  5 - Expanded test suite to 254 tests covering edge-case RFC compliance and security.
  6 - Updated documentation and class references for chunked streaming and feature support.