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. 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/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-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(); }; 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(); }; 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); +}