From e8630528af78bfd8568febd0603f3a97a331b5ce Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 19 May 2026 02:53:50 +0200 Subject: [PATCH] browser wasm --- README.md | 100 +++- additional/network-env.js | 329 +++++++++++++ examples/SimpleClient/cert-hash.txt | 1 + examples/SimpleClient/main.cpp | 227 +++++++++ examples/SimpleClient/project.cpp | 79 ++++ .../Crafter.Network-ClientHTTP-Browser.cpp | 199 ++++++++ .../Crafter.Network-ClientHTTP.cpp | 18 + .../Crafter.Network-ClientQUIC-Browser.cpp | 443 ++++++++++++++++++ .../Crafter.Network-ClientQUIC.cpp | 33 ++ .../Crafter.Network-ListenerHTTP.cpp | 291 ++++++++++-- .../Crafter.Network-ListenerQUIC.cpp | 129 ++++- .../Crafter.Network-WebTransport.cpp | 152 ++++++ interfaces/Crafter.Network-ClientHTTP.cppm | 16 + interfaces/Crafter.Network-ClientQUIC.cppm | 78 ++- interfaces/Crafter.Network-ClientTCP.cppm | 6 +- interfaces/Crafter.Network-HTTP3.cppm | 73 +++ interfaces/Crafter.Network-ListenerHTTP.cppm | 25 + interfaces/Crafter.Network-ListenerQUIC.cppm | 16 + interfaces/Crafter.Network-ListenerTCP.cppm | 4 +- interfaces/Crafter.Network-WebTransport.cppm | 105 +++++ interfaces/Crafter.Network.cppm | 10 +- project.cpp | 60 ++- .../ShouldEchoWebTransport.cpp | 182 +++++++ tests/ShouldEchoWebTransport/project.cpp | 20 + 24 files changed, 2493 insertions(+), 103 deletions(-) create mode 100644 additional/network-env.js create mode 100644 examples/SimpleClient/cert-hash.txt create mode 100644 examples/SimpleClient/main.cpp create mode 100644 examples/SimpleClient/project.cpp create mode 100644 implementations/Crafter.Network-ClientHTTP-Browser.cpp create mode 100644 implementations/Crafter.Network-ClientQUIC-Browser.cpp create mode 100644 implementations/Crafter.Network-WebTransport.cpp create mode 100644 interfaces/Crafter.Network-WebTransport.cppm create mode 100644 tests/ShouldEchoWebTransport/ShouldEchoWebTransport.cpp create mode 100644 tests/ShouldEchoWebTransport/project.cpp diff --git a/README.md b/README.md index ed6a5aa..bd9fcdd 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ # Crafter.Network -A cross-platform C++ networking library providing TCP, QUIC, and HTTP/3 client/server functionality with modern C++ features. +A cross-platform C++ networking library providing TCP, QUIC, HTTP/3, and WebTransport client/server functionality with modern C++ features. Builds for native Linux and for the browser (wasm32-wasip1). ## Overview -Crafter.Network is a C++ networking library designed for modern C++ applications. It provides TCP, QUIC, and HTTP/3 networking capabilities with support for synchronous and asynchronous operations, making it suitable for a wide range of networking tasks including real-time multiplayer games. +Crafter.Network is a C++ networking library designed for modern C++ applications. It provides TCP, QUIC, HTTP/3, and WebTransport-over-HTTP/3 capabilities with support for synchronous and asynchronous operations, making it suitable for a wide range of networking tasks including real-time multiplayer games. The same source compiles for native Linux (via msquic + POSIX sockets) and for the browser (via `fetch()` + `WebTransport` JS APIs); see [Browser build](#browser-build). ## Features -- **TCP Networking**: Client and server implementations for raw TCP connections. +- **TCP Networking**: Client and server implementations for raw TCP connections (native only). - **QUIC Networking**: Encrypted, multi-stream transport via msquic — reliable streams for control plane, unreliable datagrams for low-latency state sync. -- **HTTP/3**: Client and server implementations on top of QUIC. Uses ALPN `h3`, QUIC bidi streams for requests/responses, the mandatory unidirectional control stream + SETTINGS frame (RFC 9114 §6.2.1), and a built-in QPACK codec (RFC 9204) with the full static table, Huffman *decoding* (RFC 7541), and literal-only emission. The QPACK dynamic table is unused; the optional QPACK encoder/decoder unidi streams are deliberately not opened (RFC 9204 §4.2 permits this when no dynamic-table operations are issued). The client is interoperable with mainstream public h3 endpoints (cloudflare, nghttp3-based servers, etc.). -- **Asynchronous Operations**: Thread pool–based async operations for improved performance. -- **Cross-Platform**: Built for Unix-like systems with socket-based networking. +- **HTTP/3**: Client and server implementations on top of QUIC. Uses ALPN `h3`, QUIC bidi streams for requests/responses, the mandatory unidirectional control stream + SETTINGS frame (RFC 9114 §6.2.1), the (empty) QPACK encoder + decoder unidi streams required by stricter peers like Chromium, and a built-in QPACK codec (RFC 9204) with the full static table, Huffman *decoding* (RFC 7541), and literal-only emission. The QPACK dynamic table is unused. The client is interoperable with mainstream public h3 endpoints (cloudflare, nghttp3-based servers, etc.). +- **WebTransport (server)**: `ListenerHTTP` accepts extended-CONNECT sessions (`:method=CONNECT, :protocol=webtransport`) negotiated on the existing h3 listener — no separate port or alternate stack. Both draft-02 and draft-07+ identifier sets are advertised in SETTINGS so current Chrome/Edge browsers connect out of the box. Per-route handlers receive a `WebTransportSession&` and can multiplex bidirectional streams over the session. +- **Browser client**: Same C++ API compiled to wasm32-wasip1 and routed through `fetch()` (for `ClientHTTP`) and `WebTransport` (for `ClientQUIC`). Listeners and raw TCP are not compiled in the browser build — the browser is client-only. +- **Asynchronous Operations**: Thread pool–based async operations on native; the same `*Async` API on the browser side, where it's required (no synchronous I/O in the browser event loop). +- **Cross-Platform**: Native Linux (POSIX sockets + msquic) and browser (wasm32-wasip1). - **Modern C++**: Uses C++20 modules, STL containers, and modern C++ features. ## Architecture @@ -21,15 +23,15 @@ The library follows a modular design using C++20 modules: ### Core Modules - `Crafter.Network`: Main module that exports all components -- `Crafter.Network:ClientTCP`: TCP client implementation -- `Crafter.Network:ListenerTCP`: TCP server implementation -- `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`) -- `Crafter.Network:ListenerHTTP`: HTTP/3 server (ALPN `h3`) +- `Crafter.Network:ClientTCP`: TCP client implementation (native only) +- `Crafter.Network:ListenerTCP`: TCP server implementation (native only) +- `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`). On browser builds this maps to `fetch()`. +- `Crafter.Network:ListenerHTTP`: HTTP/3 + WebTransport server (ALPN `h3`, native only) - `Crafter.Network:HTTP`: HTTP request/response types and constructors -- `Crafter.Network:ClientQUIC`: QUIC connection (client + accepted-server side) with reliable streams and unreliable datagrams -- `Crafter.Network:ListenerQUIC`: QUIC listener accepting incoming connections - -The `Crafter.Network:HTTP3` partition contains internal HTTP/3 wire-format helpers (QUIC varint, frame layer, QPACK static-table codec) and is intentionally not re-exported from the main module — it is shared between the `ClientHTTP` and `ListenerHTTP` implementations. +- `Crafter.Network:ClientQUIC`: QUIC connection (client + accepted-server side) with reliable streams and unreliable datagrams. On browser builds this maps to the `WebTransport` JS API. +- `Crafter.Network:ListenerQUIC`: QUIC listener accepting incoming connections (native only). Also exports `ComputeCertificateHashSHA256()` and `GetSelfSignedCertificatePath()` for browser-peer cert pinning. +- `Crafter.Network:WebTransport`: `WebTransportSession` type — the per-session handle handed to `ListenerHTTP` WT route handlers. +- `Crafter.Network:HTTP3`: HTTP/3 wire-format helpers (QUIC varint, frame layer, QPACK static-table codec, WT frame/setting constants). Re-exported on native, excluded on browser (it uses `throw` and the wasm build is `-fno-exceptions`). ## Components @@ -87,14 +89,64 @@ listener.Listen(); The `HTTPRequest` exposes the four HTTP/3 pseudo-headers (`method`, `scheme`, `authority`, `path`) as named struct fields rather than mixing them into the regular `headers` map. Routes are dispatched by exact match on `path`; unmatched paths return a synthetic 404. +### WebTransport Components + +`ListenerHTTP` has a WT-aware constructor overload that takes a second route map keyed by `:path`. When the map is non-empty the listener advertises both draft-02 (`SETTINGS_ENABLE_WEBTRANSPORT = 0x2b603742`) and draft-07+ (`SETTINGS_WT_MAX_SESSIONS = 0xc671706a`) identifiers in its SETTINGS frame so current browsers connect. An extended-CONNECT request (`:method=CONNECT, :protocol=webtransport`) whose `:path` matches a registered route is accepted with a `200` (no FIN), upgraded into a `WebTransportSession`, and dispatched on the ThreadPool. Plain HTTP/3 routes and WT routes coexist on the same listener and port. + +```cpp +std::unordered_map> httpRoutes; +std::unordered_map> wtRoutes; + +wtRoutes["/echo"] = [](Crafter::WebTransportSession& session) { + session.OnStream([](Crafter::QUICStream peerStream) { + auto bytes = peerStream.RecieveUntilCloseSync(); + peerStream.SendSync(bytes.data(), + static_cast(bytes.size()), + /*finish=*/true); + }); +}; + +Crafter::QUICServerCredentials creds; +creds.selfSigned = true; // dev-only, see "Self-signed certs & browser peers" +Crafter::ListenerAsyncHTTP listener(4443, creds, std::move(httpRoutes), std::move(wtRoutes)); +``` + +Browser-initiated bidi/unidi WebTransport streams arrive via `session.OnStream(...)`. The `WT_BIDI` (`0x41`) / `WT_UNIDI` (`0x54`) stream-type prefix and session-id varint are stripped before the user handler sees the stream; what the handler reads is pure session payload. Phase 1 covers session establishment + bidirectional streams; WebTransport datagrams and capsule-protocol control are stubbed for a follow-up. + +### Self-signed certs & browser peers + +Passing `QUICServerCredentials{selfSigned = true}` makes the listener generate (and cache) an ephemeral cert at `/tmp/crafter-network-quic-cert/{cert,key}.pem` and reuse it across server restarts while it's still valid. The cert is shaped to satisfy Chromium's `WebTransport.serverCertificateHashes` rules: ECDSA P-256, signed with ECDSA-SHA256, validity ≤14 days (10 in practice), `BasicConstraints CA:FALSE`, `KeyUsage digitalSignature`, `EKU serverAuth`, `SAN=DNS:localhost,IP:127.0.0.1,IP:::1`. To let a browser peer pin this cert without trusting a CA: + +```cpp +auto certPath = Crafter::GetSelfSignedCertificatePath(); +auto hash = Crafter::ComputeCertificateHashSHA256(certPath); // 32-byte SHA-256 of cert DER +// Publish hash to the browser (e.g. write hex to a file the page can fetch); +// on the browser side feed it into QUICClientCredentials::serverCertificateHash. +``` + +For production use a real cert (`certPath` + `keyPath` on `QUICServerCredentials`). + +## Browser build + +Crafter.Network compiles for `wasm32-wasip1` via [Crafter.Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build); the build path is selected automatically when `cfg.target.find("wasm") != npos`. On that target: + +- `CRAFTER_NETWORK_BROWSER` is defined. Synchronous methods on `ClientHTTP` / `QUICStream` / `ClientQUIC` are not compiled — only the `*Async` variants are available. +- `ClientHTTP` calls into `crafterNetworkFetch` (JS) which delegates to `fetch()`. An empty `host` is a same-origin sentinel: the path is passed through as the URL, so `ClientHTTP("", 0).SendAsync({.path="/data.json"}, ...)` fetches from the page origin. +- `ClientQUIC` calls into `crafterNetworkWtConnect` which constructs a `WebTransport(url, opts)` against `https://{host}:{port}/{alpn}` (i.e. `alpn` is the WebTransport URL path on this target). `QUICClientCredentials::serverCertificateHash` is forwarded as `serverCertificateHashes`; leaving it zeroed makes the browser fall back to its normal trust store. +- `ListenerTCP`, `ListenerHTTP`, `ListenerQUIC`, `ClientTCP`, and the sync receive/send paths are excluded — the browser is client-only. +- `additional/network-env.js` is shipped alongside the produced `.wasm` and merged into the runtime's `env` import object by `EnableWasiBrowserRuntime`. + +A worked example pairing a wasm browser client with a native server lives in [examples/SimpleClient/](examples/SimpleClient/). Build the server with `crafter-build --target=x86_64-pc-linux-gnu`, run it, then run `crafter-build` (no `--target`) to produce the wasm and serve it over HTTPS. + ## Build Configuration -The project uses a configuration system with multiple build targets: +The project is a single Crafter.Build configuration (`crafter-network`, `ConfigurationType::LibraryStatic`). Target selection and debug flags are handled by `ApplyStandardArgs`: -- **base**: Core interfaces only -- **lib**: Static library build with dependencies -- **lib-debug**: Debug static library build -- **lib-shared**: Shared library build with dependencies +- `crafter-build` — host native (x86_64-pc-linux-gnu by default), msquic + listeners + sync APIs. +- `crafter-build --target=wasm32-wasip1` — browser build, fetch + WebTransport, async-only API; defines `CRAFTER_NETWORK_BROWSER`, drops msquic. +- `crafter-build test [globs]` — build and run tests under `tests/`. ## Testing @@ -103,17 +155,19 @@ The library includes tests covering: - HTTP/3 connection multiplexing (`ShouldSendRecieveKeepaliveHTTP`) — two requests share one QUIC connection - HTTP/3 large body transfer (`ShouldSendRecieveLargeHTTP`) — 10 MiB POST - HTTP/3 external interop (`ShouldSend`) — live fetch from `cloudflare-quic.com:443`, exercises real TLS chain validation, mandatory control stream, peer-initiated unidi streams, and QPACK Huffman decoding -- QUIC reliable streams -- QUIC unreliable datagrams +- QUIC reliable streams (`ShouldSendRecieveQUICStream`) +- QUIC unreliable datagrams (`ShouldSendRecieveQUICDatagram`) +- WebTransport echo (`ShouldEchoWebTransport`) — extended-CONNECT acceptance, draft-02 SETTINGS, bidi data stream framing (`WT_STREAM 0x41` + session-id varint), and byte-for-byte echo The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. ## Dependencies - **Crafter.Thread**: Thread pool management for asynchronous operations. -- **msquic** — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` recursively into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=quictls`, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC and HTTP/3 modules. +- **msquic** (native target only) — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` recursively into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=quictls`, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC and HTTP/3 modules. Skipped entirely on browser builds. - On Linux msquic links against `libnuma` (provided by the `numactl` package on most distros). -- The self-signed-cert path used by tests/dev shells out to the `openssl` CLI; install `openssl` if you intend to use `QUICServerCredentials{selfSigned = true}`. +- The self-signed-cert path used by tests/dev shells out to the `openssl` CLI; install `openssl` if you intend to use `QUICServerCredentials{selfSigned = true}`. The same path also produces the cert hash that browser peers need for `serverCertificateHashes`. +- **Browser build** has no extra dependencies beyond Crafter.Build's `wasi-browser` runtime: HTTP delegates to the browser's `fetch()`, QUIC to its `WebTransport`. The JS glue lives in `additional/network-env.js` and is shipped alongside the produced `.wasm`. ## Usage Example diff --git a/additional/network-env.js b/additional/network-env.js new file mode 100644 index 0000000..3545c77 --- /dev/null +++ b/additional/network-env.js @@ -0,0 +1,329 @@ +/* +Crafter®.Network +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. +*/ + +// JS bridge for the CRAFTER_NETWORK_BROWSER build of Crafter.Network. +// Populates `window.crafter_webbuild_env` with the env imports the .wasm +// declares (crafterNetworkFetch, crafterNetworkWt*), and drives the +// reader loops that feed responses back into the wasm exports +// (CrafterNetworkOnFetchComplete, CrafterNetworkOnWt*). +// +// Crafter.Build's wasi-browser runtime merges this object into the +// WebAssembly import object as the `env` module before instantiation — +// mirrors additional/dom-env.js in Crafter.Graphics. + +const __decoder = new TextDecoder(); +const __encoder = new TextEncoder(); + +function __wasm() { return window.crafter_webbuild_wasi.instance.exports; } +function __memBuf() { return window.crafter_webbuild_wasi.instance.exports.memory.buffer; } + +function __readUtf8(ptr, len) { + return __decoder.decode(new Uint8Array(__memBuf(), ptr, len)); +} +function __readBytes(ptr, len) { + // Copy out of the linear memory — the underlying buffer is detached + // whenever wasm grows its memory, so handing the view straight back + // to fetch / WebTransport would risk operating on a freed view. + return new Uint8Array(__memBuf(), ptr, len).slice(); +} +function __allocCopy(bytes) { + const ptr = __wasm().WasmAlloc(bytes.length); + new Uint8Array(__memBuf(), ptr, bytes.length).set(bytes); + return ptr; +} +function __allocUtf8(str) { + return __allocCopy(__encoder.encode(str)); +} + +// ─── fetch() bridge ─────────────────────────────────────────────────── + +function crafterNetworkFetch(methodPtr, methodLen, + urlPtr, urlLen, + headersPtr, headersLen, + bodyPtr, bodyLen, + callbackId) { + const method = __readUtf8(methodPtr, methodLen); + const url = __readUtf8(urlPtr, urlLen); + const init = { method, headers: {} }; + + if (headersLen > 0) { + const headerStr = __readUtf8(headersPtr, headersLen); + for (const line of headerStr.split('\n')) { + const sep = line.indexOf(': '); + if (sep > 0) init.headers[line.slice(0, sep)] = line.slice(sep + 2); + } + } + if (bodyLen > 0 && method !== 'GET' && method !== 'HEAD') { + init.body = __readBytes(bodyPtr, bodyLen); + } + + fetch(url, init).then(async (response) => { + // Lowercase to match HTTP/3 convention the native client uses. + const headerLines = []; + response.headers.forEach((value, name) => headerLines.push(`${name.toLowerCase()}: ${value}`)); + const headerStr = headerLines.join('\n'); + const bodyBuf = await response.arrayBuffer(); + + const headerEncoded = __encoder.encode(headerStr); + const headerPtr = headerEncoded.length > 0 ? __allocCopy(headerEncoded) : 0; + const respBodyPtr = bodyBuf.byteLength > 0 ? __allocCopy(new Uint8Array(bodyBuf)) : 0; + __wasm().CrafterNetworkOnFetchComplete(callbackId, + response.status, + headerPtr, headerEncoded.length, + respBodyPtr, bodyBuf.byteLength); + }).catch((err) => { + const msg = String(err && err.message ? err.message : err); + const encoded = __encoder.encode(msg); + const ptr = encoded.length > 0 ? __allocCopy(encoded) : 0; + __wasm().CrafterNetworkOnFetchError(callbackId, ptr, encoded.length); + }); +} + +// ─── WebTransport bridge ────────────────────────────────────────────── +// +// Connection-handle and stream-handle counters are monotone. Each +// connection's incoming-stream + datagram reader loops are started as +// soon as wt.ready resolves and run until the session closes. Outgoing +// operations queued before ready are gated behind a per-handle `ready` +// promise. + +let __wtNextHandle = 0; +let __wtNextStream = 0; +const __wtSessions = new Map(); // handle → { wt, ready, streams: Set } +const __wtStreams = new Map(); // streamId → { connection, writer, reader, writable, readable } + +function crafterNetworkWtConnect(hostPtr, hostLen, port, alpnPtr, alpnLen, certHashPtr, certHashLen) { + const host = __readUtf8(hostPtr, hostLen); + const alpn = __readUtf8(alpnPtr, alpnLen); + const url = `https://${host}:${port}/${alpn}`; + const opts = {}; + if (certHashLen > 0) { + const hash = __readBytes(certHashPtr, certHashLen); + opts.serverCertificateHashes = [{ algorithm: 'sha-256', value: hash }]; + } + + let wt; + try { + wt = new WebTransport(url, opts); + } catch (err) { + console.error('Crafter.Network: WebTransport ctor failed:', err); + return 0; + } + + const handle = ++__wtNextHandle; + const session = { wt, ready: false, streams: new Set(), closed: false }; + __wtSessions.set(handle, session); + + wt.ready.then(() => { + session.ready = true; + __wasm().CrafterNetworkOnWtReady(handle); + __wtRunIncomingLoop(handle, wt.incomingBidirectionalStreams, /*bidi=*/true); + __wtRunIncomingLoop(handle, wt.incomingUnidirectionalStreams, /*bidi=*/false); + __wtRunDatagramLoop(handle, wt); + }).catch((err) => { + __wtFireClosed(handle, String(err && err.message ? err.message : err)); + }); + + wt.closed.then(() => { + __wtFireClosed(handle, ''); + }).catch((err) => { + __wtFireClosed(handle, String(err && err.message ? err.message : err)); + }); + + return handle; +} + +function __wtFireClosed(handle, message) { + const session = __wtSessions.get(handle); + if (!session || session.closed) return; + session.closed = true; + // Wake up any per-stream receivers with a synthetic FIN so the C++ + // state machine terminates pending callbacks instead of hanging. + for (const streamId of session.streams) { + __wasm().CrafterNetworkOnWtStreamChunk(streamId, 0, 0, 1); + __wtStreams.delete(streamId); + } + session.streams.clear(); + const msgPtr = message ? __allocUtf8(message) : 0; + __wasm().CrafterNetworkOnWtClosed(handle, msgPtr, message ? __encoder.encode(message).length : 0); +} + +function crafterNetworkWtClose(handle) { + const session = __wtSessions.get(handle); + if (!session) return; + try { session.wt.close(); } catch (_) { /* already closing */ } + __wtSessions.delete(handle); +} + +function crafterNetworkWtOpenStream(handle, unidirectional) { + const session = __wtSessions.get(handle); + if (!session || session.closed) return 0; + const streamId = ++__wtNextStream; + const record = { connection: handle, writer: null, reader: null, + pendingOps: [], opened: false, unidirectional: !!unidirectional }; + __wtStreams.set(streamId, record); + session.streams.add(streamId); + + const opener = session.ready + ? Promise.resolve() + : session.wt.ready; + opener.then(() => { + const p = unidirectional + ? session.wt.createUnidirectionalStream() + : session.wt.createBidirectionalStream(); + return p; + }).then((stream) => { + // For a bidi stream `stream` is a WebTransportBidirectionalStream + // with .readable and .writable. For a unidi outgoing stream + // `stream` is itself a WritableStream. + if (unidirectional) { + record.writer = stream.getWriter(); + } else { + record.writer = stream.writable.getWriter(); + record.reader = stream.readable.getReader(); + __wtRunStreamReader(streamId); + } + record.opened = true; + for (const op of record.pendingOps) op(); + record.pendingOps.length = 0; + }).catch((err) => { + console.error(`Crafter.Network: openStream(${streamId}) failed`, err); + // Tell C++ the stream is dead so pending receivers unblock. + __wasm().CrafterNetworkOnWtStreamChunk(streamId, 0, 0, 1); + __wtStreams.delete(streamId); + session.streams.delete(streamId); + }); + + return streamId; +} + +function crafterNetworkWtStreamWrite(streamId, bufPtr, bufLen, finish, callbackId) { + const record = __wtStreams.get(streamId); + if (!record) { + if (callbackId !== 0) __wasm().CrafterNetworkOnWtStreamWriteComplete(callbackId); + return; + } + const data = bufLen > 0 ? __readBytes(bufPtr, bufLen) : new Uint8Array(0); + const doWrite = () => { + const p = data.length > 0 ? record.writer.write(data) : Promise.resolve(); + p.then(() => { + if (finish) { try { return record.writer.close(); } catch (_) {} } + }).then(() => { + if (callbackId !== 0) __wasm().CrafterNetworkOnWtStreamWriteComplete(callbackId); + }).catch((err) => { + console.error(`Crafter.Network: write(${streamId}) failed`, err); + if (callbackId !== 0) __wasm().CrafterNetworkOnWtStreamWriteComplete(callbackId); + }); + }; + if (record.opened) doWrite(); + else record.pendingOps.push(doWrite); +} + +function crafterNetworkWtStreamStop(streamId) { + const record = __wtStreams.get(streamId); + if (!record) return; + try { record.writer && record.writer.close(); } catch (_) {} + try { record.reader && record.reader.cancel(); } catch (_) {} + const session = __wtSessions.get(record.connection); + if (session) session.streams.delete(streamId); + __wtStreams.delete(streamId); +} + +function crafterNetworkWtSendDatagram(handle, bufPtr, bufLen) { + const session = __wtSessions.get(handle); + if (!session || session.closed) return; + const data = __readBytes(bufPtr, bufLen); + const send = () => { + const writer = session.wt.datagrams.writable.getWriter(); + writer.write(data).catch((err) => { + console.error('Crafter.Network: sendDatagram failed', err); + }).finally(() => { + try { writer.releaseLock(); } catch (_) {} + }); + }; + if (session.ready) send(); + else session.wt.ready.then(send).catch(() => {}); +} + +async function __wtRunStreamReader(streamId) { + const record = __wtStreams.get(streamId); + if (!record || !record.reader) return; + try { + while (true) { + const { value, done } = await record.reader.read(); + if (value && value.byteLength > 0) { + const ptr = __allocCopy(value); + __wasm().CrafterNetworkOnWtStreamChunk(streamId, ptr, value.byteLength, done ? 1 : 0); + } else if (done) { + __wasm().CrafterNetworkOnWtStreamChunk(streamId, 0, 0, 1); + } + if (done) break; + } + } catch (err) { + // Reader cancelled or stream closed. Dispatch a synthetic FIN so + // any pending C++ receiver wakes up. + __wasm().CrafterNetworkOnWtStreamChunk(streamId, 0, 0, 1); + } +} + +async function __wtRunIncomingLoop(handle, source, bidi) { + const session = __wtSessions.get(handle); + if (!session) return; + const reader = source.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const streamId = ++__wtNextStream; + const record = { connection: handle, writer: null, reader: null, + pendingOps: [], opened: true, unidirectional: !bidi }; + if (bidi) { + record.writer = value.writable.getWriter(); + record.reader = value.readable.getReader(); + } else { + record.reader = value.getReader(); + } + __wtStreams.set(streamId, record); + session.streams.add(streamId); + __wasm().CrafterNetworkOnWtIncomingStream(handle, streamId, bidi ? 1 : 0); + __wtRunStreamReader(streamId); + } + } catch (_) { /* session closed */ } +} + +async function __wtRunDatagramLoop(handle, wt) { + const reader = wt.datagrams.readable.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value && value.byteLength > 0) { + const ptr = __allocCopy(value); + __wasm().CrafterNetworkOnWtDatagram(handle, ptr, value.byteLength); + } + } + } catch (_) { /* session closed */ } +} + +// ─── Export env object ──────────────────────────────────────────────── + +if (!window.crafter_webbuild_env) { + window.crafter_webbuild_env = {}; +} +Object.assign(window.crafter_webbuild_env, { + crafterNetworkFetch, + crafterNetworkWtConnect, + crafterNetworkWtClose, + crafterNetworkWtOpenStream, + crafterNetworkWtStreamWrite, + crafterNetworkWtStreamStop, + crafterNetworkWtSendDatagram, +}); diff --git a/examples/SimpleClient/cert-hash.txt b/examples/SimpleClient/cert-hash.txt new file mode 100644 index 0000000..3363252 --- /dev/null +++ b/examples/SimpleClient/cert-hash.txt @@ -0,0 +1 @@ +b7c4a81084fc56f45f1a6025fafdc8a1b05bf8388947f1840608da565cd22c8e \ No newline at end of file diff --git a/examples/SimpleClient/main.cpp b/examples/SimpleClient/main.cpp new file mode 100644 index 0000000..3414cd4 --- /dev/null +++ b/examples/SimpleClient/main.cpp @@ -0,0 +1,227 @@ +// Crafter.Network SimpleClient example. +// +// Browser build (wasm32-wasip1, default): +// crafter-build +// Serve bin// on a static HTTPS server, open index.html +// in Chrome and watch DevTools → Console. +// +// Native server build (e.g. x86_64-pc-linux-gnu): +// crafter-build --target=x86_64-pc-linux-gnu +// Runs a WebTransport echo server on port 4443 that the browser demo +// connects to at wt://localhost:4443/echo. + +import Crafter.Network; +import std; +#ifndef CRAFTER_NETWORK_BROWSER +import Crafter.Thread; +#include +#include +#endif + +using namespace Crafter; + +namespace { + +#ifdef CRAFTER_NETWORK_BROWSER + void RunFetchDemo() { + // httpbin.org sets Access-Control-Allow-Origin: * and returns a + // small JSON echo, so it works as a smoke test from any origin. + // Swap host/port/path for your own service when you have one. + std::println(std::cout, "[Crafter.Network] HTTP GET httpbin.org/get ..."); + + // Heap-allocated and intentionally leaked — fetch() resolves + // after main() returns and the JS event loop keeps the wasm + // instance alive. A real app would tie the lifetime to a session + // object that lives for as long as the page does. + auto* client = new ClientHTTP("httpbin.org", 443); + + HTTPRequest req; + req.method = "GET"; + req.path = "/get"; + + client->SendAsync(req, + [](HTTPResponse response) { + std::println(std::cout, + "[Crafter.Network] fetch OK — status {}, body {} bytes", + response.status, response.body.size()); + if (!response.body.empty()) { + auto preview = response.body.substr(0, std::min(80, response.body.size())); + std::println(std::cout, "[Crafter.Network] body[0..80]: {}", preview); + } + }, + [](std::string error) { + std::println(std::cerr, "[Crafter.Network] fetch ERROR: {}", error); + }); + } + + // Parse 64-char lowercase hex into a 32-byte digest. Returns all-zero + // bytes (treated as "no hash") if the input is malformed. + std::array ParseHexHash(std::string_view hex) { + std::array out{}; + if (hex.size() < 64) return out; + auto nibble = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); + return -1; + }; + for (std::size_t i = 0; i < 32; ++i) { + int hi = nibble(hex[2 * i]); + int lo = nibble(hex[2 * i + 1]); + if (hi < 0 || lo < 0) return std::array{}; + out[i] = static_cast((hi << 4) | lo); + } + return out; + } + + void StartWebTransportEcho(std::array certHash) { + const char* wtHost = "localhost"; + std::uint16_t wtPort = 4443; + const char* wtPath = "echo"; + std::println(std::cout, "[Crafter.Network] WebTransport connect {}:{}/{} ...", wtHost, wtPort, wtPath); + + QUICClientCredentials creds; + creds.serverCertificateHash = certHash; + auto* conn = new ClientQUIC(wtHost, wtPort, wtPath, creds); + + conn->OnDatagram([](std::vector bytes) { + std::string text(bytes.begin(), bytes.end()); + std::println(std::cout, "[Crafter.Network] WT datagram echo: {} ({} bytes)", text, bytes.size()); + }); + + // Leak the stream so its handle survives past this function's + // return. Read fires after the echo server has sent the bytes + // back, which happens after the JS event loop runs. finish=true + // closes the send-side so the server's RecieveUntilCloseSync + // returns and the echo handler runs. + static QUICStream s = conn->OpenStream(/*unidirectional=*/false); + constexpr std::string_view payload = "hello from crafter.network"; + s.SendAsync(payload.data(), static_cast(payload.size()), /*finish=*/true, []{ + std::println(std::cout, "[Crafter.Network] WT stream write OK"); + s.RecieveUntilCloseAsync([](std::vector bytes) { + std::string text(bytes.begin(), bytes.end()); + std::println(std::cout, "[Crafter.Network] WT stream echo: {} ({} bytes)", text, bytes.size()); + }); + }); + } + + void RunWebTransportDemo() { + // Chrome refuses self-signed WebTransport certs unless we pass their + // SHA-256 via `serverCertificateHashes`. Our native server writes the + // hex digest to ./cert-hash.txt; we fetch it from the same origin as + // this wasm (`ClientHTTP("", 0)` is the same-origin sentinel). Serve + // the wasm from the directory the server is running in so the file + // is reachable, then refresh. + static ClientHTTP origin("", 0); + HTTPRequest req; + req.method = "GET"; + req.path = "/cert-hash.txt"; + origin.SendAsync(req, + [](HTTPResponse resp) { + if (resp.status != "200") { + std::println(std::cerr, + "[Crafter.Network] /cert-hash.txt → HTTP {} — start the native server " + "from this directory so the hash file is reachable", + resp.status); + return; + } + auto hash = ParseHexHash(resp.body); + bool zero = true; + for (auto b : hash) if (b) { zero = false; break; } + if (zero) { + std::println(std::cerr, + "[Crafter.Network] /cert-hash.txt is empty or malformed; expected 64 hex chars"); + return; + } + std::println(std::cout, "[Crafter.Network] using cert hash from /cert-hash.txt"); + StartWebTransportEcho(hash); + }, + [](std::string err) { + std::println(std::cerr, + "[Crafter.Network] could not fetch /cert-hash.txt: {} — is the static server " + "serving the directory the native server runs in?", err); + }); + } + +#else // native server + + static std::atomic gRunning{true}; + + void RunEchoServer() { + ThreadPool::Start(); + + QUICServerCredentials creds; + creds.selfSigned = true; + + // Compute the SHA-256 of the self-signed cert so the browser peer + // can pass it via WebTransport's serverCertificateHashes option. + // Chrome won't accept self-signed certs without this. We also write + // the hash hex to ./cert-hash.txt alongside the binary so a static + // file server serving the wasm can hand it back to the page. + std::string certPath = GetSelfSignedCertificatePath(); + auto hash = ComputeCertificateHashSHA256(certPath); + std::string hashHex; + for (auto b : hash) { + constexpr char hex[] = "0123456789abcdef"; + hashHex.push_back(hex[b >> 4]); + hashHex.push_back(hex[b & 0xf]); + } + std::ofstream("cert-hash.txt") << hashHex; + + std::unordered_map> httpRoutes = { + {"/health", [](const HTTPRequest&) { + HTTPResponse r; + r.status = "200"; + r.body = "ok"; + return r; + }}, + }; + std::unordered_map> wtRoutes = { + {"/echo", [](WebTransportSession& session) { + session.OnStream([](QUICStream stream) { + try { + auto bytes = stream.RecieveUntilCloseSync(); + stream.SendSync(bytes.data(), + static_cast(bytes.size()), + /*finish=*/true); + } catch (...) {} + }); + }}, + }; + + ListenerAsyncHTTP server(4443, creds, std::move(httpRoutes), std::move(wtRoutes)); + std::println(std::cout, "[Crafter.Network] echo server listening on port 4443"); + std::println(std::cout, "[Crafter.Network] WebTransport /echo — bidi streams echoed back"); + std::println(std::cout, "[Crafter.Network] HTTP GET /health — returns 200 ok"); + std::println(std::cout, "[Crafter.Network] cert SHA-256: {}", hashHex); + std::println(std::cout, "[Crafter.Network] (also written to ./cert-hash.txt for the browser to fetch)"); + std::println(std::cout, "[Crafter.Network] Press Ctrl-C to stop"); + + std::signal(SIGINT, [](int) { gRunning = false; }); + std::signal(SIGTERM, [](int) { gRunning = false; }); + while (gRunning) std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + std::println(std::cout, "[Crafter.Network] shutting down"); + server.Stop(); + } + +#endif + +} // namespace + +int main() { +#ifdef CRAFTER_NETWORK_BROWSER + // Full-buffer stdout means async-callback prints never reach the + // console after main() returns. unitbuf flushes after every insert + // so logs show up live. + std::cout << std::unitbuf; + std::cerr << std::unitbuf; + std::println(std::cout, "[Crafter.Network] SimpleClient starting (browser)"); + RunFetchDemo(); + RunWebTransportDemo(); + std::println(std::cout, "[Crafter.Network] main() returning (async work continues in JS event loop)"); +#else + RunEchoServer(); +#endif + return 0; +} diff --git a/examples/SimpleClient/project.cpp b/examples/SimpleClient/project.cpp new file mode 100644 index 0000000..5eac1a6 --- /dev/null +++ b/examples/SimpleClient/project.cpp @@ -0,0 +1,79 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span args) { + // Dispatch on --target: wasm32-* → browser client; anything else → native server. + // Plain `crafter-build` (no --target) defaults to the browser client so the + // primary use case keeps working without flags. + bool isBrowser = true; + for (const auto& a : args) { + if (a.starts_with("--target=")) { + isBrowser = (a.find("wasm") != std::string_view::npos); + break; + } + } + + std::vector netArgs(args.begin(), args.end()); + + if (isBrowser) { + // Force wasm32-wasip1 when no --target was supplied. + bool hasTarget = false; + for (const auto& a : netArgs) { + if (a.starts_with("--target=")) { hasTarget = true; break; } + } + if (!hasTarget) netArgs.emplace_back("--target=wasm32-wasip1"); + + Configuration* network = LocalProject({ + .projectFile = "../../project.cpp", + .args = netArgs, + }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "SimpleClient"; + cfg.outputName = "SimpleClient"; + cfg.type = ConfigurationType::Executable; + cfg.target = "wasm32-wasip1"; + // Mirror CRAFTER_NETWORK_BROWSER so main.cpp sees the same API surface + // as the wasm .pcm (Crafter.Build does not propagate defines from deps). + cfg.defines.push_back({"CRAFTER_NETWORK_BROWSER", ""}); + ApplyStandardArgs(cfg, args); + cfg.dependencies = { network }; + cfg.files = { "cert-hash.txt" }; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + // Crafter.Network ships additional/network-env.js via cfg.files; + // Crafter.Build propagates it to the output dir alongside the .wasm. + // EnableWasiBrowserRuntime wires it into the generated index.html. + EnableWasiBrowserRuntime(cfg); + + return cfg; + } + + // Native server — echo server on port 4443. + Configuration* network = LocalProject({ + .projectFile = "../../project.cpp", + .args = netArgs, + }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "SimpleServer"; + cfg.outputName = "SimpleServer"; + cfg.type = ConfigurationType::Executable; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { network }; + cfg.linkFlags.push_back("-Wl,--export-dynamic"); + cfg.linkFlags.push_back("-ldl"); + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + return cfg; +} diff --git a/implementations/Crafter.Network-ClientHTTP-Browser.cpp b/implementations/Crafter.Network-ClientHTTP-Browser.cpp new file mode 100644 index 0000000..ca14bea --- /dev/null +++ b/implementations/Crafter.Network-ClientHTTP-Browser.cpp @@ -0,0 +1,199 @@ +/* +Crafter®.Network +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// CRAFTER_NETWORK_BROWSER implementation of ClientHTTP. Each SendAsync +// hands its request to the JS bridge (additional/network-env.js), which +// runs a fetch() and dispatches the result back through the +// CrafterNetworkOnFetchComplete / CrafterNetworkOnFetchError wasm exports. + +module; +module Crafter.Network:ClientHTTP_impl; +import :ClientHTTP; +import :HTTP; +import std; + +using namespace Crafter; + +namespace Crafter::NetworkBrowserBindings { + // External linkage so the import_module/import_name attributes wire up. + __attribute__((import_module("env"), import_name("crafterNetworkFetch"))) + void crafterNetworkFetch( + const char* method, std::int32_t methodLen, + const char* url, std::int32_t urlLen, + const char* headers, std::int32_t headersLen, + const char* body, std::int32_t bodyLen, + std::int32_t callbackId); +} + +namespace { + struct FetchCallbacks { + std::function onSuccess; + std::function onError; + }; + + // JS dispatches back into wasm via a stable id we mint here. The id + // counter is monotone — wraparound at 2 billion fetches is not a + // realistic concern. The map is touched only from the JS event loop + // (single-threaded in the browser), so no synchronisation is needed. + std::unordered_map& Callbacks() { + static std::unordered_map m; + return m; + } + std::int32_t NextId() { + static std::int32_t counter = 0; + return ++counter; + } + + // Serialise headers as newline-separated "name: value" pairs. The JS + // side splits on '\n' and the first ": " for header construction. + std::string SerialiseHeaders(const std::unordered_map& headers) { + std::string out; + bool first = true; + for (const auto& [name, value] : headers) { + if (!first) out += '\n'; + first = false; + out += name; + out += ": "; + out += value; + } + return out; + } + + // Parse a "name: value\nname2: value2" blob into the HTTPResponse map. + // Names are kept verbatim (fetch surfaces them lowercase already on the + // browser side via response.headers.forEach). + void ParseHeaders(std::string_view raw, HTTPResponse& response) { + std::size_t pos = 0; + while (pos < raw.size()) { + std::size_t end = raw.find('\n', pos); + std::string_view line = raw.substr(pos, end == std::string_view::npos ? raw.size() - pos : end - pos); + std::size_t sep = line.find(": "); + if (sep != std::string_view::npos) { + response.headers.emplace(std::string(line.substr(0, sep)), + std::string(line.substr(sep + 2))); + } + if (end == std::string_view::npos) break; + pos = end + 1; + } + } +} + +struct ClientHTTP::Impl {}; + +ClientHTTP::ClientHTTP(const char* host, std::uint16_t port, QUICClientCredentials) + : host(host), port(port), impl(std::make_unique()) {} + +ClientHTTP::ClientHTTP(std::string host, std::uint16_t port, QUICClientCredentials creds) + : ClientHTTP(host.c_str(), port, std::move(creds)) {} + +ClientHTTP::ClientHTTP(ClientHTTP&&) noexcept = default; +ClientHTTP::~ClientHTTP() = default; + +void ClientHTTP::SendAsync(const HTTPRequest& request, + std::function onSuccess, + std::function onError) { + std::int32_t id = NextId(); + Callbacks().emplace(id, FetchCallbacks{std::move(onSuccess), std::move(onError)}); + + std::string method = request.method.empty() ? std::string("GET") : request.method; + std::string path = request.path.empty() ? std::string("/") : request.path; + // Sentinel: a ClientHTTP constructed with an empty host fetches against + // the page's own origin. fetch(url) in JS handles a leading-slash path + // by resolving it against window.location, so we just hand the path + // straight through. Useful for fetching files served by whatever static + // server is hosting the wasm (e.g. ./cert-hash.txt for WebTransport). + std::string url; + if (host.empty()) { + url = path; + } else { + std::string scheme = request.scheme.empty() ? std::string("https") : request.scheme; + std::string authority = request.authority.empty() ? (host + ":" + std::to_string(port)) : request.authority; + url = scheme + "://" + authority + path; + } + std::string headerStr = SerialiseHeaders(request.headers); + + Crafter::NetworkBrowserBindings::crafterNetworkFetch( + method.data(), static_cast(method.size()), + url.data(), static_cast(url.size()), + headerStr.data(), static_cast(headerStr.size()), + request.body.data(), static_cast(request.body.size()), + id); +} + +extern "C" { + // JS allocates `headersPtr` and `bodyPtr` via WasmAlloc, copies the + // response into them, then transfers ownership across this call. We + // free the buffers after copying into the HTTPResponse. + __attribute__((export_name("CrafterNetworkOnFetchComplete"))) + void CrafterNetworkOnFetchComplete(std::int32_t callbackId, + std::int32_t status, + char* headersPtr, std::int32_t headersLen, + char* bodyPtr, std::int32_t bodyLen) { + auto& callbacks = Callbacks(); + auto it = callbacks.find(callbackId); + if (it == callbacks.end()) { + std::free(headersPtr); + std::free(bodyPtr); + return; + } + + HTTPResponse response; + response.status = std::to_string(status); + if (headersPtr && headersLen > 0) { + ParseHeaders(std::string_view(headersPtr, static_cast(headersLen)), response); + } + if (bodyPtr && bodyLen > 0) { + response.body.assign(bodyPtr, static_cast(bodyLen)); + } + std::free(headersPtr); + std::free(bodyPtr); + + auto onSuccess = std::move(it->second.onSuccess); + callbacks.erase(it); + if (onSuccess) onSuccess(std::move(response)); + } + + __attribute__((export_name("CrafterNetworkOnFetchError"))) + void CrafterNetworkOnFetchError(std::int32_t callbackId, + char* messagePtr, std::int32_t messageLen) { + auto& callbacks = Callbacks(); + auto it = callbacks.find(callbackId); + if (it == callbacks.end()) { + std::free(messagePtr); + return; + } + std::string msg(messagePtr ? messagePtr : "", static_cast(messageLen)); + std::free(messagePtr); + + auto onError = std::move(it->second.onError); + callbacks.erase(it); + if (onError) onError(std::move(msg)); + } + + // WasmAlloc / WasmFree are the buffer-marshalling primitives the JS + // bridge calls into. Crafter.Graphics's Dom backend defines the same + // pair; we mark ours weak so the two libraries can coexist in one + // executable without a duplicate-symbol error. + __attribute__((export_name("WasmAlloc"), weak)) + void* WasmAlloc(std::int32_t size) { return std::malloc(static_cast(size)); } + + __attribute__((export_name("WasmFree"), weak)) + void WasmFree(void* ptr) { std::free(ptr); } +} diff --git a/implementations/Crafter.Network-ClientHTTP.cpp b/implementations/Crafter.Network-ClientHTTP.cpp index a665415..6e4ea70 100644 --- a/implementations/Crafter.Network-ClientHTTP.cpp +++ b/implementations/Crafter.Network-ClientHTTP.cpp @@ -130,6 +130,24 @@ namespace { } } +void ClientHTTP::SendAsync(const HTTPRequest& request, + std::function onSuccess, + std::function onError) { + HTTPRequest copy = request; + ThreadPool::Enqueue([this, copy = std::move(copy), + onSuccess = std::move(onSuccess), + onError = std::move(onError)]() mutable { + try { + HTTPResponse response = this->Send(copy); + if (onSuccess) onSuccess(std::move(response)); + } catch (const std::exception& e) { + if (onError) onError(e.what()); + } catch (...) { + if (onError) onError("unknown error"); + } + }); +} + HTTPResponse ClientHTTP::Send(const HTTPRequest& request) { QUICStream stream = impl->quic.OpenStream(); diff --git a/implementations/Crafter.Network-ClientQUIC-Browser.cpp b/implementations/Crafter.Network-ClientQUIC-Browser.cpp new file mode 100644 index 0000000..7372433 --- /dev/null +++ b/implementations/Crafter.Network-ClientQUIC-Browser.cpp @@ -0,0 +1,443 @@ +/* +Crafter®.Network +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// CRAFTER_NETWORK_BROWSER implementation of ClientQUIC / QUICStream backed +// by the browser's WebTransport API. WebTransport is HTTP/3-based and +// expects a URL — we use https://${host}:${port}/${alpn}. Native msquic +// is not linked into the browser build. +// +// Async-only: synchronous send/receive methods on QUICStream are not +// compiled (gated out in the interface). Everything goes through the +// existing *Async / OnStream / OnDatagram callbacks. The JS bridge +// (additional/network-env.js) runs the WebTransport reader loops and +// dispatches each chunk back through the wasm exports declared below. + +module; +module Crafter.Network:ClientQUIC_impl; +import :ClientQUIC; +import std; + +using namespace Crafter; + +namespace Crafter::NetworkBrowserBindings { + __attribute__((import_module("env"), import_name("crafterNetworkWtConnect"))) + std::int32_t crafterNetworkWtConnect( + const char* host, std::int32_t hostLen, + std::int32_t port, + const char* alpn, std::int32_t alpnLen, + const std::uint8_t* certHash, std::int32_t certHashLen); + + __attribute__((import_module("env"), import_name("crafterNetworkWtClose"))) + void crafterNetworkWtClose(std::int32_t handle); + + __attribute__((import_module("env"), import_name("crafterNetworkWtOpenStream"))) + std::int32_t crafterNetworkWtOpenStream(std::int32_t handle, std::int32_t unidirectional); + + __attribute__((import_module("env"), import_name("crafterNetworkWtStreamWrite"))) + void crafterNetworkWtStreamWrite(std::int32_t streamId, + const char* buf, std::int32_t bufLen, + std::int32_t finish, + std::int32_t callbackId); + + __attribute__((import_module("env"), import_name("crafterNetworkWtStreamStop"))) + void crafterNetworkWtStreamStop(std::int32_t streamId); + + __attribute__((import_module("env"), import_name("crafterNetworkWtSendDatagram"))) + void crafterNetworkWtSendDatagram(std::int32_t handle, + const char* buf, std::int32_t bufLen); +} + +namespace Crafter::NetworkBrowser { + // ─── Receive state machine per stream ───────────────────────────────── + // + // The JS reader loop pushes every chunk it sees through + // CrafterNetworkOnWtStreamChunk. We buffer them until the user calls + // one of the Receive*Async variants — at which point we either + // dispatch immediately (chunks already queued) or wait for the next + // chunk to arrive. A FIN signal marks the end of the peer's send- + // side; further chunks after FIN do not arrive. + // + // StreamState / ConnectionState live in a named namespace (not + // anonymous) because they are referenced from the QUICStream::Impl + // and ClientQUIC::Impl definitions below — private nested types of + // exported classes can't have TU-local member types without a + // diagnostic. + + enum class RecvMode { None, Once, UntilClose, UntilFull }; + + struct StreamState { + // 0 means destroyed / closed. + std::int32_t handle = 0; + + // Buffered chunks not yet delivered to a pending callback. + std::vector buffer; + bool finReceived = false; + bool closed = false; + + // Pending one-shot receive. + RecvMode mode = RecvMode::None; + std::uint32_t target = 0; + std::function)> cb; + + ClientQUIC* connection = nullptr; + }; + + struct ConnectionState { + std::int32_t handle = 0; + bool ready = false; + bool closed = false; + std::function onStream; + std::function)> onDatagram; + }; + + // Handle → state. Allocated on the heap so the pointer is stable + // across QUICStream / ClientQUIC moves (each holds a unique_ptr + // that wraps a pointer into these maps via its handle). + inline std::unordered_map& Streams() { + static std::unordered_map m; + return m; + } + inline std::unordered_map& Connections() { + static std::unordered_map m; + return m; + } + inline std::unordered_map>& WriteCallbacks() { + static std::unordered_map> m; + return m; + } + inline std::int32_t NextWriteCallbackId() { + static std::int32_t counter = 0; + return ++counter; + } + + inline void TryDispatchRecv(StreamState& s) { + if (s.mode == RecvMode::None) return; + + if (s.mode == RecvMode::Once) { + if (!s.buffer.empty()) { + auto chunk = std::move(s.buffer); + s.buffer.clear(); + auto cb = std::move(s.cb); + s.mode = RecvMode::None; + if (cb) cb(std::move(chunk)); + } else if (s.finReceived || s.closed) { + auto cb = std::move(s.cb); + s.mode = RecvMode::None; + if (cb) cb({}); + } + return; + } + if (s.mode == RecvMode::UntilClose) { + if (s.finReceived || s.closed) { + auto chunk = std::move(s.buffer); + s.buffer.clear(); + auto cb = std::move(s.cb); + s.mode = RecvMode::None; + if (cb) cb(std::move(chunk)); + } + return; + } + if (s.mode == RecvMode::UntilFull) { + if (s.buffer.size() >= s.target) { + std::vector chunk(s.buffer.begin(), s.buffer.begin() + s.target); + s.buffer.erase(s.buffer.begin(), s.buffer.begin() + s.target); + auto cb = std::move(s.cb); + s.mode = RecvMode::None; + if (cb) cb(std::move(chunk)); + } else if (s.finReceived || s.closed) { + // Peer closed before we got the requested byte count — + // deliver whatever's left. Mirrors the native variant's + // "throws if peer closes early" only loosely (we have no + // exception channel from JS). + auto chunk = std::move(s.buffer); + s.buffer.clear(); + auto cb = std::move(s.cb); + s.mode = RecvMode::None; + if (cb) cb(std::move(chunk)); + } + } + } +} + +// All implementation-private state types live in Crafter::NetworkBrowser +// (above). Pull them in unqualified for readability. +using Crafter::NetworkBrowser::StreamState; +using Crafter::NetworkBrowser::ConnectionState; +using Crafter::NetworkBrowser::RecvMode; +using Crafter::NetworkBrowser::Streams; +using Crafter::NetworkBrowser::Connections; +using Crafter::NetworkBrowser::WriteCallbacks; +using Crafter::NetworkBrowser::NextWriteCallbackId; +using Crafter::NetworkBrowser::TryDispatchRecv; + +// ─── QUICStream::Impl ──────────────────────────────────────────────────── + +struct QUICStream::Impl { + StreamState state; +}; + +QUICStream::QUICStream() : impl(std::make_unique()) {} + +QUICStream::QUICStream(std::int32_t streamHandle, ClientQUIC* conn, + bool canSendArg, bool canReceiveArg) + : canSend(canSendArg), canReceive(canReceiveArg), + impl(std::make_unique()) +{ + connection = conn; + impl->state.handle = streamHandle; + impl->state.connection = conn; + Streams()[streamHandle] = &impl->state; +} + +QUICStream::QUICStream(QUICStream&&) noexcept = default; +QUICStream& QUICStream::operator=(QUICStream&&) noexcept = default; + +QUICStream::~QUICStream() { + if (impl && impl->state.handle != 0) { + Streams().erase(impl->state.handle); + Crafter::NetworkBrowserBindings::crafterNetworkWtStreamStop(impl->state.handle); + } +} + +void QUICStream::SendAsync(const void* buffer, std::uint32_t size, bool finish, + std::function onSent) { + if (!impl || impl->state.handle == 0 || impl->state.closed) { + if (onSent) onSent(); + return; + } + std::int32_t cbId = 0; + if (onSent) { + cbId = NextWriteCallbackId(); + WriteCallbacks()[cbId] = std::move(onSent); + } + Crafter::NetworkBrowserBindings::crafterNetworkWtStreamWrite( + impl->state.handle, + static_cast(buffer), + static_cast(size), + finish ? 1 : 0, + cbId); +} + +void QUICStream::RecieveAsync(std::function)> cb) { + if (!impl) { if (cb) cb({}); return; } + impl->state.mode = RecvMode::Once; + impl->state.cb = std::move(cb); + TryDispatchRecv(impl->state); +} + +void QUICStream::RecieveUntilCloseAsync(std::function)> cb) { + if (!impl) { if (cb) cb({}); return; } + impl->state.mode = RecvMode::UntilClose; + impl->state.cb = std::move(cb); + TryDispatchRecv(impl->state); +} + +void QUICStream::RecieveUntilFullAsync(std::uint32_t bufferSize, + std::function)> cb) { + if (!impl) { if (cb) cb({}); return; } + impl->state.mode = RecvMode::UntilFull; + impl->state.target = bufferSize; + impl->state.cb = std::move(cb); + TryDispatchRecv(impl->state); +} + +void QUICStream::Stop() { + if (impl && impl->state.handle != 0) { + Crafter::NetworkBrowserBindings::crafterNetworkWtStreamStop(impl->state.handle); + Streams().erase(impl->state.handle); + impl->state.handle = 0; + impl->state.closed = true; + } +} + +// ─── ClientQUIC::Impl ──────────────────────────────────────────────────── + +struct ClientQUIC::Impl { + ConnectionState state; +}; + +namespace { + QUICStream MakeStreamFromHandle(std::int32_t streamId, ClientQUIC* conn, + bool canSend, bool canReceive) { + return QUICStream{streamId, conn, canSend, canReceive}; + } +} + +ClientQUIC::ClientQUIC(const char* host, std::uint16_t port, std::string alpnArg, + QUICClientCredentials creds) + : alpn(std::move(alpnArg)), impl(std::make_unique()) { + // Zeroed hash means "no pinning" — JS passes an empty array, browser + // falls back to its trust store. A non-zero hash is forwarded as a + // serverCertificateHashes entry (Chrome only, < 14 day cert validity). + const std::uint8_t* hashPtr = nullptr; + std::int32_t hashLen = 0; + for (std::uint8_t b : creds.serverCertificateHash) { + if (b != 0) { hashPtr = creds.serverCertificateHash.data(); hashLen = 32; break; } + } + std::string hostStr = host; + impl->state.handle = Crafter::NetworkBrowserBindings::crafterNetworkWtConnect( + hostStr.data(), static_cast(hostStr.size()), + static_cast(port), + alpn.data(), static_cast(alpn.size()), + hashPtr, hashLen); + // wasm builds run with -fno-exceptions; a failed JS-side allocation + // leaves the connection in a closed state and the first operation will + // produce a sentinel result (OpenStream returns a default-constructed + // QUICStream, SendDatagram silently drops, OnStream/OnDatagram never + // fire). The constructor cannot signal the failure synchronously. + if (impl->state.handle == 0) { + impl->state.closed = true; + } else { + Connections()[impl->state.handle] = &impl->state; + } +} + +ClientQUIC::ClientQUIC(std::string host, std::uint16_t port, std::string alpnArg, + QUICClientCredentials creds) + : ClientQUIC(host.c_str(), port, std::move(alpnArg), std::move(creds)) {} + +ClientQUIC::ClientQUIC(ClientQUIC&&) noexcept = default; + +ClientQUIC::~ClientQUIC() { + if (impl && impl->state.handle != 0) { + Connections().erase(impl->state.handle); + Crafter::NetworkBrowserBindings::crafterNetworkWtClose(impl->state.handle); + } +} + +QUICStream ClientQUIC::OpenStream(bool unidirectional) { + if (!impl || impl->state.handle == 0 || impl->state.closed) { + return QUICStream{}; // default-constructed: closed sentinel + } + std::int32_t streamId = Crafter::NetworkBrowserBindings::crafterNetworkWtOpenStream( + impl->state.handle, unidirectional ? 1 : 0); + if (streamId == 0) { + return QUICStream{}; + } + return MakeStreamFromHandle(streamId, this, + /*canSend=*/true, + /*canReceive=*/!unidirectional); +} + +void ClientQUIC::SendDatagram(const void* buffer, std::uint32_t size) { + if (!impl || impl->state.handle == 0 || impl->state.closed) return; + Crafter::NetworkBrowserBindings::crafterNetworkWtSendDatagram( + impl->state.handle, + static_cast(buffer), + static_cast(size)); +} + +void ClientQUIC::OnStream(std::function cb) { + if (impl) impl->state.onStream = std::move(cb); +} + +void ClientQUIC::OnDatagram(std::function)> cb) { + if (impl) impl->state.onDatagram = std::move(cb); +} + +void ClientQUIC::Stop() { + if (impl && impl->state.handle != 0) { + Crafter::NetworkBrowserBindings::crafterNetworkWtClose(impl->state.handle); + Connections().erase(impl->state.handle); + impl->state.handle = 0; + impl->state.closed = true; + } +} + +// ─── WASM exports the JS bridge dispatches back through ────────────────── + +extern "C" { + __attribute__((export_name("CrafterNetworkOnWtReady"))) + void CrafterNetworkOnWtReady(std::int32_t connectionHandle) { + auto it = Connections().find(connectionHandle); + if (it == Connections().end()) return; + it->second->ready = true; + } + + __attribute__((export_name("CrafterNetworkOnWtClosed"))) + void CrafterNetworkOnWtClosed(std::int32_t connectionHandle, + char* messagePtr, std::int32_t /*messageLen*/) { + std::free(messagePtr); + auto it = Connections().find(connectionHandle); + if (it == Connections().end()) return; + it->second->closed = true; + it->second->ready = false; + // The JS bridge dispatches CrafterNetworkOnWtStreamChunk(_, nullptr, 0, + // fin=1) for each stream that belonged to this connection so pending + // receivers terminate. We don't iterate stream state here. + } + + __attribute__((export_name("CrafterNetworkOnWtIncomingStream"))) + void CrafterNetworkOnWtIncomingStream(std::int32_t connectionHandle, + std::int32_t streamId, + std::int32_t bidirectional) { + auto it = Connections().find(connectionHandle); + if (it == Connections().end() || !it->second->onStream) { + // No registered handler — close the stream JS-side to free + // resources. Mirrors what the native msquic backend does + // when a stream arrives before OnStream is registered (it + // queues, but in the browser we don't have a backing buffer + // to queue against without leaking). + Crafter::NetworkBrowserBindings::crafterNetworkWtStreamStop(streamId); + return; + } + QUICStream stream = MakeStreamFromHandle(streamId, /*conn=*/nullptr, + /*canSend=*/bidirectional != 0, + /*canReceive=*/true); + it->second->onStream(std::move(stream)); + } + + __attribute__((export_name("CrafterNetworkOnWtStreamChunk"))) + void CrafterNetworkOnWtStreamChunk(std::int32_t streamId, + char* dataPtr, std::int32_t dataLen, + std::int32_t fin) { + auto it = Streams().find(streamId); + if (it == Streams().end()) { std::free(dataPtr); return; } + StreamState& s = *it->second; + if (dataPtr && dataLen > 0) { + s.buffer.insert(s.buffer.end(), dataPtr, dataPtr + dataLen); + } + std::free(dataPtr); + if (fin) s.finReceived = true; + TryDispatchRecv(s); + } + + __attribute__((export_name("CrafterNetworkOnWtStreamWriteComplete"))) + void CrafterNetworkOnWtStreamWriteComplete(std::int32_t callbackId) { + auto it = WriteCallbacks().find(callbackId); + if (it == WriteCallbacks().end()) return; + auto cb = std::move(it->second); + WriteCallbacks().erase(it); + if (cb) cb(); + } + + __attribute__((export_name("CrafterNetworkOnWtDatagram"))) + void CrafterNetworkOnWtDatagram(std::int32_t connectionHandle, + char* dataPtr, std::int32_t dataLen) { + auto it = Connections().find(connectionHandle); + if (it == Connections().end() || !it->second->onDatagram) { + std::free(dataPtr); + return; + } + std::vector data(dataPtr, dataPtr + dataLen); + std::free(dataPtr); + it->second->onDatagram(std::move(data)); + } +} diff --git a/implementations/Crafter.Network-ClientQUIC.cpp b/implementations/Crafter.Network-ClientQUIC.cpp index 7b16d68..0f71494 100644 --- a/implementations/Crafter.Network-ClientQUIC.cpp +++ b/implementations/Crafter.Network-ClientQUIC.cpp @@ -280,6 +280,18 @@ std::vector QUICStream::RecieveUntilFullSync(std::uint32_t bufferSize) { return out; } +void QUICStream::SendAsync(const void* buffer, std::uint32_t size, bool finish, + std::function onSent) { + // Copy now: the caller's buffer may not outlive the enqueued task. + std::vector copy(static_cast(buffer), + static_cast(buffer) + size); + ThreadPool::Enqueue([this, copy = std::move(copy), finish, onSent = std::move(onSent)]() mutable { + try { this->SendSync(copy.data(), static_cast(copy.size()), finish); } + catch (...) { /* swallowed — callback still fires so the caller can move on */ } + if (onSent) onSent(); + }); +} + void QUICStream::RecieveAsync(std::function)> cb) { ThreadPool::Enqueue([this, cb]{ cb(this->RecieveSync()); }); } @@ -290,6 +302,27 @@ void QUICStream::RecieveUntilFullAsync(std::uint32_t bufferSize, std::functionRecieveUntilFullSync(bufferSize)); }); } +void QUICStream::PrependReceived(std::vector bytes) { + if (bytes.empty() || !impl) return; + { + std::lock_guard lk(impl->mtx); + impl->pending.push_front(std::move(bytes)); + } + impl->cv.notify_all(); +} + +std::uint64_t QUICStream::GetStreamId() const { + if (!handle) throw QUICException("GetStreamId: stream is not open"); + QUIC_UINT62 id = 0; + std::uint32_t size = sizeof(id); + QUIC_STATUS s = Runtime().api->GetParam(handle, QUIC_PARAM_STREAM_ID, &size, &id); + if (QUIC_FAILED(s)) { + throw QUICException(std::format("GetParam(QUIC_PARAM_STREAM_ID) failed: 0x{:x}", + static_cast(s))); + } + return static_cast(id); +} + // ---------------- ClientQUIC::Impl ---------------- struct ClientQUIC::Impl { HQUIC connection = nullptr; diff --git a/implementations/Crafter.Network-ListenerHTTP.cpp b/implementations/Crafter.Network-ListenerHTTP.cpp index c1aadb2..6514659 100644 --- a/implementations/Crafter.Network-ListenerHTTP.cpp +++ b/implementations/Crafter.Network-ListenerHTTP.cpp @@ -26,6 +26,7 @@ import :ListenerQUIC; import :ClientQUIC; import :HTTP; import :HTTP3; +import :WebTransport; import Crafter.Thread; import std; @@ -65,7 +66,10 @@ namespace { else if (name == ":authority") request.authority = std::move(value); else if (name == ":path") request.path = std::move(value); else if (!name.empty() && name[0] == ':') { - // Unknown request pseudo-header — ignore. + // Pass through other pseudo-headers (e.g. :protocol + // for extended CONNECT — RFC 8441 / RFC 9220) as + // regular headers so the routing layer can see them. + request.headers.emplace(std::move(name), std::move(value)); } else { request.headers.emplace(std::move(name), std::move(value)); } @@ -109,13 +113,49 @@ namespace { } return wire; } + + // Read enough bytes from `stream` to decode one QUIC varint starting at + // `buffer[offset]`. Appends consumed chunks to `buffer` and advances + // `offset` past the varint. Returns the decoded value. Throws + // QUICClosedException if the peer closes before the varint is complete. + std::uint64_t ReadVarintFromStream(QUICStream& stream, + std::vector& buffer, + std::size_t& offset) { + std::uint64_t value = 0; + std::size_t consumed = 0; + while (true) { + const auto* p = reinterpret_cast(buffer.data() + offset); + if (HTTP3::DecodeVarint(p, buffer.size() - offset, value, consumed)) { + offset += consumed; + return value; + } + auto chunk = stream.RecieveSync(); // throws QUICClosed on FIN-without-data + buffer.insert(buffer.end(), chunk.begin(), chunk.end()); + } + } } -// Per-peer state for an accepted connection. Holds the connection wrapper -// and the server-side control stream alive for the lifetime of the peer. +// One accepted-CONNECT WebTransport session living inside a peer. +struct WTSessionEntry { + std::unique_ptr session; +}; + +// Per-peer state for an accepted connection. Holds the connection wrapper, +// the server-side control stream, and the WebTransport sessions that have +// been upgraded on this connection. struct PeerState { std::unique_ptr quic; QUICStream controlStream; + // QPACK encoder/decoder streams; opened immediately after the control + // stream and never written to again (we run without a dynamic table). + // Their presence is what lets strict HTTP/3 stacks like Chromium decide + // the peer is ready for request streams. + QUICStream qpackEncoderStream; + QUICStream qpackDecoderStream; + std::mutex wtMtx; + // Session id == CONNECT stream's QUIC stream id, supplied by the peer + // as the first varint of every WT data stream. + std::unordered_map> wtSessions; }; struct ListenerHTTP::Impl { @@ -125,40 +165,147 @@ struct ListenerHTTP::Impl { bool running = true; }; -ListenerHTTP::ListenerHTTP(std::uint16_t port, - QUICServerCredentials creds, - std::unordered_map> r) - : routes(std::move(r)) - , alpn(HTTP3::kAlpn) - , impl(std::make_unique()) -{ - // The connect callback wires up an OnStream handler that splits unidi - // streams (control / QPACK) from bidi streams (request streams) and - // sends our own SETTINGS frame on a freshly-opened control stream. - auto onConnect = [this](ClientQUIC* peer) { - auto state = std::make_unique(); - state->quic.reset(peer); - - peer->OnStream([this](QUICStream stream) { - if (!stream.canSend) { - // Peer-initiated unidi: client's control stream + optional - // QPACK encoder/decoder streams. Drain — we honour SETTINGS - // by accepting defaults, and we don't track QPACK dynamic- - // table mutations because we don't use the dynamic table. - try { - while (true) (void)stream.RecieveSync(); - } catch (...) {} - return; - } - - // Bidi stream: a request. Drive a single request/response cycle. +namespace { + // Build the per-connection bidi-stream handler. Demuxes WT streams from + // HTTP/3 request streams by peeking the first varint on the wire. Lives + // as a free helper so both ListenerHTTP constructors can install it. + std::function MakeBidiHandler( + ListenerHTTP* self, PeerState* peerState, + const std::unordered_map>* routes, + const std::unordered_map>* wtRoutes) + { + return [self, peerState, routes, wtRoutes](QUICStream stream) { try { - auto raw = stream.RecieveUntilCloseSync(); - HTTPRequest request = ParseRequestFrames(raw); + // ── Phase A: identify the stream kind ───────────────────── + // + // We peek the leading varint(s) off the wire incrementally. + // For HTTP/3 streams that's `frame_type, frame_length`. For + // a WT_STREAM the body runs to FIN so there is no length. + std::vector peeked; + std::size_t cursor = 0; + std::uint64_t firstType = ReadVarintFromStream(stream, peeked, cursor); + + if (firstType == HTTP3::kFrameWtStream && !wtRoutes->empty()) { + // ── WT bidi data stream — second varint is session id. + std::uint64_t sessionId = ReadVarintFromStream(stream, peeked, cursor); + std::vector remaining(peeked.begin() + cursor, peeked.end()); + + WebTransportSession* session = nullptr; + { + std::lock_guard lk(peerState->wtMtx); + auto it = peerState->wtSessions.find(sessionId); + if (it == peerState->wtSessions.end()) return; + session = it->second->session.get(); + } + if (!remaining.empty()) { + stream.PrependReceived(std::move(remaining)); + } + WebTransportDeliverStream(*session, std::move(stream)); + return; + } + + // ── Phase B: HTTP/3 request stream ──────────────────────── + // + // First frame must be HEADERS. Read its length varint, then + // its payload, then look at :method / :protocol. The + // remaining stream content (DATA frames, FIN) is only + // consumed for non-CONNECT requests — WebTransport CONNECT + // never sends a body and the stream stays open for the + // session's lifetime. + std::uint64_t headerLen = ReadVarintFromStream(stream, peeked, cursor); + std::size_t payloadStart = cursor; + while (peeked.size() < payloadStart + headerLen) { + auto chunk = stream.RecieveSync(); + peeked.insert(peeked.end(), chunk.begin(), chunk.end()); + } + + if (firstType != HTTP3::kFrameHeaders) { + // Some other top-level frame — not a valid request. + return; + } + + auto fields = HTTP3::DecodeFieldSection( + reinterpret_cast(peeked.data() + payloadStart), + static_cast(headerLen)); + + HTTPRequest request; + for (auto& [name, value] : fields) { + if (name == ":method") request.method = std::move(value); + else if (name == ":scheme") request.scheme = std::move(value); + else if (name == ":authority") request.authority = std::move(value); + else if (name == ":path") request.path = std::move(value); + else request.headers.emplace(std::move(name), std::move(value)); + } + + // Extended CONNECT? RFC 8441 / draft-ietf-webtrans-http3. + auto protoIt = request.headers.find(":protocol"); + if (request.method == "CONNECT" && protoIt != request.headers.end() + && protoIt->second == "webtransport") + { + auto wtIt = wtRoutes->find(request.path); + if (wtIt == wtRoutes->end()) { + HTTPResponse nf; nf.status = "404"; nf.body = "WebTransport route not found"; + auto wire = SerializeResponse(nf); + try { stream.SendSync(wire.data(), static_cast(wire.size()), true); } catch (...) {} + return; + } + + // Accept: 200 OK HEADERS without FIN. The peer sees the + // session as "ready" once it reads this and keeps the + // CONNECT stream open as the session-control stream. + HTTPResponse ok; ok.status = "200"; + auto wire = SerializeResponse(ok); + stream.SendSync(wire.data(), static_cast(wire.size()), /*finish=*/false); + + std::uint64_t sessionId = stream.GetStreamId(); + + auto entry = std::make_unique(); + entry->session = std::make_unique(); + WebTransportSession* sessionPtr = entry->session.get(); + WebTransportInitialise(*sessionPtr, + peerState->quic.get(), + std::move(stream), + sessionId, + request.path); + { + std::lock_guard lk(peerState->wtMtx); + peerState->wtSessions.emplace(sessionId, std::move(entry)); + } + auto handler = wtIt->second; + ThreadPool::Enqueue([handler, sessionPtr]{ + try { handler(*sessionPtr); } catch (...) {} + }); + return; + } + + // Plain HTTP/3 request. Drain remaining DATA frames + FIN, + // reconstruct the body, dispatch. + std::vector remainingFrames(peeked.begin() + payloadStart + headerLen, peeked.end()); + try { + auto rest = stream.RecieveUntilCloseSync(); + remainingFrames.insert(remainingFrames.end(), rest.begin(), rest.end()); + } catch (...) {} + std::size_t pos = 0; + const auto* p = reinterpret_cast(remainingFrames.data()); + std::size_t avail = remainingFrames.size(); + while (pos < avail) { + std::uint64_t frameType = 0, frameLen = 0; + std::size_t cn = 0; + if (!HTTP3::DecodeVarint(p + pos, avail - pos, frameType, cn)) break; + pos += cn; + if (!HTTP3::DecodeVarint(p + pos, avail - pos, frameLen, cn)) break; + pos += cn; + if (pos + frameLen > avail) break; + if (frameType == HTTP3::kFrameData) { + request.body.append(reinterpret_cast(p + pos), + static_cast(frameLen)); + } + pos += static_cast(frameLen); + } HTTPResponse response; - auto it = routes.find(request.path); - if (it != routes.end()) { + auto it = routes->find(request.path); + if (it != routes->end()) { response = it->second(request); } else { response.status = "404"; @@ -170,8 +317,6 @@ ListenerHTTP::ListenerHTTP(std::uint16_t port, static_cast(wire.size()), /*finish=*/true); } catch (const std::exception& e) { - // Best-effort 500 if we can still send. Stream may already - // be closed; swallow further errors silently. try { HTTPResponse err; err.status = "500"; @@ -182,21 +327,72 @@ ListenerHTTP::ListenerHTTP(std::uint16_t port, /*finish=*/true); } catch (...) {} } + }; + } +} + +ListenerHTTP::ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> r) + : ListenerHTTP(port, std::move(creds), std::move(r), {}) +{} + +ListenerHTTP::ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> r, + std::unordered_map> wt) + : routes(std::move(r)) + , wtRoutes(std::move(wt)) + , alpn(HTTP3::kAlpn) + , impl(std::make_unique()) +{ + auto onConnect = [this](ClientQUIC* peer) { + auto state = std::make_unique(); + state->quic.reset(peer); + PeerState* statePtr = state.get(); + + peer->OnStream([this, statePtr](QUICStream stream) { + if (!stream.canSend) { + // Peer-initiated unidi: client's control stream + optional + // QPACK encoder/decoder streams. Phase 1 drains — peer- + // initiated WT unidi streams are deferred to a later phase. + try { + while (true) (void)stream.RecieveSync(); + } catch (...) {} + return; + } + // Bidi: either HTTP/3 request or WT data stream. Demux inside. + auto handler = MakeBidiHandler(this, statePtr, &this->routes, &this->wtRoutes); + handler(std::move(stream)); }); // Open our outgoing control stream and write the SETTINGS prelude. - // Do this AFTER OnStream is registered so any client-initiated - // unidi stream that races in is handled. The control stream must - // remain open for the connection's lifetime — we never FIN it. + // When wtRoutes is non-empty we advertise WebTransport support so + // the browser will issue extended CONNECT against us. try { state->controlStream = peer->OpenStream(/*unidirectional=*/true); - auto prelude = HTTP3::BuildControlStreamPrelude(); + auto prelude = this->wtRoutes.empty() + ? HTTP3::BuildControlStreamPrelude() + : HTTP3::BuildWebTransportControlStreamPrelude(/*maxSessions=*/16); state->controlStream.SendSync(prelude.data(), static_cast(prelude.size()), /*finish=*/false); + + // QPACK encoder + decoder streams (RFC 9204 §5). We don't use the + // dynamic table, so these streams stay idle for the lifetime of + // the connection. They're not optional in practice: Chromium and + // some other HTTP/3 stacks won't issue any request stream until + // they've seen both stream types from the peer, even when the + // encoder is silent. + state->qpackEncoderStream = peer->OpenStream(/*unidirectional=*/true); + std::uint8_t encType = static_cast(HTTP3::kStreamQpackEnc); + state->qpackEncoderStream.SendSync(&encType, 1, /*finish=*/false); + + state->qpackDecoderStream = peer->OpenStream(/*unidirectional=*/true); + std::uint8_t decType = static_cast(HTTP3::kStreamQpackDec); + state->qpackDecoderStream.SendSync(&decType, 1, /*finish=*/false); } catch (...) { - // If the connection died mid-handshake we land here; the peer - // gets dropped via destruction below. + // Connection died mid-handshake; drop the peer. } std::lock_guard lk(impl->peersMtx); @@ -223,9 +419,6 @@ void ListenerHTTP::Stop() { void ListenerHTTP::Listen() { if (!impl || !impl->listener) return; - // ListenSyncAsync runs the accept loop on this thread and dispatches the - // per-connection callback (control-stream open + OnStream wiring) on the - // ThreadPool. That keeps route handlers off the accept thread. impl->listener->ListenSyncAsync(); } @@ -236,6 +429,14 @@ ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port, , thread(&ListenerHTTP::Listen, &listener) {} +ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::unordered_map> wtRoutes) + : listener(port, std::move(creds), std::move(routes), std::move(wtRoutes)) + , thread(&ListenerHTTP::Listen, &listener) +{} + ListenerAsyncHTTP::~ListenerAsyncHTTP() { Stop(); } diff --git a/implementations/Crafter.Network-ListenerQUIC.cpp b/implementations/Crafter.Network-ListenerQUIC.cpp index 022b7b9..c9e0c9f 100644 --- a/implementations/Crafter.Network-ListenerQUIC.cpp +++ b/implementations/Crafter.Network-ListenerQUIC.cpp @@ -21,6 +21,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA module; #include #include +#include #include #include module Crafter.Network:ListenerQUIC_impl; @@ -84,6 +85,12 @@ namespace { // mkdtemp'd directory under /tmp for the lifetime of the process. // Intended for dev / LAN play / tests — production should pass real // cert/key paths. + // + // Cert shape: ECDSA P-256, validity 13 days, SAN={DNS:localhost, + // IP:127.0.0.1, IP:::1}. These constraints are mandated by Chromium's + // WebTransport `serverCertificateHashes` (must be ECDSA P-256, validity + // <14 days, SAN with the connect target). msquic accepts the same cert + // unchanged, so existing pure-QUIC callers are unaffected. struct SelfSignedCert { std::string certPath; std::string keyPath; @@ -94,24 +101,62 @@ namespace { std::lock_guard lk(mtx); if (cached) return *cached; - char tmpl[] = "/tmp/crafter-quic-cert-XXXXXX"; - if (mkdtemp(tmpl) == nullptr) { - throw QUICException("mkdtemp failed for self-signed cert dir"); - } - std::string dir = tmpl; - SelfSignedCert s; - s.keyPath = dir + "/key.pem"; - s.certPath = dir + "/cert.pem"; - std::string cmd = std::format( - "openssl req -x509 -newkey rsa:2048 -keyout '{}' -out '{}' " - "-days 1 -nodes -subj '/CN=localhost' >/dev/null 2>&1", - s.keyPath, s.certPath); - int rc = std::system(cmd.c_str()); - if (rc != 0) { + // Stable on-disk location so the cert (and therefore its SHA-256) is + // reused across server restarts. Without this, a browser peer that + // pinned the cert hash on a previous run would see a hash mismatch + // the moment we restart. We only regenerate if the cert file is + // missing or has expired. + std::filesystem::path dir = "/tmp/crafter-network-quic-cert"; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) { throw QUICException(std::format( - "openssl CLI failed to generate self-signed cert " - "(exit {}); install openssl or pass certPath/keyPath", - rc)); + "could not create cert cache dir {}: {}", dir.string(), ec.message())); + } + SelfSignedCert s; + s.keyPath = (dir / "key.pem").string(); + s.certPath = (dir / "cert.pem").string(); + + bool needRegen = !std::filesystem::exists(s.certPath, ec) + || !std::filesystem::exists(s.keyPath, ec); + if (!needRegen) { + // Use openssl to ask whether the cert is still valid. -checkend 0 + // returns 0 if the cert is still good, non-zero if expired. + int rc = std::system(std::format( + "openssl x509 -in '{}' -noout -checkend 0 >/dev/null 2>&1", + s.certPath).c_str()); + if (rc != 0) needRegen = true; + } + if (needRegen) { + // Inline openssl config so we get exactly the extensions Chromium's + // WebTransport cert-hash verifier accepts (BasicConstraints CA:FALSE, + // KeyUsage digitalSignature, EKU serverAuth, SAN). Skipping the + // implicit subjectKeyIdentifier / authorityKeyIdentifier that + // `openssl req -x509 -addext ...` would otherwise add. + std::string cmd = std::format( + "openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 " + "-keyout '{}' -out '{}' -days 10 -nodes " + "-extensions v3_wt " + "-config /dev/stdin >/dev/null 2>&1 <<'CONFIG'\n" + "[req]\n" + "distinguished_name = req_dn\n" + "prompt = no\n" + "[req_dn]\n" + "CN = localhost\n" + "[v3_wt]\n" + "basicConstraints = critical, CA:FALSE\n" + "keyUsage = critical, digitalSignature\n" + "extendedKeyUsage = serverAuth\n" + "subjectAltName = DNS:localhost, IP:127.0.0.1, IP:::1\n" + "CONFIG\n", + s.keyPath, s.certPath); + int rc = std::system(cmd.c_str()); + if (rc != 0) { + throw QUICException(std::format( + "openssl CLI failed to generate self-signed cert " + "(exit {}); install openssl or pass certPath/keyPath", + rc)); + } } cached = std::move(s); return *cached; @@ -329,3 +374,53 @@ void ListenerQUIC::ListenAsyncSync() { void ListenerQUIC::ListenAsyncAsync() { impl->acceptLoop = std::thread([this]{ ListenSyncAsync(); }); } + +std::array Crafter::ComputeCertificateHashSHA256(const std::string& certPath) { + // Convert PEM → DER → SHA-256 via openssl, capture hex digest from stdout. + // openssl pipes give us "SHA2-256(stdin)= \n"; we parse the trailing + // hex run. Shelling out keeps msquic the sole TLS dependency. + std::string cmd = std::format( + "openssl x509 -in '{}' -outform der | openssl dgst -sha256 2>/dev/null", + certPath); + FILE* p = popen(cmd.c_str(), "r"); + if (!p) throw QUICException("popen failed while computing cert hash"); + std::string out; + char chunk[256]; + while (auto n = std::fread(chunk, 1, sizeof(chunk), p)) { + out.append(chunk, n); + } + int rc = pclose(p); + if (rc != 0) { + throw QUICException(std::format( + "openssl failed to compute SHA-256 of {} (exit {})", certPath, rc)); + } + auto eq = out.rfind('='); + if (eq == std::string::npos) { + throw QUICException("could not parse openssl dgst output"); + } + std::string hex; + for (std::size_t i = eq + 1; i < out.size(); ++i) { + char c = out[i]; + if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + hex.push_back(c); + } + } + if (hex.size() != 64) { + throw QUICException(std::format( + "unexpected SHA-256 hex length {} (expected 64)", hex.size())); + } + std::array result{}; + for (std::size_t i = 0; i < 32; ++i) { + auto nibble = [](char c) -> std::uint8_t { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + return 10 + (c - 'A'); + }; + result[i] = (nibble(hex[2 * i]) << 4) | nibble(hex[2 * i + 1]); + } + return result; +} + +std::string Crafter::GetSelfSignedCertificatePath() { + return GetSelfSignedCert().certPath; +} diff --git a/implementations/Crafter.Network-WebTransport.cpp b/implementations/Crafter.Network-WebTransport.cpp new file mode 100644 index 0000000..c002ddf --- /dev/null +++ b/implementations/Crafter.Network-WebTransport.cpp @@ -0,0 +1,152 @@ +/* +Crafter®.Network +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +module; +module Crafter.Network:WebTransport_impl; +import :WebTransport; +import :ClientQUIC; +import :HTTP3; +import Crafter.Thread; +import std; + +using namespace Crafter; + +struct WebTransportSession::Impl { + // Non-owning. The session is constructed and owned by ListenerHTTP, and + // ListenerHTTP keeps the underlying ClientQUIC alive in its PeerState + // for the duration of the connection — so this pointer is valid as + // long as the session is. + ClientQUIC* connection = nullptr; + + // The HTTP/3 extended-CONNECT bidi stream the session was upgraded on. + // Stays open for the session's lifetime. Phase 1 closes it with a bare + // FIN; later phases will emit a CLOSE_WEBTRANSPORT_SESSION capsule. + QUICStream connectStream; + + std::mutex mtx; + std::function onStream; + std::deque pendingStreams; + std::function)> onDatagram; // Phase 2 + + bool closed = false; +}; + +WebTransportSession::WebTransportSession() + : impl(std::make_unique()) +{} + +WebTransportSession::WebTransportSession(WebTransportSession&&) noexcept = default; +WebTransportSession& WebTransportSession::operator=(WebTransportSession&&) noexcept = default; + +WebTransportSession::~WebTransportSession() { + if (impl) Close(); +} + +QUICStream WebTransportSession::OpenStream(bool unidirectional) { + if (!impl || impl->closed || !impl->connection) { + throw QUICClosedException(); + } + QUICStream stream = impl->connection->OpenStream(unidirectional); + auto prefix = unidirectional + ? HTTP3::BuildWtUnidiPrefix(sessionId) + : HTTP3::BuildWtBidiPrefix(sessionId); + // Write the WT_STREAM (bidi) or stream-type (unidi) prefix as the + // first send on the stream. The peer reads it to associate this + // stream with our session before treating the rest as opaque payload. + stream.SendSync(prefix.data(), static_cast(prefix.size()), /*finish=*/false); + return stream; +} + +void WebTransportSession::OnStream(std::function callback) { + std::deque drained; + { + std::lock_guard lk(impl->mtx); + impl->onStream = callback; + drained.swap(impl->pendingStreams); + } + // Dispatch any streams that arrived before the handler was installed. + // Each goes to the ThreadPool so user code runs off the demuxer thread. + for (auto& s : drained) { + auto* shared = new QUICStream(std::move(s)); + ThreadPool::Enqueue([callback, shared]{ + callback(std::move(*shared)); + delete shared; + }); + } +} + +void WebTransportSession::OnDatagram(std::function)> callback) { + // Phase 1 stub. Phase 2 will plumb QUIC datagrams through here after + // demuxing on quarter_session_id. + if (impl) impl->onDatagram = std::move(callback); +} + +void WebTransportSession::SendDatagram(const void*, std::uint32_t) { + // Phase 1 stub — would prepend quarter_session_id varint and call + // connection->SendDatagram. Drops silently for now. +} + +void WebTransportSession::Close() { + if (!impl || impl->closed) return; + impl->closed = true; + try { + // Empty FIN on the CONNECT stream. Chrome / Firefox both treat + // peer-FIN of the CONNECT stream as session-close. + impl->connectStream.SendSync(nullptr, 0, /*finish=*/true); + } catch (...) { + // Connection may already be gone — that's fine. + } +} + +// ─── Internal ListenerHTTP-facing helpers ─────────────────────────────── +// +// Declared (not exported) in the interface partition so ListenerHTTP_impl +// can call them; defined here. Friendship in WebTransportSession gives +// them access to the private Impl. + +namespace Crafter { + void WebTransportInitialise(WebTransportSession& session, + ClientQUIC* connection, + QUICStream connectStream, + std::uint64_t sessionId, + std::string path) { + session.impl->connection = connection; + session.impl->connectStream = std::move(connectStream); + session.sessionId = sessionId; + session.path = std::move(path); + } + + void WebTransportDeliverStream(WebTransportSession& session, QUICStream stream) { + std::function cb; + { + std::lock_guard lk(session.impl->mtx); + cb = session.impl->onStream; + if (!cb) { + session.impl->pendingStreams.push_back(std::move(stream)); + return; + } + } + auto* shared = new QUICStream(std::move(stream)); + ThreadPool::Enqueue([cb, shared]{ + cb(std::move(*shared)); + delete shared; + }); + } +} diff --git a/interfaces/Crafter.Network-ClientHTTP.cppm b/interfaces/Crafter.Network-ClientHTTP.cppm index 1ce2793..c75cf23 100644 --- a/interfaces/Crafter.Network-ClientHTTP.cppm +++ b/interfaces/Crafter.Network-ClientHTTP.cppm @@ -32,6 +32,12 @@ namespace Crafter { // // For local development against a self-signed listener, pass // QUICClientCredentials{insecureNoServerValidation = true}. + // + // Browser build: the request is dispatched via the browser's fetch() + // and the synchronous Send() is not compiled — use SendAsync instead. + // The ClientHTTP instance does not maintain a persistent connection + // (fetch is request-scoped); host and port are stored and prefixed to + // the request path on each call. QUICClientCredentials is ignored. export class ClientHTTP { public: std::string host; @@ -44,8 +50,18 @@ namespace Crafter { ClientHTTP(const ClientHTTP&) = delete; ClientHTTP(ClientHTTP&&) noexcept; +#ifndef CRAFTER_NETWORK_BROWSER // Send a request and synchronously read back the full response. HTTPResponse Send(const HTTPRequest& request); +#endif + + // Send a request and deliver the response (or an error) via callback. + // Available on both native and browser builds. Native dispatches on + // Crafter.Thread's ThreadPool; browser uses fetch() and resolves on + // the JS event loop. + void SendAsync(const HTTPRequest& request, + std::function onSuccess, + std::function onError); private: struct Impl; diff --git a/interfaces/Crafter.Network-ClientQUIC.cppm b/interfaces/Crafter.Network-ClientQUIC.cppm index 713d22a..1ad567e 100644 --- a/interfaces/Crafter.Network-ClientQUIC.cppm +++ b/interfaces/Crafter.Network-ClientQUIC.cppm @@ -19,7 +19,9 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ module; +#ifndef CRAFTER_NETWORK_BROWSER #include +#endif export module Crafter.Network:ClientQUIC; import std; @@ -45,9 +47,16 @@ namespace Crafter { }; // Client-side credential validation. By default we require a real cert. - // insecureNoServerValidation disables peer cert checks — only for dev. + // insecureNoServerValidation disables peer cert checks — only for dev, + // and silently ignored in the browser build (browsers enforce their own + // certificate policy). For browser dev against a self-signed listener, + // populate serverCertificateHash with the SHA-256 of the server's DER + // certificate; on the browser it is forwarded to WebTransport's + // serverCertificateHashes option. A zeroed array means "unused" — the + // browser will then require a publicly trusted cert. export struct QUICClientCredentials { bool insecureNoServerValidation = false; + std::array serverCertificateHash{}; }; export class ClientQUIC; @@ -60,8 +69,10 @@ namespace Crafter { // for inbound streams initiated by the peer. export class QUICStream { public: +#ifndef CRAFTER_NETWORK_BROWSER // Underlying msquic HQUIC handle. Treated as opaque by callers. HQUIC handle = nullptr; +#endif // The connection that owns this stream (non-owning). ClientQUIC* connection = nullptr; @@ -72,12 +83,22 @@ namespace Crafter { bool canReceive = true; QUICStream(); +#ifndef CRAFTER_NETWORK_BROWSER QUICStream(HQUIC handle, ClientQUIC* connection); +#else + // Browser-only constructor: wraps a JS-side WebTransport stream + // identified by its integer handle. Used by ClientQUIC::OpenStream + // and by the incoming-stream dispatcher in the JS bridge — not + // intended for direct use. + QUICStream(std::int32_t handle, ClientQUIC* connection, + bool canSend, bool canReceive); +#endif ~QUICStream(); QUICStream(const QUICStream&) = delete; QUICStream(QUICStream&&) noexcept; QUICStream& operator=(QUICStream&&) noexcept; +#ifndef CRAFTER_NETWORK_BROWSER // Send a buffer. If finish=true, the send-side of the stream is closed // after the buffer is delivered (peer will see graceful shutdown). // Blocks until msquic accepts the buffer; throws on stream/conn close. @@ -93,12 +114,36 @@ namespace Crafter { // Read exactly bufferSize bytes; throws if the peer closes early. std::vector RecieveUntilFullSync(std::uint32_t bufferSize); +#endif - // Async variants: dispatched on Crafter.Thread's ThreadPool. + // Send a buffer. If finish=true, the send-side is closed after the + // buffer is delivered. onSent fires once the transport has accepted + // the buffer (native) or the WritableStream writer has resolved + // (browser). Available on both native and browser builds. + void SendAsync(const void* buffer, std::uint32_t size, bool finish, + std::function onSent); + + // Async receive variants. Dispatched on Crafter.Thread's ThreadPool + // (native) or driven by a per-stream JS reader loop (browser). void RecieveAsync(std::function)> recieveCallback); void RecieveUntilCloseAsync(std::function)> recieveCallback); void RecieveUntilFullAsync(std::uint32_t bufferSize, std::function)> recieveCallback); +#ifndef CRAFTER_NETWORK_BROWSER + // Advanced: re-inject already-consumed bytes at the front of the + // receive queue so the next Recieve* call sees them. Used by + // protocol demuxers (e.g. the WebTransport stream router in + // ListenerHTTP) that need to peek a prefix off the wire, then hand + // the stream to user code as if the prefix had never been read. + void PrependReceived(std::vector bytes); + + // Underlying QUIC stream id. Stable for the stream's lifetime. + // Browsers identify WT streams by the session's CONNECT stream id, + // so the server has to query and remember it at session creation + // time. Throws if the stream is not yet started. + std::uint64_t GetStreamId() const; +#endif + // Cleanly shut down the stream (both directions). void Stop(); @@ -109,8 +154,9 @@ namespace Crafter { }; // A QUIC connection. On the client side, constructing one initiates the - // handshake and blocks until it succeeds (or throws on failure). On the - // server side, ListenerQUIC instantiates these for accepted peers. + // handshake and (on native) blocks until it succeeds, or throws on + // failure. On the server side, ListenerQUIC instantiates these for + // accepted peers. // // A connection multiplexes: // - Reliable, ordered streams (open via OpenStream() / observe inbound @@ -120,10 +166,28 @@ namespace Crafter { // Lifetime: ~ClientQUIC closes the connection. Streams obtained from // OpenStream() are scoped to the connection and must be destroyed (or // moved out) before the ClientQUIC. + // + // Browser build: the only QUIC-shaped API the browser exposes is + // WebTransport, which is HTTP/3-based and reached at a fixed URL. Here: + // - The constructor returns immediately; the connection is opened in + // the background. Operations issued before the connection is ready + // are queued JS-side until WebTransport's "ready" promise resolves + // (or fail with QUICClosedException if the connection rejects). + // - `alpn` is mapped to the URL path: new WebTransport( + // `https://${host}:${port}/${alpn}`). The QUIC-layer ALPN itself + // is fixed to "h3" by the browser and cannot be customised. + // - The server side must accept WebTransport sessions (HTTP/3 extended + // CONNECT) on the path equal to `alpn`. Plain QUIC with a custom + // ALPN — what ListenerQUIC offers today — is not reachable from a + // browser. + // - Synchronous send/receive methods are not compiled. Use the *Async + // variants instead. export class ClientQUIC { public: // ALPN identifier exchanged in the handshake. Both peers must agree. // For 3DForts use e.g. "f3d/1" or similar — a short stable token. + // On the browser build, this is the WebTransport URL path instead + // of an ALPN token; see the class comment above. std::string alpn; // Client constructor: connects to host:port using QUIC. ALPN must @@ -133,10 +197,12 @@ namespace Crafter { ClientQUIC(std::string host, std::uint16_t port, std::string alpn, QUICClientCredentials creds = {}); +#ifndef CRAFTER_NETWORK_BROWSER // Server-side constructor used by ListenerQUIC for accepted peers. // Takes ownership of an already-accepted msquic connection handle // and the server configuration handle. Not intended for direct use. ClientQUIC(HQUIC connectionHandle, HQUIC serverConfiguration, std::string alpn); +#endif ~ClientQUIC(); ClientQUIC(const ClientQUIC&) = delete; @@ -162,15 +228,19 @@ namespace Crafter { // msquic worker; copy/queue and return promptly. void OnDatagram(std::function)> callback); +#ifndef CRAFTER_NETWORK_BROWSER // Block the caller until the next datagram arrives; returns it. // Throws QUICClosedException if the connection closes first. std::vector RecieveDatagramSync(); +#endif // Cleanly shut down the connection. void Stop(); +#ifndef CRAFTER_NETWORK_BROWSER // Underlying handle for advanced use (parameter queries, etc.). HQUIC GetHandle() const; +#endif private: struct Impl; diff --git a/interfaces/Crafter.Network-ClientTCP.cppm b/interfaces/Crafter.Network-ClientTCP.cppm index efca771..c020945 100755 --- a/interfaces/Crafter.Network-ClientTCP.cppm +++ b/interfaces/Crafter.Network-ClientTCP.cppm @@ -18,6 +18,7 @@ License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ module; +#ifndef CRAFTER_NETWORK_BROWSER #include #include #include @@ -31,9 +32,11 @@ module; #include #include #include +#endif export module Crafter.Network:ClientTCP; import std; +#ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { export class SocketClosedException : public std::exception { public: @@ -68,4 +71,5 @@ namespace Crafter { hostent* host; sockaddr_in serv_addr; }; -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/interfaces/Crafter.Network-HTTP3.cppm b/interfaces/Crafter.Network-HTTP3.cppm index bc0615c..07b01d5 100644 --- a/interfaces/Crafter.Network-HTTP3.cppm +++ b/interfaces/Crafter.Network-HTTP3.cppm @@ -43,11 +43,33 @@ namespace Crafter::HTTP3 { export inline constexpr std::uint64_t kFrameData = 0x00; export inline constexpr std::uint64_t kFrameHeaders = 0x01; export inline constexpr std::uint64_t kFrameSettings = 0x04; + // WebTransport bidirectional stream frame type (draft-ietf-webtrans-http3). + // Distinct from normal HTTP/3 frames — its body is unbounded (runs to FIN) + // rather than length-prefixed, and the first bytes of the body are the + // session id varint. + export inline constexpr std::uint64_t kFrameWtStream = 0x41; // ---------------- Unidirectional stream types (RFC 9114 §6.2) ---------------- export inline constexpr std::uint64_t kStreamControl = 0x00; export inline constexpr std::uint64_t kStreamQpackEnc = 0x02; export inline constexpr std::uint64_t kStreamQpackDec = 0x03; + // WebTransport unidirectional stream type (draft-ietf-webtrans-http3). + // After this varint comes a session id varint, then opaque payload to FIN. + export inline constexpr std::uint64_t kStreamWt = 0x54; + + // ---------------- SETTINGS parameter identifiers ---------------- + // Required to negotiate WebTransport over HTTP/3 + HTTP/3 datagrams. + export inline constexpr std::uint64_t kSettingQpackMaxTableCapacity = 0x01; // RFC 9204 + export inline constexpr std::uint64_t kSettingQpackBlockedStreams = 0x07; // RFC 9204 + export inline constexpr std::uint64_t kSettingEnableConnectProtocol = 0x08; // RFC 9220 + export inline constexpr std::uint64_t kSettingH3Datagram = 0x33; // RFC 9297 + // Legacy identifiers from older WebTransport / H3-DATAGRAM drafts. Chrome + // (as of M120-ish) advertises and looks for the draft-02 / draft-04 ids + // alongside the RFC ones; if we only send the modern ids it decides we + // don't support WebTransport and aborts with ERR_METHOD_NOT_SUPPORTED. + export inline constexpr std::uint64_t kSettingH3DatagramDraft04 = 0xffd277; // draft-ietf-masque-h3-datagram-04 + export inline constexpr std::uint64_t kSettingEnableWebTransport = 0x2b603742; // draft-02 boolean + export inline constexpr std::uint64_t kSettingWtMaxSessions = 0xc671706a; // draft-ietf-webtrans-http3 (-07+) // ---------------- Errors ---------------- export class HTTP3ProtocolError : public std::runtime_error { @@ -575,4 +597,55 @@ namespace Crafter::HTTP3 { EncodeVarint(0, out); // frame length 0 return out; } + + // Server-side variant that advertises WebTransport-over-HTTP/3 support + // to the peer. Without these three SETTINGS the browser silently rejects + // the extended CONNECT and the WebTransport.ready promise never resolves. + // `maxSessions` becomes the value of SETTINGS_WT_MAX_SESSIONS. + export inline std::vector BuildWebTransportControlStreamPrelude( + std::uint64_t maxSessions = 1) + { + // Encode the SETTINGS body first so we can write its length. The two + // QPACK settings declare we run with no dynamic table — sent + // explicitly because some HTTP/3 stacks (Chrome among them) refuse + // to consider the peer ready for extended-CONNECT until they have + // seen a baseline QPACK configuration. The draft-02 ENABLE_WEBTRANSPORT + // and draft-04 H3_DATAGRAM ids are sent alongside their RFC counterparts + // for compatibility with current Chrome (which still negotiates the + // draft form even when advertising RFC support). + std::vector body; + EncodeVarint(kSettingQpackMaxTableCapacity, body); EncodeVarint(0, body); + EncodeVarint(kSettingQpackBlockedStreams, body); EncodeVarint(0, body); + EncodeVarint(kSettingEnableConnectProtocol, body); EncodeVarint(1, body); + EncodeVarint(kSettingH3Datagram, body); EncodeVarint(1, body); + EncodeVarint(kSettingH3DatagramDraft04, body); EncodeVarint(1, body); + EncodeVarint(kSettingEnableWebTransport, body); EncodeVarint(1, body); + EncodeVarint(kSettingWtMaxSessions, body); EncodeVarint(maxSessions, body); + + std::vector out; + EncodeVarint(kStreamControl, out); + WriteFrame(out, kFrameSettings, body.data(), body.size()); + return out; + } + + // Prefix bytes that go on the front of an outgoing WT bidi stream — the + // peer reads these to know which session the stream belongs to. After + // this prefix the stream contains opaque WebTransport payload until FIN + // (there is no length field — WT_STREAM is the only HTTP/3 frame whose + // body runs to end-of-stream). + export inline std::vector BuildWtBidiPrefix(std::uint64_t sessionId) { + std::vector out; + EncodeVarint(kFrameWtStream, out); + EncodeVarint(sessionId, out); + return out; + } + + // Prefix bytes that go on the front of an outgoing WT unidi stream + // (server-initiated → client). Stream-type varint then session id. + export inline std::vector BuildWtUnidiPrefix(std::uint64_t sessionId) { + std::vector out; + EncodeVarint(kStreamWt, out); + EncodeVarint(sessionId, out); + return out; + } } diff --git a/interfaces/Crafter.Network-ListenerHTTP.cppm b/interfaces/Crafter.Network-ListenerHTTP.cppm index b86358b..b81d6ab 100644 --- a/interfaces/Crafter.Network-ListenerHTTP.cppm +++ b/interfaces/Crafter.Network-ListenerHTTP.cppm @@ -23,7 +23,9 @@ import std; import :HTTP; import :ListenerQUIC; import :ClientQUIC; +import :WebTransport; +#ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { // HTTP/3 server. Wraps a ListenerQUIC: each accepted QUIC connection // registers a per-stream handler that parses one request, dispatches it @@ -33,6 +35,13 @@ namespace Crafter { // Routes are keyed by `:path` (exact match). Unknown paths return a // synthetic 404. Route handlers run on the ThreadPool — multiple requests // on the same connection can therefore execute concurrently. + // + // WebTransport: pass a non-empty `wtRoutes` to additionally accept + // extended-CONNECT requests (`:method=CONNECT, :protocol=webtransport`) + // whose `:path` matches a registered route. The matching handler runs + // on the ThreadPool with a `WebTransportSession&` argument scoped to + // the session's lifetime. Sending WT-required SETTINGS happens + // automatically when wtRoutes is non-empty. export class ListenerHTTP { public: // The underlying QUIC listener owns the accept loop, certificates, @@ -40,12 +49,20 @@ namespace Crafter { // and owned by this Impl so that move construction/destruction is // straightforward. std::unordered_map> routes; + std::unordered_map> wtRoutes; std::string alpn; ListenerHTTP(std::uint16_t port, QUICServerCredentials creds, std::unordered_map> routes); + // WT-aware overload. `routes` and `wtRoutes` may both be non-empty; + // they are dispatched on disjoint criteria so they don't collide. + ListenerHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::unordered_map> wtRoutes); + ~ListenerHTTP(); ListenerHTTP(const ListenerHTTP&) = delete; ListenerHTTP(ListenerHTTP&&) noexcept; @@ -71,7 +88,15 @@ namespace Crafter { ListenerAsyncHTTP(std::uint16_t port, QUICServerCredentials creds, std::unordered_map> routes); + + // WT-aware overload. + ListenerAsyncHTTP(std::uint16_t port, + QUICServerCredentials creds, + std::unordered_map> routes, + std::unordered_map> wtRoutes); + ~ListenerAsyncHTTP(); void Stop(); }; } +#endif diff --git a/interfaces/Crafter.Network-ListenerQUIC.cppm b/interfaces/Crafter.Network-ListenerQUIC.cppm index 49b3bc1..746e47b 100644 --- a/interfaces/Crafter.Network-ListenerQUIC.cppm +++ b/interfaces/Crafter.Network-ListenerQUIC.cppm @@ -19,11 +19,14 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ module; +#ifndef CRAFTER_NETWORK_BROWSER #include +#endif export module Crafter.Network:ListenerQUIC; import std; import :ClientQUIC; +#ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { // Server side of a QUIC connection. Mirrors ListenerTCP in shape: // four Listen* methods covering the sync/async outer-loop x sync/async @@ -72,4 +75,17 @@ namespace Crafter { std::unique_ptr impl; std::uint32_t totalClientCounter = 0; }; + + // Compute the SHA-256 of the DER bytes of a PEM-encoded X.509 certificate. + // Returns the 32-byte digest. Intended for surfacing the self-signed cert + // hash to a browser peer (Chrome's WebTransport requires the client to + // pass this hash via `serverCertificateHashes` when peering against a + // cert that's not in the system trust store). Shells out to openssl. + export std::array ComputeCertificateHashSHA256(const std::string& certPath); + + // Path of the lazily-generated self-signed cert (PEM). Triggers generation + // on first call. Useful for piping into ComputeCertificateHashSHA256 so + // a browser peer can be told the hash to put in `serverCertificateHashes`. + export std::string GetSelfSignedCertificatePath(); } +#endif diff --git a/interfaces/Crafter.Network-ListenerTCP.cppm b/interfaces/Crafter.Network-ListenerTCP.cppm index 007d6e7..6acfd3c 100755 --- a/interfaces/Crafter.Network-ListenerTCP.cppm +++ b/interfaces/Crafter.Network-ListenerTCP.cppm @@ -22,6 +22,7 @@ export module Crafter.Network:ListenerTCP; import std; import :ClientTCP; +#ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { export class ListenerTCP { public: @@ -40,4 +41,5 @@ namespace Crafter { std::uint32_t totalClientCounter = 0; int s; }; -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/interfaces/Crafter.Network-WebTransport.cppm b/interfaces/Crafter.Network-WebTransport.cppm new file mode 100644 index 0000000..9e87e57 --- /dev/null +++ b/interfaces/Crafter.Network-WebTransport.cppm @@ -0,0 +1,105 @@ +/* +Crafter®.Network +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3.0 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +export module Crafter.Network:WebTransport; +import std; +import :ClientQUIC; + +#ifndef CRAFTER_NETWORK_BROWSER +namespace Crafter { + // Server-side handle to one accepted WebTransport-over-HTTP/3 session. + // Constructed by ListenerHTTP when it receives an extended-CONNECT + // request whose :path matches a registered WT route. Handed to the + // user's route handler as the only argument. + // + // API shape mirrors ClientQUIC so application code can be written once + // and used on either side of the wire — open bidi streams, register an + // OnStream handler for peer-initiated streams, close the session. + // + // Lifetime: the session owns the CONNECT stream that was upgraded. + // Destruction (or explicit Close()) FINs that stream, which the peer + // interprets as session-end. Phase 1 does not emit a CLOSE_WEBTRANSPORT + // _SESSION capsule — bare FIN is sufficient for Chrome / Firefox. + // + // Phase 1 scope: + // - bidirectional streams: OpenStream + OnStream + // - session close via Close() / destruction + // Out of scope (later phases): + // - datagrams (SendDatagram / OnDatagram are stubs that no-op) + // - unidirectional streams (OpenStream(unidirectional=true) throws) + // - capsule protocol (DRAIN/CLOSE capsules) + export class WebTransportSession { + public: + // Underlying QUIC stream id of the CONNECT stream. The peer + // identifies streams that belong to this session by this number. + std::uint64_t sessionId = 0; + + // Path the client connected to. Useful for routing within a single + // wtRoutes handler that's registered against multiple paths. + std::string path; + + WebTransportSession(); + ~WebTransportSession(); + WebTransportSession(const WebTransportSession&) = delete; + WebTransportSession(WebTransportSession&&) noexcept; + WebTransportSession& operator=(WebTransportSession&&) noexcept; + + // Open a new bidi stream toward the peer. The WT_STREAM prefix + // (frame type + session id) is written to the stream automatically + // before this returns; the caller's first Send* delivers the first + // bytes of opaque payload. Throws on connection close. + QUICStream OpenStream(bool unidirectional = false); + + // Register a handler for streams the peer opens against this + // session. Already-buffered streams that arrived before the + // handler was installed are drained into the new handler. + void OnStream(std::function callback); + + // Register a handler for datagrams the peer sends on this + // session. Phase 1 STUB — datagrams are not yet plumbed through. + void OnDatagram(std::function)> callback); + + // Send a datagram. Phase 1 STUB — silently drops. + void SendDatagram(const void* buffer, std::uint32_t size); + + // FIN the CONNECT stream. Subsequent OpenStream calls throw; any + // pending receivers on owned streams will fail with the connection + // close. Idempotent. + void Close(); + + private: + struct Impl; + std::unique_ptr impl; + friend class ListenerHTTP; + friend void WebTransportInitialise(WebTransportSession&, ClientQUIC*, QUICStream, + std::uint64_t, std::string); + friend void WebTransportDeliverStream(WebTransportSession&, QUICStream); + }; + + // Internal — used by ListenerHTTP's WT demuxer. Not exported (and only + // visible to other TUs within the Crafter.Network module). + void WebTransportInitialise(WebTransportSession& session, + ClientQUIC* connection, + QUICStream connectStream, + std::uint64_t sessionId, + std::string path); + void WebTransportDeliverStream(WebTransportSession& session, QUICStream stream); +} +#endif diff --git a/interfaces/Crafter.Network.cppm b/interfaces/Crafter.Network.cppm index 1c6d696..a6713ad 100755 --- a/interfaces/Crafter.Network.cppm +++ b/interfaces/Crafter.Network.cppm @@ -26,4 +26,12 @@ export import :ClientHTTP; export import :ListenerHTTP; export import :HTTP; export import :ClientQUIC; -export import :ListenerQUIC; \ No newline at end of file +export import :ListenerQUIC; +export import :WebTransport; +#ifndef CRAFTER_NETWORK_BROWSER +// Exposed so user code can build WebTransport clients by hand against a +// ClientQUIC until we ship a ClientWebTransport wrapper. Most callers do +// not need the HTTP/3 frame helpers directly. Excluded from the browser +// build — HTTP3 uses throw and the wasm target runs with -fno-exceptions. +export import :HTTP3; +#endif \ No newline at end of file diff --git a/project.cpp b/project.cpp index 4a67874..24179e4 100644 --- a/project.cpp +++ b/project.cpp @@ -4,7 +4,7 @@ namespace fs = std::filesystem; using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { - constexpr std::array networkInterfaces = { + constexpr std::array networkInterfaces = { "interfaces/Crafter.Network", "interfaces/Crafter.Network-ClientTCP", "interfaces/Crafter.Network-ListenerTCP", @@ -14,14 +14,7 @@ extern "C" Configuration CrafterBuildProject(std::span a "interfaces/Crafter.Network-HTTP3", "interfaces/Crafter.Network-ClientQUIC", "interfaces/Crafter.Network-ListenerQUIC", - }; - constexpr std::array networkImplementations = { - "implementations/Crafter.Network-ClientTCP", - "implementations/Crafter.Network-ListenerTCP", - "implementations/Crafter.Network-ClientHTTP", - "implementations/Crafter.Network-ListenerHTTP", - "implementations/Crafter.Network-ClientQUIC", - "implementations/Crafter.Network-ListenerQUIC", + "interfaces/Crafter.Network-WebTransport", }; std::vector depArgs(args.begin(), args.end()); @@ -38,6 +31,51 @@ extern "C" Configuration CrafterBuildProject(std::span a ApplyStandardArgs(cfg, args); cfg.dependencies = { thread }; + // Browser path: any wasm32-* target gets the browser network stack + // (fetch + WebTransport via JS glue). msquic and the POSIX socket + // backends are skipped; the listener / TCP partitions stub to empty + // modules via #ifdef CRAFTER_NETWORK_BROWSER in their interface files. + // HTTP3 (varint / frame / QPACK codec) is dropped entirely — it threw + // exceptions for protocol errors, which the wasm build's -fno-exceptions + // forbids, and the browser's fetch() handles HTTP-layer framing itself. + bool browser = cfg.target.find("wasm") != std::string::npos; + if (browser) { + cfg.defines.push_back({"CRAFTER_NETWORK_BROWSER", ""}); + + std::array browserIfaces = { + "interfaces/Crafter.Network", + "interfaces/Crafter.Network-ClientTCP", + "interfaces/Crafter.Network-ListenerTCP", + "interfaces/Crafter.Network-ClientHTTP", + "interfaces/Crafter.Network-ListenerHTTP", + "interfaces/Crafter.Network-HTTP", + "interfaces/Crafter.Network-ClientQUIC", + "interfaces/Crafter.Network-ListenerQUIC", + "interfaces/Crafter.Network-WebTransport", + }; + std::array browserImpls = { + "implementations/Crafter.Network-ClientHTTP-Browser", + "implementations/Crafter.Network-ClientQUIC-Browser", + }; + cfg.GetInterfacesAndImplementations(browserIfaces, browserImpls); + + // JS glue shipped alongside the .wasm. The consuming executable's + // wasi-browser runtime merges this into the env import object + // before instantiation (mirrors Crafter.Graphics/dom-env.js). + cfg.files.emplace_back(fs::path("additional/network-env.js")); + return cfg; + } + + constexpr std::array networkImplementations = { + "implementations/Crafter.Network-ClientTCP", + "implementations/Crafter.Network-ListenerTCP", + "implementations/Crafter.Network-ClientHTTP", + "implementations/Crafter.Network-ListenerHTTP", + "implementations/Crafter.Network-ClientQUIC", + "implementations/Crafter.Network-ListenerQUIC", + "implementations/Crafter.Network-WebTransport", + }; + // msquic — provides the QUIC transport used by ClientQUIC / ListenerQUIC. // Cloned + built via CMake into the per-project external cache; no system // package required. Submodules (quictls / clog / etc.) come via the @@ -62,9 +100,9 @@ extern "C" Configuration CrafterBuildProject(std::span a // linker at the actual output location. msquic.libDirs = { "bin/Release" }; msquic.libs = { "msquic" }; - std::array ifaces; + std::array ifaces; std::ranges::copy(networkInterfaces, ifaces.begin()); - std::array impls; + std::array impls; std::ranges::copy(networkImplementations, impls.begin()); cfg.GetInterfacesAndImplementations(ifaces, impls); diff --git a/tests/ShouldEchoWebTransport/ShouldEchoWebTransport.cpp b/tests/ShouldEchoWebTransport/ShouldEchoWebTransport.cpp new file mode 100644 index 0000000..d1daa22 --- /dev/null +++ b/tests/ShouldEchoWebTransport/ShouldEchoWebTransport.cpp @@ -0,0 +1,182 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License version 3.0 as published by the Free Software Foundation; + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// End-to-end WebTransport echo. Spins up a ListenerHTTP with a wtRoutes +// handler that echoes back whatever the peer sent on each new bidi +// stream, then drives a hand-rolled client side using raw ClientQUIC + +// HTTP3 framing (there's no ClientWebTransport class yet; that's Phase 4). +// Verifies: +// - extended CONNECT is accepted, 200 OK delivered without FIN +// - WT_STREAM bidi framing parses correctly on both sides +// - echoed payload round-trips byte-for-byte + +import Crafter.Network; +import Crafter.Thread; +import std; +using namespace Crafter; + +namespace { + // Helper: read one HTTP/3 frame off `stream` into a freshly-allocated + // buffer. Returns (frameType, payload). The peeked-but-unconsumed + // tail bytes (e.g. start of the next frame) are PrependReceived'd + // back onto the stream. + std::pair> ReadFrame(QUICStream& stream) { + std::vector buf; + // First varint = frame type. + std::uint64_t type = 0; std::size_t cn = 0; + while (true) { + const auto* p = reinterpret_cast(buf.data()); + if (HTTP3::DecodeVarint(p, buf.size(), type, cn)) break; + auto chunk = stream.RecieveSync(); + buf.insert(buf.end(), chunk.begin(), chunk.end()); + } + std::vector afterType(buf.begin() + cn, buf.end()); + buf = std::move(afterType); + + // Second varint = frame length. + std::uint64_t len = 0; std::size_t lc = 0; + while (true) { + const auto* p = reinterpret_cast(buf.data()); + if (HTTP3::DecodeVarint(p, buf.size(), len, lc)) break; + auto chunk = stream.RecieveSync(); + buf.insert(buf.end(), chunk.begin(), chunk.end()); + } + std::vector afterLen(buf.begin() + lc, buf.end()); + buf = std::move(afterLen); + + // Read enough for the full payload. + while (buf.size() < len) { + auto chunk = stream.RecieveSync(); + buf.insert(buf.end(), chunk.begin(), chunk.end()); + } + + std::vector payload(buf.begin(), buf.begin() + len); + std::vector tail(buf.begin() + len, buf.end()); + if (!tail.empty()) stream.PrependReceived(std::move(tail)); + return {type, std::move(payload)}; + } +} + +int main() { + ThreadPool::Start(); + + constexpr std::string_view kPayload = "hello-webtransport"; + constexpr std::uint16_t kPort = 8083; + + // ── Server ──────────────────────────────────────────────────────── + QUICServerCredentials serverCreds; + serverCreds.selfSigned = true; + + std::unordered_map> httpRoutes = {}; + std::unordered_map> wtRoutes = { + {"/echo", [](WebTransportSession& session) { + session.OnStream([](QUICStream peerStream) { + try { + auto bytes = peerStream.RecieveUntilCloseSync(); + peerStream.SendSync(bytes.data(), + static_cast(bytes.size()), + /*finish=*/true); + } catch (...) {} + }); + }}, + }; + ListenerAsyncHTTP listener(kPort, serverCreds, std::move(httpRoutes), std::move(wtRoutes)); + + try { + // ── Client (hand-rolled WT bring-up over raw ClientQUIC) ────── + QUICClientCredentials clientCreds; + clientCreds.insecureNoServerValidation = true; + ClientQUIC quic("localhost", kPort, std::string(HTTP3::kAlpn), clientCreds); + + // Drain peer-initiated unidi streams (its control + QPACK streams). + // Without this they'd back up and msquic might abort the connection. + quic.OnStream([](QUICStream stream) { + try { while (true) (void)stream.RecieveSync(); } catch (...) {} + }); + + // Our outgoing control stream + WT-aware SETTINGS prelude. + QUICStream controlStream = quic.OpenStream(/*unidirectional=*/true); + auto prelude = HTTP3::BuildWebTransportControlStreamPrelude(/*maxSessions=*/1); + controlStream.SendSync(prelude.data(), + static_cast(prelude.size()), + /*finish=*/false); + + // CONNECT request stream. Send HEADERS, do NOT FIN — the stream + // is the session-control stream and stays open for its lifetime. + QUICStream connectStream = quic.OpenStream(/*unidirectional=*/false); + std::vector> connectFields = { + {":method", "CONNECT"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/echo"}, + {":protocol", "webtransport"}, + }; + auto headerPayload = HTTP3::EncodeFieldSection(connectFields); + std::vector connectWire; + HTTP3::WriteFrame(connectWire, HTTP3::kFrameHeaders, + headerPayload.data(), headerPayload.size()); + connectStream.SendSync(connectWire.data(), + static_cast(connectWire.size()), + /*finish=*/false); + + // Read the response HEADERS frame. + auto [respType, respPayload] = ReadFrame(connectStream); + if (respType != HTTP3::kFrameHeaders) { + std::println("bad response frame type: {}", respType); + return 1; + } + auto respFields = HTTP3::DecodeFieldSection( + reinterpret_cast(respPayload.data()), + respPayload.size()); + std::string status; + for (auto& [k, v] : respFields) if (k == ":status") status = v; + if (status != "200") { + std::println("CONNECT rejected with status {}", status); + return 1; + } + + // Session is ready. session_id equals the CONNECT stream's QUIC + // stream id — same number on both ends of the wire. + std::uint64_t sessionId = connectStream.GetStreamId(); + + // Open a WT data bidi stream. Prefix: varint(0x41) varint(sessionId). + QUICStream wtStream = quic.OpenStream(/*unidirectional=*/false); + auto prefix = HTTP3::BuildWtBidiPrefix(sessionId); + + std::vector wire; + wire.insert(wire.end(), prefix.begin(), prefix.end()); + wire.insert(wire.end(), kPayload.begin(), kPayload.end()); + wtStream.SendSync(wire.data(), + static_cast(wire.size()), + /*finish=*/true); + + // Server echoes the payload (the prefix has already been stripped + // server-side; the bytes we read here are pure echo). + auto echoed = wtStream.RecieveUntilCloseSync(); + std::string got(echoed.begin(), echoed.end()); + if (got == kPayload) { + std::_Exit(0); + } + std::println("payload mismatch: expected '{}', got '{}'", kPayload, got); + return 1; + } catch (std::exception& e) { + std::println("client failed: {}", e.what()); + return 1; + } +} diff --git a/tests/ShouldEchoWebTransport/project.cpp b/tests/ShouldEchoWebTransport/project.cpp new file mode 100644 index 0000000..b0ca04d --- /dev/null +++ b/tests/ShouldEchoWebTransport/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "tests/ShouldEchoWebTransport/"; + cfg.name = "ShouldEchoWebTransport"; + cfg.outputName = "ShouldEchoWebTransport"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + cfg.dependencies = { ParentLib("crafter-network") }; + cfg.linkFlags.push_back("-Wl,--export-dynamic"); + cfg.linkFlags.push_back("-ldl"); + std::array ifaces = {}; + std::array impls = { "ShouldEchoWebTransport" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +}