Elsie.Core 0.3.0-beta.2

This is a prerelease version of Elsie.Core.
dotnet add package Elsie.Core --version 0.3.0-beta.2
                    
NuGet\Install-Package Elsie.Core -Version 0.3.0-beta.2
                    
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="Elsie.Core" Version="0.3.0-beta.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Elsie.Core" Version="0.3.0-beta.2" />
                    
Directory.Packages.props
<PackageReference Include="Elsie.Core" />
                    
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 Elsie.Core --version 0.3.0-beta.2
                    
#r "nuget: Elsie.Core, 0.3.0-beta.2"
                    
#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 Elsie.Core@0.3.0-beta.2
                    
#: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=Elsie.Core&version=0.3.0-beta.2&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Elsie.Core&version=0.3.0-beta.2&prerelease
                    
Install as a Cake Tool

Elsie

HTTP module framework for .NET 8 and .NET 10. Define routes in small modules, return results, run on Elsie’s own lightweight host.

Why Elsie? Tiny Sinatra-style modules, one fluent host (ElsieApp), no ASP.NET tax. Hold the whole request path in your head — modules → results → server — without WebApplication ceremony or a shared-framework dependency.

dotnet add package Elsie          # 0.3.0-beta.2 — host + Elsie.Core
using Elsie;
using Elsie.Web;

ElsieApp.Run<App>(args);

public sealed class App : ElsieModule
{
    public App()
    {
        Get("/", () => ElsieResult.Text("Hello, Elsie!"));
        Get("/hello/{name}", ctx =>
            ElsieResult.Text($"Hello {ctx.RouteOrDefault("name")}!"));
    }
}
dotnet run
# GET /           → Hello, Elsie!
# GET /hello/Ada  → Hello Ada!

Templates:

dotnet new install Elsie.Templates
dotnet new elsie -n HelloApp        # minimal app
dotnet new elsie-api -n TodosApi    # CRUD + cookie auth + OpenAPI

Guides: docs/ · Samples: HelloWorld · Hello · Api (validation/OpenAPI) · Views · Dashboard (cookie + form CSRF) · Full (kitchen sink) · NuGet: Elsie


Quickstart (fluent host)

ElsieApp.Create(args)
    .Module<TodosModule>()
    .Services(s => s.AddSingleton<ITodoStore, TodoStore>())
    .Configure(o => o.ScanEntryAssembly = false)
    .Listen("http://127.0.0.1:5000")
    .OpenApi(o =>
    {
        o.Info.Title = "My API";
        o.UiPath = "/scalar";
    })
    .StaticFiles(s =>
    {
        s.Root = "wwwroot";
        s.RequestPath = "/assets";
    })
    .Run();
API Use
ElsieApp.Run<T>(args) / ElsieWeb.Run<T> Smallest host
ElsieApp.Create(args) Fluent host builder
.Module<T>() / .Services(...) Modules + MS.DI
.Listen / .ContentRoot / .Server Bind, roots, limits
.Logging / .Compression Observability + gzip/br
.OpenApi(...) / .StaticFiles(...) Document UI + static files

Modules are singletons. Inject singleton-safe services in the ctor; resolve request-scoped services with ctx.GetRequiredService<T>() / ctx.Services.

JSON: ElsieResult.Json(...) uses framework defaults (ElsieJson.DefaultOptions); ctx.Json(...) uses app ElsieOptions.JsonSerializerOptions.


Modules and routing

public sealed class TodosModule : ElsieModule
{
    public TodosModule(ITodoStore store)
    {
        Path("/api");
        Before(ElsieAuth.RequireApiKey("dev-secret", onlyMutatingMethods: true));

        Group("/todos", () =>
        {
            Get("/", ctx => ctx.Json(store.List(ctx.QueryOrDefault("q"))))
                .Named("listTodos")
                .WithTags("todos");

            Get("/{id:guid}", ctx =>
            {
                if (!ctx.RequireRoute("id", out Guid id, out var error))
                    return error!;
                return ctx.Json(store.Get(id));
            }).Named("getTodo").Produces<Todo>();

            Post("/", async (ctx, ct) =>
            {
                var bind = await ctx.BindJsonAsync<CreateTodo>(ct);
                if (!bind.IsSuccess) return bind.Error!;
                var created = store.Add(bind.Value!.Title);
                return ElsieResult.Created(ctx.UrlFor("getTodo", new { id = created.Id }), created);
            }).Accepts<CreateTodo>().Produces<Todo>(201);
        });
    }
}

