Crafter.Network/AGENTS.consumer.md
Jorijn van der Graaf 3a58564422
docs: agent notes for this repository and for its consumers
AGENTS.template.md holds the notes for working on Crafter.Network itself.
crafter-build 4.0.0 writes AGENTS.md from it on every build and test run,
with the notes of each dependency that ships some merged in, so AGENTS.md
is build output and gitignored. AGENTS.consumer.md holds the notes for
agents working in a project that depends on this library; crafter-build
merges it into that project's AGENTS.md, as the Crafter.Build README's
"Dependency notes for agents" describes.

Both were written from the interfaces, implementations, tests and
project.cpp rather than from the README. Where a comment in the sources
disagrees with the code (the HTTP3 header, the -fno-exceptions remarks,
"on the msquic worker", OpenStream(true) "throws", the QUIC certificate's
location and validity), the notes follow the code and list the comment
as a gotcha.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:22:49 +01:00

37 KiB

Crafter.Network

The Crafter engine's networking library: a C++ module, import Crafter.Network;, with TCP, TLS, HTTP/1.1, QUIC, HTTP/3 and WebTransport as clients and listeners on native Linux, and the client half of HTTP and WebTransport in the browser, where the same code compiles for wasm32-wasip1 and runs on fetch() and the WebTransport API. Everything is in namespace Crafter; the two wire-format codecs are Crafter::HTTP1 and Crafter::HTTP3. Callbacks and *Async calls run on Crafter.Thread's pool (import Crafter.Thread;, Crafter::ThreadPool), which comes with the dependency. Source, examples and issues: https://forgejo.catcrafts.net/Catcrafts/Crafter.Network (LGPL-3.0-only; examples/SimpleClient/ is MIT and meant to be copied). The README there is the manual; these notes are its AGENTS.consumer.md: a fix to them goes there, not into the copy crafter-build writes.

