Add a fallback handler for routes that cannot be enumerated #5
8 changed files with 339 additions and 37 deletions
30
README.md
30
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/<slug>`, `/order/<token>`, `/posts/<id>` 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void(QUICStream)> MakeBidiHandler(
|
||||
ListenerHTTP* self, PeerState* peerState,
|
||||
const std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>>* routes,
|
||||
const std::unordered_map<std::string, std::function<void(WebTransportSession&)>>* 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<void(QUICStream)> 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<char> 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<std::uint32_t>(wire.size()), true); } catch (...) {}
|
||||
|
|
@ -313,10 +312,18 @@ namespace {
|
|||
pos += static_cast<std::size_t>(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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> r,
|
||||
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wt)
|
||||
: ListenerHTTP(port, std::move(creds), std::move(r), std::move(wt), {})
|
||||
{}
|
||||
|
||||
ListenerHTTP::ListenerHTTP(std::uint16_t port,
|
||||
QUICServerCredentials creds,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> r,
|
||||
std::function<HTTPResponse(const HTTPRequest&)> fb)
|
||||
: ListenerHTTP(port, std::move(creds), std::move(r), {}, std::move(fb))
|
||||
{}
|
||||
|
||||
ListenerHTTP::ListenerHTTP(std::uint16_t port,
|
||||
QUICServerCredentials creds,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> r,
|
||||
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wt,
|
||||
std::function<HTTPResponse(const HTTPRequest&)> fb)
|
||||
: routes(std::move(r))
|
||||
, wtRoutes(std::move(wt))
|
||||
, fallback(std::move(fb))
|
||||
, alpn(HTTP3::kAlpn)
|
||||
, impl(std::make_unique<Impl>())
|
||||
{
|
||||
|
|
@ -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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||
std::function<HTTPResponse(const HTTPRequest&)> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wtRoutes,
|
||||
std::function<HTTPResponse(const HTTPRequest&)> fallback)
|
||||
: listener(port, std::move(creds), std::move(routes), std::move(wtRoutes), std::move(fallback))
|
||||
, thread(&ListenerHTTP::Listen, &listener)
|
||||
{}
|
||||
|
||||
ListenerAsyncHTTP::~ListenerAsyncHTTP() {
|
||||
Stop();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> 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/<token>`,
|
||||
// `/shop/<slug>` — 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<HTTPResponse(const HTTPRequest&)> fallback;
|
||||
std::string alpn;
|
||||
|
||||
ListenerHTTP(std::uint16_t port,
|
||||
|
|
@ -46,6 +61,19 @@ namespace Crafter {
|
|||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||
std::function<HTTPResponse(const HTTPRequest&)> fallback);
|
||||
|
||||
ListenerHTTP(std::uint16_t port,
|
||||
QUICServerCredentials creds,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wtRoutes,
|
||||
std::function<HTTPResponse(const HTTPRequest&)> fallback);
|
||||
|
||||
~ListenerHTTP();
|
||||
ListenerHTTP(const ListenerHTTP&) = delete;
|
||||
ListenerHTTP(ListenerHTTP&&) noexcept;
|
||||
|
|
@ -78,6 +106,20 @@ namespace Crafter {
|
|||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||
std::function<HTTPResponse(const HTTPRequest&)> fallback);
|
||||
|
||||
ListenerAsyncHTTP(std::uint16_t port,
|
||||
QUICServerCredentials creds,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wtRoutes,
|
||||
std::function<HTTPResponse(const HTTPRequest&)> fallback);
|
||||
|
||||
~ListenerAsyncHTTP();
|
||||
void Stop();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> 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/<token>`,
|
||||
// `/shop/<slug>` — 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<HTTPResponse(const HTTPRequest&)> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes);
|
||||
|
||||
ListenerHTTP1(std::uint16_t port,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||
std::function<HTTPResponse(const HTTPRequest&)> fallback);
|
||||
|
||||
~ListenerHTTP1();
|
||||
ListenerHTTP1(const ListenerHTTP1&) = delete;
|
||||
ListenerHTTP1(ListenerHTTP1&&) noexcept;
|
||||
|
|
@ -69,6 +84,14 @@ namespace Crafter {
|
|||
|
||||
ListenerAsyncHTTP1(std::uint16_t port,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||
std::function<HTTPResponse(const HTTPRequest&)> fallback);
|
||||
|
||||
~ListenerAsyncHTTP1();
|
||||
void Stop();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> 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 });
|
||||
|
|
|
|||
157
tests/ShouldFallbackUnknownRoutes/main.cpp
Normal file
157
tests/ShouldFallbackUnknownRoutes/main.cpp
Normal file
|
|
@ -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/<token>`,
|
||||
// `/shop/<slug>` — 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<Exchange, 8> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> 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<HTTPResponse(std::string_view)>& 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);
|
||||
}
|
||||
Loading…
Reference in a new issue