C++ multipurpose networking library for linux
  • C++ 98.2%
  • JavaScript 1.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jorijn van der Graaf f29c4a83d2
docs(quic): OnRecieve and Abort, and serving a stream without a thread
The README's QUIC section gains receiving by callback: a server
answering one request per stream with a size cap enforced by Abort,
the threads the callbacks run on, what they may call, and the
std::stop_callback rule for destroying the wrapper; its follower
example now drops a lagging peer with Abort rather than a graceful
Stop. The consumer notes give the full contract, the weak_ptr idiom for
reaching a stream from its own callbacks, and list the callbacks among
what runs on msquic's workers; the agent notes count the new test,
describe the per-stream delivery in the partition list, and add the
callbacks to the worker-thread gotcha.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 02:33:28 +01:00
additional feat: TCP-only builds; TCP options and timeouts; receives that report and keep what arrived 2026-10-20 20:04:57 +02:00
examples/SimpleClient feat: TCP-only builds; TCP options and timeouts; receives that report and keep what arrived 2026-10-20 20:04:57 +02:00
implementations feat(quic): QUICStream::OnRecieve and Abort, callbacks that hold no thread 2026-11-01 02:32:48 +01:00
interfaces feat(quic): QUICStream::OnRecieve and Abort, callbacks that hold no thread 2026-11-01 02:32:48 +01:00
tests test(quic): receive by callback, one callback at a time, ended once 2026-11-01 02:32:48 +01:00
.gitignore docs: agent notes for this repository and for its consumers 2026-10-31 19:22:49 +01:00
AGENTS.consumer.md docs(quic): OnRecieve and Abort, and serving a stream without a thread 2026-11-01 02:33:28 +01:00
AGENTS.template.md docs(quic): OnRecieve and Abort, and serving a stream without a thread 2026-11-01 02:33:28 +01:00
LICENSE SPDX license 2026-07-22 17:55:45 +02:00
LICENSE.GPL SPDX license 2026-07-22 17:55:45 +02:00
project.cpp test(quic): receive by callback, one callback at a time, ended once 2026-11-01 02:32:48 +01:00
README.md docs(quic): OnRecieve and Abort, and serving a stream without a thread 2026-11-01 02:33:28 +01:00

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.

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.
  • 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

// 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.

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

// 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.

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.

QUIC Components

Plain QUIC under an ALPN of your own, for a protocol that is not HTTP. A browser cannot reach these: it speaks QUIC only as WebTransport, see WebTransport Components.

ListenerQUIC

Crafter::QUICServerCredentials creds;
creds.selfSigned = true; // dev-only, see "Self-signed certs & browser peers"
Crafter::ListenerQUIC listener(0, "myproto/1", creds, [](Crafter::ClientQUIC* peer) {
    // An accepted connection, heap-allocated and yours to delete.
});
listener.ListenAsyncAsync();
const std::uint16_t port = listener.Port(); // the port msquic picked for port 0

Port() reports the UDP port the listener is bound to, so port 0 is usable: the listener takes whatever port is free and tells you which.

The port-only constructor listens on every address of both families. To keep a listener to this machine, name the address first, as with ListenerTCP; it must be numeric (127.0.0.1, ::1, 0.0.0.0, ::), and a name such as localhost is refused. msquic still binds the UDP port on every address, but turns away a connection sent to any other address of the machine during its first flight, so such a client fails at once rather than at its handshake timeout. A client of a listener on 127.0.0.1 must dial 127.0.0.1: ::1 is another address.

Crafter::ListenerQUIC local("127.0.0.1", 0, "myproto/1", creds, onConnect);

Owning accepted connections

Each connection the listener accepts reaches the callback as a heap-allocated ClientQUIC* that the callback owns. OnClosed says when to delete it: it fires once when the connection has ended for any reason but its own destruction (the peer closed it, the idle timeout or a transport error ended it, or Stop() was called), on msquic's worker thread, and deleting the connection from inside it is safe.

Crafter::ListenerQUIC listener("127.0.0.1", 0, "myproto/1", creds, [](Crafter::ClientQUIC* peer) {
    peer->OnClosed([peer] { delete peer; });
    peer->OnStream([](Crafter::QUICStream stream) { /* ... */ });
});

