From 4d5ef7c96bac6e374db4cdd57085258b22b94b6c Mon Sep 17 00:00:00 2001 From: catbot Date: Tue, 28 Jul 2026 19:41:57 +0000 Subject: [PATCH 1/4] 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(); }; -- 2.47.3 From 7a524cfdd02118391f1d11181b00b964e271c570 Mon Sep 17 00:00:00 2001 From: catbot Date: Tue, 28 Jul 2026 19:42:06 +0000 Subject: [PATCH 2/4] feat(http): give ListenerHTTP the same fallback and query-strip routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListenerHTTP1's docs promise the same route map shape as ListenerHTTP so a handler can be registered with both and served over either protocol. That only holds if the dispatch rule is the same on both, so mirror it here: exact `:path`, then the query-stripped path, then `fallback`, then the synthetic 404. The query-strip half is a behaviour change on this listener. `/thing?x=1` previously 404'd even with `/thing` registered, while HTTP/1.1 routed it — the asymmetry the shared route map was supposed to avoid. It also matters for `fallback`: without it a query string would divert a registered path to the fallback over HTTP/3 but not over HTTP/1.1. `fallback` covers `routes` only. An unmatched WebTransport CONNECT is still a 404 — a WT handler takes a session, not a request, so there is nothing sensible to hand it. MakeBidiHandler now reads the maps off `self` instead of taking them as pointer parameters. `self` was already captured and unused, and this mirrors how ListenerHTTP1 reaches its own state through Impl::owner. --- .../Crafter.Network-ListenerHTTP.cpp | 72 ++++++++++++++----- interfaces/Crafter.Network-ListenerHTTP.cppm | 48 ++++++++++++- 2 files changed, 101 insertions(+), 19 deletions(-) diff --git a/implementations/Crafter.Network-ListenerHTTP.cpp b/implementations/Crafter.Network-ListenerHTTP.cpp index b364cfd..21f6a95 100644 --- a/implementations/Crafter.Network-ListenerHTTP.cpp +++ b/implementations/Crafter.Network-ListenerHTTP.cpp @@ -157,14 +157,13 @@ struct ListenerHTTP::Impl { namespace { // Build the per-connection bidi-stream handler. Demuxes WT streams from - // HTTP/3 request streams by peeking the first varint on the wire. Lives - // as a free helper so both ListenerHTTP constructors can install it. - std::function MakeBidiHandler( - ListenerHTTP* self, PeerState* peerState, - const std::unordered_map>* routes, - const std::unordered_map>* wtRoutes) - { - return [self, peerState, routes, wtRoutes](QUICStream stream) { + // HTTP/3 request streams by peeking the first varint on the wire. Reaches + // the route maps and `fallback` through `self` rather than capturing them, + // so nothing here has to be re-plumbed when a dispatch input is added. + std::function MakeBidiHandler(ListenerHTTP* self, PeerState* peerState) { + return [self, peerState](QUICStream stream) { + const auto& routes = self->routes; + const auto& wtRoutes = self->wtRoutes; try { // ── Phase A: identify the stream kind ───────────────────── // @@ -175,7 +174,7 @@ namespace { std::size_t cursor = 0; std::uint64_t firstType = ReadVarintFromStream(stream, peeked, cursor); - if (firstType == HTTP3::kFrameWtStream && !wtRoutes->empty()) { + if (firstType == HTTP3::kFrameWtStream && !wtRoutes.empty()) { // ── WT bidi data stream — second varint is session id. std::uint64_t sessionId = ReadVarintFromStream(stream, peeked, cursor); std::vector remaining(peeked.begin() + cursor, peeked.end()); @@ -240,8 +239,8 @@ namespace { if (request.method == "CONNECT" && protoIt != request.headers.end() && protoIt->second == "webtransport") { - auto wtIt = wtRoutes->find(request.path); - if (wtIt == wtRoutes->end()) { + auto wtIt = wtRoutes.find(request.path); + if (wtIt == wtRoutes.end()) { HTTPResponse nf; nf.status = "404"; nf.body = "WebTransport route not found"; auto wire = SerializeResponse(nf); try { stream.SendSync(wire.data(), static_cast(wire.size()), true); } catch (...) {} @@ -313,10 +312,18 @@ namespace { pos += static_cast(frameLen); } + // Exact `:path`, then the query-stripped path, then the + // caller's fallback. Same precedence as ListenerHTTP1, so a + // handler set behaves identically over either protocol. + auto it = routes.find(request.path); + if (it == routes.end()) { + it = routes.find(std::string(PathWithoutQueryHTTP(request.path))); + } + const auto& route = it != routes.end() ? it->second : self->fallback; + HTTPResponse response; - auto it = routes->find(request.path); - if (it != routes->end()) { - response = it->second(request); + if (route) { + response = route(request); } else { response.status = "404"; response.body = "Not Found"; @@ -344,15 +351,31 @@ namespace { ListenerHTTP::ListenerHTTP(std::uint16_t port, QUICServerCredentials creds, std::unordered_map> r) - : ListenerHTTP(port, std::move(creds), std::move(r), {}) + : ListenerHTTP(port, std::move(creds), std::move(r), {}, {}) {} ListenerHTTP::ListenerHTTP(std::uint16_t port, QUICServerCredentials creds, std::unordered_map> r, std::unordered_map> wt) + : ListenerHTTP(port, std::move(creds), std::move(r), std::move(wt), {}) +{} + +ListenerHTTP::ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> r, + std::function fb) + : ListenerHTTP(port, std::move(creds), std::move(r), {}, std::move(fb)) +{} + +ListenerHTTP::ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> r, + std::unordered_map> wt, + std::function fb) : routes(std::move(r)) , wtRoutes(std::move(wt)) + , fallback(std::move(fb)) , alpn(HTTP3::kAlpn) , impl(std::make_unique()) { @@ -372,7 +395,7 @@ ListenerHTTP::ListenerHTTP(std::uint16_t port, return; } // Bidi: either HTTP/3 request or WT data stream. Demux inside. - auto handler = MakeBidiHandler(this, statePtr, &this->routes, &this->wtRoutes); + auto handler = MakeBidiHandler(this, statePtr); handler(std::move(stream)); }); @@ -447,6 +470,23 @@ ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port, , thread(&ListenerHTTP::Listen, &listener) {} +ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::function fallback) + : listener(port, std::move(creds), std::move(routes), std::move(fallback)) + , thread(&ListenerHTTP::Listen, &listener) +{} + +ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::unordered_map> wtRoutes, + std::function fallback) + : listener(port, std::move(creds), std::move(routes), std::move(wtRoutes), std::move(fallback)) + , thread(&ListenerHTTP::Listen, &listener) +{} + ListenerAsyncHTTP::~ListenerAsyncHTTP() { Stop(); } diff --git a/interfaces/Crafter.Network-ListenerHTTP.cppm b/interfaces/Crafter.Network-ListenerHTTP.cppm index 20076d3..a6b0ec2 100644 --- a/interfaces/Crafter.Network-ListenerHTTP.cppm +++ b/interfaces/Crafter.Network-ListenerHTTP.cppm @@ -15,9 +15,11 @@ namespace Crafter { // through the route map, and writes a response back on the same bidi // stream. ALPN is fixed to "h3". // - // Routes are keyed by `:path` (exact match). Unknown paths return a - // synthetic 404. Route handlers run on the ThreadPool — multiple requests - // on the same connection can therefore execute concurrently. + // Routes are keyed by `:path`, matched exactly and then with the query + // string stripped. A path matching nothing goes to `fallback` if one is + // set, and returns a synthetic 404 otherwise. Route handlers run on the + // ThreadPool — multiple requests on the same connection can therefore + // execute concurrently. // // WebTransport: pass a non-empty `wtRoutes` to additionally accept // extended-CONNECT requests (`:method=CONNECT, :protocol=webtransport`) @@ -33,6 +35,19 @@ namespace Crafter { // straightforward. std::unordered_map> routes; std::unordered_map> wtRoutes; + + // Called for any request whose `:path` matches no entry in `routes`, + // with the full path 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. + // Applies to `routes` only: an unmatched WebTransport CONNECT is + // still a 404, since a WT handler has a different signature. + // + // Must be assigned before Listen(); stream handlers read it without + // synchronisation. ListenerAsyncHTTP starts listening in its + // constructor, so pass the fallback to that constructor instead. + std::function fallback; std::string alpn; ListenerHTTP(std::uint16_t port, @@ -46,6 +61,19 @@ namespace Crafter { std::unordered_map> routes, std::unordered_map> wtRoutes); + // Fallback-aware overloads, for callers that construct and Listen() + // in one step (and for ListenerAsyncHTTP, which has to). + ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::function fallback); + + ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::unordered_map> wtRoutes, + std::function fallback); + ~ListenerHTTP(); ListenerHTTP(const ListenerHTTP&) = delete; ListenerHTTP(ListenerHTTP&&) noexcept; @@ -78,6 +106,20 @@ namespace Crafter { std::unordered_map> routes, std::unordered_map> wtRoutes); + // Fallback-aware overloads. The accept loop starts inside these + // constructors, so a fallback has to be installed here rather than + // assigned to `listener.fallback` afterwards. + ListenerAsyncHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::function fallback); + + ListenerAsyncHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::unordered_map> wtRoutes, + std::function fallback); + ~ListenerAsyncHTTP(); void Stop(); }; -- 2.47.3 From fc0adddbe9ed81751b3d40f7ef7dc3d3798afd0b Mon Sep 17 00:00:00 2001 From: catbot Date: Tue, 28 Jul 2026 19:42:16 +0000 Subject: [PATCH 3/4] test(http): replay one fallback route table over both listeners The point of the feature is that a URL means the same thing over either protocol, so the test states it that way: one route map plus one fallback, registered with ListenerHTTP1 and ListenerHTTP, asked the same eight questions, asserting identical answers. Covers exact routes beating the fallback, query strings still routing to the bare path, the fallback seeing the full target including the query, the fallback choosing its own status (404 and 303), a throwing fallback becoming a 500, and an unset fallback still producing the listener's own synthetic 404. Verified against a broken build both ways: dropping the fallback lookup fails 9 checks, dropping the query-strip fails 2. --- project.cpp | 1 + tests/ShouldFallbackUnknownRoutes/main.cpp | 157 +++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 tests/ShouldFallbackUnknownRoutes/main.cpp diff --git a/project.cpp b/project.cpp index cddc2cb..c8180e2 100644 --- a/project.cpp +++ b/project.cpp @@ -119,6 +119,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // crafter-network static lib via .Dependencies({ &cfg }). if (cfg.target == "x86_64-pc-linux-gnu") { cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg }); + cfg.AddTest("ShouldFallbackUnknownRoutes").Dependencies({ &cfg }); cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg }); cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg }); diff --git a/tests/ShouldFallbackUnknownRoutes/main.cpp b/tests/ShouldFallbackUnknownRoutes/main.cpp new file mode 100644 index 0000000..89e37b8 --- /dev/null +++ b/tests/ShouldFallbackUnknownRoutes/main.cpp @@ -0,0 +1,157 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// A route table whose paths cannot be enumerated up front — `/order/`, +// `/shop/` — is served through the listeners' `fallback` handler. The +// same table is registered with ListenerHTTP1 and ListenerHTTP and asked the +// same questions, because the point of the feature is that a URL means the +// same thing over either protocol. + +import Crafter.Network; +import Crafter.Thread; +import std; +using namespace Crafter; + +namespace { + constexpr std::uint16_t kPortHTTP1 = 8096; + constexpr std::uint16_t kPortHTTP1Plain = 8097; + constexpr std::uint16_t kPortHTTP3 = 8098; + + int failures = 0; + + void Check(bool condition, std::string_view what) { + if (!condition) { + std::println("FAIL: {}", what); + ++failures; + } + } + + // Every exchange the test performs, phrased so it can be replayed against + // either protocol. `body` is matched exactly; an empty expectation means + // "don't care". + struct Exchange { + std::string_view what; + std::string_view path; + std::string_view status; + std::string_view body; + }; + + constexpr std::array kExchanges = {{ + // A registered route still wins: fallback only sees what routes miss. + {"an exact route beats the fallback", "/", "200", "root"}, + {"query strings still route to the path", "/?utm=1", "200", "root"}, + // ...and everything else reaches the fallback with the target intact. + {"an unknown path reaches the fallback", "/shop/blue-mug", "200", "slug:blue-mug"}, + {"a second segment value reaches it too", "/shop/red-mug", "200", "slug:red-mug"}, + {"the fallback sees the full target", "/shop/mug?ref=x", "200", "slug:mug?ref=x"}, + {"the fallback may answer 404 itself", "/shop/", "404", "no such product"}, + {"the fallback may answer non-404", "/order/deadbeef", "303", ""}, + // A throwing fallback must be contained exactly like a throwing route. + {"a throwing fallback becomes a 500", "/boom", "500", ""}, + }}; + + std::unordered_map> MakeRoutes() { + return { + {"/", [](const HTTPRequest&) { return CreateResponseHTTP("200", "root"); }}, + }; + } + + // Stands in for an application router: the set of valid slugs and order + // tokens is only known at runtime, so none of these paths could have been + // registered in `routes`. + HTTPResponse Fallback(const HTTPRequest& request) { + const std::string_view path = PathWithoutQueryHTTP(request.path); + if (path == "/boom") throw std::runtime_error("fallback exploded"); + if (path.starts_with("/order/")) { + return CreateResponseHTTP("303", {{"location", "/"}}, ""); + } + if (path.starts_with("/shop/")) { + if (path.size() == std::string_view("/shop/").size()) { + return CreateResponseHTTP("404", "no such product"); + } + // The full target, query string and all, reached the handler. + return CreateResponseHTTP("200", std::format("slug:{}", + request.path.substr(std::string_view("/shop/").size()))); + } + return CreateResponseHTTP("404", "unrouted"); + } + + void Replay(std::string_view protocol, + const std::function& send) { + for (const Exchange& exchange : kExchanges) { + HTTPResponse response = send(exchange.path); + Check(response.status == exchange.status, + std::format("{}: {} (status {}, wanted {})", + protocol, exchange.what, response.status, exchange.status)); + if (!exchange.body.empty()) { + Check(response.body == exchange.body, + std::format("{}: {} (body '{}', wanted '{}')", + protocol, exchange.what, response.body, exchange.body)); + } + } + } +} + +int main() { + ThreadPool::Start(); + + // A hung read would otherwise stall the whole suite. + std::thread watchdog([] { + std::this_thread::sleep_for(std::chrono::seconds(30)); + std::println("timed out"); + std::cout.flush(); + std::_Exit(1); + }); + watchdog.detach(); + + try { + // ── HTTP/1.1 over TCP ──────────────────────────────────────────── + { + ListenerAsyncHTTP1 listener(kPortHTTP1, MakeRoutes(), Fallback); + ClientHTTP1 client("localhost", kPortHTTP1); + Replay("http1", [&](std::string_view path) { + return client.Send(CreateRequestHTTP("GET", std::string(path), "localhost")); + }); + + // Without a fallback the listener keeps synthesising its own 404, + // so nothing about the default behaviour moved. + ListenerAsyncHTTP1 plain(kPortHTTP1Plain, MakeRoutes()); + ClientHTTP1 plainClient("localhost", kPortHTTP1Plain); + HTTPResponse missing = plainClient.Send( + CreateRequestHTTP("GET", "/shop/blue-mug", "localhost")); + Check(missing.status == "404", "http1: no fallback still means a synthetic 404"); + Check(missing.body == "Not Found", "http1: ...with the listener's own body"); + plain.Stop(); + + listener.Stop(); + } + + // ── HTTP/3 over QUIC, same routes and same fallback ────────────── + { + QUICServerCredentials serverCreds; + serverCreds.selfSigned = true; + ListenerAsyncHTTP listener(kPortHTTP3, serverCreds, MakeRoutes(), Fallback); + + QUICClientCredentials clientCreds; + clientCreds.insecureNoServerValidation = true; + ClientHTTP client("localhost", kPortHTTP3, clientCreds); + Replay("http3", [&](std::string_view path) { + return client.Send(CreateRequestHTTP("GET", std::string(path), "localhost")); + }); + } + } catch (const std::exception& error) { + std::println("threw: {}", error.what()); + std::cout.flush(); + std::_Exit(1); + } + + if (failures != 0) { + std::println("{} check(s) failed", failures); + std::cout.flush(); + std::_Exit(1); + } + // See ShouldSendRecieveQUICStream: msquic's RegistrationClose blocks on + // outstanding connections, so skip graceful teardown once we are done. + std::cout.flush(); + std::_Exit(0); +} -- 2.47.3 From bb25250e755fb880434d4f6415bdb3bd1f2e2ed7 Mon Sep 17 00:00:00 2001 From: catbot Date: Tue, 28 Jul 2026 19:42:16 +0000 Subject: [PATCH 4/4] docs: document fallback routing for both listeners New "Routes that cannot be enumerated" section, linked from both listener sections since the hook and its precedence are identical on each. Also records the HTTP/3 query-strip change and why a fallback has to be a constructor argument on the ListenerAsync* wrappers. --- README.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 608966d..efaf1e7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Crafter.Network is a C++ networking library designed for modern C++ applications - **TCP Networking**: Client and server implementations for raw TCP connections (native only). - **QUIC Networking**: Encrypted, multi-stream transport via msquic — reliable streams for control plane, unreliable datagrams for low-latency state sync. - **HTTP/3**: Client and server implementations on top of QUIC. Uses ALPN `h3`, QUIC bidi streams for requests/responses, the mandatory unidirectional control stream + SETTINGS frame (RFC 9114 §6.2.1), the (empty) QPACK encoder + decoder unidi streams required by stricter peers like Chromium, and a built-in QPACK codec (RFC 9204) with the full static table, Huffman *decoding* (RFC 7541), and literal-only emission. The QPACK dynamic table is unused. The client is interoperable with mainstream public h3 endpoints (cloudflare, nghttp3-based servers, etc.). -- **HTTP/1.1**: Client and server over plain TCP (RFC 9112), for the large part of the world that is not ready for HTTP/3 — old proxies, CI tooling, load balancers, `curl` scripts. Shares the `HTTPRequest`/`HTTPResponse` types and the route-map API with the HTTP/3 stack, so a handler or call site moves between the two by changing the class name. Keep-alive and pipelining, `content-length` and `chunked` bodies with trailers, `Expect: 100-continue`, HEAD, automatic `Date`, and per-connection timeouts. Plaintext only — see [HTTP/1.1 Components](#http11-components). +- **HTTP/1.1**: Client and server over plain TCP (RFC 9112), for the large part of the world that is not ready for HTTP/3 — old proxies, CI tooling, load balancers, `curl` scripts. Shares the `HTTPRequest`/`HTTPResponse` types and the route-map API — including the `fallback` hook for paths that cannot be enumerated — with the HTTP/3 stack, so a handler or call site moves between the two by changing the class name. Keep-alive and pipelining, `content-length` and `chunked` bodies with trailers, `Expect: 100-continue`, HEAD, automatic `Date`, and per-connection timeouts. Plaintext only — see [HTTP/1.1 Components](#http11-components). - **WebTransport (server)**: `ListenerHTTP` accepts extended-CONNECT sessions (`:method=CONNECT, :protocol=webtransport`) negotiated on the existing h3 listener — no separate port or alternate stack. Both draft-02 and draft-07+ identifier sets are advertised in SETTINGS so current Chrome/Edge browsers connect out of the box. Per-route handlers receive a `WebTransportSession&` and can multiplex bidirectional streams over the session. - **Browser client**: Same C++ API compiled to wasm32-wasip1 and routed through `fetch()` (for `ClientHTTP`) and `WebTransport` (for `ClientQUIC`). Listeners and raw TCP are not compiled in the browser build — the browser is client-only. - **Asynchronous Operations**: Thread pool–based async operations on native; the same `*Async` API on the browser side, where it's required (no synchronous I/O in the browser event loop). @@ -28,7 +28,7 @@ The library follows a modular design using C++20 modules: - `Crafter.Network:ListenerTCP`: TCP server implementation (native only) - `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`). On browser builds this maps to `fetch()`. - `Crafter.Network:ListenerHTTP`: HTTP/3 + WebTransport server (ALPN `h3`, native only) -- `Crafter.Network:HTTP`: HTTP request/response types and constructors, shared by every HTTP version +- `Crafter.Network:HTTP`: HTTP request/response types, constructors, and `PathWithoutQueryHTTP`, shared by every HTTP version - `Crafter.Network:ClientHTTP1`: HTTP/1.1 client over TCP (native only) - `Crafter.Network:ListenerHTTP1`: HTTP/1.1 server over TCP (native only) - `Crafter.Network:HTTP1`: HTTP/1.1 wire format — serialisation plus an incremental parser (RFC 9112). Transport-free; usable on its own to speak HTTP/1.1 over some other byte stream. Native only, for the same reason as `:HTTP3` @@ -91,7 +91,7 @@ Crafter::ListenerHTTP listener(8082, creds, routes); listener.Listen(); ``` -The `HTTPRequest` exposes the four HTTP/3 pseudo-headers (`method`, `scheme`, `authority`, `path`) as named struct fields rather than mixing them into the regular `headers` map. Routes are dispatched by exact match on `path`; unmatched paths return a synthetic 404. +The `HTTPRequest` exposes the four HTTP/3 pseudo-headers (`method`, `scheme`, `authority`, `path`) as named struct fields rather than mixing them into the regular `headers` map. Routes are dispatched by exact match on `path` and then on the query-stripped path; anything still unmatched goes to [`fallback`](#routes-that-cannot-be-enumerated) if one is set and returns a synthetic 404 otherwise. ### HTTP/1.1 Components @@ -121,7 +121,7 @@ Crafter::ListenerAsyncHTTP1 listener(8080, std::move(routes)); Each accepted connection gets its own thread and is served sequentially until the peer closes it, a `Connection: close` is seen, or a timeout expires (`keepAliveTimeout`, default 15 s between requests; `requestTimeout`, default 30 s for one request to arrive). A dedicated thread rather than a ThreadPool task is deliberate: keep-alive connections are idle most of their life and would otherwise pin every pool thread. -Routing matches `path` exactly and then falls back to the query-stripped path, so `/thing?x=1` reaches the handler registered for `/thing` while the handler still sees the full target in `request.path`. A handler that throws becomes a 500; an unknown path a 404; a request we refuse to parse a 400. `Date` is stamped automatically unless the handler set one, HEAD returns the headers a GET would have produced with no body, and a handler can end the connection by answering with a `connection: close` header. +Routing matches `path` exactly and then falls back to the query-stripped path, so `/thing?x=1` reaches the handler registered for `/thing` while the handler still sees the full target in `request.path`. A handler that throws becomes a 500; an unknown path a 404 (or `fallback`, see [Routes that cannot be enumerated](#routes-that-cannot-be-enumerated)); a request we refuse to parse a 400. `Date` is stamped automatically unless the handler set one, HEAD returns the headers a GET would have produced with no body, and a handler can end the connection by answering with a `connection: close` header. Implemented: keep-alive, pipelining, `content-length` and `chunked` request bodies with trailers, `Expect: 100-continue`, absolute-form request targets, obs-fold, and HTTP/1.0 peers (which only get connection reuse when they ask for it). Not implemented: TLS, CONNECT tunnels, `Upgrade`, and chunked *responses* — handlers return a complete body, so responses are always `content-length` framed. @@ -145,6 +145,27 @@ if (parser.Complete()) { } ``` +### Routes that cannot be enumerated + +The route map only answers paths known when the listener is built. `/shop/`, `/order/`, `/posts/` cannot be pre-registered — the token space is unbounded and the product set changes while the server runs. Both listeners therefore take an optional `fallback`, called for any request the route map missed, with the full target still in `request.path`: + +```cpp +auto router = [](const Crafter::HTTPRequest& request) { + auto path = Crafter::PathWithoutQueryHTTP(request.path); // everything up to '?' + if (path.starts_with("/shop/")) return RenderProduct(path.substr(6)); + return Crafter::CreateResponseHTTP("404", "Not Found"); +}; + +Crafter::ListenerAsyncHTTP1 listener(8080, std::move(routes), router); // HTTP/1.1 +Crafter::ListenerAsyncHTTP quic(4443, creds, std::move(routes), router); // same handler over HTTP/3 +``` + +Dispatch precedence is the same on both: exact `path`, then the query-stripped path, then `fallback`, then a synthetic 404. So a fallback only ever sees what the route map did not claim, and leaving it unset keeps the previous behaviour exactly. + +This is deliberately a hook rather than a pattern-matching syntax. Consumers that already have a router — one shared between a wasm frontend and the server, say, 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. A throwing fallback becomes a 500, like any other handler. On `ListenerHTTP` it applies to `routes` only; an unmatched WebTransport CONNECT is still a 404, since a WT handler has a different signature. + +`fallback` is a plain public member on `ListenerHTTP`/`ListenerHTTP1` and may be assigned before `Listen()`. The `ListenerAsync*` wrappers start accepting inside their constructor, so there they have to be passed as the trailing constructor argument shown above — assigning afterwards races the accept loop. + ### WebTransport Components `ListenerHTTP` has a WT-aware constructor overload that takes a second route map keyed by `:path`. When the map is non-empty the listener advertises both draft-02 (`SETTINGS_ENABLE_WEBTRANSPORT = 0x2b603742`) and draft-07+ (`SETTINGS_WT_MAX_SESSIONS = 0xc671706a`) identifiers in its SETTINGS frame so current browsers connect. An extended-CONNECT request (`:method=CONNECT, :protocol=webtransport`) whose `:path` matches a registered route is accepted with a `200` (no FIN), upgraded into a `WebTransportSession`, and dispatched on the ThreadPool. Plain HTTP/3 routes and WT routes coexist on the same listener and port. @@ -220,6 +241,7 @@ The library includes tests covering: - HTTP/1.1 large body transfer (`ShouldSendRecieveLargeHTTP1`) — 10 MiB in both directions on one connection - HTTP/1.1 interop (`ShouldInteropCurlHTTP1`) — `curl` against `ListenerHTTP1` (keep-alive reuse, chunked upload, `Expect: 100-continue`, HEAD) and `ClientHTTP1` against python3's `http.server`, which answers HTTP/1.0 with `Connection: close` - HTTP/1.1 under abuse (`ShouldSurviveAbuseHTTP1`) — 24 concurrent keep-alive clients, peers that vanish mid-request or send garbage, and a stalled peer that must be timed out +- Fallback routing (`ShouldFallbackUnknownRoutes`) — one route map plus a `fallback` replayed over both `ListenerHTTP1` and `ListenerHTTP`, asserting identical answers: exact routes win, query strings still route to the bare path, the fallback sees the full target, a throwing fallback is a 500, and an unset fallback still means a synthetic 404 The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. `ShouldInteropCurlHTTP1` skips whichever half is unavailable when `curl` or `python3` is not installed, so it passes on a bare machine — install both to actually exercise it. -- 2.47.3