browser wasm

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

View file

@ -0,0 +1,199 @@
/*
Crafter®.Network
Copyright (C) 2026 Catcrafts®
Catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3.0 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
// CRAFTER_NETWORK_BROWSER implementation of ClientHTTP. Each SendAsync
// hands its request to the JS bridge (additional/network-env.js), which
// runs a fetch() and dispatches the result back through the
// CrafterNetworkOnFetchComplete / CrafterNetworkOnFetchError wasm exports.
module;
module Crafter.Network:ClientHTTP_impl;
import :ClientHTTP;
import :HTTP;
import std;
using namespace Crafter;
namespace Crafter::NetworkBrowserBindings {
// External linkage so the import_module/import_name attributes wire up.
__attribute__((import_module("env"), import_name("crafterNetworkFetch")))
void crafterNetworkFetch(
const char* method, std::int32_t methodLen,
const char* url, std::int32_t urlLen,
const char* headers, std::int32_t headersLen,
const char* body, std::int32_t bodyLen,
std::int32_t callbackId);
}
namespace {
struct FetchCallbacks {
std::function<void(HTTPResponse)> onSuccess;
std::function<void(std::string)> onError;
};
// JS dispatches back into wasm via a stable id we mint here. The id
// counter is monotone — wraparound at 2 billion fetches is not a
// realistic concern. The map is touched only from the JS event loop
// (single-threaded in the browser), so no synchronisation is needed.
std::unordered_map<std::int32_t, FetchCallbacks>& Callbacks() {
static std::unordered_map<std::int32_t, FetchCallbacks> m;
return m;
}
std::int32_t NextId() {
static std::int32_t counter = 0;
return ++counter;
}
// Serialise headers as newline-separated "name: value" pairs. The JS
// side splits on '\n' and the first ": " for header construction.
std::string SerialiseHeaders(const std::unordered_map<std::string, std::string>& headers) {
std::string out;
bool first = true;
for (const auto& [name, value] : headers) {
if (!first) out += '\n';
first = false;
out += name;
out += ": ";
out += value;
}
return out;
}
// Parse a "name: value\nname2: value2" blob into the HTTPResponse map.
// Names are kept verbatim (fetch surfaces them lowercase already on the
// browser side via response.headers.forEach).
void ParseHeaders(std::string_view raw, HTTPResponse& response) {
std::size_t pos = 0;
while (pos < raw.size()) {
std::size_t end = raw.find('\n', pos);
std::string_view line = raw.substr(pos, end == std::string_view::npos ? raw.size() - pos : end - pos);
std::size_t sep = line.find(": ");
if (sep != std::string_view::npos) {
response.headers.emplace(std::string(line.substr(0, sep)),
std::string(line.substr(sep + 2)));
}
if (end == std::string_view::npos) break;
pos = end + 1;
}
}
}
struct ClientHTTP::Impl {};
ClientHTTP::ClientHTTP(const char* host, std::uint16_t port, QUICClientCredentials)
: host(host), port(port), impl(std::make_unique<Impl>()) {}
ClientHTTP::ClientHTTP(std::string host, std::uint16_t port, QUICClientCredentials creds)
: ClientHTTP(host.c_str(), port, std::move(creds)) {}
ClientHTTP::ClientHTTP(ClientHTTP&&) noexcept = default;
ClientHTTP::~ClientHTTP() = default;
void ClientHTTP::SendAsync(const HTTPRequest& request,
std::function<void(HTTPResponse)> onSuccess,
std::function<void(std::string)> onError) {
std::int32_t id = NextId();
Callbacks().emplace(id, FetchCallbacks{std::move(onSuccess), std::move(onError)});
std::string method = request.method.empty() ? std::string("GET") : request.method;
std::string path = request.path.empty() ? std::string("/") : request.path;
// Sentinel: a ClientHTTP constructed with an empty host fetches against
// the page's own origin. fetch(url) in JS handles a leading-slash path
// by resolving it against window.location, so we just hand the path
// straight through. Useful for fetching files served by whatever static
// server is hosting the wasm (e.g. ./cert-hash.txt for WebTransport).
std::string url;
if (host.empty()) {
url = path;
} else {
std::string scheme = request.scheme.empty() ? std::string("https") : request.scheme;
std::string authority = request.authority.empty() ? (host + ":" + std::to_string(port)) : request.authority;
url = scheme + "://" + authority + path;
}
std::string headerStr = SerialiseHeaders(request.headers);
Crafter::NetworkBrowserBindings::crafterNetworkFetch(
method.data(), static_cast<std::int32_t>(method.size()),
url.data(), static_cast<std::int32_t>(url.size()),
headerStr.data(), static_cast<std::int32_t>(headerStr.size()),
request.body.data(), static_cast<std::int32_t>(request.body.size()),
id);
}
extern "C" {
// JS allocates `headersPtr` and `bodyPtr` via WasmAlloc, copies the
// response into them, then transfers ownership across this call. We
// free the buffers after copying into the HTTPResponse.
__attribute__((export_name("CrafterNetworkOnFetchComplete")))
void CrafterNetworkOnFetchComplete(std::int32_t callbackId,
std::int32_t status,
char* headersPtr, std::int32_t headersLen,
char* bodyPtr, std::int32_t bodyLen) {
auto& callbacks = Callbacks();
auto it = callbacks.find(callbackId);
if (it == callbacks.end()) {
std::free(headersPtr);
std::free(bodyPtr);
return;
}
HTTPResponse response;
response.status = std::to_string(status);
if (headersPtr && headersLen > 0) {
ParseHeaders(std::string_view(headersPtr, static_cast<std::size_t>(headersLen)), response);
}
if (bodyPtr && bodyLen > 0) {
response.body.assign(bodyPtr, static_cast<std::size_t>(bodyLen));
}
std::free(headersPtr);
std::free(bodyPtr);
auto onSuccess = std::move(it->second.onSuccess);
callbacks.erase(it);
if (onSuccess) onSuccess(std::move(response));
}
__attribute__((export_name("CrafterNetworkOnFetchError")))
void CrafterNetworkOnFetchError(std::int32_t callbackId,
char* messagePtr, std::int32_t messageLen) {
auto& callbacks = Callbacks();
auto it = callbacks.find(callbackId);
if (it == callbacks.end()) {
std::free(messagePtr);
return;
}
std::string msg(messagePtr ? messagePtr : "", static_cast<std::size_t>(messageLen));
std::free(messagePtr);
auto onError = std::move(it->second.onError);
callbacks.erase(it);
if (onError) onError(std::move(msg));
}
// WasmAlloc / WasmFree are the buffer-marshalling primitives the JS
// bridge calls into. Crafter.Graphics's Dom backend defines the same
// pair; we mark ours weak so the two libraries can coexist in one
// executable without a duplicate-symbol error.
__attribute__((export_name("WasmAlloc"), weak))
void* WasmAlloc(std::int32_t size) { return std::malloc(static_cast<std::size_t>(size)); }
__attribute__((export_name("WasmFree"), weak))
void WasmFree(void* ptr) { std::free(ptr); }
}

View file

@ -130,6 +130,24 @@ namespace {
}
}
void ClientHTTP::SendAsync(const HTTPRequest& request,
std::function<void(HTTPResponse)> onSuccess,
std::function<void(std::string)> onError) {
HTTPRequest copy = request;
ThreadPool::Enqueue([this, copy = std::move(copy),
onSuccess = std::move(onSuccess),
onError = std::move(onError)]() mutable {
try {
HTTPResponse response = this->Send(copy);
if (onSuccess) onSuccess(std::move(response));
} catch (const std::exception& e) {
if (onError) onError(e.what());
} catch (...) {
if (onError) onError("unknown error");
}
});
}
HTTPResponse ClientHTTP::Send(const HTTPRequest& request) {
QUICStream stream = impl->quic.OpenStream();

View file

@ -0,0 +1,443 @@
/*
Crafter®.Network
Copyright (C) 2026 Catcrafts®
Catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3.0 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
// CRAFTER_NETWORK_BROWSER implementation of ClientQUIC / QUICStream backed
// by the browser's WebTransport API. WebTransport is HTTP/3-based and
// expects a URL — we use https://${host}:${port}/${alpn}. Native msquic
// is not linked into the browser build.
//
// Async-only: synchronous send/receive methods on QUICStream are not
// compiled (gated out in the interface). Everything goes through the
// existing *Async / OnStream / OnDatagram callbacks. The JS bridge
// (additional/network-env.js) runs the WebTransport reader loops and
// dispatches each chunk back through the wasm exports declared below.
module;
module Crafter.Network:ClientQUIC_impl;
import :ClientQUIC;
import std;
using namespace Crafter;
namespace Crafter::NetworkBrowserBindings {
__attribute__((import_module("env"), import_name("crafterNetworkWtConnect")))
std::int32_t crafterNetworkWtConnect(
const char* host, std::int32_t hostLen,
std::int32_t port,
const char* alpn, std::int32_t alpnLen,
const std::uint8_t* certHash, std::int32_t certHashLen);
__attribute__((import_module("env"), import_name("crafterNetworkWtClose")))
void crafterNetworkWtClose(std::int32_t handle);
__attribute__((import_module("env"), import_name("crafterNetworkWtOpenStream")))
std::int32_t crafterNetworkWtOpenStream(std::int32_t handle, std::int32_t unidirectional);
__attribute__((import_module("env"), import_name("crafterNetworkWtStreamWrite")))
void crafterNetworkWtStreamWrite(std::int32_t streamId,
const char* buf, std::int32_t bufLen,
std::int32_t finish,
std::int32_t callbackId);
__attribute__((import_module("env"), import_name("crafterNetworkWtStreamStop")))
void crafterNetworkWtStreamStop(std::int32_t streamId);
__attribute__((import_module("env"), import_name("crafterNetworkWtSendDatagram")))
void crafterNetworkWtSendDatagram(std::int32_t handle,
const char* buf, std::int32_t bufLen);
}
namespace Crafter::NetworkBrowser {
// ─── Receive state machine per stream ─────────────────────────────────
//
// The JS reader loop pushes every chunk it sees through
// CrafterNetworkOnWtStreamChunk. We buffer them until the user calls
// one of the Receive*Async variants — at which point we either
// dispatch immediately (chunks already queued) or wait for the next
// chunk to arrive. A FIN signal marks the end of the peer's send-
// side; further chunks after FIN do not arrive.
//
// StreamState / ConnectionState live in a named namespace (not
// anonymous) because they are referenced from the QUICStream::Impl
// and ClientQUIC::Impl definitions below — private nested types of
// exported classes can't have TU-local member types without a
// diagnostic.
enum class RecvMode { None, Once, UntilClose, UntilFull };
struct StreamState {
// 0 means destroyed / closed.
std::int32_t handle = 0;
// Buffered chunks not yet delivered to a pending callback.
std::vector<char> buffer;
bool finReceived = false;
bool closed = false;
// Pending one-shot receive.
RecvMode mode = RecvMode::None;
std::uint32_t target = 0;
std::function<void(std::vector<char>)> cb;
ClientQUIC* connection = nullptr;
};
struct ConnectionState {
std::int32_t handle = 0;
bool ready = false;
bool closed = false;
std::function<void(QUICStream)> onStream;
std::function<void(std::vector<char>)> onDatagram;
};
// Handle → state. Allocated on the heap so the pointer is stable
// across QUICStream / ClientQUIC moves (each holds a unique_ptr<Impl>
// that wraps a pointer into these maps via its handle).
inline std::unordered_map<std::int32_t, StreamState*>& Streams() {
static std::unordered_map<std::int32_t, StreamState*> m;
return m;
}
inline std::unordered_map<std::int32_t, ConnectionState*>& Connections() {
static std::unordered_map<std::int32_t, ConnectionState*> m;
return m;
}
inline std::unordered_map<std::int32_t, std::function<void()>>& WriteCallbacks() {
static std::unordered_map<std::int32_t, std::function<void()>> m;
return m;
}
inline std::int32_t NextWriteCallbackId() {
static std::int32_t counter = 0;
return ++counter;
}
inline void TryDispatchRecv(StreamState& s) {
if (s.mode == RecvMode::None) return;
if (s.mode == RecvMode::Once) {
if (!s.buffer.empty()) {
auto chunk = std::move(s.buffer);
s.buffer.clear();
auto cb = std::move(s.cb);
s.mode = RecvMode::None;
if (cb) cb(std::move(chunk));
} else if (s.finReceived || s.closed) {
auto cb = std::move(s.cb);
s.mode = RecvMode::None;
if (cb) cb({});
}
return;
}
if (s.mode == RecvMode::UntilClose) {
if (s.finReceived || s.closed) {
auto chunk = std::move(s.buffer);
s.buffer.clear();
auto cb = std::move(s.cb);
s.mode = RecvMode::None;
if (cb) cb(std::move(chunk));
}
return;
}
if (s.mode == RecvMode::UntilFull) {
if (s.buffer.size() >= s.target) {
std::vector<char> chunk(s.buffer.begin(), s.buffer.begin() + s.target);
s.buffer.erase(s.buffer.begin(), s.buffer.begin() + s.target);
auto cb = std::move(s.cb);
s.mode = RecvMode::None;
if (cb) cb(std::move(chunk));
} else if (s.finReceived || s.closed) {
// Peer closed before we got the requested byte count —
// deliver whatever's left. Mirrors the native variant's
// "throws if peer closes early" only loosely (we have no
// exception channel from JS).
auto chunk = std::move(s.buffer);
s.buffer.clear();
auto cb = std::move(s.cb);
s.mode = RecvMode::None;
if (cb) cb(std::move(chunk));
}
}
}
}
// All implementation-private state types live in Crafter::NetworkBrowser
// (above). Pull them in unqualified for readability.
using Crafter::NetworkBrowser::StreamState;
using Crafter::NetworkBrowser::ConnectionState;
using Crafter::NetworkBrowser::RecvMode;
using Crafter::NetworkBrowser::Streams;
using Crafter::NetworkBrowser::Connections;
using Crafter::NetworkBrowser::WriteCallbacks;
using Crafter::NetworkBrowser::NextWriteCallbackId;
using Crafter::NetworkBrowser::TryDispatchRecv;
// ─── QUICStream::Impl ────────────────────────────────────────────────────
struct QUICStream::Impl {
StreamState state;
};
QUICStream::QUICStream() : impl(std::make_unique<Impl>()) {}
QUICStream::QUICStream(std::int32_t streamHandle, ClientQUIC* conn,
bool canSendArg, bool canReceiveArg)
: canSend(canSendArg), canReceive(canReceiveArg),
impl(std::make_unique<Impl>())
{
connection = conn;
impl->state.handle = streamHandle;
impl->state.connection = conn;
Streams()[streamHandle] = &impl->state;
}
QUICStream::QUICStream(QUICStream&&) noexcept = default;
QUICStream& QUICStream::operator=(QUICStream&&) noexcept = default;
QUICStream::~QUICStream() {
if (impl && impl->state.handle != 0) {
Streams().erase(impl->state.handle);
Crafter::NetworkBrowserBindings::crafterNetworkWtStreamStop(impl->state.handle);
}
}
void QUICStream::SendAsync(const void* buffer, std::uint32_t size, bool finish,
std::function<void()> onSent) {
if (!impl || impl->state.handle == 0 || impl->state.closed) {
if (onSent) onSent();
return;
}
std::int32_t cbId = 0;
if (onSent) {
cbId = NextWriteCallbackId();
WriteCallbacks()[cbId] = std::move(onSent);
}
Crafter::NetworkBrowserBindings::crafterNetworkWtStreamWrite(
impl->state.handle,
static_cast<const char*>(buffer),
static_cast<std::int32_t>(size),
finish ? 1 : 0,
cbId);
}
void QUICStream::RecieveAsync(std::function<void(std::vector<char>)> cb) {
if (!impl) { if (cb) cb({}); return; }
impl->state.mode = RecvMode::Once;
impl->state.cb = std::move(cb);
TryDispatchRecv(impl->state);
}
void QUICStream::RecieveUntilCloseAsync(std::function<void(std::vector<char>)> cb) {
if (!impl) { if (cb) cb({}); return; }
impl->state.mode = RecvMode::UntilClose;
impl->state.cb = std::move(cb);
TryDispatchRecv(impl->state);
}
void QUICStream::RecieveUntilFullAsync(std::uint32_t bufferSize,
std::function<void(std::vector<char>)> cb) {
if (!impl) { if (cb) cb({}); return; }
impl->state.mode = RecvMode::UntilFull;
impl->state.target = bufferSize;
impl->state.cb = std::move(cb);
TryDispatchRecv(impl->state);
}
void QUICStream::Stop() {
if (impl && impl->state.handle != 0) {
Crafter::NetworkBrowserBindings::crafterNetworkWtStreamStop(impl->state.handle);
Streams().erase(impl->state.handle);
impl->state.handle = 0;
impl->state.closed = true;
}
}
// ─── ClientQUIC::Impl ────────────────────────────────────────────────────
struct ClientQUIC::Impl {
ConnectionState state;
};
namespace {
QUICStream MakeStreamFromHandle(std::int32_t streamId, ClientQUIC* conn,
bool canSend, bool canReceive) {
return QUICStream{streamId, conn, canSend, canReceive};
}
}
ClientQUIC::ClientQUIC(const char* host, std::uint16_t port, std::string alpnArg,
QUICClientCredentials creds)
: alpn(std::move(alpnArg)), impl(std::make_unique<Impl>()) {
// Zeroed hash means "no pinning" — JS passes an empty array, browser
// falls back to its trust store. A non-zero hash is forwarded as a
// serverCertificateHashes entry (Chrome only, < 14 day cert validity).
const std::uint8_t* hashPtr = nullptr;
std::int32_t hashLen = 0;
for (std::uint8_t b : creds.serverCertificateHash) {
if (b != 0) { hashPtr = creds.serverCertificateHash.data(); hashLen = 32; break; }
}
std::string hostStr = host;
impl->state.handle = Crafter::NetworkBrowserBindings::crafterNetworkWtConnect(
hostStr.data(), static_cast<std::int32_t>(hostStr.size()),
static_cast<std::int32_t>(port),
alpn.data(), static_cast<std::int32_t>(alpn.size()),
hashPtr, hashLen);
// wasm builds run with -fno-exceptions; a failed JS-side allocation
// leaves the connection in a closed state and the first operation will
// produce a sentinel result (OpenStream returns a default-constructed
// QUICStream, SendDatagram silently drops, OnStream/OnDatagram never
// fire). The constructor cannot signal the failure synchronously.
if (impl->state.handle == 0) {
impl->state.closed = true;
} else {
Connections()[impl->state.handle] = &impl->state;
}
}
ClientQUIC::ClientQUIC(std::string host, std::uint16_t port, std::string alpnArg,
QUICClientCredentials creds)
: ClientQUIC(host.c_str(), port, std::move(alpnArg), std::move(creds)) {}
ClientQUIC::ClientQUIC(ClientQUIC&&) noexcept = default;
ClientQUIC::~ClientQUIC() {
if (impl && impl->state.handle != 0) {
Connections().erase(impl->state.handle);
Crafter::NetworkBrowserBindings::crafterNetworkWtClose(impl->state.handle);
}
}
QUICStream ClientQUIC::OpenStream(bool unidirectional) {
if (!impl || impl->state.handle == 0 || impl->state.closed) {
return QUICStream{}; // default-constructed: closed sentinel
}
std::int32_t streamId = Crafter::NetworkBrowserBindings::crafterNetworkWtOpenStream(
impl->state.handle, unidirectional ? 1 : 0);
if (streamId == 0) {
return QUICStream{};
}
return MakeStreamFromHandle(streamId, this,
/*canSend=*/true,
/*canReceive=*/!unidirectional);
}
void ClientQUIC::SendDatagram(const void* buffer, std::uint32_t size) {
if (!impl || impl->state.handle == 0 || impl->state.closed) return;
Crafter::NetworkBrowserBindings::crafterNetworkWtSendDatagram(
impl->state.handle,
static_cast<const char*>(buffer),
static_cast<std::int32_t>(size));
}
void ClientQUIC::OnStream(std::function<void(QUICStream)> cb) {
if (impl) impl->state.onStream = std::move(cb);
}
void ClientQUIC::OnDatagram(std::function<void(std::vector<char>)> cb) {
if (impl) impl->state.onDatagram = std::move(cb);
}
void ClientQUIC::Stop() {
if (impl && impl->state.handle != 0) {
Crafter::NetworkBrowserBindings::crafterNetworkWtClose(impl->state.handle);
Connections().erase(impl->state.handle);
impl->state.handle = 0;
impl->state.closed = true;
}
}
// ─── WASM exports the JS bridge dispatches back through ──────────────────
extern "C" {
__attribute__((export_name("CrafterNetworkOnWtReady")))
void CrafterNetworkOnWtReady(std::int32_t connectionHandle) {
auto it = Connections().find(connectionHandle);
if (it == Connections().end()) return;
it->second->ready = true;
}
__attribute__((export_name("CrafterNetworkOnWtClosed")))
void CrafterNetworkOnWtClosed(std::int32_t connectionHandle,
char* messagePtr, std::int32_t /*messageLen*/) {
std::free(messagePtr);
auto it = Connections().find(connectionHandle);
if (it == Connections().end()) return;
it->second->closed = true;
it->second->ready = false;
// The JS bridge dispatches CrafterNetworkOnWtStreamChunk(_, nullptr, 0,
// fin=1) for each stream that belonged to this connection so pending
// receivers terminate. We don't iterate stream state here.
}
__attribute__((export_name("CrafterNetworkOnWtIncomingStream")))
void CrafterNetworkOnWtIncomingStream(std::int32_t connectionHandle,
std::int32_t streamId,
std::int32_t bidirectional) {
auto it = Connections().find(connectionHandle);
if (it == Connections().end() || !it->second->onStream) {
// No registered handler — close the stream JS-side to free
// resources. Mirrors what the native msquic backend does
// when a stream arrives before OnStream is registered (it
// queues, but in the browser we don't have a backing buffer
// to queue against without leaking).
Crafter::NetworkBrowserBindings::crafterNetworkWtStreamStop(streamId);
return;
}
QUICStream stream = MakeStreamFromHandle(streamId, /*conn=*/nullptr,
/*canSend=*/bidirectional != 0,
/*canReceive=*/true);
it->second->onStream(std::move(stream));
}
__attribute__((export_name("CrafterNetworkOnWtStreamChunk")))
void CrafterNetworkOnWtStreamChunk(std::int32_t streamId,
char* dataPtr, std::int32_t dataLen,
std::int32_t fin) {
auto it = Streams().find(streamId);
if (it == Streams().end()) { std::free(dataPtr); return; }
StreamState& s = *it->second;
if (dataPtr && dataLen > 0) {
s.buffer.insert(s.buffer.end(), dataPtr, dataPtr + dataLen);
}
std::free(dataPtr);
if (fin) s.finReceived = true;
TryDispatchRecv(s);
}
__attribute__((export_name("CrafterNetworkOnWtStreamWriteComplete")))
void CrafterNetworkOnWtStreamWriteComplete(std::int32_t callbackId) {
auto it = WriteCallbacks().find(callbackId);
if (it == WriteCallbacks().end()) return;
auto cb = std::move(it->second);
WriteCallbacks().erase(it);
if (cb) cb();
}
__attribute__((export_name("CrafterNetworkOnWtDatagram")))
void CrafterNetworkOnWtDatagram(std::int32_t connectionHandle,
char* dataPtr, std::int32_t dataLen) {
auto it = Connections().find(connectionHandle);
if (it == Connections().end() || !it->second->onDatagram) {
std::free(dataPtr);
return;
}
std::vector<char> data(dataPtr, dataPtr + dataLen);
std::free(dataPtr);
it->second->onDatagram(std::move(data));
}
}

