The README's self-signed section, its dependency list, the consumer notes and the agent notes described the openssl CLI and the shared /tmp/crafter-network-quic-cert/. They now give the per-user location and its fallbacks, the day-before-expiry rollover (which is when a pinned hash changes), what keeps processes that start together on one pair, and that nothing has to be installed. The agent notes' dev environment says where the Flatpak keeps the certificate between runs, drops the two gotchas the move resolved (the CLI, and the comments in -ListenerQUIC.cpp and ClientQUIC.cppm that disagreed with the code), and notes the module-internal functions :TLS declares for the listener. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
41 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 aLocalProjectof a sibling checkout, intocfg.dependencies. Its link flags come with it:-lssl -lcryptofrom the system OpenSSL, andlibmsquic.so.2from 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, andcrafter-build cleannever 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,cmakeand 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 (opensslon Arch,libssl-devon 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.cppforwards (all of them, by default):--no-quicleaves out QUIC, HTTP/3, WebTransport and msquic and keeps TCP, TLS and HTTP/1.1;--localtakes Crafter.Thread from the checkout next to Crafter.Network's own instead of forgejo. It strips--no-quicfrom 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
wasmis 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::*, URL, 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) andCRAFTER_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 yourproject.cppdefines: a wasm consumer pushes{"CRAFTER_NETWORK_BROWSER", ""}intocfg.defines, asexamples/SimpleClient/project.cppdoes, and a--no-quicconsumer with QUIC-conditional code does the same withCRAFTER_NETWORK_NO_QUIC. - Browser build:
cfg.target = "wasm32-wasip1", that define, andEnableWasiBrowserRuntime(cfg)aftercfg.dependenciesis set: it walks the dependencies at call time for the.jsfiles the page must load, and called earlier it missesnetwork-env.js, so the wasm fails to instantiate on its missingenvimports. The library shipsadditional/network-env.jsthroughcfg.files; crafter-build copies it next to your.wasmand the generated page merges it into theenvimports, nothing to declare. ItsWasmAlloc/WasmFreeexports 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::SendAsyncandRecieve*Async,ClientHTTP::SendAsync,ClientHTTP1::SendAsync.ClientHTTPas such,Sendincluded: its constructor installs anOnStreamdrain, and every stream the server opens (its control and QPACK streams, right after the handshake) is delivered as a pool task.ClientQUIC::OnStreamandOnDatagramcallbacks (each delivery is a pool task, whatever the interface comment says about the msquic worker) andWebTransportSession::OnStream.ListenerTCP::ListenSyncAsync,ListenAsyncSync,ListenAsyncAsync;ListenerQUIC::ListenSyncAsyncandListenAsyncAsyncforconnectCallback(ListenAsync*run the accept loop on a thread of their own), and every stream a peer opens reachesOnStreamthrough the pool, so a QUIC server needs it regardless.ListenerHTTPandListenerAsyncHTTPentirely: 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), connectTimeout (15 s, per address the host resolves to), handshakeTimeout (15 s) and timeout (30 s for the next piece of a response) 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, SocketErrorException for a socket that cannot resolve or connect or breaks under the request, SocketTimeoutException when the connect runs out, and std::runtime_error when a response stops arriving or the connection closes before one is complete.
ClientHTTP1::Fetch(url, options = {}) is one request to an http:// or https:// URL with redirects followed by RFC 9110 §15.4 (301, 302, 303, 307, 308 for every method; a 303 makes the request a GET without its body and content headers, a 301 or 302 does so for a POST alone, a 307 or 308 keeps method and body), the Location resolved against the sending URL, a hop between the schemes allowed, Authorization, Proxy-Authorization and Cookie dropped on a change of origin, and a client per hop. FetchOptions { method, headers, body, connectTimeout, handshakeTimeout, timeout, limits, credentials, maxRedirects = 10 }; FetchResult { response, url, redirects, stop }, where url is the Crafter::URL that answered and stop (FetchStop::None, NoLocation, OtherScheme, Loop, Limit) says why a redirect came back unfollowed. Throws std::invalid_argument for a URL that is not http(s) or that URL::Parse refuses, else what Send throws. URL::Parse(text), url.Resolve(reference), url.ToString(), Authority(), Target(), PortOrDefault(), Secure(), SameOrigin(other) are the URL type behind it (scheme, userinfo, host, port, path, query, fragment; scheme and host lowercased, an IPv6 host without its brackets).
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-lengthandchunkedrequest bodies with trailers (merged intoheaders),Expect: 100-continue, absolute-form targets, obs-fold, HTTP/1.0 peers (reuse only when they ask), HEAD (the route's handler runs with methodHEADand its body is dropped,content-lengthkept), an automaticdateunless the handler set one, a handler ending the connection with aconnection: closeheader. Requests reach handlers withscheme"http"or"https"as the transport decided. Not implemented: CONNECT,Upgrade, chunked responses (a handler returns the whole body; responses arecontent-lengthframed, and 1xx/204/304 carry none). - Refused with a 400 rather than guessed at (request smuggling, RFC 9112 §11.2):
Content-LengthwithTransfer-Encoding, disagreeing duplicateContent-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 throwsHTTP1ProtocolErrorinstead of splitting the message. - The framing headers
host,content-length,transfer-encodingandconnectionbelong to the serialiser: copies in yourheadersmap are dropped, so setauthority,bodyand the options, not those names. Pseudo-header names (:methodand 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), elsecertPem/keyPem(the same inline), elseselfSigned;clientCaPathwithrequireClientCertificatefor mutual TLS (a peer with no certificate, or one not chaining to that CA, is dropped in the handshake; an emptyclientCaPathmeans the system store);alpnProtocols = { "http/1.1" }in preference order. A client that offers ALPN and nothing on the list is refused withno_application_protocol; a client offering no ALPN is served.TLSClientCredentials: the default verifies the chain against the system store and the hostname, both.caPath/caPemadd a trust anchor (a self-signed listener's own certificate) without replacing the store;serverNameoverrides SNI and the name checked (a tunnel, a pinned IP);certPath/keyPathpresent a client certificate;alpnProtocols = { "http/1.1" }, empty sends none;insecureNoServerValidationskips both checks and accepts anyone, development only. An IP literal as host sends no SNI and is checked against the certificate'siPAddressSANs.TLSContext::Server(creds)/Client(creds)return astd::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)andAccept(fd, context, timeout)complete the handshake before returning astd::unique_ptr<TLSStream>, or throwTLSExceptionwith the certificate error spelled out. The descriptor stays owned by whoever opened it (aClientTCP'ssocketid) and is switched to non-blocking.Version()("TLSv1.3"),PeerCertificateSubject()(empty without a client certificate; withrequireClientCertificateit is the authenticated identity),Protocol().ByteStreamis whatTLSStreamandPlainStream(fd)implement and the HTTP/1.1 endpoints are written against:ReadSome(buffer, size, timeout, read&)returnsStreamStatus::Data,ClosedorTimedOutand throws on a transport error;Write(buffer, size, timeout)writes everything or throws (PlainStreamastd::runtime_errorsuch astimed out writing to the peer,TLSStreamaTLSException);Shutdown()never throws;Descriptor(),Protocol(),Secure(). Every call carries its own timeout.SetNonBlocking(fd)andPollDescriptor(fd, events, deadline)are exported for code of the same shape.GetSelfSignedCertificatePem()is the process-wide development certificateselfSigneduses, asTLSCertificatePem { certificate, privateKey }: ECDSA P-256,CN=localhost, SANslocalhost,127.0.0.1,::1, 10 days, generated in-process on first use and new on every process start. Hand.certificateto a client'scaPemto test real verification;tests/ShouldSendRecieveHTTPS1does. It is not the certificate the QUIC listener'sselfSigneduses, see "QUIC".
TCP
ClientTCP(host, port, connectTimeout = 0ms) resolves with getaddrinfo for IPv4 and IPv6 and tries every address in the order it returned them, so localhost reaches a server on ::1 alone; zero waits out the kernel's retries per address (about two minutes to a host that never answers), a timeout throws SocketTimeoutException after that long per address, and when no address connects what the last one failed with is thrown. A failed resolve, socket(), connect, send or receive throws SocketErrorException, a std::system_error: code() is the errno under std::generic_category() or the getaddrinfo result under Crafter::ResolverCategory() (whose message() is gai_strerror's), and phase is the step (SocketPhase::Resolve, Open, Connect, Send, Receive); what() is the message ClientTCP always threw with the code's text after it, so a handler for std::runtime_error still catches it, and [Errno {}] {} is code().value() and code().message(). 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 ton,RecieveUntilFullSync(n)exactlyn,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 throwSocketClosedExceptiononce the peer has closed,SocketTimeoutExceptionon a receive timeout,SocketErrorExceptionotherwise, and wait on through a signal.- The
*Asyncreceives (RecieveAsync,RecieveUntilCloseAsync,RecieveAsync(n, ...),RecieveUntilFullAsync(n, ...), andRecieveAsync(n, callback(count), buffer, ...)into memory that must outlive the call) run the same receive as a pool task and take anonError(std::exception_ptr): what the receive throws goes there, and an emptyonErrordrops it, since an exception leaving a pool task ends the program. The task uses theClientTCP, which must outlive it; a parked receive ends throughStop()from another thread or a receive timeout, both of which arrive atonError.
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; ListenerTCP(address, port, ...) binds that one address, IPv4 or IPv6, and port 0 lets the kernel pick, reported by Port(). 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 toserverCertificateHashesin the browser, where all zeros means the browser's trust store),settings.QUICServerCredentials:certPath/keyPathorselfSigned, andsettings.QUICSettings, applied to the msquic configuration:idleTimeoutMs120 000,handshakeIdleTimeoutMs10 000,keepAliveIntervalMs10 000 (client only; keep it below the idle timeout),peerUnidiStreamCount16 andpeerBidiStreamCount16 (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, astests/ShouldHonourQUICSettingsshows),datagramReceiveEnabledtrue.OpenStream(unidirectional = false)returns aQUICStreamwhose start msquic has queued (it starts asynchronously, whatever the interface comment says), or throwsQUICExceptionwhen 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 throwsQUICExceptionwhen 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 forRecieveDatagramSync(), which blocks and throwsQUICClosedExceptionwhen the connection closes first. A datagram that arrives beforeOnDatagramis set goes to that queue, never to the callback: a server that setsOnDatagraminconnectCallbackcan miss one sent right after the handshake (tests/ShouldSendRecieveQUICDatagramsleeps 200 ms for it). Both callbacks run as pool tasks.Stop()initiates a clean shutdown and returns.GetHandle()is the msquicHQUIC.QUICStreamis move-only, withconnection,canSend,canReceive(a unidirectional stream has one of them false) and the msquichandle.SendSync(buffer, size, finish = false)blocks until msquic has taken the buffer, throwsQUICClosedExceptionon a closed stream or connection andQUICExceptionon a failed send;finishcloses the send side after it (the peer'sRecieveUntilCloseSyncreturns).RecieveSync()waits for at least one byte and throwsQUICClosedExceptiononce the peer closed its side and the buffer is drained;RecieveUntilCloseSync();RecieveUntilFullSync(n)takes exactlynor 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,RecieveUntilCloseAsyncandRecieveUntilFullAsync(n, ...)give a closed stream an empty chunk and an early close what arrived, never an exception. A default-constructedQUICStreambehaves as closed.PrependReceived(bytes)pushes bytes back for the next receive (a demuxer that peeked a prefix);GetStreamId()throwsQUICExceptionbefore 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:
~ClientQUICfirst 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 orStop()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 orStop()what you own beforemainreturns, or end withstd::_Exitafter 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. ListenerQUIC(bindAddress, port, alpn, ...), same arguments after the address, listens on one: numeric only (127.0.0.1 or ::1 for this machine alone, 0.0.0.0 or :: for every address of one family; localhost throws QUICException). msquic still binds the UDP port on every address and drops, at its own layer, a connection sent to another one: the client fails within milliseconds with QUIC_STATUS_ALPN_NEG_FAILURE (0x5c) and connectCallback never hears of it. A listener on 127.0.0.1 is not reachable through ::1, so its clients dial 127.0.0.1, not localhost. Port() is the UDP port it bound: pass port 0 to let msquic pick a free one and read it back (still valid after Stop(), 0 on a moved-from listener). 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.
selfSignedserves one development pair per user, made in-process (nothing to install) and kept at$XDG_CACHE_HOME/crafter-network/quic-cert/{cert,key}.pem(else~/.cache/..., else$XDG_RUNTIME_DIR/...; inside crafter-build's FlatpakXDG_CACHE_HOMEis~/.var/app/net.catcrafts.CrafterBuild/cache). The directory is made 0700 and refused, withQUICException, if another user owns it; with none of those variables set the constructor throws too. The pair is reused across restarts until it has less than a day of its 10 left, then the next process that needs it makes a new one, so a browser's pinned hash survives restarts but not that rollover; a process keeps the pair it loaded for all its listeners until then. Processes starting together share one pair (a lock covers check and replacement; the listener loads it from memory), and a pair that does not parse or match is replaced. ECDSA P-256,CN=localhost, SANslocalhost,127.0.0.1,::1, with the extensions Chromium'sserverCertificateHashesdemands.GetSelfSignedCertificatePath()is that file, andComputeCertificateHashSHA256(certPath)is the 32-byte SHA-256 of a PEM file's certificate, computed in-process (QUICExceptionfor a file it cannot read or parse), the 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::SendAsyncisfetch(): the URL isscheme://authority/pathwithauthoritydefaulting tohost:port, or the barepathwhen 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.QUICClientCredentialsis ignored, and CORS applies as to anyfetch.ClientQUIC(host, port, alpn, creds)returns at once and opensnew WebTransport("https://host:port/alpn", { serverCertificateHashes })in the background:alpnis the URL path here, so the server is aListenerHTTPwith awtRoutesentry keyed/<alpn>(the example passesechoand registers/echo; a plainListenerQUICis unreachable from a browser), and the QUIC ALPN is the browser'sh3. Calls before the connection is ready queue on the JS side. An all-zeroserverCertificateHashmeans the browser's trust store, so a self-signed server needs its hash: the native side writesComputeCertificateHashSHA256(GetSelfSignedCertificatePath())somewhere the page can fetch it and the page feeds it in, asexamples/SimpleClientdoes withcert-hash.txt. The*Asyncstream calls behave as natively: a closed stream gives an empty chunk, an early close what arrived, a failed writefalse. The rest differs: a stream the server opens beforeOnStreamis set is closed, not queued;OpenStreamon a closed or failed connection returns a default-constructed (closed) stream instead of throwing;SendDatagramdrops silently instead of throwing; a datagram with noOnDatagramhandler is dropped, there is no queue and noRecieveDatagramSync; and the constructor cannot report a failed connection, which shows up as those sentinels.- Callbacks run on the JS event loop after
mainhas returned, so the objects they need must outlivemain(static, or heap-allocated and left alone); aClientQUICorQUICStreamdestroyed at the end ofmaincloses its connection or stream first.std::cout << std::unitbuf;so prints from callbacks reach the console.exitand_Exitend the program for good, butnetwork-env.jsdoes 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
WebTransportand 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-gnubuilds the native echo server, which writescert-hash.txtinto its working directory at startup; plaincrafter-buildbuilds the wasm, andcfg.filescopies the trackedexamples/SimpleClient/cert-hash.txtnext to it, which is only a placeholder; the page fetches/cert-hash.txtfrom its own origin, so serve the wasm output directory over HTTPS and run the server with that directory as its working directory (crafter-build -rwould run it from its ownbin/directory instead), then open the page.
Errors
| Call | On failure |
|---|---|
ClientTCP constructor, Connect, Send, Recieve*Sync |
SocketErrorException (a std::system_error: a failed resolve, socket(), connect, send or receive, with phase and code()); 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, SocketErrorException (the socket's), SocketTimeoutException (the connect), std::runtime_error (timed out waiting for the response, connection closed before a complete response arrived) |
ClientHTTP1::Fetch, URL::Parse, URL::Resolve |
std::invalid_argument for a URL that is not http(s), has no host or a bad port; Fetch otherwise what Send throws |
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; SocketErrorException (through std::system_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 andStop()every client beforemainreturns; otherwise the exit waits for the idle timeout. - crafter-build runs tests in parallel, so each test binds a port of its own.
ListenerTCPandListenerHTTP1setSO_REUSEADDR, so a port inTIME_WAITfrom the previous run is fine; one held by a stray server from a crashed run isCould not bind port. Simpler is no fixed port at all:ListenerTCP(address, 0, ...)andListenerQUIC(0, ...)take whatever port is free and report it withPort(). QUICServerCredentials{ .selfSigned = true }againstQUICClientCredentials{ .insecureNoServerValidation = true }is the local pair;TLSServerCredentials{ .selfSigned = true }againstTLSClientCredentials{ .caPem = GetSelfSignedCertificatePem().certificate }exercises real verification. Neither needs anything installed.- Give a blocked reader (
RecieveSyncin a loop) astd::thread, not a pool task, and a test that could hang a watchdog thread that callsstd::_Exit(1)sooner than the runner's 60 s timeout, astests/ShouldNotDropEarlyStreamsdoes at 8 s (the churn test's 90 s watchdog never fires first).