DEV Community

rinat kozin
rinat kozin

Posted on

Apache Camel for .NET, dissected: the HTTP connector with no ASP.NET MVC + the Content-Based Router pattern

redb route http

  • Series: redb ecosystem

This continues the redb.Route series. Earlier on dev.to:

Full list on the author's dev.to profile.

In redb.Route — our Apache Camel-style ESB for .NET — a route always reads the same way: From(source) → [processors] → To(sink). This installment takes one simple integration pattern and one connector and dissects both all the way down.

  • The pattern: the Content-Based Router — the most basic of the routing patterns from Hohpe & Woolf: look inside a message and decide where it goes next. In the DSL it's .Choice().When(...).Otherwise().
  • The connector: redb.Route.Http — built-in HTTP/HTTPS. On one side it's a producer (an HttpClient-based caller); on the other, a consumer (an embedded Kestrel server). No controllers, no [ApiController], no ASP.NET middleware pipeline you wire up by hand.

This is a long, technical piece. You'll get the five-line "hello world", but then we go into how the connector works internally: how a single Kestrel is shared across routes, how request headers and route values flow into the Exchange and back, how CORS actually works on a shared server, what happens with streaming, and why there isn't a single app.UseCors() in the codebase.

Every snippet is verified against redb.Route/src/redb.Route.Http; every example is lifted from the real redb.Route.Demo.


Part 0. The scenario everything hangs on

Take a down-to-earth task: an HTTP gateway. A POST /api/demo comes in, and inside we:

  1. take the body,
  2. look at the mode header and pick a processing branch accordingly — that's the Content-Based Router;
  3. reply synchronously over the same HTTP request (request/reply).

Here's the skeleton (full version at the end), from redb.Route.Demo/Routes/MainPipelineRoutes.cs:

From("http:0.0.0.0:5088/api/demo?inOut=true")
    .RouteId("demo-http-entry")
    .ConvertBody<string>()

    .Choice()
        .When(e => GetHeader(e, "mode") == "full")
            .SetHeader("stamp.dsl", "full-branch")
        .When(e => GetHeader(e, "mode") == "short")
            .SetHeader("stamp.dsl", "short-branch")
        .Otherwise()
            .SetHeader("mode", "default")
            .SetHeader("stamp.dsl", "default-branch")
    .EndChoice()

    .SetHeader("Content-Type", "application/json")
    .SetBody(e => BuildResponse(e));
Enter fullscreen mode Exit fullscreen mode

One From brings up an HTTP server on port 5088, one .Choice() decides the message's fate, one .SetBody(...) builds the reply. Now let's see how it works.


Part 1. Content-Based Router — the simple pattern, honestly dissected

What it actually is

The Content-Based Router answers one question — "where next?" — by looking at the message itself, not at external configuration. The textbook example: orders with region=EU go to one handler, region=US to another, everything else to a default.

In redb.Route this is the ChoiceProcessor (redb.Route/src/redb.Route/Processors/ChoiceProcessor.cs), and in the DSL it's a .Choice() block:

.Choice()
    .When(<predicate>)   // branch 1
        ...processors...
    .When(<predicate>)   // branch 2
        ...processors...
    .Otherwise()         // default branch (optional)
        ...processors...
.EndChoice()
Enter fullscreen mode Exit fullscreen mode

Semantics are exactly a switch: predicates are checked top to bottom, the first branch whose predicate returns true runs, the rest are skipped. If none match and there's an .Otherwise(), it runs; if there's no .Otherwise(), the message passes through untouched.

Two ways to express a predicate

1. A lambda — when the condition is easier to write as code:

.Choice()
    .When(e => GetHeader(e, "mode") == "full")  ...
    .When(e => GetHeader(e, "mode") == "short") ...
    .Otherwise() ...
.EndChoice()
Enter fullscreen mode Exit fullscreen mode

2. Fluent predicates over the expression engine — when you want it declarative. From redb.Route.Demo/Routes/DataObservabilityRoutes.cs:

.Choice()
    .When(Header("amount").isGreaterThanOrEqualTo(1000).Matches, w => w
        .SetHeader("tier", "gold"))
    .When(Header("amount").isBetween(500, 999).Matches, w => w
        .SetHeader("tier", "silver"))
    .When(Header("amount").isLessThan(500).Matches, w => w
        .SetHeader("tier", "bronze"))
    .Otherwise(o => o
        .SetHeader("tier", "unknown"))
Enter fullscreen mode Exit fullscreen mode

Header("amount").isBetween(500, 999) isn't a closure — it's a real IPredicate, compiled once and cached as a delegate forever after. Under the hood it's the series' compiled expression engine (Tokenizer → Parser → AST → System.Linq.Expressions → IL), but that's a whole article of its own.

Why pair it with HTTP

The Content-Based Router and an HTTP gateway are made for each other. On the way in, the HTTP consumer decomposes the request into Exchange headers (more below): method, path, query params, route params, every HTTP header. Any of them is ready material for .When(...):

.Choice()
    .When(e => GetHeader(e, "redbHttp.Method") == "DELETE")        ... // by method
    .When(e => GetHeader(e, "X-Tenant") == "acme")                 ... // by header
    .When(e => GetHeader(e, "redbHttp.QueryParam.debug") == "1")   ... // by query
.EndChoice()
Enter fullscreen mode Exit fullscreen mode

The router never touches HTTP itself — it works on an already-decoded Exchange. That's the whole point of a connector: turn transport into a message so the integration patterns know nothing about transport.

Straight from production

Here's a production route from the TsUM system (delivery monitoring), verbatim. HTTP entry + Content-Based Router by method — GET and POST on one path fan out to different handlers:

From("http:0.0.0.0:5090/api/tsum/user-filters?inOut=true&cors=true&corsOrigins=*")
    .RouteId("tsum-api-user-filters")
    .Process(Auth.ProcessAsync)                 // JWT auth — just a processor
    .ConvertBody<string>()
    .Choice()
        .When(e => e.In.Headers.TryGetValue("redbHttp.Method", out var m) && m?.ToString() == "POST")
            .ProcessWithRedb((redb, exchange, ct) => HandlePost(redb, exchange))
        .Otherwise()
            .ProcessWithRedb((redb, exchange, ct) => HandleGet(redb, exchange))
    .EndChoice();
Enter fullscreen mode Exit fullscreen mode

You can see everything we're about to cover in one shot: the HTTP consumer on port 5090 (inOut=true, cors=true&corsOrigins=*), the Content-Based Router over redbHttp.Method, and authentication as an ordinary processor in the chain — no [Authorize] attributes. Now let's dissect how each piece works inside.


Part 2. The HTTP connector from 10,000 feet

The same http/https scheme yields two fundamentally different roles depending on whether it sits in From(...) or To(...):

Role Class Built on What it does
Consumer (From) HttpConsumer Kestrel Brings up an embedded HTTP server and accepts inbound requests
Producer (To) HttpProducer HttpClient Sends outbound HTTP requests to a remote address

The DSL entry points are the static Http and Https classes (redb.Route.Http/Fluent/HttpDsl.cs):

// Consumer — listen for inbound
.From(Http.Listen("/webhook").Port(8080).Cors("https://app.example.com").InOut())

// Producer — send outbound
.To(Http.Post("api.example.com/orders").BearerAuth().Timeout(5000))
Enter fullscreen mode Exit fullscreen mode

Or the raw URI string (the builder compiles to exactly this):

.From("http:0.0.0.0:8080/webhook?cors=true&corsOrigins=https://app.example.com&inOut=true")
.To("http:api.example.com/orders?method=POST&timeout=5000")
Enter fullscreen mode Exit fullscreen mode

tsak route http

HTTP methods: the consumer listens, the producer sends

Methods (GET/POST/PUT/…) are set differently for the two roles — and there's more than one way. First the producer (To) — which method to send:

// fluent — the method is chosen by the factory method:
.To(Http.Get("api.example.com/users"))           // GET
.To(Http.Post("api.example.com/users"))          // POST
.To(Http.Put("api.example.com/users/42"))        // PUT
.To(Http.Delete("api.example.com/users/42"))     // DELETE
.To(Http.Patch("api.example.com/users/42"))      // PATCH
.To(Http.Head("api.example.com/users/42"))       // HEAD

// the same as a URI string (singular parameter — method):
.To("http:api.example.com/users?method=POST")

// the method can be overridden per-message by a header — it wins over the option:
.SetHeader("redbHttp.Method", "PUT")
.To(Http.Post("api.example.com/users/42"))        // actually sends PUT
Enter fullscreen mode Exit fullscreen mode

Now the consumer (From) — which methods to accept:

// by default ALL methods are accepted:
.From(Http.Listen("/webhook").Port(8080))

// restrict the allowed set (anything else → 405 Method Not Allowed):
.From(Http.Listen("/webhook").Port(8080).Methods("POST"))
.From(Http.Listen("/orders").Port(8080).Methods("POST,PUT"))

// the same as a URI string (plural parameter — methods):
.From("http:0.0.0.0:8080/webhook?methods=POST")

// shorthand: a method prefix right in the path (for the consumer):
.From("http:POST:0.0.0.0:8080/webhook")
.From("http:GET:/health")
Enter fullscreen mode Exit fullscreen mode

The terminology difference is easy to trip over:

Producer (To) Consumer (From)
URI parameter method (singular) methods (plural, comma-separated)
Meaning which method to send which methods to accept (empty = all)
Default GET all methods
Per-message override redbHttp.Method header — (the filter is static)

The prefix shorthand (http:POST:/...) is a special case: it sets both values at once (method and methods), because the same string can serve as either a producer or a consumer.

And the typical move when different methods hit one path: accept several and fan them out with a Content-Based Router on redbHttp.Method (this is exactly the production example from Part 1):

.From(Http.Listen("/api/tsum/user-filters").Port(5090).Methods("GET,POST").Cors("*").InOut())
    .Choice()
        .When(e => GetHeader(e, "redbHttp.Method") == "POST")
            .ProcessWithRedb((redb, ex, ct) => HandlePost(redb, ex))   // write
        .Otherwise()
            .ProcessWithRedb((redb, ex, ct) => HandleGet(redb, ex))    // read
    .EndChoice();
Enter fullscreen mode Exit fullscreen mode

We'll dissect both roles separately — but first, the big question.


Part 3. "Where's ASP.NET?" — there isn't any, on purpose

When a .NET developer hears "embedded HTTP server", they picture WebApplication, controllers, [HttpPost], filters, model binding, app.UseRouting(), app.UseCors(), a DI middleware pipeline. The redb.Route HTTP connector has none of that. There's Kestrel — bare, with no MVC layer on top.

Here's how the server comes up (SharedHttpServerManager.StartServer, abridged):

var builder = WebApplication.CreateSlimBuilder();   // slim — no MVC, no extra services

builder.WebHost.ConfigureKestrel(kestrel =>
{
    kestrel.Listen(IPAddress.Parse(entry.Host), entry.Port, listenOptions =>
    {
        listenOptions.Protocols = protocols;          // HTTP/1, /2, /3 — see below
        if (entry.Ssl) listenOptions.UseHttps(entry.SslCertPath, entry.SslCertPassword);
    });
    kestrel.Limits.MaxRequestBodySize = entry.MaxRequestBodySize > 0
        ? entry.MaxRequestBodySize : null;
});

builder.Logging.ClearProviders();
var app = builder.Build();

// ONE catch-all endpoint — we route from here ourselves
app.Map("/{**path}", (HttpContext ctx) => HandleCatchAll(entry, ctx));
app.MapGet("/",    (HttpContext ctx) => HandleCatchAll(entry, ctx));
app.MapPost("/",   (HttpContext ctx) => HandleCatchAll(entry, ctx));
// ... PUT/DELETE/PATCH/HEAD/OPTIONS on "/"
await app.StartAsync(ct);
Enter fullscreen mode Exit fullscreen mode

Note the key decisions:

  • WebApplication.CreateSlimBuilder(), not CreateBuilder(). The slim builder doesn't drag in MVC, Razor, ASP.NET auth, or the rest of the scaffolding — only what Kestrel needs.
  • Exactly one catch-all route /{**path}. ASP.NET routing is used solely to intercept everything and hand it to our own dispatcher, HandleCatchAll. No controller matching.
  • builder.Logging.ClearProviders() — the server stays quiet on the host console; logs go through the route's logger.

Why? Because redb.Route is an integration engine, and HTTP is just transport to it — the same as Kafka or RabbitMQ. A route shouldn't know Kestrel is behind it: it gets an Exchange. ASP.NET controllers would impose their own model (attributes, model binding, ActionResult) that's redundant and alien inside a DSL route.

One Kestrel per process, not per route — and Tsak relies on it

A frequent question: "if my app already runs on ASP.NET/Kestrel (say, inside a Tsak worker), does the connector reuse the server or spawn new ones?"

First, what the connector does not do: it doesn't graft onto an external ASP.NET pipeline. The redb.Route.Http project has zero integration points with an external host (no IApplicationBuilder, no UseEndpoints, no IServer — none of it). It stands up its own Kestrel via WebApplication.CreateSlimBuilder().

But "stands up its own" ≠ "spawns instances." The key is that SharedHttpServerManager is registered in DI as a singleton (redb.Route.Http/Extensions/ServiceCollectionExtensions.cs):