Building against it

  • GitProject({ .source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Network.git" } }), or a LocalProject of a sibling checkout, into cfg.dependencies. Its link flags come with it: -lssl -lcrypto from the system OpenSSL, and libmsquic.so.2 from crafter-build's external cache (~/.cache/crafter.build/external/msquic-<hash>/, under ~/.var/app/net.catcrafts.CrafterBuild/cache/ for the Flatpak), which the executable finds through an absolute runpath into that cache. Like every host binary the Flatpak builds, it runs only inside the Flatpak (crafter-build -r, the test runner, flatpak run --command=<path> net.catcrafts.CrafterBuild) and only on the machine that built it; the cache directory is named after msquic's spec hash, and crafter-build clean never touches the cache.
  • A native build clones msquic at a pinned release, v2.6.3, with its submodules, and builds it with CMake on the first build (several minutes, then shared by every project with the same spec) against the OpenSSL the build runs with, 3.5 or newer. crafter-build's Flatpak brings its own git, cmake and OpenSSL 3.5, so nothing is installed for it. A crafter-build built from source uses the host's: git, cmake, the OpenSSL 3 development package (openssl on Arch, libssl-dev on Debian/Ubuntu) and, where present, libnuma (numactl), which msquic links when it finds it. Where that OpenSSL is older than 3.5 (Debian 12, Ubuntu 24.04), build with --no-quic.
  • Project args it reads, from the args your project.cpp forwards (all of them, by default): --no-quic leaves out QUIC, HTTP/3, WebTransport and msquic and keeps TCP, TLS and HTTP/1.1; --local takes Crafter.Thread from the checkout next to Crafter.Network's own instead of forgejo. It strips --no-quic from the args it hands Crafter.Thread; a consumer whose other dependencies also reach Crafter.Thread has to strip it for them the same way, or the build stops with two loads of Crafter.Thread that disagree on their args.
  • The target picks the build: any target whose triple contains wasm is the browser library, everything else the native one. The three surfaces:
native native --no-quic wasm32-*
HTTPRequest, HTTPResponse, CreateRequestHTTP, CreateResponseHTTP, PathWithoutQueryHTTP yes yes yes
ClientTCP, ListenerTCP yes yes no
ByteStream, PlainStream, TLSContext, TLSStream, HTTP1::*, ClientHTTP1, ListenerHTTP1 yes yes no
ClientQUIC, QUICStream yes no *Async only, over WebTransport
ClientHTTP yes no SendAsync only, over fetch()
ListenerQUIC, ListenerHTTP, WebTransportSession, HTTP3::* yes no no
  • Defines do not cross a dependency. The library defines CRAFTER_NETWORK_BROWSER (wasm) and CRAFTER_NETWORK_NO_QUIC (--no-quic) for its own units, and the PCMs carry the surface above either way, but your own #ifdefs see only what your project.cpp defines: a wasm consumer pushes {"CRAFTER_NETWORK_BROWSER", ""} into cfg.defines, as examples/SimpleClient/project.cpp does, and a --no-quic consumer with QUIC-conditional code does the same with CRAFTER_NETWORK_NO_QUIC.
  • Browser build: cfg.target = "wasm32-wasip1", that define, and EnableWasiBrowserRuntime(cfg) after cfg.dependencies is set: it walks the dependencies at call time for the .js files the page must load, and called earlier it misses network-env.js, so the wasm fails to instantiate on its missing env imports. The library ships additional/network-env.js through cfg.files; crafter-build copies it next to your .wasm and the generated page merges it into the env imports, nothing to declare. Its WasmAlloc/WasmFree exports are weak, so it coexists with Crafter.Graphics's DOM backend in one executable.

The thread pool

ThreadPool::Enqueue before ThreadPool::Start() dereferences a null pointer, and most of this library enqueues: start the pool in main before the first listener or *Async call. Start() takes hardware_concurrency() threads, one of them the calling thread's own slot, which only runs tasks inside JoinPool(); Start(n) sets the count, and Start(1) has no worker at all. What goes through the pool:

  • ClientTCP::Recieve*Async, QUICStream::SendAsync and Recieve*Async, ClientHTTP::SendAsync, ClientHTTP1::SendAsync.
  • ClientHTTP as such, Send included: its constructor installs an OnStream drain, and every stream the server opens (its control and QPACK streams, right after the handshake) is delivered as a pool task.
  • ClientQUIC::OnStream and OnDatagram callbacks (each delivery is a pool task, whatever the interface comment says about the msquic worker) and WebTransportSession::OnStream.
  • ListenerTCP::ListenSyncAsync, ListenAsyncSync, ListenAsyncAsync; ListenerQUIC::ListenSyncAsync and ListenAsyncAsync for connectCallback (ListenAsync* run the accept loop on a thread of their own), and every stream a peer opens reaches OnStream through the pool, so a QUIC server needs it regardless.
  • ListenerHTTP and ListenerAsyncHTTP entirely: the per-connection setup (the server's SETTINGS), every route handler and every WebTransport handler are pool tasks, and handlers for one connection run concurrently. A saturated pool stalls the server.

What does not: the *Sync calls of ClientTCP, QUICStream and ClientQUIC, ClientHTTP1::Send, ListenerHTTP1 (a thread per connection, so an idle keep-alive connection pins no worker), ListenerTCP::ListenSyncSync, ListenerQUIC::ListenSyncSync, the codecs and the TLS streams. A task that blocks for the life of a connection (a reader parked in RecieveSync) holds a worker that long: give such readers a std::thread, as the library's tests do.

HTTP, shared by both versions

HTTPRequest { method, scheme = "https", authority, path = "/", headers, body } and HTTPResponse { status = "200", headers, body }, with headers an std::unordered_map<std::string, std::string> keyed lowercase (HTTP/3 forbids uppercase names; the HTTP/1.1 side lowercases on parse and on send, the HTTP/3 client on send) and status the three digits as a string: response.status == "200". The four pseudo-headers are fields, never map entries. CreateRequestHTTP(method, path, authority[, headers][, body]) and CreateResponseHTTP(status[, headers][, body]) fill one in; PathWithoutQueryHTTP(target) is everything before ?.

A route map is std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>>. ListenerHTTP1 and ListenerHTTP take the same map and the same optional fallback, and dispatch alike: the exact path, then the query-stripped path (/thing?x=1 reaches /thing, with the full target still in request.path), then fallback, then a synthetic 404. A handler or fallback that throws becomes a 500 with what() in the body; ListenerHTTP catches only std::exception, so anything else escapes the pool task and ends the program, and its try also covers request parsing, so a malformed HTTP/3 request is a 500 where HTTP/1.1 answers 400. fallback is a plain member to assign before Listen(); the ListenerAsync* wrappers start accepting in their constructor, so there it is the constructor argument after routes (before the TLS credentials on ListenerAsyncHTTP1, last on ListenerAsyncHTTP). A handler moves between the protocols by changing the class name; tests/ShouldFallbackUnknownRoutes replays one table over both.

HTTP/1.1

ClientHTTP1(host, port) is plaintext, ClientHTTP1(host, port, TLSClientCredentials) is https://. Send(request) returns the HTTPResponse on the calling thread, no pool; SendAsync(request, onSuccess, onError) runs it as a pool task with the error as a string. The connection is persistent: the first Send dials, later ones reuse the socket until the peer asks to close; a pooled connection the peer closed meanwhile is redialled once and the request replayed, never once a response byte has arrived and never on a fresh connection, so a broken server is an exception, not a loop. An empty authority means host:port, with the scheme's default port left off (443 under TLS, 80 without); scheme is ignored (origin-form targets; the constructor decided the transport). Set limits (HTTP1::MessageLimits: start line 8 KiB, header section 64 KiB, body 512 MiB), timeout (30 s for the next piece of a response) and handshakeTimeout (15 s) before the first Send. Connected(), Secure(), Protocol() (the ALPN the last connection agreed, "http/1.1" under TLS), Disconnect(). One instance serves one caller at a time; instances are independent. Throws HTTP1::HTTP1ProtocolError for a response it refuses to parse, TLSException for a certificate it rejects, std::runtime_error for a socket that cannot connect, times out or closes early. There is no connect timeout: the dial is a ClientTCP with the default zero, so a host that never answers blocks Send for the kernel's two minutes before handshakeTimeout and timeout even start.

ListenerHTTP1(port, routes[, fallback][, TLSServerCredentials]) is a ListenerTCP underneath (every IPv4 address, SO_REUSEADDR) and throws std::runtime_error when the port cannot be bound, or TLSException when the credentials yield no usable certificate. Listen() runs the accept loop on the calling thread until Stop(), which stops accepting, closes every live connection and joins their threads. ListenerAsyncHTTP1, same constructors, runs that loop on its thread and exposes the listener; Stop() joins and the destructor stops. Each accepted connection gets its own thread, served one request at a time until the peer closes, sends connection: close, or a timeout passes: keepAliveTimeout 15 s between requests, requestTimeout 30 s between pieces of one request (each read, not the whole request), handshakeTimeout 15 s for TLS; limits as on the client. ConnectionCount(), AcceptedCount(), HandshakeFailureCount() (connections dropped in the TLS handshake: a scanner, a client with no protocol in common, an untrusted client certificate; ordinary on a public port), Secure().

  • Implemented: keep-alive and pipelining, content-length and chunked request bodies with trailers (merged into headers), Expect: 100-continue, absolute-form targets, obs-fold, HTTP/1.0 peers (reuse only when they ask), HEAD (the route's handler runs with method HEAD and its body is dropped, content-length kept), an automatic date unless the handler set one, a handler ending the connection with a connection: close header. Requests reach handlers with scheme "http" or "https" as the transport decided. Not implemented: CONNECT, Upgrade, chunked responses (a handler returns the whole body; responses are content-length framed, and 1xx/204/304 carry none).
  • Refused with a 400 rather than guessed at (request smuggling, RFC 9112 §11.2): Content-Length with Transfer-Encoding, disagreeing duplicate Content-Length, whitespace before a colon, HTTP/0.9, a version other than 1.0 or 1.1, stacked or non-chunked transfer codings. A CR or LF in a header value you ask it to send throws HTTP1ProtocolError instead of splitting the message.
  • The framing headers host, content-length, transfer-encoding and connection belong to the serialiser: copies in your headers map are dropped, so set authority, body and the options, not those names. Pseudo-header names (:method and the like) in the map are skipped.

HTTP1::MessageParser(kind, limits) with SerializeRequest, SerializeResponse and SerializeContinue is the codec on its own, for HTTP/1.1 over a byte stream the library does not own: Feed(bytes) takes arbitrary chunks, Finish() says the peer closed its send side (completes a read-to-EOF body, errors anything else mid-message), then Complete(), TakeRequest() / TakeResponse(), and Reset() to rearm keeping pipelined bytes. SetRequestMethod("HEAD") before feeding a response, since a HEAD response has no body whatever its length says; SetDefaultScheme("https") once per connection; HeadersComplete(), ExpectsContinue() / ContinueSent(), KeepAlive(), AtMessageBoundary(), Method(), Version(), Status(). Interim 1xx responses are dropped. RequestOptions{ keepAlive }, ResponseOptions{ keepAlive, explicitKeepAlive, omitBody }. Helpers: HasToken(fieldValue, token), SplitCommaList, ReasonPhrase(status), FormatHTTPDate(time_point).

TLS

:TLS is libssl (OpenSSL 3) over any connected descriptor, HTTP or not, and no OpenSSL type crosses the interface. TLS 1.2 is the floor; the platform's cipher defaults apply.

  • TLSServerCredentials: certPath/keyPath (PEM files; the certificate file may be a chain, leaf first), else certPem/keyPem (the same inline), else selfSigned; clientCaPath with requireClientCertificate for mutual TLS (a peer with no certificate, or one not chaining to that CA, is dropped in the handshake; an empty clientCaPath means the system store); alpnProtocols = { "http/1.1" } in preference order. A client that offers ALPN and nothing on the list is refused with no_application_protocol; a client offering no ALPN is served.
  • TLSClientCredentials: the default verifies the chain against the system store and the hostname, both. caPath / caPem add a trust anchor (a self-signed listener's own certificate) without replacing the store; serverName overrides SNI and the name checked (a tunnel, a pinned IP); certPath/keyPath present a client certificate; alpnProtocols = { "http/1.1" }, empty sends none; insecureNoServerValidation skips both checks and accepts anyone, development only. An IP literal as host sends no SNI and is checked against the certificate's iPAddress SANs.
  • TLSContext::Server(creds) / Client(creds) return a std::shared_ptr<TLSContext> shared by every stream it produces (one per listener or client: the trust store and certificate are parsed once, and a stream keeps its context alive). TLSStream::Connect(fd, context, hostName, timeout) and Accept(fd, context, timeout) complete the handshake before returning a std::unique_ptr<TLSStream>, or throw TLSException with the certificate error spelled out. The descriptor stays owned by whoever opened it (a ClientTCP's socketid) and is switched to non-blocking. Version() ("TLSv1.3"), PeerCertificateSubject() (empty without a client certificate; with requireClientCertificate it is the authenticated identity), Protocol().
  • ByteStream is what TLSStream and PlainStream(fd) implement and the HTTP/1.1 endpoints are written against: ReadSome(buffer, size, timeout, read&) returns StreamStatus::Data, Closed or TimedOut and throws on a transport error; Write(buffer, size, timeout) writes everything or throws (PlainStream a std::runtime_error such as timed out writing to the peer, TLSStream a TLSException); Shutdown() never throws; Descriptor(), Protocol(), Secure(). Every call carries its own timeout. SetNonBlocking(fd) and PollDescriptor(fd, events, deadline) are exported for code of the same shape.
  • GetSelfSignedCertificatePem() is the process-wide development certificate selfSigned uses, as TLSCertificatePem { certificate, privateKey }: ECDSA P-256, CN=localhost, SANs localhost, 127.0.0.1, ::1, 10 days, generated in-process on first use and new on every process start. Hand .certificate to a client's caPem to test real verification; tests/ShouldSendRecieveHTTPS1 does. It is not the certificate the QUIC listener's selfSigned uses, see "QUIC".

TCP

ClientTCP(host, port, connectTimeout = 0ms) resolves with getaddrinfo, IPv4 only, and connects; zero waits out the kernel's retries (about two minutes to a host that never answers), a timeout throws SocketTimeoutException after that long, and a refused connect throws std::runtime_error at once. ClientTCP(int socket) adopts a descriptor. Move-only; the destructor shuts down and closes socketid, a public member. SetNoDelay(true) for small frames that must leave at once; SetReceiveTimeout(ms) makes a receive that waits longer throw SocketTimeoutException, losing what a whole-buffer receive had collected; Stop() closes; Connect(timeout) after it opens a new socket to the host and port the client was built from (not on one that adopted a descriptor, which recorded no address).

  • Send(buffer, size) writes it all. The receives return what arrived, at least one byte: RecieveSync() one chunk, RecieveSync(n) up to n, RecieveUntilFullSync(n) exactly n, RecieveUntilCloseSync() everything to the peer's close, RecieveSync(n, buffer) into your memory, returning the count. Asked for zero bytes they return at once. They throw SocketClosedException once the peer has closed, SocketTimeoutException on a receive timeout, std::runtime_error otherwise, and wait on through a signal.
  • The *Async receives (RecieveAsync, RecieveUntilCloseAsync, RecieveAsync(n, ...), RecieveUntilFullAsync(n, ...), and RecieveAsync(n, callback(count), buffer, ...) into memory that must outlive the call) run the same receive as a pool task and take an onError(std::exception_ptr): what the receive throws goes there, and an empty onError drops it, since an exception leaving a pool task ends the program. The task uses the ClientTCP, which must outlive it; a parked receive ends through Stop() from another thread or a receive timeout, both of which arrive at onError.

ListenerTCP(port, connectCallback, concurrentClientLimit, totalClientLimit) binds every IPv4 address with SO_REUSEADDR, backlog 128, and throws std::runtime_error when it cannot bind or listen. The callback gets a heap-allocated ClientTCP* and owns it: delete it when the connection is done. ListenSyncSync() accepts on the calling thread and calls the callback there; ListenSyncAsync() calls it as a pool task; ListenAsyncSync() and ListenAsyncAsync() run the accept loop itself as a pool task, the second with the callback as another. Stop() closes the socket, which ends the loop. totalClientLimit ends it after that many accepts; concurrentClientLimit is stored and not enforced.

QUIC

ClientQUIC(host, port, alpn, QUICClientCredentials creds = {}) connects over IPv4 or IPv6 and blocks until the handshake is done, or throws QUICException once settings.handshakeIdleTimeoutMs (10 s) passes. alpn is any token both sides agree on ("h3", HTTP3::kAlpn, for HTTP/3). Move-only.

  • QUICClientCredentials: insecureNoServerValidation (accepts any certificate; development only), serverCertificateHash (32 bytes; unused natively, forwarded to serverCertificateHashes in the browser, where all zeros means the browser's trust store), settings. QUICServerCredentials: certPath/keyPath or selfSigned, and settings.
  • QUICSettings, applied to the msquic configuration: idleTimeoutMs 120 000, handshakeIdleTimeoutMs 10 000, keepAliveIntervalMs 10 000 (client only; keep it below the idle timeout), peerUnidiStreamCount 16 and peerBidiStreamCount 16 (how many streams the peer may have open toward you at once; an HTTP/3 server needs 3 unidirectional ones; each costs flow-control state, and a peer past the cap stalls, as tests/ShouldHonourQUICSettings shows), datagramReceiveEnabled true.
  • OpenStream(unidirectional = false) returns a QUICStream whose start msquic has queued (it starts asynchronously, whatever the interface comment says), or throws QUICException when it cannot be opened; GetStreamId() throws until the stream has started, so call it once the stream has carried data. SendDatagram(buffer, size) is best effort within the path MTU (about 1200 bytes on the open internet) and throws QUICException when msquic refuses it. OnStream(callback(QUICStream)) receives the streams the peer opens; streams that arrived before it was set are queued and delivered when it is, so setting it after the constructor returns is fine. OnDatagram(callback(std::vector<char>)); without one, datagrams queue for RecieveDatagramSync(), which blocks and throws QUICClosedException when the connection closes first. A datagram that arrives before OnDatagram is set goes to that queue, never to the callback: a server that sets OnDatagram in connectCallback can miss one sent right after the handshake (tests/ShouldSendRecieveQUICDatagram sleeps 200 ms for it). Both callbacks run as pool tasks. Stop() initiates a clean shutdown and returns. GetHandle() is the msquic HQUIC.
  • QUICStream is move-only, with connection, canSend, canReceive (a unidirectional stream has one of them false) and the msquic handle. SendSync(buffer, size, finish = false) blocks until msquic has taken the buffer, throws QUICClosedException on a closed stream or connection and QUICException on a failed send; finish closes the send side after it (the peer's RecieveUntilCloseSync returns). RecieveSync() waits for at least one byte and throws QUICClosedException once the peer closed its side and the buffer is drained; RecieveUntilCloseSync(); RecieveUntilFullSync(n) takes exactly n or throws and leaves what arrived for the next receive. SendAsync(buffer, size, finish, onSent(bool)) copies the buffer and tells the callback whether the stream took it; RecieveAsync, RecieveUntilCloseAsync and RecieveUntilFullAsync(n, ...) give a closed stream an empty chunk and an early close what arrived, never an exception. A default-constructed QUICStream behaves as closed. PrependReceived(bytes) pushes bytes back for the next receive (a demuxer that peeked a prefix); GetStreamId() throws QUICException before the stream has started; Stop() shuts both directions down (the send side with a FIN, the receive side abortively, else a peer holding its side open keeps the handle alive), completes asynchronously, and is what the destructor calls.
  • Lifetimes: ~ClientQUIC first destroys peer streams nobody claimed, then shuts the connection down and closes it, waiting up to 5 s for every stream on it to finish closing. Streams may outlive their connection, dead; destroy or Stop() them first when you can. msquic has two process-wide registrations here, one for clients and one for listeners and the peers they accept, each closed by a static destructor at exit, and each close blocks until every connection and stream handle opened under it is closed: an accepted peer you never deleted holds the listener's until the idle timeout, two minutes by default. Delete or Stop() what you own before main returns, or end with std::_Exit after flushing, as the library's own tests do.

ListenerQUIC(port, alpn, QUICServerCredentials, connectCallback(ClientQUIC*), concurrentClientLimit, totalClientLimit) listens on every address of both families and throws QUICException when it cannot. The callback's ClientQUIC* is heap-allocated and yours: set OnStream / OnDatagram on it, keep it as long as the connection should live, Stop() or delete it to shut the connection down, and delete it in any case, since only that frees it (a connection the peer or the idle timeout ended has its msquic handle closed for it, but the object stays). ListenSyncSync() runs the accept loop on the calling thread and the callback there; ListenSyncAsync() runs the callback as a pool task; ListenAsyncSync() and ListenAsyncAsync() run the loop on a thread of their own, the second with the callback on the pool. Stop() stops accepting; the destructor stops, joins, and deletes peers accepted but not yet handed out. totalClientLimit ends the hand-off loop after that many connections (later ones queue until the listener is destroyed); concurrentClientLimit is stored and not enforced.

  • selfSigned shells out to the openssl CLI (install it, or pass certPath/keyPath; without it the constructor throws QUICException) and caches the pair at /tmp/crafter-network-quic-cert/{cert,key}.pem, reused across restarts while valid (inside crafter-build's Flatpak, /tmp is a per-app directory discarded once no instance is running, so there the pair rarely survives between runs; pass certPath/keyPath for a stable certificate): ECDSA P-256, CN=localhost, SANs localhost, 127.0.0.1, ::1, 10 days, with the extensions Chromium's serverCertificateHashes demands. GetSelfSignedCertificatePath() is that file, and ComputeCertificateHashSHA256(certPath) (also through openssl) is the 32-byte digest a browser pins, see "Browser build". A different certificate from the TLS partition's: a client of both needs both.

HTTP/3 and WebTransport

ClientHTTP(host, port, QUICClientCredentials creds = {}) opens the QUIC connection in the constructor (ALPN h3, the mandatory control stream and SETTINGS, the server's control and QPACK streams drained) and throws QUICException when it cannot. Send(request) opens a request stream, sends HEADERS (an empty authority means host:port; names lowercased; scheme defaults to https) and a DATA frame for a non-empty body, FINs, reads to the server's FIN, and returns the status, the headers and the DATA frames concatenated; trailers are decoded and dropped. Throws QUICClosedException when the connection is gone before the request is sent, and HTTP3::HTTP3ProtocolError for a malformed response, which is also what a connection lost mid-response looks like (RecieveUntilCloseSync returns what arrived). SendAsync(request, onSuccess, onError) is Send as a pool task with the error text. One caller at a time per instance; instances are independent connections. It interoperates with public h3 servers through the system trust store (tests/ShouldSend fetches https://cloudflare-quic.com/): QPACK static table and Huffman decoding, no dynamic table.

ListenerHTTP(port, QUICServerCredentials, routes[, wtRoutes][, fallback]) is a ListenerQUIC with ALPN h3 that parses one request per bidirectional stream, dispatches as under "HTTP", and answers on the same stream. Listen() blocks the calling thread (connection setup, route handlers and WT handlers are pool tasks, and handlers for one connection run concurrently); Stop(). ListenerAsyncHTTP, same constructors, runs Listen() on its thread; Stop() joins and the destructor stops. A non-empty wtRoutes (std::unordered_map<std::string, std::function<void(WebTransportSession&)>>, keyed by :path) advertises both the draft-02 and the draft-07+ WebTransport SETTINGS, so current Chrome and Firefox connect (per the code's comments; no test drives a browser), and accepts an extended CONNECT (:method=CONNECT, :protocol=webtransport) whose :path matches an entry exactly, query string included, with a 200 and no FIN; a CONNECT matching nothing is a 404, and fallback does not apply to it. Plain routes and WT routes share the port.

WebTransportSession is handed by reference to the WT handler, as a pool task, and owned by the listener: the handler may return at once and the session lives on. Per-connection state is never pruned, so the sessions, streams and ClientQUIC of every connection the listener ever accepted live until the listener is destroyed (Stop() only stops accepting; the destructor then runs ~ClientQUIC per peer, each waiting up to 5 s for its streams). sessionId (the CONNECT stream's id), path; OpenStream(unidirectional = false) writes the WebTransport stream prefix first, so your first send is the first payload byte (unidirectional = true writes the unidirectional prefix; the interface comment saying it throws is stale); OnStream(callback(QUICStream)) for streams the browser opens, with the 0x41 prefix and the session id already stripped, so what you read is payload, and streams that arrived before the handler delivered when it is set; Close() FINs the CONNECT stream, which browsers take as the session's end, and so does the destructor. Not there yet: OnDatagram and SendDatagram are stubs (the second drops silently), streams the browser opens unidirectionally are drained by the listener and never reach a session, and there is no capsule protocol.

There is no WebTransport client class natively: a client is a ClientQUIC with ALPN h3 plus the HTTP3 helpers, as tests/ShouldEchoWebTransport builds one (the control-stream prelude from BuildWebTransportControlStreamPrelude(maxSessions), a CONNECT through EncodeFieldSection and WriteFrame, BuildWtBidiPrefix(sessionId) first on each data stream). The helpers: EncodeVarint/DecodeVarint, EncodeFieldSection(fields)/DecodeFieldSection(ptr, len), DecodeHuffman, WriteFrame(out, type, payload, len), BuildControlStreamPrelude(), BuildWtUnidiPrefix, the frame, stream-type and setting constants, and HTTP3ProtocolError. The comment at the top of Crafter.Network-HTTP3.cppm predates the export and the Huffman decoder; the code wins. Any ClientQUIC against an HTTP/3 server must drain the server's unidirectional streams (OnStream with a reader loop, as that test does), or they back up and msquic may abort the connection.

Browser build

On a wasm32-* target the same ClientHTTP and ClientQUIC compile against the browser, *Async only (the *Sync methods are not compiled), and everything else in the table above is left out of the build or compiled as an empty partition.

  • ClientHTTP::SendAsync is fetch(): the URL is scheme://authority/path with authority defaulting to host:port, or the bare path when the client was built with an empty host (ClientHTTP("", 0)), which fetches from the page's own origin; the body is sent for any method but GET and HEAD; the response's status is the number as a string, its headers as the browser lowercases them, its body the bytes. QUICClientCredentials is ignored, and CORS applies as to any fetch.
  • ClientQUIC(host, port, alpn, creds) returns at once and opens new WebTransport("https://host:port/alpn", { serverCertificateHashes }) in the background: alpn is the URL path here, so the server is a ListenerHTTP with a wtRoutes entry keyed /<alpn> (the example passes echo and registers /echo; a plain ListenerQUIC is unreachable from a browser), and the QUIC ALPN is the browser's h3. Calls before the connection is ready queue on the JS side. An all-zero serverCertificateHash means the browser's trust store, so a self-signed server needs its hash: the native side writes ComputeCertificateHashSHA256(GetSelfSignedCertificatePath()) somewhere the page can fetch it and the page feeds it in, as examples/SimpleClient does with cert-hash.txt. The *Async stream calls behave as natively: a closed stream gives an empty chunk, an early close what arrived, a failed write false. The rest differs: a stream the server opens before OnStream is set is closed, not queued; OpenStream on a closed or failed connection returns a default-constructed (closed) stream instead of throwing; SendDatagram drops silently instead of throwing; a datagram with no OnDatagram handler is dropped, there is no queue and no RecieveDatagramSync; and the constructor cannot report a failed connection, which shows up as those sentinels.
  • Callbacks run on the JS event loop after main has returned, so the objects they need must outlive main (static, or heap-allocated and left alone); a ClientQUIC or QUICStream destroyed at the end of main closes its connection or stream first. std::cout << std::unitbuf; so prints from callbacks reach the console. exit and _Exit end the program for good, but network-env.js does not notice and keeps calling into the instance when a fetch or a stream completes, after the static destructors have run: don't exit with a request or a stream pending.
  • The page needs a secure context (HTTPS) for WebTransport and a browser with WebAssembly exception handling (Chrome 137, Firefox 131, Safari 18.4 or newer; the generated page names what an older one lacks). The example's workflow: crafter-build --target=x86_64-pc-linux-gnu builds the native echo server, which writes cert-hash.txt into its working directory at startup; plain crafter-build builds the wasm, and cfg.files copies the tracked examples/SimpleClient/cert-hash.txt next to it, which is only a placeholder; the page fetches /cert-hash.txt from its own origin, so serve the wasm output directory over HTTPS and run the server with that directory as its working directory (crafter-build -r would run it from its own bin/ directory instead), then open the page.

Errors

Call On failure
ClientTCP constructor, Connect, Send, Recieve*Sync std::runtime_error (resolve, refused, socket errors); SocketTimeoutException (connect or receive timeout); SocketClosedException (the peer closed)
ClientTCP::Recieve*Async the same, as an std::exception_ptr to onError; dropped without one
ListenerTCP, ListenerHTTP1, ListenerAsyncHTTP1 constructors std::runtime_error when the port cannot be bound; TLSException for unusable credentials
ListenerHTTP, ListenerAsyncHTTP constructors QUICException (they build a ListenerQUIC)
ClientHTTP1(host, port, TLSClientCredentials) constructor TLSException for an unreadable certificate or trust anchor, at construction rather than at the first Send
TLSContext::Server/Client, TLSStream::Connect/Accept TLSException, naming the certificate error
ByteStream::ReadSome / Write std::runtime_error; a read that times out is StreamStatus::TimedOut, a write that does throws
ClientHTTP1::Send HTTP1::HTTP1ProtocolError, TLSException, std::runtime_error (timed out waiting for the response, connection closed before a complete response arrived, the socket's)
ClientHTTP1::SendAsync, ClientHTTP::SendAsync what() as a string to onError
HTTP1::MessageParser, SerializeRequest/SerializeResponse HTTP1::HTTP1ProtocolError
ClientQUIC constructor, OpenStream, SendDatagram, GetStreamId, ListenerQUIC constructor QUICException (native; the browser build's ClientQUIC never throws, see "Browser build")
QUICStream::SendSync, Recieve*Sync, RecieveDatagramSync, WebTransportSession::OpenStream QUICClosedException (SendSync also QUICException for a send msquic refuses)
QUICStream::*Async never throw: an empty chunk, what arrived, or sent == false
ClientHTTP::Send QUICException, QUICClosedException, HTTP3::HTTP3ProtocolError
Route handlers and fallbacks an exception becomes a 500; a WT handler's exception is swallowed

SocketClosedException, SocketTimeoutException and QUICClosedException derive from std::exception, not std::runtime_error; QUICException, TLSException, HTTP1ProtocolError and HTTP3ProtocolError do, so a catch (const std::runtime_error&) misses the first three.

Spellings

Recieve is how every receive is spelled: RecieveSync, RecieveUntilCloseSync, RecieveUntilFullSync, RecieveAsync, RecieveUntilCloseAsync, RecieveUntilFullAsync, RecieveDatagramSync, and the tests ShouldRecieveTCP and ShouldSendRecieve*. The listeners' loop variants are ListenSyncSync, ListenSyncAsync, ListenAsyncSync, ListenAsyncAsync: the accept loop first, then the callback. Don't correct them in a consumer; a rename is an upstream change for every consumer at once.

Testing a consumer

  • Start the pool first, and end a QUIC test with std::fflush(stdout); std::_Exit(code);, or delete every accepted peer and Stop() every client before main returns; otherwise the exit waits for the idle timeout.
  • crafter-build runs tests in parallel, so each test binds a port of its own. ListenerTCP and ListenerHTTP1 set SO_REUSEADDR, so a port in TIME_WAIT from the previous run is fine; one held by a stray server from a crashed run is Could not bind port. For an ephemeral port, bind a socket yourself and read it back with getsockname, as tests/ShouldConfigureTCP does, then hand it to a ClientTCP.
  • QUICServerCredentials{ .selfSigned = true } against QUICClientCredentials{ .insecureNoServerValidation = true } is the local pair; TLSServerCredentials{ .selfSigned = true } against TLSClientCredentials{ .caPem = GetSelfSignedCertificatePem().certificate } exercises real verification. Neither needs anything installed except, for QUIC, the openssl CLI (the Flatpak has it).
  • Give a blocked reader (RecieveSync in a loop) a std::thread, not a pool task, and a test that could hang a watchdog thread that calls std::_Exit(1) sooner than the runner's 60 s timeout, as tests/ShouldNotDropEarlyStreams does at 8 s (the churn test's 90 s watchdog never fires first).