Compare commits
2 commits
45479a46ff
...
e8630528af
| Author | SHA1 | Date | |
|---|---|---|---|
| e8630528af | |||
| 28fab2509b |
32 changed files with 3743 additions and 664 deletions
156
README.md
156
README.md
|
|
@ -1,19 +1,21 @@
|
|||
# Crafter.Network
|
||||
|
||||
A cross-platform C++ networking library providing TCP and HTTP client/server functionality with modern C++ features.
|
||||
A cross-platform C++ networking library providing TCP, QUIC, HTTP/3, and WebTransport client/server functionality with modern C++ features. Builds for native Linux and for the browser (wasm32-wasip1).
|
||||
|
||||
## Overview
|
||||
|
||||
Crafter.Network is a comprehensive networking library designed for modern C++ applications. It provides TCP, HTTP, and QUIC networking capabilities with support for synchronous and asynchronous operations, making it suitable for a wide range of networking tasks including real-time multiplayer games.
|
||||
Crafter.Network is a C++ networking library designed for modern C++ applications. It provides TCP, QUIC, HTTP/3, and WebTransport-over-HTTP/3 capabilities with support for synchronous and asynchronous operations, making it suitable for a wide range of networking tasks including real-time multiplayer games. The same source compiles for native Linux (via msquic + POSIX sockets) and for the browser (via `fetch()` + `WebTransport` JS APIs); see [Browser build](#browser-build).
|
||||
|
||||
## Features
|
||||
|
||||
- **TCP Networking**: Client and server implementations for TCP connections
|
||||
- **HTTP Support**: Full HTTP client and server implementations with routing capabilities
|
||||
- **QUIC Networking**: Encrypted, multi-stream transport via msquic — reliable streams for control plane, unreliable datagrams for low-latency state sync
|
||||
- **Asynchronous Operations**: Thread pool-based async operations for improved performance
|
||||
- **Cross-Platform**: Built for Unix-like systems with socket-based networking
|
||||
- **Modern C++**: Uses C++ modules, STL containers, and modern C++ features
|
||||
- **TCP Networking**: Client and server implementations for raw TCP connections (native only).
|
||||
- **QUIC Networking**: Encrypted, multi-stream transport via msquic — reliable streams for control plane, unreliable datagrams for low-latency state sync.
|
||||
- **HTTP/3**: Client and server implementations on top of QUIC. Uses ALPN `h3`, QUIC bidi streams for requests/responses, the mandatory unidirectional control stream + SETTINGS frame (RFC 9114 §6.2.1), the (empty) QPACK encoder + decoder unidi streams required by stricter peers like Chromium, and a built-in QPACK codec (RFC 9204) with the full static table, Huffman *decoding* (RFC 7541), and literal-only emission. The QPACK dynamic table is unused. The client is interoperable with mainstream public h3 endpoints (cloudflare, nghttp3-based servers, etc.).
|
||||
- **WebTransport (server)**: `ListenerHTTP` accepts extended-CONNECT sessions (`:method=CONNECT, :protocol=webtransport`) negotiated on the existing h3 listener — no separate port or alternate stack. Both draft-02 and draft-07+ identifier sets are advertised in SETTINGS so current Chrome/Edge browsers connect out of the box. Per-route handlers receive a `WebTransportSession&` and can multiplex bidirectional streams over the session.
|
||||
- **Browser client**: Same C++ API compiled to wasm32-wasip1 and routed through `fetch()` (for `ClientHTTP`) and `WebTransport` (for `ClientQUIC`). Listeners and raw TCP are not compiled in the browser build — the browser is client-only.
|
||||
- **Asynchronous Operations**: Thread pool–based async operations on native; the same `*Async` API on the browser side, where it's required (no synchronous I/O in the browser event loop).
|
||||
- **Cross-Platform**: Native Linux (POSIX sockets + msquic) and browser (wasm32-wasip1).
|
||||
- **Modern C++**: Uses C++20 modules, STL containers, and modern C++ features.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -21,13 +23,15 @@ The library follows a modular design using C++20 modules:
|
|||
|
||||
### Core Modules
|
||||
- `Crafter.Network`: Main module that exports all components
|
||||
- `Crafter.Network:ClientTCP`: TCP client implementation
|
||||
- `Crafter.Network:ListenerTCP`: TCP server implementation
|
||||
- `Crafter.Network:ClientHTTP`: HTTP client implementation
|
||||
- `Crafter.Network:ListenerHTTP`: HTTP server implementation
|
||||
- `Crafter.Network:HTTP`: HTTP protocol utilities and data structures
|
||||
- `Crafter.Network:ClientQUIC`: QUIC connection (client + accepted-server side) with reliable streams and unreliable datagrams
|
||||
- `Crafter.Network:ListenerQUIC`: QUIC listener accepting incoming connections
|
||||
- `Crafter.Network:ClientTCP`: TCP client implementation (native only)
|
||||
- `Crafter.Network:ListenerTCP`: TCP server implementation (native only)
|
||||
- `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`). On browser builds this maps to `fetch()`.
|
||||
- `Crafter.Network:ListenerHTTP`: HTTP/3 + WebTransport server (ALPN `h3`, native only)
|
||||
- `Crafter.Network:HTTP`: HTTP request/response types and constructors
|
||||
- `Crafter.Network:ClientQUIC`: QUIC connection (client + accepted-server side) with reliable streams and unreliable datagrams. On browser builds this maps to the `WebTransport` JS API.
|
||||
- `Crafter.Network:ListenerQUIC`: QUIC listener accepting incoming connections (native only). Also exports `ComputeCertificateHashSHA256()` and `GetSelfSignedCertificatePath()` for browser-peer cert pinning.
|
||||
- `Crafter.Network:WebTransport`: `WebTransportSession` type — the per-session handle handed to `ListenerHTTP` WT route handlers.
|
||||
- `Crafter.Network:HTTP3`: HTTP/3 wire-format helpers (QUIC varint, frame layer, QPACK static-table codec, WT frame/setting constants). Re-exported on native, excluded on browser (it uses `throw` and the wasm build is `-fno-exceptions`).
|
||||
|
||||
## Components
|
||||
|
||||
|
|
@ -53,54 +57,117 @@ Crafter::ListenerTCP listener(8080, callback);
|
|||
listener.ListenSyncSync(); // Synchronous listening
|
||||
```
|
||||
|
||||
### HTTP Components
|
||||
### HTTP/3 Components
|
||||
|
||||
HTTP/3 runs over QUIC, which always requires TLS. Pass server credentials when constructing the listener (or set `selfSigned = true` for a development-only ephemeral cert) and matching client credentials when constructing the client (`insecureNoServerValidation = true` for self-signed servers).
|
||||
|
||||
#### ClientHTTP
|
||||
```cpp
|
||||
// Create an HTTP client
|
||||
Crafter::ClientHTTP client("httpbin.org", 80);
|
||||
Crafter::QUICClientCredentials creds;
|
||||
creds.insecureNoServerValidation = true; // dev-only
|
||||
Crafter::ClientHTTP client("localhost", 8082, creds);
|
||||
|
||||
// Send HTTP request
|
||||
std::string request = Crafter::CreateRequestHTTP("GET", "/get", "httpbin.org");
|
||||
Crafter::HTTPResponse response = client.Send(request);
|
||||
Crafter::HTTPResponse response = client.Send(
|
||||
Crafter::CreateRequestHTTP("GET", "/", "localhost")
|
||||
);
|
||||
// response.status is the numeric status as a string, e.g. "200"
|
||||
```
|
||||
|
||||
#### ListenerHTTP
|
||||
```cpp
|
||||
// Create an HTTP listener with routes
|
||||
std::unordered_map<std::string, std::function<std::string(const Crafter::HTTPRequest&)>> routes;
|
||||
routes["/hello"] = [](const Crafter::HTTPRequest& req) {
|
||||
return Crafter::CreateResponseHTTP("200 OK", "Hello World!");
|
||||
std::unordered_map<std::string,
|
||||
std::function<Crafter::HTTPResponse(const Crafter::HTTPRequest&)>> routes;
|
||||
routes["/hello"] = [](const Crafter::HTTPRequest&) {
|
||||
return Crafter::CreateResponseHTTP("200", "Hello World!");
|
||||
};
|
||||
|
||||
Crafter::ListenerHTTP listener(8080, routes);
|
||||
Crafter::QUICServerCredentials creds;
|
||||
creds.selfSigned = true; // dev-only
|
||||
Crafter::ListenerHTTP listener(8082, creds, routes);
|
||||
listener.Listen();
|
||||
```
|
||||
|
||||
The `HTTPRequest` exposes the four HTTP/3 pseudo-headers (`method`, `scheme`, `authority`, `path`) as named struct fields rather than mixing them into the regular `headers` map. Routes are dispatched by exact match on `path`; unmatched paths return a synthetic 404.
|
||||
|
||||
### WebTransport Components
|
||||
|
||||
`ListenerHTTP` has a WT-aware constructor overload that takes a second route map keyed by `:path`. When the map is non-empty the listener advertises both draft-02 (`SETTINGS_ENABLE_WEBTRANSPORT = 0x2b603742`) and draft-07+ (`SETTINGS_WT_MAX_SESSIONS = 0xc671706a`) identifiers in its SETTINGS frame so current browsers connect. An extended-CONNECT request (`:method=CONNECT, :protocol=webtransport`) whose `:path` matches a registered route is accepted with a `200` (no FIN), upgraded into a `WebTransportSession`, and dispatched on the ThreadPool. Plain HTTP/3 routes and WT routes coexist on the same listener and port.
|
||||
|
||||
```cpp
|
||||
std::unordered_map<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
|
||||
|
||||
The project uses a configuration system with multiple build targets:
|
||||
The project is a single Crafter.Build configuration (`crafter-network`, `ConfigurationType::LibraryStatic`). Target selection and debug flags are handled by `ApplyStandardArgs`:
|
||||
|
||||
- **base**: Core interfaces only
|
||||
- **lib**: Static library build with dependencies
|
||||
- **lib-debug**: Debug static library build
|
||||
- **lib-shared**: Shared library build with dependencies
|
||||
- `crafter-build` — host native (x86_64-pc-linux-gnu by default), msquic + listeners + sync APIs.
|
||||
- `crafter-build --target=wasm32-wasip1` — browser build, fetch + WebTransport, async-only API; defines `CRAFTER_NETWORK_BROWSER`, drops msquic.
|
||||
- `crafter-build test [globs]` — build and run tests under `tests/`.
|
||||
|
||||
## Testing
|
||||
|
||||
The library includes comprehensive tests covering:
|
||||
- Compilation verification
|
||||
- HTTP receive functionality
|
||||
- HTTP send functionality
|
||||
- HTTP send/receive operations
|
||||
- Keep-alive HTTP operations
|
||||
- Large HTTP data transfers
|
||||
The library includes tests covering:
|
||||
- HTTP/3 round-trip (`ShouldSendRecieveHTTP`) — canonical local client/server round-trip
|
||||
- HTTP/3 connection multiplexing (`ShouldSendRecieveKeepaliveHTTP`) — two requests share one QUIC connection
|
||||
- HTTP/3 large body transfer (`ShouldSendRecieveLargeHTTP`) — 10 MiB POST
|
||||
- HTTP/3 external interop (`ShouldSend`) — live fetch from `cloudflare-quic.com:443`, exercises real TLS chain validation, mandatory control stream, peer-initiated unidi streams, and QPACK Huffman decoding
|
||||
- QUIC reliable streams (`ShouldSendRecieveQUICStream`)
|
||||
- QUIC unreliable datagrams (`ShouldSendRecieveQUICDatagram`)
|
||||
- WebTransport echo (`ShouldEchoWebTransport`) — extended-CONNECT acceptance, draft-02 SETTINGS, bidi data stream framing (`WT_STREAM 0x41` + session-id varint), and byte-for-byte echo
|
||||
|
||||
The external-interop test requires outbound UDP/443; if your network blocks it the test will fail.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Crafter.Thread: Thread pool management for asynchronous operations
|
||||
- **msquic** — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` recursively into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=quictls`, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC modules.
|
||||
- **Crafter.Thread**: Thread pool management for asynchronous operations.
|
||||
- **msquic** (native target only) — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` recursively into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=quictls`, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC and HTTP/3 modules. Skipped entirely on browser builds.
|
||||
- On Linux msquic links against `libnuma` (provided by the `numactl` package on most distros).
|
||||
- The self-signed-cert path used by tests/dev shells out to the `openssl` CLI; install `openssl` if you intend to use `QUICServerCredentials{selfSigned = true}`. The 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
|
||||
|
||||
|
|
@ -109,15 +176,14 @@ The library includes comprehensive tests covering:
|
|||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
// Simple HTTP client example
|
||||
Crafter::ClientHTTP client("httpbin.org", 80);
|
||||
Crafter::QUICClientCredentials creds;
|
||||
creds.insecureNoServerValidation = true;
|
||||
Crafter::ClientHTTP client("localhost", 8443, creds);
|
||||
|
||||
auto request = Crafter::CreateRequestHTTP("GET", "/get", "httpbin.org");
|
||||
auto response = client.Send(request);
|
||||
auto response = client.Send(Crafter::CreateRequestHTTP("GET", "/", "localhost"));
|
||||
|
||||
std::cout << "Status: " << response.status << std::endl;
|
||||
std::cout << "Body: " << response.body << std::endl;
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
|
|
|||
329
additional/network-env.js
Normal file
329
additional/network-env.js
Normal 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,
|
||||
});
|
||||
1
examples/SimpleClient/cert-hash.txt
Normal file
1
examples/SimpleClient/cert-hash.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
b7c4a81084fc56f45f1a6025fafdc8a1b05bf8388947f1840608da565cd22c8e
|
||||
227
examples/SimpleClient/main.cpp
Normal file
227
examples/SimpleClient/main.cpp
Normal 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;
|
||||
}
|
||||
79
examples/SimpleClient/project.cpp
Normal file
79
examples/SimpleClient/project.cpp
Normal 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;
|
||||
}
|
||||
199
implementations/Crafter.Network-ClientHTTP-Browser.cpp
Normal file
199
implementations/Crafter.Network-ClientHTTP-Browser.cpp
Normal 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); }
|
||||
}
|
||||
|
|
@ -19,190 +19,168 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|||
*/
|
||||
|
||||
module;
|
||||
|
||||
#include <stdio.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/uio.h>
|
||||
#include <sys/time.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
#include <msquic.h>
|
||||
module Crafter.Network:ClientHTTP_impl;
|
||||
import :ClientHTTP;
|
||||
import :ClientQUIC;
|
||||
import :HTTP;
|
||||
import :HTTP3;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
ClientHTTP::ClientHTTP(const char* host, std::uint16_t port): host(host), port(port), client(host, port) {
|
||||
struct ClientHTTP::Impl {
|
||||
ClientQUIC quic;
|
||||
// Outgoing control stream — RFC 9114 §6.2.1: each peer MUST open a
|
||||
// unidirectional control stream and send a SETTINGS frame as its first
|
||||
// frame. Most real h3 servers (cloudflare, nghttp3, lsquic, …) close
|
||||
// the connection with H3_MISSING_SETTINGS if we don't. The stream
|
||||
// stays open for the lifetime of the connection; we never FIN it.
|
||||
QUICStream controlStream;
|
||||
|
||||
}
|
||||
|
||||
ClientHTTP::ClientHTTP(std::string host, std::uint16_t port): ClientHTTP(host.c_str(), port) {
|
||||
|
||||
}
|
||||
|
||||
HTTPResponse ClientHTTP::Send(const char* request, std::uint32_t length) {
|
||||
std::cout << "Send started" << std::endl;
|
||||
client.Send(request, length);
|
||||
std::cout << "Send Complete" << std::endl;
|
||||
std::vector<char> buffer;
|
||||
HTTPResponse response;
|
||||
std::uint32_t i = 0;
|
||||
std::uint32_t statusStart = 0;
|
||||
while(true) {
|
||||
try {
|
||||
buffer = client.RecieveSync();
|
||||
std::cout << "Recieved: " << buffer.size() << std::endl;
|
||||
} catch(const SocketClosedException& e) {
|
||||
std::cout << "Retry" << std::endl;
|
||||
client.Stop();
|
||||
client.Connect();
|
||||
client.Send(request, length);
|
||||
buffer = client.RecieveSync();
|
||||
std::cout << "Recieved: " << buffer.size() << std::endl;
|
||||
}
|
||||
|
||||
for(; i < buffer.size(); i++) {
|
||||
if(buffer[i] == ' ') {
|
||||
statusStart = i;
|
||||
break;
|
||||
Impl(const char* host, std::uint16_t port, QUICClientCredentials creds)
|
||||
: quic(host, port, std::string(HTTP3::kAlpn), creds) {
|
||||
// Drain any unidi streams the server opens to us (its control
|
||||
// stream + optional QPACK encoder/decoder streams). We don't act
|
||||
// on the contents — SETTINGS we accept by defaults, dynamic-table
|
||||
// mutations we discard since we operate with no dynamic table.
|
||||
// Any bidi stream from the server would be a server push, which
|
||||
// we don't support — best-effort drain it as well.
|
||||
quic.OnStream([](QUICStream stream) {
|
||||
try {
|
||||
while (true) (void)stream.RecieveSync();
|
||||
} catch (...) {
|
||||
// Stream / connection closed. Done.
|
||||
}
|
||||
}
|
||||
for(; i < buffer.size(); i++) {
|
||||
if(buffer[i] == '\r') {
|
||||
response.status.assign(buffer.data()+statusStart+1, i-statusStart-1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
i+=2;
|
||||
while(i < buffer.size()) {
|
||||
std::uint32_t headerStart = i;
|
||||
std::string headerName;
|
||||
for(; i < buffer.size(); i++) {
|
||||
if(buffer[i] == ':') {
|
||||
headerName.assign(buffer.data()+headerStart, i-headerStart);
|
||||
std::transform(headerName.begin(), headerName.end(), headerName.begin(), [](unsigned char c){ return std::tolower(c); });
|
||||
i+=2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
headerStart = i;
|
||||
std::string headerValue;
|
||||
for(; i < buffer.size(); i++) {
|
||||
if(buffer[i] == '\r' && buffer[i+1] == '\n') {
|
||||
headerValue.assign(buffer.data()+headerStart, i-headerStart);
|
||||
response.headers.insert({headerName, headerValue});
|
||||
if(buffer[i+2] == '\r'){
|
||||
goto headersComplete;
|
||||
} else{
|
||||
i+=2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
i = 0;
|
||||
controlStream = quic.OpenStream(/*unidirectional=*/true);
|
||||
auto prelude = HTTP3::BuildControlStreamPrelude();
|
||||
controlStream.SendSync(prelude.data(),
|
||||
static_cast<std::uint32_t>(prelude.size()),
|
||||
/*finish=*/false);
|
||||
}
|
||||
headersComplete:;
|
||||
std::cout << "Header complete" << std::endl;
|
||||
i+=4;
|
||||
std::unordered_map<std::string, std::string>::iterator it = response.headers.find("content-length");
|
||||
if(it != response.headers.end())
|
||||
{
|
||||
const int lenght = std::stoi(it->second);
|
||||
std::cout << "Content lenght: " << lenght << std::endl;
|
||||
response.body.resize(lenght, 0);
|
||||
if(i < buffer.size()){
|
||||
std::memcpy(&response.body[0], buffer.data()+i, buffer.size()-i);
|
||||
}
|
||||
const int remaining = lenght-(buffer.size()-i);
|
||||
std::cout << "Remain: " << remaining << std::endl;
|
||||
if(remaining > 0){
|
||||
std::vector<char> bodyBuffer = client.RecieveUntilFullSync(remaining);
|
||||
std::memcpy(&response.body[ buffer.size()-i], bodyBuffer.data(), bodyBuffer.size());
|
||||
std::cout << "Recieved: " << bodyBuffer.size() << std::endl;
|
||||
}
|
||||
} else {
|
||||
std::cout << "No Content Lenght" << std::endl;
|
||||
std::unordered_map<std::string, std::string>::iterator it = response.headers.find("transfer-encoding");
|
||||
if(it != response.headers.end() && it->second == "chunked") {
|
||||
std::cout << "Chunked" << std::endl;
|
||||
while(i < buffer.size()){
|
||||
std::string lenght;
|
||||
int lenghtStart = i;
|
||||
for(; i < buffer.size(); i++) {
|
||||
if(buffer[i] == '\r') {
|
||||
lenght.assign(buffer.data()+lenghtStart, i-lenghtStart);
|
||||
break;
|
||||
}
|
||||
}
|
||||
i+=2;
|
||||
int lenghtInt = stoi(lenght, 0, 8);
|
||||
if(lenghtInt != 0){
|
||||
int oldSize = response.body.size();
|
||||
response.body.resize(oldSize+lenghtInt, 0);
|
||||
if(buffer.size() < lenghtInt) {
|
||||
std::memcpy(&response.body[oldSize], buffer.data()+i, buffer.size()-i);
|
||||
std::vector<char> bodyBuffer2 = client.RecieveUntilFullSync(lenghtInt-buffer.size());
|
||||
std::memcpy(&response.body[oldSize+(buffer.size()-i)], buffer.data(), buffer.size());
|
||||
} else {
|
||||
std::memcpy(&response.body[oldSize], buffer.data()+i, lenghtInt);
|
||||
i+=lenghtInt;
|
||||
}
|
||||
} else{
|
||||
goto bodyFinished;
|
||||
}
|
||||
};
|
||||
|
||||
ClientHTTP::ClientHTTP(const char* host, std::uint16_t port, QUICClientCredentials creds)
|
||||
: host(host), port(port), impl(std::make_unique<Impl>(host, port, std::move(creds))) {}
|
||||
|
||||
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;
|
||||
|
||||
namespace {
|
||||
// Parse a sequence of HTTP/3 frames from `bytes`. Populates response from
|
||||
// the first HEADERS frame and concatenates all DATA payloads. Trailing
|
||||
// HEADERS frames (trailers) are decoded but discarded. Throws on
|
||||
// malformed input.
|
||||
HTTPResponse ParseResponseFrames(const std::vector<char>& bytes) {
|
||||
HTTPResponse response;
|
||||
bool sawHeaders = false;
|
||||
std::size_t pos = 0;
|
||||
const auto* p = reinterpret_cast<const std::uint8_t*>(bytes.data());
|
||||
std::size_t avail = bytes.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)) {
|
||||
throw HTTP3::HTTP3ProtocolError("truncated frame type");
|
||||
}
|
||||
while(true) {
|
||||
std::vector<char> bodyBuffer = client.RecieveSync();
|
||||
int i2 = 0;
|
||||
while(i2 < bodyBuffer.size()){
|
||||
std::string lenght;
|
||||
int lenghtStart = i2;
|
||||
for(; i2 < bodyBuffer.size(); i2++) {
|
||||
if(buffer[i2] == '\r') {
|
||||
lenght.assign(bodyBuffer.data()+lenghtStart, i2-lenghtStart);
|
||||
break;
|
||||
}
|
||||
}
|
||||
i2+=2;
|
||||
int lenghtInt = stoi(lenght, 0, 8);
|
||||
if(lenghtInt != 0){
|
||||
int oldSize = response.body.size();
|
||||
response.body.resize(oldSize+lenghtInt, 0);
|
||||
if(bodyBuffer.size() < lenghtInt) {
|
||||
std::memcpy(&response.body[oldSize], bodyBuffer.data()+i2, bodyBuffer.size()-i2);
|
||||
std::vector<char> bodyBuffer2 = client.RecieveUntilFullSync(lenghtInt-bodyBuffer.size());
|
||||
std::memcpy(&response.body[oldSize+(bodyBuffer.size()-i2)], bodyBuffer2.data(), bodyBuffer2.size());
|
||||
pos += cn;
|
||||
if (!HTTP3::DecodeVarint(p + pos, avail - pos, frameLen, cn)) {
|
||||
throw HTTP3::HTTP3ProtocolError("truncated frame length");
|
||||
}
|
||||
pos += cn;
|
||||
if (pos + frameLen > avail) {
|
||||
throw HTTP3::HTTP3ProtocolError("frame length runs past buffer");
|
||||
}
|
||||
if (frameType == HTTP3::kFrameHeaders) {
|
||||
auto fields = HTTP3::DecodeFieldSection(p + pos, static_cast<std::size_t>(frameLen));
|
||||
if (!sawHeaders) {
|
||||
for (auto& [name, value] : fields) {
|
||||
if (name == ":status") {
|
||||
response.status = std::move(value);
|
||||
} else if (!name.empty() && name[0] == ':') {
|
||||
// Unknown response pseudo-header — ignore.
|
||||
} else {
|
||||
std::memcpy(&response.body[oldSize], bodyBuffer.data()+i2, lenghtInt);
|
||||
i2+=lenghtInt;
|
||||
response.headers.emplace(std::move(name), std::move(value));
|
||||
}
|
||||
} else{
|
||||
goto bodyFinished;
|
||||
}
|
||||
sawHeaders = true;
|
||||
}
|
||||
// Trailer HEADERS frames are skipped; the field section was
|
||||
// already decoded above and the contents discarded.
|
||||
} else if (frameType == HTTP3::kFrameData) {
|
||||
response.body.append(reinterpret_cast<const char*>(p + pos),
|
||||
static_cast<std::size_t>(frameLen));
|
||||
} else {
|
||||
// Unknown frame types are reserved/extensions — RFC 9114 §9
|
||||
// says skip them.
|
||||
}
|
||||
bodyFinished:;
|
||||
} else {
|
||||
std::cout << "Recv until close" << std::endl;
|
||||
std::vector<char> bodyBuffer = client.RecieveUntilCloseSync();
|
||||
response.body.resize((buffer.size()-i)+(bodyBuffer.size()), 0);
|
||||
if(i < buffer.size()){
|
||||
std::memcpy(&response.body[0], buffer.data()+i, buffer.size()-i);
|
||||
}
|
||||
std::memcpy(&response.body[buffer.size()-i], bodyBuffer.data(), bodyBuffer.size());
|
||||
std::cout << "Closed" << std::endl;
|
||||
pos += static_cast<std::size_t>(frameLen);
|
||||
}
|
||||
if (!sawHeaders) {
|
||||
throw HTTP3::HTTP3ProtocolError("response stream had no HEADERS frame");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
std::cout << "Response recieved" << std::endl;
|
||||
return response;
|
||||
}
|
||||
HTTPResponse ClientHTTP::Send(std::string request) {
|
||||
return Send(request.c_str(), request.size());
|
||||
|
||||
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) {
|
||||
QUICStream stream = impl->quic.OpenStream();
|
||||
|
||||
// Pseudo-headers MUST appear before regular fields (RFC 9114 §4.3).
|
||||
std::vector<std::pair<std::string, std::string>> fields;
|
||||
fields.reserve(4 + request.headers.size());
|
||||
fields.emplace_back(":method", request.method.empty() ? std::string("GET") : request.method);
|
||||
fields.emplace_back(":scheme", request.scheme.empty() ? std::string("https") : request.scheme);
|
||||
fields.emplace_back(":authority",
|
||||
request.authority.empty() ? (host + ":" + std::to_string(port)) : request.authority);
|
||||
fields.emplace_back(":path", request.path.empty() ? std::string("/") : request.path);
|
||||
for (const auto& [name, value] : request.headers) {
|
||||
// HTTP/3 forbids uppercase in field names — lowercase defensively.
|
||||
std::string lower = name;
|
||||
std::ranges::transform(lower, lower.begin(),
|
||||
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
|
||||
fields.emplace_back(std::move(lower), value);
|
||||
}
|
||||
|
||||
auto encoded = HTTP3::EncodeFieldSection(fields);
|
||||
|
||||
std::vector<std::uint8_t> wire;
|
||||
HTTP3::WriteFrame(wire, HTTP3::kFrameHeaders, encoded.data(), encoded.size());
|
||||
if (!request.body.empty()) {
|
||||
HTTP3::WriteFrame(wire, HTTP3::kFrameData,
|
||||
reinterpret_cast<const std::uint8_t*>(request.body.data()),
|
||||
request.body.size());
|
||||
}
|
||||
|
||||
// Send the entire request and FIN our send-side. HTTP/3 servers need FIN
|
||||
// to know the request is complete — there's no Content-Length signal.
|
||||
stream.SendSync(wire.data(), static_cast<std::uint32_t>(wire.size()), /*finish=*/true);
|
||||
|
||||
auto raw = stream.RecieveUntilCloseSync();
|
||||
return ParseResponseFrames(raw);
|
||||
}
|
||||
|
|
|
|||
443
implementations/Crafter.Network-ClientQUIC-Browser.cpp
Normal file
443
implementations/Crafter.Network-ClientQUIC-Browser.cpp
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -150,6 +150,8 @@ struct QUICStream::Impl {
|
|||
}
|
||||
};
|
||||
|
||||
QUICStream::QUICStream() = default;
|
||||
|
||||
QUICStream::QUICStream(HQUIC handle, ClientQUIC* connection)
|
||||
: handle(handle), connection(connection), impl(std::make_unique<Impl>())
|
||||
{
|
||||
|
|
@ -159,7 +161,9 @@ QUICStream::QUICStream(HQUIC handle, ClientQUIC* connection)
|
|||
}
|
||||
|
||||
QUICStream::QUICStream(QUICStream&& other) noexcept
|
||||
: handle(other.handle), connection(other.connection), impl(std::move(other.impl))
|
||||
: handle(other.handle), connection(other.connection),
|
||||
canSend(other.canSend), canReceive(other.canReceive),
|
||||
impl(std::move(other.impl))
|
||||
{
|
||||
other.handle = nullptr;
|
||||
other.connection = nullptr;
|
||||
|
|
@ -170,6 +174,8 @@ QUICStream& QUICStream::operator=(QUICStream&& other) noexcept {
|
|||
Stop();
|
||||
handle = other.handle;
|
||||
connection = other.connection;
|
||||
canSend = other.canSend;
|
||||
canReceive = other.canReceive;
|
||||
impl = std::move(other.impl);
|
||||
other.handle = nullptr;
|
||||
other.connection = nullptr;
|
||||
|
|
@ -183,12 +189,26 @@ QUICStream::~QUICStream() {
|
|||
|
||||
void QUICStream::Stop() {
|
||||
if (!handle) return;
|
||||
Runtime().api->StreamShutdown(handle, QUIC_STREAM_SHUTDOWN_FLAG_GRACEFUL, 0);
|
||||
// If the stream's SHUTDOWN_COMPLETE event has already fired, msquic has
|
||||
// internally called StreamClose for us (see Impl::Callback) and the
|
||||
// handle is no longer valid — calling StreamShutdown on it trips a
|
||||
// quic_bugcheck inside msquic. Skip in that case. This is the common
|
||||
// path for short-lived request/response streams where both peers FIN
|
||||
// before the wrapper is destroyed.
|
||||
bool alreadyClosed = false;
|
||||
if (impl) {
|
||||
std::lock_guard lk(impl->mtx);
|
||||
alreadyClosed = impl->shutdownComplete;
|
||||
}
|
||||
if (!alreadyClosed) {
|
||||
Runtime().api->StreamShutdown(handle, QUIC_STREAM_SHUTDOWN_FLAG_GRACEFUL, 0);
|
||||
}
|
||||
handle = nullptr;
|
||||
if (impl) impl->handle = nullptr;
|
||||
}
|
||||
|
||||
void QUICStream::SendSync(const void* buffer, std::uint32_t size, bool finish) {
|
||||
if (!handle) throw QUICClosedException();
|
||||
if (!handle || !canSend) throw QUICClosedException();
|
||||
auto* copy = new char[size];
|
||||
std::memcpy(copy, buffer, size);
|
||||
QUIC_BUFFER quicBuf{};
|
||||
|
|
@ -210,7 +230,7 @@ void QUICStream::SendSync(const void* buffer, std::uint32_t size, bool finish) {
|
|||
}
|
||||
|
||||
std::vector<char> QUICStream::RecieveSync() {
|
||||
if (!handle) throw QUICClosedException();
|
||||
if (!handle || !canReceive) throw QUICClosedException();
|
||||
std::unique_lock lk(impl->mtx);
|
||||
impl->cv.wait(lk, [&]{ return !impl->pending.empty() || impl->peerSendClosed || impl->shutdownComplete; });
|
||||
if (!impl->pending.empty()) {
|
||||
|
|
@ -222,7 +242,7 @@ std::vector<char> QUICStream::RecieveSync() {
|
|||
}
|
||||
|
||||
std::vector<char> QUICStream::RecieveUntilCloseSync() {
|
||||
if (!handle) throw QUICClosedException();
|
||||
if (!handle || !canReceive) throw QUICClosedException();
|
||||
std::vector<char> out;
|
||||
while (true) {
|
||||
std::unique_lock lk(impl->mtx);
|
||||
|
|
@ -237,7 +257,7 @@ std::vector<char> QUICStream::RecieveUntilCloseSync() {
|
|||
}
|
||||
|
||||
std::vector<char> QUICStream::RecieveUntilFullSync(std::uint32_t bufferSize) {
|
||||
if (!handle) throw QUICClosedException();
|
||||
if (!handle || !canReceive) throw QUICClosedException();
|
||||
std::vector<char> out;
|
||||
out.reserve(bufferSize);
|
||||
while (out.size() < bufferSize) {
|
||||
|
|
@ -260,6 +280,18 @@ std::vector<char> QUICStream::RecieveUntilFullSync(std::uint32_t bufferSize) {
|
|||
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) {
|
||||
ThreadPool::Enqueue([this, cb]{ cb(this->RecieveSync()); });
|
||||
}
|
||||
|
|
@ -270,6 +302,27 @@ void QUICStream::RecieveUntilFullAsync(std::uint32_t bufferSize, std::function<v
|
|||
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 ----------------
|
||||
struct ClientQUIC::Impl {
|
||||
HQUIC connection = nullptr;
|
||||
|
|
@ -285,6 +338,12 @@ struct ClientQUIC::Impl {
|
|||
std::function<void(QUICStream)> onStream;
|
||||
std::function<void(std::vector<char>)> onDatagram;
|
||||
std::deque<std::vector<char>> datagramQueue;
|
||||
// Streams the peer started before the user installed an OnStream
|
||||
// handler. Without this backlog the early streams (e.g. an h3 server's
|
||||
// control stream right after handshake) would be aborted in the
|
||||
// PEER_STREAM_STARTED branch and the connection would die with
|
||||
// H3_MISSING_SETTINGS on the peer side.
|
||||
std::deque<QUICStream> pendingStreams;
|
||||
|
||||
ClientQUIC* outer = nullptr;
|
||||
|
||||
|
|
@ -325,18 +384,30 @@ struct ClientQUIC::Impl {
|
|||
}
|
||||
case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: {
|
||||
HQUIC streamHandle = ev->PEER_STREAM_STARTED.Stream;
|
||||
bool unidirectional = (ev->PEER_STREAM_STARTED.Flags
|
||||
& QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL) != 0;
|
||||
QUICStream stream(streamHandle, self->outer);
|
||||
if (self->onStream) {
|
||||
auto cb = self->onStream;
|
||||
auto* shared = new QUICStream(std::move(stream));
|
||||
ThreadPool::Enqueue([cb, shared]{
|
||||
cb(std::move(*shared));
|
||||
delete shared;
|
||||
});
|
||||
} else {
|
||||
// No handler: shut down to avoid leaking a stream.
|
||||
Runtime().api->StreamShutdown(streamHandle, QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0);
|
||||
if (unidirectional) {
|
||||
// Peer-initiated unidi: peer sends, we read; we cannot send.
|
||||
stream.canSend = false;
|
||||
stream.canReceive = true;
|
||||
}
|
||||
std::function<void(QUICStream)> cb;
|
||||
{
|
||||
std::lock_guard lk(self->mtx);
|
||||
cb = self->onStream;
|
||||
if (!cb) {
|
||||
// Buffer until OnStream is installed; OnStream's
|
||||
// setter drains this queue.
|
||||
self->pendingStreams.push_back(std::move(stream));
|
||||
return QUIC_STATUS_SUCCESS;
|
||||
}
|
||||
}
|
||||
auto* shared = new QUICStream(std::move(stream));
|
||||
ThreadPool::Enqueue([cb, shared]{
|
||||
cb(std::move(*shared));
|
||||
delete shared;
|
||||
});
|
||||
return QUIC_STATUS_SUCCESS;
|
||||
}
|
||||
case QUIC_CONNECTION_EVENT_DATAGRAM_RECEIVED: {
|
||||
|
|
@ -382,6 +453,16 @@ static HQUIC OpenClientConfiguration(const std::string& alpn, const QUICClientCr
|
|||
settings.IdleTimeoutMs = 30'000;
|
||||
settings.IsSet.DatagramReceiveEnabled = 1;
|
||||
settings.DatagramReceiveEnabled = 1;
|
||||
// Allow the server to open unidi/bidi streams to us. msquic defaults
|
||||
// both peer-stream-count limits to 0; with that, the server's HTTP/3
|
||||
// control stream + QPACK encoder/decoder streams can't be created and
|
||||
// most h3 servers will close the connection after handshake. We don't
|
||||
// currently use server push (h3 pushes ride on unidi 0x01 streams) but
|
||||
// the bidi cap is harmless to grant.
|
||||
settings.IsSet.PeerUnidiStreamCount = 1;
|
||||
settings.PeerUnidiStreamCount = 16;
|
||||
settings.IsSet.PeerBidiStreamCount = 1;
|
||||
settings.PeerBidiStreamCount = 16;
|
||||
|
||||
HQUIC cfg = nullptr;
|
||||
QUIC_STATUS s = Runtime().api->ConfigurationOpen(Runtime().registration, &alpnBuffer, 1,
|
||||
|
|
@ -470,18 +551,26 @@ void ClientQUIC::Stop() {
|
|||
Runtime().api->ConnectionShutdown(impl->connection, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, 0);
|
||||
}
|
||||
|
||||
QUICStream ClientQUIC::OpenStream() {
|
||||
QUICStream ClientQUIC::OpenStream(bool unidirectional) {
|
||||
HQUIC streamHandle = nullptr;
|
||||
QUICStream stream;
|
||||
stream.impl = std::make_unique<QUICStream::Impl>();
|
||||
stream.impl->connection = this;
|
||||
QUIC_STATUS s = Runtime().api->StreamOpen(impl->connection, QUIC_STREAM_OPEN_FLAG_NONE,
|
||||
QUIC_STREAM_OPEN_FLAGS openFlags = unidirectional
|
||||
? QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL
|
||||
: QUIC_STREAM_OPEN_FLAG_NONE;
|
||||
QUIC_STATUS s = Runtime().api->StreamOpen(impl->connection, openFlags,
|
||||
reinterpret_cast<QUIC_STREAM_CALLBACK_HANDLER>(&QUICStream::Impl::Callback),
|
||||
stream.impl.get(), &streamHandle);
|
||||
if (QUIC_FAILED(s)) throw QUICException(std::format("StreamOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
||||
stream.handle = streamHandle;
|
||||
stream.connection = this;
|
||||
stream.impl->handle = streamHandle;
|
||||
if (unidirectional) {
|
||||
// We initiated the unidi stream: we send, peer reads.
|
||||
stream.canSend = true;
|
||||
stream.canReceive = false;
|
||||
}
|
||||
s = Runtime().api->StreamStart(streamHandle, QUIC_STREAM_START_FLAG_NONE);
|
||||
if (QUIC_FAILED(s)) {
|
||||
Runtime().api->StreamClose(streamHandle);
|
||||
|
|
@ -510,7 +599,21 @@ void ClientQUIC::SendDatagram(const void* buffer, std::uint32_t size) {
|
|||
}
|
||||
|
||||
void ClientQUIC::OnStream(std::function<void(QUICStream)> cb) {
|
||||
impl->onStream = std::move(cb);
|
||||
std::deque<QUICStream> backlog;
|
||||
{
|
||||
std::lock_guard lk(impl->mtx);
|
||||
impl->onStream = cb;
|
||||
std::swap(backlog, impl->pendingStreams);
|
||||
}
|
||||
while (!backlog.empty()) {
|
||||
auto* shared = new QUICStream(std::move(backlog.front()));
|
||||
backlog.pop_front();
|
||||
auto handler = cb;
|
||||
ThreadPool::Enqueue([handler, shared]{
|
||||
handler(std::move(*shared));
|
||||
delete shared;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void ClientQUIC::OnDatagram(std::function<void(std::vector<char>)> cb) {
|
||||
|
|
|
|||
|
|
@ -19,224 +19,429 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|||
*/
|
||||
|
||||
module;
|
||||
|
||||
#include <stdio.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/uio.h>
|
||||
#include <sys/time.h>
|
||||
#include <sys/wait.h>
|
||||
#include <strings.h>
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
|
||||
#include <msquic.h>
|
||||
module Crafter.Network:ListenerHTTP_impl;
|
||||
import :ListenerHTTP;
|
||||
import :ClientTCP;
|
||||
import std;
|
||||
import :ListenerQUIC;
|
||||
import :ClientQUIC;
|
||||
import :HTTP;
|
||||
import :HTTP3;
|
||||
import :WebTransport;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
ListenerHTTP::ListenerHTTP(std::uint16_t port, std::unordered_map<std::string, std::function<std::string(const HTTPRequest&)>> routes): routes(routes) {
|
||||
sockaddr_in servAddr;
|
||||
bzero((char*)&servAddr, sizeof(servAddr));
|
||||
servAddr.sin_family = AF_INET;
|
||||
servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
servAddr.sin_port = htons(port);
|
||||
namespace {
|
||||
// Parse a complete request stream's bytes into an HTTPRequest. The stream
|
||||
// is closed by the peer with FIN, so we read until close and then
|
||||
// frame-walk the bytes (HEADERS [+ DATA]*).
|
||||
HTTPRequest ParseRequestFrames(const std::vector<char>& bytes) {
|
||||
HTTPRequest request;
|
||||
bool sawHeaders = false;
|
||||
std::size_t pos = 0;
|
||||
const auto* p = reinterpret_cast<const std::uint8_t*>(bytes.data());
|
||||
std::size_t avail = bytes.size();
|
||||
|
||||
s = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (s < 0) {
|
||||
throw std::runtime_error("Error establishing the server socket");
|
||||
while (pos < avail) {
|
||||
std::uint64_t frameType = 0, frameLen = 0;
|
||||
std::size_t cn = 0;
|
||||
if (!HTTP3::DecodeVarint(p + pos, avail - pos, frameType, cn)) {
|
||||
throw HTTP3::HTTP3ProtocolError("truncated frame type");
|
||||
}
|
||||
pos += cn;
|
||||
if (!HTTP3::DecodeVarint(p + pos, avail - pos, frameLen, cn)) {
|
||||
throw HTTP3::HTTP3ProtocolError("truncated frame length");
|
||||
}
|
||||
pos += cn;
|
||||
if (pos + frameLen > avail) {
|
||||
throw HTTP3::HTTP3ProtocolError("frame length runs past buffer");
|
||||
}
|
||||
if (frameType == HTTP3::kFrameHeaders) {
|
||||
auto fields = HTTP3::DecodeFieldSection(p + pos, static_cast<std::size_t>(frameLen));
|
||||
if (!sawHeaders) {
|
||||
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 if (!name.empty() && name[0] == ':') {
|
||||
// Pass through other pseudo-headers (e.g. :protocol
|
||||
// for extended CONNECT — RFC 8441 / RFC 9220) as
|
||||
// regular headers so the routing layer can see them.
|
||||
request.headers.emplace(std::move(name), std::move(value));
|
||||
} else {
|
||||
request.headers.emplace(std::move(name), std::move(value));
|
||||
}
|
||||
}
|
||||
sawHeaders = true;
|
||||
}
|
||||
} else if (frameType == HTTP3::kFrameData) {
|
||||
request.body.append(reinterpret_cast<const char*>(p + pos),
|
||||
static_cast<std::size_t>(frameLen));
|
||||
} else {
|
||||
// Skip unknown frames (RFC 9114 §9 — reserved/extension frame
|
||||
// types are silently ignored).
|
||||
}
|
||||
pos += static_cast<std::size_t>(frameLen);
|
||||
}
|
||||
if (!sawHeaders) {
|
||||
throw HTTP3::HTTP3ProtocolError("request stream had no HEADERS frame");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
int opt = 1;
|
||||
if (setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
|
||||
throw std::runtime_error("Error setting SO_REUSEADDR");
|
||||
// Serialise a response into HEADERS [+ DATA] frames.
|
||||
std::vector<std::uint8_t> SerializeResponse(const HTTPResponse& response) {
|
||||
std::vector<std::pair<std::string, std::string>> fields;
|
||||
fields.reserve(1 + response.headers.size());
|
||||
fields.emplace_back(":status", response.status.empty() ? std::string("200") : response.status);
|
||||
for (const auto& [name, value] : response.headers) {
|
||||
std::string lower = name;
|
||||
std::ranges::transform(lower, lower.begin(),
|
||||
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
|
||||
fields.emplace_back(std::move(lower), value);
|
||||
}
|
||||
auto encoded = HTTP3::EncodeFieldSection(fields);
|
||||
|
||||
std::vector<std::uint8_t> wire;
|
||||
HTTP3::WriteFrame(wire, HTTP3::kFrameHeaders, encoded.data(), encoded.size());
|
||||
if (!response.body.empty()) {
|
||||
HTTP3::WriteFrame(wire, HTTP3::kFrameData,
|
||||
reinterpret_cast<const std::uint8_t*>(response.body.data()),
|
||||
response.body.size());
|
||||
}
|
||||
return wire;
|
||||
}
|
||||
|
||||
int bindStatus = bind(s, (struct sockaddr*)&servAddr, sizeof(servAddr));
|
||||
if (bindStatus < 0) {
|
||||
throw std::runtime_error(std::format("Error binding the server socket: {}", std::strerror(errno)));
|
||||
}
|
||||
|
||||
if (listen(s, 5) < 0) {
|
||||
throw std::runtime_error("Error starting to listen on the server socket");
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One accepted-CONNECT WebTransport session living inside a 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 {
|
||||
std::unique_ptr<ClientQUIC> quic;
|
||||
QUICStream controlStream;
|
||||
// QPACK encoder/decoder streams; opened immediately after the control
|
||||
// stream and never written to again (we run without a dynamic table).
|
||||
// Their presence is what lets strict HTTP/3 stacks like Chromium decide
|
||||
// the peer is ready for request streams.
|
||||
QUICStream qpackEncoderStream;
|
||||
QUICStream qpackDecoderStream;
|
||||
std::mutex wtMtx;
|
||||
// Session id == CONNECT stream's QUIC stream id, supplied by the peer
|
||||
// as the first varint of every WT data stream.
|
||||
std::unordered_map<std::uint64_t, std::unique_ptr<WTSessionEntry>> wtSessions;
|
||||
};
|
||||
|
||||
struct ListenerHTTP::Impl {
|
||||
std::unique_ptr<ListenerQUIC> listener;
|
||||
std::mutex peersMtx;
|
||||
std::vector<std::unique_ptr<PeerState>> peers;
|
||||
bool running = true;
|
||||
};
|
||||
|
||||
namespace {
|
||||
// Build the per-connection bidi-stream handler. Demuxes WT streams from
|
||||
// HTTP/3 request streams by peeking the first varint on the wire. Lives
|
||||
// as a free helper so both ListenerHTTP constructors can install it.
|
||||
std::function<void(QUICStream)> MakeBidiHandler(
|
||||
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)
|
||||
{
|
||||
return [self, peerState, routes, wtRoutes](QUICStream stream) {
|
||||
try {
|
||||
// ── Phase A: identify the stream kind ─────────────────────
|
||||
//
|
||||
// We peek the leading varint(s) off the wire incrementally.
|
||||
// For HTTP/3 streams that's `frame_type, frame_length`. For
|
||||
// a WT_STREAM the body runs to FIN so there is no length.
|
||||
std::vector<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;
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
auto rest = stream.RecieveUntilCloseSync();
|
||||
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;
|
||||
auto it = routes->find(request.path);
|
||||
if (it != routes->end()) {
|
||||
response = it->second(request);
|
||||
} else {
|
||||
response.status = "404";
|
||||
response.body = "Not Found";
|
||||
}
|
||||
|
||||
auto wire = SerializeResponse(response);
|
||||
stream.SendSync(wire.data(),
|
||||
static_cast<std::uint32_t>(wire.size()),
|
||||
/*finish=*/true);
|
||||
} catch (const std::exception& e) {
|
||||
try {
|
||||
HTTPResponse err;
|
||||
err.status = "500";
|
||||
err.body = e.what();
|
||||
auto wire = SerializeResponse(err);
|
||||
stream.SendSync(wire.data(),
|
||||
static_cast<std::uint32_t>(wire.size()),
|
||||
/*finish=*/true);
|
||||
} 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.
|
||||
// When wtRoutes is non-empty we advertise WebTransport support so
|
||||
// the browser will issue extended CONNECT against us.
|
||||
try {
|
||||
state->controlStream = peer->OpenStream(/*unidirectional=*/true);
|
||||
auto prelude = this->wtRoutes.empty()
|
||||
? HTTP3::BuildControlStreamPrelude()
|
||||
: HTTP3::BuildWebTransportControlStreamPrelude(/*maxSessions=*/16);
|
||||
state->controlStream.SendSync(prelude.data(),
|
||||
static_cast<std::uint32_t>(prelude.size()),
|
||||
/*finish=*/false);
|
||||
|
||||
// QPACK encoder + decoder streams (RFC 9204 §5). We don't use the
|
||||
// dynamic table, so these streams stay idle for the lifetime of
|
||||
// the connection. They're not optional in practice: Chromium and
|
||||
// some other HTTP/3 stacks won't issue any request stream until
|
||||
// they've seen both stream types from the peer, even when the
|
||||
// encoder is silent.
|
||||
state->qpackEncoderStream = peer->OpenStream(/*unidirectional=*/true);
|
||||
std::uint8_t encType = static_cast<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 (...) {
|
||||
// Connection died mid-handshake; drop the peer.
|
||||
}
|
||||
|
||||
std::lock_guard lk(impl->peersMtx);
|
||||
impl->peers.push_back(std::move(state));
|
||||
};
|
||||
|
||||
impl->listener = std::make_unique<ListenerQUIC>(port,
|
||||
std::string(HTTP3::kAlpn),
|
||||
std::move(creds),
|
||||
onConnect);
|
||||
}
|
||||
|
||||
ListenerHTTP::ListenerHTTP(ListenerHTTP&&) noexcept = default;
|
||||
|
||||
ListenerHTTP::~ListenerHTTP() {
|
||||
if(s != -1) {
|
||||
Stop();
|
||||
}
|
||||
if (impl) Stop();
|
||||
}
|
||||
|
||||
void ListenerHTTP::Stop() {
|
||||
running = false;
|
||||
shutdown(s, SHUT_RDWR);
|
||||
close(s);
|
||||
s = -1;
|
||||
for(ListenerHTTPClient* client : clients) {
|
||||
client->client.Stop();
|
||||
client->thread.join();
|
||||
delete client;
|
||||
}
|
||||
if (!impl) return;
|
||||
impl->running = false;
|
||||
if (impl->listener) impl->listener->Stop();
|
||||
}
|
||||
|
||||
void ListenerHTTP::Listen() {
|
||||
while(running) {
|
||||
sockaddr_in newSockAddr;
|
||||
socklen_t newSockAddrSize = sizeof(newSockAddr);
|
||||
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
|
||||
if(!running) {
|
||||
return;
|
||||
}
|
||||
if (client > 0) {
|
||||
clients.push_back(new ListenerHTTPClient(this, client));
|
||||
} else {
|
||||
std::cerr << "Error accepting request from client!" << std::endl;
|
||||
}
|
||||
std::erase_if(clients, [](ListenerHTTPClient* client) {
|
||||
if (client->disconnected.load()) {
|
||||
client->thread.join();
|
||||
delete client;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (!impl || !impl->listener) return;
|
||||
impl->listener->ListenSyncAsync();
|
||||
}
|
||||
|
||||
ListenerHTTPClient::ListenerHTTPClient(ListenerHTTP* server, int s) : server(server), client(s), thread(&ListenerHTTPClient::ListenRoutes, this), disconnected(false) {
|
||||
ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port,
|
||||
QUICServerCredentials creds,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
|
||||
: listener(port, std::move(creds), std::move(routes))
|
||||
, thread(&ListenerHTTP::Listen, &listener)
|
||||
{}
|
||||
|
||||
}
|
||||
|
||||
void ListenerHTTPClient::ListenRoutes() {
|
||||
try {
|
||||
while(true) {
|
||||
std::vector<char> buffer;
|
||||
HTTPRequest request;
|
||||
std::string route;
|
||||
std::uint32_t i = 0;
|
||||
std::uint32_t routeStart = 0;
|
||||
while(true) {
|
||||
buffer = client.RecieveSync();
|
||||
while(true) {
|
||||
std::string str(buffer.begin(), buffer.end());
|
||||
for(; i < buffer.size(); i++) {
|
||||
if(buffer[i] == ' ') {
|
||||
request.method.assign(buffer.data(), i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
for(; i < buffer.size(); i++) {
|
||||
if(buffer[i] == '/') {
|
||||
routeStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for(; i < buffer.size(); i++) {
|
||||
if(buffer[i] == ' ') {
|
||||
route.assign(buffer.data()+routeStart, i-routeStart);
|
||||
break;
|
||||
}
|
||||
}
|
||||
for(; i < buffer.size(); i++) {
|
||||
if(buffer[i] == '\r' && buffer[i+1] == '\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
i+=2;
|
||||
while(i < buffer.size()) {
|
||||
std::uint32_t headerStart = i;
|
||||
std::string headerName;
|
||||
for(; i < buffer.size(); i++) {
|
||||
if(buffer[i] == ':') {
|
||||
headerName.assign(buffer.data()+headerStart, i-headerStart);
|
||||
std::transform(headerName.begin(), headerName.end(), headerName.begin(), [](unsigned char c){ return std::tolower(c); });
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
headerStart = i;
|
||||
std::string headerValue;
|
||||
for(; i < buffer.size(); i++) {
|
||||
if(buffer[i] == '\r' && buffer[i+1] == '\n') {
|
||||
headerValue.assign(buffer.data()+headerStart, i-headerStart);
|
||||
request.headers.insert({headerName, headerValue});
|
||||
if(buffer[i+2] == '\r'){
|
||||
goto headersComplete;
|
||||
} else{
|
||||
i+=2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i = 0;
|
||||
}
|
||||
headersComplete:;
|
||||
i+=4;
|
||||
std::unordered_map<std::string, std::string>::iterator it = request.headers.find("content-length");
|
||||
if(it != request.headers.end()) {
|
||||
const int lenght = std::stoi(it->second);
|
||||
request.body.resize(lenght, 0);
|
||||
if(lenght > 0 ){
|
||||
std::int_fast32_t remaining = lenght-(buffer.size()-i);
|
||||
if(remaining < 0) {
|
||||
std::memcpy(&request.body[0], buffer.data()+i, lenght);
|
||||
std::string response = server->routes.at(route)(request);
|
||||
client.Send(&response[0], response.size());
|
||||
i+=lenght;
|
||||
} else if(remaining == 0){
|
||||
std::memcpy(&request.body[0], buffer.data()+i, lenght);
|
||||
std::string response = server->routes.at(route)(request);
|
||||
client.Send(&response[0], response.size());
|
||||
break;
|
||||
} else {
|
||||
std::memcpy(&request.body[0], buffer.data()+i, buffer.size()-i);
|
||||
std::vector<char> bodyBuffer = client.RecieveUntilFullSync(remaining);
|
||||
std::memcpy(&request.body[buffer.size()-i], bodyBuffer.data(), remaining);
|
||||
std::string response = server->routes.at(route)(request);
|
||||
client.Send(&response[0], response.size());
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
std::string response = server->routes.at(route)(request);
|
||||
client.Send(&response[0], response.size());
|
||||
if(i == buffer.size()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
std::string response = server->routes.at(route)(request);
|
||||
client.Send(&response[0], response.size());
|
||||
if(i == buffer.size()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(SocketClosedException& e) {
|
||||
disconnected.store(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port, std::unordered_map<std::string, std::function<std::string(const HTTPRequest&)>> routes): listener(port, routes), 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() {
|
||||
if(listener.s != -1) {
|
||||
Stop();
|
||||
}
|
||||
Stop();
|
||||
}
|
||||
|
||||
void ListenerAsyncHTTP::Stop() {
|
||||
listener.Stop();
|
||||
thread.join();
|
||||
listener.Stop();
|
||||
if (thread.joinable()) thread.join();
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|||
module;
|
||||
#include <msquic.h>
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <unistd.h>
|
||||
module Crafter.Network:ListenerQUIC_impl;
|
||||
|
|
@ -84,6 +85,12 @@ namespace {
|
|||
// mkdtemp'd directory under /tmp for the lifetime of the process.
|
||||
// Intended for dev / LAN play / tests — production should pass real
|
||||
// cert/key paths.
|
||||
//
|
||||
// Cert shape: ECDSA P-256, validity 13 days, SAN={DNS:localhost,
|
||||
// IP:127.0.0.1, IP:::1}. These constraints are mandated by Chromium's
|
||||
// WebTransport `serverCertificateHashes` (must be ECDSA P-256, validity
|
||||
// <14 days, SAN with the connect target). msquic accepts the same cert
|
||||
// unchanged, so existing pure-QUIC callers are unaffected.
|
||||
struct SelfSignedCert {
|
||||
std::string certPath;
|
||||
std::string keyPath;
|
||||
|
|
@ -94,24 +101,62 @@ namespace {
|
|||
std::lock_guard lk(mtx);
|
||||
if (cached) return *cached;
|
||||
|
||||
char tmpl[] = "/tmp/crafter-quic-cert-XXXXXX";
|
||||
if (mkdtemp(tmpl) == nullptr) {
|
||||
throw QUICException("mkdtemp failed for self-signed cert dir");
|
||||
}
|
||||
std::string dir = tmpl;
|
||||
SelfSignedCert s;
|
||||
s.keyPath = dir + "/key.pem";
|
||||
s.certPath = dir + "/cert.pem";
|
||||
std::string cmd = std::format(
|
||||
"openssl req -x509 -newkey rsa:2048 -keyout '{}' -out '{}' "
|
||||
"-days 1 -nodes -subj '/CN=localhost' >/dev/null 2>&1",
|
||||
s.keyPath, s.certPath);
|
||||
int rc = std::system(cmd.c_str());
|
||||
if (rc != 0) {
|
||||
// Stable on-disk location so the cert (and therefore its SHA-256) is
|
||||
// reused across server restarts. Without this, a browser peer that
|
||||
// pinned the cert hash on a previous run would see a hash mismatch
|
||||
// the moment we restart. We only regenerate if the cert file is
|
||||
// missing or has expired.
|
||||
std::filesystem::path dir = "/tmp/crafter-network-quic-cert";
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
if (ec) {
|
||||
throw QUICException(std::format(
|
||||
"openssl CLI failed to generate self-signed cert "
|
||||
"(exit {}); install openssl or pass certPath/keyPath",
|
||||
rc));
|
||||
"could not create cert cache dir {}: {}", dir.string(), ec.message()));
|
||||
}
|
||||
SelfSignedCert s;
|
||||
s.keyPath = (dir / "key.pem").string();
|
||||
s.certPath = (dir / "cert.pem").string();
|
||||
|
||||
bool needRegen = !std::filesystem::exists(s.certPath, ec)
|
||||
|| !std::filesystem::exists(s.keyPath, ec);
|
||||
if (!needRegen) {
|
||||
// Use openssl to ask whether the cert is still valid. -checkend 0
|
||||
// returns 0 if the cert is still good, non-zero if expired.
|
||||
int rc = std::system(std::format(
|
||||
"openssl x509 -in '{}' -noout -checkend 0 >/dev/null 2>&1",
|
||||
s.certPath).c_str());
|
||||
if (rc != 0) needRegen = true;
|
||||
}
|
||||
if (needRegen) {
|
||||
// Inline openssl config so we get exactly the extensions Chromium's
|
||||
// WebTransport cert-hash verifier accepts (BasicConstraints CA:FALSE,
|
||||
// KeyUsage digitalSignature, EKU serverAuth, SAN). Skipping the
|
||||
// implicit subjectKeyIdentifier / authorityKeyIdentifier that
|
||||
// `openssl req -x509 -addext ...` would otherwise add.
|
||||
std::string cmd = std::format(
|
||||
"openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 "
|
||||
"-keyout '{}' -out '{}' -days 10 -nodes "
|
||||
"-extensions v3_wt "
|
||||
"-config /dev/stdin >/dev/null 2>&1 <<'CONFIG'\n"
|
||||
"[req]\n"
|
||||
"distinguished_name = req_dn\n"
|
||||
"prompt = no\n"
|
||||
"[req_dn]\n"
|
||||
"CN = localhost\n"
|
||||
"[v3_wt]\n"
|
||||
"basicConstraints = critical, CA:FALSE\n"
|
||||
"keyUsage = critical, digitalSignature\n"
|
||||
"extendedKeyUsage = serverAuth\n"
|
||||
"subjectAltName = DNS:localhost, IP:127.0.0.1, IP:::1\n"
|
||||
"CONFIG\n",
|
||||
s.keyPath, s.certPath);
|
||||
int rc = std::system(cmd.c_str());
|
||||
if (rc != 0) {
|
||||
throw QUICException(std::format(
|
||||
"openssl CLI failed to generate self-signed cert "
|
||||
"(exit {}); install openssl or pass certPath/keyPath",
|
||||
rc));
|
||||
}
|
||||
}
|
||||
cached = std::move(s);
|
||||
return *cached;
|
||||
|
|
@ -329,3 +374,53 @@ void ListenerQUIC::ListenAsyncSync() {
|
|||
void ListenerQUIC::ListenAsyncAsync() {
|
||||
impl->acceptLoop = std::thread([this]{ ListenSyncAsync(); });
|
||||
}
|
||||
|
||||
std::array<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;
|
||||
}
|
||||
|
|
|
|||
152
implementations/Crafter.Network-WebTransport.cpp
Normal file
152
implementations/Crafter.Network-WebTransport.cpp
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -20,19 +20,51 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|||
|
||||
export module Crafter.Network:ClientHTTP;
|
||||
import std;
|
||||
import :ClientTCP;
|
||||
import :HTTP;
|
||||
import :ClientQUIC;
|
||||
|
||||
namespace Crafter {
|
||||
export class ClientHTTP {
|
||||
public:
|
||||
std::string host;
|
||||
std::uint16_t port;
|
||||
ClientHTTP(const char* host, std::uint16_t port);
|
||||
ClientHTTP(std::string host, std::uint16_t port);
|
||||
HTTPResponse Send(const char* request, std::uint32_t length);
|
||||
HTTPResponse Send(std::string request);
|
||||
private:
|
||||
ClientTCP client;
|
||||
};
|
||||
// HTTP/3 client over QUIC. The constructor establishes the QUIC connection
|
||||
// (TLS handshake + ALPN "h3"); each Send() opens a fresh request stream
|
||||
// on the multiplexed connection. Thread-affinity: Send() is not safe to
|
||||
// call from multiple threads concurrently against the same ClientHTTP,
|
||||
// but distinct ClientHTTP instances are independent.
|
||||
//
|
||||
// For local development against a self-signed listener, pass
|
||||
// QUICClientCredentials{insecureNoServerValidation = true}.
|
||||
//
|
||||
// Browser build: the request is dispatched via the browser's fetch()
|
||||
// and the synchronous Send() is not compiled — use SendAsync instead.
|
||||
// The ClientHTTP instance does not maintain a persistent connection
|
||||
// (fetch is request-scoped); host and port are stored and prefixed to
|
||||
// the request path on each call. QUICClientCredentials is ignored.
|
||||
export class ClientHTTP {
|
||||
public:
|
||||
std::string host;
|
||||
std::uint16_t port;
|
||||
|
||||
ClientHTTP(const char* host, std::uint16_t port, QUICClientCredentials creds = {});
|
||||
ClientHTTP(std::string host, std::uint16_t port, QUICClientCredentials creds = {});
|
||||
|
||||
~ClientHTTP();
|
||||
ClientHTTP(const ClientHTTP&) = delete;
|
||||
ClientHTTP(ClientHTTP&&) noexcept;
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
// Send a request and synchronously read back the full response.
|
||||
HTTPResponse Send(const HTTPRequest& request);
|
||||
#endif
|
||||
|
||||
// Send a request and deliver the response (or an error) via callback.
|
||||
// Available on both native and browser builds. Native dispatches on
|
||||
// Crafter.Thread's ThreadPool; browser uses fetch() and resolves on
|
||||
// the JS event loop.
|
||||
void SendAsync(const HTTPRequest& request,
|
||||
std::function<void(HTTPResponse)> onSuccess,
|
||||
std::function<void(std::string)> onError);
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl;
|
||||
};
|
||||
}
|
||||
|
|
@ -19,7 +19,9 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|||
*/
|
||||
|
||||
module;
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
#include <msquic.h>
|
||||
#endif
|
||||
export module Crafter.Network:ClientQUIC;
|
||||
import std;
|
||||
|
||||
|
|
@ -45,32 +47,58 @@ namespace Crafter {
|
|||
};
|
||||
|
||||
// Client-side credential validation. By default we require a real cert.
|
||||
// insecureNoServerValidation disables peer cert checks — only for dev.
|
||||
// insecureNoServerValidation disables peer cert checks — only for dev,
|
||||
// and silently ignored in the browser build (browsers enforce their own
|
||||
// certificate policy). For browser dev against a self-signed listener,
|
||||
// populate serverCertificateHash with the SHA-256 of the server's DER
|
||||
// certificate; on the browser it is forwarded to WebTransport's
|
||||
// serverCertificateHashes option. A zeroed array means "unused" — the
|
||||
// browser will then require a publicly trusted cert.
|
||||
export struct QUICClientCredentials {
|
||||
bool insecureNoServerValidation = false;
|
||||
std::array<std::uint8_t, 32> serverCertificateHash{};
|
||||
};
|
||||
|
||||
export class ClientQUIC;
|
||||
|
||||
// A reliable, ordered, bidirectional stream within a QUIC connection.
|
||||
// Owned by ClientQUIC; do not destroy directly. Obtain via
|
||||
// ClientQUIC::OpenStream() or via the on-stream callback for inbound
|
||||
// streams initiated by the peer.
|
||||
// A reliable, ordered stream within a QUIC connection. May be
|
||||
// bidirectional or unidirectional; for unidi streams either canSend or
|
||||
// canReceive will be false depending on which side initiated. Owned by
|
||||
// ClientQUIC; do not destroy directly. Obtain via ClientQUIC::OpenStream
|
||||
// (optionally with unidirectional=true) or via the on-stream callback
|
||||
// for inbound streams initiated by the peer.
|
||||
export class QUICStream {
|
||||
public:
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
// Underlying msquic HQUIC handle. Treated as opaque by callers.
|
||||
HQUIC handle = nullptr;
|
||||
#endif
|
||||
|
||||
// The connection that owns this stream (non-owning).
|
||||
ClientQUIC* connection = nullptr;
|
||||
|
||||
QUICStream() = default;
|
||||
// Direction flags. Bidi streams have both true; outgoing unidi sets
|
||||
// canReceive=false; incoming unidi (peer-initiated) sets canSend=false.
|
||||
bool canSend = true;
|
||||
bool canReceive = true;
|
||||
|
||||
QUICStream();
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
QUICStream(HQUIC handle, ClientQUIC* connection);
|
||||
#else
|
||||
// Browser-only constructor: wraps a JS-side WebTransport stream
|
||||
// identified by its integer handle. Used by ClientQUIC::OpenStream
|
||||
// and by the incoming-stream dispatcher in the JS bridge — not
|
||||
// intended for direct use.
|
||||
QUICStream(std::int32_t handle, ClientQUIC* connection,
|
||||
bool canSend, bool canReceive);
|
||||
#endif
|
||||
~QUICStream();
|
||||
QUICStream(const QUICStream&) = delete;
|
||||
QUICStream(QUICStream&&) noexcept;
|
||||
QUICStream& operator=(QUICStream&&) noexcept;
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
// Send a buffer. If finish=true, the send-side of the stream is closed
|
||||
// after the buffer is delivered (peer will see graceful shutdown).
|
||||
// Blocks until msquic accepts the buffer; throws on stream/conn close.
|
||||
|
|
@ -86,12 +114,36 @@ namespace Crafter {
|
|||
|
||||
// Read exactly bufferSize bytes; throws if the peer closes early.
|
||||
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 RecieveUntilCloseAsync(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).
|
||||
void Stop();
|
||||
|
||||
|
|
@ -102,8 +154,9 @@ namespace Crafter {
|
|||
};
|
||||
|
||||
// A QUIC connection. On the client side, constructing one initiates the
|
||||
// handshake and blocks until it succeeds (or throws on failure). On the
|
||||
// server side, ListenerQUIC instantiates these for accepted peers.
|
||||
// handshake and (on native) blocks until it succeeds, or throws on
|
||||
// failure. On the server side, ListenerQUIC instantiates these for
|
||||
// accepted peers.
|
||||
//
|
||||
// A connection multiplexes:
|
||||
// - Reliable, ordered streams (open via OpenStream() / observe inbound
|
||||
|
|
@ -113,10 +166,28 @@ namespace Crafter {
|
|||
// Lifetime: ~ClientQUIC closes the connection. Streams obtained from
|
||||
// OpenStream() are scoped to the connection and must be destroyed (or
|
||||
// moved out) before the ClientQUIC.
|
||||
//
|
||||
// Browser build: the only QUIC-shaped API the browser exposes is
|
||||
// WebTransport, which is HTTP/3-based and reached at a fixed URL. Here:
|
||||
// - The constructor returns immediately; the connection is opened in
|
||||
// the background. Operations issued before the connection is ready
|
||||
// are queued JS-side until WebTransport's "ready" promise resolves
|
||||
// (or fail with QUICClosedException if the connection rejects).
|
||||
// - `alpn` is mapped to the URL path: new WebTransport(
|
||||
// `https://${host}:${port}/${alpn}`). The QUIC-layer ALPN itself
|
||||
// is fixed to "h3" by the browser and cannot be customised.
|
||||
// - The server side must accept WebTransport sessions (HTTP/3 extended
|
||||
// CONNECT) on the path equal to `alpn`. Plain QUIC with a custom
|
||||
// ALPN — what ListenerQUIC offers today — is not reachable from a
|
||||
// browser.
|
||||
// - Synchronous send/receive methods are not compiled. Use the *Async
|
||||
// variants instead.
|
||||
export class ClientQUIC {
|
||||
public:
|
||||
// ALPN identifier exchanged in the handshake. Both peers must agree.
|
||||
// For 3DForts use e.g. "f3d/1" or similar — a short stable token.
|
||||
// On the browser build, this is the WebTransport URL path instead
|
||||
// of an ALPN token; see the class comment above.
|
||||
std::string alpn;
|
||||
|
||||
// Client constructor: connects to host:port using QUIC. ALPN must
|
||||
|
|
@ -126,18 +197,22 @@ namespace Crafter {
|
|||
ClientQUIC(std::string host, std::uint16_t port, std::string alpn,
|
||||
QUICClientCredentials creds = {});
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
// Server-side constructor used by ListenerQUIC for accepted peers.
|
||||
// Takes ownership of an already-accepted msquic connection handle
|
||||
// and the server configuration handle. Not intended for direct use.
|
||||
ClientQUIC(HQUIC connectionHandle, HQUIC serverConfiguration, std::string alpn);
|
||||
#endif
|
||||
|
||||
~ClientQUIC();
|
||||
ClientQUIC(const ClientQUIC&) = delete;
|
||||
ClientQUIC(ClientQUIC&&) noexcept;
|
||||
|
||||
// Open a new bidirectional stream initiated by this side.
|
||||
// Open a new stream initiated by this side. Defaults to bidirectional;
|
||||
// pass unidirectional=true to open a one-way send stream (used for
|
||||
// HTTP/3's control + QPACK encoder/decoder streams).
|
||||
// Blocks until the stream is started; throws on failure.
|
||||
QUICStream OpenStream();
|
||||
QUICStream OpenStream(bool unidirectional = false);
|
||||
|
||||
// Send a datagram. Best-effort: may be silently dropped under loss
|
||||
// or congestion. Size must fit within the path MTU (msquic surfaces
|
||||
|
|
@ -153,15 +228,19 @@ namespace Crafter {
|
|||
// msquic worker; copy/queue and return promptly.
|
||||
void OnDatagram(std::function<void(std::vector<char>)> callback);
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
// Block the caller until the next datagram arrives; returns it.
|
||||
// Throws QUICClosedException if the connection closes first.
|
||||
std::vector<char> RecieveDatagramSync();
|
||||
#endif
|
||||
|
||||
// Cleanly shut down the connection.
|
||||
void Stop();
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
// Underlying handle for advanced use (parameter queries, etc.).
|
||||
HQUIC GetHandle() const;
|
||||
#endif
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ License along with this library; if not, write to the Free Software
|
|||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
module;
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
#include <stdio.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/socket.h>
|
||||
|
|
@ -31,9 +32,11 @@ module;
|
|||
#include <netdb.h>
|
||||
#include <strings.h>
|
||||
#include <cerrno>
|
||||
#endif
|
||||
export module Crafter.Network:ClientTCP;
|
||||
import std;
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
namespace Crafter {
|
||||
export class SocketClosedException : public std::exception {
|
||||
public:
|
||||
|
|
@ -69,3 +72,4 @@ namespace Crafter {
|
|||
sockaddr_in serv_addr;
|
||||
};
|
||||
}
|
||||
#endif
|
||||
|
|
@ -22,63 +22,98 @@ export module Crafter.Network:HTTP;
|
|||
import std;
|
||||
|
||||
namespace Crafter {
|
||||
// HTTP/3 request as carried over a QUIC bidirectional stream. The four
|
||||
// pseudo-headers (method/scheme/authority/path) are split out as named
|
||||
// fields rather than living in the headers map, because RFC 9114 forbids
|
||||
// them from appearing in the regular header section and this shape makes
|
||||
// route dispatch and request construction cleaner. `headers` keys are
|
||||
// expected lowercase; HTTP/3 forbids uppercase characters in field names.
|
||||
export struct HTTPRequest {
|
||||
std::string method;
|
||||
std::string scheme = "https";
|
||||
std::string authority;
|
||||
std::string path = "/";
|
||||
std::unordered_map<std::string, std::string> headers;
|
||||
std::string body;
|
||||
};
|
||||
|
||||
// HTTP/3 response. `status` is the numeric three-digit code as a string
|
||||
// (e.g. "200") — HTTP/3 has no reason phrase. `headers` keys are expected
|
||||
// lowercase.
|
||||
export struct HTTPResponse {
|
||||
std::string status;
|
||||
std::string status = "200";
|
||||
std::unordered_map<std::string, std::string> headers;
|
||||
std::string body;
|
||||
};
|
||||
|
||||
export constexpr std::string CreateResponseHTTP(std::string status) {
|
||||
return std::format("HTTP/1.1 {}\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n", status);
|
||||
export inline HTTPRequest CreateRequestHTTP(std::string method, std::string path, std::string authority) {
|
||||
HTTPRequest r;
|
||||
r.method = std::move(method);
|
||||
r.path = std::move(path);
|
||||
r.authority = std::move(authority);
|
||||
return r;
|
||||
}
|
||||
|
||||
export constexpr std::string CreateResponseHTTP(std::string status, std::unordered_map<std::string, std::string> headers) {
|
||||
std::string headersString;
|
||||
for (auto const& [key, val] : headers) {
|
||||
headersString+=std::format("{}: {}\r\n", key, val);
|
||||
}
|
||||
return std::format("HTTP/1.1 {}\r\nConnection: keep-alive\r\nContent-Length: 0\r\n{}\r\n", status, headersString);
|
||||
export inline HTTPRequest CreateRequestHTTP(std::string method, std::string path, std::string authority,
|
||||
std::unordered_map<std::string, std::string> headers) {
|
||||
HTTPRequest r;
|
||||
r.method = std::move(method);
|
||||
r.path = std::move(path);
|
||||
r.authority = std::move(authority);
|
||||
r.headers = std::move(headers);
|
||||
return r;
|
||||
}
|
||||
|
||||
export constexpr std::string CreateResponseHTTP(std::string status, std::string body) {
|
||||
return std::format("HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{}", status, body.size(), body);
|
||||
export inline HTTPRequest CreateRequestHTTP(std::string method, std::string path, std::string authority,
|
||||
std::string body) {
|
||||
HTTPRequest r;
|
||||
r.method = std::move(method);
|
||||
r.path = std::move(path);
|
||||
r.authority = std::move(authority);
|
||||
r.body = std::move(body);
|
||||
return r;
|
||||
}
|
||||
|
||||
export constexpr std::string CreateResponseHTTP(std::string status, std::unordered_map<std::string, std::string> headers, std::string body) {
|
||||
std::string headersString;
|
||||
for (auto const& [key, val] : headers) {
|
||||
headersString+=std::format("{}: {}\r\n", key, val);
|
||||
}
|
||||
return std::format("HTTP/1.1 {}\r\nConnection: keep-alive\r\nContent-Length: {}\r\n{}\r\n{}", status, body.size(), headersString, body);
|
||||
export inline HTTPRequest CreateRequestHTTP(std::string method, std::string path, std::string authority,
|
||||
std::unordered_map<std::string, std::string> headers,
|
||||
std::string body) {
|
||||
HTTPRequest r;
|
||||
r.method = std::move(method);
|
||||
r.path = std::move(path);
|
||||
r.authority = std::move(authority);
|
||||
r.headers = std::move(headers);
|
||||
r.body = std::move(body);
|
||||
return r;
|
||||
}
|
||||
|
||||
export constexpr std::string CreateRequestHTTP(std::string method, std::string route, std::string host) {
|
||||
return std::format("{} {} HTTP/1.1\r\nConnection: keep-alive\r\nAccept-Encoding: identity\r\nContent-Length: 0\r\nHost: {}\r\n\r\n", method, route, host);
|
||||
}
|
||||
export inline HTTPResponse CreateResponseHTTP(std::string status) {
|
||||
HTTPResponse r;
|
||||
r.status = std::move(status);
|
||||
return r;
|
||||
}
|
||||
|
||||
export constexpr std::string CreateRequestHTTP(std::string method, std::string route, std::string host, std::unordered_map<std::string, std::string> headers) {
|
||||
std::string headersString;
|
||||
for (auto const& [key, val] : headers) {
|
||||
headersString+=std::format("{}: {}\r\n", key, val);
|
||||
}
|
||||
return std::format("{} {} HTTP/1.1\r\nConnection: keep-alive\r\nContent-Length: 0\r\nAccept-Encoding: identity\r\nHost: {}\r\n{}\r\n", method, route, host, headersString);
|
||||
}
|
||||
export inline HTTPResponse CreateResponseHTTP(std::string status,
|
||||
std::unordered_map<std::string, std::string> headers) {
|
||||
HTTPResponse r;
|
||||
r.status = std::move(status);
|
||||
r.headers = std::move(headers);
|
||||
return r;
|
||||
}
|
||||
|
||||
export constexpr std::string CreateRequestHTTP(std::string method, std::string route, std::string host, std::string body) {
|
||||
return std::format("{} {} HTTP/1.1\r\nContent-Length: {}\r\nConnection: keep-alive\r\nAccept-Encoding: identity\r\nHost: {}\r\n\r\n{}", method, route, body.size(), host, body);
|
||||
}
|
||||
export inline HTTPResponse CreateResponseHTTP(std::string status, std::string body) {
|
||||
HTTPResponse r;
|
||||
r.status = std::move(status);
|
||||
r.body = std::move(body);
|
||||
return r;
|
||||
}
|
||||
|
||||
export constexpr std::string CreateRequestHTTP(std::string method, std::string route, std::string host, std::unordered_map<std::string, std::string> headers, std::string body) {
|
||||
std::string headersString;
|
||||
for (auto const& [key, val] : headers) {
|
||||
headersString+=std::format("{}: {}\r\n", key, val);
|
||||
}
|
||||
return std::format("{} {} HTTP/1.1\r\nConnection: keep-alive\r\nContent-Length: {}\r\nHost: {}\r\nAccept-Encoding: identity\r\n{}\r\n{}", method, route, body.size(), host, headersString, body);
|
||||
}
|
||||
export inline HTTPResponse CreateResponseHTTP(std::string status,
|
||||
std::unordered_map<std::string, std::string> headers,
|
||||
std::string body) {
|
||||
HTTPResponse r;
|
||||
r.status = std::move(status);
|
||||
r.headers = std::move(headers);
|
||||
r.body = std::move(body);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
651
interfaces/Crafter.Network-HTTP3.cppm
Normal file
651
interfaces/Crafter.Network-HTTP3.cppm
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
/*
|
||||
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
|
||||
*/
|
||||
|
||||
// HTTP/3 wire-format helpers. This partition is intentionally NOT re-exported
|
||||
// from Crafter.Network — it's an internal building block shared between the
|
||||
// ClientHTTP and ListenerHTTP implementation files.
|
||||
//
|
||||
// Scope:
|
||||
// - QUIC variable-length integers (RFC 9000 §16)
|
||||
// - HTTP/3 frame type/length codec (RFC 9114 §7)
|
||||
// - QPACK field-section encode/decode (RFC 9204) limited to the static
|
||||
// table + literal representations. No dynamic table, no Huffman. This
|
||||
// suffices because both peers in this library are this same library;
|
||||
// interoperability with browsers/curl over h3 would additionally require
|
||||
// a Huffman codec which is out of scope here.
|
||||
|
||||
export module Crafter.Network:HTTP3;
|
||||
import std;
|
||||
|
||||
namespace Crafter::HTTP3 {
|
||||
// ---------------- ALPN ----------------
|
||||
// RFC 9114 §3.1 — final h3 ALPN identifier.
|
||||
export inline constexpr std::string_view kAlpn = "h3";
|
||||
|
||||
// ---------------- Frame types (RFC 9114 §7.2) ----------------
|
||||
export inline constexpr std::uint64_t kFrameData = 0x00;
|
||||
export inline constexpr std::uint64_t kFrameHeaders = 0x01;
|
||||
export inline constexpr std::uint64_t kFrameSettings = 0x04;
|
||||
// WebTransport bidirectional stream frame type (draft-ietf-webtrans-http3).
|
||||
// Distinct from normal HTTP/3 frames — its body is unbounded (runs to FIN)
|
||||
// rather than length-prefixed, and the first bytes of the body are the
|
||||
// session id varint.
|
||||
export inline constexpr std::uint64_t kFrameWtStream = 0x41;
|
||||
|
||||
// ---------------- Unidirectional stream types (RFC 9114 §6.2) ----------------
|
||||
export inline constexpr std::uint64_t kStreamControl = 0x00;
|
||||
export inline constexpr std::uint64_t kStreamQpackEnc = 0x02;
|
||||
export inline constexpr std::uint64_t kStreamQpackDec = 0x03;
|
||||
// WebTransport unidirectional stream type (draft-ietf-webtrans-http3).
|
||||
// After this varint comes a session id varint, then opaque payload to FIN.
|
||||
export inline constexpr std::uint64_t kStreamWt = 0x54;
|
||||
|
||||
// ---------------- SETTINGS parameter identifiers ----------------
|
||||
// Required to negotiate WebTransport over HTTP/3 + HTTP/3 datagrams.
|
||||
export inline constexpr std::uint64_t kSettingQpackMaxTableCapacity = 0x01; // RFC 9204
|
||||
export inline constexpr std::uint64_t kSettingQpackBlockedStreams = 0x07; // RFC 9204
|
||||
export inline constexpr std::uint64_t kSettingEnableConnectProtocol = 0x08; // RFC 9220
|
||||
export inline constexpr std::uint64_t kSettingH3Datagram = 0x33; // RFC 9297
|
||||
// Legacy identifiers from older WebTransport / H3-DATAGRAM drafts. Chrome
|
||||
// (as of M120-ish) advertises and looks for the draft-02 / draft-04 ids
|
||||
// alongside the RFC ones; if we only send the modern ids it decides we
|
||||
// don't support WebTransport and aborts with ERR_METHOD_NOT_SUPPORTED.
|
||||
export inline constexpr std::uint64_t kSettingH3DatagramDraft04 = 0xffd277; // draft-ietf-masque-h3-datagram-04
|
||||
export inline constexpr std::uint64_t kSettingEnableWebTransport = 0x2b603742; // draft-02 boolean
|
||||
export inline constexpr std::uint64_t kSettingWtMaxSessions = 0xc671706a; // draft-ietf-webtrans-http3 (-07+)
|
||||
|
||||
// ---------------- Errors ----------------
|
||||
export class HTTP3ProtocolError : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// ---------------- QUIC varint (RFC 9000 §16) ----------------
|
||||
// Encodes value into the smallest of {1, 2, 4, 8}-byte forms.
|
||||
export inline void EncodeVarint(std::uint64_t value, std::vector<std::uint8_t>& out) {
|
||||
if (value < (1ULL << 6)) {
|
||||
out.push_back(static_cast<std::uint8_t>(value));
|
||||
} else if (value < (1ULL << 14)) {
|
||||
out.push_back(static_cast<std::uint8_t>(0x40 | (value >> 8)));
|
||||
out.push_back(static_cast<std::uint8_t>(value & 0xFF));
|
||||
} else if (value < (1ULL << 30)) {
|
||||
out.push_back(static_cast<std::uint8_t>(0x80 | ((value >> 24) & 0x3F)));
|
||||
out.push_back(static_cast<std::uint8_t>((value >> 16) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((value >> 8) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>(value & 0xFF));
|
||||
} else if (value < (1ULL << 62)) {
|
||||
out.push_back(static_cast<std::uint8_t>(0xC0 | ((value >> 56) & 0x3F)));
|
||||
out.push_back(static_cast<std::uint8_t>((value >> 48) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((value >> 40) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((value >> 32) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((value >> 24) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((value >> 16) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((value >> 8) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>(value & 0xFF));
|
||||
} else {
|
||||
throw HTTP3ProtocolError("varint value exceeds 2^62-1");
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true on success. On false, no consumed/value mutation observed.
|
||||
export inline bool DecodeVarint(const std::uint8_t* data, std::size_t available,
|
||||
std::uint64_t& value, std::size_t& consumed) {
|
||||
if (available == 0) return false;
|
||||
std::uint8_t first = data[0];
|
||||
std::size_t len = std::size_t{1} << (first >> 6);
|
||||
if (available < len) return false;
|
||||
std::uint64_t v = first & 0x3F;
|
||||
for (std::size_t i = 1; i < len; ++i) {
|
||||
v = (v << 8) | data[i];
|
||||
}
|
||||
value = v;
|
||||
consumed = len;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------- QPACK / HPACK-style integer (RFC 7541 §5.1) ----------------
|
||||
// Different beast from QUIC varint. N-bit prefix integer used inside QPACK
|
||||
// representations; the high (8-N) bits of the first byte carry pattern flags.
|
||||
export inline void EncodeQpackInt(std::vector<std::uint8_t>& out, std::uint8_t topBits,
|
||||
int N, std::uint64_t value) {
|
||||
std::uint8_t mask = static_cast<std::uint8_t>((1U << N) - 1);
|
||||
if (value < mask) {
|
||||
out.push_back(static_cast<std::uint8_t>(topBits | value));
|
||||
return;
|
||||
}
|
||||
out.push_back(static_cast<std::uint8_t>(topBits | mask));
|
||||
value -= mask;
|
||||
while (value >= 128) {
|
||||
out.push_back(static_cast<std::uint8_t>((value & 0x7F) | 0x80));
|
||||
value >>= 7;
|
||||
}
|
||||
out.push_back(static_cast<std::uint8_t>(value));
|
||||
}
|
||||
|
||||
// Decode N-bit-prefix integer. data[0] holds prefix flags; the low N bits
|
||||
// contribute to the value (continuation bytes follow if low N bits == mask).
|
||||
export inline bool DecodeQpackInt(const std::uint8_t* data, std::size_t available,
|
||||
int N, std::uint64_t& value, std::size_t& consumed) {
|
||||
if (available == 0) return false;
|
||||
std::uint8_t mask = static_cast<std::uint8_t>((1U << N) - 1);
|
||||
std::uint8_t first = data[0] & mask;
|
||||
if (first < mask) {
|
||||
value = first;
|
||||
consumed = 1;
|
||||
return true;
|
||||
}
|
||||
std::uint64_t v = mask;
|
||||
int shift = 0;
|
||||
std::size_t i = 1;
|
||||
while (true) {
|
||||
if (i >= available) return false;
|
||||
std::uint8_t b = data[i++];
|
||||
v += static_cast<std::uint64_t>(b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) break;
|
||||
shift += 7;
|
||||
if (shift > 63) return false;
|
||||
}
|
||||
value = v;
|
||||
consumed = i;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------- Huffman codec (RFC 7541 Appendix B) ----------------
|
||||
// Decode-only. Real h3 peers (browsers, curl, cloudflare, etc.) Huffman-
|
||||
// encode header values by default, so without this any external interop
|
||||
// collapses on the first response. We don't emit Huffman ourselves —
|
||||
// peers MUST accept H=0 literal per the spec, so encoding doesn't add
|
||||
// interop value, only wire compactness. The 256-entry table plus a
|
||||
// straightforward bit-walking decoder is the smallest viable form.
|
||||
struct HuffmanCode {
|
||||
std::uint32_t code;
|
||||
std::uint8_t length;
|
||||
};
|
||||
inline constexpr std::array<HuffmanCode, 256> kHuffmanTable = {{
|
||||
/* 0 */ {0x1ff8, 13}, /* 1 */ {0x7fffd8, 23}, /* 2 */ {0xfffffe2, 28},
|
||||
/* 3 */ {0xfffffe3, 28}, /* 4 */ {0xfffffe4, 28}, /* 5 */ {0xfffffe5, 28},
|
||||
/* 6 */ {0xfffffe6, 28}, /* 7 */ {0xfffffe7, 28}, /* 8 */ {0xfffffe8, 28},
|
||||
/* 9 */ {0xffffea, 24}, /* 10 */ {0x3ffffffc,30}, /* 11 */ {0xfffffe9, 28},
|
||||
/* 12 */ {0xfffffea, 28}, /* 13 */ {0x3ffffffd,30}, /* 14 */ {0xfffffeb, 28},
|
||||
/* 15 */ {0xfffffec, 28}, /* 16 */ {0xfffffed, 28}, /* 17 */ {0xfffffee, 28},
|
||||
/* 18 */ {0xfffffef, 28}, /* 19 */ {0xffffff0, 28}, /* 20 */ {0xffffff1, 28},
|
||||
/* 21 */ {0xffffff2, 28}, /* 22 */ {0x3ffffffe,30}, /* 23 */ {0xffffff3, 28},
|
||||
/* 24 */ {0xffffff4, 28}, /* 25 */ {0xffffff5, 28}, /* 26 */ {0xffffff6, 28},
|
||||
/* 27 */ {0xffffff7, 28}, /* 28 */ {0xffffff8, 28}, /* 29 */ {0xffffff9, 28},
|
||||
/* 30 */ {0xffffffa, 28}, /* 31 */ {0xffffffb, 28}, /* 32 */ {0x14, 6},
|
||||
/* 33 */ {0x3f8, 10}, /* 34 */ {0x3f9, 10}, /* 35 */ {0xffa, 12},
|
||||
/* 36 */ {0x1ff9, 13}, /* 37 */ {0x15, 6}, /* 38 */ {0xf8, 8},
|
||||
/* 39 */ {0x7fa, 11}, /* 40 */ {0x3fa, 10}, /* 41 */ {0x3fb, 10},
|
||||
/* 42 */ {0xf9, 8}, /* 43 */ {0x7fb, 11}, /* 44 */ {0xfa, 8},
|
||||
/* 45 */ {0x16, 6}, /* 46 */ {0x17, 6}, /* 47 */ {0x18, 6},
|
||||
/* 48 */ {0x0, 5}, /* 49 */ {0x1, 5}, /* 50 */ {0x2, 5},
|
||||
/* 51 */ {0x19, 6}, /* 52 */ {0x1a, 6}, /* 53 */ {0x1b, 6},
|
||||
/* 54 */ {0x1c, 6}, /* 55 */ {0x1d, 6}, /* 56 */ {0x1e, 6},
|
||||
/* 57 */ {0x1f, 6}, /* 58 */ {0x5c, 7}, /* 59 */ {0xfb, 8},
|
||||
/* 60 */ {0x7ffc, 15}, /* 61 */ {0x20, 6}, /* 62 */ {0xffb, 12},
|
||||
/* 63 */ {0x3fc, 10}, /* 64 */ {0x1ffa, 13}, /* 65 */ {0x21, 6},
|
||||
/* 66 */ {0x5d, 7}, /* 67 */ {0x5e, 7}, /* 68 */ {0x5f, 7},
|
||||
/* 69 */ {0x60, 7}, /* 70 */ {0x61, 7}, /* 71 */ {0x62, 7},
|
||||
/* 72 */ {0x63, 7}, /* 73 */ {0x64, 7}, /* 74 */ {0x65, 7},
|
||||
/* 75 */ {0x66, 7}, /* 76 */ {0x67, 7}, /* 77 */ {0x68, 7},
|
||||
/* 78 */ {0x69, 7}, /* 79 */ {0x6a, 7}, /* 80 */ {0x6b, 7},
|
||||
/* 81 */ {0x6c, 7}, /* 82 */ {0x6d, 7}, /* 83 */ {0x6e, 7},
|
||||
/* 84 */ {0x6f, 7}, /* 85 */ {0x70, 7}, /* 86 */ {0x71, 7},
|
||||
/* 87 */ {0x72, 7}, /* 88 */ {0xfc, 8}, /* 89 */ {0x73, 7},
|
||||
/* 90 */ {0xfd, 8}, /* 91 */ {0x1ffb, 13}, /* 92 */ {0x7fff0, 19},
|
||||
/* 93 */ {0x1ffc, 13}, /* 94 */ {0x3ffc, 14}, /* 95 */ {0x22, 6},
|
||||
/* 96 */ {0x7ffd, 15}, /* 97 */ {0x3, 5}, /* 98 */ {0x23, 6},
|
||||
/* 99 */ {0x4, 5}, /*100 */ {0x24, 6}, /*101 */ {0x5, 5},
|
||||
/*102 */ {0x25, 6}, /*103 */ {0x26, 6}, /*104 */ {0x27, 6},
|
||||
/*105 */ {0x6, 5}, /*106 */ {0x74, 7}, /*107 */ {0x75, 7},
|
||||
/*108 */ {0x28, 6}, /*109 */ {0x29, 6}, /*110 */ {0x2a, 6},
|
||||
/*111 */ {0x7, 5}, /*112 */ {0x2b, 6}, /*113 */ {0x76, 7},
|
||||
/*114 */ {0x2c, 6}, /*115 */ {0x8, 5}, /*116 */ {0x9, 5},
|
||||
/*117 */ {0x2d, 6}, /*118 */ {0x77, 7}, /*119 */ {0x78, 7},
|
||||
/*120 */ {0x79, 7}, /*121 */ {0x7a, 7}, /*122 */ {0x7b, 7},
|
||||
/*123 */ {0x7ffe, 15}, /*124 */ {0x7fc, 11}, /*125 */ {0x3ffd, 14},
|
||||
/*126 */ {0x1ffd, 13}, /*127 */ {0xffffffc, 28}, /*128 */ {0xfffe6, 20},
|
||||
/*129 */ {0x3fffd2, 22}, /*130 */ {0xfffe7, 20}, /*131 */ {0xfffe8, 20},
|
||||
/*132 */ {0x3fffd3, 22}, /*133 */ {0x3fffd4, 22}, /*134 */ {0x3fffd5, 22},
|
||||
/*135 */ {0x7fffd9, 23}, /*136 */ {0x3fffd6, 22}, /*137 */ {0x7fffda, 23},
|
||||
/*138 */ {0x7fffdb, 23}, /*139 */ {0x7fffdc, 23}, /*140 */ {0x7fffdd, 23},
|
||||
/*141 */ {0x7fffde, 23}, /*142 */ {0xffffeb, 24}, /*143 */ {0x7fffdf, 23},
|
||||
/*144 */ {0xffffec, 24}, /*145 */ {0xffffed, 24}, /*146 */ {0x3fffd7, 22},
|
||||
/*147 */ {0x7fffe0, 23}, /*148 */ {0xffffee, 24}, /*149 */ {0x7fffe1, 23},
|
||||
/*150 */ {0x7fffe2, 23}, /*151 */ {0x7fffe3, 23}, /*152 */ {0x7fffe4, 23},
|
||||
/*153 */ {0x1fffdc, 21}, /*154 */ {0x3fffd8, 22}, /*155 */ {0x7fffe5, 23},
|
||||
/*156 */ {0x3fffd9, 22}, /*157 */ {0x7fffe6, 23}, /*158 */ {0x7fffe7, 23},
|
||||
/*159 */ {0xffffef, 24}, /*160 */ {0x3fffda, 22}, /*161 */ {0x1fffdd, 21},
|
||||
/*162 */ {0xfffe9, 20}, /*163 */ {0x3fffdb, 22}, /*164 */ {0x3fffdc, 22},
|
||||
/*165 */ {0x7fffe8, 23}, /*166 */ {0x7fffe9, 23}, /*167 */ {0x1fffde, 21},
|
||||
/*168 */ {0x7fffea, 23}, /*169 */ {0x3fffdd, 22}, /*170 */ {0x3fffde, 22},
|
||||
/*171 */ {0xfffff0, 24}, /*172 */ {0x1fffdf, 21}, /*173 */ {0x3fffdf, 22},
|
||||
/*174 */ {0x7fffeb, 23}, /*175 */ {0x7fffec, 23}, /*176 */ {0x1fffe0, 21},
|
||||
/*177 */ {0x1fffe1, 21}, /*178 */ {0x3fffe0, 22}, /*179 */ {0x1fffe2, 21},
|
||||
/*180 */ {0x7fffed, 23}, /*181 */ {0x3fffe1, 22}, /*182 */ {0x7fffee, 23},
|
||||
/*183 */ {0x7fffef, 23}, /*184 */ {0xfffea, 20}, /*185 */ {0x3fffe2, 22},
|
||||
/*186 */ {0x3fffe3, 22}, /*187 */ {0x3fffe4, 22}, /*188 */ {0x7ffff0, 23},
|
||||
/*189 */ {0x3fffe5, 22}, /*190 */ {0x3fffe6, 22}, /*191 */ {0x7ffff1, 23},
|
||||
/*192 */ {0x3ffffe0,26}, /*193 */ {0x3ffffe1, 26}, /*194 */ {0xfffeb, 20},
|
||||
/*195 */ {0x7fff1, 19}, /*196 */ {0x3fffe7, 22}, /*197 */ {0x7ffff2, 23},
|
||||
/*198 */ {0x3fffe8, 22}, /*199 */ {0x1ffffec, 25}, /*200 */ {0x3ffffe2, 26},
|
||||
/*201 */ {0x3ffffe3,26}, /*202 */ {0x3ffffe4, 26}, /*203 */ {0x7ffffde, 27},
|
||||
/*204 */ {0x7ffffdf,27}, /*205 */ {0x3ffffe5, 26}, /*206 */ {0xfffff1, 24},
|
||||
/*207 */ {0x1ffffed,25}, /*208 */ {0x7fff2, 19}, /*209 */ {0x1fffe3, 21},
|
||||
/*210 */ {0x3ffffe6,26}, /*211 */ {0x7ffffe0, 27}, /*212 */ {0x7ffffe1, 27},
|
||||
/*213 */ {0x3ffffe7,26}, /*214 */ {0x7ffffe2, 27}, /*215 */ {0xfffff2, 24},
|
||||
/*216 */ {0x1fffe4, 21}, /*217 */ {0x1fffe5, 21}, /*218 */ {0x3ffffe8, 26},
|
||||
/*219 */ {0x3ffffe9,26}, /*220 */ {0xffffffd,28}, /*221 */ {0x7ffffe3, 27},
|
||||
/*222 */ {0x7ffffe4,27}, /*223 */ {0x7ffffe5, 27}, /*224 */ {0xfffec, 20},
|
||||
/*225 */ {0xfffff3, 24}, /*226 */ {0xfffed, 20}, /*227 */ {0x1fffe6, 21},
|
||||
/*228 */ {0x3fffe9, 22}, /*229 */ {0x1fffe7, 21}, /*230 */ {0x1fffe8, 21},
|
||||
/*231 */ {0x7ffff3, 23}, /*232 */ {0x3fffea, 22}, /*233 */ {0x3fffeb, 22},
|
||||
/*234 */ {0x1ffffee,25}, /*235 */ {0x1ffffef, 25}, /*236 */ {0xfffff4, 24},
|
||||
/*237 */ {0xfffff5, 24}, /*238 */ {0x3ffffea, 26}, /*239 */ {0x7ffff4, 23},
|
||||
/*240 */ {0x3ffffeb,26}, /*241 */ {0x7ffffe6, 27}, /*242 */ {0x3ffffec, 26},
|
||||
/*243 */ {0x3ffffed,26}, /*244 */ {0x7ffffe7, 27}, /*245 */ {0x7ffffe8, 27},
|
||||
/*246 */ {0x7ffffe9,27}, /*247 */ {0x7ffffea, 27}, /*248 */ {0x7ffffeb, 27},
|
||||
/*249 */ {0xffffffe,28}, /*250 */ {0x7ffffec, 27}, /*251 */ {0x7ffffed, 27},
|
||||
/*252 */ {0x7ffffee,27}, /*253 */ {0x7ffffef, 27}, /*254 */ {0x7fffff0, 27},
|
||||
/*255 */ {0x3ffffee,26},
|
||||
}};
|
||||
|
||||
export inline std::string DecodeHuffman(const std::uint8_t* in, std::size_t inLen) {
|
||||
// Bit-walking decoder. Refill a 64-bit register from the input byte
|
||||
// stream, then for each output symbol scan the 256-entry table for
|
||||
// the unique code that matches the top `bits` of the register at
|
||||
// some length L in [5, 30]. Linear scan is acceptable for the
|
||||
// header-section sizes seen in HTTP/3 traffic; the inner loop is
|
||||
// hot for ~100s of bytes per request.
|
||||
std::string out;
|
||||
std::uint64_t reg = 0;
|
||||
int bits = 0;
|
||||
std::size_t pos = 0;
|
||||
while (true) {
|
||||
while (bits <= 56 && pos < inLen) {
|
||||
reg = (reg << 8) | in[pos++];
|
||||
bits += 8;
|
||||
}
|
||||
if (bits == 0) return out;
|
||||
|
||||
bool matched = false;
|
||||
const int maxL = bits < 30 ? bits : 30;
|
||||
for (int L = 5; L <= maxL; ++L) {
|
||||
std::uint32_t want = static_cast<std::uint32_t>(
|
||||
(reg >> (bits - L)) & ((std::uint64_t{1} << L) - 1));
|
||||
for (std::size_t s = 0; s < 256; ++s) {
|
||||
if (kHuffmanTable[s].length == L && kHuffmanTable[s].code == want) {
|
||||
out.push_back(static_cast<char>(s));
|
||||
bits -= L;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
if (!matched) {
|
||||
// Tail must be ≤ 7 bits and all 1s — that's the EOS-prefix
|
||||
// padding RFC 7541 §5.2 mandates. Anything else is malformed.
|
||||
if (bits <= 7) {
|
||||
std::uint64_t mask = (std::uint64_t{1} << bits) - 1;
|
||||
if ((reg & mask) == mask) return out;
|
||||
}
|
||||
throw HTTP3ProtocolError("Huffman: invalid encoding");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- QPACK static table (RFC 9204 Appendix A, subset) ----------------
|
||||
// We embed only the entries we either emit (encode) or might need to look
|
||||
// up by index (decode peers using indexed/literal-with-name-ref). The
|
||||
// subset covers all pseudo-headers and a few common content-type/status
|
||||
// values — enough for self-interop. If the peer references an index
|
||||
// outside this table we throw HTTP3ProtocolError.
|
||||
struct StaticEntry {
|
||||
std::string_view name;
|
||||
std::string_view value; // empty if no canonical value (name-only entry)
|
||||
};
|
||||
|
||||
inline constexpr std::array<StaticEntry, 99> kStaticTable = {{
|
||||
{":authority", ""}, // 0
|
||||
{":path", "/"}, // 1
|
||||
{"age", "0"}, // 2
|
||||
{"content-disposition", ""}, // 3
|
||||
{"content-length", "0"}, // 4
|
||||
{"cookie", ""}, // 5
|
||||
{"date", ""}, // 6
|
||||
{"etag", ""}, // 7
|
||||
{"if-modified-since", ""}, // 8
|
||||
{"if-none-match", ""}, // 9
|
||||
{"last-modified", ""}, // 10
|
||||
{"link", ""}, // 11
|
||||
{"location", ""}, // 12
|
||||
{"referer", ""}, // 13
|
||||
{"set-cookie", ""}, // 14
|
||||
{":method", "CONNECT"}, // 15
|
||||
{":method", "DELETE"}, // 16
|
||||
{":method", "GET"}, // 17
|
||||
{":method", "HEAD"}, // 18
|
||||
{":method", "OPTIONS"}, // 19
|
||||
{":method", "POST"}, // 20
|
||||
{":method", "PUT"}, // 21
|
||||
{":scheme", "http"}, // 22
|
||||
{":scheme", "https"}, // 23
|
||||
{":status", "103"}, // 24
|
||||
{":status", "200"}, // 25
|
||||
{":status", "304"}, // 26
|
||||
{":status", "404"}, // 27
|
||||
{":status", "503"}, // 28
|
||||
{"accept", "*/*"}, // 29
|
||||
{"accept", "application/dns-message"}, // 30
|
||||
{"accept-encoding", "gzip, deflate, br"}, // 31
|
||||
{"accept-ranges", "bytes"}, // 32
|
||||
{"access-control-allow-headers", "cache-control"}, // 33
|
||||
{"access-control-allow-headers", "content-type"}, // 34
|
||||
{"access-control-allow-origin", "*"}, // 35
|
||||
{"cache-control", "max-age=0"}, // 36
|
||||
{"cache-control", "max-age=2592000"}, // 37
|
||||
{"cache-control", "max-age=604800"}, // 38
|
||||
{"cache-control", "no-cache"}, // 39
|
||||
{"cache-control", "no-store"}, // 40
|
||||
{"cache-control", "public, max-age=31536000"}, // 41
|
||||
{"content-encoding", "br"}, // 42
|
||||
{"content-encoding", "gzip"}, // 43
|
||||
{"content-type", "application/dns-message"}, // 44
|
||||
{"content-type", "application/javascript"}, // 45
|
||||
{"content-type", "application/json"}, // 46
|
||||
{"content-type", "application/x-www-form-urlencoded"}, // 47
|
||||
{"content-type", "image/gif"}, // 48
|
||||
{"content-type", "image/jpeg"}, // 49
|
||||
{"content-type", "image/png"}, // 50
|
||||
{"content-type", "text/css"}, // 51
|
||||
{"content-type", "text/html; charset=utf-8"}, // 52
|
||||
{"content-type", "text/plain"}, // 53
|
||||
{"content-type", "text/plain;charset=utf-8"}, // 54
|
||||
{"range", "bytes=0-"}, // 55
|
||||
{"strict-transport-security", "max-age=31536000"}, // 56
|
||||
{"strict-transport-security", "max-age=31536000; includesubdomains"}, // 57
|
||||
{"strict-transport-security", "max-age=31536000; includesubdomains; preload"}, // 58
|
||||
{"vary", "accept-encoding"}, // 59
|
||||
{"vary", "origin"}, // 60
|
||||
{"x-content-type-options", "nosniff"}, // 61
|
||||
{"x-xss-protection", "1; mode=block"}, // 62
|
||||
{":status", "100"}, // 63
|
||||
{":status", "204"}, // 64
|
||||
{":status", "206"}, // 65
|
||||
{":status", "302"}, // 66
|
||||
{":status", "400"}, // 67
|
||||
{":status", "403"}, // 68
|
||||
{":status", "421"}, // 69
|
||||
{":status", "425"}, // 70
|
||||
{":status", "500"}, // 71
|
||||
{"accept-language", ""}, // 72
|
||||
{"access-control-allow-credentials", "FALSE"}, // 73
|
||||
{"access-control-allow-credentials", "TRUE"}, // 74
|
||||
{"access-control-allow-headers", "*"}, // 75
|
||||
{"access-control-allow-methods", "get"}, // 76
|
||||
{"access-control-allow-methods", "get, post, options"}, // 77
|
||||
{"access-control-allow-methods", "options"}, // 78
|
||||
{"access-control-expose-headers", "content-length"}, // 79
|
||||
{"access-control-request-headers", "content-type"}, // 80
|
||||
{"access-control-request-method", "get"}, // 81
|
||||
{"access-control-request-method", "post"}, // 82
|
||||
{"alt-svc", "clear"}, // 83
|
||||
{"authorization", ""}, // 84
|
||||
{"content-security-policy", "script-src 'none'; object-src 'none'; base-uri 'none'"}, // 85
|
||||
{"early-data", "1"}, // 86
|
||||
{"expect-ct", ""}, // 87
|
||||
{"forwarded", ""}, // 88
|
||||
{"if-range", ""}, // 89
|
||||
{"origin", ""}, // 90
|
||||
{"purpose", "prefetch"}, // 91
|
||||
{"server", ""}, // 92
|
||||
{"timing-allow-origin", "*"}, // 93
|
||||
{"upgrade-insecure-requests", "1"}, // 94
|
||||
{"user-agent", ""}, // 95
|
||||
{"x-forwarded-for", ""}, // 96
|
||||
{"x-frame-options", "deny"}, // 97
|
||||
{"x-frame-options", "sameorigin"}, // 98
|
||||
}};
|
||||
|
||||
// Lookup a (name, value) pair against the static table; returns -1 if not
|
||||
// present. Linear scan is fine here: ~100 entries, called per header.
|
||||
inline int StaticTableExactLookup(std::string_view name, std::string_view value) {
|
||||
for (std::size_t i = 0; i < kStaticTable.size(); ++i) {
|
||||
if (kStaticTable[i].name == name && kStaticTable[i].value == value) {
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
// Lookup name-only; returns the lowest matching index or -1.
|
||||
inline int StaticTableNameLookup(std::string_view name) {
|
||||
for (std::size_t i = 0; i < kStaticTable.size(); ++i) {
|
||||
if (kStaticTable[i].name == name) return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ---------------- Field section codec ----------------
|
||||
// Encodes the QPACK-on-the-wire field section: a 2-byte prefix (Required
|
||||
// Insert Count = 0, Sign+DeltaBase = 0 — i.e. no dynamic table reliance)
|
||||
// followed by per-field representations. We pick the most compact static
|
||||
// representation each header allows; never emit Huffman; never use the
|
||||
// dynamic table.
|
||||
export inline std::vector<std::uint8_t> EncodeFieldSection(
|
||||
const std::vector<std::pair<std::string, std::string>>& fields) {
|
||||
std::vector<std::uint8_t> out;
|
||||
// Prefix: Required Insert Count (8-bit prefix int = 0)
|
||||
EncodeQpackInt(out, 0x00, 8, 0);
|
||||
// Sign + Delta Base (sign in high bit of 7-bit-prefix int = 0)
|
||||
EncodeQpackInt(out, 0x00, 7, 0);
|
||||
|
||||
for (const auto& [name, value] : fields) {
|
||||
int exact = StaticTableExactLookup(name, value);
|
||||
if (exact >= 0) {
|
||||
// Indexed Field Line, T=1 (static): pattern 1Tixxxxx, 6-bit prefix.
|
||||
EncodeQpackInt(out, 0xC0, 6, static_cast<std::uint64_t>(exact));
|
||||
continue;
|
||||
}
|
||||
int nameIdx = StaticTableNameLookup(name);
|
||||
if (nameIdx >= 0) {
|
||||
// Literal Field Line With Name Reference, T=1 (static),
|
||||
// N=0 (allow indexing on intermediaries — moot since no DT):
|
||||
// pattern 01NTxxxx, 4-bit name-index prefix.
|
||||
EncodeQpackInt(out, 0x50, 4, static_cast<std::uint64_t>(nameIdx));
|
||||
} else {
|
||||
// Literal Field Line With Literal Name, N=0, H=0: pattern
|
||||
// 001NHxxx with a 3-bit name-length prefix.
|
||||
EncodeQpackInt(out, 0x20, 3, name.size());
|
||||
out.insert(out.end(), name.begin(), name.end());
|
||||
}
|
||||
// Value: 7-bit length prefix, H=0 (no Huffman).
|
||||
EncodeQpackInt(out, 0x00, 7, value.size());
|
||||
out.insert(out.end(), value.begin(), value.end());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export inline std::vector<std::pair<std::string, std::string>> DecodeFieldSection(
|
||||
const std::uint8_t* data, std::size_t available) {
|
||||
std::size_t pos = 0;
|
||||
std::uint64_t reqIc = 0, deltaBase = 0;
|
||||
std::size_t cn = 0;
|
||||
if (!DecodeQpackInt(data + pos, available - pos, 8, reqIc, cn)) {
|
||||
throw HTTP3ProtocolError("QPACK: truncated Required Insert Count");
|
||||
}
|
||||
pos += cn;
|
||||
if (pos >= available) throw HTTP3ProtocolError("QPACK: missing Base");
|
||||
if (!DecodeQpackInt(data + pos, available - pos, 7, deltaBase, cn)) {
|
||||
throw HTTP3ProtocolError("QPACK: truncated Base");
|
||||
}
|
||||
pos += cn;
|
||||
if (reqIc != 0) {
|
||||
// Encoder used the dynamic table, which we don't track. Required
|
||||
// Insert Count != 0 means we cannot decode without dynamic-table
|
||||
// state; surface a clean protocol error rather than mis-decoding.
|
||||
throw HTTP3ProtocolError("QPACK: dynamic table reference (Required Insert Count != 0)");
|
||||
}
|
||||
|
||||
std::vector<std::pair<std::string, std::string>> fields;
|
||||
while (pos < available) {
|
||||
std::uint8_t b = data[pos];
|
||||
if ((b & 0x80) != 0) {
|
||||
// 1Txxxxxx — Indexed Field Line.
|
||||
bool isStatic = (b & 0x40) != 0;
|
||||
std::uint64_t idx = 0;
|
||||
if (!DecodeQpackInt(data + pos, available - pos, 6, idx, cn)) {
|
||||
throw HTTP3ProtocolError("QPACK: truncated indexed field line");
|
||||
}
|
||||
pos += cn;
|
||||
if (!isStatic) throw HTTP3ProtocolError("QPACK: dynamic-table indexed line unsupported");
|
||||
if (idx >= kStaticTable.size()) throw HTTP3ProtocolError("QPACK: static index out of range");
|
||||
const auto& e = kStaticTable[static_cast<std::size_t>(idx)];
|
||||
fields.emplace_back(std::string(e.name), std::string(e.value));
|
||||
} else if ((b & 0xC0) == 0x40) {
|
||||
// 01NTxxxx — Literal Field Line With Name Reference.
|
||||
bool isStatic = (b & 0x10) != 0;
|
||||
std::uint64_t idx = 0;
|
||||
if (!DecodeQpackInt(data + pos, available - pos, 4, idx, cn)) {
|
||||
throw HTTP3ProtocolError("QPACK: truncated literal-with-nameref index");
|
||||
}
|
||||
pos += cn;
|
||||
if (!isStatic) throw HTTP3ProtocolError("QPACK: dynamic-table name reference unsupported");
|
||||
if (idx >= kStaticTable.size()) throw HTTP3ProtocolError("QPACK: static name index out of range");
|
||||
if (pos >= available) throw HTTP3ProtocolError("QPACK: missing value byte");
|
||||
bool huffman = (data[pos] & 0x80) != 0;
|
||||
std::uint64_t vlen = 0;
|
||||
if (!DecodeQpackInt(data + pos, available - pos, 7, vlen, cn)) {
|
||||
throw HTTP3ProtocolError("QPACK: truncated value length");
|
||||
}
|
||||
pos += cn;
|
||||
if (pos + vlen > available) throw HTTP3ProtocolError("QPACK: value runs past buffer");
|
||||
std::string value = huffman
|
||||
? DecodeHuffman(data + pos, static_cast<std::size_t>(vlen))
|
||||
: std::string(reinterpret_cast<const char*>(data + pos),
|
||||
static_cast<std::size_t>(vlen));
|
||||
pos += static_cast<std::size_t>(vlen);
|
||||
fields.emplace_back(std::string(kStaticTable[static_cast<std::size_t>(idx)].name), std::move(value));
|
||||
} else if ((b & 0xE0) == 0x20) {
|
||||
// 001NHxxx — Literal Field Line With Literal Name.
|
||||
bool huffmanName = (b & 0x08) != 0;
|
||||
std::uint64_t nlen = 0;
|
||||
if (!DecodeQpackInt(data + pos, available - pos, 3, nlen, cn)) {
|
||||
throw HTTP3ProtocolError("QPACK: truncated literal-name length");
|
||||
}
|
||||
pos += cn;
|
||||
if (pos + nlen > available) throw HTTP3ProtocolError("QPACK: literal name runs past buffer");
|
||||
std::string name = huffmanName
|
||||
? DecodeHuffman(data + pos, static_cast<std::size_t>(nlen))
|
||||
: std::string(reinterpret_cast<const char*>(data + pos),
|
||||
static_cast<std::size_t>(nlen));
|
||||
pos += static_cast<std::size_t>(nlen);
|
||||
if (pos >= available) throw HTTP3ProtocolError("QPACK: missing value byte");
|
||||
bool huffmanValue = (data[pos] & 0x80) != 0;
|
||||
std::uint64_t vlen = 0;
|
||||
if (!DecodeQpackInt(data + pos, available - pos, 7, vlen, cn)) {
|
||||
throw HTTP3ProtocolError("QPACK: truncated literal-value length");
|
||||
}
|
||||
pos += cn;
|
||||
if (pos + vlen > available) throw HTTP3ProtocolError("QPACK: literal value runs past buffer");
|
||||
std::string value = huffmanValue
|
||||
? DecodeHuffman(data + pos, static_cast<std::size_t>(vlen))
|
||||
: std::string(reinterpret_cast<const char*>(data + pos),
|
||||
static_cast<std::size_t>(vlen));
|
||||
pos += static_cast<std::size_t>(vlen);
|
||||
fields.emplace_back(std::move(name), std::move(value));
|
||||
} else {
|
||||
// Indexed-with-Post-Base / Literal-with-Post-Base-Name-Reference
|
||||
// both rely on a dynamic-table base offset we don't maintain.
|
||||
throw HTTP3ProtocolError("QPACK: post-base reference unsupported");
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ---------------- Frame helpers ----------------
|
||||
// A frame is: type (varint) | length (varint) | payload (length bytes).
|
||||
export inline void WriteFrame(std::vector<std::uint8_t>& out, std::uint64_t type,
|
||||
const std::uint8_t* payload, std::size_t length) {
|
||||
EncodeVarint(type, out);
|
||||
EncodeVarint(static_cast<std::uint64_t>(length), out);
|
||||
out.insert(out.end(), payload, payload + length);
|
||||
}
|
||||
|
||||
// Empty SETTINGS frame body — we send no settings, accepting all defaults.
|
||||
export inline std::vector<std::uint8_t> BuildControlStreamPrelude() {
|
||||
std::vector<std::uint8_t> out;
|
||||
EncodeVarint(kStreamControl, out); // unidi stream type
|
||||
EncodeVarint(kFrameSettings, out); // SETTINGS frame type
|
||||
EncodeVarint(0, out); // frame length 0
|
||||
return out;
|
||||
}
|
||||
|
||||
// Server-side variant that advertises WebTransport-over-HTTP/3 support
|
||||
// to the peer. Without these three SETTINGS the browser silently rejects
|
||||
// the extended CONNECT and the WebTransport.ready promise never resolves.
|
||||
// `maxSessions` becomes the value of SETTINGS_WT_MAX_SESSIONS.
|
||||
export inline std::vector<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -21,38 +21,82 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|||
export module Crafter.Network:ListenerHTTP;
|
||||
import std;
|
||||
import :HTTP;
|
||||
import :ClientTCP;
|
||||
import :ListenerQUIC;
|
||||
import :ClientQUIC;
|
||||
import :WebTransport;
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
namespace Crafter {
|
||||
export class ListenerHTTP;
|
||||
class ListenerHTTPClient {
|
||||
public:
|
||||
std::atomic<bool> disconnected;
|
||||
ClientTCP client;
|
||||
std::thread thread;
|
||||
ListenerHTTP* server;
|
||||
ListenerHTTPClient(ListenerHTTP* server, int s);
|
||||
void ListenRoutes();
|
||||
};
|
||||
// HTTP/3 server. Wraps a ListenerQUIC: each accepted QUIC connection
|
||||
// registers a per-stream handler that parses one request, dispatches it
|
||||
// through the route map, and writes a response back on the same bidi
|
||||
// stream. ALPN is fixed to "h3".
|
||||
//
|
||||
// Routes are keyed by `:path` (exact match). Unknown paths return a
|
||||
// synthetic 404. Route handlers run on the ThreadPool — multiple requests
|
||||
// on the same connection can therefore execute concurrently.
|
||||
//
|
||||
// WebTransport: pass a non-empty `wtRoutes` to additionally accept
|
||||
// extended-CONNECT requests (`:method=CONNECT, :protocol=webtransport`)
|
||||
// whose `:path` matches a registered route. The matching handler runs
|
||||
// on the ThreadPool with a `WebTransportSession&` argument scoped to
|
||||
// the session's lifetime. Sending WT-required SETTINGS happens
|
||||
// automatically when wtRoutes is non-empty.
|
||||
export class ListenerHTTP {
|
||||
public:
|
||||
// The underlying QUIC listener owns the accept loop, certificates,
|
||||
// and the per-connection ClientQUIC instances. It is heap-allocated
|
||||
// and owned by this Impl so that move construction/destruction is
|
||||
// straightforward.
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
std::unordered_map<std::string, std::function<void(WebTransportSession&)>> wtRoutes;
|
||||
std::string alpn;
|
||||
|
||||
export class ListenerHTTP {
|
||||
public:
|
||||
int s;
|
||||
std::vector<ListenerHTTPClient*> clients;
|
||||
bool running = true;
|
||||
const std::unordered_map<std::string, std::function<std::string(const HTTPRequest&)>> routes;
|
||||
ListenerHTTP(std::uint16_t port, std::unordered_map<std::string, std::function<std::string(const HTTPRequest&)>> routes);
|
||||
~ListenerHTTP();
|
||||
void Listen();
|
||||
void Stop();
|
||||
};
|
||||
ListenerHTTP(std::uint16_t port,
|
||||
QUICServerCredentials creds,
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes);
|
||||
|
||||
export class ListenerAsyncHTTP {
|
||||
public:
|
||||
ListenerHTTP listener;
|
||||
std::thread thread;
|
||||
ListenerAsyncHTTP(std::uint16_t port, std::unordered_map<std::string, std::function<std::string(const HTTPRequest&)>> routes);
|
||||
~ListenerAsyncHTTP();
|
||||
void Stop();
|
||||
};
|
||||
// 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(const ListenerHTTP&) = delete;
|
||||
ListenerHTTP(ListenerHTTP&&) noexcept;
|
||||
|
||||
// Block on this thread, dispatch each accepted connection on this
|
||||
// thread (matches ListenerQUIC::ListenSyncSync semantics).
|
||||
void Listen();
|
||||
void Stop();
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl;
|
||||
};
|
||||
|
||||
// Async wrapper: runs the listener's accept loop on a background thread
|
||||
// so the caller can construct it and continue. Mirrors the old
|
||||
// ListenerAsyncHTTP so existing call sites keep working.
|
||||
export class ListenerAsyncHTTP {
|
||||
public:
|
||||
ListenerHTTP listener;
|
||||
std::thread thread;
|
||||
|
||||
ListenerAsyncHTTP(std::uint16_t port,
|
||||
QUICServerCredentials creds,
|
||||
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();
|
||||
void Stop();
|
||||
};
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -19,11 +19,14 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|||
*/
|
||||
|
||||
module;
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
#include <msquic.h>
|
||||
#endif
|
||||
export module Crafter.Network:ListenerQUIC;
|
||||
import std;
|
||||
import :ClientQUIC;
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
namespace Crafter {
|
||||
// Server side of a QUIC connection. Mirrors ListenerTCP in shape:
|
||||
// four Listen* methods covering the sync/async outer-loop x sync/async
|
||||
|
|
@ -72,4 +75,17 @@ namespace Crafter {
|
|||
std::unique_ptr<Impl> impl;
|
||||
std::uint32_t totalClientCounter = 0;
|
||||
};
|
||||
|
||||
// Compute the SHA-256 of the DER bytes of a PEM-encoded X.509 certificate.
|
||||
// Returns the 32-byte digest. Intended for surfacing the self-signed cert
|
||||
// hash to a browser peer (Chrome's WebTransport requires the client to
|
||||
// pass this hash via `serverCertificateHashes` when peering against a
|
||||
// cert that's not in the system trust store). Shells out to openssl.
|
||||
export std::array<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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export module Crafter.Network:ListenerTCP;
|
|||
import std;
|
||||
import :ClientTCP;
|
||||
|
||||
#ifndef CRAFTER_NETWORK_BROWSER
|
||||
namespace Crafter {
|
||||
export class ListenerTCP {
|
||||
public:
|
||||
|
|
@ -41,3 +42,4 @@ namespace Crafter {
|
|||
int s;
|
||||
};
|
||||
}
|
||||
#endif
|
||||
105
interfaces/Crafter.Network-WebTransport.cppm
Normal file
105
interfaces/Crafter.Network-WebTransport.cppm
Normal 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
|
||||
|
|
@ -27,3 +27,11 @@ export import :ListenerHTTP;
|
|||
export import :HTTP;
|
||||
export import :ClientQUIC;
|
||||
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
|
||||
61
project.cpp
61
project.cpp
|
|
@ -4,23 +4,17 @@ namespace fs = std::filesystem;
|
|||
using namespace Crafter;
|
||||
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||
constexpr std::array<std::string_view, 8> networkInterfaces = {
|
||||
constexpr std::array<std::string_view, 10> networkInterfaces = {
|
||||
"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-HTTP3",
|
||||
"interfaces/Crafter.Network-ClientQUIC",
|
||||
"interfaces/Crafter.Network-ListenerQUIC",
|
||||
};
|
||||
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",
|
||||
"interfaces/Crafter.Network-WebTransport",
|
||||
};
|
||||
|
||||
std::vector<std::string> depArgs(args.begin(), args.end());
|
||||
|
|
@ -37,6 +31,51 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
ApplyStandardArgs(cfg, args);
|
||||
cfg.dependencies = { thread };
|
||||
|
||||
// Browser path: any wasm32-* target gets the browser network stack
|
||||
// (fetch + WebTransport via JS glue). msquic and the POSIX socket
|
||||
// backends are skipped; the listener / TCP partitions stub to empty
|
||||
// modules via #ifdef CRAFTER_NETWORK_BROWSER in their interface files.
|
||||
// HTTP3 (varint / frame / QPACK codec) is dropped entirely — it threw
|
||||
// exceptions for protocol errors, which the wasm build's -fno-exceptions
|
||||
// forbids, and the browser's fetch() handles HTTP-layer framing itself.
|
||||
bool browser = cfg.target.find("wasm") != std::string::npos;
|
||||
if (browser) {
|
||||
cfg.defines.push_back({"CRAFTER_NETWORK_BROWSER", ""});
|
||||
|
||||
std::array<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.
|
||||
// Cloned + built via CMake into the per-project external cache; no system
|
||||
// package required. Submodules (quictls / clog / etc.) come via the
|
||||
|
|
@ -61,9 +100,9 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
// linker at the actual output location.
|
||||
msquic.libDirs = { "bin/Release" };
|
||||
msquic.libs = { "msquic" };
|
||||
std::array<fs::path, 8> ifaces;
|
||||
std::array<fs::path, 10> ifaces;
|
||||
std::ranges::copy(networkInterfaces, ifaces.begin());
|
||||
std::array<fs::path, 6> impls;
|
||||
std::array<fs::path, 7> impls;
|
||||
std::ranges::copy(networkImplementations, impls.begin());
|
||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||
|
||||
|
|
|
|||
182
tests/ShouldEchoWebTransport/ShouldEchoWebTransport.cpp
Normal file
182
tests/ShouldEchoWebTransport/ShouldEchoWebTransport.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,16 +5,16 @@ using namespace Crafter;
|
|||
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||
Configuration cfg;
|
||||
cfg.path = "tests/ShouldRecieveHTTP/";
|
||||
cfg.name = "ShouldRecieveHTTP";
|
||||
cfg.outputName = "ShouldRecieveHTTP";
|
||||
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 = { "ShouldRecieveHTTP" };
|
||||
std::array<fs::path, 1> impls = { "ShouldEchoWebTransport" };
|
||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||
return cfg;
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/*
|
||||
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
|
||||
*/
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
int main() {
|
||||
bool success = false;
|
||||
ListenerAsyncHTTP listener(8081, {{"/", [&](const HTTPRequest& request) {
|
||||
success = true;
|
||||
return CreateResponseHTTP("200 OK", "Hello World!");
|
||||
}}});
|
||||
try {
|
||||
system("curl http://localhost:8081 > /dev/null 2>&1");
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
if (success) {
|
||||
return 0;
|
||||
}
|
||||
std::println("Did not receive");
|
||||
return 1;
|
||||
} catch (std::exception& e) {
|
||||
std::println("{}", e.what());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
60
tests/ShouldSend/ShouldSend.cpp
Normal file
60
tests/ShouldSend/ShouldSend.cpp
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
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
|
||||
*/
|
||||
import Crafter.Network;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
// External-interop smoke test: connect to a public h3 endpoint, fetch /, and
|
||||
// verify a 200 response with a non-empty body. Exercises:
|
||||
// - real TLS chain validation against the system trust store
|
||||
// - mandatory client control stream + SETTINGS prelude
|
||||
// - peer's control + QPACK encoder/decoder unidi streams (drained)
|
||||
// - QPACK Huffman decode on the response headers
|
||||
//
|
||||
// Targets cloudflare-quic.com (Cloudflare's public h3 demo). Network-
|
||||
// dependent — if outbound UDP/443 is firewalled or the endpoint goes away,
|
||||
// this will fail.
|
||||
int main() {
|
||||
ThreadPool::Start();
|
||||
try {
|
||||
QUICClientCredentials creds; // default: validate against system trust
|
||||
ClientHTTP client("cloudflare-quic.com", 443, creds);
|
||||
HTTPResponse r = client.Send(
|
||||
CreateRequestHTTP("GET", "/", "cloudflare-quic.com")
|
||||
);
|
||||
std::cout << "status=" << r.status << " bodyBytes=" << r.body.size() << std::endl;
|
||||
if (r.headers.count("server")) {
|
||||
std::cout << "server=" << r.headers["server"] << std::endl;
|
||||
}
|
||||
if (r.body.size() > 0) {
|
||||
auto preview = r.body.substr(0, std::min<std::size_t>(80, r.body.size()));
|
||||
std::cout << "preview: " << preview << std::endl;
|
||||
}
|
||||
if (r.status != "200" || r.body.empty()) {
|
||||
std::cout << "unexpected response" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
std::cout.flush();
|
||||
std::_Exit(0);
|
||||
} catch (std::exception& e) {
|
||||
std::println("error: {}", e.what());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,16 +5,16 @@ using namespace Crafter;
|
|||
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||
Configuration cfg;
|
||||
cfg.path = "tests/ShouldSendHTTP/";
|
||||
cfg.name = "ShouldSendHTTP";
|
||||
cfg.outputName = "ShouldSendHTTP";
|
||||
cfg.path = "tests/ShouldSend/";
|
||||
cfg.name = "ShouldSend";
|
||||
cfg.outputName = "ShouldSend";
|
||||
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 = { "ShouldSendHTTP" };
|
||||
std::array<fs::path, 1> impls = { "ShouldSend" };
|
||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||
return cfg;
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/*
|
||||
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
|
||||
*/
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
int main() {
|
||||
ClientHTTP client("httpbin.org", 80);
|
||||
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/get", "httpbin.org"));
|
||||
if (response.status == "200 OK") {
|
||||
return 0;
|
||||
}
|
||||
std::println("{}", response.body);
|
||||
return 1;
|
||||
}
|
||||
|
|
@ -16,23 +16,31 @@ 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
|
||||
*/
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
import Crafter.Network;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
int main() {
|
||||
ListenerAsyncHTTP listener(8082, {{"/", [&](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200 OK", "Hello World!");
|
||||
ThreadPool::Start();
|
||||
|
||||
QUICServerCredentials serverCreds;
|
||||
serverCreds.selfSigned = true;
|
||||
ListenerAsyncHTTP listener(8082, serverCreds, {{"/", [&](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", "Hello World!");
|
||||
}}});
|
||||
try {
|
||||
ClientHTTP client("localhost", 8082);
|
||||
QUICClientCredentials clientCreds;
|
||||
clientCreds.insecureNoServerValidation = true;
|
||||
ClientHTTP client("localhost", 8082, clientCreds);
|
||||
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
if (response.status == "200 OK" && response.body == "Hello World!") {
|
||||
return 0;
|
||||
if (response.status == "200" && response.body == "Hello World!") {
|
||||
// See ShouldSendRecieveQUICStream for rationale: msquic's
|
||||
// RegistrationClose blocks on outstanding connections, so skip
|
||||
// graceful teardown after the test logic succeeds.
|
||||
std::_Exit(0);
|
||||
}
|
||||
std::println("{}{}", response.status, response.body);
|
||||
std::println("{} {}", response.status, response.body);
|
||||
return 1;
|
||||
} catch (std::exception& e) {
|
||||
std::println("{}", e.what());
|
||||
|
|
|
|||
|
|
@ -16,31 +16,39 @@ 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
|
||||
*/
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
import Crafter.Network;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
// "Keep-alive" in HTTP/3 corresponds to the QUIC connection being multiplexed:
|
||||
// successive client.Send() calls reuse the same connection and open new
|
||||
// request streams within it. This test exercises that — two requests on one
|
||||
// ClientHTTP must both succeed.
|
||||
int main() {
|
||||
ListenerAsyncHTTP listener(8083, {{"/", [&](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200 OK", "Hello World!");
|
||||
ThreadPool::Start();
|
||||
|
||||
QUICServerCredentials serverCreds;
|
||||
serverCreds.selfSigned = true;
|
||||
ListenerAsyncHTTP listener(8083, serverCreds, {{"/", [&](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", "Hello World!");
|
||||
}}});
|
||||
try {
|
||||
ClientHTTP client("localhost", 8083);
|
||||
QUICClientCredentials clientCreds;
|
||||
clientCreds.insecureNoServerValidation = true;
|
||||
ClientHTTP client("localhost", 8083, clientCreds);
|
||||
|
||||
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
if (response.status != "200 OK" || response.body != "Hello World!") {
|
||||
std::println("{}{}", response.status, response.body);
|
||||
if (response.status != "200" || response.body != "Hello World!") {
|
||||
std::println("{} {}", response.status, response.body);
|
||||
return 1;
|
||||
}
|
||||
response = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
if (response.status != "200 OK" || response.body != "Hello World!") {
|
||||
std::println("{}{}", response.status, response.body);
|
||||
if (response.status != "200" || response.body != "Hello World!") {
|
||||
std::println("{} {}", response.status, response.body);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
std::_Exit(0);
|
||||
} catch (std::exception& e) {
|
||||
std::println("{}", e.what());
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -16,28 +16,31 @@ 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
|
||||
*/
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
import Crafter.Network;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
int main() {
|
||||
ListenerAsyncHTTP listener(8084, {{ "/", [&](const HTTPRequest& request) {
|
||||
if (request.body.size() > 1'000'000) {
|
||||
return CreateResponseHTTP("200 OK", "Large request received: " + std::to_string(request.body.size()) + " bytes");
|
||||
}
|
||||
return CreateResponseHTTP("200 OK", "Small request received");
|
||||
ThreadPool::Start();
|
||||
|
||||
QUICServerCredentials serverCreds;
|
||||
serverCreds.selfSigned = true;
|
||||
ListenerAsyncHTTP listener(8084, serverCreds, {{ "/", [&](const HTTPRequest& request) {
|
||||
if (request.body.size() > 1'000'000) {
|
||||
return CreateResponseHTTP("200", "Large request received: " + std::to_string(request.body.size()) + " bytes");
|
||||
}
|
||||
}});
|
||||
return CreateResponseHTTP("200", "Small request received");
|
||||
}}});
|
||||
|
||||
try {
|
||||
ClientHTTP client("localhost", 8084);
|
||||
QUICClientCredentials clientCreds;
|
||||
clientCreds.insecureNoServerValidation = true;
|
||||
ClientHTTP client("localhost", 8084, clientCreds);
|
||||
std::string large_body(10 * 1024 * 1024, 'A');
|
||||
HTTPResponse response = client.Send(CreateRequestHTTP("POST", "/", "localhost", large_body));
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
if (response.status == "200 OK" && response.body.find("Large request received") != std::string::npos) {
|
||||
return 0;
|
||||
if (response.status == "200" && response.body.find("Large request received") != std::string::npos) {
|
||||
std::_Exit(0);
|
||||
}
|
||||
std::println("Unexpected response: {} {}", response.status, response.body);
|
||||
return 1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue