From 4d5ef7c96bac6e374db4cdd57085258b22b94b6c Mon Sep 17 00:00:00 2001 From: catbot Date: Tue, 28 Jul 2026 19:41:57 +0000 Subject: [PATCH] feat(http1): dispatch unmatched paths to an optional fallback handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route map only answers paths that are known when the listener is built. A route with an unbounded segment — /shop/, /order/, /posts/ — 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. --- .../Crafter.Network-ListenerHTTP1.cpp | 30 ++++++++++++------- interfaces/Crafter.Network-HTTP.cppm | 9 ++++++ interfaces/Crafter.Network-ListenerHTTP1.cppm | 29 ++++++++++++++++-- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/implementations/Crafter.Network-ListenerHTTP1.cpp b/implementations/Crafter.Network-ListenerHTTP1.cpp index d45e3fc..45d764e 100644 --- a/implementations/Crafter.Network-ListenerHTTP1.cpp +++ b/implementations/Crafter.Network-ListenerHTTP1.cpp @@ -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> routes) + : ListenerHTTP1(port, std::move(routes), {}) +{} + +ListenerHTTP1::ListenerHTTP1(std::uint16_t port, + std::unordered_map> routes, + std::function fallback) : routes(std::move(routes)) + , fallback(std::move(fallback)) , impl(std::make_unique()) { 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> routes, + std::function fallback) + : listener(port, std::move(routes), std::move(fallback)) + , thread(&ListenerHTTP1::Listen, &listener) +{} + ListenerAsyncHTTP1::~ListenerAsyncHTTP1() { Stop(); } diff --git a/interfaces/Crafter.Network-HTTP.cppm b/interfaces/Crafter.Network-HTTP.cppm index ee535cf..1b40198 100644 --- a/interfaces/Crafter.Network-HTTP.cppm +++ b/interfaces/Crafter.Network-HTTP.cppm @@ -29,6 +29,15 @@ namespace Crafter { std::string body; }; + // Everything in a request target up to '?'. Both listeners dispatch on + // this when the full target is not a registered route, so `/thing?x=1` + // reaches the handler registered for `/thing`: browsers append query + // strings freely and registering every variant is not a workable API. + // Exported because a fallback handler generally needs the same split. + export inline std::string_view PathWithoutQueryHTTP(std::string_view target) { + return target.substr(0, target.find('?')); + } + export inline HTTPRequest CreateRequestHTTP(std::string method, std::string path, std::string authority) { HTTPRequest r; r.method = std::move(method); diff --git a/interfaces/Crafter.Network-ListenerHTTP1.cppm b/interfaces/Crafter.Network-ListenerHTTP1.cppm index 72f4cbd..7f2454e 100644 --- a/interfaces/Crafter.Network-ListenerHTTP1.cppm +++ b/interfaces/Crafter.Network-ListenerHTTP1.cppm @@ -8,9 +8,9 @@ import :HTTP1; #ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { - // HTTP/1.1 server over plain TCP. Same route map shape as ListenerHTTP, - // so a handler can be registered with both and served over either - // protocol. + // HTTP/1.1 server over plain TCP. Same route map and `fallback` shape as + // ListenerHTTP, so a handler can be registered with both and served over + // either protocol. // // Each accepted connection gets its own thread and is served // sequentially until the peer closes it, `Connection: close` is seen, or @@ -29,6 +29,17 @@ namespace Crafter { public: std::unordered_map> routes; + // Called for any request whose target matches no entry in `routes`, + // with the full target still in `request.path`. Lets a caller route + // paths that cannot be enumerated up front — `/order/`, + // `/shop/` — with its own matcher, instead of this class + // imposing a pattern syntax. Leave unset to keep synthesising a 404. + // + // Must be assigned before Listen(); the accept loop reads it without + // synchronisation. ListenerAsyncHTTP1 starts listening in its + // constructor, so pass the fallback to that constructor instead. + std::function fallback; + // How long a connection may stay idle between requests, and how long // a single request may take to arrive once started. Both guard // against a peer holding a thread forever. @@ -40,6 +51,10 @@ namespace Crafter { ListenerHTTP1(std::uint16_t port, std::unordered_map> routes); + ListenerHTTP1(std::uint16_t port, + std::unordered_map> routes, + std::function fallback); + ~ListenerHTTP1(); ListenerHTTP1(const ListenerHTTP1&) = delete; ListenerHTTP1(ListenerHTTP1&&) noexcept; @@ -69,6 +84,14 @@ namespace Crafter { ListenerAsyncHTTP1(std::uint16_t port, std::unordered_map> routes); + + // Fallback-aware overload. The accept loop starts inside this + // constructor, so a fallback has to be installed here rather than + // assigned to `listener.fallback` afterwards. + ListenerAsyncHTTP1(std::uint16_t port, + std::unordered_map> routes, + std::function fallback); + ~ListenerAsyncHTTP1(); void Stop(); };