Elsie.Cors 0.3.0-beta.2

This is a prerelease version of Elsie.Cors.
dotnet add package Elsie.Cors --version 0.3.0-beta.2
                    
NuGet\Install-Package Elsie.Cors -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.Cors" 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.Cors" Version="0.3.0-beta.2" />
                    
Directory.Packages.props
<PackageReference Include="Elsie.Cors" />
                    
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.Cors --version 0.3.0-beta.2
                    
#r "nuget: Elsie.Cors, 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.Cors@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.Cors&version=0.3.0-beta.2&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Elsie.Cors&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

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.3.0-beta.2 0 7/31/2026
0.3.0-alpha.1 28 7/30/2026
0.2.0-alpha.2 24 7/30/2026
0.2.0-alpha.1 32 7/30/2026