SocketErrorException is now a std::system_error: code() carries the errno
under std::generic_category(), or getaddrinfo's result under the new
ResolverCategory(), whose message() is gai_strerror's text; `phase` says
which step failed (resolve, open, connect, send, receive). It is still a
std::runtime_error with the message ClientTCP always threw, the code's
text after it, so existing handlers keep working.
Before, the exception carried a `kind` and an int, and a caller that had
to print the cause its own way still had to match the message prefix to
learn where the failure happened. The standard type ends both: the
category knows how to print the number, and the phase is a field. A
caller writes "[Errno {}] {}" from code().value() and code().message(),
or compares code() with std::errc::connection_refused.
Breaking: SocketErrorException's `kind` and `code` members are gone;
read code().category() and code().value().
376 lines
34 KiB
Markdown
376 lines
34 KiB
Markdown
# Crafter.Network
|
||
|
||
A cross-platform C++ networking library providing TCP, QUIC, HTTP/3, HTTP/1.1, 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, HTTP/3, HTTP/1.1, 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 (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), 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.).
|
||
- **HTTP/1.1**: Client and server over plain TCP (RFC 9112), for the large part of the world that is not ready for HTTP/3 — old proxies, CI tooling, load balancers, `curl` scripts. Shares the `HTTPRequest`/`HTTPResponse` types and the route-map API — including the `fallback` hook for paths that cannot be enumerated — with the HTTP/3 stack, so a handler or call site moves between the two by changing the class name. Keep-alive and pipelining, `content-length` and `chunked` bodies with trailers, `Expect: 100-continue`, HEAD, automatic `Date`, and per-connection timeouts. `https://` on both sides via libssl, including ALPN and mutual TLS — see [HTTP/1.1 Components](#http11-components).
|
||
- **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
|
||
|
||
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 (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, constructors, and `PathWithoutQueryHTTP`, shared by every HTTP version
|
||
- `Crafter.Network:ClientHTTP1`: HTTP/1.1 client over TCP, plaintext or TLS (native only)
|
||
- `Crafter.Network:ListenerHTTP1`: HTTP/1.1 server over TCP, plaintext or TLS (native only)
|
||
- `Crafter.Network:Stream`: `ByteStream` — a reliable byte stream with deadlines on both directions — plus `PlainStream`, the plaintext socket implementation. The seam that lets one HTTP/1.1 implementation serve `http://` and `https://` (native only)
|
||
- `Crafter.Network:TLS`: TLS over a connected socket via libssl (OpenSSL 3): `TLSContext`, `TLSStream`, and the credential types for both roles. Protocol-agnostic — usable over any descriptor, not just HTTP (native only)
|
||
- `Crafter.Network:HTTP1`: HTTP/1.1 wire format — serialisation plus an incremental parser (RFC 9112). Transport-free; usable on its own to speak HTTP/1.1 over some other byte stream. Native only, for the same reason as `:HTTP3`
|
||
- `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
|
||
|
||
### TCP Components
|
||
|
||
#### ClientTCP
|
||
```cpp
|
||
// Create a TCP client
|
||
Crafter::ClientTCP client("localhost", 8080);
|
||
client.Send("Hello World", 11);
|
||
|
||
// Receive data
|
||
std::vector<char> data = client.RecieveSync();
|
||
```
|
||
|
||
The host is resolved for IPv4 and IPv6 alike and every address it resolves to is tried in turn, as Python's `socket.create_connection` does, so `localhost` reaches a server that listens on `::1` only. The optional third argument is a connect timeout, applied to each address on its own. A failed resolve, connect, send or receive throws `SocketErrorException`: a `std::system_error`, so still a `std::runtime_error` with the same message, whose `code()` is the errno under `std::generic_category()` or the `getaddrinfo` result under `Crafter::ResolverCategory()`, and whose `phase` names the step (`SocketPhase::Resolve`, `Open`, `Connect`, `Send` or `Receive`), so a caller can report the cause without parsing the text.
|
||
|
||
```cpp
|
||
try {
|
||
Crafter::ClientTCP client("localhost", 5432, std::chrono::seconds(5));
|
||
} catch (const Crafter::SocketErrorException& e) {
|
||
if (e.code() == std::errc::connection_refused) std::println("nothing listens there");
|
||
std::println("[Errno {}] {}", e.code().value(), e.code().message()); // [Errno 111] Connection refused
|
||
} catch (const Crafter::SocketTimeoutException&) {
|
||
std::println("timed out");
|
||
}
|
||
```
|
||
|
||
#### ListenerTCP
|
||
```cpp
|
||
// Create a TCP listener
|
||
auto callback = [](Crafter::ClientTCP* client) {
|
||
// Handle new connection
|
||
};
|
||
Crafter::ListenerTCP listener(8080, callback);
|
||
listener.ListenSyncSync(); // Synchronous listening
|
||
```
|
||
|
||
The port-only constructor listens on every IPv4 interface. To keep a listener to this machine, or to let the kernel pick the port, name the address and pass port 0; `Port()` reports the port it got. The address must be numeric (`127.0.0.1`, `::1`, `0.0.0.0`, `::`): a name such as `localhost` is refused rather than bound to whichever address the resolver ranks first.
|
||
|
||
```cpp
|
||
Crafter::ListenerTCP broker("127.0.0.1", 0, callback);
|
||
const std::uint16_t port = broker.Port();
|
||
std::thread([&] { broker.ListenSyncSync(); }).detach();
|
||
// ... broker.Stop() ends the loop from another thread.
|
||
```
|
||
|
||
### 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
|
||
Crafter::QUICClientCredentials creds;
|
||
creds.insecureNoServerValidation = true; // dev-only
|
||
Crafter::ClientHTTP client("localhost", 8082, creds);
|
||
|
||
Crafter::HTTPResponse response = client.Send(
|
||
Crafter::CreateRequestHTTP("GET", "/", "localhost")
|
||
);
|
||
// response.status is the numeric status as a string, e.g. "200"
|
||
```
|
||
|
||
#### ListenerHTTP
|
||
```cpp
|
||
std::unordered_map<std::string,
|
||
std::function<Crafter::HTTPResponse(const Crafter::HTTPRequest&)>> routes;
|
||
routes["/hello"] = [](const Crafter::HTTPRequest&) {
|
||
return Crafter::CreateResponseHTTP("200", "Hello World!");
|
||
};
|
||
|
||
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` and then on the query-stripped path; anything still unmatched goes to [`fallback`](#routes-that-cannot-be-enumerated) if one is set and returns a synthetic 404 otherwise.
|
||
|
||
### HTTP/1.1 Components
|
||
|
||
HTTP/3 is not something every peer can be made to speak. `ClientHTTP1` and `ListenerHTTP1` provide the same API over TCP — with or without TLS — using the same `HTTPRequest`/`HTTPResponse` types and the same route-map shape, so a handler can be registered with both and served over either protocol.
|
||
|
||
#### ClientHTTP1
|
||
```cpp
|
||
Crafter::ClientHTTP1 client("localhost", 8080);
|
||
|
||
Crafter::HTTPResponse response = client.Send(
|
||
Crafter::CreateRequestHTTP("GET", "/", "localhost")
|
||
);
|
||
```
|
||
|
||
The connection is persistent: the first `Send()` dials, and later calls reuse the socket unless the peer asked for it to be closed. A pooled connection that the peer closed while it looked idle — the race HTTP/1.1 keep-alive cannot avoid — is redialled once and the request replayed; nothing is replayed once a response byte has arrived, and a freshly dialled connection is never retried on, so a genuinely broken server surfaces as an exception rather than a retry loop. `authority` defaults to the host:port the client was constructed with (the port is elided when it is the scheme default — 443 under TLS, 80 without; an IPv6 literal goes in brackets).
|
||
|
||
Three timeouts bound a request: `connectTimeout` (default 15 s) for the TCP connect, per address the host resolves to; `handshakeTimeout` (default 15 s) for the TLS handshake; and `timeout` (default 30 s) for each wait on the next piece of a response.
|
||
|
||
#### FetchHTTP1
|
||
|
||
`FetchHTTP1(url, options)` is one request to an `http://` or `https://` URL, following redirects the way Python's urllib does, so code ported from urllib keeps its behaviour:
|
||
|
||
```cpp
|
||
Crafter::FetchOptionsHTTP1 options;
|
||
options.timeout = std::chrono::seconds(10);
|
||
options.headers["accept-encoding"] = "identity";
|
||
Crafter::FetchResultHTTP1 result = Crafter::FetchHTTP1("http://localhost:8080/", options);
|
||
// result.response: the last response; result.url: the URL that sent it;
|
||
// result.redirects: how many were followed; result.limited: the limits stopped the chain.
|
||
```
|
||
|
||
301, 302, 303, 307 and 308 are followed for a GET or HEAD, and 301/302/303 also for a POST; the redirected request is a GET (a HEAD stays a HEAD) without the body or its content headers. A relative `Location` is resolved against the URL that sent it, as `urljoin` resolves it, and is percent-encoded the way urllib quotes it; a redirect may move between `http://` and `https://` (`options.credentials` applies to every TLS hop). Each redirect's body is read before the next request goes out. A redirect is returned unfollowed when it has no `Location`, points at another scheme, or comes for a method urllib does not redirect. The chain stops, returning the redirect with `limited` set, at the fifth redirect to one URL (`maxRepeats`, 4) or once ten different URLs have been visited (`maxRedirects`, 10). Proxies are not consulted. A URL that is not `http(s)://`, has no host or a bad port throws `std::invalid_argument`.
|
||
|
||
#### ListenerHTTP1
|
||
```cpp
|
||
std::unordered_map<std::string,
|
||
std::function<Crafter::HTTPResponse(const Crafter::HTTPRequest&)>> routes;
|
||
routes["/hello"] = [](const Crafter::HTTPRequest&) {
|
||
return Crafter::CreateResponseHTTP("200", "Hello World!");
|
||
};
|
||
|
||
Crafter::ListenerAsyncHTTP1 listener(8080, std::move(routes));
|
||
```
|
||
|
||
Each accepted connection gets its own thread and is served sequentially until the peer closes it, a `Connection: close` is seen, or a timeout expires (`keepAliveTimeout`, default 15 s between requests; `requestTimeout`, default 30 s for one request to arrive). A dedicated thread rather than a ThreadPool task is deliberate: keep-alive connections are idle most of their life and would otherwise pin every pool thread.
|
||
|
||
Routing matches `path` exactly and then falls back to the query-stripped path, so `/thing?x=1` reaches the handler registered for `/thing` while the handler still sees the full target in `request.path`. A handler that throws becomes a 500; an unknown path a 404 (or `fallback`, see [Routes that cannot be enumerated](#routes-that-cannot-be-enumerated)); a request we refuse to parse a 400. `Date` is stamped automatically unless the handler set one, HEAD returns the headers a GET would have produced with no body, and a handler can end the connection by answering with a `connection: close` header.
|
||
|
||
Implemented: TLS (see [HTTPS](#https)), keep-alive, pipelining, `content-length` and `chunked` request bodies with trailers, `Expect: 100-continue`, absolute-form request targets, obs-fold, and HTTP/1.0 peers (which only get connection reuse when they ask for it). Not implemented: CONNECT tunnels, `Upgrade`, and chunked *responses* — handlers return a complete body, so responses are always `content-length` framed.
|
||
|
||
Ambiguous framing is rejected rather than guessed at, because guessing is how request smuggling happens (RFC 9112 §11.2): `Content-Length` together with `Transfer-Encoding`, disagreeing duplicate `Content-Length` values, and whitespace between a field name and its colon are all 400s. CR/LF in a header value we are asked to *send* throws instead of splitting the message.
|
||
|
||
#### HTTPS
|
||
|
||
`https://` is a constructor argument, not a different class. Pass credentials and every byte goes through libssl (OpenSSL 3); leave them out and the transport is plaintext. Nothing above the transport changes — same routes, same keep-alive and replay rules, same framing.
|
||
|
||
```cpp
|
||
// Server. selfSigned mints an ephemeral development certificate; in
|
||
// production set certPath/keyPath (or certPem/keyPem) instead.
|
||
Crafter::ListenerAsyncHTTP1 listener(8443, std::move(routes),
|
||
Crafter::TLSServerCredentials{ .selfSigned = true });
|
||
|
||
// Client. The default verifies the chain against the system trust store
|
||
// *and* the hostname — a chain check alone accepts any valid certificate
|
||
// for any name, which is not a check.
|
||
Crafter::TLSClientCredentials credentials;
|
||
Crafter::ClientHTTP1 client("example.com", 443, credentials);
|
||
```
|
||
|
||
To verify a self-signed or privately issued server without giving up verification, hand the client the certificate as a trust anchor (`caPath` for a file or hashed directory, `caPem` for the bytes) rather than reaching for `insecureNoServerValidation` — that switch accepts an attacker's certificate too, and exists for development only.
|
||
|
||
ALPN is negotiated: the listener advertises `http/1.1` and a client offering only something else (say `h2`) is refused with `no_application_protocol` rather than being served an HTTP/1.1 response it cannot parse. `alpnProtocols` on either side changes what is offered or accepted. Requests reach handlers with `scheme` set to `https`, so a handler shared with `ListenerHTTP` sees the same thing over both protocols.
|
||
|
||
Mutual TLS: set `requireClientCertificate` with a `clientCaPath`, and a peer presenting no certificate — or one that does not chain to that CA — is rejected during the handshake. `HandshakeFailureCount()` counts connections dropped that way; on a public port those are ordinary traffic (scanners, misconfigured callers) rather than something to alert on. Client certificates are supplied by `certPath`/`keyPath` on `TLSClientCredentials`.
|
||
|
||
The handshake runs on the connection's own thread, so a peer that stalls halfway through it costs one thread rather than the accept loop. `handshakeTimeout` (default 15 s) bounds it on both sides.
|
||
|
||
For an encrypted transport with better properties than TLS-over-TCP, `ClientHTTP`/`ListenerHTTP` speak HTTP/3 over QUIC, which is always encrypted.
|
||
|
||
#### TLS on its own
|
||
|
||
`Crafter::TLSContext` and `Crafter::TLSStream` are transport-agnostic: anything holding a connected descriptor can put a record layer over it, HTTP or not. `TLSStream` implements `Crafter::ByteStream` — the same interface `PlainStream` implements and the HTTP/1.1 endpoints are written against — so code that reads and writes through a `ByteStream&` works over either.
|
||
|
||
```cpp
|
||
Crafter::ClientTCP socket("example.com", 443);
|
||
auto context = Crafter::TLSContext::Client({});
|
||
auto stream = Crafter::TLSStream::Connect(socket.socketid, context,
|
||
"example.com", std::chrono::seconds(10));
|
||
stream->Write(request.data(), request.size(), std::chrono::seconds(10));
|
||
```
|
||
|
||
The descriptor stays owned by its `ClientTCP`; the stream only adds the record layer. Both factories complete the handshake before returning, so a stream you hold is one you can write to, and both throw `Crafter::TLSException` — with the specific certificate error, not just "handshake failed" — when they cannot. Reads and writes are driven by `poll()` against a deadline, which is what makes the timeouts real; the descriptor is put into non-blocking mode to allow it.
|
||
|
||
`GetSelfSignedCertificatePem()` returns the process-wide development certificate (ECDSA P-256, `CN=localhost`, SANs for `localhost`/`127.0.0.1`/`::1`, 10 days) so it can be written out for a peer process or handed to a client as a trust anchor. It is generated in-process and regenerated on every start — no peer has any reason to trust it.
|
||
|
||
#### HTTP/1.1 wire format on its own
|
||
|
||
`Crafter::HTTP1` exposes the codec without the transport — `SerializeRequest` / `SerializeResponse` and an incremental `MessageParser` that takes arbitrary byte chunks and yields one message at a time. Useful for speaking HTTP/1.1 over a byte stream this library does not own.
|
||
|
||
```cpp
|
||
Crafter::HTTP1::MessageParser parser(Crafter::HTTP1::MessageKind::Response);
|
||
parser.SetRequestMethod("GET"); // framing depends on the request method
|
||
parser.Feed(chunk.data(), chunk.size());
|
||
if (parser.Complete()) {
|
||
Crafter::HTTPResponse response = parser.TakeResponse();
|
||
parser.Reset(); // rearm; keeps any pipelined bytes
|
||
}
|
||
```
|
||
|
||
### Routes that cannot be enumerated
|
||
|
||
The route map only answers paths known when the listener is built. `/shop/<slug>`, `/order/<token>`, `/posts/<id>` cannot be pre-registered — the token space is unbounded and the product set changes while the server runs. Both listeners therefore take an optional `fallback`, called for any request the route map missed, with the full target still in `request.path`:
|
||
|
||
```cpp
|
||
auto router = [](const Crafter::HTTPRequest& request) {
|
||
auto path = Crafter::PathWithoutQueryHTTP(request.path); // everything up to '?'
|
||
if (path.starts_with("/shop/")) return RenderProduct(path.substr(6));
|
||
return Crafter::CreateResponseHTTP("404", "Not Found");
|
||
};
|
||
|
||
Crafter::ListenerAsyncHTTP1 listener(8080, std::move(routes), router); // HTTP/1.1
|
||
Crafter::ListenerAsyncHTTP quic(4443, creds, std::move(routes), router); // same handler over HTTP/3
|
||
```
|
||
|
||
Dispatch precedence is the same on both: exact `path`, then the query-stripped path, then `fallback`, then a synthetic 404. So a fallback only ever sees what the route map did not claim, and leaving it unset keeps the previous behaviour exactly.
|
||
|
||
This is deliberately a hook rather than a pattern-matching syntax. Consumers that already have a router — one shared between a wasm frontend and the server, say, so a URL cannot mean different things to a crawler and to the app — keep using it, and there is no second route table to disagree with the first. A throwing fallback becomes a 500, like any other handler. On `ListenerHTTP` it applies to `routes` only; an unmatched WebTransport CONNECT is still a 404, since a WT handler has a different signature.
|
||
|
||
`fallback` is a plain public member on `ListenerHTTP`/`ListenerHTTP1` and may be assigned before `Listen()`. The `ListenerAsync*` wrappers start accepting inside their constructor, so there they have to be passed as the trailing constructor argument shown above — assigning afterwards races the accept loop.
|
||
|
||
### 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<std::string,
|
||
std::function<Crafter::HTTPResponse(const Crafter::HTTPRequest&)>> httpRoutes;
|
||
std::unordered_map<std::string,
|
||
std::function<void(Crafter::WebTransportSession&)>> wtRoutes;
|
||
|
||
wtRoutes["/echo"] = [](Crafter::WebTransportSession& session) {
|
||
session.OnStream([](Crafter::QUICStream peerStream) {
|
||
auto bytes = peerStream.RecieveUntilCloseSync();
|
||
peerStream.SendSync(bytes.data(),
|
||
static_cast<std::uint32_t>(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. So are `ClientHTTP1` / `ListenerHTTP1` / the `HTTP1` codec: a page cannot open a raw TCP socket anyway, and `fetch()` behind `ClientHTTP` already negotiates whatever HTTP version the server offers.
|
||
- `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 is a single Crafter.Build configuration (`crafter-network`, `ConfigurationType::LibraryStatic`). Target selection and debug flags are handled by `ApplyStandardArgs`:
|
||
|
||
- `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
|
||
|
||
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 (`ShouldSendRecieveQUICStream`)
|
||
- QUIC unreliable datagrams (`ShouldSendRecieveQUICDatagram`)
|
||
- QUIC connection teardown (`ShouldSurviveConnectionChurn`) — 25 build-up/tear-down cycles with reader threads parked inside `RecieveSync`, the shape a reconnect loop has; the rest of the QUIC tests end in `std::_Exit(0)` and so never run a `ClientQUIC` destructor
|
||
- WebTransport echo (`ShouldEchoWebTransport`) — extended-CONNECT acceptance, draft-02 SETTINGS, bidi data stream framing (`WT_STREAM 0x41` + session-id varint), and byte-for-byte echo
|
||
- HTTP/1.1 wire format (`ShouldParseHTTP1`) — serialisation, incremental parsing fed one byte at a time, chunked bodies with trailers, pipelining, interim 1xx, HTTP/1.0 and HEAD framing, and the malformed inputs the parser is required to reject
|
||
- HTTP/1.1 round-trip (`ShouldSendRecieveHTTP1`) — routing, query strings, HEAD, 404 and a throwing handler
|
||
- HTTP/1.1 keep-alive (`ShouldSendRecieveKeepaliveHTTP1`) — connection reuse, handler-requested close, and recovery from a pooled connection the server closed
|
||
- HTTP/1.1 large body transfer (`ShouldSendRecieveLargeHTTP1`) — 10 MiB in both directions on one connection
|
||
- HTTP/1.1 interop (`ShouldInteropCurlHTTP1`) — `curl` against `ListenerHTTP1` (keep-alive reuse, chunked upload, `Expect: 100-continue`, HEAD) and `ClientHTTP1` against python3's `http.server`, which answers HTTP/1.0 with `Connection: close`
|
||
- HTTP/1.1 under abuse (`ShouldSurviveAbuseHTTP1`) — 24 concurrent keep-alive clients, peers that vanish mid-request or send garbage, and a stalled peer that must be timed out
|
||
- Fallback routing (`ShouldFallbackUnknownRoutes`) — one route map plus a `fallback` replayed over both `ListenerHTTP1` and `ListenerHTTP`, asserting identical answers: exact routes win, query strings still route to the bare path, the fallback sees the full target, a throwing fallback is a 500, and an unset fallback still means a synthetic 404
|
||
- HTTPS round-trip (`ShouldSendRecieveHTTPS1`) — the plaintext round-trip replayed over TLS, so a transport regression surfaces as an HTTP failure, plus what only exists under TLS: ALPN, `scheme=https` reaching handlers, a body spanning many records, verification failing for an untrusted certificate *and* for a trusted certificate presented under the wrong name, and a plaintext peer on the TLS port being counted and shrugged off
|
||
- HTTPS interop (`ShouldInteropCurlHTTPS1`) — `curl` verifying our certificate with `--cacert` (not `--insecure`): keep-alive reuse, POST, HEAD, negotiated version, and an h2-only client being refused rather than mis-served; then `ClientHTTP1` against python3's `http.server` behind `ssl.wrap_socket`, whose HTTP/1.0 `Connection: close` frames the body by `close_notify`
|
||
- Mutual TLS and the raw stream (`ShouldRequireClientCertificateHTTPS1`) — a client certificate the listener's CA vouches for is served and one absent is refused; `TLSStream` is also driven directly with hand-written HTTP/1.1 to keep `:TLS` usable without `:ClientHTTP1` on top
|
||
- Dual-stack connect (`ShouldConnectDualStack`) — `localhost` reaches a server on `::1` only and one on `127.0.0.1` only, an IPv6 literal connects over IPv6, and the last failure is what is thrown when no address answers; skips on a host without IPv6 loopback
|
||
- Connect errors (`ShouldReportConnectErrors`) — a refused connect and an unresolvable name throw `SocketErrorException` with their errno or getaddrinfo code and the message ClientTCP always threw, and still reach a `std::runtime_error` handler
|
||
- Send and receive errors (`ShouldReportIoErrors`) — a connection the peer resets fails the next receive, and a send into it, with `SocketErrorException` carrying ECONNRESET or EPIPE under the plain strerror text a receive and a send always threw
|
||
- Loopback listeners (`ShouldBindLoopbackEphemeral`) — a listener on `127.0.0.1` at port 0 reports its port, takes loopback clients and refuses them through the host's other address; `::1` works too, a name is refused, and the port-only constructor still listens on every interface
|
||
- HTTP/1.1 timeouts (`ShouldTimeoutSilentHTTP1`) — a server that accepts and never answers fails after `timeout`, and a connect nobody answers after `connectTimeout`
|
||
- Redirects (`ShouldFollowRedirectsHTTP1`) — relative and `..` Locations, an http-to-https hop, the 4-repeat and 10-URL limits, a redirect without Location, POST after 303 and 307, percent-encoding a Location, and the URLs `FetchHTTP1` refuses
|
||
|
||
The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. `ShouldInteropCurlHTTP1` and `ShouldInteropCurlHTTPS1` skip whichever half is unavailable when `curl` or `python3` is not installed, and the mutual-TLS half of `ShouldRequireClientCertificateHTTPS1` skips without the `openssl` CLI (a *client* certificate needs the `clientAuth` extended key usage, which the built-in development certificate does not carry) — so all three pass on a bare machine. Install `curl`, `python3` and `openssl` to actually exercise them.
|
||
|
||
## Dependencies
|
||
|
||
- **Crafter.Thread**: Thread pool management for asynchronous operations.
|
||
- **msquic** (native target only) — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` at a pinned release (v2.6.3), with the submodules that release records, into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=openssl` against the system OpenSSL, 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 same path also produces the cert hash that browser peers need for `serverCertificateHashes`.
|
||
- **libssl / libcrypto** (OpenSSL 3, native target only) — a *system* package, unlike msquic, linked via `-lssl -lcrypto`. Backs `Crafter.Network:TLS`, i.e. `https://` on the HTTP/1.1 client and listener. It is not built here on purpose, and msquic uses it too, so the process has one OpenSSL: msquic's QUIC support needs OpenSSL 3.5 or newer (Arch has it, as does the Flatpak runtime crafter-build builds in). msquic's own bundled fork (quictls) is not used: it is an older OpenSSL that still reads the system's `openssl.cnf`, and a config written for the system's newer OpenSSL (the Flatpak runtime's loads a PKCS#11 engine, with `config_diagnostics` on) made its initialisation fail. Install your distro's OpenSSL development package (`openssl` on Arch, `libssl-dev` on Debian/Ubuntu). Where that OpenSSL is older than 3.5 (Debian 12 and Ubuntu 24.04 ship 3.0), msquic doesn't build against it: build with `--no-quic`, which keeps TCP, TLS and HTTP/1.1. Certificate generation for `TLSServerCredentials{selfSigned = true}` happens in-process through this library — no `openssl` CLI needed.
|
||
- Plaintext HTTP/1.1 needs nothing beyond POSIX sockets: `ClientHTTP1`/`ListenerHTTP1` built without credentials pull in no msquic and touch no TLS code path.
|
||
- **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
|
||
|
||
```cpp
|
||
#include <Crafter.Network>
|
||
#include <iostream>
|
||
|
||
int main() {
|
||
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;
|
||
}
|
||
```
|
||
|
||
## License
|
||
|
||
This library is licensed under the GNU Lesser General Public License, version 3 only (LGPL-3.0-only). See [LICENSE](LICENSE) for the license text. The LGPL supplements the GNU General Public License version 3, a copy of which is included as [LICENSE.GPL](LICENSE.GPL).
|
||
|
||
The example code under [examples/](examples/) is licensed under the MIT license (see [examples/SimpleClient/LICENSE](examples/SimpleClient/LICENSE)), so it can be freely copied into your own projects.
|
||
|
||
## Copyright
|
||
|
||
Copyright (C) 2026 Catcrafts®
|
||
Catcrafts.net
|