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
155
implementations/Crafter.Network-ClientHTTP1.cpp
Normal file
155
implementations/Crafter.Network-ClientHTTP1.cpp
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module;
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <cerrno>
|
||||
|
||||
module Crafter.Network:ClientHTTP1_impl;
|
||||
import :ClientHTTP1;
|
||||
import :ClientTCP;
|
||||
import :HTTP;
|
||||
import :HTTP1;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
// Read whatever is available, waiting at most `timeout`. Returns 0 on a
|
||||
// clean close by the peer. ClientTCP::RecieveSync() can't express a
|
||||
// timeout — and a server that accepts the connection and then says
|
||||
// nothing would hang Send() forever — so the read is done here.
|
||||
std::size_t ReadSome(int socketid, char* buffer, std::size_t size,
|
||||
std::chrono::milliseconds timeout) {
|
||||
for (;;) {
|
||||
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
|
||||
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
|
||||
if (ready < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno));
|
||||
}
|
||||
if (ready == 0) throw std::runtime_error("timed out waiting for the response");
|
||||
|
||||
const auto read = recv(socketid, buffer, size, 0);
|
||||
if (read < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno));
|
||||
}
|
||||
return static_cast<std::size_t>(read);
|
||||
}
|
||||
}
|
||||
|
||||
// Host header value. The port is elided when it is the scheme default,
|
||||
// which is what every other HTTP/1.1 client on the wire does and what
|
||||
// virtual-host matching on the far side tends to expect.
|
||||
std::string DefaultAuthority(const std::string& host, std::uint16_t port) {
|
||||
if (port == 80) return host;
|
||||
return host + ":" + std::to_string(port);
|
||||
}
|
||||
}
|
||||
|
||||
struct ClientHTTP1::Impl {
|
||||
std::string host;
|
||||
std::uint16_t port;
|
||||
std::unique_ptr<ClientTCP> tcp;
|
||||
std::chrono::milliseconds timeout{30000};
|
||||
|
||||
void Connect() {
|
||||
tcp = std::make_unique<ClientTCP>(host, port);
|
||||
}
|
||||
|
||||
void Close() {
|
||||
tcp.reset();
|
||||
}
|
||||
|
||||
// One request/response exchange on the current connection. `received`
|
||||
// is set as soon as any response byte arrives, which is what decides
|
||||
// whether a failure is safe to replay on a new connection.
|
||||
HTTPResponse Exchange(const std::string& wire, std::string_view method,
|
||||
const HTTP1::MessageLimits& limits, bool& received) {
|
||||
tcp->Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
|
||||
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits);
|
||||
parser.SetRequestMethod(method);
|
||||
std::vector<char> chunk(16 * 1024);
|
||||
while (!parser.Complete()) {
|
||||
const std::size_t read = ReadSome(tcp->socketid, chunk.data(), chunk.size(), timeout);
|
||||
if (read == 0) {
|
||||
// Peer closed. Completes a response framed by close;
|
||||
// anything else throws out of Finish().
|
||||
parser.Finish();
|
||||
break;
|
||||
}
|
||||
received = true;
|
||||
parser.Feed(chunk.data(), read);
|
||||
}
|
||||
if (!parser.Complete()) {
|
||||
throw std::runtime_error("connection closed before a complete response arrived");
|
||||
}
|
||||
HTTPResponse response = parser.TakeResponse();
|
||||
if (!parser.KeepAlive()) Close();
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port)
|
||||
: host(host), port(port), impl(std::make_unique<Impl>(std::string(host), port)) {}
|
||||
|
||||
ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port)
|
||||
: ClientHTTP1(host.c_str(), port) {}
|
||||
|
||||
ClientHTTP1::ClientHTTP1(ClientHTTP1&&) noexcept = default;
|
||||
ClientHTTP1::~ClientHTTP1() = default;
|
||||
|
||||
bool ClientHTTP1::Connected() const noexcept {
|
||||
return impl && impl->tcp != nullptr;
|
||||
}
|
||||
|
||||
void ClientHTTP1::Disconnect() {
|
||||
if (impl) impl->Close();
|
||||
}
|
||||
|
||||
HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
|
||||
HTTPRequest prepared = request;
|
||||
if (prepared.authority.empty()) prepared.authority = DefaultAuthority(host, port);
|
||||
const std::string wire = HTTP1::SerializeRequest(prepared);
|
||||
const std::string method = prepared.method.empty() ? std::string("GET") : prepared.method;
|
||||
|
||||
// Two attempts at most, and the second one only for the keep-alive
|
||||
// race: the peer may have closed a pooled connection at the same moment
|
||||
// we wrote to it, which is indistinguishable from success until the
|
||||
// read fails. Nothing is replayed once a response byte has arrived.
|
||||
for (int attempt = 0;; ++attempt) {
|
||||
const bool reused = impl->tcp != nullptr;
|
||||
if (!reused) impl->Connect();
|
||||
|
||||
bool received = false;
|
||||
try {
|
||||
return impl->Exchange(wire, method, limits, received);
|
||||
} catch (const std::exception&) {
|
||||
impl->Close();
|
||||
if (attempt == 0 && reused && !received) continue;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ClientHTTP1::SendAsync(const HTTPRequest& request,
|
||||
std::function<void(HTTPResponse)> onSuccess,
|
||||
std::function<void(std::string)> onError) {
|
||||
HTTPRequest copy = request;
|
||||
ThreadPool::Enqueue([this, copy = std::move(copy),
|
||||
onSuccess = std::move(onSuccess),
|
||||
onError = std::move(onError)]() mutable {
|
||||
try {
|
||||
HTTPResponse response = this->Send(copy);
|
||||
if (onSuccess) onSuccess(std::move(response));
|
||||
} catch (const std::exception& e) {
|
||||
if (onError) onError(e.what());
|
||||
} catch (...) {
|
||||
if (onError) onError("unknown error");
|
||||
}
|
||||
});
|
||||
}
|
||||
301
implementations/Crafter.Network-ListenerHTTP1.cpp
Normal file
301
implementations/Crafter.Network-ListenerHTTP1.cpp
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module;
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <cerrno>
|
||||
|
||||
module Crafter.Network:ListenerHTTP1_impl;
|
||||
import :ListenerHTTP1;
|
||||
import :ListenerTCP;
|
||||
import :ClientTCP;
|
||||
import :HTTP;
|
||||
import :HTTP1;
|
||||
import std;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
enum class ReadStatus { Data, Closed, TimedOut };
|
||||
|
||||
// Wait for readable data with a deadline, so an idle or stalled peer
|
||||
// releases the connection thread instead of holding it forever.
|
||||
ReadStatus ReadSome(int socketid, char* buffer, std::size_t size,
|
||||
std::chrono::milliseconds timeout, std::size_t& read) {
|
||||
read = 0;
|
||||
for (;;) {
|
||||
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
|
||||
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
|
||||
if (ready < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
return ReadStatus::Closed;
|
||||
}
|
||||
if (ready == 0) return ReadStatus::TimedOut;
|
||||
|
||||
const auto got = recv(socketid, buffer, size, 0);
|
||||
if (got < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
return ReadStatus::Closed;
|
||||
}
|
||||
if (got == 0) return ReadStatus::Closed;
|
||||
read = static_cast<std::size_t>(got);
|
||||
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
|
||||
// the accept loop uses to join finished threads without blocking.
|
||||
struct HTTP1Connection {
|
||||
std::unique_ptr<ClientTCP> client;
|
||||
std::thread thread;
|
||||
std::atomic<bool> finished{false};
|
||||
};
|
||||
|
||||
struct ListenerHTTP1::Impl {
|
||||
ListenerHTTP1* owner = nullptr;
|
||||
std::unique_ptr<ListenerTCP> listener;
|
||||
std::mutex mutex;
|
||||
std::vector<std::unique_ptr<HTTP1Connection>> connections;
|
||||
std::atomic<bool> running{true};
|
||||
std::atomic<std::uint64_t> accepted{0};
|
||||
|
||||
// Join threads whose connection has ended. Called from the accept loop
|
||||
// with `mutex` held, so a connection is never reaped mid-registration.
|
||||
void ReapLocked() {
|
||||
std::erase_if(connections, [](const std::unique_ptr<HTTP1Connection>& connection) {
|
||||
if (!connection->finished.load()) return false;
|
||||
if (connection->thread.joinable()) connection->thread.join();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
void Adopt(ClientTCP* accepted) {
|
||||
auto connection = std::make_unique<HTTP1Connection>();
|
||||
connection->client.reset(accepted);
|
||||
HTTP1Connection* pointer = connection.get();
|
||||
|
||||
std::lock_guard lock(mutex);
|
||||
if (!running.load()) return; // shutting down — let the socket close
|
||||
ReapLocked();
|
||||
this->accepted.fetch_add(1);
|
||||
connection->thread = std::thread([this, pointer] {
|
||||
try {
|
||||
Serve(*pointer->client);
|
||||
} catch (...) {
|
||||
// A connection dying must never take the server with it.
|
||||
}
|
||||
pointer->finished.store(true);
|
||||
});
|
||||
connections.push_back(std::move(connection));
|
||||
}
|
||||
|
||||
void Send(ClientTCP& client, const std::string& wire) {
|
||||
client.Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
|
||||
}
|
||||
|
||||
HTTPResponse Dispatch(const HTTPRequest& request) {
|
||||
const auto& routes = owner->routes;
|
||||
auto route = routes.find(request.path);
|
||||
if (route == routes.end()) {
|
||||
const std::string bare(PathWithoutQuery(request.path));
|
||||
route = routes.find(bare);
|
||||
}
|
||||
if (route == routes.end()) {
|
||||
return CreateResponseHTTP("404", "Not Found");
|
||||
}
|
||||
try {
|
||||
return route->second(request);
|
||||
} catch (const std::exception& error) {
|
||||
return CreateResponseHTTP("500", std::string(error.what()));
|
||||
} catch (...) {
|
||||
return CreateResponseHTTP("500", "Internal Server Error");
|
||||
}
|
||||
}
|
||||
|
||||
// Serve one connection until the peer goes away, asks to close, stalls,
|
||||
// or sends something we refuse to parse.
|
||||
void Serve(ClientTCP& client) {
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Request, owner->limits);
|
||||
std::vector<char> chunk(16 * 1024);
|
||||
bool keepAlive = true;
|
||||
|
||||
while (running.load() && keepAlive) {
|
||||
// ── Read one complete request ─────────────────────────────
|
||||
bool closed = false;
|
||||
try {
|
||||
while (!parser.Complete()) {
|
||||
const bool idle = parser.AtMessageBoundary();
|
||||
const auto timeout = idle ? owner->keepAliveTimeout : owner->requestTimeout;
|
||||
std::size_t read = 0;
|
||||
const ReadStatus status = ReadSome(client.socketid, chunk.data(), chunk.size(),
|
||||
timeout, read);
|
||||
if (status == ReadStatus::TimedOut) {
|
||||
// An idle keep-alive connection is simply dropped;
|
||||
// a half-sent request earns a 408 first.
|
||||
if (!idle) {
|
||||
try {
|
||||
Send(client, HTTP1::SerializeResponse(
|
||||
CreateResponseHTTP("408", "Request Timeout"),
|
||||
{ .keepAlive = false }));
|
||||
} catch (...) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (status == ReadStatus::Closed) {
|
||||
if (parser.AtMessageBoundary()) return; // clean end of connection
|
||||
parser.Finish(); // throws if truncated
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
parser.Feed(chunk.data(), read);
|
||||
// The peer is holding its body back until we say go.
|
||||
if (parser.ExpectsContinue()) {
|
||||
Send(client, HTTP1::SerializeContinue());
|
||||
parser.ContinueSent();
|
||||
}
|
||||
}
|
||||
} catch (const HTTP1::HTTP1ProtocolError& error) {
|
||||
try {
|
||||
Send(client, HTTP1::SerializeResponse(
|
||||
CreateResponseHTTP("400", std::string(error.what())),
|
||||
{ .keepAlive = false }));
|
||||
} catch (...) {}
|
||||
return;
|
||||
} catch (...) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parser.Complete()) return;
|
||||
|
||||
// ── Dispatch and answer ───────────────────────────────────
|
||||
const bool http10 = parser.Version() == HTTP1::kVersion10;
|
||||
const bool head = parser.Method() == "HEAD";
|
||||
keepAlive = parser.KeepAlive() && !closed && running.load();
|
||||
|
||||
HTTPRequest request = parser.TakeRequest();
|
||||
HTTPResponse response = Dispatch(request);
|
||||
// A handler may end the connection by answering with
|
||||
// `connection: close`. The header itself is hop-by-hop and gets
|
||||
// re-derived by the serialiser, so this is where it takes effect.
|
||||
if (auto connection = response.headers.find("connection");
|
||||
connection != response.headers.end() && HTTP1::HasToken(connection->second, "close")) {
|
||||
keepAlive = false;
|
||||
}
|
||||
if (!response.headers.contains("date")) {
|
||||
response.headers["date"] = HTTP1::FormatHTTPDate(std::chrono::system_clock::now());
|
||||
}
|
||||
|
||||
try {
|
||||
Send(client, HTTP1::SerializeResponse(response, {
|
||||
.keepAlive = keepAlive,
|
||||
// HTTP/1.0 peers only reuse a connection they were told
|
||||
// stays open.
|
||||
.explicitKeepAlive = http10,
|
||||
.omitBody = head,
|
||||
}));
|
||||
} catch (...) {
|
||||
return; // peer vanished mid-response
|
||||
}
|
||||
|
||||
if (!keepAlive) return;
|
||||
try {
|
||||
// Rearms for the next request, parsing anything the peer
|
||||
// already pipelined behind this one.
|
||||
parser.Reset();
|
||||
} catch (const HTTP1::HTTP1ProtocolError&) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
|
||||
: routes(std::move(routes))
|
||||
, impl(std::make_unique<Impl>())
|
||||
{
|
||||
impl->owner = this;
|
||||
Impl* state = impl.get();
|
||||
impl->listener = std::make_unique<ListenerTCP>(port, [state](ClientTCP* client) {
|
||||
state->Adopt(client);
|
||||
});
|
||||
}
|
||||
|
||||
ListenerHTTP1::ListenerHTTP1(ListenerHTTP1&& other) noexcept
|
||||
: routes(std::move(other.routes))
|
||||
, keepAliveTimeout(other.keepAliveTimeout)
|
||||
, requestTimeout(other.requestTimeout)
|
||||
, limits(other.limits)
|
||||
, impl(std::move(other.impl))
|
||||
{
|
||||
// The accept callback reaches the routes through Impl::owner, so the
|
||||
// back-pointer has to follow the object.
|
||||
if (impl) impl->owner = this;
|
||||
}
|
||||
|
||||
ListenerHTTP1::~ListenerHTTP1() {
|
||||
if (impl) Stop();
|
||||
}
|
||||
|
||||
void ListenerHTTP1::Listen() {
|
||||
if (!impl || !impl->listener) return;
|
||||
// Accept on this thread; each connection is served on its own thread
|
||||
// (see Impl::Adopt), so a slow keep-alive peer can't stall the loop.
|
||||
impl->listener->ListenSyncSync();
|
||||
}
|
||||
|
||||
void ListenerHTTP1::Stop() {
|
||||
if (!impl) return;
|
||||
if (!impl->running.exchange(false)) return;
|
||||
if (impl->listener) impl->listener->Stop();
|
||||
|
||||
std::vector<std::unique_ptr<HTTP1Connection>> closing;
|
||||
{
|
||||
std::lock_guard lock(impl->mutex);
|
||||
for (auto& connection : impl->connections) {
|
||||
// Wake the serving thread out of poll()/recv() without closing
|
||||
// the descriptor underneath it.
|
||||
if (connection->client) shutdown(connection->client->socketid, SHUT_RDWR);
|
||||
}
|
||||
closing = std::move(impl->connections);
|
||||
impl->connections.clear();
|
||||
}
|
||||
for (auto& connection : closing) {
|
||||
if (connection->thread.joinable()) connection->thread.join();
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t ListenerHTTP1::ConnectionCount() const {
|
||||
if (!impl) return 0;
|
||||
std::lock_guard lock(impl->mutex);
|
||||
return impl->connections.size();
|
||||
}
|
||||
|
||||
std::uint64_t ListenerHTTP1::AcceptedCount() const {
|
||||
return impl ? impl->accepted.load() : 0;
|
||||
}
|
||||
|
||||
ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
|
||||
: listener(port, std::move(routes))
|
||||
, thread(&ListenerHTTP1::Listen, &listener)
|
||||
{}
|
||||
|
||||
ListenerAsyncHTTP1::~ListenerAsyncHTTP1() {
|
||||
Stop();
|
||||
}
|
||||
|
||||
void ListenerAsyncHTTP1::Stop() {
|
||||
listener.Stop();
|
||||
if (thread.joinable()) thread.join();
|
||||
}
|
||||
Loading…
Reference in a new issue