Route templates: {name}, optional {name?}, default {name=5}, constraints {id:int}, catch-all {*path} (last segment only).

Built-in constraints: int, long, guid, bool, alpha, datetime, decimal, double, minlength(n), maxlength(n), length(n|min,max), min(n), max(n), range(a,b), regex(...).

Matching: per segment, static > constrained > param > catch-all. Startup fails on unknown constraints, duplicate param names, bad catch-all placement, ambiguous routes, and duplicate route names. Wrong verb on a known path → 405 + Allow + problem+json. HEAD maps to GET when ElsieOptions.ImplicitHead is on (default).


Results, binding, context

ElsieResult.Text("ok")
ElsieResult.Html("<p>hi</p>")
ElsieResult.Json(payload, statusCode: 201)   // framework JSON defaults
ctx.Json(payload)                            // app JsonSerializerOptions
ElsieResult.Created("/items/1", body)
ElsieResult.Accepted()
ElsieResult.File(bytes, "application/pdf", downloadName: "a.pdf")
ElsieResult.Redirect("/elsewhere")           // 302; 307/308 helpers also
ElsieResult.NoContent()
ElsieResult.NotModified()
ElsieResult.Problem(400, "Bad Request", detail)
ElsieResult.ValidationProblem(errors)
ElsieResult.ServerSentEvents(async (w, ct) => await w.WriteEventAsync("tick", "1", ct))
ElsieResult.WebSocket(async (ws, ct) => { /* … */ })

var bind = await ctx.BindJsonAsync<CreateTodo>(ct);
var form = await ctx.BindFormAsync<LoginForm>(ct);
var files = await ctx.ReadFormFilesAsync(ct);   // multipart ElsieFormFile
var query = ctx.BindQuery<SearchQuery>();
ctx.Route<int>("id");
ctx.Query<bool>("shout");
ctx.RequireRoute("id", out Guid id, out var error);

ctx.Request.Method / Path / Scheme / Host / PathBase / Protocol / RemoteIp
ctx.Request.GetHeader / GetQuery / GetCookie
ctx.UrlFor("getTodo", new { id })
ctx.UrlFor("getTodo", new { id }, absolute: true)
ctx.Response.Headers["X-App"] = "1";
ctx.Response.SetCookie("sid", value, new ElsieCookieOptions { HttpOnly = true, SameSite = ElsieSameSite.Lax });
ctx.GetRequiredService<IMyService>();

Exception handling (first match wins):

.Configure(o =>
{
    o.MapException<KeyNotFoundException>((_, ex) => ElsieResult.NotFound(ex.Message));
    o.ExceptionHandler = (ctx, ex, ct) =>
        Task.FromResult(ElsieResult.Problem(500, "Server Error"));
})
// MapException → module OnError → ExceptionHandler → rethrow
// After-hooks still run when the exception is mapped to a result

Pipelines

App-wide or per-module before / after hooks. After-hooks may transform the result.

.Services(s => s.ConfigureElsiePipelines(p =>
{
    p.AddBefore((ctx, _) =>
    {
        ctx.Response.Headers["X-Request-Id"] = Guid.NewGuid().ToString("n");
        return Task.FromResult<ElsieResult?>(null); // null = continue
    });
    p.AddAfter((ctx, result) =>
    {
        ctx.Response.Headers["X-Status"] = result.StatusCode.ToString();
        return result;
    });
}))
// Order: app.Before → module.Before → handler → module.After → app.After

Core gates:

