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>
2026-07-27 00:45:09 +00:00
|
|
|
//SPDX-License-Identifier: LGPL-3.0-only
|
|
|
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
|
|
|
|
|
|
// Canonical HTTP/1.1 client/server round-trip: the same shape as
|
|
|
|
|
// ShouldSendRecieveHTTP, over TCP instead of QUIC.
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int main() {
|
|
|
|
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
|
|
|
|
routes["/"] = [](const HTTPRequest&) {
|
|
|
|
|
return CreateResponseHTTP("200", "Hello World!");
|
|
|
|
|
};
|
|
|
|
|
routes["/echo"] = [](const HTTPRequest& request) {
|
|
|
|
|
return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, request.body);
|
|
|
|
|
};
|
|
|
|
|
routes["/query"] = [](const HTTPRequest& request) {
|
|
|
|
|
return CreateResponseHTTP("200", request.path);
|
|
|
|
|
};
|
|
|
|
|
routes["/boom"] = [](const HTTPRequest&) -> HTTPResponse {
|
|
|
|
|
throw std::runtime_error("handler exploded");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
ListenerAsyncHTTP1 listener(8090, std::move(routes));
|
|
|
|
|
ClientHTTP1 client("localhost", 8090);
|
|
|
|
|
|
|
|
|
|
HTTPResponse hello = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
|
|
|
|
Check(hello.status == "200", "GET / status");
|
|
|
|
|
Check(hello.body == "Hello World!", "GET / body");
|
|
|
|
|
Check(hello.headers.contains("date"), "the server stamps a Date header");
|
|
|
|
|
Check(hello.headers.at("content-length") == "12", "content-length matches the body");
|
|
|
|
|
|
|
|
|
|
HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost",
|
|
|
|
|
std::string("ping pong")));
|
|
|
|
|
Check(echoed.status == "200", "POST /echo status");
|
|
|
|
|
Check(echoed.body == "ping pong", "POST /echo returns the request body");
|
|
|
|
|
Check(echoed.headers.at("content-type") == "text/plain", "handler headers survive");
|
|
|
|
|
|
|
|
|
|
// Query strings must reach the handler intact while still routing on
|
|
|
|
|
// the bare path — browsers append them to everything.
|
|
|
|
|
HTTPResponse query = client.Send(CreateRequestHTTP("GET", "/query?a=1&b=2", "localhost"));
|
|
|
|
|
Check(query.status == "200", "query-string request routes to the bare path");
|
|
|
|
|
Check(query.body == "/query?a=1&b=2", "the handler sees the full target");
|
|
|
|
|
|
|
|
|
|
HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nope", "localhost"));
|
|
|
|
|
Check(missing.status == "404", "unknown route is a 404");
|
|
|
|
|
|
|
|
|
|
// A HEAD gets the headers a GET would produce, and no body.
|
|
|
|
|
HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/", "localhost"));
|
|
|
|
|
Check(head.status == "200", "HEAD status");
|
|
|
|
|
Check(head.body.empty(), "HEAD has no body");
|
|
|
|
|
Check(head.headers.at("content-length") == "12", "HEAD still advertises the length");
|
|
|
|
|
|
|
|
|
|
// A throwing handler must become a 500, not a dropped connection.
|
|
|
|
|
HTTPResponse boom = client.Send(CreateRequestHTTP("GET", "/boom", "localhost"));
|
|
|
|
|
Check(boom.status == "500", "a throwing handler yields 500");
|
|
|
|
|
Check(boom.body.find("handler exploded") != std::string::npos, "500 carries the reason");
|
|
|
|
|
|
|
|
|
|
// Every exchange above shared one connection.
|
|
|
|
|
Check(listener.listener.AcceptedCount() == 1, "the whole test used a single connection");
|
|
|
|
|
|
docs(http1): document the HTTP/1.1 stack, and expose the client's timeout
README: HTTP/1.1 in the intro, feature list, module list, browser-build
exclusions, dependencies and test list, plus a Components section
covering both classes, the standalone codec, what is and is not
implemented, the smuggling-shaped inputs that are rejected, and an
explicit note that this path is plaintext and belongs behind a TLS
terminator.
ClientHTTP1::timeout was hard-coded and invisible; make it a public
member alongside `limits`, mirroring the listener's timeouts.
Also pipelining coverage in ShouldSendRecieveHTTP1: two requests written
before either is answered, driven from a raw socket since ClientHTTP1
waits for each response.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 01:02:37 +00:00
|
|
|
// Pipelining: two requests written in one go, before either is
|
|
|
|
|
// answered. The responses must come back in order, on that same
|
|
|
|
|
// connection. ClientHTTP1 never does this — it waits for each
|
|
|
|
|
// response — so drive it from a raw socket.
|
|
|
|
|
{
|
|
|
|
|
ClientTCP socket("localhost", 8090);
|
|
|
|
|
const std::string pipelined =
|
|
|
|
|
"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
|
|
|
|
"GET /nope HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
|
|
|
|
socket.Send(pipelined.data(), static_cast<std::uint32_t>(pipelined.size()));
|
|
|
|
|
|
|
|
|
|
HTTP1::MessageParser parser(HTTP1::MessageKind::Response);
|
|
|
|
|
std::string first;
|
|
|
|
|
std::string second;
|
|
|
|
|
while (second.empty()) {
|
|
|
|
|
std::vector<char> chunk = socket.RecieveSync();
|
|
|
|
|
parser.Feed(chunk.data(), chunk.size());
|
|
|
|
|
while (parser.Complete()) {
|
|
|
|
|
HTTPResponse response = parser.TakeResponse();
|
|
|
|
|
(first.empty() ? first : second) = response.status;
|
|
|
|
|
parser.Reset();
|
|
|
|
|
if (!second.empty()) break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Check(first == "200", "first pipelined response");
|
|
|
|
|
Check(second == "404", "second pipelined response, in order");
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-07-27 00:45:09 +00:00
|
|
|
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;
|
|
|
|
|
}
|