Keep the callback short: it runs on the msquic worker, so no *Sync call and no deleting a different connection from inside it. If the connection had already closed when OnClosed was called, it fires as a pool task instead, never inside OnClosed. Once ~ClientQUIC has begun it never fires, and the destructor waits for one already running on another thread, as std::stop_callback does, so don't destroy a connection while holding a lock its callback takes. A client-side ClientQUIC has OnClosed too.

~ClientQUIC waits, up to 1 s and about a round trip when the peer is there, for its close to reach the peer, so a server learns of a client that went away at once rather than at its idle timeout.

Sending without a thread

QUICStream::SendAsync copies the buffer, hands it to msquic and returns; nothing waits for msquic to take it. Its callback fires exactly once, on msquic's worker thread when msquic is done with the copy: true once msquic has taken the whole buffer, false when the stream or the connection closed first. Sends queue in call order, from any thread and from inside callbacks, and BytesInFlight() / SendsInFlight() say how far behind a slow peer is:

stream.SendAsync(line.data(), static_cast<std::uint32_t>(line.size()), /*finish=*/false,
                 [](bool sent) { if (!sent) { /* the peer is gone */ } });
if (stream.BytesInFlight() > 4 * 1024 * 1024) stream.Abort(); // a follower that cannot keep up

The callback runs on msquic's worker, so it must not block (no *Sync call) or throw. A send that fails before msquic takes it, on a closed stream for one, reports from a pool task instead, never from inside SendAsync. Sends made before the wrapper is destroyed still go out and report. SendSync is the blocking form, and throws only when its own send was canceled.

Receiving by callback

QUICStream::OnRecieve hands a stream's bytes to one callback as they arrive and tells another when the peer is done, holding no thread in between: no reader parked in RecieveSync, no pool task blocked in RecieveUntilCloseAsync. A server answering one request per stream, with a size cap:

peer->OnStream([](Crafter::QUICStream incoming) {
    auto stream = std::make_shared<Crafter::QUICStream>(std::move(incoming));
    auto request = std::make_shared<std::string>();
    std::weak_ptr<Crafter::QUICStream> weak = stream;
    stream->OnRecieve(
        [request, weak](std::span<const char> bytes) {
            request->append(bytes.begin(), bytes.end());
            if (request->size() > 64 * 1024) {
                if (auto s = weak.lock()) s->Abort(1); // too big: reset both directions
            }
        },
        [request, weak](bool clean) {
            if (!clean) return;                        // reset, or the connection went
            if (auto s = weak.lock()) {
                const std::string answer = Answer(*request); // or later, from any thread
                s->SendAsync(answer.data(), static_cast<std::uint32_t>(answer.size()), /*finish=*/true, nullptr);
            }
        });
    Keep(stream); // destroying the wrapper unregisters its callbacks
});

Bytes that arrived before OnRecieve are delivered by it, on the calling thread, before it returns; after that the callbacks run on msquic's worker thread. No two callbacks of one stream, SendAsync's included, ever run at once. onEnd fires once, after the last onData: true when the peer finished its side, false for a reset by either side or a lost connection. Inside the callbacks, SendAsync, Abort and destroying or moving the wrapper are fine; nothing that blocks (*Sync calls wait for the very worker they run on) and nothing that throws. Destroying the wrapper unregisters both, as std::stop_callback does: once the destructor returns neither runs again, and it waits for one running on another thread, so don't destroy it while holding a lock the callbacks take. Abort(code) resets both directions at once, from any thread or from inside onData; no onData follows it and onEnd(false) does. Don't mix OnRecieve with the Recieve* calls on one stream.

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

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

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 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

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.

ClientHTTP1::Fetch

ClientHTTP1::Fetch(url, options) is one request to an http:// or https:// URL, following redirects by RFC 9110 §15.4:

Crafter::FetchOptions options;
options.timeout = std::chrono::seconds(10);
options.headers["accept-encoding"] = "identity";
Crafter::FetchResult result = Crafter::ClientHTTP1::Fetch("http://localhost:8080/", options);
// result.response: the last response; result.url: the URL that sent it (a Crafter::URL);
// result.redirects: how many were followed; result.stop: why a redirect was not followed, or None.