public static IServiceCollection AddRedbRouteHttp(this IServiceCollection services, ...)
{
    services.AddSingleton<SharedHttpServerManager>();   // ← one server manager per process
    services.AddSingleton(sp => { var c = new HttpComponent();
        c.ServerManager = sp.GetRequiredService<SharedHttpServerManager>(); ... return c; });
    services.AddSingleton(sp => { var c = new HttpsComponent(); ... });
    ...
}
Enter fullscreen mode Exit fullscreen mode

One manager per process means one Kestrel pool, shared across all route contexts. And Tsak leans on exactly this. Its worker is a plain Host.CreateDefaultBuilder (not a WebApplication) — it has no Kestrel of its own. Even Tsak's own REST admin API (the _system context, port 9090 by default) is not a separate web server — it's an ordinary redb.Route HTTP route brought up through that same singleton manager (redb.Tsak.Core/Services/SystemContextBuilder.cs):

// Tsak registers the HTTP connector itself...
services.AddRedbRouteHttp();   // redb.Tsak.Core/Extensions/ServiceCollectionExtensions.cs

// ...and brings up its admin API as a regular route on the shared manager:
var listenUri = $"http:{host}:{port}/{{**path}}?host={host}&port={port}&inOut=true";
routeContext.AddRoutes(r => r.From(listenUri).Process(/* bridge → auth → dispatch */));
Enter fullscreen mode Exit fullscreen mode

So nothing is "forwarded from the host's Kestrel" — on the contrary, the host (Tsak) brings up Kestrel through the connector and reuses it. Any route targeting the same (host, port) joins the already-running server instead of starting a second one. Tsak even mounts its system-echo route on the admin port — and they don't collide: the specificity ordering from Part 4 separates the concrete /api/echo from the catch-all {**path} (called out in a comment in SystemContextBuilder).

Without Tsak — same thing, you just wire the manager yourself

Tsak isn't magic here: Kestrel is always brought up by SharedHttpServerManager; Tsak is merely a host that registers that manager and routes through it. In a standalone app (no Tsak) you wire the manager by hand. Here's a bare RouteContext from the Llm.HttpShell demo — no DI, no Tsak:

var ctx = new RouteContext(sp, contextId: "llm-http-shell");
ctx.AddComponent(new HttpComponent { ServerManager = new SharedHttpServerManager() });
// ...
ctx.AddRoutes(r => r.From("http:0.0.0.0:5088/api/llm/shell?inOut=true") ... );
Enter fullscreen mode Exit fullscreen mode

Or the same through DI — AddRedbRouteHttp() registers that same singleton manager for you. Either way, the first From("http:host:port/...") that starts brings up a fresh Kestrel via CreateSlimBuilder() for that (host, port) pair, and the rest of the routes on the same (host, port) join it.

Subtlety: pooling works within a single SharedHttpServerManager instance. Create two separate managers and point both at one port and you get a socket-bind conflict, not sharing. The "one Kestrel per (host, port)" guarantee comes from a shared manager — a singleton out of the box under Tsak, and your responsibility standalone.