Before(ElsieAuth.RequireApiKey("dev-secret"));                    // all methods (default)
Before(ElsieAuth.RequireApiKey("dev-secret", onlyMutatingMethods: true));
Before(ElsieAuth.RequireHeader("X-Tenant", "acme"));
Before(ElsieAuth.RequireBearer(token => token == "ok"));
Before(ElsieAuth.RequireCookie("session"));

Cookie sessions + JWT + antiforgery → Elsie.Auth.


Optional packages

Auth — Elsie.Auth

.Services(s =>
{
    s.AddElsieAuth(o =>
    {
        o.Cookie = new ElsieCookieAuthOptions
        {
            CookieName = "elsie-auth",
            HttpOnly = true,
            SameSite = ElsieSameSite.Lax   // core enum
        };
        o.Cookie.TicketKeyFromString("change-me-at-least-16");
    });
    s.AddElsieAntiforgery(); // double-submit cookie
})

Before(ElsieAuthGates.RequireAuthenticated());
Before(ElsieAuthGates.RequireRole("admin"));
Before(ElsieAntiforgeryService.RequireAntiforgery()); // header X-CSRF-TOKEN or form field
await ctx.SignInCookieAsync("ada", roles: ["user"]);
var user = ctx.GetUser();
var csrf = ctx.GetAntiforgeryToken(); // Base64Url

CORS — Elsie.Cors

Preflight is handled by a host request filter; ACAO is applied on actual responses via an after-hook.

.Services(s => s.AddElsieCors(o => o.AddDefaultPolicy(p => p
    .AllowOrigins("http://localhost:5173")
    .AllowMethods("GET", "POST", "OPTIONS")
    .AllowHeaders("Content-Type", "X-CSRF-TOKEN")
    .AllowCredentials())))
// optional: Get(...).WithCors("policy-name")

Health checks (in Elsie.Core)

.Services(s => s.AddElsieHealthChecks(o =>
{
    o.AddCheck("self", () => ElsieHealthCheckResult.Healthy(), ElsieHealthCheckTags.Live);
    o.AddCheck("db", ct => CheckDbAsync(ct), ElsieHealthCheckTags.Ready);
}))
// GET /healthz | /healthz/live | /healthz/ready  (unhealthy → 503)

Rate limiting (in Elsie.Core)

Before(ElsieRateLimit.FixedWindow(100, TimeSpan.FromMinutes(1)));
Before(ElsieRateLimit.SlidingWindow(30, TimeSpan.FromSeconds(10),
    partitionKey: ctx => ctx.Request.GetHeader("X-Api-Key") ?? "anon"));
Before(ElsieRateLimit.TokenBucket(capacity: 20, tokensPerSecond: 5));
// 429 problem+json + Retry-After; default partition = RemoteIp only (not XFF)
// Behind a trusted proxy: partitionKey: ElsieRateLimit.ForwardedPartitionKey

Validation — Elsie.Validation

.Services(s => s.AddElsieDataAnnotationsValidation())
// after bind:
if (ctx.ValidateWithDataAnnotations(body) is { } invalid) return invalid;

OpenAPI (host)

Route metadata (.Named / .Accepts / .Produces / .WithSecurity / .WithExample / …) builds the document.

.OpenApi(o =>
{
    o.Info.Title = "My API";
    o.UiPath = "/scalar";              // Scalar CDN by default
    // o.UseScalarCdn = false;         // minimal embedded UI
    // o.PrebuiltDocumentPath = "…";  // skip reflection at runtime
})

Views — Elsie.Views (Fluid / Liquid)

.Services(s => s.AddElsieViews(o => o.ContentRoot = contentRoot))
return await ctx.ViewAsync("home", new { Title = "Hi", Name = "Ada" }, cancellationToken: ct);
{% layout '_Layout.liquid' %}
<h1>Hello {{ Name }}!</h1>

Static files (host)

.StaticFiles(s =>
{
    s.Root = "wwwroot";
    s.RequestPath = "/assets";
})
// streams; ETag / If-Modified-Since / Range

Testing — Elsie.Testing

await using var mem = ElsieInMemoryHost.Create(s => s.AddElsieModule<HelloModule>());
var r = await mem.GetAsync("/hello/Ada");
Assert.Equal(200, r.StatusCode);

