feat(http1): dispatch unmatched paths to an optional fallback handler

The route map only answers paths that are known when the listener is
built. A route with an unbounded segment — /shop/<slug>, /order/<token>,
/posts/<id> — cannot be pre-registered: the token space is unbounded and
the product set changes while the server runs. Every such request became
a synthetic 404 that the application never got to see.

Add one optional member, called for anything `routes` missed, with the
full target still in request.path. Precedence is exact path, then the
query-stripped path, then fallback, then the 404 as before, so a
default-constructed listener behaves byte-identically and no call site
changes.

A hook rather than a pattern syntax: consumers that already have a
router — one shared between a wasm frontend and the server, so a URL
cannot mean different things to a crawler and to the app — keep using
it, and there is no second route table to disagree with the first.

PathWithoutQuery moves out of this file into :HTTP as an exported
PathWithoutQueryHTTP, since ListenerHTTP now needs the same split and a
fallback handler almost always does too.

ListenerAsyncHTTP1 starts accepting inside its constructor, so assigning
`listener.fallback` afterwards would race the accept loop; it gets a
constructor overload that installs the fallback before the thread
starts.
This commit is contained in:
catbot 2026-07-28 19:41:57 +00:00
commit 4d5ef7c96b
3 changed files with 54 additions and 14 deletions

View file

@ -43,14 +43,6 @@ namespace {
return ReadStatus::Data;
}
}
// Everything up to '?' — used as the fallback route key so `/thing?x=1`
// reaches the handler registered for `/thing`. Browsers append query
// strings freely, and requiring handlers to register every variant is
// not a workable API.
std::string_view PathWithoutQuery(std::string_view target) {
return target.substr(0, target.find('?'));
}
}
// One accepted connection: the socket, the thread serving it, and a flag
@ -128,14 +120,15 @@ struct ListenerHTTP1::Impl {
const auto& routes = owner->routes;
auto route = routes.find(request.path);
if (route == routes.end()) {
const std::string bare(PathWithoutQuery(request.path));
const std::string bare(PathWithoutQueryHTTP(request.path));
route = routes.find(bare);
}
if (route == routes.end()) {
const auto& handler = route != routes.end() ? route->second : owner->fallback;
if (!handler) {
return CreateResponseHTTP("404", "Not Found");
}
try {
return route->second(request);
return handler(request);
} catch (const std::exception& error) {
return CreateResponseHTTP("500", std::string(error.what()));
} catch (...) {
@ -242,7 +235,14 @@ struct ListenerHTTP1::Impl {
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
: ListenerHTTP1(port, std::move(routes), {})
{}
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
std::function<HTTPResponse(const HTTPRequest&)> fallback)
: routes(std::move(routes))
, fallback(std::move(fallback))
, impl(std::make_unique<Impl>())
{
impl->owner = this;
@ -254,6 +254,7 @@ ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
ListenerHTTP1::ListenerHTTP1(ListenerHTTP1&& other) noexcept
: routes(std::move(other.routes))
, fallback(std::move(other.fallback))
, keepAliveTimeout(other.keepAliveTimeout)
, requestTimeout(other.requestTimeout)
, limits(other.limits)
@ -318,6 +319,13 @@ ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
, thread(&ListenerHTTP1::Listen, &listener)
{}
ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
std::function<HTTPResponse(const HTTPRequest&)> fallback)
: listener(port, std::move(routes), std::move(fallback))
, thread(&ListenerHTTP1::Listen, &listener)
{}
ListenerAsyncHTTP1::~ListenerAsyncHTTP1() {
Stop();
}