Complete guide to the trie-based HTTP routing library for ASP.NET Core.
- Quick Start
- Core Concepts
- Route Definition & Matching
- Constraints
- Execution Chains
- Route Groups
- Route Providers
- Hot-Reload
- Configuration
- Advanced Examples
- API Reference
<PackageReference Include="JG.WebKit.Router" Version="1.0.0" />var builder = WebApplication.CreateBuilder(args);
// Register the router
builder.Services.AddWebKitRouter(options =>
{
options.CaseSensitive = false;
options.EnableTrailingSlashRedirect = false;
});
var app = builder.Build();
// Use the router middleware
app.UseWebKitRouter();
// Define a simple route
app.MapRoute("GET", "/", (ctx, ct) =>
ValueTask.FromResult(RouteResult.Ok("Hello, World!")));
app.Run();JG.WebKit.Router is a trie-based HTTP router that:
- Matches incoming requests to routes using an immutable trie structure
- Executes pre-compiled per-route execution chains (not middleware)
- Provides zero-allocation path parsing
- Supports hot-reload with atomic trie swapping
- Offers 11 built-in parameter constraints
- Performance: O(1) route matching, zero allocations in hot path
- Type Safety: Built-in constraints validate parameters
- Clean API: Fluent route mapping without middleware complexity
- Extensibility: Custom constraints, providers, and chain nodes
- Thread-Safe: Lock-free design, safe hot-reload
1. Incoming Request
↓
2. URL Normalization
(trailing slash, case, path segments)
↓
3. Trie Matching
(literal → constrained param → unconstrained param → wildcard)
↓
4. Constraint Validation
(int, long, guid, bool, slug, alpha, alphanum, filename, range, length, regex)
↓
5. Chain Node Execution
(sequential pre-route filters with early termination)
↓
6. Handler Execution
(your async handler function)
↓
7. Response
(RouteResult with status, headers, body)
app.MapRoute("GET", "/api/users", (ctx, ct) =>
ValueTask.FromResult(RouteResult.Ok("User list")));// Unconstrained parameter
app.MapRoute("GET", "/users/{id}", (ctx, ct) =>
{
var id = ctx.Match.Parameters["id"];
return ValueTask.FromResult(RouteResult.Json(new { id }));
});
// Constrained parameter
app.MapRoute("GET", "/users/{id:int}", (ctx, ct) =>
{
var userId = int.Parse(ctx.Match.Parameters["id"]);
return ValueTask.FromResult(RouteResult.Json(new { userId }));
});
// Multiple parameters
app.MapRoute("GET", "/blog/{year:int}/{month:int}/{slug}", (ctx, ct) =>
{
var year = ctx.Match.Parameters["year"];
var month = ctx.Match.Parameters["month"];
var slug = ctx.Match.Parameters["slug"];
return ValueTask.FromResult(RouteResult.Ok($"Post: {year}/{month}/{slug}"));
});// Capture remaining path
app.MapRoute("GET", "/{**path}", (ctx, ct) =>
{
var remainingPath = ctx.Match.Parameters["path"];
return ValueTask.FromResult(RouteResult.Ok($"Caught: {remainingPath}"));
});
// Matches: /foo/bar/baz → path="foo/bar/baz"Routes are matched in this order:
- Literal segments (exact match, O(1) lookup)
- Constrained parameters (validated by constraint)
- Unconstrained parameters (accept any value)
- Wildcard (catch remaining segments)
// These routes work together (different priorities):
app.MapRoute("GET", "/users/me", (ctx, ct) => /* special handling */);
app.MapRoute("GET", "/users/{id:int}", (ctx, ct) => /* numeric id */);
app.MapRoute("GET", "/users/{username}", (ctx, ct) => /* any username */);
// Requests matched:
// GET /users/me → First route (literal)
// GET /users/123 → Second route (constrained param)
// GET /users/john → Third route (unconstrained param)app.MapRoute("GET", "/api/items", GetItems);
app.MapRoute("POST", "/api/items", CreateItem);
app.MapRoute("PUT", "/api/items/{id:int}", UpdateItem);
app.MapRoute("DELETE", "/api/items/{id:int}", DeleteItem);
app.MapRoute("PATCH", "/api/items/{id:int}", PatchItem);
// Same path, different methods = different routesConstraints validate route parameters before execution. Use syntax: {paramName:constraintType}
// 32-bit integer
app.MapRoute("GET", "/posts/{id:int}", (ctx, ct) =>
{
var id = int.Parse(ctx.Match.Parameters["id"]);
return ValueTask.FromResult(RouteResult.Json(new { id }));
});
// 64-bit integer
app.MapRoute("GET", "/data/{ref:long}", (ctx, ct) =>
{
var reference = long.Parse(ctx.Match.Parameters["ref"]);
return ValueTask.FromResult(RouteResult.Json(new { reference }));
});app.MapRoute("GET", "/items/{id:guid}", (ctx, ct) =>
{
var itemId = Guid.Parse(ctx.Match.Parameters["id"]);
return ValueTask.FromResult(RouteResult.Json(new { itemId }));
});app.MapRoute("GET", "/config/{enabled:bool}", (ctx, ct) =>
{
var enabled = bool.Parse(ctx.Match.Parameters["enabled"]);
return ValueTask.FromResult(RouteResult.Json(new { enabled }));
});// URL-friendly slug
app.MapRoute("GET", "/posts/{slug:slug}", (ctx, ct) =>
{
var slug = ctx.Match.Parameters["slug"];
return ValueTask.FromResult(RouteResult.Ok($"Post: {slug}"));
});
// Letters only
app.MapRoute("GET", "/{name:alpha}", (ctx, ct) =>
{
var name = ctx.Match.Parameters["name"];
return ValueTask.FromResult(RouteResult.Ok($"Hello {name}"));
});
// Alphanumeric
app.MapRoute("GET", "/{code:alphanum}", (ctx, ct) =>
{
var code = ctx.Match.Parameters["code"];
return ValueTask.FromResult(RouteResult.Ok($"Code: {code}"));
});
// Safe filename
app.MapRoute("GET", "/download/{file:filename}", (ctx, ct) =>
{
var file = ctx.Match.Parameters["file"];
return ValueTask.FromResult(RouteResult.Ok($"Download: {file}"));
});app.MapRoute("GET", "/page/{number:range(1,100)}", (ctx, ct) =>
{
var page = int.Parse(ctx.Match.Parameters["number"]);
return ValueTask.FromResult(RouteResult.Json(new { page }));
});app.MapRoute("GET", "/search/{query:length(2,100)}", (ctx, ct) =>
{
var query = ctx.Match.Parameters["query"];
return ValueTask.FromResult(RouteResult.Json(new { query }));
});// Pattern validation
app.MapRoute("GET", "/code/{value:regex(^[A-Z]{3}\\d{3}$)}", (ctx, ct) =>
{
var code = ctx.Match.Parameters["value"];
return ValueTask.FromResult(RouteResult.Json(new { code }));
});public class EvenNumberConstraint : IRouteConstraint
{
public bool Match(string parameterName, ReadOnlySpan<char> value)
{
if (!int.TryParse(value, out int num))
return false;
return num % 2 == 0;
}
}
builder.Services.AddRouteConstraint<EvenNumberConstraint>("even");
app.MapRoute("GET", "/numbers/{value:even}", (ctx, ct) =>
{
var num = int.Parse(ctx.Match.Parameters["value"]);
return ValueTask.FromResult(RouteResult.Json(new { num }));
});Chain nodes execute before the route handler. They can validate, modify context, or short-circuit.
public class LoggingChainNode : IChainNode
{
public async ValueTask<ChainResult> ExecuteAsync(RequestContext context, CancellationToken ct)
{
Console.WriteLine($"Route: {context.Match.Path}");
return ChainResult.Next();
}
}
app.MapRoute("GET", "/api/users", handler)
.AddChainNode(new LoggingChainNode());public class AuthChainNode : IChainNode
{
public async ValueTask<ChainResult> ExecuteAsync(RequestContext context, CancellationToken ct)
{
var user = context.HttpContext.User;
if (!user.Identity?.IsAuthenticated ?? false)
return ChainResult.Stop(RouteResult.Unauthorized());
return ChainResult.Next();
}
}
app.MapRoute("GET", "/admin", AdminHandler)
.AddChainNode(new AuthChainNode());public class RateLimitChainNode : IChainNode
{
private readonly Dictionary<string, (int count, DateTime reset)> _limits = new();
public async ValueTask<ChainResult> ExecuteAsync(RequestContext context, CancellationToken ct)
{
var clientId = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (_limits.TryGetValue(clientId, out var limit) && DateTime.UtcNow < limit.reset)
{
if (limit.count >= 10)
{
var retryAfter = (int)(limit.reset - DateTime.UtcNow).TotalSeconds;
return ChainResult.Stop(RouteResult.TooManyRequests(retryAfter.ToString()));
}
}
return ChainResult.Next();
}
}app.MapRoute("POST", "/api/secure", Handler)
.AddChainNode(new AuthChainNode())
.AddChainNode(new RateLimitChainNode())
.AddChainNode(new ValidationChainNode());app.MapRouteGroup("/api/v1", group =>
{
group.MapRoute("GET", "/users", ListUsers);
group.MapRoute("GET", "/users/{id:int}", GetUser);
group.MapRoute("POST", "/users", CreateUser);
});app.MapRouteGroup("/api/admin", group =>
{
group.AddChainNode(new AuthChainNode());
group.AddChainNode(new AdminAuthChainNode());
group.MapRoute("GET", "/users", AdminListUsers);
group.MapRoute("POST", "/users/{id:int}/suspend", SuspendUser);
group.MapRoute("DELETE", "/users/{id:int}", DeleteUser);
});public class DatabaseRouteProvider : IRouteProvider
{
private readonly IDbContext _db;
public DatabaseRouteProvider(IDbContext db) => _db = db;
public async ValueTask<IReadOnlyList<RouteDefinition>> GetRoutesAsync(CancellationToken ct)
{
var routes = await _db.Routes.ToListAsync(ct);
return routes.Select(r => new RouteDefinition
{
Method = r.HttpMethod,
Path = r.UrlPattern,
Handler = ResolveHandler(r.HandlerName),
}).ToList();
}
private RouteHandler ResolveHandler(string name) =>
async (ctx, ct) => await _db.InvokeHandlerAsync(name, ctx, ct);
}
builder.Services.AddRouteProvider<DatabaseRouteProvider>();public class YamlRouteProvider : IRouteProvider
{
private readonly string _filePath;
public YamlRouteProvider(string filePath) => _filePath = filePath;
public async ValueTask<IReadOnlyList<RouteDefinition>> GetRoutesAsync(CancellationToken ct)
{
var yaml = await File.ReadAllTextAsync(_filePath, ct);
var routes = ParseYaml(yaml);
return routes.Select(r => new RouteDefinition
{
Method = r.Method,
Path = r.Path,
Handler = CreateHandler(r),
}).ToList();
}
}var router = app.Services.GetRequiredService<IRouter>();
await router.ReloadAsync(CancellationToken.None);public class RouteReloader : IHostedService
{
private readonly IRouter _router;
private FileSystemWatcher? _watcher;
public RouteReloader(IRouter router) => _router = router;
public Task StartAsync(CancellationToken ct)
{
_watcher = new FileSystemWatcher(".", "routes.yaml")
{
EnableRaisingEvents = true
};
_watcher.Changed += async (s, e) =>
await _router.ReloadAsync(ct);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_watcher?.Dispose();
return Task.CompletedTask;
}
}
builder.Services.AddHostedService<RouteReloader>();builder.Services.AddWebKitRouter(options =>
{
options.CaseSensitive = false;
options.EnableTrailingSlashRedirect = false;
options.ConflictPolicy = RouteConflictPolicy.LastWins;
options.RespectProxyHeaders = true;
options.MaxCompiledRegexCache = 100;
});public class Product { public int Id { get; set; } public string Name { get; set; } }
public class ProductApi
{
private readonly List<Product> _products = new();
public ValueTask<RouteResult> List(RequestContext ctx, CancellationToken ct) =>
ValueTask.FromResult(RouteResult.Json(_products));
public ValueTask<RouteResult> Get(RequestContext ctx, CancellationToken ct)
{
var id = int.Parse(ctx.Match.Parameters["id"]);
var product = _products.FirstOrDefault(p => p.Id == id);
return ValueTask.FromResult(product == null
? RouteResult.NotFound()
: RouteResult.Json(product));
}
public async ValueTask<RouteResult> Create(RequestContext ctx, CancellationToken ct)
{
var product = await ctx.HttpContext.Request.ReadAsAsync<Product>(ct);
product.Id = _products.Max(p => p.Id) + 1;
_products.Add(product);
return RouteResult.Json(product, 201);
}
}
var api = new ProductApi();
app.MapRouteGroup("/api/products", group =>
{
group.MapRoute("GET", "", api.List);
group.MapRoute("GET", "/{id:int}", api.Get);
group.MapRoute("POST", "", api.Create);
});app.MapRoute("GET", "/health", async (ctx, ct) =>
{
var health = new
{
status = "healthy",
timestamp = DateTime.UtcNow,
checks = new { database = "ok", cache = "ok" }
};
return RouteResult.Json(health);
});
app.MapRoute("GET", "/health/ready", async (ctx, ct) =>
{
var ready = await CheckReadiness(ct);
return ready ? RouteResult.Ok() : RouteResult.Error(503);
});RouteResult.Ok() // 200
RouteResult.Ok(body) // 200 with body
RouteResult.Json(object) // 200 JSON
RouteResult.Json(object, statusCode) // Custom status
RouteResult.Html(string) // 200 HTML
RouteResult.Redirect(url) // 302
RouteResult.BadRequest() // 400
RouteResult.Unauthorized() // 401
RouteResult.Forbidden() // 403
RouteResult.NotFound() // 404
RouteResult.TooManyRequests(retryAfter) // 429
RouteResult.Error(statusCode) // Custompublic readonly struct RequestContext
{
public HttpContext HttpContext { get; }
public RouteMatch Match { get; }
public IReadOnlyDictionary<string, object> RouteMetadata { get; }
}public readonly struct RouteMatch
{
public string Method { get; }
public string Path { get; }
public IReadOnlyDictionary<string, string> Parameters { get; }
}ChainResult.Next() // Continue
ChainResult.Stop(RouteResult) // Stop executionpublic interface IChainNode
{
ValueTask<ChainResult> ExecuteAsync(RequestContext context, CancellationToken ct);
}public interface IRouteConstraint
{
bool Match(string parameterName, ReadOnlySpan<char> value);
}public interface IRouteProvider
{
ValueTask<IReadOnlyList<RouteDefinition>> GetRoutesAsync(CancellationToken ct);
}public interface IRouter
{
ValueTask<RouteResult> HandleRequestAsync(HttpContext httpContext, CancellationToken ct);
ValueTask ReloadAsync(CancellationToken ct);
ValueTask RegisterRouteAsync(RouteDefinition definition, CancellationToken ct);
}