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:
parent
b758419007
commit
337ce32eca
12 changed files with 2175 additions and 4 deletions
74
tests/ShouldSendRecieveLargeHTTP1/main.cpp
Normal file
74
tests/ShouldSendRecieveLargeHTTP1/main.cpp
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// A body far larger than any socket buffer, in both directions. This is
|
||||
// where short writes, partial reads and off-by-one framing show up: the
|
||||
// request has to survive being split across dozens of send() calls and the
|
||||
// response across dozens of recv() calls.
|
||||
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-uniform payload, so a mangled offset can't accidentally compare
|
||||
// equal the way a run of identical bytes would.
|
||||
std::string MakePayload(std::size_t size) {
|
||||
std::string payload(size, '\0');
|
||||
for (std::size_t i = 0; i < size; ++i) {
|
||||
payload[i] = static_cast<char>('A' + (i * 7 + i / 251) % 26);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
constexpr std::size_t payloadSize = 10 * 1024 * 1024;
|
||||
const std::string payload = MakePayload(payloadSize);
|
||||
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
routes["/echo"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", request.body);
|
||||
};
|
||||
routes["/size"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", std::to_string(request.body.size()));
|
||||
};
|
||||
|
||||
try {
|
||||
ListenerAsyncHTTP1 listener(8092, std::move(routes));
|
||||
ClientHTTP1 client("localhost", 8092);
|
||||
|
||||
HTTPResponse size = client.Send(CreateRequestHTTP("POST", "/size", "localhost", payload));
|
||||
Check(size.status == "200", "large POST status");
|
||||
Check(size.body == std::to_string(payloadSize), "the server received every byte");
|
||||
|
||||
// Same again in both directions, and on the same connection, so a
|
||||
// leftover byte from the previous exchange would desynchronise the
|
||||
// framing and show up here.
|
||||
HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost", payload));
|
||||
Check(echoed.status == "200", "large echo status");
|
||||
Check(echoed.body.size() == payloadSize, "the response body is the right length");
|
||||
Check(echoed.body == payload, "the response body is byte-for-byte the request");
|
||||
Check(listener.listener.AcceptedCount() == 1, "both transfers shared one connection");
|
||||
|
||||
listener.Stop();
|
||||
} catch (const std::exception& error) {
|
||||
std::println("threw: {}", error.what());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println("{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in a new issue