Elsie.Cors
0.3.0-beta.2
dotnet add package Elsie.Cors --version 0.3.0-beta.2
NuGet\Install-Package Elsie.Cors -Version 0.3.0-beta.2
<PackageReference Include="Elsie.Cors" Version="0.3.0-beta.2" />
<PackageVersion Include="Elsie.Cors" Version="0.3.0-beta.2" />
<PackageReference Include="Elsie.Cors" />
paket add Elsie.Cors --version 0.3.0-beta.2
#r "nuget: Elsie.Cors, 0.3.0-beta.2"
#:package Elsie.Cors@0.3.0-beta.2
#addin nuget:?package=Elsie.Cors&version=0.3.0-beta.2&prerelease
#tool nuget:?package=Elsie.Cors&version=0.3.0-beta.2&prerelease
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 © WavestormSoftware — LICENSE
| Product | Versions 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. |
-
net10.0
- Elsie (>= 0.3.0-beta.2)
- Elsie.Core (>= 0.3.0-beta.2)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
-
net8.0
- Elsie (>= 0.3.0-beta.2)
- Elsie.Core (>= 0.3.0-beta.2)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
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 |