301, 302, 303, 307 and 308 are followed for every method: a 303 turns the request into a GET (a HEAD stays a HEAD) without its body and content headers, a 301 or 302 does the same for a POST alone, and a 307 or 308 keeps the method and the body. A relative Location is resolved against the URL that sent it (RFC 3986, through Crafter::URL::Resolve), and a redirect may move between http:// and https:// (options.credentials applies to every TLS hop). Authorization, Proxy-Authorization and Cookie are not carried to another origin. Each hop is a client of its own, so a redirect's body is read and its connection closed before the next request goes out. A redirect comes back as the response, with result.stop saying why, when it has no usable Location, points at another scheme, points back at a URL the chain already visited, or would be the eleventh (maxRedirects, 10). Proxies are not consulted. A URL that is not http(s)://, has no host or has a bad port throws std::invalid_argument.

Crafter::URL is the URL type behind it, exported for callers that build or resolve URLs themselves: URL::Parse(text), url.Resolve(reference), url.ToString(), and the pieces a request is built from (Authority(), Target(), PortOrDefault(), Secure(), SameOrigin(other)).

ListenerHTTP1

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); 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), 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.

// 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.

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.

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:

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.

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 serve a development certificate it makes in-process (no openssl CLI) and keeps per user at $XDG_CACHE_HOME/crafter-network/quic-cert/{cert,key}.pem (~/.cache/... without XDG_CACHE_HOME, $XDG_RUNTIME_DIR/... without a home), in a directory only that user can enter. It is reused across server restarts until it has less than a day left, then replaced by the next process that needs it. Processes starting at the same moment share one pair: a lock covers the check and the replacement, each file is renamed into place whole, and the listener loads the pair from memory, so none ends up with one run's key and another's certificate. 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:

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; 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/. 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 listener ports (ShouldReportListenerPortQUIC) — a listener on port 0 reports the port it got and is reachable there, a second one gets another, and a fixed port reads back as itself
  • QUIC loopback listeners (ShouldBindLoopbackQUIC) — a listener on 127.0.0.1 serves loopback clients and refuses, at once, clients through the host's LAN address or ::1; ::1 works too, and a name is refused
  • The QUIC development certificate (ShouldShareSelfSignedCertificateQUIC) — six processes start selfSigned listeners together from an empty cache, with no openssl on PATH, and all serve one certificate; its directory is private, a later process reuses it, and a mismatched pair on disk is replaced
  • QUIC connection teardown (ShouldSurviveConnectionChurn) — 25 build-up/tear-down cycles with reader threads parked inside RecieveSync, the shape a reconnect loop has; most of the other QUIC tests end in std::_Exit(0)
  • Closed-connection notices (ShouldNotifyClosedConnectionQUIC) — a server deletes each of 100 accepted connections from its OnClosed and the process then exits normally; a peer that exits or freezes, a server-side Stop(), a callback set after the close and an owner that deletes first each behave as documented
  • The peer hears a close (ShouldTellPeerOfCloseQUIC) — the server side of each of 20 connections learns at once that the client destroyed its ClientQUIC, rather than at the idle timeout
  • SendSync under a fast peer (ShouldReturnFromSendSyncQUIC) — 2000 sends to a peer that answers and finishes the stream at once all return
  • Receiving by callback (ShouldRecieveByCallbackQUIC) — bytes waiting before OnRecieve are delivered by it; registered mid-flow no two callbacks overlap and every byte arrives in order; a stream is echoed both ways with every pool worker parked; resets and lost connections end it once, not clean; Abort inside onData enforces a byte cap; and a wrapper destroyed or moved under its callbacks behaves as documented
  • Sending without a thread (ShouldSendWithoutBlockingQUIC) — a 16 MiB burst of SendAsync returns at once and completes in order; a chain of sends from inside callbacks completes with every pool worker parked; a stopped connection, a destroyed wrapper, concurrent senders and a closed stream each report as documented
  • 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, a loop and the redirect limit, a redirect without Location or to another scheme, a POST after 303, 302 and 307 and a PUT after 302, credentials dropped across origins, and the URLs Fetch refuses; ShouldParseURLs runs RFC 3986 §5.4's resolution examples and the parse rules

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).
  • 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} and QUICServerCredentials{selfSigned = true}, and the hash ComputeCertificateHashSHA256 gives browser peers, happen 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

#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 for the license text. The LGPL supplements the GNU General Public License version 3, a copy of which is included as LICENSE.GPL.

The example code under examples/ is licensed under the MIT license (see examples/SimpleClient/LICENSE), so it can be freely copied into your own projects.

Copyright (C) 2026 Catcrafts® Catcrafts.net