browser wasm
This commit is contained in:
parent
28fab2509b
commit
e8630528af
24 changed files with 2490 additions and 100 deletions
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); }
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue