ListenerHTTP1/ListenerHTTP: allow dispatching routes that aren't an exact path match #4

Closed
opened 2026-07-28 19:26:25 +00:00 by jorijnvdgraaf · 0 comments

Problem

ListenerHTTP1 and ListenerHTTP both dispatch on an exact-match route map:

std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;

Anything not present as a literal key gets a synthetic 404, and there is no way for the
application to see that request. That works for a fixed set of paths, but not for any route with
an unbounded segment:

  • /shop/<slug> — one per product
  • /order/<token> — one per order, 128-bit random
  • /posts/<id> — one per item

Pre-registering these is not possible: the token space is unbounded, and the product set changes
at runtime without a listener restart.

Proposal: a fallback handler

The smallest change that unblocks this is one optional member:

// Called for any request whose :path is not in `routes`. When unset, the
// listener keeps its current behaviour and synthesises a 404.
std::function<HTTPResponse(const HTTPRequest&)> fallback;

Dispatch becomes: exact match in routes first, then fallback if set, then the synthetic 404.

Why this shape rather than pattern matching:

  • Minimal API surface. One member, one branch in the dispatch loop. No pattern syntax to
    design, document, or get wrong.
  • No policy imposed. Consumers that already have a router keep using it. In my case
    catcrafts.net has a ParseRoute(path, query) that both the wasm frontend and the server call,
    precisely so a URL cannot mean different things to a crawler and to the app. A fallback lets the
    listener hand me the request and stay out of the way; a built-in pattern matcher would mean two
    route tables that can disagree.
  • Fully backwards compatible. Default-constructed fallback is empty, so existing behaviour
    is byte-identical and no call site changes.

Alternative, if you'd rather it be built in

Prefix routes would also work, e.g. a second map matched longest-prefix-first after exact match:

std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> prefixRoutes;

with the handler reading the remainder off HTTPRequest::path. This is more convenient for the
common case but needs a documented precedence rule (exact beats prefix, longest prefix wins), and
it still doesn't cover anything needing real pattern matching. I'd take either, and fallback
alone is enough for what I need.

Please keep the two listeners symmetric

ListenerHTTP1's docs say "Same route map shape as ListenerHTTP, so a handler can be registered
with both and served over either protocol."
That property is genuinely useful — I'd like to serve
the same handlers over HTTP/1.1 behind Caddy now and consider HTTP/3 later without touching
application code. Whichever approach is taken, applying it to both would preserve that.

## Problem `ListenerHTTP1` and `ListenerHTTP` both dispatch on an exact-match route map: ```cpp std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes; ``` Anything not present as a literal key gets a synthetic 404, and there is no way for the application to see that request. That works for a fixed set of paths, but not for any route with an unbounded segment: - `/shop/<slug>` — one per product - `/order/<token>` — one per order, 128-bit random - `/posts/<id>` — one per item Pre-registering these is not possible: the token space is unbounded, and the product set changes at runtime without a listener restart. ## Proposal: a fallback handler The smallest change that unblocks this is one optional member: ```cpp // Called for any request whose :path is not in `routes`. When unset, the // listener keeps its current behaviour and synthesises a 404. std::function<HTTPResponse(const HTTPRequest&)> fallback; ``` Dispatch becomes: exact match in `routes` first, then `fallback` if set, then the synthetic 404. Why this shape rather than pattern matching: - **Minimal API surface.** One member, one branch in the dispatch loop. No pattern syntax to design, document, or get wrong. - **No policy imposed.** Consumers that already have a router keep using it. In my case `catcrafts.net` has a `ParseRoute(path, query)` that both the wasm frontend and the server call, precisely so a URL cannot mean different things to a crawler and to the app. A fallback lets the listener hand me the request and stay out of the way; a built-in pattern matcher would mean two route tables that can disagree. - **Fully backwards compatible.** Default-constructed `fallback` is empty, so existing behaviour is byte-identical and no call site changes. ## Alternative, if you'd rather it be built in Prefix routes would also work, e.g. a second map matched longest-prefix-first after exact match: ```cpp std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> prefixRoutes; ``` with the handler reading the remainder off `HTTPRequest::path`. This is more convenient for the common case but needs a documented precedence rule (exact beats prefix, longest prefix wins), and it still doesn't cover anything needing real pattern matching. I'd take either, and `fallback` alone is enough for what I need. ## Please keep the two listeners symmetric `ListenerHTTP1`'s docs say *"Same route map shape as ListenerHTTP, so a handler can be registered with both and served over either protocol."* That property is genuinely useful — I'd like to serve the same handlers over HTTP/1.1 behind Caddy now and consider HTTP/3 later without touching application code. Whichever approach is taken, applying it to both would preserve that.
catbot 2026-07-28 19:43:08 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Catcrafts/Crafter.Network#4
No description provided.