View file

@ -280,6 +280,18 @@ std::vector<char> QUICStream::RecieveUntilFullSync(std::uint32_t bufferSize) {
return out;
}
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()); });
}
@ -290,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;

View file

@ -26,6 +26,7 @@ import :ListenerQUIC;
import :ClientQUIC;
import :HTTP;
import :HTTP3;
import :WebTransport;
import Crafter.Thread;
import std;
@ -65,7 +66,10 @@ namespace {
else if (name == ":authority") request.authority = std::move(value);
else if (name == ":path") request.path = std::move(value);
else if (!name.empty() && name[0] == ':') {
// Unknown request pseudo-header — ignore.
// Pass through other pseudo-headers (e.g. :protocol
// for extended CONNECT — RFC 8441 / RFC 9220) as
// regular headers so the routing layer can see them.
request.headers.emplace(std::move(name), std::move(value));
} else {
request.headers.emplace(std::move(name), std::move(value));
}
@ -109,13 +113,49 @@ namespace {
}
return wire;
}
// Read enough bytes from `stream` to decode one QUIC varint starting at
// `buffer[offset]`. Appends consumed chunks to `buffer` and advances
// `offset` past the varint. Returns the decoded value. Throws
// QUICClosedException if the peer closes before the varint is complete.
std::uint64_t ReadVarintFromStream(QUICStream& stream,
std::vector<char>& buffer,
std::size_t& offset) {
std::uint64_t value = 0;
std::size_t consumed = 0;
while (true) {
const auto* p = reinterpret_cast<const std::uint8_t*>(buffer.data() + offset);
if (HTTP3::DecodeVarint(p, buffer.size() - offset, value, consumed)) {
offset += consumed;
return value;
}
auto chunk = stream.RecieveSync(); // throws QUICClosed on FIN-without-data
buffer.insert(buffer.end(), chunk.begin(), chunk.end());
}
}
}
// Per-peer state for an accepted connection. Holds the connection wrapper
// and the server-side control stream alive for the lifetime of the peer.
// One accepted-CONNECT WebTransport session living inside a peer.
struct WTSessionEntry {
std::unique_ptr<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 {
@ -125,40 +165,147 @@ struct ListenerHTTP::Impl {
bool running = true;
};
ListenerHTTP::ListenerHTTP(std::uint16_t port,
QUICServerCredentials creds,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> r)
: routes(std::move(r))
, alpn(HTTP3::kAlpn)
, impl(std::make_unique<Impl>())
{
// The connect callback wires up an OnStream handler that splits unidi
// streams (control / QPACK) from bidi streams (request streams) and
// sends our own SETTINGS frame on a freshly-opened control stream.
auto onConnect = [this](ClientQUIC* peer) {
auto state = std::make_unique<PeerState>();
state->quic.reset(peer);
peer->OnStream([this](QUICStream stream) {
if (!stream.canSend) {
// Peer-initiated unidi: client's control stream + optional
// QPACK encoder/decoder streams. Drain — we honour SETTINGS
// by accepting defaults, and we don't track QPACK dynamic-
// table mutations because we don't use the dynamic table.
try {
while (true) (void)stream.RecieveSync();
} catch (...) {}
return;
}
// Bidi stream: a request. Drive a single request/response cycle.
namespace {
// Build the per-connection bidi-stream handler. Demuxes WT streams from
// HTTP/3 request streams by peeking the first varint on the wire. Lives
// as a free helper so both ListenerHTTP constructors can install it.
std::function<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 {
auto raw = stream.RecieveUntilCloseSync();
HTTPRequest request = ParseRequestFrames(raw);
// ── Phase A: identify the stream kind ─────────────────────
//
// We peek the leading varint(s) off the wire incrementally.
// For HTTP/3 streams that's `frame_type, frame_length`. For
// a WT_STREAM the body runs to FIN so there is no length.
std::vector<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()) {
auto it = routes->find(request.path);
if (it != routes->end()) {
response = it->second(request);
} else {
response.status = "404";
@ -170,8 +317,6 @@ ListenerHTTP::ListenerHTTP(std::uint16_t port,
static_cast<std::uint32_t>(wire.size()),
/*finish=*/true);
} catch (const std::exception& e) {
// Best-effort 500 if we can still send. Stream may already
// be closed; swallow further errors silently.
try {
HTTPResponse err;
err.status = "500";
@ -182,21 +327,72 @@ ListenerHTTP::ListenerHTTP(std::uint16_t port,
/*finish=*/true);
} catch (...) {}
}
};
}
}
ListenerHTTP::ListenerHTTP(std::uint16_t port,
QUICServerCredentials creds,
std::unordered_map<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.
// Do this AFTER OnStream is registered so any client-initiated
// unidi stream that races in is handled. The control stream must
// remain open for the connection's lifetime — we never FIN it.
// When wtRoutes is non-empty we advertise WebTransport support so
// the browser will issue extended CONNECT against us.
try {
state->controlStream = peer->OpenStream(/*unidirectional=*/true);
auto prelude = HTTP3::BuildControlStreamPrelude();
auto prelude = this->wtRoutes.empty()
? HTTP3::BuildControlStreamPrelude()
: HTTP3::BuildWebTransportControlStreamPrelude(/*maxSessions=*/16);
state->controlStream.SendSync(prelude.data(),
static_cast<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 (...) {
// If the connection died mid-handshake we land here; the peer
// gets dropped via destruction below.
// Connection died mid-handshake; drop the peer.
}
std::lock_guard lk(impl->peersMtx);
@ -223,9 +419,6 @@ void ListenerHTTP::Stop() {
void ListenerHTTP::Listen() {
if (!impl || !impl->listener) return;
// ListenSyncAsync runs the accept loop on this thread and dispatches the
// per-connection callback (control-stream open + OnStream wiring) on the
// ThreadPool. That keeps route handlers off the accept thread.
impl->listener->ListenSyncAsync();
}
@ -236,6 +429,14 @@ ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port,
, thread(&ListenerHTTP::Listen, &listener)
{}
ListenerAsyncHTTP::ListenerAsyncHTTP(std::uint16_t port,
QUICServerCredentials creds,
std::unordered_map<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() {
Stop();
}

View file

@ -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;
}

View file

@ -0,0 +1,152 @@
/*
Crafter®.Network
Copyright (C) 2026 Catcrafts®
Catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3.0 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
module;
module Crafter.Network:WebTransport_impl;
import :WebTransport;
import :ClientQUIC;
import :HTTP3;
import Crafter.Thread;
import std;
using namespace Crafter;
struct WebTransportSession::Impl {
// Non-owning. The session is constructed and owned by ListenerHTTP, and
// ListenerHTTP keeps the underlying ClientQUIC alive in its PeerState
// for the duration of the connection — so this pointer is valid as
// long as the session is.
ClientQUIC* connection = nullptr;
// The HTTP/3 extended-CONNECT bidi stream the session was upgraded on.
// Stays open for the session's lifetime. Phase 1 closes it with a bare
// FIN; later phases will emit a CLOSE_WEBTRANSPORT_SESSION capsule.
QUICStream connectStream;
std::mutex mtx;
std::function<void(QUICStream)> onStream;
std::deque<QUICStream> pendingStreams;
std::function<void(std::vector<char>)> onDatagram; // Phase 2
bool closed = false;
};
WebTransportSession::WebTransportSession()
: impl(std::make_unique<Impl>())
{}
WebTransportSession::WebTransportSession(WebTransportSession&&) noexcept = default;
WebTransportSession& WebTransportSession::operator=(WebTransportSession&&) noexcept = default;
WebTransportSession::~WebTransportSession() {
if (impl) Close();
}
QUICStream WebTransportSession::OpenStream(bool unidirectional) {
if (!impl || impl->closed || !impl->connection) {
throw QUICClosedException();
}
QUICStream stream = impl->connection->OpenStream(unidirectional);
auto prefix = unidirectional
? HTTP3::BuildWtUnidiPrefix(sessionId)
: HTTP3::BuildWtBidiPrefix(sessionId);
// Write the WT_STREAM (bidi) or stream-type (unidi) prefix as the
// first send on the stream. The peer reads it to associate this
// stream with our session before treating the rest as opaque payload.
stream.SendSync(prefix.data(), static_cast<std::uint32_t>(prefix.size()), /*finish=*/false);
return stream;
}
void WebTransportSession::OnStream(std::function<void(QUICStream)> callback) {
std::deque<QUICStream> drained;
{
std::lock_guard lk(impl->mtx);
impl->onStream = callback;
drained.swap(impl->pendingStreams);
}
// Dispatch any streams that arrived before the handler was installed.
// Each goes to the ThreadPool so user code runs off the demuxer thread.
for (auto& s : drained) {
auto* shared = new QUICStream(std::move(s));
ThreadPool::Enqueue([callback, shared]{
callback(std::move(*shared));
delete shared;
});
}
}
void WebTransportSession::OnDatagram(std::function<void(std::vector<char>)> callback) {
// Phase 1 stub. Phase 2 will plumb QUIC datagrams through here after
// demuxing on quarter_session_id.
if (impl) impl->onDatagram = std::move(callback);
}
void WebTransportSession::SendDatagram(const void*, std::uint32_t) {
// Phase 1 stub — would prepend quarter_session_id varint and call
// connection->SendDatagram. Drops silently for now.
}
void WebTransportSession::Close() {
if (!impl || impl->closed) return;
impl->closed = true;
try {
// Empty FIN on the CONNECT stream. Chrome / Firefox both treat
// peer-FIN of the CONNECT stream as session-close.
impl->connectStream.SendSync(nullptr, 0, /*finish=*/true);
} catch (...) {
// Connection may already be gone — that's fine.
}
}
// ─── Internal ListenerHTTP-facing helpers ───────────────────────────────
//
// Declared (not exported) in the interface partition so ListenerHTTP_impl
// can call them; defined here. Friendship in WebTransportSession gives
// them access to the private Impl.
namespace Crafter {
void WebTransportInitialise(WebTransportSession& session,
ClientQUIC* connection,
QUICStream connectStream,
std::uint64_t sessionId,
std::string path) {
session.impl->connection = connection;
session.impl->connectStream = std::move(connectStream);
session.sessionId = sessionId;
session.path = std::move(path);
}
void WebTransportDeliverStream(WebTransportSession& session, QUICStream stream) {
std::function<void(QUICStream)> cb;
{
std::lock_guard lk(session.impl->mtx);
cb = session.impl->onStream;
if (!cb) {
session.impl->pendingStreams.push_back(std::move(stream));
return;
}
}
auto* shared = new QUICStream(std::move(stream));
ThreadPool::Enqueue([cb, shared]{
cb(std::move(*shared));
delete shared;
});
}
}