From 28fab2509b20a0c7206a06cbb0606cc3721c8469 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Thu, 7 May 2026 00:06:44 +0200 Subject: [PATCH 1/2] full QUIC support --- README.md | 90 +-- .../Crafter.Network-ClientHTTP.cpp | 292 ++++----- .../Crafter.Network-ClientQUIC.cpp | 108 +++- .../Crafter.Network-ListenerHTTP.cpp | 392 ++++++------ interfaces/Crafter.Network-ClientHTTP.cppm | 42 +- interfaces/Crafter.Network-ClientQUIC.cppm | 23 +- interfaces/Crafter.Network-HTTP.cppm | 123 ++-- interfaces/Crafter.Network-HTTP3.cppm | 578 ++++++++++++++++++ interfaces/Crafter.Network-ListenerHTTP.cppm | 79 ++- project.cpp | 5 +- tests/ShouldRecieveHTTP/ShouldRecieveHTTP.cpp | 43 -- tests/ShouldRecieveHTTP/project.cpp | 20 - tests/ShouldSend/ShouldSend.cpp | 60 ++ .../project.cpp | 8 +- tests/ShouldSendHTTP/ShouldSendHTTP.cpp | 31 - .../ShouldSendRecieveHTTP.cpp | 26 +- .../ShouldSendRecieveKeepaliveHTTP.cpp | 34 +- .../ShouldSendRecieveLargeHTTP.cpp | 29 +- 18 files changed, 1336 insertions(+), 647 deletions(-) create mode 100644 interfaces/Crafter.Network-HTTP3.cppm delete mode 100644 tests/ShouldRecieveHTTP/ShouldRecieveHTTP.cpp delete mode 100644 tests/ShouldRecieveHTTP/project.cpp create mode 100644 tests/ShouldSend/ShouldSend.cpp rename tests/{ShouldSendHTTP => ShouldSend}/project.cpp (76%) delete mode 100644 tests/ShouldSendHTTP/ShouldSendHTTP.cpp diff --git a/README.md b/README.md index 1076a2e..ed6a5aa 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # Crafter.Network -A cross-platform C++ networking library providing TCP and HTTP client/server functionality with modern C++ features. +A cross-platform C++ networking library providing TCP, QUIC, and HTTP/3 client/server functionality with modern C++ features. ## Overview -Crafter.Network is a comprehensive networking library designed for modern C++ applications. It provides TCP, HTTP, and QUIC networking capabilities with support for synchronous and asynchronous operations, making it suitable for a wide range of networking tasks including real-time multiplayer games. +Crafter.Network is a C++ networking library designed for modern C++ applications. It provides TCP, QUIC, and HTTP/3 networking capabilities with support for synchronous and asynchronous operations, making it suitable for a wide range of networking tasks including real-time multiplayer games. ## Features -- **TCP Networking**: Client and server implementations for TCP connections -- **HTTP Support**: Full HTTP client and server implementations with routing capabilities -- **QUIC Networking**: Encrypted, multi-stream transport via msquic — reliable streams for control plane, unreliable datagrams for low-latency state sync -- **Asynchronous Operations**: Thread pool-based async operations for improved performance -- **Cross-Platform**: Built for Unix-like systems with socket-based networking -- **Modern C++**: Uses C++ modules, STL containers, and modern C++ features +- **TCP Networking**: Client and server implementations for raw TCP connections. +- **QUIC Networking**: Encrypted, multi-stream transport via msquic — reliable streams for control plane, unreliable datagrams for low-latency state sync. +- **HTTP/3**: Client and server implementations on top of QUIC. Uses ALPN `h3`, QUIC bidi streams for requests/responses, the mandatory unidirectional control stream + SETTINGS frame (RFC 9114 §6.2.1), and a built-in QPACK codec (RFC 9204) with the full static table, Huffman *decoding* (RFC 7541), and literal-only emission. The QPACK dynamic table is unused; the optional QPACK encoder/decoder unidi streams are deliberately not opened (RFC 9204 §4.2 permits this when no dynamic-table operations are issued). The client is interoperable with mainstream public h3 endpoints (cloudflare, nghttp3-based servers, etc.). +- **Asynchronous Operations**: Thread pool–based async operations for improved performance. +- **Cross-Platform**: Built for Unix-like systems with socket-based networking. +- **Modern C++**: Uses C++20 modules, STL containers, and modern C++ features. ## Architecture @@ -23,12 +23,14 @@ The library follows a modular design using C++20 modules: - `Crafter.Network`: Main module that exports all components - `Crafter.Network:ClientTCP`: TCP client implementation - `Crafter.Network:ListenerTCP`: TCP server implementation -- `Crafter.Network:ClientHTTP`: HTTP client implementation -- `Crafter.Network:ListenerHTTP`: HTTP server implementation -- `Crafter.Network:HTTP`: HTTP protocol utilities and data structures +- `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`) +- `Crafter.Network:ListenerHTTP`: HTTP/3 server (ALPN `h3`) +- `Crafter.Network:HTTP`: HTTP request/response types and constructors - `Crafter.Network:ClientQUIC`: QUIC connection (client + accepted-server side) with reliable streams and unreliable datagrams - `Crafter.Network:ListenerQUIC`: QUIC listener accepting incoming connections +The `Crafter.Network:HTTP3` partition contains internal HTTP/3 wire-format helpers (QUIC varint, frame layer, QPACK static-table codec) and is intentionally not re-exported from the main module — it is shared between the `ClientHTTP` and `ListenerHTTP` implementations. + ## Components ### TCP Components @@ -53,30 +55,38 @@ Crafter::ListenerTCP listener(8080, callback); listener.ListenSyncSync(); // Synchronous listening ``` -### HTTP Components +### HTTP/3 Components + +HTTP/3 runs over QUIC, which always requires TLS. Pass server credentials when constructing the listener (or set `selfSigned = true` for a development-only ephemeral cert) and matching client credentials when constructing the client (`insecureNoServerValidation = true` for self-signed servers). #### ClientHTTP ```cpp -// Create an HTTP client -Crafter::ClientHTTP client("httpbin.org", 80); +Crafter::QUICClientCredentials creds; +creds.insecureNoServerValidation = true; // dev-only +Crafter::ClientHTTP client("localhost", 8082, creds); -// Send HTTP request -std::string request = Crafter::CreateRequestHTTP("GET", "/get", "httpbin.org"); -Crafter::HTTPResponse response = client.Send(request); +Crafter::HTTPResponse response = client.Send( + Crafter::CreateRequestHTTP("GET", "/", "localhost") +); +// response.status is the numeric status as a string, e.g. "200" ``` #### ListenerHTTP ```cpp -// Create an HTTP listener with routes -std::unordered_map> routes; -routes["/hello"] = [](const Crafter::HTTPRequest& req) { - return Crafter::CreateResponseHTTP("200 OK", "Hello World!"); +std::unordered_map> routes; +routes["/hello"] = [](const Crafter::HTTPRequest&) { + return Crafter::CreateResponseHTTP("200", "Hello World!"); }; -Crafter::ListenerHTTP listener(8080, routes); +Crafter::QUICServerCredentials creds; +creds.selfSigned = true; // dev-only +Crafter::ListenerHTTP listener(8082, creds, routes); listener.Listen(); ``` +The `HTTPRequest` exposes the four HTTP/3 pseudo-headers (`method`, `scheme`, `authority`, `path`) as named struct fields rather than mixing them into the regular `headers` map. Routes are dispatched by exact match on `path`; unmatched paths return a synthetic 404. + ## Build Configuration The project uses a configuration system with multiple build targets: @@ -88,19 +98,22 @@ The project uses a configuration system with multiple build targets: ## Testing -The library includes comprehensive tests covering: -- Compilation verification -- HTTP receive functionality -- HTTP send functionality -- HTTP send/receive operations -- Keep-alive HTTP operations -- Large HTTP data transfers +The library includes tests covering: +- HTTP/3 round-trip (`ShouldSendRecieveHTTP`) — canonical local client/server round-trip +- HTTP/3 connection multiplexing (`ShouldSendRecieveKeepaliveHTTP`) — two requests share one QUIC connection +- HTTP/3 large body transfer (`ShouldSendRecieveLargeHTTP`) — 10 MiB POST +- HTTP/3 external interop (`ShouldSend`) — live fetch from `cloudflare-quic.com:443`, exercises real TLS chain validation, mandatory control stream, peer-initiated unidi streams, and QPACK Huffman decoding +- QUIC reliable streams +- QUIC unreliable datagrams + +The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. ## Dependencies -- Crafter.Thread: Thread pool management for asynchronous operations -- **msquic** — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` recursively into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=quictls`, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC modules. +- **Crafter.Thread**: Thread pool management for asynchronous operations. +- **msquic** — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` recursively into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=quictls`, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC and HTTP/3 modules. - On Linux msquic links against `libnuma` (provided by the `numactl` package on most distros). +- The self-signed-cert path used by tests/dev shells out to the `openssl` CLI; install `openssl` if you intend to use `QUICServerCredentials{selfSigned = true}`. ## Usage Example @@ -109,15 +122,14 @@ The library includes comprehensive tests covering: #include int main() { - // Simple HTTP client example - Crafter::ClientHTTP client("httpbin.org", 80); - - auto request = Crafter::CreateRequestHTTP("GET", "/get", "httpbin.org"); - auto response = client.Send(request); - + Crafter::QUICClientCredentials creds; + creds.insecureNoServerValidation = true; + Crafter::ClientHTTP client("localhost", 8443, creds); + + auto response = client.Send(Crafter::CreateRequestHTTP("GET", "/", "localhost")); + std::cout << "Status: " << response.status << std::endl; std::cout << "Body: " << response.body << std::endl; - return 0; } ``` @@ -129,4 +141,4 @@ This library is licensed under the GNU Lesser General Public License version 3.0 ## Copyright Copyright (C) 2026 Catcrafts® -Catcrafts.net \ No newline at end of file +Catcrafts.net diff --git a/implementations/Crafter.Network-ClientHTTP.cpp b/implementations/Crafter.Network-ClientHTTP.cpp index a470cf6..a665415 100644 --- a/implementations/Crafter.Network-ClientHTTP.cpp +++ b/implementations/Crafter.Network-ClientHTTP.cpp @@ -19,190 +19,150 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ module; - -#include -#include -#include -#include -#include -#include -#include -#include -#include - +#include module Crafter.Network:ClientHTTP_impl; import :ClientHTTP; +import :ClientQUIC; +import :HTTP; +import :HTTP3; import Crafter.Thread; import std; using namespace Crafter; -ClientHTTP::ClientHTTP(const char* host, std::uint16_t port): host(host), port(port), client(host, port) { +struct ClientHTTP::Impl { + ClientQUIC quic; + // Outgoing control stream — RFC 9114 §6.2.1: each peer MUST open a + // unidirectional control stream and send a SETTINGS frame as its first + // frame. Most real h3 servers (cloudflare, nghttp3, lsquic, …) close + // the connection with H3_MISSING_SETTINGS if we don't. The stream + // stays open for the lifetime of the connection; we never FIN it. + QUICStream controlStream; -} - -ClientHTTP::ClientHTTP(std::string host, std::uint16_t port): ClientHTTP(host.c_str(), port) { - -} - -HTTPResponse ClientHTTP::Send(const char* request, std::uint32_t length) { - std::cout << "Send started" << std::endl; - client.Send(request, length); - std::cout << "Send Complete" << std::endl; - std::vector buffer; - HTTPResponse response; - std::uint32_t i = 0; - std::uint32_t statusStart = 0; - while(true) { - try { - buffer = client.RecieveSync(); - std::cout << "Recieved: " << buffer.size() << std::endl; - } catch(const SocketClosedException& e) { - std::cout << "Retry" << std::endl; - client.Stop(); - client.Connect(); - client.Send(request, length); - buffer = client.RecieveSync(); - std::cout << "Recieved: " << buffer.size() << std::endl; - } - - for(; i < buffer.size(); i++) { - if(buffer[i] == ' ') { - statusStart = i; - break; + Impl(const char* host, std::uint16_t port, QUICClientCredentials creds) + : quic(host, port, std::string(HTTP3::kAlpn), creds) { + // Drain any unidi streams the server opens to us (its control + // stream + optional QPACK encoder/decoder streams). We don't act + // on the contents — SETTINGS we accept by defaults, dynamic-table + // mutations we discard since we operate with no dynamic table. + // Any bidi stream from the server would be a server push, which + // we don't support — best-effort drain it as well. + quic.OnStream([](QUICStream stream) { + try { + while (true) (void)stream.RecieveSync(); + } catch (...) { + // Stream / connection closed. Done. } - } - for(; i < buffer.size(); i++) { - if(buffer[i] == '\r') { - response.status.assign(buffer.data()+statusStart+1, i-statusStart-1); - break; - } - } - i+=2; - while(i < buffer.size()) { - std::uint32_t headerStart = i; - std::string headerName; - for(; i < buffer.size(); i++) { - if(buffer[i] == ':') { - headerName.assign(buffer.data()+headerStart, i-headerStart); - std::transform(headerName.begin(), headerName.end(), headerName.begin(), [](unsigned char c){ return std::tolower(c); }); - i+=2; - break; - } - } - headerStart = i; - std::string headerValue; - for(; i < buffer.size(); i++) { - if(buffer[i] == '\r' && buffer[i+1] == '\n') { - headerValue.assign(buffer.data()+headerStart, i-headerStart); - response.headers.insert({headerName, headerValue}); - if(buffer[i+2] == '\r'){ - goto headersComplete; - } else{ - i+=2; - break; - } - } - } - } + }); - i = 0; + controlStream = quic.OpenStream(/*unidirectional=*/true); + auto prelude = HTTP3::BuildControlStreamPrelude(); + controlStream.SendSync(prelude.data(), + static_cast(prelude.size()), + /*finish=*/false); } - headersComplete:; - std::cout << "Header complete" << std::endl; - i+=4; - std::unordered_map::iterator it = response.headers.find("content-length"); - if(it != response.headers.end()) - { - const int lenght = std::stoi(it->second); - std::cout << "Content lenght: " << lenght << std::endl; - response.body.resize(lenght, 0); - if(i < buffer.size()){ - std::memcpy(&response.body[0], buffer.data()+i, buffer.size()-i); - } - const int remaining = lenght-(buffer.size()-i); - std::cout << "Remain: " << remaining << std::endl; - if(remaining > 0){ - std::vector bodyBuffer = client.RecieveUntilFullSync(remaining); - std::memcpy(&response.body[ buffer.size()-i], bodyBuffer.data(), bodyBuffer.size()); - std::cout << "Recieved: " << bodyBuffer.size() << std::endl; - } - } else { - std::cout << "No Content Lenght" << std::endl; - std::unordered_map::iterator it = response.headers.find("transfer-encoding"); - if(it != response.headers.end() && it->second == "chunked") { - std::cout << "Chunked" << std::endl; - while(i < buffer.size()){ - std::string lenght; - int lenghtStart = i; - for(; i < buffer.size(); i++) { - if(buffer[i] == '\r') { - lenght.assign(buffer.data()+lenghtStart, i-lenghtStart); - break; - } - } - i+=2; - int lenghtInt = stoi(lenght, 0, 8); - if(lenghtInt != 0){ - int oldSize = response.body.size(); - response.body.resize(oldSize+lenghtInt, 0); - if(buffer.size() < lenghtInt) { - std::memcpy(&response.body[oldSize], buffer.data()+i, buffer.size()-i); - std::vector bodyBuffer2 = client.RecieveUntilFullSync(lenghtInt-buffer.size()); - std::memcpy(&response.body[oldSize+(buffer.size()-i)], buffer.data(), buffer.size()); - } else { - std::memcpy(&response.body[oldSize], buffer.data()+i, lenghtInt); - i+=lenghtInt; - } - } else{ - goto bodyFinished; - } +}; + +ClientHTTP::ClientHTTP(const char* host, std::uint16_t port, QUICClientCredentials creds) + : host(host), port(port), impl(std::make_unique(host, port, std::move(creds))) {} + +ClientHTTP::ClientHTTP(std::string host, std::uint16_t port, QUICClientCredentials creds) + : ClientHTTP(host.c_str(), port, std::move(creds)) {} + +ClientHTTP::ClientHTTP(ClientHTTP&&) noexcept = default; +ClientHTTP::~ClientHTTP() = default; + +namespace { + // Parse a sequence of HTTP/3 frames from `bytes`. Populates response from + // the first HEADERS frame and concatenates all DATA payloads. Trailing + // HEADERS frames (trailers) are decoded but discarded. Throws on + // malformed input. + HTTPResponse ParseResponseFrames(const std::vector& bytes) { + HTTPResponse response; + bool sawHeaders = false; + std::size_t pos = 0; + const auto* p = reinterpret_cast(bytes.data()); + std::size_t avail = bytes.size(); + + while (pos < avail) { + std::uint64_t frameType = 0, frameLen = 0; + std::size_t cn = 0; + if (!HTTP3::DecodeVarint(p + pos, avail - pos, frameType, cn)) { + throw HTTP3::HTTP3ProtocolError("truncated frame type"); } - while(true) { - std::vector bodyBuffer = client.RecieveSync(); - int i2 = 0; - while(i2 < bodyBuffer.size()){ - std::string lenght; - int lenghtStart = i2; - for(; i2 < bodyBuffer.size(); i2++) { - if(buffer[i2] == '\r') { - lenght.assign(bodyBuffer.data()+lenghtStart, i2-lenghtStart); - break; - } - } - i2+=2; - int lenghtInt = stoi(lenght, 0, 8); - if(lenghtInt != 0){ - int oldSize = response.body.size(); - response.body.resize(oldSize+lenghtInt, 0); - if(bodyBuffer.size() < lenghtInt) { - std::memcpy(&response.body[oldSize], bodyBuffer.data()+i2, bodyBuffer.size()-i2); - std::vector bodyBuffer2 = client.RecieveUntilFullSync(lenghtInt-bodyBuffer.size()); - std::memcpy(&response.body[oldSize+(bodyBuffer.size()-i2)], bodyBuffer2.data(), bodyBuffer2.size()); + pos += cn; + if (!HTTP3::DecodeVarint(p + pos, avail - pos, frameLen, cn)) { + throw HTTP3::HTTP3ProtocolError("truncated frame length"); + } + pos += cn; + if (pos + frameLen > avail) { + throw HTTP3::HTTP3ProtocolError("frame length runs past buffer"); + } + if (frameType == HTTP3::kFrameHeaders) { + auto fields = HTTP3::DecodeFieldSection(p + pos, static_cast(frameLen)); + if (!sawHeaders) { + for (auto& [name, value] : fields) { + if (name == ":status") { + response.status = std::move(value); + } else if (!name.empty() && name[0] == ':') { + // Unknown response pseudo-header — ignore. } else { - std::memcpy(&response.body[oldSize], bodyBuffer.data()+i2, lenghtInt); - i2+=lenghtInt; + response.headers.emplace(std::move(name), std::move(value)); } - } else{ - goto bodyFinished; } + sawHeaders = true; } + // Trailer HEADERS frames are skipped; the field section was + // already decoded above and the contents discarded. + } else if (frameType == HTTP3::kFrameData) { + response.body.append(reinterpret_cast(p + pos), + static_cast(frameLen)); + } else { + // Unknown frame types are reserved/extensions — RFC 9114 §9 + // says skip them. } - bodyFinished:; - } else { - std::cout << "Recv until close" << std::endl; - std::vector bodyBuffer = client.RecieveUntilCloseSync(); - response.body.resize((buffer.size()-i)+(bodyBuffer.size()), 0); - if(i < buffer.size()){ - std::memcpy(&response.body[0], buffer.data()+i, buffer.size()-i); - } - std::memcpy(&response.body[buffer.size()-i], bodyBuffer.data(), bodyBuffer.size()); - std::cout << "Closed" << std::endl; + pos += static_cast(frameLen); } + if (!sawHeaders) { + throw HTTP3::HTTP3ProtocolError("response stream had no HEADERS frame"); + } + return response; } - std::cout << "Response recieved" << std::endl; - return response; } -HTTPResponse ClientHTTP::Send(std::string request) { - return Send(request.c_str(), request.size()); + +HTTPResponse ClientHTTP::Send(const HTTPRequest& request) { + QUICStream stream = impl->quic.OpenStream(); + + // Pseudo-headers MUST appear before regular fields (RFC 9114 §4.3). + std::vector> fields; + fields.reserve(4 + request.headers.size()); + fields.emplace_back(":method", request.method.empty() ? std::string("GET") : request.method); + fields.emplace_back(":scheme", request.scheme.empty() ? std::string("https") : request.scheme); + fields.emplace_back(":authority", + request.authority.empty() ? (host + ":" + std::to_string(port)) : request.authority); + fields.emplace_back(":path", request.path.empty() ? std::string("/") : request.path); + for (const auto& [name, value] : request.headers) { + // HTTP/3 forbids uppercase in field names — lowercase defensively. + std::string lower = name; + std::ranges::transform(lower, lower.begin(), + [](unsigned char c){ return static_cast(std::tolower(c)); }); + fields.emplace_back(std::move(lower), value); + } + + auto encoded = HTTP3::EncodeFieldSection(fields); + + std::vector wire; + HTTP3::WriteFrame(wire, HTTP3::kFrameHeaders, encoded.data(), encoded.size()); + if (!request.body.empty()) { + HTTP3::WriteFrame(wire, HTTP3::kFrameData, + reinterpret_cast(request.body.data()), + request.body.size()); + } + + // Send the entire request and FIN our send-side. HTTP/3 servers need FIN + // to know the request is complete — there's no Content-Length signal. + stream.SendSync(wire.data(), static_cast(wire.size()), /*finish=*/true); + + auto raw = stream.RecieveUntilCloseSync(); + return ParseResponseFrames(raw); } diff --git a/implementations/Crafter.Network-ClientQUIC.cpp b/implementations/Crafter.Network-ClientQUIC.cpp index 6bb4b23..7b16d68 100644 --- a/implementations/Crafter.Network-ClientQUIC.cpp +++ b/implementations/Crafter.Network-ClientQUIC.cpp @@ -150,6 +150,8 @@ struct QUICStream::Impl { } }; +QUICStream::QUICStream() = default; + QUICStream::QUICStream(HQUIC handle, ClientQUIC* connection) : handle(handle), connection(connection), impl(std::make_unique()) { @@ -159,7 +161,9 @@ QUICStream::QUICStream(HQUIC handle, ClientQUIC* connection) } QUICStream::QUICStream(QUICStream&& other) noexcept - : handle(other.handle), connection(other.connection), impl(std::move(other.impl)) + : handle(other.handle), connection(other.connection), + canSend(other.canSend), canReceive(other.canReceive), + impl(std::move(other.impl)) { other.handle = nullptr; other.connection = nullptr; @@ -170,6 +174,8 @@ QUICStream& QUICStream::operator=(QUICStream&& other) noexcept { Stop(); handle = other.handle; connection = other.connection; + canSend = other.canSend; + canReceive = other.canReceive; impl = std::move(other.impl); other.handle = nullptr; other.connection = nullptr; @@ -183,12 +189,26 @@ QUICStream::~QUICStream() { void QUICStream::Stop() { if (!handle) return; - Runtime().api->StreamShutdown(handle, QUIC_STREAM_SHUTDOWN_FLAG_GRACEFUL, 0); + // If the stream's SHUTDOWN_COMPLETE event has already fired, msquic has + // internally called StreamClose for us (see Impl::Callback) and the + // handle is no longer valid — calling StreamShutdown on it trips a + // quic_bugcheck inside msquic. Skip in that case. This is the common + // path for short-lived request/response streams where both peers FIN + // before the wrapper is destroyed. + bool alreadyClosed = false; + if (impl) { + std::lock_guard lk(impl->mtx); + alreadyClosed = impl->shutdownComplete; + } + if (!alreadyClosed) { + Runtime().api->StreamShutdown(handle, QUIC_STREAM_SHUTDOWN_FLAG_GRACEFUL, 0); + } handle = nullptr; + if (impl) impl->handle = nullptr; } void QUICStream::SendSync(const void* buffer, std::uint32_t size, bool finish) { - if (!handle) throw QUICClosedException(); + if (!handle || !canSend) throw QUICClosedException(); auto* copy = new char[size]; std::memcpy(copy, buffer, size); QUIC_BUFFER quicBuf{}; @@ -210,7 +230,7 @@ void QUICStream::SendSync(const void* buffer, std::uint32_t size, bool finish) { } std::vector QUICStream::RecieveSync() { - if (!handle) throw QUICClosedException(); + if (!handle || !canReceive) throw QUICClosedException(); std::unique_lock lk(impl->mtx); impl->cv.wait(lk, [&]{ return !impl->pending.empty() || impl->peerSendClosed || impl->shutdownComplete; }); if (!impl->pending.empty()) { @@ -222,7 +242,7 @@ std::vector QUICStream::RecieveSync() { } std::vector QUICStream::RecieveUntilCloseSync() { - if (!handle) throw QUICClosedException(); + if (!handle || !canReceive) throw QUICClosedException(); std::vector out; while (true) { std::unique_lock lk(impl->mtx); @@ -237,7 +257,7 @@ std::vector QUICStream::RecieveUntilCloseSync() { } std::vector QUICStream::RecieveUntilFullSync(std::uint32_t bufferSize) { - if (!handle) throw QUICClosedException(); + if (!handle || !canReceive) throw QUICClosedException(); std::vector out; out.reserve(bufferSize); while (out.size() < bufferSize) { @@ -285,6 +305,12 @@ struct ClientQUIC::Impl { std::function onStream; std::function)> onDatagram; std::deque> datagramQueue; + // Streams the peer started before the user installed an OnStream + // handler. Without this backlog the early streams (e.g. an h3 server's + // control stream right after handshake) would be aborted in the + // PEER_STREAM_STARTED branch and the connection would die with + // H3_MISSING_SETTINGS on the peer side. + std::deque pendingStreams; ClientQUIC* outer = nullptr; @@ -325,18 +351,30 @@ struct ClientQUIC::Impl { } case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: { HQUIC streamHandle = ev->PEER_STREAM_STARTED.Stream; + bool unidirectional = (ev->PEER_STREAM_STARTED.Flags + & QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL) != 0; QUICStream stream(streamHandle, self->outer); - if (self->onStream) { - auto cb = self->onStream; - auto* shared = new QUICStream(std::move(stream)); - ThreadPool::Enqueue([cb, shared]{ - cb(std::move(*shared)); - delete shared; - }); - } else { - // No handler: shut down to avoid leaking a stream. - Runtime().api->StreamShutdown(streamHandle, QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0); + if (unidirectional) { + // Peer-initiated unidi: peer sends, we read; we cannot send. + stream.canSend = false; + stream.canReceive = true; } + std::function cb; + { + std::lock_guard lk(self->mtx); + cb = self->onStream; + if (!cb) { + // Buffer until OnStream is installed; OnStream's + // setter drains this queue. + self->pendingStreams.push_back(std::move(stream)); + return QUIC_STATUS_SUCCESS; + } + } + auto* shared = new QUICStream(std::move(stream)); + ThreadPool::Enqueue([cb, shared]{ + cb(std::move(*shared)); + delete shared; + }); return QUIC_STATUS_SUCCESS; } case QUIC_CONNECTION_EVENT_DATAGRAM_RECEIVED: { @@ -382,6 +420,16 @@ static HQUIC OpenClientConfiguration(const std::string& alpn, const QUICClientCr settings.IdleTimeoutMs = 30'000; settings.IsSet.DatagramReceiveEnabled = 1; settings.DatagramReceiveEnabled = 1; + // Allow the server to open unidi/bidi streams to us. msquic defaults + // both peer-stream-count limits to 0; with that, the server's HTTP/3 + // control stream + QPACK encoder/decoder streams can't be created and + // most h3 servers will close the connection after handshake. We don't + // currently use server push (h3 pushes ride on unidi 0x01 streams) but + // the bidi cap is harmless to grant. + settings.IsSet.PeerUnidiStreamCount = 1; + settings.PeerUnidiStreamCount = 16; + settings.IsSet.PeerBidiStreamCount = 1; + settings.PeerBidiStreamCount = 16; HQUIC cfg = nullptr; QUIC_STATUS s = Runtime().api->ConfigurationOpen(Runtime().registration, &alpnBuffer, 1, @@ -470,18 +518,26 @@ void ClientQUIC::Stop() { Runtime().api->ConnectionShutdown(impl->connection, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, 0); } -QUICStream ClientQUIC::OpenStream() { +QUICStream ClientQUIC::OpenStream(bool unidirectional) { HQUIC streamHandle = nullptr; QUICStream stream; stream.impl = std::make_unique(); stream.impl->connection = this; - QUIC_STATUS s = Runtime().api->StreamOpen(impl->connection, QUIC_STREAM_OPEN_FLAG_NONE, + QUIC_STREAM_OPEN_FLAGS openFlags = unidirectional + ? QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL + : QUIC_STREAM_OPEN_FLAG_NONE; + QUIC_STATUS s = Runtime().api->StreamOpen(impl->connection, openFlags, reinterpret_cast(&QUICStream::Impl::Callback), stream.impl.get(), &streamHandle); if (QUIC_FAILED(s)) throw QUICException(std::format("StreamOpen failed: 0x{:x}", static_cast(s))); stream.handle = streamHandle; stream.connection = this; stream.impl->handle = streamHandle; + if (unidirectional) { + // We initiated the unidi stream: we send, peer reads. + stream.canSend = true; + stream.canReceive = false; + } s = Runtime().api->StreamStart(streamHandle, QUIC_STREAM_START_FLAG_NONE); if (QUIC_FAILED(s)) { Runtime().api->StreamClose(streamHandle); @@ -510,7 +566,21 @@ void ClientQUIC::SendDatagram(const void* buffer, std::uint32_t size) { } void ClientQUIC::OnStream(std::function cb) { - impl->onStream = std::move(cb); + std::deque backlog; + { + std::lock_guard lk(impl->mtx); + impl->onStream = cb; + std::swap(backlog, impl->pendingStreams); + } + while (!backlog.empty()) { + auto* shared = new QUICStream(std::move(backlog.front())); + backlog.pop_front(); + auto handler = cb; + ThreadPool::Enqueue([handler, shared]{ + handler(std::move(*shared)); + delete shared; + }); + } } void ClientQUIC::OnDatagram(std::function)> cb) { diff --git a/implementations/Crafter.Network-ListenerHTTP.cpp b/implementations/Crafter.Network-ListenerHTTP.cpp index 3124eef..c1aadb2 100644 --- a/implementations/Crafter.Network-ListenerHTTP.cpp +++ b/implementations/Crafter.Network-ListenerHTTP.cpp @@ -19,224 +19,228 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ module; - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - +#include module Crafter.Network:ListenerHTTP_impl; import :ListenerHTTP; -import :ClientTCP; -import std; +import :ListenerQUIC; +import :ClientQUIC; +import :HTTP; +import :HTTP3; import Crafter.Thread; +import std; using namespace Crafter; -ListenerHTTP::ListenerHTTP(std::uint16_t port, std::unordered_map> routes): routes(routes) { - sockaddr_in servAddr; - bzero((char*)&servAddr, sizeof(servAddr)); - servAddr.sin_family = AF_INET; - servAddr.sin_addr.s_addr = htonl(INADDR_ANY); - servAddr.sin_port = htons(port); +namespace { + // Parse a complete request stream's bytes into an HTTPRequest. The stream + // is closed by the peer with FIN, so we read until close and then + // frame-walk the bytes (HEADERS [+ DATA]*). + HTTPRequest ParseRequestFrames(const std::vector& bytes) { + HTTPRequest request; + bool sawHeaders = false; + std::size_t pos = 0; + const auto* p = reinterpret_cast(bytes.data()); + std::size_t avail = bytes.size(); - s = socket(AF_INET, SOCK_STREAM, 0); - if (s < 0) { - throw std::runtime_error("Error establishing the server socket"); + while (pos < avail) { + std::uint64_t frameType = 0, frameLen = 0; + std::size_t cn = 0; + if (!HTTP3::DecodeVarint(p + pos, avail - pos, frameType, cn)) { + throw HTTP3::HTTP3ProtocolError("truncated frame type"); + } + pos += cn; + if (!HTTP3::DecodeVarint(p + pos, avail - pos, frameLen, cn)) { + throw HTTP3::HTTP3ProtocolError("truncated frame length"); + } + pos += cn; + if (pos + frameLen > avail) { + throw HTTP3::HTTP3ProtocolError("frame length runs past buffer"); + } + if (frameType == HTTP3::kFrameHeaders) { + auto fields = HTTP3::DecodeFieldSection(p + pos, static_cast(frameLen)); + if (!sawHeaders) { + for (auto& [name, value] : fields) { + if (name == ":method") request.method = std::move(value); + else if (name == ":scheme") request.scheme = std::move(value); + else if (name == ":authority") request.authority = std::move(value); + else if (name == ":path") request.path = std::move(value); + else if (!name.empty() && name[0] == ':') { + // Unknown request pseudo-header — ignore. + } else { + request.headers.emplace(std::move(name), std::move(value)); + } + } + sawHeaders = true; + } + } else if (frameType == HTTP3::kFrameData) { + request.body.append(reinterpret_cast(p + pos), + static_cast(frameLen)); + } else { + // Skip unknown frames (RFC 9114 §9 — reserved/extension frame + // types are silently ignored). + } + pos += static_cast(frameLen); + } + if (!sawHeaders) { + throw HTTP3::HTTP3ProtocolError("request stream had no HEADERS frame"); + } + return request; } - int opt = 1; - if (setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) { - throw std::runtime_error("Error setting SO_REUSEADDR"); - } + // Serialise a response into HEADERS [+ DATA] frames. + std::vector SerializeResponse(const HTTPResponse& response) { + std::vector> fields; + fields.reserve(1 + response.headers.size()); + fields.emplace_back(":status", response.status.empty() ? std::string("200") : response.status); + for (const auto& [name, value] : response.headers) { + std::string lower = name; + std::ranges::transform(lower, lower.begin(), + [](unsigned char c){ return static_cast(std::tolower(c)); }); + fields.emplace_back(std::move(lower), value); + } + auto encoded = HTTP3::EncodeFieldSection(fields); - int bindStatus = bind(s, (struct sockaddr*)&servAddr, sizeof(servAddr)); - if (bindStatus < 0) { - throw std::runtime_error(std::format("Error binding the server socket: {}", std::strerror(errno))); - } - - if (listen(s, 5) < 0) { - throw std::runtime_error("Error starting to listen on the server socket"); + std::vector wire; + HTTP3::WriteFrame(wire, HTTP3::kFrameHeaders, encoded.data(), encoded.size()); + if (!response.body.empty()) { + HTTP3::WriteFrame(wire, HTTP3::kFrameData, + reinterpret_cast(response.body.data()), + response.body.size()); + } + return wire; } } +// Per-peer state for an accepted connection. Holds the connection wrapper +// and the server-side control stream alive for the lifetime of the peer. +struct PeerState { + std::unique_ptr quic; + QUICStream controlStream; +}; + +struct ListenerHTTP::Impl { + std::unique_ptr listener; + std::mutex peersMtx; + std::vector> peers; + bool running = true; +}; + +ListenerHTTP::ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> r) + : routes(std::move(r)) + , alpn(HTTP3::kAlpn) + , impl(std::make_unique()) +{ + // The connect callback wires up an OnStream handler that splits unidi + // streams (control / QPACK) from bidi streams (request streams) and + // sends our own SETTINGS frame on a freshly-opened control stream. + auto onConnect = [this](ClientQUIC* peer) { + auto state = std::make_unique(); + state->quic.reset(peer); + + peer->OnStream([this](QUICStream stream) { + if (!stream.canSend) { + // Peer-initiated unidi: client's control stream + optional + // QPACK encoder/decoder streams. Drain — we honour SETTINGS + // by accepting defaults, and we don't track QPACK dynamic- + // table mutations because we don't use the dynamic table. + try { + while (true) (void)stream.RecieveSync(); + } catch (...) {} + return; + } + + // Bidi stream: a request. Drive a single request/response cycle. + try { + auto raw = stream.RecieveUntilCloseSync(); + HTTPRequest request = ParseRequestFrames(raw); + + HTTPResponse response; + auto it = routes.find(request.path); + if (it != routes.end()) { + response = it->second(request); + } else { + response.status = "404"; + response.body = "Not Found"; + } + + auto wire = SerializeResponse(response); + stream.SendSync(wire.data(), + static_cast(wire.size()), + /*finish=*/true); + } catch (const std::exception& e) { + // Best-effort 500 if we can still send. Stream may already + // be closed; swallow further errors silently. + try { + HTTPResponse err; + err.status = "500"; + err.body = e.what(); + auto wire = SerializeResponse(err); + stream.SendSync(wire.data(), + static_cast(wire.size()), + /*finish=*/true); + } catch (...) {} + } + }); + + // Open our outgoing control stream and write the SETTINGS prelude. + // Do this AFTER OnStream is registered so any client-initiated + // unidi stream that races in is handled. The control stream must + // remain open for the connection's lifetime — we never FIN it. + try { + state->controlStream = peer->OpenStream(/*unidirectional=*/true); + auto prelude = HTTP3::BuildControlStreamPrelude(); + state->controlStream.SendSync(prelude.data(), + static_cast(prelude.size()), + /*finish=*/false); + } catch (...) { + // If the connection died mid-handshake we land here; the peer + // gets dropped via destruction below. + } + + std::lock_guard lk(impl->peersMtx); + impl->peers.push_back(std::move(state)); + }; + + impl->listener = std::make_unique(port, + std::string(HTTP3::kAlpn), + std::move(creds), + onConnect); +} + +ListenerHTTP::ListenerHTTP(ListenerHTTP&&) noexcept = default; + ListenerHTTP::~ListenerHTTP() { - if(s != -1) { - Stop(); - } + if (impl) Stop(); } void ListenerHTTP::Stop() { - running = false; - shutdown(s, SHUT_RDWR); - close(s); - s = -1; - for(ListenerHTTPClient* client : clients) { - client->client.Stop(); - client->thread.join(); - delete client; - } + if (!impl) return; + impl->running = false; + if (impl->listener) impl->listener->Stop(); } void ListenerHTTP::Listen() { - while(running) { - sockaddr_in newSockAddr; - socklen_t newSockAddrSize = sizeof(newSockAddr); - int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); - if(!running) { - return; - } - if (client > 0) { - clients.push_back(new ListenerHTTPClient(this, client)); - } else { - std::cerr << "Error accepting request from client!" << std::endl; - } - std::erase_if(clients, [](ListenerHTTPClient* client) { - if (client->disconnected.load()) { - client->thread.join(); - delete client; - return true; - } - return false; - }); - } + if (!impl || !impl->listener) return; + // ListenSyncAsync runs the accept loop on this thread and dispatches the + // per-connection callback (control-stream open + OnStream wiring) on the + // ThreadPool. That keeps route handlers off the accept thread. + impl->listener->ListenSyncAsync(); } -ListenerHTTPClient::ListenerHTTPClient(ListenerHTTP* server, int s) : server(server), client(s), thread(&ListenerHTTPClient::ListenRoutes, this), disconnected(false) { - -} - -void ListenerHTTPClient::ListenRoutes() { - try { - while(true) { - std::vector buffer; - HTTPRequest request; - std::string route; - std::uint32_t i = 0; - std::uint32_t routeStart = 0; - while(true) { - buffer = client.RecieveSync(); - while(true) { - std::string str(buffer.begin(), buffer.end()); - for(; i < buffer.size(); i++) { - if(buffer[i] == ' ') { - request.method.assign(buffer.data(), i); - break; - } - } - for(; i < buffer.size(); i++) { - if(buffer[i] == '/') { - routeStart = i; - break; - } - } - for(; i < buffer.size(); i++) { - if(buffer[i] == ' ') { - route.assign(buffer.data()+routeStart, i-routeStart); - break; - } - } - for(; i < buffer.size(); i++) { - if(buffer[i] == '\r' && buffer[i+1] == '\n') { - break; - } - } - i+=2; - while(i < buffer.size()) { - std::uint32_t headerStart = i; - std::string headerName; - for(; i < buffer.size(); i++) { - if(buffer[i] == ':') { - headerName.assign(buffer.data()+headerStart, i-headerStart); - std::transform(headerName.begin(), headerName.end(), headerName.begin(), [](unsigned char c){ return std::tolower(c); }); - i++; - break; - } - } - headerStart = i; - std::string headerValue; - for(; i < buffer.size(); i++) { - if(buffer[i] == '\r' && buffer[i+1] == '\n') { - headerValue.assign(buffer.data()+headerStart, i-headerStart); - request.headers.insert({headerName, headerValue}); - if(buffer[i+2] == '\r'){ - goto headersComplete; - } else{ - i+=2; - break; - } - } - } - } - i = 0; - } - headersComplete:; - i+=4; - std::unordered_map::iterator it = request.headers.find("content-length"); - if(it != request.headers.end()) { - const int lenght = std::stoi(it->second); - request.body.resize(lenght, 0); - if(lenght > 0 ){ - std::int_fast32_t remaining = lenght-(buffer.size()-i); - if(remaining < 0) { - std::memcpy(&request.body[0], buffer.data()+i, lenght); - std::string response = server->routes.at(route)(request); - client.Send(&response[0], response.size()); - i+=lenght; - } else if(remaining == 0){ - std::memcpy(&request.body[0], buffer.data()+i, lenght); - std::string response = server->routes.at(route)(request); - client.Send(&response[0], response.size()); - break; - } else { - std::memcpy(&request.body[0], buffer.data()+i, buffer.size()-i); - std::vector bodyBuffer = client.RecieveUntilFullSync(remaining); - std::memcpy(&request.body[buffer.size()-i], bodyBuffer.data(), remaining); - std::string response = server->routes.at(route)(request); - client.Send(&response[0], response.size()); - break; - } - } else { - std::string response = server->routes.at(route)(request); - client.Send(&response[0], response.size()); - if(i == buffer.size()) { - break; - } - } - } else { - std::string response = server->routes.at(route)(request); - client.Send(&response[0], response.size()); - if(i == buffer.size()) { - break; - } - } - } - } - } catch(SocketClosedException& e) { - disconnected.store(true); - } -} - - -ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port, std::unordered_map> routes): listener(port, routes), thread(&ListenerHTTP::Listen, &listener) { - -} +ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes) + : listener(port, std::move(creds), std::move(routes)) + , thread(&ListenerHTTP::Listen, &listener) +{} ListenerAsyncHTTP::~ListenerAsyncHTTP() { - if(listener.s != -1) { - Stop(); - } + Stop(); } void ListenerAsyncHTTP::Stop() { - listener.Stop(); - thread.join(); -} \ No newline at end of file + listener.Stop(); + if (thread.joinable()) thread.join(); +} diff --git a/interfaces/Crafter.Network-ClientHTTP.cppm b/interfaces/Crafter.Network-ClientHTTP.cppm index e4df28e..1ce2793 100644 --- a/interfaces/Crafter.Network-ClientHTTP.cppm +++ b/interfaces/Crafter.Network-ClientHTTP.cppm @@ -20,19 +20,35 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA export module Crafter.Network:ClientHTTP; import std; -import :ClientTCP; import :HTTP; +import :ClientQUIC; namespace Crafter { - export class ClientHTTP { - public: - std::string host; - std::uint16_t port; - ClientHTTP(const char* host, std::uint16_t port); - ClientHTTP(std::string host, std::uint16_t port); - HTTPResponse Send(const char* request, std::uint32_t length); - HTTPResponse Send(std::string request); - private: - ClientTCP client; - }; -} \ No newline at end of file + // HTTP/3 client over QUIC. The constructor establishes the QUIC connection + // (TLS handshake + ALPN "h3"); each Send() opens a fresh request stream + // on the multiplexed connection. Thread-affinity: Send() is not safe to + // call from multiple threads concurrently against the same ClientHTTP, + // but distinct ClientHTTP instances are independent. + // + // For local development against a self-signed listener, pass + // QUICClientCredentials{insecureNoServerValidation = true}. + export class ClientHTTP { + public: + std::string host; + std::uint16_t port; + + ClientHTTP(const char* host, std::uint16_t port, QUICClientCredentials creds = {}); + ClientHTTP(std::string host, std::uint16_t port, QUICClientCredentials creds = {}); + + ~ClientHTTP(); + ClientHTTP(const ClientHTTP&) = delete; + ClientHTTP(ClientHTTP&&) noexcept; + + // Send a request and synchronously read back the full response. + HTTPResponse Send(const HTTPRequest& request); + + private: + struct Impl; + std::unique_ptr impl; + }; +} diff --git a/interfaces/Crafter.Network-ClientQUIC.cppm b/interfaces/Crafter.Network-ClientQUIC.cppm index 1d31b8c..713d22a 100644 --- a/interfaces/Crafter.Network-ClientQUIC.cppm +++ b/interfaces/Crafter.Network-ClientQUIC.cppm @@ -52,10 +52,12 @@ namespace Crafter { export class ClientQUIC; - // A reliable, ordered, bidirectional stream within a QUIC connection. - // Owned by ClientQUIC; do not destroy directly. Obtain via - // ClientQUIC::OpenStream() or via the on-stream callback for inbound - // streams initiated by the peer. + // A reliable, ordered stream within a QUIC connection. May be + // bidirectional or unidirectional; for unidi streams either canSend or + // canReceive will be false depending on which side initiated. Owned by + // ClientQUIC; do not destroy directly. Obtain via ClientQUIC::OpenStream + // (optionally with unidirectional=true) or via the on-stream callback + // for inbound streams initiated by the peer. export class QUICStream { public: // Underlying msquic HQUIC handle. Treated as opaque by callers. @@ -64,7 +66,12 @@ namespace Crafter { // The connection that owns this stream (non-owning). ClientQUIC* connection = nullptr; - QUICStream() = default; + // Direction flags. Bidi streams have both true; outgoing unidi sets + // canReceive=false; incoming unidi (peer-initiated) sets canSend=false. + bool canSend = true; + bool canReceive = true; + + QUICStream(); QUICStream(HQUIC handle, ClientQUIC* connection); ~QUICStream(); QUICStream(const QUICStream&) = delete; @@ -135,9 +142,11 @@ namespace Crafter { ClientQUIC(const ClientQUIC&) = delete; ClientQUIC(ClientQUIC&&) noexcept; - // Open a new bidirectional stream initiated by this side. + // Open a new stream initiated by this side. Defaults to bidirectional; + // pass unidirectional=true to open a one-way send stream (used for + // HTTP/3's control + QPACK encoder/decoder streams). // Blocks until the stream is started; throws on failure. - QUICStream OpenStream(); + QUICStream OpenStream(bool unidirectional = false); // Send a datagram. Best-effort: may be silently dropped under loss // or congestion. Size must fit within the path MTU (msquic surfaces diff --git a/interfaces/Crafter.Network-HTTP.cppm b/interfaces/Crafter.Network-HTTP.cppm index b6788a5..84f2652 100644 --- a/interfaces/Crafter.Network-HTTP.cppm +++ b/interfaces/Crafter.Network-HTTP.cppm @@ -22,63 +22,98 @@ export module Crafter.Network:HTTP; import std; namespace Crafter { + // HTTP/3 request as carried over a QUIC bidirectional stream. The four + // pseudo-headers (method/scheme/authority/path) are split out as named + // fields rather than living in the headers map, because RFC 9114 forbids + // them from appearing in the regular header section and this shape makes + // route dispatch and request construction cleaner. `headers` keys are + // expected lowercase; HTTP/3 forbids uppercase characters in field names. export struct HTTPRequest { std::string method; + std::string scheme = "https"; + std::string authority; + std::string path = "/"; std::unordered_map headers; std::string body; }; + // HTTP/3 response. `status` is the numeric three-digit code as a string + // (e.g. "200") — HTTP/3 has no reason phrase. `headers` keys are expected + // lowercase. export struct HTTPResponse { - std::string status; + std::string status = "200"; std::unordered_map headers; std::string body; }; - export constexpr std::string CreateResponseHTTP(std::string status) { - return std::format("HTTP/1.1 {}\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n", status); - } - - export constexpr std::string CreateResponseHTTP(std::string status, std::unordered_map headers) { - std::string headersString; - for (auto const& [key, val] : headers) { - headersString+=std::format("{}: {}\r\n", key, val); - } - return std::format("HTTP/1.1 {}\r\nConnection: keep-alive\r\nContent-Length: 0\r\n{}\r\n", status, headersString); - } - - export constexpr std::string CreateResponseHTTP(std::string status, std::string body) { - return std::format("HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{}", status, body.size(), body); - } - - export constexpr std::string CreateResponseHTTP(std::string status, std::unordered_map headers, std::string body) { - std::string headersString; - for (auto const& [key, val] : headers) { - headersString+=std::format("{}: {}\r\n", key, val); - } - return std::format("HTTP/1.1 {}\r\nConnection: keep-alive\r\nContent-Length: {}\r\n{}\r\n{}", status, body.size(), headersString, body); + export inline HTTPRequest CreateRequestHTTP(std::string method, std::string path, std::string authority) { + HTTPRequest r; + r.method = std::move(method); + r.path = std::move(path); + r.authority = std::move(authority); + return r; } - export constexpr std::string CreateRequestHTTP(std::string method, std::string route, std::string host) { - return std::format("{} {} HTTP/1.1\r\nConnection: keep-alive\r\nAccept-Encoding: identity\r\nContent-Length: 0\r\nHost: {}\r\n\r\n", method, route, host); - } + export inline HTTPRequest CreateRequestHTTP(std::string method, std::string path, std::string authority, + std::unordered_map headers) { + HTTPRequest r; + r.method = std::move(method); + r.path = std::move(path); + r.authority = std::move(authority); + r.headers = std::move(headers); + return r; + } - export constexpr std::string CreateRequestHTTP(std::string method, std::string route, std::string host, std::unordered_map headers) { - std::string headersString; - for (auto const& [key, val] : headers) { - headersString+=std::format("{}: {}\r\n", key, val); - } - return std::format("{} {} HTTP/1.1\r\nConnection: keep-alive\r\nContent-Length: 0\r\nAccept-Encoding: identity\r\nHost: {}\r\n{}\r\n", method, route, host, headersString); - } + export inline HTTPRequest CreateRequestHTTP(std::string method, std::string path, std::string authority, + std::string body) { + HTTPRequest r; + r.method = std::move(method); + r.path = std::move(path); + r.authority = std::move(authority); + r.body = std::move(body); + return r; + } - export constexpr std::string CreateRequestHTTP(std::string method, std::string route, std::string host, std::string body) { - return std::format("{} {} HTTP/1.1\r\nContent-Length: {}\r\nConnection: keep-alive\r\nAccept-Encoding: identity\r\nHost: {}\r\n\r\n{}", method, route, body.size(), host, body); - } + export inline HTTPRequest CreateRequestHTTP(std::string method, std::string path, std::string authority, + std::unordered_map headers, + std::string body) { + HTTPRequest r; + r.method = std::move(method); + r.path = std::move(path); + r.authority = std::move(authority); + r.headers = std::move(headers); + r.body = std::move(body); + return r; + } - export constexpr std::string CreateRequestHTTP(std::string method, std::string route, std::string host, std::unordered_map headers, std::string body) { - std::string headersString; - for (auto const& [key, val] : headers) { - headersString+=std::format("{}: {}\r\n", key, val); - } - return std::format("{} {} HTTP/1.1\r\nConnection: keep-alive\r\nContent-Length: {}\r\nHost: {}\r\nAccept-Encoding: identity\r\n{}\r\n{}", method, route, body.size(), host, headersString, body); - } -} \ No newline at end of file + export inline HTTPResponse CreateResponseHTTP(std::string status) { + HTTPResponse r; + r.status = std::move(status); + return r; + } + + export inline HTTPResponse CreateResponseHTTP(std::string status, + std::unordered_map headers) { + HTTPResponse r; + r.status = std::move(status); + r.headers = std::move(headers); + return r; + } + + export inline HTTPResponse CreateResponseHTTP(std::string status, std::string body) { + HTTPResponse r; + r.status = std::move(status); + r.body = std::move(body); + return r; + } + + export inline HTTPResponse CreateResponseHTTP(std::string status, + std::unordered_map headers, + std::string body) { + HTTPResponse r; + r.status = std::move(status); + r.headers = std::move(headers); + r.body = std::move(body); + return r; + } +} diff --git a/interfaces/Crafter.Network-HTTP3.cppm b/interfaces/Crafter.Network-HTTP3.cppm new file mode 100644 index 0000000..bc0615c --- /dev/null +++ b/interfaces/Crafter.Network-HTTP3.cppm @@ -0,0 +1,578 @@ +/* +Crafter®.Network +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// HTTP/3 wire-format helpers. This partition is intentionally NOT re-exported +// from Crafter.Network — it's an internal building block shared between the +// ClientHTTP and ListenerHTTP implementation files. +// +// Scope: +// - QUIC variable-length integers (RFC 9000 §16) +// - HTTP/3 frame type/length codec (RFC 9114 §7) +// - QPACK field-section encode/decode (RFC 9204) limited to the static +// table + literal representations. No dynamic table, no Huffman. This +// suffices because both peers in this library are this same library; +// interoperability with browsers/curl over h3 would additionally require +// a Huffman codec which is out of scope here. + +export module Crafter.Network:HTTP3; +import std; + +namespace Crafter::HTTP3 { + // ---------------- ALPN ---------------- + // RFC 9114 §3.1 — final h3 ALPN identifier. + export inline constexpr std::string_view kAlpn = "h3"; + + // ---------------- Frame types (RFC 9114 §7.2) ---------------- + export inline constexpr std::uint64_t kFrameData = 0x00; + export inline constexpr std::uint64_t kFrameHeaders = 0x01; + export inline constexpr std::uint64_t kFrameSettings = 0x04; + + // ---------------- Unidirectional stream types (RFC 9114 §6.2) ---------------- + export inline constexpr std::uint64_t kStreamControl = 0x00; + export inline constexpr std::uint64_t kStreamQpackEnc = 0x02; + export inline constexpr std::uint64_t kStreamQpackDec = 0x03; + + // ---------------- Errors ---------------- + export class HTTP3ProtocolError : public std::runtime_error { + public: + using std::runtime_error::runtime_error; + }; + + // ---------------- QUIC varint (RFC 9000 §16) ---------------- + // Encodes value into the smallest of {1, 2, 4, 8}-byte forms. + export inline void EncodeVarint(std::uint64_t value, std::vector& out) { + if (value < (1ULL << 6)) { + out.push_back(static_cast(value)); + } else if (value < (1ULL << 14)) { + out.push_back(static_cast(0x40 | (value >> 8))); + out.push_back(static_cast(value & 0xFF)); + } else if (value < (1ULL << 30)) { + out.push_back(static_cast(0x80 | ((value >> 24) & 0x3F))); + out.push_back(static_cast((value >> 16) & 0xFF)); + out.push_back(static_cast((value >> 8) & 0xFF)); + out.push_back(static_cast(value & 0xFF)); + } else if (value < (1ULL << 62)) { + out.push_back(static_cast(0xC0 | ((value >> 56) & 0x3F))); + out.push_back(static_cast((value >> 48) & 0xFF)); + out.push_back(static_cast((value >> 40) & 0xFF)); + out.push_back(static_cast((value >> 32) & 0xFF)); + out.push_back(static_cast((value >> 24) & 0xFF)); + out.push_back(static_cast((value >> 16) & 0xFF)); + out.push_back(static_cast((value >> 8) & 0xFF)); + out.push_back(static_cast(value & 0xFF)); + } else { + throw HTTP3ProtocolError("varint value exceeds 2^62-1"); + } + } + + // Returns true on success. On false, no consumed/value mutation observed. + export inline bool DecodeVarint(const std::uint8_t* data, std::size_t available, + std::uint64_t& value, std::size_t& consumed) { + if (available == 0) return false; + std::uint8_t first = data[0]; + std::size_t len = std::size_t{1} << (first >> 6); + if (available < len) return false; + std::uint64_t v = first & 0x3F; + for (std::size_t i = 1; i < len; ++i) { + v = (v << 8) | data[i]; + } + value = v; + consumed = len; + return true; + } + + // ---------------- QPACK / HPACK-style integer (RFC 7541 §5.1) ---------------- + // Different beast from QUIC varint. N-bit prefix integer used inside QPACK + // representations; the high (8-N) bits of the first byte carry pattern flags. + export inline void EncodeQpackInt(std::vector& out, std::uint8_t topBits, + int N, std::uint64_t value) { + std::uint8_t mask = static_cast((1U << N) - 1); + if (value < mask) { + out.push_back(static_cast(topBits | value)); + return; + } + out.push_back(static_cast(topBits | mask)); + value -= mask; + while (value >= 128) { + out.push_back(static_cast((value & 0x7F) | 0x80)); + value >>= 7; + } + out.push_back(static_cast(value)); + } + + // Decode N-bit-prefix integer. data[0] holds prefix flags; the low N bits + // contribute to the value (continuation bytes follow if low N bits == mask). + export inline bool DecodeQpackInt(const std::uint8_t* data, std::size_t available, + int N, std::uint64_t& value, std::size_t& consumed) { + if (available == 0) return false; + std::uint8_t mask = static_cast((1U << N) - 1); + std::uint8_t first = data[0] & mask; + if (first < mask) { + value = first; + consumed = 1; + return true; + } + std::uint64_t v = mask; + int shift = 0; + std::size_t i = 1; + while (true) { + if (i >= available) return false; + std::uint8_t b = data[i++]; + v += static_cast(b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 63) return false; + } + value = v; + consumed = i; + return true; + } + + // ---------------- Huffman codec (RFC 7541 Appendix B) ---------------- + // Decode-only. Real h3 peers (browsers, curl, cloudflare, etc.) Huffman- + // encode header values by default, so without this any external interop + // collapses on the first response. We don't emit Huffman ourselves — + // peers MUST accept H=0 literal per the spec, so encoding doesn't add + // interop value, only wire compactness. The 256-entry table plus a + // straightforward bit-walking decoder is the smallest viable form. + struct HuffmanCode { + std::uint32_t code; + std::uint8_t length; + }; + inline constexpr std::array kHuffmanTable = {{ + /* 0 */ {0x1ff8, 13}, /* 1 */ {0x7fffd8, 23}, /* 2 */ {0xfffffe2, 28}, + /* 3 */ {0xfffffe3, 28}, /* 4 */ {0xfffffe4, 28}, /* 5 */ {0xfffffe5, 28}, + /* 6 */ {0xfffffe6, 28}, /* 7 */ {0xfffffe7, 28}, /* 8 */ {0xfffffe8, 28}, + /* 9 */ {0xffffea, 24}, /* 10 */ {0x3ffffffc,30}, /* 11 */ {0xfffffe9, 28}, + /* 12 */ {0xfffffea, 28}, /* 13 */ {0x3ffffffd,30}, /* 14 */ {0xfffffeb, 28}, + /* 15 */ {0xfffffec, 28}, /* 16 */ {0xfffffed, 28}, /* 17 */ {0xfffffee, 28}, + /* 18 */ {0xfffffef, 28}, /* 19 */ {0xffffff0, 28}, /* 20 */ {0xffffff1, 28}, + /* 21 */ {0xffffff2, 28}, /* 22 */ {0x3ffffffe,30}, /* 23 */ {0xffffff3, 28}, + /* 24 */ {0xffffff4, 28}, /* 25 */ {0xffffff5, 28}, /* 26 */ {0xffffff6, 28}, + /* 27 */ {0xffffff7, 28}, /* 28 */ {0xffffff8, 28}, /* 29 */ {0xffffff9, 28}, + /* 30 */ {0xffffffa, 28}, /* 31 */ {0xffffffb, 28}, /* 32 */ {0x14, 6}, + /* 33 */ {0x3f8, 10}, /* 34 */ {0x3f9, 10}, /* 35 */ {0xffa, 12}, + /* 36 */ {0x1ff9, 13}, /* 37 */ {0x15, 6}, /* 38 */ {0xf8, 8}, + /* 39 */ {0x7fa, 11}, /* 40 */ {0x3fa, 10}, /* 41 */ {0x3fb, 10}, + /* 42 */ {0xf9, 8}, /* 43 */ {0x7fb, 11}, /* 44 */ {0xfa, 8}, + /* 45 */ {0x16, 6}, /* 46 */ {0x17, 6}, /* 47 */ {0x18, 6}, + /* 48 */ {0x0, 5}, /* 49 */ {0x1, 5}, /* 50 */ {0x2, 5}, + /* 51 */ {0x19, 6}, /* 52 */ {0x1a, 6}, /* 53 */ {0x1b, 6}, + /* 54 */ {0x1c, 6}, /* 55 */ {0x1d, 6}, /* 56 */ {0x1e, 6}, + /* 57 */ {0x1f, 6}, /* 58 */ {0x5c, 7}, /* 59 */ {0xfb, 8}, + /* 60 */ {0x7ffc, 15}, /* 61 */ {0x20, 6}, /* 62 */ {0xffb, 12}, + /* 63 */ {0x3fc, 10}, /* 64 */ {0x1ffa, 13}, /* 65 */ {0x21, 6}, + /* 66 */ {0x5d, 7}, /* 67 */ {0x5e, 7}, /* 68 */ {0x5f, 7}, + /* 69 */ {0x60, 7}, /* 70 */ {0x61, 7}, /* 71 */ {0x62, 7}, + /* 72 */ {0x63, 7}, /* 73 */ {0x64, 7}, /* 74 */ {0x65, 7}, + /* 75 */ {0x66, 7}, /* 76 */ {0x67, 7}, /* 77 */ {0x68, 7}, + /* 78 */ {0x69, 7}, /* 79 */ {0x6a, 7}, /* 80 */ {0x6b, 7}, + /* 81 */ {0x6c, 7}, /* 82 */ {0x6d, 7}, /* 83 */ {0x6e, 7}, + /* 84 */ {0x6f, 7}, /* 85 */ {0x70, 7}, /* 86 */ {0x71, 7}, + /* 87 */ {0x72, 7}, /* 88 */ {0xfc, 8}, /* 89 */ {0x73, 7}, + /* 90 */ {0xfd, 8}, /* 91 */ {0x1ffb, 13}, /* 92 */ {0x7fff0, 19}, + /* 93 */ {0x1ffc, 13}, /* 94 */ {0x3ffc, 14}, /* 95 */ {0x22, 6}, + /* 96 */ {0x7ffd, 15}, /* 97 */ {0x3, 5}, /* 98 */ {0x23, 6}, + /* 99 */ {0x4, 5}, /*100 */ {0x24, 6}, /*101 */ {0x5, 5}, + /*102 */ {0x25, 6}, /*103 */ {0x26, 6}, /*104 */ {0x27, 6}, + /*105 */ {0x6, 5}, /*106 */ {0x74, 7}, /*107 */ {0x75, 7}, + /*108 */ {0x28, 6}, /*109 */ {0x29, 6}, /*110 */ {0x2a, 6}, + /*111 */ {0x7, 5}, /*112 */ {0x2b, 6}, /*113 */ {0x76, 7}, + /*114 */ {0x2c, 6}, /*115 */ {0x8, 5}, /*116 */ {0x9, 5}, + /*117 */ {0x2d, 6}, /*118 */ {0x77, 7}, /*119 */ {0x78, 7}, + /*120 */ {0x79, 7}, /*121 */ {0x7a, 7}, /*122 */ {0x7b, 7}, + /*123 */ {0x7ffe, 15}, /*124 */ {0x7fc, 11}, /*125 */ {0x3ffd, 14}, + /*126 */ {0x1ffd, 13}, /*127 */ {0xffffffc, 28}, /*128 */ {0xfffe6, 20}, + /*129 */ {0x3fffd2, 22}, /*130 */ {0xfffe7, 20}, /*131 */ {0xfffe8, 20}, + /*132 */ {0x3fffd3, 22}, /*133 */ {0x3fffd4, 22}, /*134 */ {0x3fffd5, 22}, + /*135 */ {0x7fffd9, 23}, /*136 */ {0x3fffd6, 22}, /*137 */ {0x7fffda, 23}, + /*138 */ {0x7fffdb, 23}, /*139 */ {0x7fffdc, 23}, /*140 */ {0x7fffdd, 23}, + /*141 */ {0x7fffde, 23}, /*142 */ {0xffffeb, 24}, /*143 */ {0x7fffdf, 23}, + /*144 */ {0xffffec, 24}, /*145 */ {0xffffed, 24}, /*146 */ {0x3fffd7, 22}, + /*147 */ {0x7fffe0, 23}, /*148 */ {0xffffee, 24}, /*149 */ {0x7fffe1, 23}, + /*150 */ {0x7fffe2, 23}, /*151 */ {0x7fffe3, 23}, /*152 */ {0x7fffe4, 23}, + /*153 */ {0x1fffdc, 21}, /*154 */ {0x3fffd8, 22}, /*155 */ {0x7fffe5, 23}, + /*156 */ {0x3fffd9, 22}, /*157 */ {0x7fffe6, 23}, /*158 */ {0x7fffe7, 23}, + /*159 */ {0xffffef, 24}, /*160 */ {0x3fffda, 22}, /*161 */ {0x1fffdd, 21}, + /*162 */ {0xfffe9, 20}, /*163 */ {0x3fffdb, 22}, /*164 */ {0x3fffdc, 22}, + /*165 */ {0x7fffe8, 23}, /*166 */ {0x7fffe9, 23}, /*167 */ {0x1fffde, 21}, + /*168 */ {0x7fffea, 23}, /*169 */ {0x3fffdd, 22}, /*170 */ {0x3fffde, 22}, + /*171 */ {0xfffff0, 24}, /*172 */ {0x1fffdf, 21}, /*173 */ {0x3fffdf, 22}, + /*174 */ {0x7fffeb, 23}, /*175 */ {0x7fffec, 23}, /*176 */ {0x1fffe0, 21}, + /*177 */ {0x1fffe1, 21}, /*178 */ {0x3fffe0, 22}, /*179 */ {0x1fffe2, 21}, + /*180 */ {0x7fffed, 23}, /*181 */ {0x3fffe1, 22}, /*182 */ {0x7fffee, 23}, + /*183 */ {0x7fffef, 23}, /*184 */ {0xfffea, 20}, /*185 */ {0x3fffe2, 22}, + /*186 */ {0x3fffe3, 22}, /*187 */ {0x3fffe4, 22}, /*188 */ {0x7ffff0, 23}, + /*189 */ {0x3fffe5, 22}, /*190 */ {0x3fffe6, 22}, /*191 */ {0x7ffff1, 23}, + /*192 */ {0x3ffffe0,26}, /*193 */ {0x3ffffe1, 26}, /*194 */ {0xfffeb, 20}, + /*195 */ {0x7fff1, 19}, /*196 */ {0x3fffe7, 22}, /*197 */ {0x7ffff2, 23}, + /*198 */ {0x3fffe8, 22}, /*199 */ {0x1ffffec, 25}, /*200 */ {0x3ffffe2, 26}, + /*201 */ {0x3ffffe3,26}, /*202 */ {0x3ffffe4, 26}, /*203 */ {0x7ffffde, 27}, + /*204 */ {0x7ffffdf,27}, /*205 */ {0x3ffffe5, 26}, /*206 */ {0xfffff1, 24}, + /*207 */ {0x1ffffed,25}, /*208 */ {0x7fff2, 19}, /*209 */ {0x1fffe3, 21}, + /*210 */ {0x3ffffe6,26}, /*211 */ {0x7ffffe0, 27}, /*212 */ {0x7ffffe1, 27}, + /*213 */ {0x3ffffe7,26}, /*214 */ {0x7ffffe2, 27}, /*215 */ {0xfffff2, 24}, + /*216 */ {0x1fffe4, 21}, /*217 */ {0x1fffe5, 21}, /*218 */ {0x3ffffe8, 26}, + /*219 */ {0x3ffffe9,26}, /*220 */ {0xffffffd,28}, /*221 */ {0x7ffffe3, 27}, + /*222 */ {0x7ffffe4,27}, /*223 */ {0x7ffffe5, 27}, /*224 */ {0xfffec, 20}, + /*225 */ {0xfffff3, 24}, /*226 */ {0xfffed, 20}, /*227 */ {0x1fffe6, 21}, + /*228 */ {0x3fffe9, 22}, /*229 */ {0x1fffe7, 21}, /*230 */ {0x1fffe8, 21}, + /*231 */ {0x7ffff3, 23}, /*232 */ {0x3fffea, 22}, /*233 */ {0x3fffeb, 22}, + /*234 */ {0x1ffffee,25}, /*235 */ {0x1ffffef, 25}, /*236 */ {0xfffff4, 24}, + /*237 */ {0xfffff5, 24}, /*238 */ {0x3ffffea, 26}, /*239 */ {0x7ffff4, 23}, + /*240 */ {0x3ffffeb,26}, /*241 */ {0x7ffffe6, 27}, /*242 */ {0x3ffffec, 26}, + /*243 */ {0x3ffffed,26}, /*244 */ {0x7ffffe7, 27}, /*245 */ {0x7ffffe8, 27}, + /*246 */ {0x7ffffe9,27}, /*247 */ {0x7ffffea, 27}, /*248 */ {0x7ffffeb, 27}, + /*249 */ {0xffffffe,28}, /*250 */ {0x7ffffec, 27}, /*251 */ {0x7ffffed, 27}, + /*252 */ {0x7ffffee,27}, /*253 */ {0x7ffffef, 27}, /*254 */ {0x7fffff0, 27}, + /*255 */ {0x3ffffee,26}, + }}; + + export inline std::string DecodeHuffman(const std::uint8_t* in, std::size_t inLen) { + // Bit-walking decoder. Refill a 64-bit register from the input byte + // stream, then for each output symbol scan the 256-entry table for + // the unique code that matches the top `bits` of the register at + // some length L in [5, 30]. Linear scan is acceptable for the + // header-section sizes seen in HTTP/3 traffic; the inner loop is + // hot for ~100s of bytes per request. + std::string out; + std::uint64_t reg = 0; + int bits = 0; + std::size_t pos = 0; + while (true) { + while (bits <= 56 && pos < inLen) { + reg = (reg << 8) | in[pos++]; + bits += 8; + } + if (bits == 0) return out; + + bool matched = false; + const int maxL = bits < 30 ? bits : 30; + for (int L = 5; L <= maxL; ++L) { + std::uint32_t want = static_cast( + (reg >> (bits - L)) & ((std::uint64_t{1} << L) - 1)); + for (std::size_t s = 0; s < 256; ++s) { + if (kHuffmanTable[s].length == L && kHuffmanTable[s].code == want) { + out.push_back(static_cast(s)); + bits -= L; + matched = true; + break; + } + } + if (matched) break; + } + if (!matched) { + // Tail must be ≤ 7 bits and all 1s — that's the EOS-prefix + // padding RFC 7541 §5.2 mandates. Anything else is malformed. + if (bits <= 7) { + std::uint64_t mask = (std::uint64_t{1} << bits) - 1; + if ((reg & mask) == mask) return out; + } + throw HTTP3ProtocolError("Huffman: invalid encoding"); + } + } + } + + // ---------------- QPACK static table (RFC 9204 Appendix A, subset) ---------------- + // We embed only the entries we either emit (encode) or might need to look + // up by index (decode peers using indexed/literal-with-name-ref). The + // subset covers all pseudo-headers and a few common content-type/status + // values — enough for self-interop. If the peer references an index + // outside this table we throw HTTP3ProtocolError. + struct StaticEntry { + std::string_view name; + std::string_view value; // empty if no canonical value (name-only entry) + }; + + inline constexpr std::array kStaticTable = {{ + {":authority", ""}, // 0 + {":path", "/"}, // 1 + {"age", "0"}, // 2 + {"content-disposition", ""}, // 3 + {"content-length", "0"}, // 4 + {"cookie", ""}, // 5 + {"date", ""}, // 6 + {"etag", ""}, // 7 + {"if-modified-since", ""}, // 8 + {"if-none-match", ""}, // 9 + {"last-modified", ""}, // 10 + {"link", ""}, // 11 + {"location", ""}, // 12 + {"referer", ""}, // 13 + {"set-cookie", ""}, // 14 + {":method", "CONNECT"}, // 15 + {":method", "DELETE"}, // 16 + {":method", "GET"}, // 17 + {":method", "HEAD"}, // 18 + {":method", "OPTIONS"}, // 19 + {":method", "POST"}, // 20 + {":method", "PUT"}, // 21 + {":scheme", "http"}, // 22 + {":scheme", "https"}, // 23 + {":status", "103"}, // 24 + {":status", "200"}, // 25 + {":status", "304"}, // 26 + {":status", "404"}, // 27 + {":status", "503"}, // 28 + {"accept", "*/*"}, // 29 + {"accept", "application/dns-message"}, // 30 + {"accept-encoding", "gzip, deflate, br"}, // 31 + {"accept-ranges", "bytes"}, // 32 + {"access-control-allow-headers", "cache-control"}, // 33 + {"access-control-allow-headers", "content-type"}, // 34 + {"access-control-allow-origin", "*"}, // 35 + {"cache-control", "max-age=0"}, // 36 + {"cache-control", "max-age=2592000"}, // 37 + {"cache-control", "max-age=604800"}, // 38 + {"cache-control", "no-cache"}, // 39 + {"cache-control", "no-store"}, // 40 + {"cache-control", "public, max-age=31536000"}, // 41 + {"content-encoding", "br"}, // 42 + {"content-encoding", "gzip"}, // 43 + {"content-type", "application/dns-message"}, // 44 + {"content-type", "application/javascript"}, // 45 + {"content-type", "application/json"}, // 46 + {"content-type", "application/x-www-form-urlencoded"}, // 47 + {"content-type", "image/gif"}, // 48 + {"content-type", "image/jpeg"}, // 49 + {"content-type", "image/png"}, // 50 + {"content-type", "text/css"}, // 51 + {"content-type", "text/html; charset=utf-8"}, // 52 + {"content-type", "text/plain"}, // 53 + {"content-type", "text/plain;charset=utf-8"}, // 54 + {"range", "bytes=0-"}, // 55 + {"strict-transport-security", "max-age=31536000"}, // 56 + {"strict-transport-security", "max-age=31536000; includesubdomains"}, // 57 + {"strict-transport-security", "max-age=31536000; includesubdomains; preload"}, // 58 + {"vary", "accept-encoding"}, // 59 + {"vary", "origin"}, // 60 + {"x-content-type-options", "nosniff"}, // 61 + {"x-xss-protection", "1; mode=block"}, // 62 + {":status", "100"}, // 63 + {":status", "204"}, // 64 + {":status", "206"}, // 65 + {":status", "302"}, // 66 + {":status", "400"}, // 67 + {":status", "403"}, // 68 + {":status", "421"}, // 69 + {":status", "425"}, // 70 + {":status", "500"}, // 71 + {"accept-language", ""}, // 72 + {"access-control-allow-credentials", "FALSE"}, // 73 + {"access-control-allow-credentials", "TRUE"}, // 74 + {"access-control-allow-headers", "*"}, // 75 + {"access-control-allow-methods", "get"}, // 76 + {"access-control-allow-methods", "get, post, options"}, // 77 + {"access-control-allow-methods", "options"}, // 78 + {"access-control-expose-headers", "content-length"}, // 79 + {"access-control-request-headers", "content-type"}, // 80 + {"access-control-request-method", "get"}, // 81 + {"access-control-request-method", "post"}, // 82 + {"alt-svc", "clear"}, // 83 + {"authorization", ""}, // 84 + {"content-security-policy", "script-src 'none'; object-src 'none'; base-uri 'none'"}, // 85 + {"early-data", "1"}, // 86 + {"expect-ct", ""}, // 87 + {"forwarded", ""}, // 88 + {"if-range", ""}, // 89 + {"origin", ""}, // 90 + {"purpose", "prefetch"}, // 91 + {"server", ""}, // 92 + {"timing-allow-origin", "*"}, // 93 + {"upgrade-insecure-requests", "1"}, // 94 + {"user-agent", ""}, // 95 + {"x-forwarded-for", ""}, // 96 + {"x-frame-options", "deny"}, // 97 + {"x-frame-options", "sameorigin"}, // 98 + }}; + + // Lookup a (name, value) pair against the static table; returns -1 if not + // present. Linear scan is fine here: ~100 entries, called per header. + inline int StaticTableExactLookup(std::string_view name, std::string_view value) { + for (std::size_t i = 0; i < kStaticTable.size(); ++i) { + if (kStaticTable[i].name == name && kStaticTable[i].value == value) { + return static_cast(i); + } + } + return -1; + } + // Lookup name-only; returns the lowest matching index or -1. + inline int StaticTableNameLookup(std::string_view name) { + for (std::size_t i = 0; i < kStaticTable.size(); ++i) { + if (kStaticTable[i].name == name) return static_cast(i); + } + return -1; + } + + // ---------------- Field section codec ---------------- + // Encodes the QPACK-on-the-wire field section: a 2-byte prefix (Required + // Insert Count = 0, Sign+DeltaBase = 0 — i.e. no dynamic table reliance) + // followed by per-field representations. We pick the most compact static + // representation each header allows; never emit Huffman; never use the + // dynamic table. + export inline std::vector EncodeFieldSection( + const std::vector>& fields) { + std::vector out; + // Prefix: Required Insert Count (8-bit prefix int = 0) + EncodeQpackInt(out, 0x00, 8, 0); + // Sign + Delta Base (sign in high bit of 7-bit-prefix int = 0) + EncodeQpackInt(out, 0x00, 7, 0); + + for (const auto& [name, value] : fields) { + int exact = StaticTableExactLookup(name, value); + if (exact >= 0) { + // Indexed Field Line, T=1 (static): pattern 1Tixxxxx, 6-bit prefix. + EncodeQpackInt(out, 0xC0, 6, static_cast(exact)); + continue; + } + int nameIdx = StaticTableNameLookup(name); + if (nameIdx >= 0) { + // Literal Field Line With Name Reference, T=1 (static), + // N=0 (allow indexing on intermediaries — moot since no DT): + // pattern 01NTxxxx, 4-bit name-index prefix. + EncodeQpackInt(out, 0x50, 4, static_cast(nameIdx)); + } else { + // Literal Field Line With Literal Name, N=0, H=0: pattern + // 001NHxxx with a 3-bit name-length prefix. + EncodeQpackInt(out, 0x20, 3, name.size()); + out.insert(out.end(), name.begin(), name.end()); + } + // Value: 7-bit length prefix, H=0 (no Huffman). + EncodeQpackInt(out, 0x00, 7, value.size()); + out.insert(out.end(), value.begin(), value.end()); + } + return out; + } + + export inline std::vector> DecodeFieldSection( + const std::uint8_t* data, std::size_t available) { + std::size_t pos = 0; + std::uint64_t reqIc = 0, deltaBase = 0; + std::size_t cn = 0; + if (!DecodeQpackInt(data + pos, available - pos, 8, reqIc, cn)) { + throw HTTP3ProtocolError("QPACK: truncated Required Insert Count"); + } + pos += cn; + if (pos >= available) throw HTTP3ProtocolError("QPACK: missing Base"); + if (!DecodeQpackInt(data + pos, available - pos, 7, deltaBase, cn)) { + throw HTTP3ProtocolError("QPACK: truncated Base"); + } + pos += cn; + if (reqIc != 0) { + // Encoder used the dynamic table, which we don't track. Required + // Insert Count != 0 means we cannot decode without dynamic-table + // state; surface a clean protocol error rather than mis-decoding. + throw HTTP3ProtocolError("QPACK: dynamic table reference (Required Insert Count != 0)"); + } + + std::vector> fields; + while (pos < available) { + std::uint8_t b = data[pos]; + if ((b & 0x80) != 0) { + // 1Txxxxxx — Indexed Field Line. + bool isStatic = (b & 0x40) != 0; + std::uint64_t idx = 0; + if (!DecodeQpackInt(data + pos, available - pos, 6, idx, cn)) { + throw HTTP3ProtocolError("QPACK: truncated indexed field line"); + } + pos += cn; + if (!isStatic) throw HTTP3ProtocolError("QPACK: dynamic-table indexed line unsupported"); + if (idx >= kStaticTable.size()) throw HTTP3ProtocolError("QPACK: static index out of range"); + const auto& e = kStaticTable[static_cast(idx)]; + fields.emplace_back(std::string(e.name), std::string(e.value)); + } else if ((b & 0xC0) == 0x40) { + // 01NTxxxx — Literal Field Line With Name Reference. + bool isStatic = (b & 0x10) != 0; + std::uint64_t idx = 0; + if (!DecodeQpackInt(data + pos, available - pos, 4, idx, cn)) { + throw HTTP3ProtocolError("QPACK: truncated literal-with-nameref index"); + } + pos += cn; + if (!isStatic) throw HTTP3ProtocolError("QPACK: dynamic-table name reference unsupported"); + if (idx >= kStaticTable.size()) throw HTTP3ProtocolError("QPACK: static name index out of range"); + if (pos >= available) throw HTTP3ProtocolError("QPACK: missing value byte"); + bool huffman = (data[pos] & 0x80) != 0; + std::uint64_t vlen = 0; + if (!DecodeQpackInt(data + pos, available - pos, 7, vlen, cn)) { + throw HTTP3ProtocolError("QPACK: truncated value length"); + } + pos += cn; + if (pos + vlen > available) throw HTTP3ProtocolError("QPACK: value runs past buffer"); + std::string value = huffman + ? DecodeHuffman(data + pos, static_cast(vlen)) + : std::string(reinterpret_cast(data + pos), + static_cast(vlen)); + pos += static_cast(vlen); + fields.emplace_back(std::string(kStaticTable[static_cast(idx)].name), std::move(value)); + } else if ((b & 0xE0) == 0x20) { + // 001NHxxx — Literal Field Line With Literal Name. + bool huffmanName = (b & 0x08) != 0; + std::uint64_t nlen = 0; + if (!DecodeQpackInt(data + pos, available - pos, 3, nlen, cn)) { + throw HTTP3ProtocolError("QPACK: truncated literal-name length"); + } + pos += cn; + if (pos + nlen > available) throw HTTP3ProtocolError("QPACK: literal name runs past buffer"); + std::string name = huffmanName + ? DecodeHuffman(data + pos, static_cast(nlen)) + : std::string(reinterpret_cast(data + pos), + static_cast(nlen)); + pos += static_cast(nlen); + if (pos >= available) throw HTTP3ProtocolError("QPACK: missing value byte"); + bool huffmanValue = (data[pos] & 0x80) != 0; + std::uint64_t vlen = 0; + if (!DecodeQpackInt(data + pos, available - pos, 7, vlen, cn)) { + throw HTTP3ProtocolError("QPACK: truncated literal-value length"); + } + pos += cn; + if (pos + vlen > available) throw HTTP3ProtocolError("QPACK: literal value runs past buffer"); + std::string value = huffmanValue + ? DecodeHuffman(data + pos, static_cast(vlen)) + : std::string(reinterpret_cast(data + pos), + static_cast(vlen)); + pos += static_cast(vlen); + fields.emplace_back(std::move(name), std::move(value)); + } else { + // Indexed-with-Post-Base / Literal-with-Post-Base-Name-Reference + // both rely on a dynamic-table base offset we don't maintain. + throw HTTP3ProtocolError("QPACK: post-base reference unsupported"); + } + } + return fields; + } + + // ---------------- Frame helpers ---------------- + // A frame is: type (varint) | length (varint) | payload (length bytes). + export inline void WriteFrame(std::vector& out, std::uint64_t type, + const std::uint8_t* payload, std::size_t length) { + EncodeVarint(type, out); + EncodeVarint(static_cast(length), out); + out.insert(out.end(), payload, payload + length); + } + + // Empty SETTINGS frame body — we send no settings, accepting all defaults. + export inline std::vector BuildControlStreamPrelude() { + std::vector out; + EncodeVarint(kStreamControl, out); // unidi stream type + EncodeVarint(kFrameSettings, out); // SETTINGS frame type + EncodeVarint(0, out); // frame length 0 + return out; + } +} diff --git a/interfaces/Crafter.Network-ListenerHTTP.cppm b/interfaces/Crafter.Network-ListenerHTTP.cppm index 728b7e9..b86358b 100644 --- a/interfaces/Crafter.Network-ListenerHTTP.cppm +++ b/interfaces/Crafter.Network-ListenerHTTP.cppm @@ -21,38 +21,57 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA export module Crafter.Network:ListenerHTTP; import std; import :HTTP; -import :ClientTCP; +import :ListenerQUIC; +import :ClientQUIC; namespace Crafter { - export class ListenerHTTP; - class ListenerHTTPClient { - public: - std::atomic disconnected; - ClientTCP client; - std::thread thread; - ListenerHTTP* server; - ListenerHTTPClient(ListenerHTTP* server, int s); - void ListenRoutes(); - }; + // HTTP/3 server. Wraps a ListenerQUIC: each accepted QUIC connection + // registers a per-stream handler that parses one request, dispatches it + // through the route map, and writes a response back on the same bidi + // stream. ALPN is fixed to "h3". + // + // Routes are keyed by `:path` (exact match). Unknown paths return a + // synthetic 404. Route handlers run on the ThreadPool — multiple requests + // on the same connection can therefore execute concurrently. + export class ListenerHTTP { + public: + // The underlying QUIC listener owns the accept loop, certificates, + // and the per-connection ClientQUIC instances. It is heap-allocated + // and owned by this Impl so that move construction/destruction is + // straightforward. + std::unordered_map> routes; + std::string alpn; - export class ListenerHTTP { - public: - int s; - std::vector clients; - bool running = true; - const std::unordered_map> routes; - ListenerHTTP(std::uint16_t port, std::unordered_map> routes); - ~ListenerHTTP(); - void Listen(); - void Stop(); - }; + ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes); - export class ListenerAsyncHTTP { - public: - ListenerHTTP listener; - std::thread thread; - ListenerAsyncHTTP(std::uint16_t port, std::unordered_map> routes); - ~ListenerAsyncHTTP(); - void Stop(); - }; + ~ListenerHTTP(); + ListenerHTTP(const ListenerHTTP&) = delete; + ListenerHTTP(ListenerHTTP&&) noexcept; + + // Block on this thread, dispatch each accepted connection on this + // thread (matches ListenerQUIC::ListenSyncSync semantics). + void Listen(); + void Stop(); + + private: + struct Impl; + std::unique_ptr impl; + }; + + // Async wrapper: runs the listener's accept loop on a background thread + // so the caller can construct it and continue. Mirrors the old + // ListenerAsyncHTTP so existing call sites keep working. + export class ListenerAsyncHTTP { + public: + ListenerHTTP listener; + std::thread thread; + + ListenerAsyncHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes); + ~ListenerAsyncHTTP(); + void Stop(); + }; } diff --git a/project.cpp b/project.cpp index fd17dc6..4a67874 100644 --- a/project.cpp +++ b/project.cpp @@ -4,13 +4,14 @@ namespace fs = std::filesystem; using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { - constexpr std::array networkInterfaces = { + constexpr std::array networkInterfaces = { "interfaces/Crafter.Network", "interfaces/Crafter.Network-ClientTCP", "interfaces/Crafter.Network-ListenerTCP", "interfaces/Crafter.Network-ClientHTTP", "interfaces/Crafter.Network-ListenerHTTP", "interfaces/Crafter.Network-HTTP", + "interfaces/Crafter.Network-HTTP3", "interfaces/Crafter.Network-ClientQUIC", "interfaces/Crafter.Network-ListenerQUIC", }; @@ -61,7 +62,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // linker at the actual output location. msquic.libDirs = { "bin/Release" }; msquic.libs = { "msquic" }; - std::array ifaces; + std::array ifaces; std::ranges::copy(networkInterfaces, ifaces.begin()); std::array impls; std::ranges::copy(networkImplementations, impls.begin()); diff --git a/tests/ShouldRecieveHTTP/ShouldRecieveHTTP.cpp b/tests/ShouldRecieveHTTP/ShouldRecieveHTTP.cpp deleted file mode 100644 index d405537..0000000 --- a/tests/ShouldRecieveHTTP/ShouldRecieveHTTP.cpp +++ /dev/null @@ -1,43 +0,0 @@ -/* -Crafter® Build -Copyright (C) 2026 Catcrafts® -Catcrafts.net - -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License version 3.0 as published by the Free Software Foundation; - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -*/ -#include -#include -import Crafter.Network; -import std; -using namespace Crafter; - -int main() { - bool success = false; - ListenerAsyncHTTP listener(8081, {{"/", [&](const HTTPRequest& request) { - success = true; - return CreateResponseHTTP("200 OK", "Hello World!"); - }}}); - try { - system("curl http://localhost:8081 > /dev/null 2>&1"); - std::this_thread::sleep_for(std::chrono::seconds(1)); - if (success) { - return 0; - } - std::println("Did not receive"); - return 1; - } catch (std::exception& e) { - std::println("{}", e.what()); - return 1; - } -} \ No newline at end of file diff --git a/tests/ShouldRecieveHTTP/project.cpp b/tests/ShouldRecieveHTTP/project.cpp deleted file mode 100644 index c083256..0000000 --- a/tests/ShouldRecieveHTTP/project.cpp +++ /dev/null @@ -1,20 +0,0 @@ -import std; -import Crafter.Build; -namespace fs = std::filesystem; -using namespace Crafter; - -extern "C" Configuration CrafterBuildProject(std::span) { - Configuration cfg; - cfg.path = "tests/ShouldRecieveHTTP/"; - cfg.name = "ShouldRecieveHTTP"; - cfg.outputName = "ShouldRecieveHTTP"; - cfg.target = "x86_64-pc-linux-gnu"; - cfg.type = ConfigurationType::Executable; - cfg.dependencies = { ParentLib("crafter-network") }; - cfg.linkFlags.push_back("-Wl,--export-dynamic"); - cfg.linkFlags.push_back("-ldl"); - std::array ifaces = {}; - std::array impls = { "ShouldRecieveHTTP" }; - cfg.GetInterfacesAndImplementations(ifaces, impls); - return cfg; -} diff --git a/tests/ShouldSend/ShouldSend.cpp b/tests/ShouldSend/ShouldSend.cpp new file mode 100644 index 0000000..c4f6799 --- /dev/null +++ b/tests/ShouldSend/ShouldSend.cpp @@ -0,0 +1,60 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License version 3.0 as published by the Free Software Foundation; + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ +import Crafter.Network; +import Crafter.Thread; +import std; +using namespace Crafter; + +// External-interop smoke test: connect to a public h3 endpoint, fetch /, and +// verify a 200 response with a non-empty body. Exercises: +// - real TLS chain validation against the system trust store +// - mandatory client control stream + SETTINGS prelude +// - peer's control + QPACK encoder/decoder unidi streams (drained) +// - QPACK Huffman decode on the response headers +// +// Targets cloudflare-quic.com (Cloudflare's public h3 demo). Network- +// dependent — if outbound UDP/443 is firewalled or the endpoint goes away, +// this will fail. +int main() { + ThreadPool::Start(); + try { + QUICClientCredentials creds; // default: validate against system trust + ClientHTTP client("cloudflare-quic.com", 443, creds); + HTTPResponse r = client.Send( + CreateRequestHTTP("GET", "/", "cloudflare-quic.com") + ); + std::cout << "status=" << r.status << " bodyBytes=" << r.body.size() << std::endl; + if (r.headers.count("server")) { + std::cout << "server=" << r.headers["server"] << std::endl; + } + if (r.body.size() > 0) { + auto preview = r.body.substr(0, std::min(80, r.body.size())); + std::cout << "preview: " << preview << std::endl; + } + if (r.status != "200" || r.body.empty()) { + std::cout << "unexpected response" << std::endl; + return 1; + } + std::cout.flush(); + std::_Exit(0); + } catch (std::exception& e) { + std::println("error: {}", e.what()); + return 1; + } +} diff --git a/tests/ShouldSendHTTP/project.cpp b/tests/ShouldSend/project.cpp similarity index 76% rename from tests/ShouldSendHTTP/project.cpp rename to tests/ShouldSend/project.cpp index aa4b825..1dffb04 100644 --- a/tests/ShouldSendHTTP/project.cpp +++ b/tests/ShouldSend/project.cpp @@ -5,16 +5,16 @@ using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span) { Configuration cfg; - cfg.path = "tests/ShouldSendHTTP/"; - cfg.name = "ShouldSendHTTP"; - cfg.outputName = "ShouldSendHTTP"; + cfg.path = "tests/ShouldSend/"; + cfg.name = "ShouldSend"; + cfg.outputName = "ShouldSend"; cfg.target = "x86_64-pc-linux-gnu"; cfg.type = ConfigurationType::Executable; cfg.dependencies = { ParentLib("crafter-network") }; cfg.linkFlags.push_back("-Wl,--export-dynamic"); cfg.linkFlags.push_back("-ldl"); std::array ifaces = {}; - std::array impls = { "ShouldSendHTTP" }; + std::array impls = { "ShouldSend" }; cfg.GetInterfacesAndImplementations(ifaces, impls); return cfg; } diff --git a/tests/ShouldSendHTTP/ShouldSendHTTP.cpp b/tests/ShouldSendHTTP/ShouldSendHTTP.cpp deleted file mode 100644 index 51e32b2..0000000 --- a/tests/ShouldSendHTTP/ShouldSendHTTP.cpp +++ /dev/null @@ -1,31 +0,0 @@ -/* -Crafter® Build -Copyright (C) 2026 Catcrafts® -Catcrafts.net - -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License version 3.0 as published by the Free Software Foundation; - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -*/ -import Crafter.Network; -import std; -using namespace Crafter; - -int main() { - ClientHTTP client("httpbin.org", 80); - HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/get", "httpbin.org")); - if (response.status == "200 OK") { - return 0; - } - std::println("{}", response.body); - return 1; -} \ No newline at end of file diff --git a/tests/ShouldSendRecieveHTTP/ShouldSendRecieveHTTP.cpp b/tests/ShouldSendRecieveHTTP/ShouldSendRecieveHTTP.cpp index e50ebef..a626f67 100644 --- a/tests/ShouldSendRecieveHTTP/ShouldSendRecieveHTTP.cpp +++ b/tests/ShouldSendRecieveHTTP/ShouldSendRecieveHTTP.cpp @@ -16,26 +16,34 @@ You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ -#include -#include import Crafter.Network; +import Crafter.Thread; import std; using namespace Crafter; int main() { - ListenerAsyncHTTP listener(8082, {{"/", [&](const HTTPRequest& request) { - return CreateResponseHTTP("200 OK", "Hello World!"); + ThreadPool::Start(); + + QUICServerCredentials serverCreds; + serverCreds.selfSigned = true; + ListenerAsyncHTTP listener(8082, serverCreds, {{"/", [&](const HTTPRequest& request) { + return CreateResponseHTTP("200", "Hello World!"); }}}); try { - ClientHTTP client("localhost", 8082); + QUICClientCredentials clientCreds; + clientCreds.insecureNoServerValidation = true; + ClientHTTP client("localhost", 8082, clientCreds); HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost")); - if (response.status == "200 OK" && response.body == "Hello World!") { - return 0; + if (response.status == "200" && response.body == "Hello World!") { + // See ShouldSendRecieveQUICStream for rationale: msquic's + // RegistrationClose blocks on outstanding connections, so skip + // graceful teardown after the test logic succeeds. + std::_Exit(0); } - std::println("{}{}", response.status, response.body); + std::println("{} {}", response.status, response.body); return 1; } catch (std::exception& e) { std::println("{}", e.what()); return 1; } -} \ No newline at end of file +} diff --git a/tests/ShouldSendRecieveKeepaliveHTTP/ShouldSendRecieveKeepaliveHTTP.cpp b/tests/ShouldSendRecieveKeepaliveHTTP/ShouldSendRecieveKeepaliveHTTP.cpp index 742ef55..6a23889 100644 --- a/tests/ShouldSendRecieveKeepaliveHTTP/ShouldSendRecieveKeepaliveHTTP.cpp +++ b/tests/ShouldSendRecieveKeepaliveHTTP/ShouldSendRecieveKeepaliveHTTP.cpp @@ -16,33 +16,41 @@ You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ -#include -#include import Crafter.Network; +import Crafter.Thread; import std; using namespace Crafter; +// "Keep-alive" in HTTP/3 corresponds to the QUIC connection being multiplexed: +// successive client.Send() calls reuse the same connection and open new +// request streams within it. This test exercises that — two requests on one +// ClientHTTP must both succeed. int main() { - ListenerAsyncHTTP listener(8083, {{"/", [&](const HTTPRequest& request) { - return CreateResponseHTTP("200 OK", "Hello World!"); + ThreadPool::Start(); + + QUICServerCredentials serverCreds; + serverCreds.selfSigned = true; + ListenerAsyncHTTP listener(8083, serverCreds, {{"/", [&](const HTTPRequest& request) { + return CreateResponseHTTP("200", "Hello World!"); }}}); try { - ClientHTTP client("localhost", 8083); + QUICClientCredentials clientCreds; + clientCreds.insecureNoServerValidation = true; + ClientHTTP client("localhost", 8083, clientCreds); + HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost")); - std::this_thread::sleep_for(std::chrono::seconds(1)); - if (response.status != "200 OK" || response.body != "Hello World!") { - std::println("{}{}", response.status, response.body); + if (response.status != "200" || response.body != "Hello World!") { + std::println("{} {}", response.status, response.body); return 1; } response = client.Send(CreateRequestHTTP("GET", "/", "localhost")); - std::this_thread::sleep_for(std::chrono::seconds(1)); - if (response.status != "200 OK" || response.body != "Hello World!") { - std::println("{}{}", response.status, response.body); + if (response.status != "200" || response.body != "Hello World!") { + std::println("{} {}", response.status, response.body); return 1; } - return 0; + std::_Exit(0); } catch (std::exception& e) { std::println("{}", e.what()); return 1; } -} \ No newline at end of file +} diff --git a/tests/ShouldSendRecieveLargeHTTP/ShouldSendRecieveLargeHTTP.cpp b/tests/ShouldSendRecieveLargeHTTP/ShouldSendRecieveLargeHTTP.cpp index 7b6f003..5d0c265 100644 --- a/tests/ShouldSendRecieveLargeHTTP/ShouldSendRecieveLargeHTTP.cpp +++ b/tests/ShouldSendRecieveLargeHTTP/ShouldSendRecieveLargeHTTP.cpp @@ -16,28 +16,31 @@ You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ -#include -#include import Crafter.Network; +import Crafter.Thread; import std; using namespace Crafter; int main() { - ListenerAsyncHTTP listener(8084, {{ "/", [&](const HTTPRequest& request) { - if (request.body.size() > 1'000'000) { - return CreateResponseHTTP("200 OK", "Large request received: " + std::to_string(request.body.size()) + " bytes"); - } - return CreateResponseHTTP("200 OK", "Small request received"); + ThreadPool::Start(); + + QUICServerCredentials serverCreds; + serverCreds.selfSigned = true; + ListenerAsyncHTTP listener(8084, serverCreds, {{ "/", [&](const HTTPRequest& request) { + if (request.body.size() > 1'000'000) { + return CreateResponseHTTP("200", "Large request received: " + std::to_string(request.body.size()) + " bytes"); } - }}); + return CreateResponseHTTP("200", "Small request received"); + }}}); try { - ClientHTTP client("localhost", 8084); + QUICClientCredentials clientCreds; + clientCreds.insecureNoServerValidation = true; + ClientHTTP client("localhost", 8084, clientCreds); std::string large_body(10 * 1024 * 1024, 'A'); HTTPResponse response = client.Send(CreateRequestHTTP("POST", "/", "localhost", large_body)); - std::this_thread::sleep_for(std::chrono::seconds(1)); - if (response.status == "200 OK" && response.body.find("Large request received") != std::string::npos) { - return 0; + if (response.status == "200" && response.body.find("Large request received") != std::string::npos) { + std::_Exit(0); } std::println("Unexpected response: {} {}", response.status, response.body); return 1; @@ -45,4 +48,4 @@ int main() { std::println("{}", e.what()); return 1; } -} \ No newline at end of file +} From e8630528af78bfd8568febd0603f3a97a331b5ce Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 19 May 2026 02:53:50 +0200 Subject: [PATCH 2/2] browser wasm --- README.md | 100 +++- additional/network-env.js | 329 +++++++++++++ examples/SimpleClient/cert-hash.txt | 1 + examples/SimpleClient/main.cpp | 227 +++++++++ examples/SimpleClient/project.cpp | 79 ++++ .../Crafter.Network-ClientHTTP-Browser.cpp | 199 ++++++++ .../Crafter.Network-ClientHTTP.cpp | 18 + .../Crafter.Network-ClientQUIC-Browser.cpp | 443 ++++++++++++++++++ .../Crafter.Network-ClientQUIC.cpp | 33 ++ .../Crafter.Network-ListenerHTTP.cpp | 291 ++++++++++-- .../Crafter.Network-ListenerQUIC.cpp | 129 ++++- .../Crafter.Network-WebTransport.cpp | 152 ++++++ interfaces/Crafter.Network-ClientHTTP.cppm | 16 + interfaces/Crafter.Network-ClientQUIC.cppm | 78 ++- interfaces/Crafter.Network-ClientTCP.cppm | 6 +- interfaces/Crafter.Network-HTTP3.cppm | 73 +++ interfaces/Crafter.Network-ListenerHTTP.cppm | 25 + interfaces/Crafter.Network-ListenerQUIC.cppm | 16 + interfaces/Crafter.Network-ListenerTCP.cppm | 4 +- interfaces/Crafter.Network-WebTransport.cppm | 105 +++++ interfaces/Crafter.Network.cppm | 10 +- project.cpp | 60 ++- .../ShouldEchoWebTransport.cpp | 182 +++++++ tests/ShouldEchoWebTransport/project.cpp | 20 + 24 files changed, 2493 insertions(+), 103 deletions(-) create mode 100644 additional/network-env.js create mode 100644 examples/SimpleClient/cert-hash.txt create mode 100644 examples/SimpleClient/main.cpp create mode 100644 examples/SimpleClient/project.cpp create mode 100644 implementations/Crafter.Network-ClientHTTP-Browser.cpp create mode 100644 implementations/Crafter.Network-ClientQUIC-Browser.cpp create mode 100644 implementations/Crafter.Network-WebTransport.cpp create mode 100644 interfaces/Crafter.Network-WebTransport.cppm create mode 100644 tests/ShouldEchoWebTransport/ShouldEchoWebTransport.cpp create mode 100644 tests/ShouldEchoWebTransport/project.cpp diff --git a/README.md b/README.md index ed6a5aa..bd9fcdd 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ # Crafter.Network -A cross-platform C++ networking library providing TCP, QUIC, and HTTP/3 client/server functionality with modern C++ features. +A cross-platform C++ networking library providing TCP, QUIC, HTTP/3, and WebTransport client/server functionality with modern C++ features. Builds for native Linux and for the browser (wasm32-wasip1). ## Overview -Crafter.Network is a C++ networking library designed for modern C++ applications. It provides TCP, QUIC, and HTTP/3 networking capabilities with support for synchronous and asynchronous operations, making it suitable for a wide range of networking tasks including real-time multiplayer games. +Crafter.Network is a C++ networking library designed for modern C++ applications. It provides TCP, QUIC, HTTP/3, and WebTransport-over-HTTP/3 capabilities with support for synchronous and asynchronous operations, making it suitable for a wide range of networking tasks including real-time multiplayer games. The same source compiles for native Linux (via msquic + POSIX sockets) and for the browser (via `fetch()` + `WebTransport` JS APIs); see [Browser build](#browser-build). ## Features -- **TCP Networking**: Client and server implementations for raw TCP connections. +- **TCP Networking**: Client and server implementations for raw TCP connections (native only). - **QUIC Networking**: Encrypted, multi-stream transport via msquic — reliable streams for control plane, unreliable datagrams for low-latency state sync. -- **HTTP/3**: Client and server implementations on top of QUIC. Uses ALPN `h3`, QUIC bidi streams for requests/responses, the mandatory unidirectional control stream + SETTINGS frame (RFC 9114 §6.2.1), and a built-in QPACK codec (RFC 9204) with the full static table, Huffman *decoding* (RFC 7541), and literal-only emission. The QPACK dynamic table is unused; the optional QPACK encoder/decoder unidi streams are deliberately not opened (RFC 9204 §4.2 permits this when no dynamic-table operations are issued). The client is interoperable with mainstream public h3 endpoints (cloudflare, nghttp3-based servers, etc.). -- **Asynchronous Operations**: Thread pool–based async operations for improved performance. -- **Cross-Platform**: Built for Unix-like systems with socket-based networking. +- **HTTP/3**: Client and server implementations on top of QUIC. Uses ALPN `h3`, QUIC bidi streams for requests/responses, the mandatory unidirectional control stream + SETTINGS frame (RFC 9114 §6.2.1), the (empty) QPACK encoder + decoder unidi streams required by stricter peers like Chromium, and a built-in QPACK codec (RFC 9204) with the full static table, Huffman *decoding* (RFC 7541), and literal-only emission. The QPACK dynamic table is unused. The client is interoperable with mainstream public h3 endpoints (cloudflare, nghttp3-based servers, etc.). +- **WebTransport (server)**: `ListenerHTTP` accepts extended-CONNECT sessions (`:method=CONNECT, :protocol=webtransport`) negotiated on the existing h3 listener — no separate port or alternate stack. Both draft-02 and draft-07+ identifier sets are advertised in SETTINGS so current Chrome/Edge browsers connect out of the box. Per-route handlers receive a `WebTransportSession&` and can multiplex bidirectional streams over the session. +- **Browser client**: Same C++ API compiled to wasm32-wasip1 and routed through `fetch()` (for `ClientHTTP`) and `WebTransport` (for `ClientQUIC`). Listeners and raw TCP are not compiled in the browser build — the browser is client-only. +- **Asynchronous Operations**: Thread pool–based async operations on native; the same `*Async` API on the browser side, where it's required (no synchronous I/O in the browser event loop). +- **Cross-Platform**: Native Linux (POSIX sockets + msquic) and browser (wasm32-wasip1). - **Modern C++**: Uses C++20 modules, STL containers, and modern C++ features. ## Architecture @@ -21,15 +23,15 @@ The library follows a modular design using C++20 modules: ### Core Modules - `Crafter.Network`: Main module that exports all components -- `Crafter.Network:ClientTCP`: TCP client implementation -- `Crafter.Network:ListenerTCP`: TCP server implementation -- `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`) -- `Crafter.Network:ListenerHTTP`: HTTP/3 server (ALPN `h3`) +- `Crafter.Network:ClientTCP`: TCP client implementation (native only) +- `Crafter.Network:ListenerTCP`: TCP server implementation (native only) +- `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`). On browser builds this maps to `fetch()`. +- `Crafter.Network:ListenerHTTP`: HTTP/3 + WebTransport server (ALPN `h3`, native only) - `Crafter.Network:HTTP`: HTTP request/response types and constructors -- `Crafter.Network:ClientQUIC`: QUIC connection (client + accepted-server side) with reliable streams and unreliable datagrams -- `Crafter.Network:ListenerQUIC`: QUIC listener accepting incoming connections - -The `Crafter.Network:HTTP3` partition contains internal HTTP/3 wire-format helpers (QUIC varint, frame layer, QPACK static-table codec) and is intentionally not re-exported from the main module — it is shared between the `ClientHTTP` and `ListenerHTTP` implementations. +- `Crafter.Network:ClientQUIC`: QUIC connection (client + accepted-server side) with reliable streams and unreliable datagrams. On browser builds this maps to the `WebTransport` JS API. +- `Crafter.Network:ListenerQUIC`: QUIC listener accepting incoming connections (native only). Also exports `ComputeCertificateHashSHA256()` and `GetSelfSignedCertificatePath()` for browser-peer cert pinning. +- `Crafter.Network:WebTransport`: `WebTransportSession` type — the per-session handle handed to `ListenerHTTP` WT route handlers. +- `Crafter.Network:HTTP3`: HTTP/3 wire-format helpers (QUIC varint, frame layer, QPACK static-table codec, WT frame/setting constants). Re-exported on native, excluded on browser (it uses `throw` and the wasm build is `-fno-exceptions`). ## Components @@ -87,14 +89,64 @@ listener.Listen(); The `HTTPRequest` exposes the four HTTP/3 pseudo-headers (`method`, `scheme`, `authority`, `path`) as named struct fields rather than mixing them into the regular `headers` map. Routes are dispatched by exact match on `path`; unmatched paths return a synthetic 404. +### WebTransport Components + +`ListenerHTTP` has a WT-aware constructor overload that takes a second route map keyed by `:path`. When the map is non-empty the listener advertises both draft-02 (`SETTINGS_ENABLE_WEBTRANSPORT = 0x2b603742`) and draft-07+ (`SETTINGS_WT_MAX_SESSIONS = 0xc671706a`) identifiers in its SETTINGS frame so current browsers connect. An extended-CONNECT request (`:method=CONNECT, :protocol=webtransport`) whose `:path` matches a registered route is accepted with a `200` (no FIN), upgraded into a `WebTransportSession`, and dispatched on the ThreadPool. Plain HTTP/3 routes and WT routes coexist on the same listener and port. + +```cpp +std::unordered_map> httpRoutes; +std::unordered_map> wtRoutes; + +wtRoutes["/echo"] = [](Crafter::WebTransportSession& session) { + session.OnStream([](Crafter::QUICStream peerStream) { + auto bytes = peerStream.RecieveUntilCloseSync(); + peerStream.SendSync(bytes.data(), + static_cast(bytes.size()), + /*finish=*/true); + }); +}; + +Crafter::QUICServerCredentials creds; +creds.selfSigned = true; // dev-only, see "Self-signed certs & browser peers" +Crafter::ListenerAsyncHTTP listener(4443, creds, std::move(httpRoutes), std::move(wtRoutes)); +``` + +Browser-initiated bidi/unidi WebTransport streams arrive via `session.OnStream(...)`. The `WT_BIDI` (`0x41`) / `WT_UNIDI` (`0x54`) stream-type prefix and session-id varint are stripped before the user handler sees the stream; what the handler reads is pure session payload. Phase 1 covers session establishment + bidirectional streams; WebTransport datagrams and capsule-protocol control are stubbed for a follow-up. + +### Self-signed certs & browser peers + +Passing `QUICServerCredentials{selfSigned = true}` makes the listener generate (and cache) an ephemeral cert at `/tmp/crafter-network-quic-cert/{cert,key}.pem` and reuse it across server restarts while it's still valid. The cert is shaped to satisfy Chromium's `WebTransport.serverCertificateHashes` rules: ECDSA P-256, signed with ECDSA-SHA256, validity ≤14 days (10 in practice), `BasicConstraints CA:FALSE`, `KeyUsage digitalSignature`, `EKU serverAuth`, `SAN=DNS:localhost,IP:127.0.0.1,IP:::1`. To let a browser peer pin this cert without trusting a CA: + +```cpp +auto certPath = Crafter::GetSelfSignedCertificatePath(); +auto hash = Crafter::ComputeCertificateHashSHA256(certPath); // 32-byte SHA-256 of cert DER +// Publish hash to the browser (e.g. write hex to a file the page can fetch); +// on the browser side feed it into QUICClientCredentials::serverCertificateHash. +``` + +For production use a real cert (`certPath` + `keyPath` on `QUICServerCredentials`). + +## Browser build + +Crafter.Network compiles for `wasm32-wasip1` via [Crafter.Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build); the build path is selected automatically when `cfg.target.find("wasm") != npos`. On that target: + +- `CRAFTER_NETWORK_BROWSER` is defined. Synchronous methods on `ClientHTTP` / `QUICStream` / `ClientQUIC` are not compiled — only the `*Async` variants are available. +- `ClientHTTP` calls into `crafterNetworkFetch` (JS) which delegates to `fetch()`. An empty `host` is a same-origin sentinel: the path is passed through as the URL, so `ClientHTTP("", 0).SendAsync({.path="/data.json"}, ...)` fetches from the page origin. +- `ClientQUIC` calls into `crafterNetworkWtConnect` which constructs a `WebTransport(url, opts)` against `https://{host}:{port}/{alpn}` (i.e. `alpn` is the WebTransport URL path on this target). `QUICClientCredentials::serverCertificateHash` is forwarded as `serverCertificateHashes`; leaving it zeroed makes the browser fall back to its normal trust store. +- `ListenerTCP`, `ListenerHTTP`, `ListenerQUIC`, `ClientTCP`, and the sync receive/send paths are excluded — the browser is client-only. +- `additional/network-env.js` is shipped alongside the produced `.wasm` and merged into the runtime's `env` import object by `EnableWasiBrowserRuntime`. + +A worked example pairing a wasm browser client with a native server lives in [examples/SimpleClient/](examples/SimpleClient/). Build the server with `crafter-build --target=x86_64-pc-linux-gnu`, run it, then run `crafter-build` (no `--target`) to produce the wasm and serve it over HTTPS. + ## Build Configuration -The project uses a configuration system with multiple build targets: +The project is a single Crafter.Build configuration (`crafter-network`, `ConfigurationType::LibraryStatic`). Target selection and debug flags are handled by `ApplyStandardArgs`: -- **base**: Core interfaces only -- **lib**: Static library build with dependencies -- **lib-debug**: Debug static library build -- **lib-shared**: Shared library build with dependencies +- `crafter-build` — host native (x86_64-pc-linux-gnu by default), msquic + listeners + sync APIs. +- `crafter-build --target=wasm32-wasip1` — browser build, fetch + WebTransport, async-only API; defines `CRAFTER_NETWORK_BROWSER`, drops msquic. +- `crafter-build test [globs]` — build and run tests under `tests/`. ## Testing @@ -103,17 +155,19 @@ The library includes tests covering: - HTTP/3 connection multiplexing (`ShouldSendRecieveKeepaliveHTTP`) — two requests share one QUIC connection - HTTP/3 large body transfer (`ShouldSendRecieveLargeHTTP`) — 10 MiB POST - HTTP/3 external interop (`ShouldSend`) — live fetch from `cloudflare-quic.com:443`, exercises real TLS chain validation, mandatory control stream, peer-initiated unidi streams, and QPACK Huffman decoding -- QUIC reliable streams -- QUIC unreliable datagrams +- QUIC reliable streams (`ShouldSendRecieveQUICStream`) +- QUIC unreliable datagrams (`ShouldSendRecieveQUICDatagram`) +- WebTransport echo (`ShouldEchoWebTransport`) — extended-CONNECT acceptance, draft-02 SETTINGS, bidi data stream framing (`WT_STREAM 0x41` + session-id varint), and byte-for-byte echo The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. ## Dependencies - **Crafter.Thread**: Thread pool management for asynchronous operations. -- **msquic** — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` recursively into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=quictls`, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC and HTTP/3 modules. +- **msquic** (native target only) — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` recursively into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=quictls`, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC and HTTP/3 modules. Skipped entirely on browser builds. - On Linux msquic links against `libnuma` (provided by the `numactl` package on most distros). -- The self-signed-cert path used by tests/dev shells out to the `openssl` CLI; install `openssl` if you intend to use `QUICServerCredentials{selfSigned = true}`. +- The self-signed-cert path used by tests/dev shells out to the `openssl` CLI; install `openssl` if you intend to use `QUICServerCredentials{selfSigned = true}`. The same path also produces the cert hash that browser peers need for `serverCertificateHashes`. +- **Browser build** has no extra dependencies beyond Crafter.Build's `wasi-browser` runtime: HTTP delegates to the browser's `fetch()`, QUIC to its `WebTransport`. The JS glue lives in `additional/network-env.js` and is shipped alongside the produced `.wasm`. ## Usage Example diff --git a/additional/network-env.js b/additional/network-env.js new file mode 100644 index 0000000..3545c77 --- /dev/null +++ b/additional/network-env.js @@ -0,0 +1,329 @@ +/* +Crafter®.Network +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. +*/ + +// JS bridge for the CRAFTER_NETWORK_BROWSER build of Crafter.Network. +// Populates `window.crafter_webbuild_env` with the env imports the .wasm +// declares (crafterNetworkFetch, crafterNetworkWt*), and drives the +// reader loops that feed responses back into the wasm exports +// (CrafterNetworkOnFetchComplete, CrafterNetworkOnWt*). +// +// Crafter.Build's wasi-browser runtime merges this object into the +// WebAssembly import object as the `env` module before instantiation — +// mirrors additional/dom-env.js in Crafter.Graphics. + +const __decoder = new TextDecoder(); +const __encoder = new TextEncoder(); + +function __wasm() { return window.crafter_webbuild_wasi.instance.exports; } +function __memBuf() { return window.crafter_webbuild_wasi.instance.exports.memory.buffer; } + +function __readUtf8(ptr, len) { + return __decoder.decode(new Uint8Array(__memBuf(), ptr, len)); +} +function __readBytes(ptr, len) { + // Copy out of the linear memory — the underlying buffer is detached + // whenever wasm grows its memory, so handing the view straight back + // to fetch / WebTransport would risk operating on a freed view. + return new Uint8Array(__memBuf(), ptr, len).slice(); +} +function __allocCopy(bytes) { + const ptr = __wasm().WasmAlloc(bytes.length); + new Uint8Array(__memBuf(), ptr, bytes.length).set(bytes); + return ptr; +} +function __allocUtf8(str) { + return __allocCopy(__encoder.encode(str)); +} + +// ─── fetch() bridge ─────────────────────────────────────────────────── + +function crafterNetworkFetch(methodPtr, methodLen, + urlPtr, urlLen, + headersPtr, headersLen, + bodyPtr, bodyLen, + callbackId) { + const method = __readUtf8(methodPtr, methodLen); + const url = __readUtf8(urlPtr, urlLen); + const init = { method, headers: {} }; + + if (headersLen > 0) { + const headerStr = __readUtf8(headersPtr, headersLen); + for (const line of headerStr.split('\n')) { + const sep = line.indexOf(': '); + if (sep > 0) init.headers[line.slice(0, sep)] = line.slice(sep + 2); + } + } + if (bodyLen > 0 && method !== 'GET' && method !== 'HEAD') { + init.body = __readBytes(bodyPtr, bodyLen); + } + + fetch(url, init).then(async (response) => { + // Lowercase to match HTTP/3 convention the native client uses. + const headerLines = []; + response.headers.forEach((value, name) => headerLines.push(`${name.toLowerCase()}: ${value}`)); + const headerStr = headerLines.join('\n'); + const bodyBuf = await response.arrayBuffer(); + + const headerEncoded = __encoder.encode(headerStr); + const headerPtr = headerEncoded.length > 0 ? __allocCopy(headerEncoded) : 0; + const respBodyPtr = bodyBuf.byteLength > 0 ? __allocCopy(new Uint8Array(bodyBuf)) : 0; + __wasm().CrafterNetworkOnFetchComplete(callbackId, + response.status, + headerPtr, headerEncoded.length, + respBodyPtr, bodyBuf.byteLength); + }).catch((err) => { + const msg = String(err && err.message ? err.message : err); + const encoded = __encoder.encode(msg); + const ptr = encoded.length > 0 ? __allocCopy(encoded) : 0; + __wasm().CrafterNetworkOnFetchError(callbackId, ptr, encoded.length); + }); +} + +// ─── WebTransport bridge ────────────────────────────────────────────── +// +// Connection-handle and stream-handle counters are monotone. Each +// connection's incoming-stream + datagram reader loops are started as +// soon as wt.ready resolves and run until the session closes. Outgoing +// operations queued before ready are gated behind a per-handle `ready` +// promise. + +let __wtNextHandle = 0; +let __wtNextStream = 0; +const __wtSessions = new Map(); // handle → { wt, ready, streams: Set } +const __wtStreams = new Map(); // streamId → { connection, writer, reader, writable, readable } + +function crafterNetworkWtConnect(hostPtr, hostLen, port, alpnPtr, alpnLen, certHashPtr, certHashLen) { + const host = __readUtf8(hostPtr, hostLen); + const alpn = __readUtf8(alpnPtr, alpnLen); + const url = `https://${host}:${port}/${alpn}`; + const opts = {}; + if (certHashLen > 0) { + const hash = __readBytes(certHashPtr, certHashLen); + opts.serverCertificateHashes = [{ algorithm: 'sha-256', value: hash }]; + } + + let wt; + try { + wt = new WebTransport(url, opts); + } catch (err) { + console.error('Crafter.Network: WebTransport ctor failed:', err); + return 0; + } + + const handle = ++__wtNextHandle; + const session = { wt, ready: false, streams: new Set(), closed: false }; + __wtSessions.set(handle, session); + + wt.ready.then(() => { + session.ready = true; + __wasm().CrafterNetworkOnWtReady(handle); + __wtRunIncomingLoop(handle, wt.incomingBidirectionalStreams, /*bidi=*/true); + __wtRunIncomingLoop(handle, wt.incomingUnidirectionalStreams, /*bidi=*/false); + __wtRunDatagramLoop(handle, wt); + }).catch((err) => { + __wtFireClosed(handle, String(err && err.message ? err.message : err)); + }); + + wt.closed.then(() => { + __wtFireClosed(handle, ''); + }).catch((err) => { + __wtFireClosed(handle, String(err && err.message ? err.message : err)); + }); + + return handle; +} + +function __wtFireClosed(handle, message) { + const session = __wtSessions.get(handle); + if (!session || session.closed) return; + session.closed = true; + // Wake up any per-stream receivers with a synthetic FIN so the C++ + // state machine terminates pending callbacks instead of hanging. + for (const streamId of session.streams) { + __wasm().CrafterNetworkOnWtStreamChunk(streamId, 0, 0, 1); + __wtStreams.delete(streamId); + } + session.streams.clear(); + const msgPtr = message ? __allocUtf8(message) : 0; + __wasm().CrafterNetworkOnWtClosed(handle, msgPtr, message ? __encoder.encode(message).length : 0); +} + +function crafterNetworkWtClose(handle) { + const session = __wtSessions.get(handle); + if (!session) return; + try { session.wt.close(); } catch (_) { /* already closing */ } + __wtSessions.delete(handle); +} + +function crafterNetworkWtOpenStream(handle, unidirectional) { + const session = __wtSessions.get(handle); + if (!session || session.closed) return 0; + const streamId = ++__wtNextStream; + const record = { connection: handle, writer: null, reader: null, + pendingOps: [], opened: false, unidirectional: !!unidirectional }; + __wtStreams.set(streamId, record); + session.streams.add(streamId); + + const opener = session.ready + ? Promise.resolve() + : session.wt.ready; + opener.then(() => { + const p = unidirectional + ? session.wt.createUnidirectionalStream() + : session.wt.createBidirectionalStream(); + return p; + }).then((stream) => { + // For a bidi stream `stream` is a WebTransportBidirectionalStream + // with .readable and .writable. For a unidi outgoing stream + // `stream` is itself a WritableStream. + if (unidirectional) { + record.writer = stream.getWriter(); + } else { + record.writer = stream.writable.getWriter(); + record.reader = stream.readable.getReader(); + __wtRunStreamReader(streamId); + } + record.opened = true; + for (const op of record.pendingOps) op(); + record.pendingOps.length = 0; + }).catch((err) => { + console.error(`Crafter.Network: openStream(${streamId}) failed`, err); + // Tell C++ the stream is dead so pending receivers unblock. + __wasm().CrafterNetworkOnWtStreamChunk(streamId, 0, 0, 1); + __wtStreams.delete(streamId); + session.streams.delete(streamId); + }); + + return streamId; +} + +function crafterNetworkWtStreamWrite(streamId, bufPtr, bufLen, finish, callbackId) { + const record = __wtStreams.get(streamId); + if (!record) { + if (callbackId !== 0) __wasm().CrafterNetworkOnWtStreamWriteComplete(callbackId); + return; + } + const data = bufLen > 0 ? __readBytes(bufPtr, bufLen) : new Uint8Array(0); + const doWrite = () => { + const p = data.length > 0 ? record.writer.write(data) : Promise.resolve(); + p.then(() => { + if (finish) { try { return record.writer.close(); } catch (_) {} } + }).then(() => { + if (callbackId !== 0) __wasm().CrafterNetworkOnWtStreamWriteComplete(callbackId); + }).catch((err) => { + console.error(`Crafter.Network: write(${streamId}) failed`, err); + if (callbackId !== 0) __wasm().CrafterNetworkOnWtStreamWriteComplete(callbackId); + }); + }; + if (record.opened) doWrite(); + else record.pendingOps.push(doWrite); +} + +function crafterNetworkWtStreamStop(streamId) { + const record = __wtStreams.get(streamId); + if (!record) return; + try { record.writer && record.writer.close(); } catch (_) {} + try { record.reader && record.reader.cancel(); } catch (_) {} + const session = __wtSessions.get(record.connection); + if (session) session.streams.delete(streamId); + __wtStreams.delete(streamId); +} + +function crafterNetworkWtSendDatagram(handle, bufPtr, bufLen) { + const session = __wtSessions.get(handle); + if (!session || session.closed) return; + const data = __readBytes(bufPtr, bufLen); + const send = () => { + const writer = session.wt.datagrams.writable.getWriter(); + writer.write(data).catch((err) => { + console.error('Crafter.Network: sendDatagram failed', err); + }).finally(() => { + try { writer.releaseLock(); } catch (_) {} + }); + }; + if (session.ready) send(); + else session.wt.ready.then(send).catch(() => {}); +} + +async function __wtRunStreamReader(streamId) { + const record = __wtStreams.get(streamId); + if (!record || !record.reader) return; + try { + while (true) { + const { value, done } = await record.reader.read(); + if (value && value.byteLength > 0) { + const ptr = __allocCopy(value); + __wasm().CrafterNetworkOnWtStreamChunk(streamId, ptr, value.byteLength, done ? 1 : 0); + } else if (done) { + __wasm().CrafterNetworkOnWtStreamChunk(streamId, 0, 0, 1); + } + if (done) break; + } + } catch (err) { + // Reader cancelled or stream closed. Dispatch a synthetic FIN so + // any pending C++ receiver wakes up. + __wasm().CrafterNetworkOnWtStreamChunk(streamId, 0, 0, 1); + } +} + +async function __wtRunIncomingLoop(handle, source, bidi) { + const session = __wtSessions.get(handle); + if (!session) return; + const reader = source.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const streamId = ++__wtNextStream; + const record = { connection: handle, writer: null, reader: null, + pendingOps: [], opened: true, unidirectional: !bidi }; + if (bidi) { + record.writer = value.writable.getWriter(); + record.reader = value.readable.getReader(); + } else { + record.reader = value.getReader(); + } + __wtStreams.set(streamId, record); + session.streams.add(streamId); + __wasm().CrafterNetworkOnWtIncomingStream(handle, streamId, bidi ? 1 : 0); + __wtRunStreamReader(streamId); + } + } catch (_) { /* session closed */ } +} + +async function __wtRunDatagramLoop(handle, wt) { + const reader = wt.datagrams.readable.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value && value.byteLength > 0) { + const ptr = __allocCopy(value); + __wasm().CrafterNetworkOnWtDatagram(handle, ptr, value.byteLength); + } + } + } catch (_) { /* session closed */ } +} + +// ─── Export env object ──────────────────────────────────────────────── + +if (!window.crafter_webbuild_env) { + window.crafter_webbuild_env = {}; +} +Object.assign(window.crafter_webbuild_env, { + crafterNetworkFetch, + crafterNetworkWtConnect, + crafterNetworkWtClose, + crafterNetworkWtOpenStream, + crafterNetworkWtStreamWrite, + crafterNetworkWtStreamStop, + crafterNetworkWtSendDatagram, +}); diff --git a/examples/SimpleClient/cert-hash.txt b/examples/SimpleClient/cert-hash.txt new file mode 100644 index 0000000..3363252 --- /dev/null +++ b/examples/SimpleClient/cert-hash.txt @@ -0,0 +1 @@ +b7c4a81084fc56f45f1a6025fafdc8a1b05bf8388947f1840608da565cd22c8e \ No newline at end of file diff --git a/examples/SimpleClient/main.cpp b/examples/SimpleClient/main.cpp new file mode 100644 index 0000000..3414cd4 --- /dev/null +++ b/examples/SimpleClient/main.cpp @@ -0,0 +1,227 @@ +// Crafter.Network SimpleClient example. +// +// Browser build (wasm32-wasip1, default): +// crafter-build +// Serve bin// on a static HTTPS server, open index.html +// in Chrome and watch DevTools → Console. +// +// Native server build (e.g. x86_64-pc-linux-gnu): +// crafter-build --target=x86_64-pc-linux-gnu +// Runs a WebTransport echo server on port 4443 that the browser demo +// connects to at wt://localhost:4443/echo. + +import Crafter.Network; +import std; +#ifndef CRAFTER_NETWORK_BROWSER +import Crafter.Thread; +#include +#include +#endif + +using namespace Crafter; + +namespace { + +#ifdef CRAFTER_NETWORK_BROWSER + void RunFetchDemo() { + // httpbin.org sets Access-Control-Allow-Origin: * and returns a + // small JSON echo, so it works as a smoke test from any origin. + // Swap host/port/path for your own service when you have one. + std::println(std::cout, "[Crafter.Network] HTTP GET httpbin.org/get ..."); + + // Heap-allocated and intentionally leaked — fetch() resolves + // after main() returns and the JS event loop keeps the wasm + // instance alive. A real app would tie the lifetime to a session + // object that lives for as long as the page does. + auto* client = new ClientHTTP("httpbin.org", 443); + + HTTPRequest req; + req.method = "GET"; + req.path = "/get"; + + client->SendAsync(req, + [](HTTPResponse response) { + std::println(std::cout, + "[Crafter.Network] fetch OK — status {}, body {} bytes", + response.status, response.body.size()); + if (!response.body.empty()) { + auto preview = response.body.substr(0, std::min(80, response.body.size())); + std::println(std::cout, "[Crafter.Network] body[0..80]: {}", preview); + } + }, + [](std::string error) { + std::println(std::cerr, "[Crafter.Network] fetch ERROR: {}", error); + }); + } + + // Parse 64-char lowercase hex into a 32-byte digest. Returns all-zero + // bytes (treated as "no hash") if the input is malformed. + std::array ParseHexHash(std::string_view hex) { + std::array out{}; + if (hex.size() < 64) return out; + auto nibble = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); + return -1; + }; + for (std::size_t i = 0; i < 32; ++i) { + int hi = nibble(hex[2 * i]); + int lo = nibble(hex[2 * i + 1]); + if (hi < 0 || lo < 0) return std::array{}; + out[i] = static_cast((hi << 4) | lo); + } + return out; + } + + void StartWebTransportEcho(std::array certHash) { + const char* wtHost = "localhost"; + std::uint16_t wtPort = 4443; + const char* wtPath = "echo"; + std::println(std::cout, "[Crafter.Network] WebTransport connect {}:{}/{} ...", wtHost, wtPort, wtPath); + + QUICClientCredentials creds; + creds.serverCertificateHash = certHash; + auto* conn = new ClientQUIC(wtHost, wtPort, wtPath, creds); + + conn->OnDatagram([](std::vector bytes) { + std::string text(bytes.begin(), bytes.end()); + std::println(std::cout, "[Crafter.Network] WT datagram echo: {} ({} bytes)", text, bytes.size()); + }); + + // Leak the stream so its handle survives past this function's + // return. Read fires after the echo server has sent the bytes + // back, which happens after the JS event loop runs. finish=true + // closes the send-side so the server's RecieveUntilCloseSync + // returns and the echo handler runs. + static QUICStream s = conn->OpenStream(/*unidirectional=*/false); + constexpr std::string_view payload = "hello from crafter.network"; + s.SendAsync(payload.data(), static_cast(payload.size()), /*finish=*/true, []{ + std::println(std::cout, "[Crafter.Network] WT stream write OK"); + s.RecieveUntilCloseAsync([](std::vector bytes) { + std::string text(bytes.begin(), bytes.end()); + std::println(std::cout, "[Crafter.Network] WT stream echo: {} ({} bytes)", text, bytes.size()); + }); + }); + } + + void RunWebTransportDemo() { + // Chrome refuses self-signed WebTransport certs unless we pass their + // SHA-256 via `serverCertificateHashes`. Our native server writes the + // hex digest to ./cert-hash.txt; we fetch it from the same origin as + // this wasm (`ClientHTTP("", 0)` is the same-origin sentinel). Serve + // the wasm from the directory the server is running in so the file + // is reachable, then refresh. + static ClientHTTP origin("", 0); + HTTPRequest req; + req.method = "GET"; + req.path = "/cert-hash.txt"; + origin.SendAsync(req, + [](HTTPResponse resp) { + if (resp.status != "200") { + std::println(std::cerr, + "[Crafter.Network] /cert-hash.txt → HTTP {} — start the native server " + "from this directory so the hash file is reachable", + resp.status); + return; + } + auto hash = ParseHexHash(resp.body); + bool zero = true; + for (auto b : hash) if (b) { zero = false; break; } + if (zero) { + std::println(std::cerr, + "[Crafter.Network] /cert-hash.txt is empty or malformed; expected 64 hex chars"); + return; + } + std::println(std::cout, "[Crafter.Network] using cert hash from /cert-hash.txt"); + StartWebTransportEcho(hash); + }, + [](std::string err) { + std::println(std::cerr, + "[Crafter.Network] could not fetch /cert-hash.txt: {} — is the static server " + "serving the directory the native server runs in?", err); + }); + } + +#else // native server + + static std::atomic gRunning{true}; + + void RunEchoServer() { + ThreadPool::Start(); + + QUICServerCredentials creds; + creds.selfSigned = true; + + // Compute the SHA-256 of the self-signed cert so the browser peer + // can pass it via WebTransport's serverCertificateHashes option. + // Chrome won't accept self-signed certs without this. We also write + // the hash hex to ./cert-hash.txt alongside the binary so a static + // file server serving the wasm can hand it back to the page. + std::string certPath = GetSelfSignedCertificatePath(); + auto hash = ComputeCertificateHashSHA256(certPath); + std::string hashHex; + for (auto b : hash) { + constexpr char hex[] = "0123456789abcdef"; + hashHex.push_back(hex[b >> 4]); + hashHex.push_back(hex[b & 0xf]); + } + std::ofstream("cert-hash.txt") << hashHex; + + std::unordered_map> httpRoutes = { + {"/health", [](const HTTPRequest&) { + HTTPResponse r; + r.status = "200"; + r.body = "ok"; + return r; + }}, + }; + std::unordered_map> wtRoutes = { + {"/echo", [](WebTransportSession& session) { + session.OnStream([](QUICStream stream) { + try { + auto bytes = stream.RecieveUntilCloseSync(); + stream.SendSync(bytes.data(), + static_cast(bytes.size()), + /*finish=*/true); + } catch (...) {} + }); + }}, + }; + + ListenerAsyncHTTP server(4443, creds, std::move(httpRoutes), std::move(wtRoutes)); + std::println(std::cout, "[Crafter.Network] echo server listening on port 4443"); + std::println(std::cout, "[Crafter.Network] WebTransport /echo — bidi streams echoed back"); + std::println(std::cout, "[Crafter.Network] HTTP GET /health — returns 200 ok"); + std::println(std::cout, "[Crafter.Network] cert SHA-256: {}", hashHex); + std::println(std::cout, "[Crafter.Network] (also written to ./cert-hash.txt for the browser to fetch)"); + std::println(std::cout, "[Crafter.Network] Press Ctrl-C to stop"); + + std::signal(SIGINT, [](int) { gRunning = false; }); + std::signal(SIGTERM, [](int) { gRunning = false; }); + while (gRunning) std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + std::println(std::cout, "[Crafter.Network] shutting down"); + server.Stop(); + } + +#endif + +} // namespace + +int main() { +#ifdef CRAFTER_NETWORK_BROWSER + // Full-buffer stdout means async-callback prints never reach the + // console after main() returns. unitbuf flushes after every insert + // so logs show up live. + std::cout << std::unitbuf; + std::cerr << std::unitbuf; + std::println(std::cout, "[Crafter.Network] SimpleClient starting (browser)"); + RunFetchDemo(); + RunWebTransportDemo(); + std::println(std::cout, "[Crafter.Network] main() returning (async work continues in JS event loop)"); +#else + RunEchoServer(); +#endif + return 0; +} diff --git a/examples/SimpleClient/project.cpp b/examples/SimpleClient/project.cpp new file mode 100644 index 0000000..5eac1a6 --- /dev/null +++ b/examples/SimpleClient/project.cpp @@ -0,0 +1,79 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span args) { + // Dispatch on --target: wasm32-* → browser client; anything else → native server. + // Plain `crafter-build` (no --target) defaults to the browser client so the + // primary use case keeps working without flags. + bool isBrowser = true; + for (const auto& a : args) { + if (a.starts_with("--target=")) { + isBrowser = (a.find("wasm") != std::string_view::npos); + break; + } + } + + std::vector netArgs(args.begin(), args.end()); + + if (isBrowser) { + // Force wasm32-wasip1 when no --target was supplied. + bool hasTarget = false; + for (const auto& a : netArgs) { + if (a.starts_with("--target=")) { hasTarget = true; break; } + } + if (!hasTarget) netArgs.emplace_back("--target=wasm32-wasip1"); + + Configuration* network = LocalProject({ + .projectFile = "../../project.cpp", + .args = netArgs, + }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "SimpleClient"; + cfg.outputName = "SimpleClient"; + cfg.type = ConfigurationType::Executable; + cfg.target = "wasm32-wasip1"; + // Mirror CRAFTER_NETWORK_BROWSER so main.cpp sees the same API surface + // as the wasm .pcm (Crafter.Build does not propagate defines from deps). + cfg.defines.push_back({"CRAFTER_NETWORK_BROWSER", ""}); + ApplyStandardArgs(cfg, args); + cfg.dependencies = { network }; + cfg.files = { "cert-hash.txt" }; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + // Crafter.Network ships additional/network-env.js via cfg.files; + // Crafter.Build propagates it to the output dir alongside the .wasm. + // EnableWasiBrowserRuntime wires it into the generated index.html. + EnableWasiBrowserRuntime(cfg); + + return cfg; + } + + // Native server — echo server on port 4443. + Configuration* network = LocalProject({ + .projectFile = "../../project.cpp", + .args = netArgs, + }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "SimpleServer"; + cfg.outputName = "SimpleServer"; + cfg.type = ConfigurationType::Executable; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { network }; + cfg.linkFlags.push_back("-Wl,--export-dynamic"); + cfg.linkFlags.push_back("-ldl"); + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + return cfg; +} diff --git a/implementations/Crafter.Network-ClientHTTP-Browser.cpp b/implementations/Crafter.Network-ClientHTTP-Browser.cpp new file mode 100644 index 0000000..ca14bea --- /dev/null +++ b/implementations/Crafter.Network-ClientHTTP-Browser.cpp @@ -0,0 +1,199 @@ +/* +Crafter®.Network +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// CRAFTER_NETWORK_BROWSER implementation of ClientHTTP. Each SendAsync +// hands its request to the JS bridge (additional/network-env.js), which +// runs a fetch() and dispatches the result back through the +// CrafterNetworkOnFetchComplete / CrafterNetworkOnFetchError wasm exports. + +module; +module Crafter.Network:ClientHTTP_impl; +import :ClientHTTP; +import :HTTP; +import std; + +using namespace Crafter; + +namespace Crafter::NetworkBrowserBindings { + // External linkage so the import_module/import_name attributes wire up. + __attribute__((import_module("env"), import_name("crafterNetworkFetch"))) + void crafterNetworkFetch( + const char* method, std::int32_t methodLen, + const char* url, std::int32_t urlLen, + const char* headers, std::int32_t headersLen, + const char* body, std::int32_t bodyLen, + std::int32_t callbackId); +} + +namespace { + struct FetchCallbacks { + std::function onSuccess; + std::function onError; + }; + + // JS dispatches back into wasm via a stable id we mint here. The id + // counter is monotone — wraparound at 2 billion fetches is not a + // realistic concern. The map is touched only from the JS event loop + // (single-threaded in the browser), so no synchronisation is needed. + std::unordered_map& Callbacks() { + static std::unordered_map m; + return m; + } + std::int32_t NextId() { + static std::int32_t counter = 0; + return ++counter; + } + + // Serialise headers as newline-separated "name: value" pairs. The JS + // side splits on '\n' and the first ": " for header construction. + std::string SerialiseHeaders(const std::unordered_map& headers) { + std::string out; + bool first = true; + for (const auto& [name, value] : headers) { + if (!first) out += '\n'; + first = false; + out += name; + out += ": "; + out += value; + } + return out; + } + + // Parse a "name: value\nname2: value2" blob into the HTTPResponse map. + // Names are kept verbatim (fetch surfaces them lowercase already on the + // browser side via response.headers.forEach). + void ParseHeaders(std::string_view raw, HTTPResponse& response) { + std::size_t pos = 0; + while (pos < raw.size()) { + std::size_t end = raw.find('\n', pos); + std::string_view line = raw.substr(pos, end == std::string_view::npos ? raw.size() - pos : end - pos); + std::size_t sep = line.find(": "); + if (sep != std::string_view::npos) { + response.headers.emplace(std::string(line.substr(0, sep)), + std::string(line.substr(sep + 2))); + } + if (end == std::string_view::npos) break; + pos = end + 1; + } + } +} + +struct ClientHTTP::Impl {}; + +ClientHTTP::ClientHTTP(const char* host, std::uint16_t port, QUICClientCredentials) + : host(host), port(port), impl(std::make_unique()) {} + +ClientHTTP::ClientHTTP(std::string host, std::uint16_t port, QUICClientCredentials creds) + : ClientHTTP(host.c_str(), port, std::move(creds)) {} + +ClientHTTP::ClientHTTP(ClientHTTP&&) noexcept = default; +ClientHTTP::~ClientHTTP() = default; + +void ClientHTTP::SendAsync(const HTTPRequest& request, + std::function onSuccess, + std::function onError) { + std::int32_t id = NextId(); + Callbacks().emplace(id, FetchCallbacks{std::move(onSuccess), std::move(onError)}); + + std::string method = request.method.empty() ? std::string("GET") : request.method; + std::string path = request.path.empty() ? std::string("/") : request.path; + // Sentinel: a ClientHTTP constructed with an empty host fetches against + // the page's own origin. fetch(url) in JS handles a leading-slash path + // by resolving it against window.location, so we just hand the path + // straight through. Useful for fetching files served by whatever static + // server is hosting the wasm (e.g. ./cert-hash.txt for WebTransport). + std::string url; + if (host.empty()) { + url = path; + } else { + std::string scheme = request.scheme.empty() ? std::string("https") : request.scheme; + std::string authority = request.authority.empty() ? (host + ":" + std::to_string(port)) : request.authority; + url = scheme + "://" + authority + path; + } + std::string headerStr = SerialiseHeaders(request.headers); + + Crafter::NetworkBrowserBindings::crafterNetworkFetch( + method.data(), static_cast(method.size()), + url.data(), static_cast(url.size()), + headerStr.data(), static_cast(headerStr.size()), + request.body.data(), static_cast(request.body.size()), + id); +} + +extern "C" { + // JS allocates `headersPtr` and `bodyPtr` via WasmAlloc, copies the + // response into them, then transfers ownership across this call. We + // free the buffers after copying into the HTTPResponse. + __attribute__((export_name("CrafterNetworkOnFetchComplete"))) + void CrafterNetworkOnFetchComplete(std::int32_t callbackId, + std::int32_t status, + char* headersPtr, std::int32_t headersLen, + char* bodyPtr, std::int32_t bodyLen) { + auto& callbacks = Callbacks(); + auto it = callbacks.find(callbackId); + if (it == callbacks.end()) { + std::free(headersPtr); + std::free(bodyPtr); + return; + } + + HTTPResponse response; + response.status = std::to_string(status); + if (headersPtr && headersLen > 0) { + ParseHeaders(std::string_view(headersPtr, static_cast(headersLen)), response); + } + if (bodyPtr && bodyLen > 0) { + response.body.assign(bodyPtr, static_cast(bodyLen)); + } + std::free(headersPtr); + std::free(bodyPtr); + + auto onSuccess = std::move(it->second.onSuccess); + callbacks.erase(it); + if (onSuccess) onSuccess(std::move(response)); + } + + __attribute__((export_name("CrafterNetworkOnFetchError"))) + void CrafterNetworkOnFetchError(std::int32_t callbackId, + char* messagePtr, std::int32_t messageLen) { + auto& callbacks = Callbacks(); + auto it = callbacks.find(callbackId); + if (it == callbacks.end()) { + std::free(messagePtr); + return; + } + std::string msg(messagePtr ? messagePtr : "", static_cast(messageLen)); + std::free(messagePtr); + + auto onError = std::move(it->second.onError); + callbacks.erase(it); + if (onError) onError(std::move(msg)); + } + + // WasmAlloc / WasmFree are the buffer-marshalling primitives the JS + // bridge calls into. Crafter.Graphics's Dom backend defines the same + // pair; we mark ours weak so the two libraries can coexist in one + // executable without a duplicate-symbol error. + __attribute__((export_name("WasmAlloc"), weak)) + void* WasmAlloc(std::int32_t size) { return std::malloc(static_cast(size)); } + + __attribute__((export_name("WasmFree"), weak)) + void WasmFree(void* ptr) { std::free(ptr); } +} diff --git a/implementations/Crafter.Network-ClientHTTP.cpp b/implementations/Crafter.Network-ClientHTTP.cpp index a665415..6e4ea70 100644 --- a/implementations/Crafter.Network-ClientHTTP.cpp +++ b/implementations/Crafter.Network-ClientHTTP.cpp @@ -130,6 +130,24 @@ namespace { } } +void ClientHTTP::SendAsync(const HTTPRequest& request, + std::function onSuccess, + std::function 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"); + } + }); +} + HTTPResponse ClientHTTP::Send(const HTTPRequest& request) { QUICStream stream = impl->quic.OpenStream(); diff --git a/implementations/Crafter.Network-ClientQUIC-Browser.cpp b/implementations/Crafter.Network-ClientQUIC-Browser.cpp new file mode 100644 index 0000000..7372433 --- /dev/null +++ b/implementations/Crafter.Network-ClientQUIC-Browser.cpp @@ -0,0 +1,443 @@ +/* +Crafter®.Network +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// CRAFTER_NETWORK_BROWSER implementation of ClientQUIC / QUICStream backed +// by the browser's WebTransport API. WebTransport is HTTP/3-based and +// expects a URL — we use https://${host}:${port}/${alpn}. Native msquic +// is not linked into the browser build. +// +// Async-only: synchronous send/receive methods on QUICStream are not +// compiled (gated out in the interface). Everything goes through the +// existing *Async / OnStream / OnDatagram callbacks. The JS bridge +// (additional/network-env.js) runs the WebTransport reader loops and +// dispatches each chunk back through the wasm exports declared below. + +module; +module Crafter.Network:ClientQUIC_impl; +import :ClientQUIC; +import std; + +using namespace Crafter; + +namespace Crafter::NetworkBrowserBindings { + __attribute__((import_module("env"), import_name("crafterNetworkWtConnect"))) + std::int32_t crafterNetworkWtConnect( + const char* host, std::int32_t hostLen, + std::int32_t port, + const char* alpn, std::int32_t alpnLen, + const std::uint8_t* certHash, std::int32_t certHashLen); + + __attribute__((import_module("env"), import_name("crafterNetworkWtClose"))) + void crafterNetworkWtClose(std::int32_t handle); + + __attribute__((import_module("env"), import_name("crafterNetworkWtOpenStream"))) + std::int32_t crafterNetworkWtOpenStream(std::int32_t handle, std::int32_t unidirectional); + + __attribute__((import_module("env"), import_name("crafterNetworkWtStreamWrite"))) + void crafterNetworkWtStreamWrite(std::int32_t streamId, + const char* buf, std::int32_t bufLen, + std::int32_t finish, + std::int32_t callbackId); + + __attribute__((import_module("env"), import_name("crafterNetworkWtStreamStop"))) + void crafterNetworkWtStreamStop(std::int32_t streamId); + + __attribute__((import_module("env"), import_name("crafterNetworkWtSendDatagram"))) + void crafterNetworkWtSendDatagram(std::int32_t handle, + const char* buf, std::int32_t bufLen); +} + +namespace Crafter::NetworkBrowser { + // ─── Receive state machine per stream ───────────────────────────────── + // + // The JS reader loop pushes every chunk it sees through + // CrafterNetworkOnWtStreamChunk. We buffer them until the user calls + // one of the Receive*Async variants — at which point we either + // dispatch immediately (chunks already queued) or wait for the next + // chunk to arrive. A FIN signal marks the end of the peer's send- + // side; further chunks after FIN do not arrive. + // + // StreamState / ConnectionState live in a named namespace (not + // anonymous) because they are referenced from the QUICStream::Impl + // and ClientQUIC::Impl definitions below — private nested types of + // exported classes can't have TU-local member types without a + // diagnostic. + + enum class RecvMode { None, Once, UntilClose, UntilFull }; + + struct StreamState { + // 0 means destroyed / closed. + std::int32_t handle = 0; + + // Buffered chunks not yet delivered to a pending callback. + std::vector buffer; + bool finReceived = false; + bool closed = false; + + // Pending one-shot receive. + RecvMode mode = RecvMode::None; + std::uint32_t target = 0; + std::function)> cb; + + ClientQUIC* connection = nullptr; + }; + + struct ConnectionState { + std::int32_t handle = 0; + bool ready = false; + bool closed = false; + std::function onStream; + std::function)> onDatagram; + }; + + // Handle → state. Allocated on the heap so the pointer is stable + // across QUICStream / ClientQUIC moves (each holds a unique_ptr + // that wraps a pointer into these maps via its handle). + inline std::unordered_map& Streams() { + static std::unordered_map m; + return m; + } + inline std::unordered_map& Connections() { + static std::unordered_map m; + return m; + } + inline std::unordered_map>& WriteCallbacks() { + static std::unordered_map> m; + return m; + } + inline std::int32_t NextWriteCallbackId() { + static std::int32_t counter = 0; + return ++counter; + } + + inline void TryDispatchRecv(StreamState& s) { + if (s.mode == RecvMode::None) return; + + if (s.mode == RecvMode::Once) { + if (!s.buffer.empty()) { + auto chunk = std::move(s.buffer); + s.buffer.clear(); + auto cb = std::move(s.cb); + s.mode = RecvMode::None; + if (cb) cb(std::move(chunk)); + } else if (s.finReceived || s.closed) { + auto cb = std::move(s.cb); + s.mode = RecvMode::None; + if (cb) cb({}); + } + return; + } + if (s.mode == RecvMode::UntilClose) { + if (s.finReceived || s.closed) { + auto chunk = std::move(s.buffer); + s.buffer.clear(); + auto cb = std::move(s.cb); + s.mode = RecvMode::None; + if (cb) cb(std::move(chunk)); + } + return; + } + if (s.mode == RecvMode::UntilFull) { + if (s.buffer.size() >= s.target) { + std::vector chunk(s.buffer.begin(), s.buffer.begin() + s.target); + s.buffer.erase(s.buffer.begin(), s.buffer.begin() + s.target); + auto cb = std::move(s.cb); + s.mode = RecvMode::None; + if (cb) cb(std::move(chunk)); + } else if (s.finReceived || s.closed) { + // Peer closed before we got the requested byte count — + // deliver whatever's left. Mirrors the native variant's + // "throws if peer closes early" only loosely (we have no + // exception channel from JS). + auto chunk = std::move(s.buffer); + s.buffer.clear(); + auto cb = std::move(s.cb); + s.mode = RecvMode::None; + if (cb) cb(std::move(chunk)); + } + } + } +} + +// All implementation-private state types live in Crafter::NetworkBrowser +// (above). Pull them in unqualified for readability. +using Crafter::NetworkBrowser::StreamState; +using Crafter::NetworkBrowser::ConnectionState; +using Crafter::NetworkBrowser::RecvMode; +using Crafter::NetworkBrowser::Streams; +using Crafter::NetworkBrowser::Connections; +using Crafter::NetworkBrowser::WriteCallbacks; +using Crafter::NetworkBrowser::NextWriteCallbackId; +using Crafter::NetworkBrowser::TryDispatchRecv; + +// ─── QUICStream::Impl ──────────────────────────────────────────────────── + +struct QUICStream::Impl { + StreamState state; +}; + +QUICStream::QUICStream() : impl(std::make_unique()) {} + +QUICStream::QUICStream(std::int32_t streamHandle, ClientQUIC* conn, + bool canSendArg, bool canReceiveArg) + : canSend(canSendArg), canReceive(canReceiveArg), + impl(std::make_unique()) +{ + connection = conn; + impl->state.handle = streamHandle; + impl->state.connection = conn; + Streams()[streamHandle] = &impl->state; +} + +QUICStream::QUICStream(QUICStream&&) noexcept = default; +QUICStream& QUICStream::operator=(QUICStream&&) noexcept = default; + +QUICStream::~QUICStream() { + if (impl && impl->state.handle != 0) { + Streams().erase(impl->state.handle); + Crafter::NetworkBrowserBindings::crafterNetworkWtStreamStop(impl->state.handle); + } +} + +void QUICStream::SendAsync(const void* buffer, std::uint32_t size, bool finish, + std::function onSent) { + if (!impl || impl->state.handle == 0 || impl->state.closed) { + if (onSent) onSent(); + return; + } + std::int32_t cbId = 0; + if (onSent) { + cbId = NextWriteCallbackId(); + WriteCallbacks()[cbId] = std::move(onSent); + } + Crafter::NetworkBrowserBindings::crafterNetworkWtStreamWrite( + impl->state.handle, + static_cast(buffer), + static_cast(size), + finish ? 1 : 0, + cbId); +} + +void QUICStream::RecieveAsync(std::function)> cb) { + if (!impl) { if (cb) cb({}); return; } + impl->state.mode = RecvMode::Once; + impl->state.cb = std::move(cb); + TryDispatchRecv(impl->state); +} + +void QUICStream::RecieveUntilCloseAsync(std::function)> cb) { + if (!impl) { if (cb) cb({}); return; } + impl->state.mode = RecvMode::UntilClose; + impl->state.cb = std::move(cb); + TryDispatchRecv(impl->state); +} + +void QUICStream::RecieveUntilFullAsync(std::uint32_t bufferSize, + std::function)> cb) { + if (!impl) { if (cb) cb({}); return; } + impl->state.mode = RecvMode::UntilFull; + impl->state.target = bufferSize; + impl->state.cb = std::move(cb); + TryDispatchRecv(impl->state); +} + +void QUICStream::Stop() { + if (impl && impl->state.handle != 0) { + Crafter::NetworkBrowserBindings::crafterNetworkWtStreamStop(impl->state.handle); + Streams().erase(impl->state.handle); + impl->state.handle = 0; + impl->state.closed = true; + } +} + +// ─── ClientQUIC::Impl ──────────────────────────────────────────────────── + +struct ClientQUIC::Impl { + ConnectionState state; +}; + +namespace { + QUICStream MakeStreamFromHandle(std::int32_t streamId, ClientQUIC* conn, + bool canSend, bool canReceive) { + return QUICStream{streamId, conn, canSend, canReceive}; + } +} + +ClientQUIC::ClientQUIC(const char* host, std::uint16_t port, std::string alpnArg, + QUICClientCredentials creds) + : alpn(std::move(alpnArg)), impl(std::make_unique()) { + // Zeroed hash means "no pinning" — JS passes an empty array, browser + // falls back to its trust store. A non-zero hash is forwarded as a + // serverCertificateHashes entry (Chrome only, < 14 day cert validity). + const std::uint8_t* hashPtr = nullptr; + std::int32_t hashLen = 0; + for (std::uint8_t b : creds.serverCertificateHash) { + if (b != 0) { hashPtr = creds.serverCertificateHash.data(); hashLen = 32; break; } + } + std::string hostStr = host; + impl->state.handle = Crafter::NetworkBrowserBindings::crafterNetworkWtConnect( + hostStr.data(), static_cast(hostStr.size()), + static_cast(port), + alpn.data(), static_cast(alpn.size()), + hashPtr, hashLen); + // wasm builds run with -fno-exceptions; a failed JS-side allocation + // leaves the connection in a closed state and the first operation will + // produce a sentinel result (OpenStream returns a default-constructed + // QUICStream, SendDatagram silently drops, OnStream/OnDatagram never + // fire). The constructor cannot signal the failure synchronously. + if (impl->state.handle == 0) { + impl->state.closed = true; + } else { + Connections()[impl->state.handle] = &impl->state; + } +} + +ClientQUIC::ClientQUIC(std::string host, std::uint16_t port, std::string alpnArg, + QUICClientCredentials creds) + : ClientQUIC(host.c_str(), port, std::move(alpnArg), std::move(creds)) {} + +ClientQUIC::ClientQUIC(ClientQUIC&&) noexcept = default; + +ClientQUIC::~ClientQUIC() { + if (impl && impl->state.handle != 0) { + Connections().erase(impl->state.handle); + Crafter::NetworkBrowserBindings::crafterNetworkWtClose(impl->state.handle); + } +} + +QUICStream ClientQUIC::OpenStream(bool unidirectional) { + if (!impl || impl->state.handle == 0 || impl->state.closed) { + return QUICStream{}; // default-constructed: closed sentinel + } + std::int32_t streamId = Crafter::NetworkBrowserBindings::crafterNetworkWtOpenStream( + impl->state.handle, unidirectional ? 1 : 0); + if (streamId == 0) { + return QUICStream{}; + } + return MakeStreamFromHandle(streamId, this, + /*canSend=*/true, + /*canReceive=*/!unidirectional); +} + +void ClientQUIC::SendDatagram(const void* buffer, std::uint32_t size) { + if (!impl || impl->state.handle == 0 || impl->state.closed) return; + Crafter::NetworkBrowserBindings::crafterNetworkWtSendDatagram( + impl->state.handle, + static_cast(buffer), + static_cast(size)); +} + +void ClientQUIC::OnStream(std::function cb) { + if (impl) impl->state.onStream = std::move(cb); +} + +void ClientQUIC::OnDatagram(std::function)> cb) { + if (impl) impl->state.onDatagram = std::move(cb); +} + +void ClientQUIC::Stop() { + if (impl && impl->state.handle != 0) { + Crafter::NetworkBrowserBindings::crafterNetworkWtClose(impl->state.handle); + Connections().erase(impl->state.handle); + impl->state.handle = 0; + impl->state.closed = true; + } +} + +// ─── WASM exports the JS bridge dispatches back through ────────────────── + +extern "C" { + __attribute__((export_name("CrafterNetworkOnWtReady"))) + void CrafterNetworkOnWtReady(std::int32_t connectionHandle) { + auto it = Connections().find(connectionHandle); + if (it == Connections().end()) return; + it->second->ready = true; + } + + __attribute__((export_name("CrafterNetworkOnWtClosed"))) + void CrafterNetworkOnWtClosed(std::int32_t connectionHandle, + char* messagePtr, std::int32_t /*messageLen*/) { + std::free(messagePtr); + auto it = Connections().find(connectionHandle); + if (it == Connections().end()) return; + it->second->closed = true; + it->second->ready = false; + // The JS bridge dispatches CrafterNetworkOnWtStreamChunk(_, nullptr, 0, + // fin=1) for each stream that belonged to this connection so pending + // receivers terminate. We don't iterate stream state here. + } + + __attribute__((export_name("CrafterNetworkOnWtIncomingStream"))) + void CrafterNetworkOnWtIncomingStream(std::int32_t connectionHandle, + std::int32_t streamId, + std::int32_t bidirectional) { + auto it = Connections().find(connectionHandle); + if (it == Connections().end() || !it->second->onStream) { + // No registered handler — close the stream JS-side to free + // resources. Mirrors what the native msquic backend does + // when a stream arrives before OnStream is registered (it + // queues, but in the browser we don't have a backing buffer + // to queue against without leaking). + Crafter::NetworkBrowserBindings::crafterNetworkWtStreamStop(streamId); + return; + } + QUICStream stream = MakeStreamFromHandle(streamId, /*conn=*/nullptr, + /*canSend=*/bidirectional != 0, + /*canReceive=*/true); + it->second->onStream(std::move(stream)); + } + + __attribute__((export_name("CrafterNetworkOnWtStreamChunk"))) + void CrafterNetworkOnWtStreamChunk(std::int32_t streamId, + char* dataPtr, std::int32_t dataLen, + std::int32_t fin) { + auto it = Streams().find(streamId); + if (it == Streams().end()) { std::free(dataPtr); return; } + StreamState& s = *it->second; + if (dataPtr && dataLen > 0) { + s.buffer.insert(s.buffer.end(), dataPtr, dataPtr + dataLen); + } + std::free(dataPtr); + if (fin) s.finReceived = true; + TryDispatchRecv(s); + } + + __attribute__((export_name("CrafterNetworkOnWtStreamWriteComplete"))) + void CrafterNetworkOnWtStreamWriteComplete(std::int32_t callbackId) { + auto it = WriteCallbacks().find(callbackId); + if (it == WriteCallbacks().end()) return; + auto cb = std::move(it->second); + WriteCallbacks().erase(it); + if (cb) cb(); + } + + __attribute__((export_name("CrafterNetworkOnWtDatagram"))) + void CrafterNetworkOnWtDatagram(std::int32_t connectionHandle, + char* dataPtr, std::int32_t dataLen) { + auto it = Connections().find(connectionHandle); + if (it == Connections().end() || !it->second->onDatagram) { + std::free(dataPtr); + return; + } + std::vector data(dataPtr, dataPtr + dataLen); + std::free(dataPtr); + it->second->onDatagram(std::move(data)); + } +} diff --git a/implementations/Crafter.Network-ClientQUIC.cpp b/implementations/Crafter.Network-ClientQUIC.cpp index 7b16d68..0f71494 100644 --- a/implementations/Crafter.Network-ClientQUIC.cpp +++ b/implementations/Crafter.Network-ClientQUIC.cpp @@ -280,6 +280,18 @@ std::vector QUICStream::RecieveUntilFullSync(std::uint32_t bufferSize) { return out; } +void QUICStream::SendAsync(const void* buffer, std::uint32_t size, bool finish, + std::function onSent) { + // Copy now: the caller's buffer may not outlive the enqueued task. + std::vector copy(static_cast(buffer), + static_cast(buffer) + size); + ThreadPool::Enqueue([this, copy = std::move(copy), finish, onSent = std::move(onSent)]() mutable { + try { this->SendSync(copy.data(), static_cast(copy.size()), finish); } + catch (...) { /* swallowed — callback still fires so the caller can move on */ } + if (onSent) onSent(); + }); +} + void QUICStream::RecieveAsync(std::function)> cb) { ThreadPool::Enqueue([this, cb]{ cb(this->RecieveSync()); }); } @@ -290,6 +302,27 @@ void QUICStream::RecieveUntilFullAsync(std::uint32_t bufferSize, std::functionRecieveUntilFullSync(bufferSize)); }); } +void QUICStream::PrependReceived(std::vector bytes) { + if (bytes.empty() || !impl) return; + { + std::lock_guard lk(impl->mtx); + impl->pending.push_front(std::move(bytes)); + } + impl->cv.notify_all(); +} + +std::uint64_t QUICStream::GetStreamId() const { + if (!handle) throw QUICException("GetStreamId: stream is not open"); + QUIC_UINT62 id = 0; + std::uint32_t size = sizeof(id); + QUIC_STATUS s = Runtime().api->GetParam(handle, QUIC_PARAM_STREAM_ID, &size, &id); + if (QUIC_FAILED(s)) { + throw QUICException(std::format("GetParam(QUIC_PARAM_STREAM_ID) failed: 0x{:x}", + static_cast(s))); + } + return static_cast(id); +} + // ---------------- ClientQUIC::Impl ---------------- struct ClientQUIC::Impl { HQUIC connection = nullptr; diff --git a/implementations/Crafter.Network-ListenerHTTP.cpp b/implementations/Crafter.Network-ListenerHTTP.cpp index c1aadb2..6514659 100644 --- a/implementations/Crafter.Network-ListenerHTTP.cpp +++ b/implementations/Crafter.Network-ListenerHTTP.cpp @@ -26,6 +26,7 @@ import :ListenerQUIC; import :ClientQUIC; import :HTTP; import :HTTP3; +import :WebTransport; import Crafter.Thread; import std; @@ -65,7 +66,10 @@ namespace { else if (name == ":authority") request.authority = std::move(value); else if (name == ":path") request.path = std::move(value); else if (!name.empty() && name[0] == ':') { - // Unknown request pseudo-header — ignore. + // Pass through other pseudo-headers (e.g. :protocol + // for extended CONNECT — RFC 8441 / RFC 9220) as + // regular headers so the routing layer can see them. + request.headers.emplace(std::move(name), std::move(value)); } else { request.headers.emplace(std::move(name), std::move(value)); } @@ -109,13 +113,49 @@ namespace { } return wire; } + + // Read enough bytes from `stream` to decode one QUIC varint starting at + // `buffer[offset]`. Appends consumed chunks to `buffer` and advances + // `offset` past the varint. Returns the decoded value. Throws + // QUICClosedException if the peer closes before the varint is complete. + std::uint64_t ReadVarintFromStream(QUICStream& stream, + std::vector& buffer, + std::size_t& offset) { + std::uint64_t value = 0; + std::size_t consumed = 0; + while (true) { + const auto* p = reinterpret_cast(buffer.data() + offset); + if (HTTP3::DecodeVarint(p, buffer.size() - offset, value, consumed)) { + offset += consumed; + return value; + } + auto chunk = stream.RecieveSync(); // throws QUICClosed on FIN-without-data + buffer.insert(buffer.end(), chunk.begin(), chunk.end()); + } + } } -// Per-peer state for an accepted connection. Holds the connection wrapper -// and the server-side control stream alive for the lifetime of the peer. +// One accepted-CONNECT WebTransport session living inside a peer. +struct WTSessionEntry { + std::unique_ptr session; +}; + +// Per-peer state for an accepted connection. Holds the connection wrapper, +// the server-side control stream, and the WebTransport sessions that have +// been upgraded on this connection. struct PeerState { std::unique_ptr quic; QUICStream controlStream; + // QPACK encoder/decoder streams; opened immediately after the control + // stream and never written to again (we run without a dynamic table). + // Their presence is what lets strict HTTP/3 stacks like Chromium decide + // the peer is ready for request streams. + QUICStream qpackEncoderStream; + QUICStream qpackDecoderStream; + std::mutex wtMtx; + // Session id == CONNECT stream's QUIC stream id, supplied by the peer + // as the first varint of every WT data stream. + std::unordered_map> wtSessions; }; struct ListenerHTTP::Impl { @@ -125,40 +165,147 @@ struct ListenerHTTP::Impl { bool running = true; }; -ListenerHTTP::ListenerHTTP(std::uint16_t port, - QUICServerCredentials creds, - std::unordered_map> r) - : routes(std::move(r)) - , alpn(HTTP3::kAlpn) - , impl(std::make_unique()) -{ - // The connect callback wires up an OnStream handler that splits unidi - // streams (control / QPACK) from bidi streams (request streams) and - // sends our own SETTINGS frame on a freshly-opened control stream. - auto onConnect = [this](ClientQUIC* peer) { - auto state = std::make_unique(); - state->quic.reset(peer); - - peer->OnStream([this](QUICStream stream) { - if (!stream.canSend) { - // Peer-initiated unidi: client's control stream + optional - // QPACK encoder/decoder streams. Drain — we honour SETTINGS - // by accepting defaults, and we don't track QPACK dynamic- - // table mutations because we don't use the dynamic table. - try { - while (true) (void)stream.RecieveSync(); - } catch (...) {} - return; - } - - // Bidi stream: a request. Drive a single request/response cycle. +namespace { + // Build the per-connection bidi-stream handler. Demuxes WT streams from + // HTTP/3 request streams by peeking the first varint on the wire. Lives + // as a free helper so both ListenerHTTP constructors can install it. + std::function MakeBidiHandler( + ListenerHTTP* self, PeerState* peerState, + const std::unordered_map>* routes, + const std::unordered_map>* wtRoutes) + { + return [self, peerState, routes, wtRoutes](QUICStream stream) { try { - auto raw = stream.RecieveUntilCloseSync(); - HTTPRequest request = ParseRequestFrames(raw); + // ── Phase A: identify the stream kind ───────────────────── + // + // We peek the leading varint(s) off the wire incrementally. + // For HTTP/3 streams that's `frame_type, frame_length`. For + // a WT_STREAM the body runs to FIN so there is no length. + std::vector peeked; + std::size_t cursor = 0; + std::uint64_t firstType = ReadVarintFromStream(stream, peeked, cursor); + + if (firstType == HTTP3::kFrameWtStream && !wtRoutes->empty()) { + // ── WT bidi data stream — second varint is session id. + std::uint64_t sessionId = ReadVarintFromStream(stream, peeked, cursor); + std::vector remaining(peeked.begin() + cursor, peeked.end()); + + WebTransportSession* session = nullptr; + { + std::lock_guard lk(peerState->wtMtx); + auto it = peerState->wtSessions.find(sessionId); + if (it == peerState->wtSessions.end()) return; + session = it->second->session.get(); + } + if (!remaining.empty()) { + stream.PrependReceived(std::move(remaining)); + } + WebTransportDeliverStream(*session, std::move(stream)); + return; + } + + // ── Phase B: HTTP/3 request stream ──────────────────────── + // + // First frame must be HEADERS. Read its length varint, then + // its payload, then look at :method / :protocol. The + // remaining stream content (DATA frames, FIN) is only + // consumed for non-CONNECT requests — WebTransport CONNECT + // never sends a body and the stream stays open for the + // session's lifetime. + std::uint64_t headerLen = ReadVarintFromStream(stream, peeked, cursor); + std::size_t payloadStart = cursor; + while (peeked.size() < payloadStart + headerLen) { + auto chunk = stream.RecieveSync(); + peeked.insert(peeked.end(), chunk.begin(), chunk.end()); + } + + if (firstType != HTTP3::kFrameHeaders) { + // Some other top-level frame — not a valid request. + return; + } + + auto fields = HTTP3::DecodeFieldSection( + reinterpret_cast(peeked.data() + payloadStart), + static_cast(headerLen)); + + HTTPRequest request; + for (auto& [name, value] : fields) { + if (name == ":method") request.method = std::move(value); + else if (name == ":scheme") request.scheme = std::move(value); + else if (name == ":authority") request.authority = std::move(value); + else if (name == ":path") request.path = std::move(value); + else request.headers.emplace(std::move(name), std::move(value)); + } + + // Extended CONNECT? RFC 8441 / draft-ietf-webtrans-http3. + auto protoIt = request.headers.find(":protocol"); + if (request.method == "CONNECT" && protoIt != request.headers.end() + && protoIt->second == "webtransport") + { + auto wtIt = wtRoutes->find(request.path); + if (wtIt == wtRoutes->end()) { + HTTPResponse nf; nf.status = "404"; nf.body = "WebTransport route not found"; + auto wire = SerializeResponse(nf); + try { stream.SendSync(wire.data(), static_cast(wire.size()), true); } catch (...) {} + return; + } + + // Accept: 200 OK HEADERS without FIN. The peer sees the + // session as "ready" once it reads this and keeps the + // CONNECT stream open as the session-control stream. + HTTPResponse ok; ok.status = "200"; + auto wire = SerializeResponse(ok); + stream.SendSync(wire.data(), static_cast(wire.size()), /*finish=*/false); + + std::uint64_t sessionId = stream.GetStreamId(); + + auto entry = std::make_unique(); + entry->session = std::make_unique(); + WebTransportSession* sessionPtr = entry->session.get(); + WebTransportInitialise(*sessionPtr, + peerState->quic.get(), + std::move(stream), + sessionId, + request.path); + { + std::lock_guard lk(peerState->wtMtx); + peerState->wtSessions.emplace(sessionId, std::move(entry)); + } + auto handler = wtIt->second; + ThreadPool::Enqueue([handler, sessionPtr]{ + try { handler(*sessionPtr); } catch (...) {} + }); + return; + } + + // Plain HTTP/3 request. Drain remaining DATA frames + FIN, + // reconstruct the body, dispatch. + std::vector remainingFrames(peeked.begin() + payloadStart + headerLen, peeked.end()); + try { + auto rest = stream.RecieveUntilCloseSync(); + remainingFrames.insert(remainingFrames.end(), rest.begin(), rest.end()); + } catch (...) {} + std::size_t pos = 0; + const auto* p = reinterpret_cast(remainingFrames.data()); + std::size_t avail = remainingFrames.size(); + while (pos < avail) { + std::uint64_t frameType = 0, frameLen = 0; + std::size_t cn = 0; + if (!HTTP3::DecodeVarint(p + pos, avail - pos, frameType, cn)) break; + pos += cn; + if (!HTTP3::DecodeVarint(p + pos, avail - pos, frameLen, cn)) break; + pos += cn; + if (pos + frameLen > avail) break; + if (frameType == HTTP3::kFrameData) { + request.body.append(reinterpret_cast(p + pos), + static_cast(frameLen)); + } + pos += static_cast(frameLen); + } HTTPResponse response; - auto it = routes.find(request.path); - if (it != routes.end()) { + auto it = routes->find(request.path); + if (it != routes->end()) { response = it->second(request); } else { response.status = "404"; @@ -170,8 +317,6 @@ ListenerHTTP::ListenerHTTP(std::uint16_t port, static_cast(wire.size()), /*finish=*/true); } catch (const std::exception& e) { - // Best-effort 500 if we can still send. Stream may already - // be closed; swallow further errors silently. try { HTTPResponse err; err.status = "500"; @@ -182,21 +327,72 @@ ListenerHTTP::ListenerHTTP(std::uint16_t port, /*finish=*/true); } catch (...) {} } + }; + } +} + +ListenerHTTP::ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> r) + : ListenerHTTP(port, std::move(creds), std::move(r), {}) +{} + +ListenerHTTP::ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> r, + std::unordered_map> wt) + : routes(std::move(r)) + , wtRoutes(std::move(wt)) + , alpn(HTTP3::kAlpn) + , impl(std::make_unique()) +{ + auto onConnect = [this](ClientQUIC* peer) { + auto state = std::make_unique(); + state->quic.reset(peer); + PeerState* statePtr = state.get(); + + peer->OnStream([this, statePtr](QUICStream stream) { + if (!stream.canSend) { + // Peer-initiated unidi: client's control stream + optional + // QPACK encoder/decoder streams. Phase 1 drains — peer- + // initiated WT unidi streams are deferred to a later phase. + try { + while (true) (void)stream.RecieveSync(); + } catch (...) {} + return; + } + // Bidi: either HTTP/3 request or WT data stream. Demux inside. + auto handler = MakeBidiHandler(this, statePtr, &this->routes, &this->wtRoutes); + handler(std::move(stream)); }); // Open our outgoing control stream and write the SETTINGS prelude. - // Do this AFTER OnStream is registered so any client-initiated - // unidi stream that races in is handled. The control stream must - // remain open for the connection's lifetime — we never FIN it. + // When wtRoutes is non-empty we advertise WebTransport support so + // the browser will issue extended CONNECT against us. try { state->controlStream = peer->OpenStream(/*unidirectional=*/true); - auto prelude = HTTP3::BuildControlStreamPrelude(); + auto prelude = this->wtRoutes.empty() + ? HTTP3::BuildControlStreamPrelude() + : HTTP3::BuildWebTransportControlStreamPrelude(/*maxSessions=*/16); state->controlStream.SendSync(prelude.data(), static_cast(prelude.size()), /*finish=*/false); + + // QPACK encoder + decoder streams (RFC 9204 §5). We don't use the + // dynamic table, so these streams stay idle for the lifetime of + // the connection. They're not optional in practice: Chromium and + // some other HTTP/3 stacks won't issue any request stream until + // they've seen both stream types from the peer, even when the + // encoder is silent. + state->qpackEncoderStream = peer->OpenStream(/*unidirectional=*/true); + std::uint8_t encType = static_cast(HTTP3::kStreamQpackEnc); + state->qpackEncoderStream.SendSync(&encType, 1, /*finish=*/false); + + state->qpackDecoderStream = peer->OpenStream(/*unidirectional=*/true); + std::uint8_t decType = static_cast(HTTP3::kStreamQpackDec); + state->qpackDecoderStream.SendSync(&decType, 1, /*finish=*/false); } catch (...) { - // If the connection died mid-handshake we land here; the peer - // gets dropped via destruction below. + // Connection died mid-handshake; drop the peer. } std::lock_guard lk(impl->peersMtx); @@ -223,9 +419,6 @@ void ListenerHTTP::Stop() { void ListenerHTTP::Listen() { if (!impl || !impl->listener) return; - // ListenSyncAsync runs the accept loop on this thread and dispatches the - // per-connection callback (control-stream open + OnStream wiring) on the - // ThreadPool. That keeps route handlers off the accept thread. impl->listener->ListenSyncAsync(); } @@ -236,6 +429,14 @@ ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port, , thread(&ListenerHTTP::Listen, &listener) {} +ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::unordered_map> wtRoutes) + : listener(port, std::move(creds), std::move(routes), std::move(wtRoutes)) + , thread(&ListenerHTTP::Listen, &listener) +{} + ListenerAsyncHTTP::~ListenerAsyncHTTP() { Stop(); } diff --git a/implementations/Crafter.Network-ListenerQUIC.cpp b/implementations/Crafter.Network-ListenerQUIC.cpp index 022b7b9..c9e0c9f 100644 --- a/implementations/Crafter.Network-ListenerQUIC.cpp +++ b/implementations/Crafter.Network-ListenerQUIC.cpp @@ -21,6 +21,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA module; #include #include +#include #include #include module Crafter.Network:ListenerQUIC_impl; @@ -84,6 +85,12 @@ namespace { // mkdtemp'd directory under /tmp for the lifetime of the process. // Intended for dev / LAN play / tests — production should pass real // cert/key paths. + // + // Cert shape: ECDSA P-256, validity 13 days, SAN={DNS:localhost, + // IP:127.0.0.1, IP:::1}. These constraints are mandated by Chromium's + // WebTransport `serverCertificateHashes` (must be ECDSA P-256, validity + // <14 days, SAN with the connect target). msquic accepts the same cert + // unchanged, so existing pure-QUIC callers are unaffected. struct SelfSignedCert { std::string certPath; std::string keyPath; @@ -94,24 +101,62 @@ namespace { std::lock_guard lk(mtx); if (cached) return *cached; - char tmpl[] = "/tmp/crafter-quic-cert-XXXXXX"; - if (mkdtemp(tmpl) == nullptr) { - throw QUICException("mkdtemp failed for self-signed cert dir"); - } - std::string dir = tmpl; - SelfSignedCert s; - s.keyPath = dir + "/key.pem"; - s.certPath = dir + "/cert.pem"; - std::string cmd = std::format( - "openssl req -x509 -newkey rsa:2048 -keyout '{}' -out '{}' " - "-days 1 -nodes -subj '/CN=localhost' >/dev/null 2>&1", - s.keyPath, s.certPath); - int rc = std::system(cmd.c_str()); - if (rc != 0) { + // Stable on-disk location so the cert (and therefore its SHA-256) is + // reused across server restarts. Without this, a browser peer that + // pinned the cert hash on a previous run would see a hash mismatch + // the moment we restart. We only regenerate if the cert file is + // missing or has expired. + std::filesystem::path dir = "/tmp/crafter-network-quic-cert"; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) { throw QUICException(std::format( - "openssl CLI failed to generate self-signed cert " - "(exit {}); install openssl or pass certPath/keyPath", - rc)); + "could not create cert cache dir {}: {}", dir.string(), ec.message())); + } + SelfSignedCert s; + s.keyPath = (dir / "key.pem").string(); + s.certPath = (dir / "cert.pem").string(); + + bool needRegen = !std::filesystem::exists(s.certPath, ec) + || !std::filesystem::exists(s.keyPath, ec); + if (!needRegen) { + // Use openssl to ask whether the cert is still valid. -checkend 0 + // returns 0 if the cert is still good, non-zero if expired. + int rc = std::system(std::format( + "openssl x509 -in '{}' -noout -checkend 0 >/dev/null 2>&1", + s.certPath).c_str()); + if (rc != 0) needRegen = true; + } + if (needRegen) { + // Inline openssl config so we get exactly the extensions Chromium's + // WebTransport cert-hash verifier accepts (BasicConstraints CA:FALSE, + // KeyUsage digitalSignature, EKU serverAuth, SAN). Skipping the + // implicit subjectKeyIdentifier / authorityKeyIdentifier that + // `openssl req -x509 -addext ...` would otherwise add. + std::string cmd = std::format( + "openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 " + "-keyout '{}' -out '{}' -days 10 -nodes " + "-extensions v3_wt " + "-config /dev/stdin >/dev/null 2>&1 <<'CONFIG'\n" + "[req]\n" + "distinguished_name = req_dn\n" + "prompt = no\n" + "[req_dn]\n" + "CN = localhost\n" + "[v3_wt]\n" + "basicConstraints = critical, CA:FALSE\n" + "keyUsage = critical, digitalSignature\n" + "extendedKeyUsage = serverAuth\n" + "subjectAltName = DNS:localhost, IP:127.0.0.1, IP:::1\n" + "CONFIG\n", + s.keyPath, s.certPath); + int rc = std::system(cmd.c_str()); + if (rc != 0) { + throw QUICException(std::format( + "openssl CLI failed to generate self-signed cert " + "(exit {}); install openssl or pass certPath/keyPath", + rc)); + } } cached = std::move(s); return *cached; @@ -329,3 +374,53 @@ void ListenerQUIC::ListenAsyncSync() { void ListenerQUIC::ListenAsyncAsync() { impl->acceptLoop = std::thread([this]{ ListenSyncAsync(); }); } + +std::array Crafter::ComputeCertificateHashSHA256(const std::string& certPath) { + // Convert PEM → DER → SHA-256 via openssl, capture hex digest from stdout. + // openssl pipes give us "SHA2-256(stdin)= \n"; we parse the trailing + // hex run. Shelling out keeps msquic the sole TLS dependency. + std::string cmd = std::format( + "openssl x509 -in '{}' -outform der | openssl dgst -sha256 2>/dev/null", + certPath); + FILE* p = popen(cmd.c_str(), "r"); + if (!p) throw QUICException("popen failed while computing cert hash"); + std::string out; + char chunk[256]; + while (auto n = std::fread(chunk, 1, sizeof(chunk), p)) { + out.append(chunk, n); + } + int rc = pclose(p); + if (rc != 0) { + throw QUICException(std::format( + "openssl failed to compute SHA-256 of {} (exit {})", certPath, rc)); + } + auto eq = out.rfind('='); + if (eq == std::string::npos) { + throw QUICException("could not parse openssl dgst output"); + } + std::string hex; + for (std::size_t i = eq + 1; i < out.size(); ++i) { + char c = out[i]; + if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + hex.push_back(c); + } + } + if (hex.size() != 64) { + throw QUICException(std::format( + "unexpected SHA-256 hex length {} (expected 64)", hex.size())); + } + std::array result{}; + for (std::size_t i = 0; i < 32; ++i) { + auto nibble = [](char c) -> std::uint8_t { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + return 10 + (c - 'A'); + }; + result[i] = (nibble(hex[2 * i]) << 4) | nibble(hex[2 * i + 1]); + } + return result; +} + +std::string Crafter::GetSelfSignedCertificatePath() { + return GetSelfSignedCert().certPath; +} diff --git a/implementations/Crafter.Network-WebTransport.cpp b/implementations/Crafter.Network-WebTransport.cpp new file mode 100644 index 0000000..c002ddf --- /dev/null +++ b/implementations/Crafter.Network-WebTransport.cpp @@ -0,0 +1,152 @@ +/* +Crafter®.Network +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +module; +module Crafter.Network:WebTransport_impl; +import :WebTransport; +import :ClientQUIC; +import :HTTP3; +import Crafter.Thread; +import std; + +using namespace Crafter; + +struct WebTransportSession::Impl { + // Non-owning. The session is constructed and owned by ListenerHTTP, and + // ListenerHTTP keeps the underlying ClientQUIC alive in its PeerState + // for the duration of the connection — so this pointer is valid as + // long as the session is. + ClientQUIC* connection = nullptr; + + // The HTTP/3 extended-CONNECT bidi stream the session was upgraded on. + // Stays open for the session's lifetime. Phase 1 closes it with a bare + // FIN; later phases will emit a CLOSE_WEBTRANSPORT_SESSION capsule. + QUICStream connectStream; + + std::mutex mtx; + std::function onStream; + std::deque pendingStreams; + std::function)> onDatagram; // Phase 2 + + bool closed = false; +}; + +WebTransportSession::WebTransportSession() + : impl(std::make_unique()) +{} + +WebTransportSession::WebTransportSession(WebTransportSession&&) noexcept = default; +WebTransportSession& WebTransportSession::operator=(WebTransportSession&&) noexcept = default; + +WebTransportSession::~WebTransportSession() { + if (impl) Close(); +} + +QUICStream WebTransportSession::OpenStream(bool unidirectional) { + if (!impl || impl->closed || !impl->connection) { + throw QUICClosedException(); + } + QUICStream stream = impl->connection->OpenStream(unidirectional); + auto prefix = unidirectional + ? HTTP3::BuildWtUnidiPrefix(sessionId) + : HTTP3::BuildWtBidiPrefix(sessionId); + // Write the WT_STREAM (bidi) or stream-type (unidi) prefix as the + // first send on the stream. The peer reads it to associate this + // stream with our session before treating the rest as opaque payload. + stream.SendSync(prefix.data(), static_cast(prefix.size()), /*finish=*/false); + return stream; +} + +void WebTransportSession::OnStream(std::function callback) { + std::deque drained; + { + std::lock_guard lk(impl->mtx); + impl->onStream = callback; + drained.swap(impl->pendingStreams); + } + // Dispatch any streams that arrived before the handler was installed. + // Each goes to the ThreadPool so user code runs off the demuxer thread. + for (auto& s : drained) { + auto* shared = new QUICStream(std::move(s)); + ThreadPool::Enqueue([callback, shared]{ + callback(std::move(*shared)); + delete shared; + }); + } +} + +void WebTransportSession::OnDatagram(std::function)> callback) { + // Phase 1 stub. Phase 2 will plumb QUIC datagrams through here after + // demuxing on quarter_session_id. + if (impl) impl->onDatagram = std::move(callback); +} + +void WebTransportSession::SendDatagram(const void*, std::uint32_t) { + // Phase 1 stub — would prepend quarter_session_id varint and call + // connection->SendDatagram. Drops silently for now. +} + +void WebTransportSession::Close() { + if (!impl || impl->closed) return; + impl->closed = true; + try { + // Empty FIN on the CONNECT stream. Chrome / Firefox both treat + // peer-FIN of the CONNECT stream as session-close. + impl->connectStream.SendSync(nullptr, 0, /*finish=*/true); + } catch (...) { + // Connection may already be gone — that's fine. + } +} + +// ─── Internal ListenerHTTP-facing helpers ─────────────────────────────── +// +// Declared (not exported) in the interface partition so ListenerHTTP_impl +// can call them; defined here. Friendship in WebTransportSession gives +// them access to the private Impl. + +namespace Crafter { + void WebTransportInitialise(WebTransportSession& session, + ClientQUIC* connection, + QUICStream connectStream, + std::uint64_t sessionId, + std::string path) { + session.impl->connection = connection; + session.impl->connectStream = std::move(connectStream); + session.sessionId = sessionId; + session.path = std::move(path); + } + + void WebTransportDeliverStream(WebTransportSession& session, QUICStream stream) { + std::function cb; + { + std::lock_guard lk(session.impl->mtx); + cb = session.impl->onStream; + if (!cb) { + session.impl->pendingStreams.push_back(std::move(stream)); + return; + } + } + auto* shared = new QUICStream(std::move(stream)); + ThreadPool::Enqueue([cb, shared]{ + cb(std::move(*shared)); + delete shared; + }); + } +} diff --git a/interfaces/Crafter.Network-ClientHTTP.cppm b/interfaces/Crafter.Network-ClientHTTP.cppm index 1ce2793..c75cf23 100644 --- a/interfaces/Crafter.Network-ClientHTTP.cppm +++ b/interfaces/Crafter.Network-ClientHTTP.cppm @@ -32,6 +32,12 @@ namespace Crafter { // // For local development against a self-signed listener, pass // QUICClientCredentials{insecureNoServerValidation = true}. + // + // Browser build: the request is dispatched via the browser's fetch() + // and the synchronous Send() is not compiled — use SendAsync instead. + // The ClientHTTP instance does not maintain a persistent connection + // (fetch is request-scoped); host and port are stored and prefixed to + // the request path on each call. QUICClientCredentials is ignored. export class ClientHTTP { public: std::string host; @@ -44,8 +50,18 @@ namespace Crafter { ClientHTTP(const ClientHTTP&) = delete; ClientHTTP(ClientHTTP&&) noexcept; +#ifndef CRAFTER_NETWORK_BROWSER // Send a request and synchronously read back the full response. HTTPResponse Send(const HTTPRequest& request); +#endif + + // Send a request and deliver the response (or an error) via callback. + // Available on both native and browser builds. Native dispatches on + // Crafter.Thread's ThreadPool; browser uses fetch() and resolves on + // the JS event loop. + void SendAsync(const HTTPRequest& request, + std::function onSuccess, + std::function onError); private: struct Impl; diff --git a/interfaces/Crafter.Network-ClientQUIC.cppm b/interfaces/Crafter.Network-ClientQUIC.cppm index 713d22a..1ad567e 100644 --- a/interfaces/Crafter.Network-ClientQUIC.cppm +++ b/interfaces/Crafter.Network-ClientQUIC.cppm @@ -19,7 +19,9 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ module; +#ifndef CRAFTER_NETWORK_BROWSER #include +#endif export module Crafter.Network:ClientQUIC; import std; @@ -45,9 +47,16 @@ namespace Crafter { }; // Client-side credential validation. By default we require a real cert. - // insecureNoServerValidation disables peer cert checks — only for dev. + // insecureNoServerValidation disables peer cert checks — only for dev, + // and silently ignored in the browser build (browsers enforce their own + // certificate policy). For browser dev against a self-signed listener, + // populate serverCertificateHash with the SHA-256 of the server's DER + // certificate; on the browser it is forwarded to WebTransport's + // serverCertificateHashes option. A zeroed array means "unused" — the + // browser will then require a publicly trusted cert. export struct QUICClientCredentials { bool insecureNoServerValidation = false; + std::array serverCertificateHash{}; }; export class ClientQUIC; @@ -60,8 +69,10 @@ namespace Crafter { // for inbound streams initiated by the peer. export class QUICStream { public: +#ifndef CRAFTER_NETWORK_BROWSER // Underlying msquic HQUIC handle. Treated as opaque by callers. HQUIC handle = nullptr; +#endif // The connection that owns this stream (non-owning). ClientQUIC* connection = nullptr; @@ -72,12 +83,22 @@ namespace Crafter { bool canReceive = true; QUICStream(); +#ifndef CRAFTER_NETWORK_BROWSER QUICStream(HQUIC handle, ClientQUIC* connection); +#else + // Browser-only constructor: wraps a JS-side WebTransport stream + // identified by its integer handle. Used by ClientQUIC::OpenStream + // and by the incoming-stream dispatcher in the JS bridge — not + // intended for direct use. + QUICStream(std::int32_t handle, ClientQUIC* connection, + bool canSend, bool canReceive); +#endif ~QUICStream(); QUICStream(const QUICStream&) = delete; QUICStream(QUICStream&&) noexcept; QUICStream& operator=(QUICStream&&) noexcept; +#ifndef CRAFTER_NETWORK_BROWSER // Send a buffer. If finish=true, the send-side of the stream is closed // after the buffer is delivered (peer will see graceful shutdown). // Blocks until msquic accepts the buffer; throws on stream/conn close. @@ -93,12 +114,36 @@ namespace Crafter { // Read exactly bufferSize bytes; throws if the peer closes early. std::vector RecieveUntilFullSync(std::uint32_t bufferSize); +#endif - // Async variants: dispatched on Crafter.Thread's ThreadPool. + // Send a buffer. If finish=true, the send-side is closed after the + // buffer is delivered. onSent fires once the transport has accepted + // the buffer (native) or the WritableStream writer has resolved + // (browser). Available on both native and browser builds. + void SendAsync(const void* buffer, std::uint32_t size, bool finish, + std::function onSent); + + // Async receive variants. Dispatched on Crafter.Thread's ThreadPool + // (native) or driven by a per-stream JS reader loop (browser). void RecieveAsync(std::function)> recieveCallback); void RecieveUntilCloseAsync(std::function)> recieveCallback); void RecieveUntilFullAsync(std::uint32_t bufferSize, std::function)> recieveCallback); +#ifndef CRAFTER_NETWORK_BROWSER + // Advanced: re-inject already-consumed bytes at the front of the + // receive queue so the next Recieve* call sees them. Used by + // protocol demuxers (e.g. the WebTransport stream router in + // ListenerHTTP) that need to peek a prefix off the wire, then hand + // the stream to user code as if the prefix had never been read. + void PrependReceived(std::vector bytes); + + // Underlying QUIC stream id. Stable for the stream's lifetime. + // Browsers identify WT streams by the session's CONNECT stream id, + // so the server has to query and remember it at session creation + // time. Throws if the stream is not yet started. + std::uint64_t GetStreamId() const; +#endif + // Cleanly shut down the stream (both directions). void Stop(); @@ -109,8 +154,9 @@ namespace Crafter { }; // A QUIC connection. On the client side, constructing one initiates the - // handshake and blocks until it succeeds (or throws on failure). On the - // server side, ListenerQUIC instantiates these for accepted peers. + // handshake and (on native) blocks until it succeeds, or throws on + // failure. On the server side, ListenerQUIC instantiates these for + // accepted peers. // // A connection multiplexes: // - Reliable, ordered streams (open via OpenStream() / observe inbound @@ -120,10 +166,28 @@ namespace Crafter { // Lifetime: ~ClientQUIC closes the connection. Streams obtained from // OpenStream() are scoped to the connection and must be destroyed (or // moved out) before the ClientQUIC. + // + // Browser build: the only QUIC-shaped API the browser exposes is + // WebTransport, which is HTTP/3-based and reached at a fixed URL. Here: + // - The constructor returns immediately; the connection is opened in + // the background. Operations issued before the connection is ready + // are queued JS-side until WebTransport's "ready" promise resolves + // (or fail with QUICClosedException if the connection rejects). + // - `alpn` is mapped to the URL path: new WebTransport( + // `https://${host}:${port}/${alpn}`). The QUIC-layer ALPN itself + // is fixed to "h3" by the browser and cannot be customised. + // - The server side must accept WebTransport sessions (HTTP/3 extended + // CONNECT) on the path equal to `alpn`. Plain QUIC with a custom + // ALPN — what ListenerQUIC offers today — is not reachable from a + // browser. + // - Synchronous send/receive methods are not compiled. Use the *Async + // variants instead. export class ClientQUIC { public: // ALPN identifier exchanged in the handshake. Both peers must agree. // For 3DForts use e.g. "f3d/1" or similar — a short stable token. + // On the browser build, this is the WebTransport URL path instead + // of an ALPN token; see the class comment above. std::string alpn; // Client constructor: connects to host:port using QUIC. ALPN must @@ -133,10 +197,12 @@ namespace Crafter { ClientQUIC(std::string host, std::uint16_t port, std::string alpn, QUICClientCredentials creds = {}); +#ifndef CRAFTER_NETWORK_BROWSER // Server-side constructor used by ListenerQUIC for accepted peers. // Takes ownership of an already-accepted msquic connection handle // and the server configuration handle. Not intended for direct use. ClientQUIC(HQUIC connectionHandle, HQUIC serverConfiguration, std::string alpn); +#endif ~ClientQUIC(); ClientQUIC(const ClientQUIC&) = delete; @@ -162,15 +228,19 @@ namespace Crafter { // msquic worker; copy/queue and return promptly. void OnDatagram(std::function)> callback); +#ifndef CRAFTER_NETWORK_BROWSER // Block the caller until the next datagram arrives; returns it. // Throws QUICClosedException if the connection closes first. std::vector RecieveDatagramSync(); +#endif // Cleanly shut down the connection. void Stop(); +#ifndef CRAFTER_NETWORK_BROWSER // Underlying handle for advanced use (parameter queries, etc.). HQUIC GetHandle() const; +#endif private: struct Impl; diff --git a/interfaces/Crafter.Network-ClientTCP.cppm b/interfaces/Crafter.Network-ClientTCP.cppm index efca771..c020945 100755 --- a/interfaces/Crafter.Network-ClientTCP.cppm +++ b/interfaces/Crafter.Network-ClientTCP.cppm @@ -18,6 +18,7 @@ License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ module; +#ifndef CRAFTER_NETWORK_BROWSER #include #include #include @@ -31,9 +32,11 @@ module; #include #include #include +#endif export module Crafter.Network:ClientTCP; import std; +#ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { export class SocketClosedException : public std::exception { public: @@ -68,4 +71,5 @@ namespace Crafter { hostent* host; sockaddr_in serv_addr; }; -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/interfaces/Crafter.Network-HTTP3.cppm b/interfaces/Crafter.Network-HTTP3.cppm index bc0615c..07b01d5 100644 --- a/interfaces/Crafter.Network-HTTP3.cppm +++ b/interfaces/Crafter.Network-HTTP3.cppm @@ -43,11 +43,33 @@ namespace Crafter::HTTP3 { export inline constexpr std::uint64_t kFrameData = 0x00; export inline constexpr std::uint64_t kFrameHeaders = 0x01; export inline constexpr std::uint64_t kFrameSettings = 0x04; + // WebTransport bidirectional stream frame type (draft-ietf-webtrans-http3). + // Distinct from normal HTTP/3 frames — its body is unbounded (runs to FIN) + // rather than length-prefixed, and the first bytes of the body are the + // session id varint. + export inline constexpr std::uint64_t kFrameWtStream = 0x41; // ---------------- Unidirectional stream types (RFC 9114 §6.2) ---------------- export inline constexpr std::uint64_t kStreamControl = 0x00; export inline constexpr std::uint64_t kStreamQpackEnc = 0x02; export inline constexpr std::uint64_t kStreamQpackDec = 0x03; + // WebTransport unidirectional stream type (draft-ietf-webtrans-http3). + // After this varint comes a session id varint, then opaque payload to FIN. + export inline constexpr std::uint64_t kStreamWt = 0x54; + + // ---------------- SETTINGS parameter identifiers ---------------- + // Required to negotiate WebTransport over HTTP/3 + HTTP/3 datagrams. + export inline constexpr std::uint64_t kSettingQpackMaxTableCapacity = 0x01; // RFC 9204 + export inline constexpr std::uint64_t kSettingQpackBlockedStreams = 0x07; // RFC 9204 + export inline constexpr std::uint64_t kSettingEnableConnectProtocol = 0x08; // RFC 9220 + export inline constexpr std::uint64_t kSettingH3Datagram = 0x33; // RFC 9297 + // Legacy identifiers from older WebTransport / H3-DATAGRAM drafts. Chrome + // (as of M120-ish) advertises and looks for the draft-02 / draft-04 ids + // alongside the RFC ones; if we only send the modern ids it decides we + // don't support WebTransport and aborts with ERR_METHOD_NOT_SUPPORTED. + export inline constexpr std::uint64_t kSettingH3DatagramDraft04 = 0xffd277; // draft-ietf-masque-h3-datagram-04 + export inline constexpr std::uint64_t kSettingEnableWebTransport = 0x2b603742; // draft-02 boolean + export inline constexpr std::uint64_t kSettingWtMaxSessions = 0xc671706a; // draft-ietf-webtrans-http3 (-07+) // ---------------- Errors ---------------- export class HTTP3ProtocolError : public std::runtime_error { @@ -575,4 +597,55 @@ namespace Crafter::HTTP3 { EncodeVarint(0, out); // frame length 0 return out; } + + // Server-side variant that advertises WebTransport-over-HTTP/3 support + // to the peer. Without these three SETTINGS the browser silently rejects + // the extended CONNECT and the WebTransport.ready promise never resolves. + // `maxSessions` becomes the value of SETTINGS_WT_MAX_SESSIONS. + export inline std::vector BuildWebTransportControlStreamPrelude( + std::uint64_t maxSessions = 1) + { + // Encode the SETTINGS body first so we can write its length. The two + // QPACK settings declare we run with no dynamic table — sent + // explicitly because some HTTP/3 stacks (Chrome among them) refuse + // to consider the peer ready for extended-CONNECT until they have + // seen a baseline QPACK configuration. The draft-02 ENABLE_WEBTRANSPORT + // and draft-04 H3_DATAGRAM ids are sent alongside their RFC counterparts + // for compatibility with current Chrome (which still negotiates the + // draft form even when advertising RFC support). + std::vector body; + EncodeVarint(kSettingQpackMaxTableCapacity, body); EncodeVarint(0, body); + EncodeVarint(kSettingQpackBlockedStreams, body); EncodeVarint(0, body); + EncodeVarint(kSettingEnableConnectProtocol, body); EncodeVarint(1, body); + EncodeVarint(kSettingH3Datagram, body); EncodeVarint(1, body); + EncodeVarint(kSettingH3DatagramDraft04, body); EncodeVarint(1, body); + EncodeVarint(kSettingEnableWebTransport, body); EncodeVarint(1, body); + EncodeVarint(kSettingWtMaxSessions, body); EncodeVarint(maxSessions, body); + + std::vector out; + EncodeVarint(kStreamControl, out); + WriteFrame(out, kFrameSettings, body.data(), body.size()); + return out; + } + + // Prefix bytes that go on the front of an outgoing WT bidi stream — the + // peer reads these to know which session the stream belongs to. After + // this prefix the stream contains opaque WebTransport payload until FIN + // (there is no length field — WT_STREAM is the only HTTP/3 frame whose + // body runs to end-of-stream). + export inline std::vector BuildWtBidiPrefix(std::uint64_t sessionId) { + std::vector out; + EncodeVarint(kFrameWtStream, out); + EncodeVarint(sessionId, out); + return out; + } + + // Prefix bytes that go on the front of an outgoing WT unidi stream + // (server-initiated → client). Stream-type varint then session id. + export inline std::vector BuildWtUnidiPrefix(std::uint64_t sessionId) { + std::vector out; + EncodeVarint(kStreamWt, out); + EncodeVarint(sessionId, out); + return out; + } } diff --git a/interfaces/Crafter.Network-ListenerHTTP.cppm b/interfaces/Crafter.Network-ListenerHTTP.cppm index b86358b..b81d6ab 100644 --- a/interfaces/Crafter.Network-ListenerHTTP.cppm +++ b/interfaces/Crafter.Network-ListenerHTTP.cppm @@ -23,7 +23,9 @@ import std; import :HTTP; import :ListenerQUIC; import :ClientQUIC; +import :WebTransport; +#ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { // HTTP/3 server. Wraps a ListenerQUIC: each accepted QUIC connection // registers a per-stream handler that parses one request, dispatches it @@ -33,6 +35,13 @@ namespace Crafter { // Routes are keyed by `:path` (exact match). Unknown paths return a // synthetic 404. Route handlers run on the ThreadPool — multiple requests // on the same connection can therefore execute concurrently. + // + // WebTransport: pass a non-empty `wtRoutes` to additionally accept + // extended-CONNECT requests (`:method=CONNECT, :protocol=webtransport`) + // whose `:path` matches a registered route. The matching handler runs + // on the ThreadPool with a `WebTransportSession&` argument scoped to + // the session's lifetime. Sending WT-required SETTINGS happens + // automatically when wtRoutes is non-empty. export class ListenerHTTP { public: // The underlying QUIC listener owns the accept loop, certificates, @@ -40,12 +49,20 @@ namespace Crafter { // and owned by this Impl so that move construction/destruction is // straightforward. std::unordered_map> routes; + std::unordered_map> wtRoutes; std::string alpn; ListenerHTTP(std::uint16_t port, QUICServerCredentials creds, std::unordered_map> routes); + // WT-aware overload. `routes` and `wtRoutes` may both be non-empty; + // they are dispatched on disjoint criteria so they don't collide. + ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::unordered_map> wtRoutes); + ~ListenerHTTP(); ListenerHTTP(const ListenerHTTP&) = delete; ListenerHTTP(ListenerHTTP&&) noexcept; @@ -71,7 +88,15 @@ namespace Crafter { ListenerAsyncHTTP(std::uint16_t port, QUICServerCredentials creds, std::unordered_map> routes); + + // WT-aware overload. + ListenerAsyncHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::unordered_map> wtRoutes); + ~ListenerAsyncHTTP(); void Stop(); }; } +#endif diff --git a/interfaces/Crafter.Network-ListenerQUIC.cppm b/interfaces/Crafter.Network-ListenerQUIC.cppm index 49b3bc1..746e47b 100644 --- a/interfaces/Crafter.Network-ListenerQUIC.cppm +++ b/interfaces/Crafter.Network-ListenerQUIC.cppm @@ -19,11 +19,14 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ module; +#ifndef CRAFTER_NETWORK_BROWSER #include +#endif export module Crafter.Network:ListenerQUIC; import std; import :ClientQUIC; +#ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { // Server side of a QUIC connection. Mirrors ListenerTCP in shape: // four Listen* methods covering the sync/async outer-loop x sync/async @@ -72,4 +75,17 @@ namespace Crafter { std::unique_ptr impl; std::uint32_t totalClientCounter = 0; }; + + // Compute the SHA-256 of the DER bytes of a PEM-encoded X.509 certificate. + // Returns the 32-byte digest. Intended for surfacing the self-signed cert + // hash to a browser peer (Chrome's WebTransport requires the client to + // pass this hash via `serverCertificateHashes` when peering against a + // cert that's not in the system trust store). Shells out to openssl. + export std::array ComputeCertificateHashSHA256(const std::string& certPath); + + // Path of the lazily-generated self-signed cert (PEM). Triggers generation + // on first call. Useful for piping into ComputeCertificateHashSHA256 so + // a browser peer can be told the hash to put in `serverCertificateHashes`. + export std::string GetSelfSignedCertificatePath(); } +#endif diff --git a/interfaces/Crafter.Network-ListenerTCP.cppm b/interfaces/Crafter.Network-ListenerTCP.cppm index 007d6e7..6acfd3c 100755 --- a/interfaces/Crafter.Network-ListenerTCP.cppm +++ b/interfaces/Crafter.Network-ListenerTCP.cppm @@ -22,6 +22,7 @@ export module Crafter.Network:ListenerTCP; import std; import :ClientTCP; +#ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { export class ListenerTCP { public: @@ -40,4 +41,5 @@ namespace Crafter { std::uint32_t totalClientCounter = 0; int s; }; -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/interfaces/Crafter.Network-WebTransport.cppm b/interfaces/Crafter.Network-WebTransport.cppm new file mode 100644 index 0000000..9e87e57 --- /dev/null +++ b/interfaces/Crafter.Network-WebTransport.cppm @@ -0,0 +1,105 @@ +/* +Crafter®.Network +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +export module Crafter.Network:WebTransport; +import std; +import :ClientQUIC; + +#ifndef CRAFTER_NETWORK_BROWSER +namespace Crafter { + // Server-side handle to one accepted WebTransport-over-HTTP/3 session. + // Constructed by ListenerHTTP when it receives an extended-CONNECT + // request whose :path matches a registered WT route. Handed to the + // user's route handler as the only argument. + // + // API shape mirrors ClientQUIC so application code can be written once + // and used on either side of the wire — open bidi streams, register an + // OnStream handler for peer-initiated streams, close the session. + // + // Lifetime: the session owns the CONNECT stream that was upgraded. + // Destruction (or explicit Close()) FINs that stream, which the peer + // interprets as session-end. Phase 1 does not emit a CLOSE_WEBTRANSPORT + // _SESSION capsule — bare FIN is sufficient for Chrome / Firefox. + // + // Phase 1 scope: + // - bidirectional streams: OpenStream + OnStream + // - session close via Close() / destruction + // Out of scope (later phases): + // - datagrams (SendDatagram / OnDatagram are stubs that no-op) + // - unidirectional streams (OpenStream(unidirectional=true) throws) + // - capsule protocol (DRAIN/CLOSE capsules) + export class WebTransportSession { + public: + // Underlying QUIC stream id of the CONNECT stream. The peer + // identifies streams that belong to this session by this number. + std::uint64_t sessionId = 0; + + // Path the client connected to. Useful for routing within a single + // wtRoutes handler that's registered against multiple paths. + std::string path; + + WebTransportSession(); + ~WebTransportSession(); + WebTransportSession(const WebTransportSession&) = delete; + WebTransportSession(WebTransportSession&&) noexcept; + WebTransportSession& operator=(WebTransportSession&&) noexcept; + + // Open a new bidi stream toward the peer. The WT_STREAM prefix + // (frame type + session id) is written to the stream automatically + // before this returns; the caller's first Send* delivers the first + // bytes of opaque payload. Throws on connection close. + QUICStream OpenStream(bool unidirectional = false); + + // Register a handler for streams the peer opens against this + // session. Already-buffered streams that arrived before the + // handler was installed are drained into the new handler. + void OnStream(std::function callback); + + // Register a handler for datagrams the peer sends on this + // session. Phase 1 STUB — datagrams are not yet plumbed through. + void OnDatagram(std::function)> callback); + + // Send a datagram. Phase 1 STUB — silently drops. + void SendDatagram(const void* buffer, std::uint32_t size); + + // FIN the CONNECT stream. Subsequent OpenStream calls throw; any + // pending receivers on owned streams will fail with the connection + // close. Idempotent. + void Close(); + + private: + struct Impl; + std::unique_ptr impl; + friend class ListenerHTTP; + friend void WebTransportInitialise(WebTransportSession&, ClientQUIC*, QUICStream, + std::uint64_t, std::string); + friend void WebTransportDeliverStream(WebTransportSession&, QUICStream); + }; + + // Internal — used by ListenerHTTP's WT demuxer. Not exported (and only + // visible to other TUs within the Crafter.Network module). + void WebTransportInitialise(WebTransportSession& session, + ClientQUIC* connection, + QUICStream connectStream, + std::uint64_t sessionId, + std::string path); + void WebTransportDeliverStream(WebTransportSession& session, QUICStream stream); +} +#endif diff --git a/interfaces/Crafter.Network.cppm b/interfaces/Crafter.Network.cppm index 1c6d696..a6713ad 100755 --- a/interfaces/Crafter.Network.cppm +++ b/interfaces/Crafter.Network.cppm @@ -26,4 +26,12 @@ export import :ClientHTTP; export import :ListenerHTTP; export import :HTTP; export import :ClientQUIC; -export import :ListenerQUIC; \ No newline at end of file +export import :ListenerQUIC; +export import :WebTransport; +#ifndef CRAFTER_NETWORK_BROWSER +// Exposed so user code can build WebTransport clients by hand against a +// ClientQUIC until we ship a ClientWebTransport wrapper. Most callers do +// 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; +#endif \ No newline at end of file diff --git a/project.cpp b/project.cpp index 4a67874..24179e4 100644 --- a/project.cpp +++ b/project.cpp @@ -4,7 +4,7 @@ namespace fs = std::filesystem; using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { - constexpr std::array networkInterfaces = { + constexpr std::array networkInterfaces = { "interfaces/Crafter.Network", "interfaces/Crafter.Network-ClientTCP", "interfaces/Crafter.Network-ListenerTCP", @@ -14,14 +14,7 @@ extern "C" Configuration CrafterBuildProject(std::span a "interfaces/Crafter.Network-HTTP3", "interfaces/Crafter.Network-ClientQUIC", "interfaces/Crafter.Network-ListenerQUIC", - }; - constexpr std::array networkImplementations = { - "implementations/Crafter.Network-ClientTCP", - "implementations/Crafter.Network-ListenerTCP", - "implementations/Crafter.Network-ClientHTTP", - "implementations/Crafter.Network-ListenerHTTP", - "implementations/Crafter.Network-ClientQUIC", - "implementations/Crafter.Network-ListenerQUIC", + "interfaces/Crafter.Network-WebTransport", }; std::vector depArgs(args.begin(), args.end()); @@ -38,6 +31,51 @@ extern "C" Configuration CrafterBuildProject(std::span a ApplyStandardArgs(cfg, args); cfg.dependencies = { thread }; + // Browser path: any wasm32-* target gets the browser network stack + // (fetch + WebTransport via JS glue). msquic and the POSIX socket + // backends are skipped; the listener / TCP partitions stub to empty + // modules via #ifdef CRAFTER_NETWORK_BROWSER in their interface files. + // HTTP3 (varint / frame / QPACK codec) is dropped entirely — it threw + // exceptions for protocol errors, which the wasm build's -fno-exceptions + // forbids, and the browser's fetch() handles HTTP-layer framing itself. + bool browser = cfg.target.find("wasm") != std::string::npos; + if (browser) { + cfg.defines.push_back({"CRAFTER_NETWORK_BROWSER", ""}); + + std::array browserIfaces = { + "interfaces/Crafter.Network", + "interfaces/Crafter.Network-ClientTCP", + "interfaces/Crafter.Network-ListenerTCP", + "interfaces/Crafter.Network-ClientHTTP", + "interfaces/Crafter.Network-ListenerHTTP", + "interfaces/Crafter.Network-HTTP", + "interfaces/Crafter.Network-ClientQUIC", + "interfaces/Crafter.Network-ListenerQUIC", + "interfaces/Crafter.Network-WebTransport", + }; + std::array browserImpls = { + "implementations/Crafter.Network-ClientHTTP-Browser", + "implementations/Crafter.Network-ClientQUIC-Browser", + }; + cfg.GetInterfacesAndImplementations(browserIfaces, browserImpls); + + // JS glue shipped alongside the .wasm. The consuming executable's + // wasi-browser runtime merges this into the env import object + // before instantiation (mirrors Crafter.Graphics/dom-env.js). + cfg.files.emplace_back(fs::path("additional/network-env.js")); + return cfg; + } + + constexpr std::array networkImplementations = { + "implementations/Crafter.Network-ClientTCP", + "implementations/Crafter.Network-ListenerTCP", + "implementations/Crafter.Network-ClientHTTP", + "implementations/Crafter.Network-ListenerHTTP", + "implementations/Crafter.Network-ClientQUIC", + "implementations/Crafter.Network-ListenerQUIC", + "implementations/Crafter.Network-WebTransport", + }; + // msquic — provides the QUIC transport used by ClientQUIC / ListenerQUIC. // Cloned + built via CMake into the per-project external cache; no system // package required. Submodules (quictls / clog / etc.) come via the @@ -62,9 +100,9 @@ extern "C" Configuration CrafterBuildProject(std::span a // linker at the actual output location. msquic.libDirs = { "bin/Release" }; msquic.libs = { "msquic" }; - std::array ifaces; + std::array ifaces; std::ranges::copy(networkInterfaces, ifaces.begin()); - std::array impls; + std::array impls; std::ranges::copy(networkImplementations, impls.begin()); cfg.GetInterfacesAndImplementations(ifaces, impls); diff --git a/tests/ShouldEchoWebTransport/ShouldEchoWebTransport.cpp b/tests/ShouldEchoWebTransport/ShouldEchoWebTransport.cpp new file mode 100644 index 0000000..d1daa22 --- /dev/null +++ b/tests/ShouldEchoWebTransport/ShouldEchoWebTransport.cpp @@ -0,0 +1,182 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License version 3.0 as published by the Free Software Foundation; + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// End-to-end WebTransport echo. Spins up a ListenerHTTP with a wtRoutes +// handler that echoes back whatever the peer sent on each new bidi +// stream, then drives a hand-rolled client side using raw ClientQUIC + +// HTTP3 framing (there's no ClientWebTransport class yet; that's Phase 4). +// Verifies: +// - extended CONNECT is accepted, 200 OK delivered without FIN +// - WT_STREAM bidi framing parses correctly on both sides +// - echoed payload round-trips byte-for-byte + +import Crafter.Network; +import Crafter.Thread; +import std; +using namespace Crafter; + +namespace { + // Helper: read one HTTP/3 frame off `stream` into a freshly-allocated + // buffer. Returns (frameType, payload). The peeked-but-unconsumed + // tail bytes (e.g. start of the next frame) are PrependReceived'd + // back onto the stream. + std::pair> ReadFrame(QUICStream& stream) { + std::vector buf; + // First varint = frame type. + std::uint64_t type = 0; std::size_t cn = 0; + while (true) { + const auto* p = reinterpret_cast(buf.data()); + if (HTTP3::DecodeVarint(p, buf.size(), type, cn)) break; + auto chunk = stream.RecieveSync(); + buf.insert(buf.end(), chunk.begin(), chunk.end()); + } + std::vector afterType(buf.begin() + cn, buf.end()); + buf = std::move(afterType); + + // Second varint = frame length. + std::uint64_t len = 0; std::size_t lc = 0; + while (true) { + const auto* p = reinterpret_cast(buf.data()); + if (HTTP3::DecodeVarint(p, buf.size(), len, lc)) break; + auto chunk = stream.RecieveSync(); + buf.insert(buf.end(), chunk.begin(), chunk.end()); + } + std::vector afterLen(buf.begin() + lc, buf.end()); + buf = std::move(afterLen); + + // Read enough for the full payload. + while (buf.size() < len) { + auto chunk = stream.RecieveSync(); + buf.insert(buf.end(), chunk.begin(), chunk.end()); + } + + std::vector payload(buf.begin(), buf.begin() + len); + std::vector tail(buf.begin() + len, buf.end()); + if (!tail.empty()) stream.PrependReceived(std::move(tail)); + return {type, std::move(payload)}; + } +} + +int main() { + ThreadPool::Start(); + + constexpr std::string_view kPayload = "hello-webtransport"; + constexpr std::uint16_t kPort = 8083; + + // ── Server ──────────────────────────────────────────────────────── + QUICServerCredentials serverCreds; + serverCreds.selfSigned = true; + + std::unordered_map> httpRoutes = {}; + std::unordered_map> wtRoutes = { + {"/echo", [](WebTransportSession& session) { + session.OnStream([](QUICStream peerStream) { + try { + auto bytes = peerStream.RecieveUntilCloseSync(); + peerStream.SendSync(bytes.data(), + static_cast(bytes.size()), + /*finish=*/true); + } catch (...) {} + }); + }}, + }; + ListenerAsyncHTTP listener(kPort, serverCreds, std::move(httpRoutes), std::move(wtRoutes)); + + try { + // ── Client (hand-rolled WT bring-up over raw ClientQUIC) ────── + QUICClientCredentials clientCreds; + clientCreds.insecureNoServerValidation = true; + ClientQUIC quic("localhost", kPort, std::string(HTTP3::kAlpn), clientCreds); + + // Drain peer-initiated unidi streams (its control + QPACK streams). + // Without this they'd back up and msquic might abort the connection. + quic.OnStream([](QUICStream stream) { + try { while (true) (void)stream.RecieveSync(); } catch (...) {} + }); + + // Our outgoing control stream + WT-aware SETTINGS prelude. + QUICStream controlStream = quic.OpenStream(/*unidirectional=*/true); + auto prelude = HTTP3::BuildWebTransportControlStreamPrelude(/*maxSessions=*/1); + controlStream.SendSync(prelude.data(), + static_cast(prelude.size()), + /*finish=*/false); + + // CONNECT request stream. Send HEADERS, do NOT FIN — the stream + // is the session-control stream and stays open for its lifetime. + QUICStream connectStream = quic.OpenStream(/*unidirectional=*/false); + std::vector> connectFields = { + {":method", "CONNECT"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/echo"}, + {":protocol", "webtransport"}, + }; + auto headerPayload = HTTP3::EncodeFieldSection(connectFields); + std::vector connectWire; + HTTP3::WriteFrame(connectWire, HTTP3::kFrameHeaders, + headerPayload.data(), headerPayload.size()); + connectStream.SendSync(connectWire.data(), + static_cast(connectWire.size()), + /*finish=*/false); + + // Read the response HEADERS frame. + auto [respType, respPayload] = ReadFrame(connectStream); + if (respType != HTTP3::kFrameHeaders) { + std::println("bad response frame type: {}", respType); + return 1; + } + auto respFields = HTTP3::DecodeFieldSection( + reinterpret_cast(respPayload.data()), + respPayload.size()); + std::string status; + for (auto& [k, v] : respFields) if (k == ":status") status = v; + if (status != "200") { + std::println("CONNECT rejected with status {}", status); + return 1; + } + + // Session is ready. session_id equals the CONNECT stream's QUIC + // stream id — same number on both ends of the wire. + std::uint64_t sessionId = connectStream.GetStreamId(); + + // Open a WT data bidi stream. Prefix: varint(0x41) varint(sessionId). + QUICStream wtStream = quic.OpenStream(/*unidirectional=*/false); + auto prefix = HTTP3::BuildWtBidiPrefix(sessionId); + + std::vector wire; + wire.insert(wire.end(), prefix.begin(), prefix.end()); + wire.insert(wire.end(), kPayload.begin(), kPayload.end()); + wtStream.SendSync(wire.data(), + static_cast(wire.size()), + /*finish=*/true); + + // Server echoes the payload (the prefix has already been stripped + // server-side; the bytes we read here are pure echo). + auto echoed = wtStream.RecieveUntilCloseSync(); + std::string got(echoed.begin(), echoed.end()); + if (got == kPayload) { + std::_Exit(0); + } + std::println("payload mismatch: expected '{}', got '{}'", kPayload, got); + return 1; + } catch (std::exception& e) { + std::println("client failed: {}", e.what()); + return 1; + } +} diff --git a/tests/ShouldEchoWebTransport/project.cpp b/tests/ShouldEchoWebTransport/project.cpp new file mode 100644 index 0000000..b0ca04d --- /dev/null +++ b/tests/ShouldEchoWebTransport/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "tests/ShouldEchoWebTransport/"; + cfg.name = "ShouldEchoWebTransport"; + cfg.outputName = "ShouldEchoWebTransport"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + cfg.dependencies = { ParentLib("crafter-network") }; + cfg.linkFlags.push_back("-Wl,--export-dynamic"); + cfg.linkFlags.push_back("-ldl"); + std::array ifaces = {}; + std::array impls = { "ShouldEchoWebTransport" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +}