Add a fallback handler for routes that cannot be enumerated #5

Merged
catbot merged 4 commits from claude/issue-4 into master 2026-07-28 19:43:08 +00:00
Member

Adds the optional fallback member proposed in #4 to both ListenerHTTP1 and ListenerHTTP, and makes the two dispatch identically.

Resolves #4

What changed

std::function<HTTPResponse(const HTTPRequest&)> fallback on both listeners, called for any request the route map missed, with the full target still in request.path. Precedence is the same on each:

  1. exact path / :path
  2. the query-stripped path
  3. fallback, if set
  4. the synthetic 404, as before

A default-constructed fallback is empty, so existing behaviour is unchanged and no call site moves. Took the hook rather than a pattern syntax, per the issue: a consumer with its own ParseRoute keeps using it, and there is no second route table to disagree with the first.

auto router = [](const Crafter::HTTPRequest& request) {
    auto path = Crafter::PathWithoutQueryHTTP(request.path);
    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

Three things worth a look

A behaviour change on ListenerHTTP. It now matches the query-stripped path too. /thing?x=1 previously 404'd on HTTP/3 even with /thing registered, while HTTP/1.1 routed it — exactly the asymmetry the shared route map is supposed to prevent. It also matters for this feature: without it, a query string would divert a registered path to the fallback over HTTP/3 but not over HTTP/1.1. Worth flagging in case anything depends on the old 404.

Constructor overloads on the ListenerAsync* wrappers. fallback is a plain public member, fine to assign before Listen(). But the async wrappers start accepting inside their constructor, so listener.fallback = f afterwards races the accept loop. They take it as a trailing constructor argument instead. The sync classes get matching overloads for symmetry.

Scope. fallback covers routes only; an unmatched WebTransport CONNECT is still a 404, since a WT handler takes a session rather than a request. PathWithoutQuery moved out of the HTTP/1.1 implementation into :HTTP as an exported PathWithoutQueryHTTP — both listeners need it now, and a fallback handler almost always wants the same split.

Testing

New ShouldFallbackUnknownRoutes states the symmetry property directly: one route map plus one fallback, registered with both listeners, asked the same eight questions, asserting identical answers. Covers exact routes beating the fallback, query strings 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 404.

Confirmed it actually bites: removing the fallback lookup from the HTTP/3 dispatch fails 9 checks, removing the query-strip fails 2.

crafter-build test14 passed, plus ShouldSend which times out. That one fetches cloudflare-quic.com:443 over UDP/443, which this sandbox blocks at the network-namespace level (sendtoEPERM); it times out identically on a pristine master checkout, and the README already notes it needs outbound UDP/443. Nothing in this branch touches ClientHTTP or the QUIC transport.

Also verified crafter-build --target=wasm32-wasip1 still compiles (:HTTP is in the browser build) and that examples/SimpleClient — a real 4-argument ListenerAsyncHTTP caller — is unaffected by the new overloads.

🤖 Generated with Claude Code

Adds the optional `fallback` member proposed in #4 to both `ListenerHTTP1` and `ListenerHTTP`, and makes the two dispatch identically. Resolves #4 ## What changed `std::function<HTTPResponse(const HTTPRequest&)> fallback` on both listeners, called for any request the route map missed, with the full target still in `request.path`. Precedence is the same on each: 1. exact `path` / `:path` 2. the query-stripped path 3. `fallback`, if set 4. the synthetic 404, as before A default-constructed `fallback` is empty, so existing behaviour is unchanged and no call site moves. Took the hook rather than a pattern syntax, per the issue: a consumer with its own `ParseRoute` keeps using it, and there is no second route table to disagree with the first. ```cpp auto router = [](const Crafter::HTTPRequest& request) { auto path = Crafter::PathWithoutQueryHTTP(request.path); 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 ``` ## Three things worth a look **A behaviour change on `ListenerHTTP`.** It now matches the query-stripped path too. `/thing?x=1` previously 404'd on HTTP/3 even with `/thing` registered, while HTTP/1.1 routed it — exactly the asymmetry the shared route map is supposed to prevent. It also matters for this feature: without it, a query string would divert a *registered* path to the fallback over HTTP/3 but not over HTTP/1.1. Worth flagging in case anything depends on the old 404. **Constructor overloads on the `ListenerAsync*` wrappers.** `fallback` is a plain public member, fine to assign before `Listen()`. But the async wrappers start accepting inside their constructor, so `listener.fallback = f` afterwards races the accept loop. They take it as a trailing constructor argument instead. The sync classes get matching overloads for symmetry. **Scope.** `fallback` covers `routes` only; an unmatched WebTransport CONNECT is still a 404, since a WT handler takes a session rather than a request. `PathWithoutQuery` moved out of the HTTP/1.1 implementation into `:HTTP` as an exported `PathWithoutQueryHTTP` — both listeners need it now, and a fallback handler almost always wants the same split. ## Testing New `ShouldFallbackUnknownRoutes` states the symmetry property directly: one route map plus one fallback, registered with both listeners, asked the same eight questions, asserting identical answers. Covers exact routes beating the fallback, query strings 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 404. Confirmed it actually bites: removing the fallback lookup from the HTTP/3 dispatch fails 9 checks, removing the query-strip fails 2. `crafter-build test` — **14 passed**, plus `ShouldSend` which times out. That one fetches `cloudflare-quic.com:443` over UDP/443, which this sandbox blocks at the network-namespace level (`sendto` → `EPERM`); it times out identically on a pristine `master` checkout, and the README already notes it needs outbound UDP/443. Nothing in this branch touches `ClientHTTP` or the QUIC transport. Also verified `crafter-build --target=wasm32-wasip1` still compiles (`:HTTP` is in the browser build) and that `examples/SimpleClient` — a real 4-argument `ListenerAsyncHTTP` caller — is unaffected by the new overloads. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The route map only answers paths that are known when the listener is
built. A route with an unbounded segment — /shop/<slug>, /order/<token>,
/posts/<id> — cannot be pre-registered: the token space is unbounded and
the product set changes while the server runs. Every such request became
a synthetic 404 that the application never got to see.

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

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

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

ListenerAsyncHTTP1 starts accepting inside its constructor, so assigning
`listener.fallback` afterwards would race the accept loop; it gets a
constructor overload that installs the fallback before the thread
starts.
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.
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.
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.
catbot merged commit e1bb116b2d into master 2026-07-28 19:43:08 +00:00
catbot deleted branch claude/issue-4 2026-07-28 19:43:08 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Catcrafts/Crafter.Network!5
No description provided.