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
69
interfaces/Crafter.Network-ClientHTTP1.cppm
Normal file
69
interfaces/Crafter.Network-ClientHTTP1.cppm
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
export module Crafter.Network:ClientHTTP1;
|
||||
import std;
|
||||
import :HTTP;
|
||||
import :HTTP1;
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
namespace Crafter {
|
||||
// HTTP/1.1 client over plain TCP, for peers that cannot speak HTTP/3.
|
||||
// The request/response types are the ones the HTTP/3 client uses, so
|
||||
// swapping ClientHTTP for ClientHTTP1 is a one-line change at the call
|
||||
// site.
|
||||
//
|
||||
// The connection is persistent: the first Send() dials, and later calls
|
||||
// reuse the socket unless the peer asked for it to be closed
|
||||
// (`Connection: close`, or an HTTP/1.0 response without
|
||||
// `Connection: keep-alive`). A reused connection that turns out to have
|
||||
// been closed by the peer in the meantime — the unavoidable race in
|
||||
// HTTP/1.1 keep-alive — is redialled once and the request replayed;
|
||||
// a freshly dialled connection is never replayed on, so a genuinely
|
||||
// broken server surfaces as an exception rather than a retry loop.
|
||||
//
|
||||
// Thread-affinity matches ClientHTTP: one ClientHTTP1 serves one caller
|
||||
// at a time; distinct instances are independent.
|
||||
//
|
||||
// No TLS. This talks `http://`; for an encrypted transport use
|
||||
// ClientHTTP (HTTP/3 over QUIC), or put a TLS-terminating proxy in
|
||||
// front of the HTTP/1.1 endpoint.
|
||||
export class ClientHTTP1 {
|
||||
public:
|
||||
std::string host;
|
||||
std::uint16_t port;
|
||||
|
||||
ClientHTTP1(const char* host, std::uint16_t port);
|
||||
ClientHTTP1(std::string host, std::uint16_t port);
|
||||
|
||||
~ClientHTTP1();
|
||||
ClientHTTP1(const ClientHTTP1&) = delete;
|
||||
ClientHTTP1(ClientHTTP1&&) noexcept;
|
||||
|
||||
// Send a request and read the full response. `authority` defaults to
|
||||
// the host:port this client was constructed with; `scheme` is
|
||||
// ignored (the transport is plaintext).
|
||||
HTTPResponse Send(const HTTPRequest& request);
|
||||
|
||||
// Send a request and deliver the response (or the error text) via
|
||||
// callback, on Crafter.Thread's ThreadPool.
|
||||
void SendAsync(const HTTPRequest& request,
|
||||
std::function<void(HTTPResponse)> onSuccess,
|
||||
std::function<void(std::string)> onError);
|
||||
|
||||
// Whether a pooled connection is currently open. Mostly useful for
|
||||
// tests asserting that keep-alive actually kept the socket.
|
||||
bool Connected() const noexcept;
|
||||
|
||||
// Drop the pooled connection; the next Send() dials again.
|
||||
void Disconnect();
|
||||
|
||||
// Limits applied to responses. Set before the first Send().
|
||||
HTTP1::MessageLimits limits;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl;
|
||||
};
|
||||
}
|
||||
#endif
|
||||
777
interfaces/Crafter.Network-HTTP1.cppm
Normal file
777
interfaces/Crafter.Network-HTTP1.cppm
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// HTTP/1.1 wire format — RFC 9112 syntax on top of the RFC 9110 semantics
|
||||
// already modelled by :HTTP (HTTPRequest / HTTPResponse are shared with the
|
||||
// HTTP/3 stack, so a route handler written for one works unchanged on the
|
||||
// other).
|
||||
//
|
||||
// Scope:
|
||||
// - request / response serialisation, with the framing headers
|
||||
// (host, content-length, transfer-encoding, connection) owned by the
|
||||
// serialiser rather than the caller
|
||||
// - an incremental parser that consumes arbitrary socket chunks and
|
||||
// surfaces one message at a time, so a single connection can carry a
|
||||
// keep-alive series (and pipelined requests)
|
||||
// - both body framings that matter in practice: content-length and
|
||||
// `Transfer-Encoding: chunked` (plus read-to-EOF for responses that
|
||||
// use neither, RFC 9112 §6.3 item 8)
|
||||
//
|
||||
// Deliberately rejected rather than guessed at, because getting these
|
||||
// wrong is how request smuggling happens (RFC 9112 §11.2):
|
||||
// - Content-Length together with Transfer-Encoding
|
||||
// - conflicting duplicate Content-Length values
|
||||
// - whitespace between a field name and its colon
|
||||
// - CR/LF inside a field value we are asked to serialise
|
||||
//
|
||||
// This partition has no transport dependency; ClientHTTP1 and ListenerHTTP1
|
||||
// pair it with :ClientTCP / :ListenerTCP.
|
||||
|
||||
export module Crafter.Network:HTTP1;
|
||||
import std;
|
||||
import :HTTP;
|
||||
|
||||
namespace Crafter::HTTP1 {
|
||||
// ---------------- Versions ----------------
|
||||
export inline constexpr std::string_view kVersion10 = "HTTP/1.0";
|
||||
export inline constexpr std::string_view kVersion11 = "HTTP/1.1";
|
||||
|
||||
// ---------------- Errors ----------------
|
||||
// Thrown for any input we refuse to interpret. Callers turn this into a
|
||||
// 400 (server side) or propagate it out of Send() (client side).
|
||||
export class HTTP1ProtocolError : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// ---------------- Lexical helpers (RFC 9110 §5.6) ----------------
|
||||
inline bool IsTChar(unsigned char c) {
|
||||
constexpr std::string_view extra = "!#$%&'*+-.^_`|~";
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
|| extra.find(static_cast<char>(c)) != std::string_view::npos;
|
||||
}
|
||||
|
||||
inline bool IsToken(std::string_view s) {
|
||||
return !s.empty() && std::ranges::all_of(s, [](char c) {
|
||||
return IsTChar(static_cast<unsigned char>(c));
|
||||
});
|
||||
}
|
||||
|
||||
inline std::string ToLowerAscii(std::string_view s) {
|
||||
std::string out(s);
|
||||
std::ranges::transform(out, out.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
inline bool EqualsIgnoreCase(std::string_view a, std::string_view b) {
|
||||
return std::ranges::equal(a, b, [](unsigned char x, unsigned char y) {
|
||||
auto lower = [](unsigned char c) { return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; };
|
||||
return lower(x) == lower(y);
|
||||
});
|
||||
}
|
||||
|
||||
// Optional whitespace — SP / HTAB only (RFC 9110 §5.6.3).
|
||||
inline std::string_view TrimOWS(std::string_view s) {
|
||||
while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1);
|
||||
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.remove_suffix(1);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Split a comma-separated field value into trimmed tokens (#rule).
|
||||
export inline std::vector<std::string_view> SplitCommaList(std::string_view value) {
|
||||
std::vector<std::string_view> parts;
|
||||
while (!value.empty()) {
|
||||
auto comma = value.find(',');
|
||||
parts.push_back(TrimOWS(value.substr(0, comma)));
|
||||
if (comma == std::string_view::npos) break;
|
||||
value.remove_prefix(comma + 1);
|
||||
}
|
||||
std::erase_if(parts, [](std::string_view p) { return p.empty(); });
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Whether a comma-separated field value contains `token`, compared
|
||||
// case-insensitively — the shape of `Connection`, `Expect`,
|
||||
// `Transfer-Encoding` and friends.
|
||||
export inline bool HasToken(std::string_view fieldValue, std::string_view token) {
|
||||
return std::ranges::any_of(SplitCommaList(fieldValue), [token](std::string_view candidate) {
|
||||
return EqualsIgnoreCase(candidate, token);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- Status reason phrases ----------------
|
||||
// HTTP/1.1 keeps a reason phrase in the status line. It carries no
|
||||
// meaning (RFC 9112 §4) but some ancient clients log it, and an empty
|
||||
// one trips up a few proxies, so emit the registered text where we know
|
||||
// it and a generic class name otherwise.
|
||||
export inline std::string_view ReasonPhrase(std::string_view status) {
|
||||
static const std::unordered_map<std::string_view, std::string_view> known = {
|
||||
{"100", "Continue"}, {"101", "Switching Protocols"}, {"103", "Early Hints"},
|
||||
{"200", "OK"}, {"201", "Created"}, {"202", "Accepted"}, {"204", "No Content"},
|
||||
{"206", "Partial Content"},
|
||||
{"301", "Moved Permanently"}, {"302", "Found"}, {"303", "See Other"},
|
||||
{"304", "Not Modified"}, {"307", "Temporary Redirect"}, {"308", "Permanent Redirect"},
|
||||
{"400", "Bad Request"}, {"401", "Unauthorized"}, {"403", "Forbidden"},
|
||||
{"404", "Not Found"}, {"405", "Method Not Allowed"}, {"408", "Request Timeout"},
|
||||
{"409", "Conflict"}, {"411", "Length Required"}, {"413", "Content Too Large"},
|
||||
{"414", "URI Too Long"}, {"415", "Unsupported Media Type"},
|
||||
{"426", "Upgrade Required"}, {"429", "Too Many Requests"},
|
||||
{"431", "Request Header Fields Too Large"},
|
||||
{"500", "Internal Server Error"}, {"501", "Not Implemented"},
|
||||
{"502", "Bad Gateway"}, {"503", "Service Unavailable"}, {"504", "Gateway Timeout"},
|
||||
{"505", "HTTP Version Not Supported"},
|
||||
};
|
||||
if (auto it = known.find(status); it != known.end()) return it->second;
|
||||
if (status.size() == 3) {
|
||||
switch (status[0]) {
|
||||
case '1': return "Informational";
|
||||
case '2': return "Success";
|
||||
case '3': return "Redirection";
|
||||
case '4': return "Client Error";
|
||||
case '5': return "Server Error";
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// IMF-fixdate, the only date format an HTTP/1.1 sender may generate
|
||||
// (RFC 9110 §5.6.7). Always UTC.
|
||||
export inline std::string FormatHTTPDate(std::chrono::system_clock::time_point when) {
|
||||
return std::format("{:%a, %d %b %Y %H:%M:%S} GMT",
|
||||
std::chrono::floor<std::chrono::seconds>(when));
|
||||
}
|
||||
|
||||
// ---------------- Serialisation ----------------
|
||||
export struct RequestOptions {
|
||||
// false emits `connection: close`, telling the peer we will not
|
||||
// reuse the connection after this exchange.
|
||||
bool keepAlive = true;
|
||||
};
|
||||
|
||||
export struct ResponseOptions {
|
||||
bool keepAlive = true;
|
||||
// HTTP/1.0 peers have no keep-alive by default, so reuse has to be
|
||||
// stated explicitly for them (RFC 9112 §9.3).
|
||||
bool explicitKeepAlive = false;
|
||||
// Response to HEAD: send the header section, including the
|
||||
// content-length the body *would* have had, but no body.
|
||||
bool omitBody = false;
|
||||
};
|
||||
|
||||
// Headers whose value is derived from the message rather than copied
|
||||
// from the caller's map — emitting the caller's copy as well would
|
||||
// produce a duplicate, and for content-length a smuggling vector.
|
||||
inline bool IsFramingHeader(std::string_view lowerName) {
|
||||
return lowerName == "host" || lowerName == "content-length"
|
||||
|| lowerName == "transfer-encoding" || lowerName == "connection";
|
||||
}
|
||||
|
||||
inline void ValidateFieldName(std::string_view name) {
|
||||
if (!IsToken(name)) {
|
||||
throw HTTP1ProtocolError("invalid header field name: '" + std::string(name) + "'");
|
||||
}
|
||||
}
|
||||
|
||||
// A CR or LF smuggled through a field value would split the message.
|
||||
inline void ValidateFieldValue(std::string_view name, std::string_view value) {
|
||||
if (value.find_first_of("\r\n") != std::string_view::npos) {
|
||||
throw HTTP1ProtocolError("header '" + std::string(name) + "' contains CR/LF");
|
||||
}
|
||||
}
|
||||
|
||||
inline void AppendHeaders(std::string& out,
|
||||
const std::unordered_map<std::string, std::string>& headers) {
|
||||
for (const auto& [name, value] : headers) {
|
||||
// HTTP/3 pseudo-headers have no HTTP/1.1 equivalent — they are
|
||||
// already represented by the start line.
|
||||
if (name.empty() || name.front() == ':') continue;
|
||||
std::string lower = ToLowerAscii(name);
|
||||
if (IsFramingHeader(lower)) continue;
|
||||
ValidateFieldName(lower);
|
||||
ValidateFieldValue(lower, value);
|
||||
out += lower;
|
||||
out += ": ";
|
||||
out += value;
|
||||
out += "\r\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Serialise a request in origin-form. `authority` becomes the mandatory
|
||||
// Host header (RFC 9112 §3.2); ClientHTTP1 fills it in from the
|
||||
// connection when the caller left it empty.
|
||||
export inline std::string SerializeRequest(const HTTPRequest& request, RequestOptions options = {}) {
|
||||
const std::string method = request.method.empty() ? std::string("GET") : request.method;
|
||||
const std::string target = request.path.empty() ? std::string("/") : request.path;
|
||||
if (!IsToken(method)) throw HTTP1ProtocolError("invalid request method: '" + method + "'");
|
||||
if (target.find_first_of(" \r\n") != std::string::npos) {
|
||||
throw HTTP1ProtocolError("invalid request target: '" + target + "'");
|
||||
}
|
||||
ValidateFieldValue("host", request.authority);
|
||||
|
||||
std::string out;
|
||||
out.reserve(256 + request.headers.size() * 32 + request.body.size());
|
||||
out += method;
|
||||
out += ' ';
|
||||
out += target;
|
||||
out += " HTTP/1.1\r\n";
|
||||
out += "host: ";
|
||||
out += request.authority;
|
||||
out += "\r\n";
|
||||
AppendHeaders(out, request.headers);
|
||||
// A body always gets an explicit length; so does a method that is
|
||||
// normally expected to carry one, since some servers answer 411 for
|
||||
// a bodyless POST that omits it.
|
||||
if (!request.body.empty() || method == "POST" || method == "PUT" || method == "PATCH") {
|
||||
out += "content-length: ";
|
||||
out += std::to_string(request.body.size());
|
||||
out += "\r\n";
|
||||
}
|
||||
if (!options.keepAlive) out += "connection: close\r\n";
|
||||
out += "\r\n";
|
||||
out += request.body;
|
||||
return out;
|
||||
}
|
||||
|
||||
export inline std::string SerializeResponse(const HTTPResponse& response, ResponseOptions options = {}) {
|
||||
std::string status = response.status.empty() ? std::string("200") : response.status;
|
||||
if (status.size() != 3 || !std::ranges::all_of(status, [](char c) { return c >= '0' && c <= '9'; })) {
|
||||
throw HTTP1ProtocolError("status must be three digits, got '" + status + "'");
|
||||
}
|
||||
|
||||
std::string out;
|
||||
out.reserve(256 + response.headers.size() * 32 + response.body.size());
|
||||
out += "HTTP/1.1 ";
|
||||
out += status;
|
||||
out += ' ';
|
||||
out += ReasonPhrase(status);
|
||||
out += "\r\n";
|
||||
AppendHeaders(out, response.headers);
|
||||
// 1xx / 204 / 304 must not carry a content-length at all — sending
|
||||
// one makes some clients wait for a body that never arrives.
|
||||
const bool bodyless = status[0] == '1' || status == "204" || status == "304";
|
||||
if (!bodyless) {
|
||||
out += "content-length: ";
|
||||
out += std::to_string(response.body.size());
|
||||
out += "\r\n";
|
||||
}
|
||||
if (!options.keepAlive) out += "connection: close\r\n";
|
||||
else if (options.explicitKeepAlive) out += "connection: keep-alive\r\n";
|
||||
out += "\r\n";
|
||||
if (!options.omitBody && !bodyless) out += response.body;
|
||||
return out;
|
||||
}
|
||||
|
||||
// The interim response a server owes a client that sent
|
||||
// `Expect: 100-continue` before it will send its body (RFC 9110 §10.1.1).
|
||||
export inline std::string SerializeContinue() {
|
||||
return "HTTP/1.1 100 Continue\r\n\r\n";
|
||||
}
|
||||
|
||||
// ---------------- Incremental parser ----------------
|
||||
export enum class MessageKind { Request, Response };
|
||||
|
||||
// Bounds on what we are willing to buffer before declaring the peer
|
||||
// hostile. maxBody applies to the decoded body, so it also caps a
|
||||
// chunked stream that never ends.
|
||||
export struct MessageLimits {
|
||||
std::size_t maxStartLine = 8 * 1024;
|
||||
std::size_t maxHeaderSection = 64 * 1024;
|
||||
std::uint64_t maxBody = 512ull * 1024 * 1024;
|
||||
};
|
||||
|
||||
// Feed socket bytes in with Feed(); call Finish() when the peer closes
|
||||
// its send side. Complete() then reports whether a whole message is
|
||||
// available, and Take*() moves it out. Reset() rearms for the next
|
||||
// message on the same connection, keeping any bytes that already
|
||||
// belonged to it (HTTP/1.1 pipelining).
|
||||
export class MessageParser {
|
||||
public:
|
||||
explicit MessageParser(MessageKind kind, MessageLimits limits = {})
|
||||
: kind(kind), limits(limits) {}
|
||||
|
||||
// Responses are framed partly by the request that provoked them: a
|
||||
// HEAD response has no body no matter what its content-length says.
|
||||
// Tell the parser before feeding it bytes.
|
||||
void SetRequestMethod(std::string_view requestMethod) {
|
||||
headRequest = EqualsIgnoreCase(requestMethod, "HEAD");
|
||||
}
|
||||
|
||||
void Feed(const char* data, std::size_t size) {
|
||||
if (size != 0) buffer.append(data, size);
|
||||
Advance();
|
||||
}
|
||||
|
||||
void Feed(std::span<const char> bytes) { Feed(bytes.data(), bytes.size()); }
|
||||
|
||||
// The peer closed. Completes a read-to-EOF body; anything else
|
||||
// that is mid-message is a truncation error.
|
||||
void Finish() {
|
||||
eof = true;
|
||||
Advance();
|
||||
}
|
||||
|
||||
bool HeadersComplete() const noexcept { return headersDone; }
|
||||
bool Complete() const noexcept { return state == State::Done; }
|
||||
|
||||
// True while the peer is waiting for `100 Continue` before it sends
|
||||
// the body. Clear it with ContinueSent() once the interim response
|
||||
// is on the wire.
|
||||
bool ExpectsContinue() const noexcept { return expectContinue && !Complete(); }
|
||||
void ContinueSent() noexcept { expectContinue = false; }
|
||||
|
||||
// Whether the connection may carry another message after this one.
|
||||
// Meaningful once HeadersComplete().
|
||||
bool KeepAlive() const noexcept { return keepAlive; }
|
||||
|
||||
// No partial message buffered — a clean point to stop reading.
|
||||
bool AtMessageBoundary() const noexcept {
|
||||
return state == State::StartLine && pos == buffer.size() && !headersDone;
|
||||
}
|
||||
|
||||
std::string_view Method() const noexcept { return method; }
|
||||
std::string_view Version() const noexcept { return version; }
|
||||
std::string_view Status() const noexcept { return status; }
|
||||
|
||||
HTTPRequest TakeRequest() {
|
||||
if (state != State::Done) throw HTTP1ProtocolError("request is not complete");
|
||||
HTTPRequest request;
|
||||
request.method = std::move(method);
|
||||
request.path = std::move(path);
|
||||
request.scheme = scheme.empty() ? std::string("http") : std::move(scheme);
|
||||
// Absolute-form targets carry their own authority and win over
|
||||
// Host (RFC 9112 §3.2.2); otherwise Host supplies it. Either way
|
||||
// it lives in the named field, matching the HTTP/3 shape, so it
|
||||
// is removed from the header map.
|
||||
if (authority.empty()) {
|
||||
if (auto it = headers.find("host"); it != headers.end()) authority = it->second;
|
||||
}
|
||||
headers.erase("host");
|
||||
request.authority = std::move(authority);
|
||||
request.headers = std::move(headers);
|
||||
request.body = std::move(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
HTTPResponse TakeResponse() {
|
||||
if (state != State::Done) throw HTTP1ProtocolError("response is not complete");
|
||||
HTTPResponse response;
|
||||
response.status = std::move(status);
|
||||
response.headers = std::move(headers);
|
||||
response.body = std::move(body);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Rearm for the next message. Any bytes past the one just taken are
|
||||
// retained and parsed immediately, so a pipelined request can be
|
||||
// Complete() as soon as this returns.
|
||||
void Reset() {
|
||||
buffer.erase(0, pos);
|
||||
pos = 0;
|
||||
state = State::StartLine;
|
||||
method.clear();
|
||||
path.clear();
|
||||
scheme.clear();
|
||||
authority.clear();
|
||||
version.clear();
|
||||
status.clear();
|
||||
lastHeaderName.clear();
|
||||
headers.clear();
|
||||
body.clear();
|
||||
headerBytes = 0;
|
||||
bodyRemaining = 0;
|
||||
chunkRemaining = 0;
|
||||
bodyMode = BodyMode::None;
|
||||
headersDone = false;
|
||||
expectContinue = false;
|
||||
keepAlive = true;
|
||||
headRequest = false;
|
||||
Advance();
|
||||
}
|
||||
|
||||
private:
|
||||
enum class State { StartLine, Headers, Body, ChunkSize, ChunkData, ChunkTrailingCRLF, Trailers, Done };
|
||||
enum class BodyMode { None, Length, Chunked, UntilClose };
|
||||
|
||||
// Read one CRLF-terminated line. A bare LF is accepted as well: it
|
||||
// is not legal to generate, but recipients may recognise it
|
||||
// (RFC 9112 §2.2) and some embedded clients still emit it.
|
||||
std::optional<std::string_view> TryReadLine(std::size_t maxLength) {
|
||||
const std::size_t nl = buffer.find('\n', pos);
|
||||
if (nl == std::string::npos) {
|
||||
if (buffer.size() - pos > maxLength) {
|
||||
throw HTTP1ProtocolError("line exceeds " + std::to_string(maxLength) + " bytes");
|
||||
}
|
||||
if (eof && buffer.size() != pos) {
|
||||
throw HTTP1ProtocolError("connection closed mid-message");
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
std::size_t end = nl;
|
||||
if (end > pos && buffer[end - 1] == '\r') --end;
|
||||
if (end - pos > maxLength) {
|
||||
throw HTTP1ProtocolError("line exceeds " + std::to_string(maxLength) + " bytes");
|
||||
}
|
||||
std::string_view line(buffer.data() + pos, end - pos);
|
||||
pos = nl + 1;
|
||||
return line;
|
||||
}
|
||||
|
||||
void ParseRequestLine(std::string_view line) {
|
||||
const auto first = line.find(' ');
|
||||
if (first == std::string_view::npos) throw HTTP1ProtocolError("malformed request line");
|
||||
const auto second = line.find(' ', first + 1);
|
||||
if (second == std::string_view::npos) {
|
||||
// HTTP/0.9 simple-request. Not supported, and accepting it
|
||||
// would leave us unable to answer with headers.
|
||||
throw HTTP1ProtocolError("malformed request line (missing version)");
|
||||
}
|
||||
method = std::string(line.substr(0, first));
|
||||
const std::string_view target = line.substr(first + 1, second - first - 1);
|
||||
version = std::string(line.substr(second + 1));
|
||||
if (!IsToken(method)) throw HTTP1ProtocolError("invalid method token");
|
||||
if (target.empty()) throw HTTP1ProtocolError("empty request target");
|
||||
CheckVersion();
|
||||
|
||||
if (target.front() == '/') {
|
||||
path = std::string(target); // origin-form
|
||||
} else if (target == "*") {
|
||||
path = "*"; // asterisk-form (OPTIONS)
|
||||
} else if (const auto sep = target.find("://"); sep != std::string_view::npos) {
|
||||
// absolute-form, as sent to a proxy.
|
||||
scheme = ToLowerAscii(target.substr(0, sep));
|
||||
const std::string_view rest = target.substr(sep + 3);
|
||||
const auto slash = rest.find('/');
|
||||
authority = std::string(rest.substr(0, slash));
|
||||
path = slash == std::string_view::npos ? std::string("/") : std::string(rest.substr(slash));
|
||||
} else {
|
||||
// authority-form — only legal for CONNECT, which this
|
||||
// listener does not implement.
|
||||
authority = std::string(target);
|
||||
path = std::string(target);
|
||||
}
|
||||
}
|
||||
|
||||
void ParseStatusLine(std::string_view line) {
|
||||
const auto first = line.find(' ');
|
||||
if (first == std::string_view::npos) throw HTTP1ProtocolError("malformed status line");
|
||||
version = std::string(line.substr(0, first));
|
||||
CheckVersion();
|
||||
std::string_view rest = TrimOWS(line.substr(first + 1));
|
||||
const auto space = rest.find(' ');
|
||||
status = std::string(space == std::string_view::npos ? rest : rest.substr(0, space));
|
||||
if (status.size() != 3
|
||||
|| !std::ranges::all_of(status, [](char c) { return c >= '0' && c <= '9'; })) {
|
||||
throw HTTP1ProtocolError("malformed status code '" + status + "'");
|
||||
}
|
||||
// The reason phrase is discarded: it carries no semantics and
|
||||
// HTTPResponse has nowhere to put it.
|
||||
}
|
||||
|
||||
void CheckVersion() {
|
||||
if (version != kVersion11 && version != kVersion10) {
|
||||
throw HTTP1ProtocolError("unsupported HTTP version '" + version + "'");
|
||||
}
|
||||
}
|
||||
|
||||
void AddHeader(std::string name, std::string_view value) {
|
||||
auto [it, inserted] = headers.try_emplace(std::move(name), std::string(value));
|
||||
if (!inserted) {
|
||||
// Repeated field lines are equivalent to one comma-joined
|
||||
// value (RFC 9110 §5.3) — except content-length, where
|
||||
// disagreeing values are a smuggling attempt.
|
||||
if (it->first == "content-length" && it->second != value) {
|
||||
throw HTTP1ProtocolError("conflicting content-length values");
|
||||
}
|
||||
if (it->first != "content-length") {
|
||||
it->second += ", ";
|
||||
it->second += value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ParseHeaderLine(std::string_view line) {
|
||||
headerBytes += line.size() + 2;
|
||||
if (headerBytes > limits.maxHeaderSection) {
|
||||
throw HTTP1ProtocolError("header section exceeds "
|
||||
+ std::to_string(limits.maxHeaderSection) + " bytes");
|
||||
}
|
||||
if (line.front() == ' ' || line.front() == '\t') {
|
||||
// obs-fold. RFC 9112 §5.2 allows replacing it with a space
|
||||
// rather than rejecting the message.
|
||||
if (lastHeaderName.empty()) throw HTTP1ProtocolError("obs-fold before any header");
|
||||
auto it = headers.find(lastHeaderName);
|
||||
if (it != headers.end()) {
|
||||
it->second += ' ';
|
||||
it->second += TrimOWS(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const auto colon = line.find(':');
|
||||
if (colon == std::string_view::npos) throw HTTP1ProtocolError("header line has no colon");
|
||||
const std::string_view rawName = line.substr(0, colon);
|
||||
// "No whitespace is allowed between the field name and colon" —
|
||||
// a recipient MUST reject such a message (RFC 9112 §5.1).
|
||||
if (!IsToken(rawName)) throw HTTP1ProtocolError("invalid header field name");
|
||||
lastHeaderName = ToLowerAscii(rawName);
|
||||
AddHeader(lastHeaderName, TrimOWS(line.substr(colon + 1)));
|
||||
}
|
||||
|
||||
std::optional<std::string_view> Header(std::string_view name) const {
|
||||
auto it = headers.find(std::string(name));
|
||||
if (it == headers.end()) return std::nullopt;
|
||||
return std::string_view(it->second);
|
||||
}
|
||||
|
||||
void ResolveKeepAlive() {
|
||||
const auto connection = Header("connection");
|
||||
const bool close = connection && HasToken(*connection, "close");
|
||||
const bool explicitKeep = connection && HasToken(*connection, "keep-alive");
|
||||
keepAlive = version == kVersion11 ? !close : (explicitKeep && !close);
|
||||
}
|
||||
|
||||
void ResolveFraming() {
|
||||
const auto transferEncoding = Header("transfer-encoding");
|
||||
const auto contentLength = Header("content-length");
|
||||
if (transferEncoding && contentLength) {
|
||||
// Ambiguous framing — the classic smuggling primitive.
|
||||
throw HTTP1ProtocolError("both content-length and transfer-encoding present");
|
||||
}
|
||||
|
||||
if (transferEncoding) {
|
||||
auto codings = SplitCommaList(*transferEncoding);
|
||||
if (codings.empty() || !EqualsIgnoreCase(codings.back(), "chunked")) {
|
||||
throw HTTP1ProtocolError("unsupported transfer-encoding '"
|
||||
+ std::string(*transferEncoding) + "'");
|
||||
}
|
||||
if (codings.size() > 1) {
|
||||
throw HTTP1ProtocolError("stacked transfer-codings are not supported");
|
||||
}
|
||||
bodyMode = BodyMode::Chunked;
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentLength) {
|
||||
if (contentLength->empty()
|
||||
|| !std::ranges::all_of(*contentLength, [](char c) { return c >= '0' && c <= '9'; })) {
|
||||
throw HTTP1ProtocolError("malformed content-length '"
|
||||
+ std::string(*contentLength) + "'");
|
||||
}
|
||||
std::uint64_t length = 0;
|
||||
const auto result = std::from_chars(contentLength->data(),
|
||||
contentLength->data() + contentLength->size(), length);
|
||||
if (result.ec != std::errc{}) throw HTTP1ProtocolError("content-length out of range");
|
||||
if (length > limits.maxBody) {
|
||||
throw HTTP1ProtocolError("body exceeds " + std::to_string(limits.maxBody) + " bytes");
|
||||
}
|
||||
bodyRemaining = length;
|
||||
bodyMode = length == 0 ? BodyMode::None : BodyMode::Length;
|
||||
return;
|
||||
}
|
||||
|
||||
// No explicit framing. A request has no body; a response runs
|
||||
// to connection close (RFC 9112 §6.3).
|
||||
if (kind == MessageKind::Request) {
|
||||
bodyMode = BodyMode::None;
|
||||
} else {
|
||||
bodyMode = BodyMode::UntilClose;
|
||||
keepAlive = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns false when this was an interim (1xx) response and parsing
|
||||
// must restart on the next status line.
|
||||
bool FinishHeaders() {
|
||||
ResolveKeepAlive();
|
||||
|
||||
if (kind == MessageKind::Response) {
|
||||
if (status[0] == '1') {
|
||||
// Interim response (100 Continue, 103 Early Hints, …).
|
||||
// It has no body and is not the answer to the request,
|
||||
// so drop it and keep reading.
|
||||
headers.clear();
|
||||
lastHeaderName.clear();
|
||||
status.clear();
|
||||
version.clear();
|
||||
headerBytes = 0;
|
||||
state = State::StartLine;
|
||||
return false;
|
||||
}
|
||||
if (status == "204" || status == "304" || headRequest) {
|
||||
bodyMode = BodyMode::None;
|
||||
headersDone = true;
|
||||
state = State::Done;
|
||||
return true;
|
||||
}
|
||||
} else if (auto expect = Header("expect")) {
|
||||
expectContinue = HasToken(*expect, "100-continue");
|
||||
}
|
||||
|
||||
ResolveFraming();
|
||||
headersDone = true;
|
||||
switch (bodyMode) {
|
||||
case BodyMode::None: state = State::Done; break;
|
||||
case BodyMode::Length: state = State::Body; break;
|
||||
case BodyMode::Chunked: state = State::ChunkSize; break;
|
||||
case BodyMode::UntilClose: state = State::Body; break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void AppendBody(const char* data, std::size_t size) {
|
||||
if (body.size() + size > limits.maxBody) {
|
||||
throw HTTP1ProtocolError("body exceeds " + std::to_string(limits.maxBody) + " bytes");
|
||||
}
|
||||
body.append(data, size);
|
||||
}
|
||||
|
||||
// Drive the state machine as far as the buffered bytes allow, then
|
||||
// drop what has been consumed so a long-lived connection (or a
|
||||
// large body arriving in many chunks) doesn't grow the buffer
|
||||
// without bound.
|
||||
void Advance() {
|
||||
AdvanceState();
|
||||
if (pos != 0 && pos == buffer.size()) {
|
||||
buffer.clear();
|
||||
pos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void AdvanceState() {
|
||||
for (;;) {
|
||||
switch (state) {
|
||||
case State::StartLine: {
|
||||
auto line = TryReadLine(limits.maxStartLine);
|
||||
if (!line) return;
|
||||
// A server should tolerate stray empty lines left
|
||||
// over from a previous message (RFC 9112 §2.2).
|
||||
if (line->empty()) continue;
|
||||
if (kind == MessageKind::Request) ParseRequestLine(*line);
|
||||
else ParseStatusLine(*line);
|
||||
state = State::Headers;
|
||||
continue;
|
||||
}
|
||||
case State::Headers: {
|
||||
auto line = TryReadLine(limits.maxHeaderSection);
|
||||
if (!line) return;
|
||||
if (line->empty()) {
|
||||
FinishHeaders();
|
||||
continue;
|
||||
}
|
||||
ParseHeaderLine(*line);
|
||||
continue;
|
||||
}
|
||||
case State::Body: {
|
||||
const std::size_t available = buffer.size() - pos;
|
||||
if (bodyMode == BodyMode::UntilClose) {
|
||||
AppendBody(buffer.data() + pos, available);
|
||||
pos = buffer.size();
|
||||
if (!eof) return;
|
||||
state = State::Done;
|
||||
continue;
|
||||
}
|
||||
const std::size_t take = static_cast<std::size_t>(
|
||||
std::min<std::uint64_t>(available, bodyRemaining));
|
||||
AppendBody(buffer.data() + pos, take);
|
||||
pos += take;
|
||||
bodyRemaining -= take;
|
||||
if (bodyRemaining != 0) {
|
||||
if (eof) throw HTTP1ProtocolError("connection closed mid-body");
|
||||
return;
|
||||
}
|
||||
state = State::Done;
|
||||
continue;
|
||||
}
|
||||
case State::ChunkSize: {
|
||||
auto line = TryReadLine(limits.maxStartLine);
|
||||
if (!line) return;
|
||||
// Chunk extensions after ';' are legal and ignorable.
|
||||
std::string_view digits = TrimOWS(line->substr(0, line->find(';')));
|
||||
if (digits.empty()) throw HTTP1ProtocolError("empty chunk size");
|
||||
std::uint64_t size = 0;
|
||||
const auto result = std::from_chars(digits.data(), digits.data() + digits.size(),
|
||||
size, 16);
|
||||
if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size()) {
|
||||
throw HTTP1ProtocolError("malformed chunk size '" + std::string(digits) + "'");
|
||||
}
|
||||
if (size == 0) {
|
||||
state = State::Trailers;
|
||||
continue;
|
||||
}
|
||||
chunkRemaining = size;
|
||||
state = State::ChunkData;
|
||||
continue;
|
||||
}
|
||||
case State::ChunkData: {
|
||||
const std::size_t available = buffer.size() - pos;
|
||||
if (available == 0) {
|
||||
if (eof) throw HTTP1ProtocolError("connection closed mid-chunk");
|
||||
return;
|
||||
}
|
||||
const std::size_t take = static_cast<std::size_t>(
|
||||
std::min<std::uint64_t>(available, chunkRemaining));
|
||||
AppendBody(buffer.data() + pos, take);
|
||||
pos += take;
|
||||
chunkRemaining -= take;
|
||||
if (chunkRemaining != 0) {
|
||||
if (eof) throw HTTP1ProtocolError("connection closed mid-chunk");
|
||||
return;
|
||||
}
|
||||
state = State::ChunkTrailingCRLF;
|
||||
continue;
|
||||
}
|
||||
case State::ChunkTrailingCRLF: {
|
||||
auto line = TryReadLine(2);
|
||||
if (!line) return;
|
||||
if (!line->empty()) throw HTTP1ProtocolError("chunk not terminated by CRLF");
|
||||
state = State::ChunkSize;
|
||||
continue;
|
||||
}
|
||||
case State::Trailers: {
|
||||
auto line = TryReadLine(limits.maxHeaderSection);
|
||||
if (!line) return;
|
||||
if (line->empty()) {
|
||||
state = State::Done;
|
||||
continue;
|
||||
}
|
||||
// Trailer fields are merged into the header map;
|
||||
// callers see one flat view of the message.
|
||||
ParseHeaderLine(*line);
|
||||
continue;
|
||||
}
|
||||
case State::Done:
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageKind kind;
|
||||
MessageLimits limits;
|
||||
State state = State::StartLine;
|
||||
BodyMode bodyMode = BodyMode::None;
|
||||
|
||||
std::string buffer;
|
||||
std::size_t pos = 0;
|
||||
bool eof = false;
|
||||
|
||||
std::string method;
|
||||
std::string path;
|
||||
std::string scheme;
|
||||
std::string authority;
|
||||
std::string version;
|
||||
std::string status;
|
||||
std::string lastHeaderName;
|
||||
std::unordered_map<std::string, std::string> headers;
|
||||
std::string body;
|
||||
|
||||
std::size_t headerBytes = 0;
|
||||
std::uint64_t bodyRemaining = 0;
|
||||
std::uint64_t chunkRemaining = 0;
|
||||
bool headersDone = false;
|
||||
bool expectContinue = false;
|
||||
bool keepAlive = true;
|
||||
bool headRequest = false;
|
||||
};
|
||||
}
|
||||
76
interfaces/Crafter.Network-ListenerHTTP1.cppm
Normal file
76
interfaces/Crafter.Network-ListenerHTTP1.cppm
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
export module Crafter.Network:ListenerHTTP1;
|
||||
import std;
|
||||
import :HTTP;
|
||||
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.
|
||||
//
|
||||
// Each accepted connection gets its own thread and is served
|
||||
// sequentially until the peer closes it, `Connection: close` is seen, or
|
||||
// an idle/read timeout expires. Handlers therefore run concurrently
|
||||
// across connections but never for two requests on the same one, which
|
||||
// is what HTTP/1.1 response ordering requires. 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.
|
||||
//
|
||||
// Implemented: keep-alive and pipelining, content-length and chunked
|
||||
// request bodies, `Expect: 100-continue`, HEAD (headers only), automatic
|
||||
// `Date`, and 400/404/500 error responses. Not implemented: TLS,
|
||||
// CONNECT tunnels, Upgrade, and chunked *responses* (handlers return a
|
||||
// complete body, so responses are always content-length framed).
|
||||
export class ListenerHTTP1 {
|
||||
public:
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
|
||||
// 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.
|
||||
std::chrono::milliseconds keepAliveTimeout{15000};
|
||||
std::chrono::milliseconds requestTimeout{30000};
|
||||
// Limits applied to incoming requests.
|
||||
HTTP1::MessageLimits limits;
|
||||
|
||||
ListenerHTTP1(std::uint16_t port,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes);
|
||||
|
||||
~ListenerHTTP1();
|
||||
ListenerHTTP1(const ListenerHTTP1&) = delete;
|
||||
ListenerHTTP1(ListenerHTTP1&&) noexcept;
|
||||
|
||||
// Run the accept loop on the calling thread until Stop().
|
||||
void Listen();
|
||||
// Stop accepting, close every live connection, and wait for the
|
||||
// connection threads to finish.
|
||||
void Stop();
|
||||
|
||||
// Number of connections currently being served.
|
||||
std::size_t ConnectionCount() const;
|
||||
// Connections accepted since construction.
|
||||
std::uint64_t AcceptedCount() const;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl;
|
||||
};
|
||||
|
||||
// Runs the accept loop on a background thread so the caller can keep
|
||||
// going. Mirrors ListenerAsyncHTTP.
|
||||
export class ListenerAsyncHTTP1 {
|
||||
public:
|
||||
ListenerHTTP1 listener;
|
||||
std::thread thread;
|
||||
|
||||
ListenerAsyncHTTP1(std::uint16_t port,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes);
|
||||
~ListenerAsyncHTTP1();
|
||||
void Stop();
|
||||
};
|
||||
}
|
||||
#endif
|
||||
|
|
@ -17,4 +17,10 @@ export import :WebTransport;
|
|||
// not need the HTTP/3 frame helpers directly. Excluded from the browser
|
||||
// build — HTTP3 uses throw and the wasm target runs with -fno-exceptions.
|
||||
export import :HTTP3;
|
||||
// HTTP/1.1 over TCP, for peers that are not ready for HTTP/3. Native only:
|
||||
// in the browser this job is already done by fetch() behind :ClientHTTP,
|
||||
// and these partitions use exceptions and POSIX sockets.
|
||||
export import :HTTP1;
|
||||
export import :ClientHTTP1;
|
||||
export import :ListenerHTTP1;
|
||||
#endif
|
||||
Loading…
Reference in a new issue