feat(http1): add an HTTP/1.1 client and listener

HTTP/3-only is not a deployable position yet: plenty of clients, proxies
and CI tooling still speak nothing but HTTP/1.1. This adds that path
using the request/response types the HTTP/3 stack already uses, so a
route handler or call site moves between the two protocols by changing
the class name.

- :HTTP1 — transport-free wire format. Serialisation with the framing
  headers owned by the serialiser, and an incremental parser that takes
  arbitrary socket chunks and yields one message at a time: keep-alive,
  pipelining, content-length and chunked bodies (with trailers),
  read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and
  Expect: 100-continue. Ambiguous framing is rejected rather than
  guessed at (content-length with transfer-encoding, disagreeing
  content-lengths, whitespace before a colon), and CR/LF in a value we
  are asked to serialise is refused.
- ClientHTTP1 — persistent connection, redialling once when a pooled
  connection turns out to have been closed by the peer, which is the
  race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a
  response byte has arrived.
- ListenerHTTP1 — one thread per connection (keep-alive connections are
  idle most of their life and would pin every ThreadPool thread),
  automatic Date, HEAD, 100-continue, handler-requested close, idle and
  request timeouts, and 400/404/500 responses. Routes fall back to the
  query-stripped path so `/thing?x=1` reaches the handler for `/thing`.

No TLS: this is `http://` only. Encrypted traffic still goes over
HTTP/3, or through a TLS-terminating proxy.

Tests: codec unit tests including the malformed inputs above, a
client/server round-trip, keep-alive and stale-connection recovery, a
10 MiB body both ways, and interop both directions against curl and
python3's http.server (skipped when those are not installed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-07-27 00:45:09 +00:00
commit 337ce32eca
12 changed files with 2175 additions and 4 deletions

View file

@ -0,0 +1,89 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// HTTP/1.1 connection reuse. Unlike HTTP/3 — where multiplexing is the
// transport's job — keep-alive here means the same socket carries request
// after request, and both ends have to agree on where each message ends.
// This test pins that down: repeated requests must not open new
// connections, `Connection: close` must end the connection, and the client
// must recover when the server closes a pooled connection under it.
import Crafter.Network;
import Crafter.Thread;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
}
int main() {
ThreadPool::Start();
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
routes["/"] = [](const HTTPRequest&) {
return CreateResponseHTTP("200", "Hello World!");
};
routes["/once"] = [](const HTTPRequest&) {
// A handler that asks for the connection to end after this reply.
return CreateResponseHTTP("200", {{"connection", "close"}}, "bye");
};
try {
ListenerAsyncHTTP1 listener(8091, std::move(routes));
ClientHTTP1 client("localhost", 8091);
for (int i = 0; i < 5; ++i) {
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
Check(response.status == "200", "keep-alive request status");
Check(response.body == "Hello World!", "keep-alive request body");
Check(client.Connected(), "the client keeps the socket between requests");
}
Check(listener.listener.AcceptedCount() == 1, "five requests shared one connection");
// A handler that answers with `connection: close` ends the
// connection after its response; the client must notice and dial
// again for the next request instead of writing into a dead socket.
HTTPResponse closing = client.Send(CreateRequestHTTP("GET", "/once", "localhost"));
Check(closing.status == "200", "close-flagged response still arrives");
Check(closing.body == "bye", "close-flagged response body");
Check(!client.Connected(), "the client drops a connection the server closed");
HTTPResponse after = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
Check(after.body == "Hello World!", "the next request redials transparently");
Check(listener.listener.AcceptedCount() == 2, "exactly one extra connection was opened");
// Force the stale-connection race that HTTP/1.1 keep-alive cannot
// avoid: the server goes away while a pooled connection looks
// usable. The client is expected to replay the request on a fresh
// connection rather than surface the error.
Check(client.Connected(), "a connection is pooled before the restart");
listener.Stop();
ListenerAsyncHTTP1 restarted(8091, {{"/", [](const HTTPRequest&) {
return CreateResponseHTTP("200", "Hello Again!");
}}});
HTTPResponse recovered = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
Check(recovered.body == "Hello Again!", "the client recovers from a stale pooled connection");
Check(restarted.listener.AcceptedCount() == 1, "recovery dialled the new listener");
restarted.Stop();
} catch (const std::exception& error) {
std::println("threw: {}", error.what());
ThreadPool::Stop();
return 1;
}
ThreadPool::Stop();
if (failures != 0) {
std::println("{} check(s) failed", failures);
return 1;
}
return 0;
}