await using var host = ElsieTestHost.Create(s => s.AddElsieModule<HelloModule>());
var response = await host.GetAsync("/hello/Ada");
response.AssertStatus(200);

In tests, set ScanEntryAssembly = false and register modules explicitly.


Package layout

Package Contents
Elsie HTTP host (ElsieApp), server, static files, OpenAPI — depends on Elsie.Core. Use this: dotnet add package Elsie
Elsie.Core Modules, routing, dispatcher, results, pipelines, health, rate limit
Elsie.Auth Cookie tickets + JWT + auth gates
Elsie.Cors Elsie-native CORS
Elsie.Views Fluid/Liquid views
Elsie.Validation DataAnnotations validation adapter
Elsie.Testing Helpers for your tests (not the same as repo tests/)
Elsie.Templates dotnet new elsie / elsie-api

Current version: 0.3.0-beta.2 (prerelease; APIs may still change).

Namespaces stay Elsie / Elsie.Web (host assembly is still Elsie.Web.dll). Library authors who want the host-agnostic surface only should reference Elsie.Core. Former package id Elsie.Web is retired — use Elsie.


Request flow

TCP (+ optional TLS/ALPN)
  → HTTP/1.1 or HTTP/2 parse
  → ElsieRequest
  → principal / filters (CORS preflight, …)
  → RouteTable.Lookup → before hooks → handler → after hooks → ElsieResult
  → ElsieHttpResponse.FromDispatch
  → host writes status / headers / body (or WebSocket upgrade)

Unmatched routes return 404 problem+json from the host.


Docs

Topic
Getting started Install, smallest app, samples
Modules Registration, DI lifetimes
Routing Templates, constraints, UrlFor
Results Response factories
Binding Route/query/JSON/form/files + validation
Pipelines & errors Before/after, exception maps
Auth Gates, cookies, JWT, CSRF, OIDC helpers
CORS Elsie.Cors
Rate limiting Fixed / sliding / token bucket
Health checks Live/ready
OpenAPI Document, Scalar, prebuilt JSON
Views Fluid/Liquid
Static files Stream, ETag, Range
Testing In-memory + loopback
Hosting & AOT TLS, HTTP/2, WebSockets, limits, reverse proxy
Security Tickets, CSRF, XFF, CI scan
Production checklist Deploy gates
Lifecycle Socket → response path
Architecture Package/host layout
Anti-patterns Common pitfalls
Minimal APIs migration Cheat sheet

Production sketch

ElsieApp.Create(args)
    .Logging(loggerFactory)
    .Compression()
    .Server(o =>
    {
        o.MaxRequestBodyBytes = 1_000_000;
        o.MaxConcurrentConnections = 10_000;
    })
    .Services(s =>
    {
        s.AddElsieAuth(/* TicketKey from secret store */);
        s.AddElsieAntiforgery();
        s.AddElsieDataAnnotationsValidation();
        s.ConfigureElsiePipelines(p => p.AddAfter(ElsieSecurityHeaders.DefaultAfter()));
    })
    .Module<AppModule>()
    .Run();

Full list: production-checklist.md.

Changelog: CHANGELOG.md


License

MIT © WavestormSoftwareLICENSE

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 is compatible.  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.

NuGet packages (6)

Showing the top 5 NuGet packages that depend on Elsie.Core:

Package Downloads
Elsie

Elsie HTTP host and app package — ElsieApp, HTTP/1.1, TLS, HTTP/2, static files, OpenAPI. Depends on Elsie.Core.

Elsie.Views

Fluid (Liquid) HTML views for Elsie — layouts, partials, HTML-encoding, IElsieViewEngine.

Elsie.Testing

In-memory and socket test hosts for Elsie.

Elsie.Auth

Elsie cookie sessions and JWT bearer auth (no ASP.NET).

Elsie.Cors

Elsie-native CORS (preflight filter + after-hook ACAO).

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.0-beta.2 54 7/31/2026
0.3.0-alpha.1 36 7/30/2026
0.2.0-alpha.2 43 7/30/2026