The practical takeaway: within a process (and one manager) there are exactly as many Kestrels as there are distinct (host, port) pairs. Mounting a route on a port another redb route already listens on (including Tsak's admin port) is fine — that's the whole "don't multiply" point. A socket-bind conflict only happens if a foreign, non-redb server already grabbed the port.

Why bus frameworks like MassTransit don't have this

It's worth contrasting with MassTransit — one of the most popular .NET messaging frameworks. It has no HTTP consumer, no embedded server, no "http as a transport." And that's not an omission; it follows from the architecture.

MassTransit is a message bus over brokers: RabbitMQ, Azure Service Bus, Amazon SQS, plus Kafka/Event Hubs as "riders." Its model is asynchronous broker-mediated delivery with guarantees, retries, and sagas; consumers are keyed to a message type (IConsumer<TMessage>), not a URI endpoint. HTTP doesn't fit that picture: synchronous request/reply contradicts the async/durable bus model. So MassTransit leaves HTTP ingress to ASP.NET — you stand up a controller or minimal API and Publish/Send to the bus from there. The "HTTP → message" boundary lives outside the framework, by hand, in your host code.

redb.Route (like Apache Camel, which it follows) is built differently: it's a mediation engine, and to it HTTP is just another transport, the same as Kafka or Rabbit. An HTTP request is normalized into the same Exchange a broker message becomes, and flows through the same EIP processors. That's why From("http:...") exists as a first-class route source, the connector owns Kestrel itself, and an "HTTP → Kafka → SQL → reply" bridge is one DSL chain without leaving the framework.

These are different tools for different jobs, not "better/worse": MassTransit shines at reliable broker delivery and sagas over queues; Camel-style engines shine at stitching together heterogeneous transports and routing by content. An embedded HTTP server is a natural part of the second approach and fundamentally alien to the first.


Part 3½. "But I love controllers" — redb.Route.Controllers

This is where an indignant voice usually pipes up: "Attributes, [HttpGet], model binding — I like that, I don't want to write a .Choice() per endpoint!" Fair. That's why there's a separate package: redb.Route.Controllers. It hands you back the familiar MVC-controller ergonomics, but it does not hand you back the ASP.NET hosting model. Here's the trick.

A controller that looks like ASP.NET — but isn't

Here's a working controller (from the redb.Route.Tests.Controllers tests):

[Route("modules")]
public class ModulesController : RedbController
{
    [HttpGet]
    public string[] GetAll() => ["module1", "module2"];

    [HttpGet("{id}")]
    public string GetById([FromRoute("id")] string id) => $"module-{id}";

    [HttpPost]
    public object Create([FromBody] CreateModuleRequest request) =>
        new { created = request.Name };

    [HttpPut("{id}")]
    public object Update([FromRoute("id")] string id, [FromBody] CreateModuleRequest request) =>
        new { updated = id, name = request.Name };

    [HttpDelete("{id}")]
    public void Delete([FromRoute("id")] string id) { }
}
Enter fullscreen mode Exit fullscreen mode

Painfully familiar: [Route] on the class, [HttpGet]/[HttpPost]/[HttpPut]/[HttpDelete]/[HttpPatch] on methods (with an optional sub-template "{id}"), parameter binding via [FromBody], [FromRoute], [FromQuery], [FromHeader], [FromProperty]. Return an object and it goes out as JSON.

But two differences are fundamental:

  1. The base class is RedbController, not ControllerBase. No HttpContext, no IActionResult, no [ApiController]. Instead, two properties: Context (the route context) and Exchange (the current message). The controller sees an Exchange, not HTTP.
  2. The controller is transport-agnostic. It knows nothing about HTTP. That matters one paragraph from now.

How a controller enters a route

A controller isn't an endpoint — it's a processor inside a route. You mount it on an HTTP entry via .RedbHttpController<T>():

From("http:0.0.0.0:5088/api/{**path}?inOut=true")
    .RouteId("modules-api")
    .RedbHttpController<ModulesController>();
Enter fullscreen mode Exit fullscreen mode

Or via a registry with several controllers (or an assembly scan):

var registry = new ControllerRegistry();
registry.RegisterController(typeof(ModulesController));
registry.RegisterController(typeof(ContextsController));
// or: registry.RegisterAssembly(typeof(ModulesController).Assembly);

From("http:0.0.0.0:5088/api/{**path}?inOut=true")
    .RedbHttpController(registry);
Enter fullscreen mode Exit fullscreen mode

Note the {**path} — the HTTP consumer from Part 5 catches everything under /api, drops redbHttp.Method, redbHttp.Path, redbHttp.RouteParam.*, redbHttp.QueryParam.* into the Exchange, and the HttpControllerDispatcher parses those and finds the right action. No manual header translation:

// HttpControllerDispatcher.Process (abridged)
var method = exchange.In.GetHeader<string>("redbHttp.Method");
var path   = exchange.In.GetHeader<string>("redbHttp.Path");
var action = _registry.Resolve(method, normalizedPath, out var routeParams);
if (action is null) { WriteError(exchange, 404, "NotFound", ...); return; }
Enter fullscreen mode Exit fullscreen mode

Routing and binding — its own, not ASP.NET's

ControllerRegistry.Resolve matches (method, path) to an action segment by segment, picking the most specific one (literals beat {param}): GET /me/sessions/current beats GET /me/sessions/{id}. Same specificity principle as the shared server in Part 4, just at the controller level.

Parameter binding (ResolveHttpParameter) is exactly what the attributes promise: [FromBody] is JSON-deserialized from the byte[], [FromRoute] comes from the template, [FromQuery] from redbHttp.QueryParam.*, [FromHeader] / [FromProperty] from the Exchange headers/properties. With no attribute, it tries a route param by name, otherwise a complex type is bound from the body.

The response is assembled like this: return an object → JSON (camelCase, with UnsafeRelaxedJsonEscaping so Cyrillic and emoji don't become А), status 200; return null/void204; throw → an error envelope and 500 (a route miss → 404). The dispatcher sets status.code and redbHttp.ResponseCode, and the HTTP consumer from Part 5 picks them up. The loop closes.

Why this way, not "the ASP.NET way"

Here's the whole point. The same ModulesController, without changing a line, can be invoked over something other than HTTP:

// same controller — over gRPC
From(grpcConsumer).RedbGrpcController<ModulesController>();

// same controller — over SignalR
From(signalRConsumer).RedbSignalRController<ModulesController>();
Enter fullscreen mode Exit fullscreen mode

RedbController is transport-agnostic precisely because it works with an Exchange, not an HttpContext. An ASP.NET controller can't do that — it's welded to the HTTP pipeline. And since .RedbHttpController<T>() is just a processor, it composes with the rest of the DSL: put .Throttle() before it, .WireTap() after it, wrap it in OnException/TryCatch, combine it with the .Choice() from Part 1.

So: love controllers? Use controllers. Just know that under them is not ASP.NET MVC but the same Exchange and the same route pipeline. You get the ergonomics without inheriting the hosting model.

And this isn't theory — Tsak itself runs on it

The best proof the approach is battle-tested: Tsak's entire REST admin API is built exactly this way. ContextsController, RoutesController, ModulesController, AuthController, UsersController, SchedulerController, LogsController, and a dozen more (redb.Tsak.Core/Controllers) all inherit RedbController and carry the same [Route] / [HttpGet] / [HttpPost] / [FromRoute] / [FromQuery]. Here's a slice of the real ContextsController:

[Route("/api/contexts")]
public class ContextsController : RedbController
{
    [HttpGet("")]
    public object? ListContexts() => /* ... */;

    [HttpGet("/{name}")]
    public object? GetContext([FromRoute("name")] string name) => /* ... */;

    [HttpPost("/{name}/stop")]
    public async Task<object?> StopContext(
        [FromRoute("name")] string name,
        [FromQuery("timeoutSeconds")] int? timeoutSeconds = null) => /* ... */;
}
Enter fullscreen mode Exit fullscreen mode

Tsak registers their assembly (ControllerRegistry.RegisterAssembly) and dispatches them via ControllerDispatcherProcessor in the _system context — on the same HTTP connector as everything else. So the controllers are as production-hardened as the HTTP consumer itself: the Tsak dashboard and CLI talk to exactly these.

A full treatment of redb.Route.Controllers (the registry, the IControllerActionFilter action filters, the gRPC/SignalR dispatchers, the error envelope) is its own article in the series. Here it's just to put the "where are my controllers" question to rest.


Part 4. One Kestrel per (host, port) — SharedHttpServerManager

The least obvious part of the consumer is that multiple routes can listen on one port. If you have three From(...) on 0.0.0.0:5088 with different paths (/api/demo, /api/echo, /api/llm/ask), you get one Kestrel, not three.

SharedHttpServerManager owns this. The key is the (host, port) pair:

private readonly ConcurrentDictionary<string, ServerEntry> _servers = new(...);
private static string BuildKey(string host, int port) => $"{host}:{port}";
Enter fullscreen mode Exit fullscreen mode

Registering a route

When a consumer starts, it doesn't "create a server" — it registers a route on the server for its (host, port). The server is created lazily, on the first registration:

// HttpConsumer.Start (abridged)
_registration = _serverManager.RegisterRoute(
    host, port, path, _options.Methods, HandleRequest,
    ssl, _options.SslCertPath, _options.SslCertPassword,
    corsOptions, _options.MaxRequestBodySize, _options.Protocol);

await _serverManager.EnsureStarted(host, port, ct);
BaseUrl = _serverManager.GetBaseUrl(host, port);   // e.g. "http://localhost:5088"
Enter fullscreen mode Exit fullscreen mode

RegisterRoute drops a RouteRegistration (path template + methods + handler + CORS) into the ServerEntry's route list. EnsureStarted brings up Kestrel if it isn't running; if it already is, it's a no-op.

Dispatch: a route table of its own

Every request lands in one HandleCatchAll, which finds the matching route itself:

private static async Task HandleCatchAll(ServerEntry entry, HttpContext ctx)
{
    var match = entry.MatchRoute(ctx.Request.Path, ctx.Request.Method);

    if (match.Registration is null)
    {
        // path matched but method didn't → 405; otherwise → 404
        ctx.Response.StatusCode = match.PathMatched
            ? StatusCodes.Status405MethodNotAllowed
            : StatusCodes.Status404NotFound;
        return;
    }

    if (match.RouteValues is not null)
        ctx.Items["__redbRouteValues"] = match.RouteValues;   // {id} etc. — into the Exchange next

    await match.Registration.Handler(ctx);
}
Enter fullscreen mode Exit fullscreen mode

Two subtleties worth knowing:

1. 404 vs 405. If the path matched but the method isn't allowed, you get an honest 405 Method Not Allowed, not a 404. Small, but correct.

2. Path templates and specificity ordering. Paths are parsed via TemplateParser/TemplateMatcher from ASP.NET routing — {id} parameters and {**rest} catch-alls are supported. But the check order is not registration order; it's by descending specificity (ServerEntry.GetCompiled):

_compiled = built
    .OrderBy(x => x.spec.HasCatchAll ? 1 : 0)   // concrete paths first, catch-all last
    .ThenByDescending(x => x.spec.Literals)      // more literal segments = more specific
    .ThenBy(x => x.spec.Parameters)              // fewer parameters = more specific
    .ThenBy(x => x.order)                        // ties: stable, by registration order
    .Select(...).ToArray();
Enter fullscreen mode Exit fullscreen mode

Why? So a concrete /api/echo always beats a catch-all /{**path} registered on the same port, even if the catch-all was registered first. Without this rule, a catch-all would swallow every later route. The behavior matches what you intuitively expect from ASP.NET routing.

Lifecycle: reference counting

A server lives exactly as long as it has at least one route. When a consumer stops:

// HttpConsumer.Stop
_serverManager.UnregisterRoute(_registration);
await _serverManager.StopIfEmpty(host, port, ct);
Enter fullscreen mode Exit fullscreen mode

StopIfEmpty stops and unloads Kestrel only if no routes remain:

public async Task StopIfEmpty(string host, int port, ...)
{
    if (entry.Routes.Count > 0) return;   // someone's still listening — leave it
    _servers.TryRemove(key, out _);
    await StopServer(entry, ct);          // graceful stop with a 5s timeout
}
Enter fullscreen mode Exit fullscreen mode

So if you stop one of three routes on port 5088, the server keeps running for the other two. Stop the last one, and Kestrel goes down. This lets you add and remove routes on the fly (e.g. from a dashboard: tsak route start demo-http-echo / stop) without bouncing the whole server.

Guard against mismatched schemes

You can't bind both HTTP and HTTPS on the same (host, port) — the manager throws on registration:

if (entry.Ssl != ssl)
    throw new InvalidOperationException(
        $"Server on {host}:{port} is already registered as {(entry.Ssl ? "HTTPS" : "HTTP")}...");
Enter fullscreen mode Exit fullscreen mode

Part 5. Consumer: how an HTTP request becomes an Exchange

This is the heart of the inbound side — HttpConsumer.BuildExchange. Let's see exactly what reaches a route.

The request body

if (request.ContentLength > 0 || request.ContentType is not null)
{
    if (_options.StreamRequest)
        body = request.Body;                       // the stream as-is (passthrough)
    else
    {
        using var ms = new MemoryStream();
        await request.Body.CopyToAsync(ms, ...);
        body = ms.ToArray();                        // buffer into a byte[]
    }
}
var message = new Message(body);
Enter fullscreen mode Exit fullscreen mode

By default the body is buffered into a byte[]. That's why you almost always see .ConvertBody<string>() right after From — to turn bytes into a string. With streamRequest=true the body stays a Stream (for large uploads), and an important detail from the code comment: that stream is owned by Kestrel, Exchange.DisposeAsync does not close it, but it stays valid until the response is written.

Headers: what the connector puts into the Exchange

This is probably the single most useful reference table in the article. The consumer decomposes the request into Exchange headers under the redbHttp. prefix (HttpHeaders.cs):

Exchange header What it holds Example
redbHttp.Method HTTP method POST
redbHttp.Path request path /api/demo
redbHttp.Url full URL http://localhost:5088/api/demo?x=1
redbHttp.Port server port (int) 5088
redbHttp.Query raw query string x=1&y=2
redbHttp.QueryParam.<name> one query parameter redbHttp.QueryParam.x = 1
redbHttp.RouteParam.<name> one path-template value /users/{id}redbHttp.RouteParam.id
redbHttp.RemoteAddress client IP 127.0.0.1
<any HTTP header> as-is Content-Type, Authorization, X-Chat-Id

A few implementation details that matter:

Multi-value query/headers. If a parameter repeats (?tag=a&tag=b), the values are joined with a comma:

message.Headers[$"redbHttp.QueryParam.{qp.Key}"] = qp.Value.Count switch
{
    0 => string.Empty,
    1 => (object)qp.Value[0]!,
    _ => string.Join(",", qp.Value!)
};
Enter fullscreen mode Exit fullscreen mode

Route values from the template. Remember ctx.Items["__redbRouteValues"] from the dispatcher? Here's where they're unpacked:

if (httpContext.Items.TryGetValue("__redbRouteValues", out var rvObj)
    && rvObj is RouteValueDictionary routeValues)
{
    foreach (var (key, value) in routeValues)
        if (value is not null)
            message.Headers[$"redbHttp.RouteParam.{key}"] = value;
}
Enter fullscreen mode Exit fullscreen mode

So From("http:0.0.0.0:8080/users/{id}") gives you ${header.redbHttp.RouteParam.id} in the route.

HTTP/2 pseudo-headers are filtered out. Headers like :method, :path (HTTP/2) are skipped — if (header.Key.StartsWith(':')) continue;.

Remembering inbound header names. Subtle but important. The connector collects the names of all inbound headers into a set and stores it in the exchange properties:

exchange.Properties["redbHttp.RequestHeaderNames"] = requestHeaderNames;
Enter fullscreen mode Exit fullscreen mode

Why — becomes clear on the response side: so request headers are not reflected back into the response. Without it, an inbound Host or User-Agent could accidentally ride back to the client in the reply.

Exchange pattern: InOnly vs InOut

exchange.Pattern = _options.InOut ? ExchangePattern.InOut : ExchangePattern.InOnly;
Enter fullscreen mode Exit fullscreen mode
  • InOnly (default) — fire-and-forget. The server replies with an empty 200 OK immediately, and the route runs "in the background" relative to the response. This is a webhook receiver: "got it, thanks."
  • InOut (?inOut=true) — request/reply. The server waits for the route to finish and returns the result as the HTTP response. This is an API endpoint.

In practice: to return a body in the response you need inOut=true. Otherwise the body you assembled with .SetBody(...) goes nowhere (see WriteResponse — all body writing is under if (_options.InOut)).

The response: how an Exchange becomes HTTP again

WriteResponse assembles the HTTP response. Status code resolution order (by descending priority):

var statusCode = _options.ResponseCode;                       // 3. options default (200)
if (responseMsg.Headers.TryGetValue("redbHttp.ResponseCode", out var rc)) ...   // 1. explicit header
else if (responseMsg.Headers.TryGetValue("status.code", out var sc)) ...        // 2. transport-neutral fallback
Enter fullscreen mode Exit fullscreen mode

So from a route you can return, say, 404 just by setting a header:

.SetHeader("redbHttp.ResponseCode", 404)
Enter fullscreen mode Exit fullscreen mode

Response Content-Type follows a similar chain: redbHttp.ResponseContentTypeMessage.ContentType → options default (application/json).

Carrying headers into the response. This is where that RequestHeaderNames set earns its keep. A message header makes it into the HTTP response only if it clears several filters:

foreach (var (key, value) in responseMsg.Headers)
{
    if (value is null) continue;
    if (requestHeaderNames?.Contains(key) == true) continue;  // don't reflect request headers
    if (HttpHeaders.NonBridgedHeaders.Contains(key)) continue; // hop-by-hop and internal
    if (HttpHeaders.IsRedbHeader(key)) continue;               // redbHttp.* — internal
    if (IsInternalHeader(key)) continue;                       // redb*/Camel* — internal
    // ... set with multi-value support
}
Enter fullscreen mode Exit fullscreen mode

And a special touch for Set-Cookie and other multi-value headers — StringValues is used so ASP.NET emits multiple header lines instead of one stringified array:

StringValues sv = value switch
{
    string s => s,
    string[] arr => arr,
    IEnumerable seq when value is not string =>
        seq.Cast<object?>().Select(o => o?.ToString() ?? "").ToArray(),
    _ => value.ToString()
};
if (ContainsInvalidHeaderValueCharacters(sv)) continue;  // Kestrel rejects control / non-ASCII
httpContext.Response.Headers.TryAdd(key, sv);
Enter fullscreen mode Exit fullscreen mode

A gotcha from the repo's history. Response headers are copied always, even when the body is empty. Otherwise body-less responses (a 302 redirect, a 204 No Content, a Set-Cookie-only reply) would silently drop Location/Set-Cookie. It's called out in a code comment.


Part 6. CORS on a shared server — without app.UseCors()

CORS in ASP.NET means middleware and named policies. Here there's no ASP.NET CORS middleware at all. Instead there's one dispatch layer per server that selects the policy by the matched route. Why: a single (host, port) hosts different routes, and each can carry its own CORS policy. A classic UseCors with one policy per server can't do that.

The CORS parameters

At the endpoint level (HttpEndpointOptions):

URI parameter Property Meaning
cors=true Cors enable CORS for the route
corsOrigins=... CorsOrigins comma-separated origin whitelist, or *
corsCredentials=true CorsCredentials allow Access-Control-Allow-Credentials
— (code only) CorsOriginsResolver a HttpRequest → string? delegate for dynamic origin selection

In the fluent DSL:

.From(Http.Listen("/api").Port(8080).Cors("https://app.example.com").CorsCredentials())
Enter fullscreen mode Exit fullscreen mode

Plus global defaults for the whole component, via DI:

services.AddRedbRouteHttp(cors =>
{
    cors.Enabled = true;
    cors.Origins = "https://example.com";
});
Enter fullscreen mode Exit fullscreen mode

Endpoint parameters always override globals (HttpComponent.ApplyCorsDefaults).

No implicit *

A deliberate decision: if cors=true, you must supply either corsOrigins (including an explicit "*" for public endpoints) or a resolver. Otherwise it throws at startup (HttpEndpointOptions.Validate):

if (Cors && string.IsNullOrEmpty(CorsOrigins) && CorsOriginsResolver is null)
    throw new ArgumentException(
        "Cors=true requires CorsOrigins (use \"*\" for public endpoints) or CorsOriginsResolver to be set.");
Enter fullscreen mode Exit fullscreen mode

Why: the old implicit * is a classic footgun. Combined with credentials, the browser silently rejects it, and the developer burns an afternoon wondering why "CORS doesn't work." Far better to fail fast at startup with a clear message.

How the dispatcher works (CorsDispatchMiddleware)

The layer is installed once per server, and only if at least one route declared CORS (entry.CorsEnabled). For each request:

var route = entry.MatchByPath(requestPath);   // NOTE: by path, method-agnostic
var cors = route?.Cors;
if (cors is null) { await next(); return; }    // a route without CORS — the layer is transparent
Enter fullscreen mode Exit fullscreen mode

Matching by path, ignoring method is deliberate — so an OPTIONS preflight finds the route's policy even when OPTIONS isn't in the route's allowed-methods list.

Then origin resolution:

var resolved = ResolveOrigin(cors, ctx.Request, requestOrigin);

// wildcard+credentials footgun: the browser rejects it anyway → fail closed
if (resolved == "*" && cors.AllowCredentials)
    resolved = null;

if (resolved is not null)
{
    ctx.Response.Headers["Access-Control-Allow-Origin"] = resolved;
    AppendVary(ctx.Response.Headers, "Origin");   // mandatory, else caches mix up policies
    if (cors.AllowCredentials)
        ctx.Response.Headers["Access-Control-Allow-Credentials"] = "true";
    // ... preflight reflection below
}
Enter fullscreen mode Exit fullscreen mode

ResolveOrigin behaves like this:

  • a resolver is set → its word is final (may return a specific origin, "*", or null);
  • static "*" → emit * verbatim;
  • static whitelist → reflect the request's Origin, only if it's in the list (browsers don't understand a CSV in Access-Control-Allow-Origin, so we single-select);
  • otherwise → null (origin not allowed, no CORS headers emitted).

Preflight (OPTIONS). On preflight, the layer reflects the method and headers the browser asked for:

if (HttpMethods.IsOptions(ctx.Request.Method))
{
    var requestedMethod = ctx.Request.Headers["Access-Control-Request-Method"].ToString();
    ctx.Response.Headers["Access-Control-Allow-Methods"] = !string.IsNullOrEmpty(requestedMethod)
        ? requestedMethod : (cors.AllowedMethods ?? route!.Methods ?? "*");

    var requestedHeaders = ctx.Request.Headers["Access-Control-Request-Headers"].ToString();
    ctx.Response.Headers["Access-Control-Allow-Headers"] = !string.IsNullOrEmpty(requestedHeaders)
        ? requestedHeaders : (cors.AllowCredentials ? "Content-Type, Authorization" : "*");

    ctx.Response.Headers["Access-Control-Max-Age"] = cors.MaxAgeSeconds.ToString();  // default 86400
}

// OPTIONS always short-circuits to 204 — even when the origin was rejected
if (HttpMethods.IsOptions(ctx.Request.Method))
{
    ctx.Response.StatusCode = StatusCodes.Status204NoContent;
    return;
}
Enter fullscreen mode Exit fullscreen mode

So the whole of CORS is a careful hand-rolled implementation of the spec over Kestrel, with a correct Vary: Origin, proper preflight handling, and a guard against wildcard+credentials. No app.UseCors() anywhere.


Part 7. Streaming the response: SSE and chunked

An InOut consumer can return more than a finished body — it can return a stream, an IAsyncEnumerable<string>. This is used, for example, in the LLM connector to stream tokens. The logic lives in WriteResponse:

else if (body is IAsyncEnumerable<string> asyncStrings)
{
    var useSse = responseContentType?.Contains("text/event-stream", ...) == true;

    httpContext.Response.Headers["Cache-Control"]   = "no-cache, no-transform";
    httpContext.Response.Headers["X-Accel-Buffering"] = "no";   // don't buffer at nginx/LB

    httpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();  // turn off ASP.NET buffering

    if (useSse)
        await WriteSseStreamAsync(...);          // text/event-stream → SSE framing
    else
        await WriteChunkedTextStreamAsync(...);  // otherwise → chunked plain text
}
Enter fullscreen mode Exit fullscreen mode

The framing choice is driven by the response Content-Type (the canonical end-to-end signal, Accept ↔ Content-Type), not a private header:

  • text/event-streamServer-Sent Events: each yield → one data: frame; at the end, an event: done carrying late-bound summary headers (llm.tokens.in/out, llm.stop_reason, …) that the producer sets only after iteration finishes.
  • everything else → chunked plain text: each yield is one chunk, with a FlushAsync after each.

DisableBuffering() is critical: without it, ASP.NET would accumulate chunks and flush them in a batch, killing the whole point of streaming.


Part 8. Producer: an HTTP client with every knob

Now the outbound side — HttpProducer over HttpClient. The client is created once in ConnectAsync:

_handler = new HttpClientHandler
{
    AllowAutoRedirect = _options.FollowRedirects,        // followRedirects (default true)
    MaxAutomaticRedirections = _options.MaxRedirects      // maxRedirects (default 50)
};
_httpClient = new HttpClient(_handler)
{
    Timeout = _options.Timeout > 0
        ? TimeSpan.FromMilliseconds(_options.Timeout)     // timeout (default 30000)
        : Timeout.InfiniteTimeSpan
};
ConfigureAuthentication(_httpClient);
Enter fullscreen mode Exit fullscreen mode

Building the URL — four layers

ResolveUrl assembles the target address layer by layer:

// 1. Base URL from the endpoint, with ${...} expressions resolved
var baseUrl = _options.ResolveOption(_endpoint.BuildProducerUrl(), exchange) ?? ...;

// 2. Substitute {name} parameters from .Param(...)
baseUrl = ResolveNamedParams(baseUrl, exchange);

// 3. Query string from the redbHttp.Query header, if present
if (exchange.In.Headers.TryGetValue("redbHttp.Query", out var query) && query is string qs && qs.Length > 0)
{
    var sep = baseUrl.Contains('?') ? "&" : "?";
    return $"{baseUrl}{sep}{qs}";
}
Enter fullscreen mode Exit fullscreen mode

The base URL is built by HttpEndpoint.BuildProducerUrl() — simply scheme + host[:port]/path. And {name} parameters are substituted with URL escaping:

// ResolveNamedParams
var resolved = valueTemplate.Contains("${")
    ? (_options.ResolveOption(valueTemplate, exchange) ?? valueTemplate)
    : valueTemplate;
url = url.Replace($"{{{name}}}", Uri.EscapeDataString(resolved), ...);
Enter fullscreen mode Exit fullscreen mode

So:

.To(Http.Get("api.example.com/users/{id}/orders").Param("id", Header("userId")))
Enter fullscreen mode Exit fullscreen mode

turns at runtime into http://api.example.com/users/42/orders, where 42 comes from the userId header and is escaped.

The request method

The method comes from options (?method=POST) but can be overridden by the redbHttp.Method header:

if (exchange.In.Headers.TryGetValue("redbHttp.Method", out var hm) && hm is string methodStr)
    return new SysHttpMethod(methodStr.ToUpperInvariant());
Enter fullscreen mode Exit fullscreen mode

A body is set only for POST/PUT/PATCH (HasBody). byte[]ByteArrayContent, StreamStreamContent, anything else → StringContent in UTF-8.

Header bridging

With bridgeHeaders=true (default), Exchange headers ride out into the HTTP request — except internal and hop-by-hop ones (NonBridgedHeaders):

foreach (var (key, value) in exchange.In.Headers)
{
    if (value is null) continue;
    if (HttpHeaders.NonBridgedHeaders.Contains(key)) continue;  // Connection, TE, redbHttp.*, Content-* etc.
    if (!request.Headers.TryAddWithoutValidation(key, strValue))
        request.Content?.Headers.TryAddWithoutValidation(key, strValue);  // else try as a content header
}
Enter fullscreen mode Exit fullscreen mode

NonBridgedHeaders is the union of internal redbHttp.*, hop-by-hop (Connection, Keep-Alive, Transfer-Encoding, TE, Trailer, Upgrade, Proxy-*), and the content headers managed by HttpClient (Content-Type, Content-Length, Content-Encoding…).

Authentication

Three schemes (HttpAuthScheme):

  • Basicusername/password, the Authorization: Basic ... header is set once on the client.
  • Bearer, static — a constant token, also set once on the client.
  • Bearer, dynamic — a token expression (DynamicValue<string>.IsDynamic), resolved per request from the Exchange:
// ConfigurePerRequestAuth
if (_options.AuthScheme == HttpAuthScheme.Bearer && _options.AuthToken.Value.IsDynamic)
{
    var token = _options.AuthToken.Value.Resolve(exchange);
    if (!string.IsNullOrEmpty(token))
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
Enter fullscreen mode Exit fullscreen mode

So .BearerAuth().AuthToken(Header("jwt")) injects a fresh JWT from the header on every outbound call.

Response → Exchange

MapResponse drops the response body into Out and sets the standard headers:

outMessage.Headers["redbHttp.StatusCode"] = (int)response.StatusCode;
outMessage.Headers["redbHttp.StatusText"] = response.ReasonPhrase ?? "";
outMessage.Headers["redbHttp.Url"]        = response.RequestMessage?.RequestUri?.ToString() ?? "";
Enter fullscreen mode Exit fullscreen mode

With copyResponseHeaders=true (default) it copies the response headers plus Content-Type/Content-Length. With streamResponse=true the body becomes a Stream, not a byte[] — and then the HttpResponseMessage is deliberately not disposed right away: Exchange.DisposeAsync closes the stream.

throwOnError

By default throwOnError=true — on 4xx/5xx the producer throws an HttpRequestException (which your OnException/TryCatch will catch). Turn it off with .NoThrowOnError() if you'd rather handle statuses by hand via ${header.redbHttp.StatusCode}.


Part 9. Putting it all together — a real route

Back to MainPipelineRoutes.cs, here's the full HTTP entry with the Content-Based Router and request/reply (redb.Route.Demo):

From("http:0.0.0.0:5088/api/demo?inOut=true")
    .RouteId("demo-http-entry")

    .ConvertBody<string>()              // byte[] → string
    .Throttle(10)                       // no more than 10 req/sec
    .Log("[1-HTTP] ▶ body=${body}, contentType=${contentType}")

    .SetHeader("traceId", e => Guid.NewGuid().ToString("N")[..12])
    .Log("[1-HTTP]   traceId=${header.traceId}, mode=${header.mode}")

    .ValidateJsonSchema(MessageSchema)  // body must be JSON with a "message" field
    .IdempotentConsumer(e => GetHeader(e, "traceId") ?? "", IdempotentRepo)

    // ── Content-Based Router: route by the mode header ──
    .Choice()
        .When(e => GetHeader(e, "mode") == "full")
            .SetHeader("fastTrack", e => GetHeader(e, "priority") == "high" ? "true" : "false")
            .SetHeader("stamp.dsl", "full-branch")
        .When(e => GetHeader(e, "mode") == "short")
            .SetHeader("fastTrack", "false")
            .SetHeader("stamp.dsl", "short-branch")
        .Otherwise()
            .SetHeader("mode", "default")
            .SetHeader("stamp.dsl", "default-branch")
    .EndChoice()

    // ... in the demo: a cross-transport pipeline, SQL, WireTaps ...

    .SetHeader("Content-Type", "application/json")
    .SetBody(e => BuildResponse(e));     // inOut=true → this is the HTTP response
Enter fullscreen mode Exit fullscreen mode

Hit it with three curls — three different branches:

# full-branch
curl -X POST http://localhost:5088/api/demo \
  -H "Content-Type: application/json" -H "mode: full" -H "priority: high" \
  -d '{"message":"hello"}'

# short-branch
curl -X POST http://localhost:5088/api/demo \
  -H "Content-Type: application/json" -H "mode: short" \
  -d '{"message":"hi"}'

# default-branch (no mode)
curl -X POST http://localhost:5088/api/demo \
  -H "Content-Type: application/json" \
  -d '{"message":"yo"}'
Enter fullscreen mode Exit fullscreen mode

And right next to it, on the same port 5088, lives an echo route (EchoRoutes.cs) — a separate From, the same shared Kestrel:

From("http:0.0.0.0:5088/api/echo?inOut=true")
    .RouteId("demo-http-echo")
    .AutoStart(false)                    // dormant until you start it by hand
    .ConvertBody<string>()
    .SetHeader("Content-Type", e => /* mirror the request's Content-Type */ ...)
    .Log("[ECHO] ◀ Echoing back: ${body}");
Enter fullscreen mode Exit fullscreen mode

.AutoStart(false) is a perfect illustration of the lifecycle from Part 4: the route is registered but doesn't start with the module. Start it by hand (tsak route start demo-http-echo) and it registers on the already-running Kestrel for port 5088. Stop it, and the server stays up — because /api/demo is still listening.


Gotcha cheat sheet

  • No body in the response? Check for inOut=true. Without it the consumer returns an empty 200 OK, no matter what you do with .SetBody(...).
  • CORS "doesn't work"? With cors=true you must supply corsOrigins (or a resolver) — otherwise it throws at startup. Wildcard * + credentials is rejected by browsers, and the connector honestly returns the response with no CORS headers (fail closed).
  • Port already in use? Within a process Kestrel is shared by (host, port) through the singleton SharedHttpServerManager — routes on one port share one server (under Tsak you can even sit on the admin API's port). A bind conflict only happens if a foreign, non-redb server already took the port.
  • Request headers leaking into the response? They shouldn't — the connector tracks them (RequestHeaderNames) and won't reflect them. But if you set a header with the same name yourself, it will go out.
  • Stream arrives as one batch? SSE needs Content-Type: text/event-stream on the response; otherwise it's chunked plain text. Buffering is disabled automatically.
  • {id} didn't substitute on the producer? The parameter is supplied via .Param("id", ...); without .Param, the {id} placeholder stays in the URL as-is.
  • 405 instead of 404? That's a feature: the path matched, the method didn't. Narrow methods= or check which method you're calling with.

Wrap-up

redb.Route.Http isn't a "wrapper over a controller" — it's a transport in its own right:

  • The consumer brings up its own Kestrel (CreateSlimBuilder, no MVC), shares it across routes by (host, port) through the SharedHttpServerManager singleton, keeps its own route table with proper specificity ordering, ref-counts for lifecycle, and implements CORS by hand — one policy per route.
  • The producer is HttpClient with every knob exposed: layered URL building, {name} parameters, header bridging, Basic/Bearer (including a dynamic per-request token), response streaming, and a controllable throwOnError.
  • HTTP ↔ Exchange is a two-way bridge through redbHttp.* headers, with honest handling of multi-values, route parameters, status codes, and body-less responses.

And the Content-Based Router (.Choice().When().Otherwise()) shows what it's all for: a route makes decisions on the content of an already-decoded message and knows nothing about whether Kestrel, Kafka, or RabbitMQ sits beneath it.

Next in the series we take the next connector and the next EIP cluster. Want a specific one? Say so in the comments.


Every snippet is verified against the sources in redb.Route/src/redb.Route.Http (HttpConsumer, HttpProducer, HttpComponent, SharedHttpServerManager, HttpEndpointOptions, HttpHeaders, Fluent/HttpDsl) and redb.Route.Controllers. Examples come from the redb.Route.Demo/Routes demo project (MainPipelineRoutes, EchoRoutes, DataObservabilityRoutes) and from the production TsUM system (tsum.Api/Routes, tsum.Api/Auth) — the same routes run in production on port 5090. The controllers section is production-backed: Tsak's entire REST admin API (redb.Tsak.Core/ControllersContexts, Routes, Modules, Auth, Users, … — ~14 controllers) is built on RedbController and runs in production via ControllerDispatcherProcessor. The application-level TsUM API prefers .Process/.Choice — both approaches coexist and both are battle-tested.

Top comments (0)