browser wasm

This commit is contained in:
Jorijn van der Graaf 2026-05-19 02:53:50 +02:00
commit e8630528af
24 changed files with 2490 additions and 100 deletions

100
README.md
View file

@ -1,18 +1,20 @@
# Crafter.Network # 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 ## 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 ## 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. - **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.). - **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.).
- **Asynchronous Operations**: Thread poolbased async operations for improved performance. - **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.
- **Cross-Platform**: Built for Unix-like systems with socket-based networking. - **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 poolbased 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. - **Modern C++**: Uses C++20 modules, STL containers, and modern C++ features.
## Architecture ## Architecture
@ -21,15 +23,15 @@ The library follows a modular design using C++20 modules:
### Core Modules ### Core Modules
- `Crafter.Network`: Main module that exports all components - `Crafter.Network`: Main module that exports all components
- `Crafter.Network:ClientTCP`: TCP client implementation - `Crafter.Network:ClientTCP`: TCP client implementation (native only)
- `Crafter.Network:ListenerTCP`: TCP server implementation - `Crafter.Network:ListenerTCP`: TCP server implementation (native only)
- `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`) - `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`). On browser builds this maps to `fetch()`.
- `Crafter.Network:ListenerHTTP`: HTTP/3 server (ALPN `h3`) - `Crafter.Network:ListenerHTTP`: HTTP/3 + WebTransport server (ALPN `h3`, native only)
- `Crafter.Network:HTTP`: HTTP request/response types and constructors - `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: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 - `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.
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: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 ## 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. 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<std::string,
std::function<Crafter::HTTPResponse(const Crafter::HTTPRequest&)>> httpRoutes;
std::unordered_map<std::string,
std::function<void(Crafter::WebTransportSession&)>> wtRoutes;
wtRoutes["/echo"] = [](Crafter::WebTransportSession& session) {
session.OnStream([](Crafter::QUICStream peerStream) {
auto bytes = peerStream.RecieveUntilCloseSync();
peerStream.SendSync(bytes.data(),
static_cast<std::uint32_t>(bytes.size()),
/*finish=*/true);
});
};
Crafter::QUICServerCredentials creds;
creds.selfSigned = true; // dev-only, see "Self-signed certs & browser peers"
Crafter::ListenerAsyncHTTP listener(4443, creds, std::move(httpRoutes), std::move(wtRoutes));
```
Browser-initiated bidi/unidi WebTransport streams arrive via `session.OnStream(...)`. The `WT_BIDI` (`0x41`) / `WT_UNIDI` (`0x54`) stream-type prefix and session-id varint are stripped before the user handler sees the stream; what the handler reads is pure session payload. Phase 1 covers session establishment + bidirectional streams; WebTransport datagrams and capsule-protocol control are stubbed for a follow-up.
### Self-signed certs & browser peers
Passing `QUICServerCredentials{selfSigned = true}` makes the listener 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 ## 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 - `crafter-build` — host native (x86_64-pc-linux-gnu by default), msquic + listeners + sync APIs.
- **lib**: Static library build with dependencies - `crafter-build --target=wasm32-wasip1` — browser build, fetch + WebTransport, async-only API; defines `CRAFTER_NETWORK_BROWSER`, drops msquic.
- **lib-debug**: Debug static library build - `crafter-build test [globs]` — build and run tests under `tests/`.
- **lib-shared**: Shared library build with dependencies
## Testing ## Testing
@ -103,17 +155,19 @@ The library includes tests covering:
- HTTP/3 connection multiplexing (`ShouldSendRecieveKeepaliveHTTP`) — two requests share one QUIC connection - HTTP/3 connection multiplexing (`ShouldSendRecieveKeepaliveHTTP`) — two requests share one QUIC connection
- HTTP/3 large body transfer (`ShouldSendRecieveLargeHTTP`) — 10 MiB POST - 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 - 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 reliable streams (`ShouldSendRecieveQUICStream`)
- QUIC unreliable datagrams - 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. The external-interop test requires outbound UDP/443; if your network blocks it the test will fail.
## Dependencies ## Dependencies
- **Crafter.Thread**: Thread pool management for asynchronous operations. - **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). - 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 ## Usage Example

329
additional/network-env.js Normal file
View file

@ -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<streamId> }
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,
});

View file

@ -0,0 +1 @@
b7c4a81084fc56f45f1a6025fafdc8a1b05bf8388947f1840608da565cd22c8e

View file

@ -0,0 +1,227 @@
// Crafter.Network SimpleClient example.
//
// Browser build (wasm32-wasip1, default):
// crafter-build
// Serve bin/<wasm-out-dir>/ 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 <csignal>
#include <atomic>
#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<std::size_t>(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<std::uint8_t, 32> ParseHexHash(std::string_view hex) {
std::array<std::uint8_t, 32> 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<std::uint8_t, 32>{};
out[i] = static_cast<std::uint8_t>((hi << 4) | lo);
}
return out;
}
void StartWebTransportEcho(std::array<std::uint8_t, 32> 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<char> 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<std::uint32_t>(payload.size()), /*finish=*/true, []{
std::println(std::cout, "[Crafter.Network] WT stream write OK");
s.RecieveUntilCloseAsync([](std::vector<char> 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<bool> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> httpRoutes = {
{"/health", [](const HTTPRequest&) {
HTTPResponse r;
r.status = "200";
r.body = "ok";
return r;
}},
};
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wtRoutes = {
{"/echo", [](WebTransportSession& session) {
session.OnStream([](QUICStream stream) {
try {
auto bytes = stream.RecieveUntilCloseSync();
stream.SendSync(bytes.data(),
static_cast<std::uint32_t>(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;
}

View file

@ -0,0 +1,79 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> 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<std::string> 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<fs::path, 0> ifaces = {};
std::array<fs::path, 1> 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<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -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<void(HTTPResponse)> onSuccess;
std::function<void(std::string)> 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<std::int32_t, FetchCallbacks>& Callbacks() {
static std::unordered_map<std::int32_t, FetchCallbacks> 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<std::string, std::string>& 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<Impl>()) {}
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<void(HTTPResponse)> onSuccess,
std::function<void(std::string)> 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<std::int32_t>(method.size()),
url.data(), static_cast<std::int32_t>(url.size()),
headerStr.data(), static_cast<std::int32_t>(headerStr.size()),
request.body.data(), static_cast<std::int32_t>(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<std::size_t>(headersLen)), response);
}
if (bodyPtr && bodyLen > 0) {
response.body.assign(bodyPtr, static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(size)); }
__attribute__((export_name("WasmFree"), weak))
void WasmFree(void* ptr) { std::free(ptr); }
}

View file

@ -130,6 +130,24 @@ namespace {
} }
} }
void ClientHTTP::SendAsync(const HTTPRequest& request,
std::function<void(HTTPResponse)> onSuccess,
std::function<void(std::string)> 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) { HTTPResponse ClientHTTP::Send(const HTTPRequest& request) {
QUICStream stream = impl->quic.OpenStream(); QUICStream stream = impl->quic.OpenStream();

View file

@ -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<char> buffer;
bool finReceived = false;
bool closed = false;
// Pending one-shot receive.
RecvMode mode = RecvMode::None;
std::uint32_t target = 0;
std::function<void(std::vector<char>)> cb;
ClientQUIC* connection = nullptr;
};
struct ConnectionState {
std::int32_t handle = 0;
bool ready = false;
bool closed = false;
std::function<void(QUICStream)> onStream;
std::function<void(std::vector<char>)> onDatagram;
};
// Handle → state. Allocated on the heap so the pointer is stable
// across QUICStream / ClientQUIC moves (each holds a unique_ptr<Impl>
// that wraps a pointer into these maps via its handle).
inline std::unordered_map<std::int32_t, StreamState*>& Streams() {
static std::unordered_map<std::int32_t, StreamState*> m;
return m;
}
inline std::unordered_map<std::int32_t, ConnectionState*>& Connections() {
static std::unordered_map<std::int32_t, ConnectionState*> m;
return m;
}
inline std::unordered_map<std::int32_t, std::function<void()>>& WriteCallbacks() {
static std::unordered_map<std::int32_t, std::function<void()>> 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<char> 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<Impl>()) {}
QUICStream::QUICStream(std::int32_t streamHandle, ClientQUIC* conn,
bool canSendArg, bool canReceiveArg)
: canSend(canSendArg), canReceive(canReceiveArg),
impl(std::make_unique<Impl>())
{
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<void()> 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<const char*>(buffer),
static_cast<std::int32_t>(size),
finish ? 1 : 0,
cbId);
}
void QUICStream::RecieveAsync(std::function<void(std::vector<char>)> 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<void(std::vector<char>)> 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<void(std::vector<char>)> 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<Impl>()) {
// 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<std::int32_t>(hostStr.size()),
static_cast<std::int32_t>(port),
alpn.data(), static_cast<std::int32_t>(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<const char*>(buffer),
static_cast<std::int32_t>(size));
}
void ClientQUIC::OnStream(std::function<void(QUICStream)> cb) {
if (impl) impl->state.onStream = std::move(cb);
}
void ClientQUIC::OnDatagram(std::function<void(std::vector<char>)> 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<char> data(dataPtr, dataPtr + dataLen);
std::free(dataPtr);
it->second->onDatagram(std::move(data));
}
}

View file

@ -280,6 +280,18 @@ std::vector<char> QUICStream::RecieveUntilFullSync(std::uint32_t bufferSize) {
return out; return out;
} }
void QUICStream::SendAsync(const void* buffer, std::uint32_t size, bool finish,
std::function<void()> onSent) {
// Copy now: the caller's buffer may not outlive the enqueued task.
std::vector<char> copy(static_cast<const char*>(buffer),
static_cast<const char*>(buffer) + size);
ThreadPool::Enqueue([this, copy = std::move(copy), finish, onSent = std::move(onSent)]() mutable {
try { this->SendSync(copy.data(), static_cast<std::uint32_t>(copy.size()), finish); }
catch (...) { /* swallowed — callback still fires so the caller can move on */ }
if (onSent) onSent();
});
}
void QUICStream::RecieveAsync(std::function<void(std::vector<char>)> cb) { void QUICStream::RecieveAsync(std::function<void(std::vector<char>)> cb) {
ThreadPool::Enqueue([this, cb]{ cb(this->RecieveSync()); }); ThreadPool::Enqueue([this, cb]{ cb(this->RecieveSync()); });
} }
@ -290,6 +302,27 @@ void QUICStream::RecieveUntilFullAsync(std::uint32_t bufferSize, std::function<v
ThreadPool::Enqueue([this, bufferSize, cb]{ cb(this->RecieveUntilFullSync(bufferSize)); }); ThreadPool::Enqueue([this, bufferSize, cb]{ cb(this->RecieveUntilFullSync(bufferSize)); });
} }
void QUICStream::PrependReceived(std::vector<char> 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<unsigned>(s)));
}
return static_cast<std::uint64_t>(id);
}
// ---------------- ClientQUIC::Impl ---------------- // ---------------- ClientQUIC::Impl ----------------
struct ClientQUIC::Impl { struct ClientQUIC::Impl {
HQUIC connection = nullptr; HQUIC connection = nullptr;

View file

@ -26,6 +26,7 @@ import :ListenerQUIC;
import :ClientQUIC; import :ClientQUIC;
import :HTTP; import :HTTP;
import :HTTP3; import :HTTP3;
import :WebTransport;
import Crafter.Thread; import Crafter.Thread;
import std; import std;
@ -65,7 +66,10 @@ namespace {
else if (name == ":authority") request.authority = std::move(value); else if (name == ":authority") request.authority = std::move(value);
else if (name == ":path") request.path = std::move(value); else if (name == ":path") request.path = std::move(value);
else if (!name.empty() && name[0] == ':') { 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 { } else {
request.headers.emplace(std::move(name), std::move(value)); request.headers.emplace(std::move(name), std::move(value));
} }
@ -109,13 +113,49 @@ namespace {
} }
return wire; 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<char>& buffer,
std::size_t& offset) {
std::uint64_t value = 0;
std::size_t consumed = 0;
while (true) {
const auto* p = reinterpret_cast<const std::uint8_t*>(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 // One accepted-CONNECT WebTransport session living inside a peer.
// and the server-side control stream alive for the lifetime of the peer. struct WTSessionEntry {
std::unique_ptr<WebTransportSession> 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 { struct PeerState {
std::unique_ptr<ClientQUIC> quic; std::unique_ptr<ClientQUIC> quic;
QUICStream controlStream; 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<std::uint64_t, std::unique_ptr<WTSessionEntry>> wtSessions;
}; };
struct ListenerHTTP::Impl { struct ListenerHTTP::Impl {
@ -125,40 +165,147 @@ struct ListenerHTTP::Impl {
bool running = true; bool running = true;
}; };
ListenerHTTP::ListenerHTTP(std::uint16_t port, namespace {
QUICServerCredentials creds, // Build the per-connection bidi-stream handler. Demuxes WT streams from
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> r) // HTTP/3 request streams by peeking the first varint on the wire. Lives
: routes(std::move(r)) // as a free helper so both ListenerHTTP constructors can install it.
, alpn(HTTP3::kAlpn) std::function<void(QUICStream)> MakeBidiHandler(
, impl(std::make_unique<Impl>()) ListenerHTTP* self, PeerState* peerState,
const std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>>* routes,
const std::unordered_map<std::string, std::function<void(WebTransportSession&)>>* wtRoutes)
{ {
// The connect callback wires up an OnStream handler that splits unidi return [self, peerState, routes, wtRoutes](QUICStream stream) {
// 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<PeerState>();
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 { try {
while (true) (void)stream.RecieveSync(); // ── Phase A: identify the stream kind ─────────────────────
} catch (...) {} //
// 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<char> 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<char> 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; return;
} }
// Bidi stream: a request. Drive a single request/response cycle. // ── 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<const std::uint8_t*>(peeked.data() + payloadStart),
static_cast<std::size_t>(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<std::uint32_t>(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<std::uint32_t>(wire.size()), /*finish=*/false);
std::uint64_t sessionId = stream.GetStreamId();
auto entry = std::make_unique<WTSessionEntry>();
entry->session = std::make_unique<WebTransportSession>();
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<char> remainingFrames(peeked.begin() + payloadStart + headerLen, peeked.end());
try { try {
auto raw = stream.RecieveUntilCloseSync(); auto rest = stream.RecieveUntilCloseSync();
HTTPRequest request = ParseRequestFrames(raw); remainingFrames.insert(remainingFrames.end(), rest.begin(), rest.end());
} catch (...) {}
std::size_t pos = 0;
const auto* p = reinterpret_cast<const std::uint8_t*>(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<const char*>(p + pos),
static_cast<std::size_t>(frameLen));
}
pos += static_cast<std::size_t>(frameLen);
}
HTTPResponse response; HTTPResponse response;
auto it = routes.find(request.path); auto it = routes->find(request.path);
if (it != routes.end()) { if (it != routes->end()) {
response = it->second(request); response = it->second(request);
} else { } else {
response.status = "404"; response.status = "404";
@ -170,8 +317,6 @@ ListenerHTTP::ListenerHTTP(std::uint16_t port,
static_cast<std::uint32_t>(wire.size()), static_cast<std::uint32_t>(wire.size()),
/*finish=*/true); /*finish=*/true);
} catch (const std::exception& e) { } catch (const std::exception& e) {
// Best-effort 500 if we can still send. Stream may already
// be closed; swallow further errors silently.
try { try {
HTTPResponse err; HTTPResponse err;
err.status = "500"; err.status = "500";
@ -182,21 +327,72 @@ ListenerHTTP::ListenerHTTP(std::uint16_t port,
/*finish=*/true); /*finish=*/true);
} catch (...) {} } catch (...) {}
} }
};
}
}
ListenerHTTP::ListenerHTTP(std::uint16_t port,
QUICServerCredentials creds,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> r)
: ListenerHTTP(port, std::move(creds), std::move(r), {})
{}
ListenerHTTP::ListenerHTTP(std::uint16_t port,
QUICServerCredentials creds,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> r,
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wt)
: routes(std::move(r))
, wtRoutes(std::move(wt))
, alpn(HTTP3::kAlpn)
, impl(std::make_unique<Impl>())
{
auto onConnect = [this](ClientQUIC* peer) {
auto state = std::make_unique<PeerState>();
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. // Open our outgoing control stream and write the SETTINGS prelude.
// Do this AFTER OnStream is registered so any client-initiated // When wtRoutes is non-empty we advertise WebTransport support so
// unidi stream that races in is handled. The control stream must // the browser will issue extended CONNECT against us.
// remain open for the connection's lifetime — we never FIN it.
try { try {
state->controlStream = peer->OpenStream(/*unidirectional=*/true); 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(), state->controlStream.SendSync(prelude.data(),
static_cast<std::uint32_t>(prelude.size()), static_cast<std::uint32_t>(prelude.size()),
/*finish=*/false); /*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<std::uint8_t>(HTTP3::kStreamQpackEnc);
state->qpackEncoderStream.SendSync(&encType, 1, /*finish=*/false);
state->qpackDecoderStream = peer->OpenStream(/*unidirectional=*/true);
std::uint8_t decType = static_cast<std::uint8_t>(HTTP3::kStreamQpackDec);
state->qpackDecoderStream.SendSync(&decType, 1, /*finish=*/false);
} catch (...) { } catch (...) {
// If the connection died mid-handshake we land here; the peer // Connection died mid-handshake; drop the peer.
// gets dropped via destruction below.
} }
std::lock_guard lk(impl->peersMtx); std::lock_guard lk(impl->peersMtx);
@ -223,9 +419,6 @@ void ListenerHTTP::Stop() {
void ListenerHTTP::Listen() { void ListenerHTTP::Listen() {
if (!impl || !impl->listener) return; 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(); impl->listener->ListenSyncAsync();
} }
@ -236,6 +429,14 @@ ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port,
, thread(&ListenerHTTP::Listen, &listener) , thread(&ListenerHTTP::Listen, &listener)
{} {}
ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port,
QUICServerCredentials creds,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wtRoutes)
: listener(port, std::move(creds), std::move(routes), std::move(wtRoutes))
, thread(&ListenerHTTP::Listen, &listener)
{}
ListenerAsyncHTTP::~ListenerAsyncHTTP() { ListenerAsyncHTTP::~ListenerAsyncHTTP() {
Stop(); Stop();
} }

View file

@ -21,6 +21,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
module; module;
#include <msquic.h> #include <msquic.h>
#include <cstdlib> #include <cstdlib>
#include <cstdio>
#include <cstring> #include <cstring>
#include <unistd.h> #include <unistd.h>
module Crafter.Network:ListenerQUIC_impl; module Crafter.Network:ListenerQUIC_impl;
@ -84,6 +85,12 @@ namespace {
// mkdtemp'd directory under /tmp for the lifetime of the process. // mkdtemp'd directory under /tmp for the lifetime of the process.
// Intended for dev / LAN play / tests — production should pass real // Intended for dev / LAN play / tests — production should pass real
// cert/key paths. // 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 { struct SelfSignedCert {
std::string certPath; std::string certPath;
std::string keyPath; std::string keyPath;
@ -94,17 +101,54 @@ namespace {
std::lock_guard lk(mtx); std::lock_guard lk(mtx);
if (cached) return *cached; if (cached) return *cached;
char tmpl[] = "/tmp/crafter-quic-cert-XXXXXX"; // Stable on-disk location so the cert (and therefore its SHA-256) is
if (mkdtemp(tmpl) == nullptr) { // reused across server restarts. Without this, a browser peer that
throw QUICException("mkdtemp failed for self-signed cert dir"); // 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(
"could not create cert cache dir {}: {}", dir.string(), ec.message()));
} }
std::string dir = tmpl;
SelfSignedCert s; SelfSignedCert s;
s.keyPath = dir + "/key.pem"; s.keyPath = (dir / "key.pem").string();
s.certPath = dir + "/cert.pem"; 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( std::string cmd = std::format(
"openssl req -x509 -newkey rsa:2048 -keyout '{}' -out '{}' " "openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 "
"-days 1 -nodes -subj '/CN=localhost' >/dev/null 2>&1", "-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); s.keyPath, s.certPath);
int rc = std::system(cmd.c_str()); int rc = std::system(cmd.c_str());
if (rc != 0) { if (rc != 0) {
@ -113,6 +157,7 @@ namespace {
"(exit {}); install openssl or pass certPath/keyPath", "(exit {}); install openssl or pass certPath/keyPath",
rc)); rc));
} }
}
cached = std::move(s); cached = std::move(s);
return *cached; return *cached;
} }
@ -329,3 +374,53 @@ void ListenerQUIC::ListenAsyncSync() {
void ListenerQUIC::ListenAsyncAsync() { void ListenerQUIC::ListenAsyncAsync() {
impl->acceptLoop = std::thread([this]{ ListenSyncAsync(); }); impl->acceptLoop = std::thread([this]{ ListenSyncAsync(); });
} }
std::array<std::uint8_t, 32> 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)= <hex>\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<std::uint8_t, 32> 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;
}

View file

@ -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<void(QUICStream)> onStream;
std::deque<QUICStream> pendingStreams;
std::function<void(std::vector<char>)> onDatagram; // Phase 2
bool closed = false;
};
WebTransportSession::WebTransportSession()
: impl(std::make_unique<Impl>())
{}
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<std::uint32_t>(prefix.size()), /*finish=*/false);
return stream;
}
void WebTransportSession::OnStream(std::function<void(QUICStream)> callback) {
std::deque<QUICStream> 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<void(std::vector<char>)> 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<void(QUICStream)> 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;
});
}
}

View file

@ -32,6 +32,12 @@ namespace Crafter {
// //
// For local development against a self-signed listener, pass // For local development against a self-signed listener, pass
// QUICClientCredentials{insecureNoServerValidation = true}. // 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 { export class ClientHTTP {
public: public:
std::string host; std::string host;
@ -44,8 +50,18 @@ namespace Crafter {
ClientHTTP(const ClientHTTP&) = delete; ClientHTTP(const ClientHTTP&) = delete;
ClientHTTP(ClientHTTP&&) noexcept; ClientHTTP(ClientHTTP&&) noexcept;
#ifndef CRAFTER_NETWORK_BROWSER
// Send a request and synchronously read back the full response. // Send a request and synchronously read back the full response.
HTTPResponse Send(const HTTPRequest& request); 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<void(HTTPResponse)> onSuccess,
std::function<void(std::string)> onError);
private: private:
struct Impl; struct Impl;

View file

@ -19,7 +19,9 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/ */
module; module;
#ifndef CRAFTER_NETWORK_BROWSER
#include <msquic.h> #include <msquic.h>
#endif
export module Crafter.Network:ClientQUIC; export module Crafter.Network:ClientQUIC;
import std; import std;
@ -45,9 +47,16 @@ namespace Crafter {
}; };
// Client-side credential validation. By default we require a real cert. // 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 { export struct QUICClientCredentials {
bool insecureNoServerValidation = false; bool insecureNoServerValidation = false;
std::array<std::uint8_t, 32> serverCertificateHash{};
}; };
export class ClientQUIC; export class ClientQUIC;
@ -60,8 +69,10 @@ namespace Crafter {
// for inbound streams initiated by the peer. // for inbound streams initiated by the peer.
export class QUICStream { export class QUICStream {
public: public:
#ifndef CRAFTER_NETWORK_BROWSER
// Underlying msquic HQUIC handle. Treated as opaque by callers. // Underlying msquic HQUIC handle. Treated as opaque by callers.
HQUIC handle = nullptr; HQUIC handle = nullptr;
#endif
// The connection that owns this stream (non-owning). // The connection that owns this stream (non-owning).
ClientQUIC* connection = nullptr; ClientQUIC* connection = nullptr;
@ -72,12 +83,22 @@ namespace Crafter {
bool canReceive = true; bool canReceive = true;
QUICStream(); QUICStream();
#ifndef CRAFTER_NETWORK_BROWSER
QUICStream(HQUIC handle, ClientQUIC* connection); 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();
QUICStream(const QUICStream&) = delete; QUICStream(const QUICStream&) = delete;
QUICStream(QUICStream&&) noexcept; QUICStream(QUICStream&&) noexcept;
QUICStream& operator=(QUICStream&&) noexcept; QUICStream& operator=(QUICStream&&) noexcept;
#ifndef CRAFTER_NETWORK_BROWSER
// Send a buffer. If finish=true, the send-side of the stream is closed // Send a buffer. If finish=true, the send-side of the stream is closed
// after the buffer is delivered (peer will see graceful shutdown). // after the buffer is delivered (peer will see graceful shutdown).
// Blocks until msquic accepts the buffer; throws on stream/conn close. // 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. // Read exactly bufferSize bytes; throws if the peer closes early.
std::vector<char> RecieveUntilFullSync(std::uint32_t bufferSize); std::vector<char> 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<void()> 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<void(std::vector<char>)> recieveCallback); void RecieveAsync(std::function<void(std::vector<char>)> recieveCallback);
void RecieveUntilCloseAsync(std::function<void(std::vector<char>)> recieveCallback); void RecieveUntilCloseAsync(std::function<void(std::vector<char>)> recieveCallback);
void RecieveUntilFullAsync(std::uint32_t bufferSize, std::function<void(std::vector<char>)> recieveCallback); void RecieveUntilFullAsync(std::uint32_t bufferSize, std::function<void(std::vector<char>)> 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<char> 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). // Cleanly shut down the stream (both directions).
void Stop(); void Stop();
@ -109,8 +154,9 @@ namespace Crafter {
}; };
// A QUIC connection. On the client side, constructing one initiates the // A QUIC connection. On the client side, constructing one initiates the
// handshake and blocks until it succeeds (or throws on failure). On the // handshake and (on native) blocks until it succeeds, or throws on
// server side, ListenerQUIC instantiates these for accepted peers. // failure. On the server side, ListenerQUIC instantiates these for
// accepted peers.
// //
// A connection multiplexes: // A connection multiplexes:
// - Reliable, ordered streams (open via OpenStream() / observe inbound // - Reliable, ordered streams (open via OpenStream() / observe inbound
@ -120,10 +166,28 @@ namespace Crafter {
// Lifetime: ~ClientQUIC closes the connection. Streams obtained from // Lifetime: ~ClientQUIC closes the connection. Streams obtained from
// OpenStream() are scoped to the connection and must be destroyed (or // OpenStream() are scoped to the connection and must be destroyed (or
// moved out) before the ClientQUIC. // 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 { export class ClientQUIC {
public: public:
// ALPN identifier exchanged in the handshake. Both peers must agree. // ALPN identifier exchanged in the handshake. Both peers must agree.
// For 3DForts use e.g. "f3d/1" or similar — a short stable token. // 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; std::string alpn;
// Client constructor: connects to host:port using QUIC. ALPN must // 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, ClientQUIC(std::string host, std::uint16_t port, std::string alpn,
QUICClientCredentials creds = {}); QUICClientCredentials creds = {});
#ifndef CRAFTER_NETWORK_BROWSER
// Server-side constructor used by ListenerQUIC for accepted peers. // Server-side constructor used by ListenerQUIC for accepted peers.
// Takes ownership of an already-accepted msquic connection handle // Takes ownership of an already-accepted msquic connection handle
// and the server configuration handle. Not intended for direct use. // and the server configuration handle. Not intended for direct use.
ClientQUIC(HQUIC connectionHandle, HQUIC serverConfiguration, std::string alpn); ClientQUIC(HQUIC connectionHandle, HQUIC serverConfiguration, std::string alpn);
#endif
~ClientQUIC(); ~ClientQUIC();
ClientQUIC(const ClientQUIC&) = delete; ClientQUIC(const ClientQUIC&) = delete;
@ -162,15 +228,19 @@ namespace Crafter {
// msquic worker; copy/queue and return promptly. // msquic worker; copy/queue and return promptly.
void OnDatagram(std::function<void(std::vector<char>)> callback); void OnDatagram(std::function<void(std::vector<char>)> callback);
#ifndef CRAFTER_NETWORK_BROWSER
// Block the caller until the next datagram arrives; returns it. // Block the caller until the next datagram arrives; returns it.
// Throws QUICClosedException if the connection closes first. // Throws QUICClosedException if the connection closes first.
std::vector<char> RecieveDatagramSync(); std::vector<char> RecieveDatagramSync();
#endif
// Cleanly shut down the connection. // Cleanly shut down the connection.
void Stop(); void Stop();
#ifndef CRAFTER_NETWORK_BROWSER
// Underlying handle for advanced use (parameter queries, etc.). // Underlying handle for advanced use (parameter queries, etc.).
HQUIC GetHandle() const; HQUIC GetHandle() const;
#endif
private: private:
struct Impl; struct Impl;

View file

@ -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 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/ */
module; module;
#ifndef CRAFTER_NETWORK_BROWSER
#include <stdio.h> #include <stdio.h>
#include <sys/types.h> #include <sys/types.h>
#include <sys/socket.h> #include <sys/socket.h>
@ -31,9 +32,11 @@ module;
#include <netdb.h> #include <netdb.h>
#include <strings.h> #include <strings.h>
#include <cerrno> #include <cerrno>
#endif
export module Crafter.Network:ClientTCP; export module Crafter.Network:ClientTCP;
import std; import std;
#ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter { namespace Crafter {
export class SocketClosedException : public std::exception { export class SocketClosedException : public std::exception {
public: public:
@ -69,3 +72,4 @@ namespace Crafter {
sockaddr_in serv_addr; sockaddr_in serv_addr;
}; };
} }
#endif

View file

@ -43,11 +43,33 @@ namespace Crafter::HTTP3 {
export inline constexpr std::uint64_t kFrameData = 0x00; export inline constexpr std::uint64_t kFrameData = 0x00;
export inline constexpr std::uint64_t kFrameHeaders = 0x01; export inline constexpr std::uint64_t kFrameHeaders = 0x01;
export inline constexpr std::uint64_t kFrameSettings = 0x04; 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) ---------------- // ---------------- Unidirectional stream types (RFC 9114 §6.2) ----------------
export inline constexpr std::uint64_t kStreamControl = 0x00; export inline constexpr std::uint64_t kStreamControl = 0x00;
export inline constexpr std::uint64_t kStreamQpackEnc = 0x02; export inline constexpr std::uint64_t kStreamQpackEnc = 0x02;
export inline constexpr std::uint64_t kStreamQpackDec = 0x03; 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 ---------------- // ---------------- Errors ----------------
export class HTTP3ProtocolError : public std::runtime_error { export class HTTP3ProtocolError : public std::runtime_error {
@ -575,4 +597,55 @@ namespace Crafter::HTTP3 {
EncodeVarint(0, out); // frame length 0 EncodeVarint(0, out); // frame length 0
return out; 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> BuildWtBidiPrefix(std::uint64_t sessionId) {
std::vector<std::uint8_t> 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<std::uint8_t> BuildWtUnidiPrefix(std::uint64_t sessionId) {
std::vector<std::uint8_t> out;
EncodeVarint(kStreamWt, out);
EncodeVarint(sessionId, out);
return out;
}
} }

View file

@ -23,7 +23,9 @@ import std;
import :HTTP; import :HTTP;
import :ListenerQUIC; import :ListenerQUIC;
import :ClientQUIC; import :ClientQUIC;
import :WebTransport;
#ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter { namespace Crafter {
// HTTP/3 server. Wraps a ListenerQUIC: each accepted QUIC connection // HTTP/3 server. Wraps a ListenerQUIC: each accepted QUIC connection
// registers a per-stream handler that parses one request, dispatches it // 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 // Routes are keyed by `:path` (exact match). Unknown paths return a
// synthetic 404. Route handlers run on the ThreadPool — multiple requests // synthetic 404. Route handlers run on the ThreadPool — multiple requests
// on the same connection can therefore execute concurrently. // 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 { export class ListenerHTTP {
public: public:
// The underlying QUIC listener owns the accept loop, certificates, // 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 // and owned by this Impl so that move construction/destruction is
// straightforward. // straightforward.
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes; std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wtRoutes;
std::string alpn; std::string alpn;
ListenerHTTP(std::uint16_t port, ListenerHTTP(std::uint16_t port,
QUICServerCredentials creds, QUICServerCredentials creds,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes); std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wtRoutes);
~ListenerHTTP(); ~ListenerHTTP();
ListenerHTTP(const ListenerHTTP&) = delete; ListenerHTTP(const ListenerHTTP&) = delete;
ListenerHTTP(ListenerHTTP&&) noexcept; ListenerHTTP(ListenerHTTP&&) noexcept;
@ -71,7 +88,15 @@ namespace Crafter {
ListenerAsyncHTTP(std::uint16_t port, ListenerAsyncHTTP(std::uint16_t port,
QUICServerCredentials creds, QUICServerCredentials creds,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes); std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes);
// WT-aware overload.
ListenerAsyncHTTP(std::uint16_t port,
QUICServerCredentials creds,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wtRoutes);
~ListenerAsyncHTTP(); ~ListenerAsyncHTTP();
void Stop(); void Stop();
}; };
} }
#endif

View file

@ -19,11 +19,14 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/ */
module; module;
#ifndef CRAFTER_NETWORK_BROWSER
#include <msquic.h> #include <msquic.h>
#endif
export module Crafter.Network:ListenerQUIC; export module Crafter.Network:ListenerQUIC;
import std; import std;
import :ClientQUIC; import :ClientQUIC;
#ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter { namespace Crafter {
// Server side of a QUIC connection. Mirrors ListenerTCP in shape: // Server side of a QUIC connection. Mirrors ListenerTCP in shape:
// four Listen* methods covering the sync/async outer-loop x sync/async // four Listen* methods covering the sync/async outer-loop x sync/async
@ -72,4 +75,17 @@ namespace Crafter {
std::unique_ptr<Impl> impl; std::unique_ptr<Impl> impl;
std::uint32_t totalClientCounter = 0; 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<std::uint8_t, 32> 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

View file

@ -22,6 +22,7 @@ export module Crafter.Network:ListenerTCP;
import std; import std;
import :ClientTCP; import :ClientTCP;
#ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter { namespace Crafter {
export class ListenerTCP { export class ListenerTCP {
public: public:
@ -41,3 +42,4 @@ namespace Crafter {
int s; int s;
}; };
} }
#endif

View file

@ -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<void(QUICStream)> 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<void(std::vector<char>)> 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> 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

View file

@ -27,3 +27,11 @@ export import :ListenerHTTP;
export import :HTTP; export import :HTTP;
export import :ClientQUIC; export import :ClientQUIC;
export import :ListenerQUIC; 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

View file

@ -4,7 +4,7 @@ namespace fs = std::filesystem;
using namespace Crafter; using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) { extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
constexpr std::array<std::string_view, 9> networkInterfaces = { constexpr std::array<std::string_view, 10> networkInterfaces = {
"interfaces/Crafter.Network", "interfaces/Crafter.Network",
"interfaces/Crafter.Network-ClientTCP", "interfaces/Crafter.Network-ClientTCP",
"interfaces/Crafter.Network-ListenerTCP", "interfaces/Crafter.Network-ListenerTCP",
@ -14,14 +14,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"interfaces/Crafter.Network-HTTP3", "interfaces/Crafter.Network-HTTP3",
"interfaces/Crafter.Network-ClientQUIC", "interfaces/Crafter.Network-ClientQUIC",
"interfaces/Crafter.Network-ListenerQUIC", "interfaces/Crafter.Network-ListenerQUIC",
}; "interfaces/Crafter.Network-WebTransport",
constexpr std::array<std::string_view, 6> 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",
}; };
std::vector<std::string> depArgs(args.begin(), args.end()); std::vector<std::string> depArgs(args.begin(), args.end());
@ -38,6 +31,51 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
ApplyStandardArgs(cfg, args); ApplyStandardArgs(cfg, args);
cfg.dependencies = { thread }; 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<fs::path, 9> 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<fs::path, 2> 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<std::string_view, 7> 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. // msquic — provides the QUIC transport used by ClientQUIC / ListenerQUIC.
// Cloned + built via CMake into the per-project external cache; no system // Cloned + built via CMake into the per-project external cache; no system
// package required. Submodules (quictls / clog / etc.) come via the // package required. Submodules (quictls / clog / etc.) come via the
@ -62,9 +100,9 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// linker at the actual output location. // linker at the actual output location.
msquic.libDirs = { "bin/Release" }; msquic.libDirs = { "bin/Release" };
msquic.libs = { "msquic" }; msquic.libs = { "msquic" };
std::array<fs::path, 9> ifaces; std::array<fs::path, 10> ifaces;
std::ranges::copy(networkInterfaces, ifaces.begin()); std::ranges::copy(networkInterfaces, ifaces.begin());
std::array<fs::path, 6> impls; std::array<fs::path, 7> impls;
std::ranges::copy(networkImplementations, impls.begin()); std::ranges::copy(networkImplementations, impls.begin());
cfg.GetInterfacesAndImplementations(ifaces, impls); cfg.GetInterfacesAndImplementations(ifaces, impls);

View file

@ -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<std::uint64_t, std::vector<char>> ReadFrame(QUICStream& stream) {
std::vector<char> buf;
// First varint = frame type.
std::uint64_t type = 0; std::size_t cn = 0;
while (true) {
const auto* p = reinterpret_cast<const std::uint8_t*>(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<char> 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<const std::uint8_t*>(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<char> 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<char> payload(buf.begin(), buf.begin() + len);
std::vector<char> 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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> httpRoutes = {};
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wtRoutes = {
{"/echo", [](WebTransportSession& session) {
session.OnStream([](QUICStream peerStream) {
try {
auto bytes = peerStream.RecieveUntilCloseSync();
peerStream.SendSync(bytes.data(),
static_cast<std::uint32_t>(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<std::uint32_t>(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<std::pair<std::string, std::string>> connectFields = {
{":method", "CONNECT"},
{":scheme", "https"},
{":authority", "localhost"},
{":path", "/echo"},
{":protocol", "webtransport"},
};
auto headerPayload = HTTP3::EncodeFieldSection(connectFields);
std::vector<std::uint8_t> connectWire;
HTTP3::WriteFrame(connectWire, HTTP3::kFrameHeaders,
headerPayload.data(), headerPayload.size());
connectStream.SendSync(connectWire.data(),
static_cast<std::uint32_t>(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<const std::uint8_t*>(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<std::uint8_t> wire;
wire.insert(wire.end(), prefix.begin(), prefix.end());
wire.insert(wire.end(), kPayload.begin(), kPayload.end());
wtStream.SendSync(wire.data(),
static_cast<std::uint32_t>(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;
}
}

View file

@ -0,0 +1,20 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
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<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "ShouldEchoWebTransport" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}