Crafter.Network/AGENTS.consumer.md

140 lines
40 KiB
Markdown
Raw Normal View History

## 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::*`, `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) and `CRAFTER_NETWORK_NO_QUIC` (`--no-quic`) for its own units, and the PCMs carry the surface above either way, but your own `#ifdef`s 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), `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-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` 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 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, `SocketErrorException` 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; **`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 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` | `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 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).