diff --git a/CMakeLists.txt b/CMakeLists.txt
index 95eff8fbfb..31ec15e505 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -304,7 +304,7 @@ include(cmake/GameABIConfig.cmake)
find_package(Threads REQUIRED)
set(GAME_COMPILE_DEFS DUSK_BUILDING_GAME=1)
set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd
- aurora::card borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::io borealis::log borealis::presentation borealis::sentry borealis::update freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt
+ aurora::card borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::io borealis::log borealis::net borealis::presentation borealis::sentry borealis::update borealis::ws freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt
Threads::Threads zstd::libzstd dusklight_game_headers)
if (DUSK_HAS_FUNCHOOK)
list(APPEND GAME_LIBS funchook-static)
diff --git a/docs/modding.md b/docs/modding.md
index d8cc8c9d75..7d7fa142a0 100644
--- a/docs/modding.md
+++ b/docs/modding.md
@@ -451,6 +451,82 @@ For large responses, set `downloadPath` to an absolute path in the calling mod's
an empty `body` and the final path in `downloadPath`. Check `Response::ok()` before using the file.
`Pending::progress()` reports download progress when the server provides a total size.
+### WebSocketService ([`mods/svc/websocket.h`](../sdk/include/mods/svc/websocket.h))
+
+WebSocket client connections with text or binary messages. Secure `wss://` URLs are supported everywhere. Insecure
+`ws://` is limited to `localhost`, `127.0.0.1`, and `[::1]`.
+
+```cpp
+#include "mods/svc/websocket.hpp"
+
+IMPORT_SERVICE(WebSocketService, svc_websocket);
+
+mods::ws::Connection connection;
+
+MOD_EXPORT ModResult mod_initialize(ModError*) {
+ connection = mods::ws::connect({.url = "wss://example.com/events"});
+ return connection ? MOD_OK : connection.result();
+}
+
+MOD_EXPORT ModResult mod_update(ModError*) {
+ mods::ws::Event event;
+ while (mods::ws::poll(event)) {
+ if (event.type == WEBSOCKET_EVENT_MESSAGE) {
+ consume(event.data);
+ } else if (event.type == WEBSOCKET_EVENT_CLOSED) {
+ schedule_reconnect(event.error);
+ }
+ }
+ return MOD_OK;
+}
+```
+
+Keep the `Connection` alive and drain `mods::ws::poll()` regularly, usually on every `mod_update` tick. A connection
+emits an (optional) `OPEN` event, zero to many `MESSAGE` events, and exactly one `CLOSED` event.
+
+**Restrictions:** A mod may only open four connections at once. Messages have a 1 MiB limit by default, and may request
+up to 16 MiB. Unread data is limited to 16 MiB and the outbound queue to 4 MiB. `send` returns `MOD_CONFLICT` when the
+outbound queue is full. Dropping the `Connection` or deactivating the mod attempts to gracefully close with code 1001,
+until the close deadline expires.
+
+### NetService ([`mods/svc/net.h`](../sdk/include/mods/svc/net.h))
+
+Asynchronous raw TCP and UDP networking. Endpoints can be `tcp://host:port` or `udp://host:port`. TCP connections and
+`resolve` accept hostnames. Listeners and UDP endpoints require IP literals.
+
+```cpp
+#include "mods/svc/net.hpp"
+
+IMPORT_SERVICE(NetService, svc_net);
+
+mods::net::BindOutcome bound;
+mods::net::Socket listener;
+mods::net::Socket client;
+
+MOD_EXPORT ModResult mod_initialize(ModError*) {
+ listener = mods::net::listen("tcp://127.0.0.1:0", &bound);
+ client = mods::net::connect(bound.local);
+ return listener && client ? MOD_OK : MOD_ERROR;
+}
+
+MOD_EXPORT ModResult mod_update(ModError*) {
+ mods::net::Event event;
+ while (mods::net::poll(event)) {
+ if (event.type == NET_EVENT_ACCEPTED) {
+ remember_client(mods::net::adopt(event.accepted));
+ } else if (event.type == NET_EVENT_STREAM_DATA) {
+ consume(event.data);
+ }
+ }
+ return MOD_OK;
+}
+```
+
+`send` and `send_to` copy the payload and return `MOD_CONFLICT` when the socket's outbound queue is full. `stats`
+reports queued bytes, traffic, dropped inbound datagrams, and asynchronous UDP send failures.
+
+**Restrictions:** A mod may only have 32 streams, 4 listeners, 4 UDP sockets, and 8 DNS resolutions active at once.
+
### HostService ([`mods/svc/host.h`](../sdk/include/mods/svc/host.h))
Mod metadata and runtime interaction with the loader.
diff --git a/extern/borealis b/extern/borealis
index 08de28e885..c3b8014495 160000
--- a/extern/borealis
+++ b/extern/borealis
@@ -1 +1 @@
-Subproject commit 08de28e885cd38a7c271a2d1ecd3f4f1c0620e06
+Subproject commit c3b80144952d4697c35ecac5c9dcaadcf1c598af
diff --git a/files.cmake b/files.cmake
index 3296b53064..bb5bebeaf1 100644
--- a/files.cmake
+++ b/files.cmake
@@ -1501,6 +1501,9 @@ set(DUSK_FILES
src/dusk/mods/svc/hook.cpp
src/dusk/mods/svc/host.cpp
src/dusk/mods/svc/http.cpp
+ src/dusk/mods/svc/net.cpp
+ src/dusk/mods/svc/net.hpp
+ src/dusk/mods/svc/websocket.cpp
src/dusk/mods/svc/item.cpp
src/dusk/mods/svc/item.hpp
src/dusk/mods/svc/log.cpp
diff --git a/platforms/android/app/src/main/AndroidManifest.xml b/platforms/android/app/src/main/AndroidManifest.xml
index b7270ee433..8dc7bf3236 100644
--- a/platforms/android/app/src/main/AndroidManifest.xml
+++ b/platforms/android/app/src/main/AndroidManifest.xml
@@ -19,6 +19,7 @@
android:appCategory="game"
android:icon="@mipmap/icon"
android:label="@string/app_name"
+ android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.NoTitleBar"
android:enableOnBackInvokedCallback="false">
diff --git a/platforms/android/app/src/main/res/xml/network_security_config.xml b/platforms/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000000..aa9543fd1f
--- /dev/null
+++ b/platforms/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,9 @@
+
+
+
+
+ localhost
+ 127.0.0.1
+ ::1
+
+
diff --git a/platforms/ios/Info.plist.in b/platforms/ios/Info.plist.in
index 395b29b7d7..9a9025b9f7 100644
--- a/platforms/ios/Info.plist.in
+++ b/platforms/ios/Info.plist.in
@@ -85,5 +85,12 @@
LSSupportsGameMode
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+ NSLocalNetworkUsageDescription
+ Dusklight allows network-enabled mods to connect to devices on your local network.
diff --git a/platforms/macos/Info.plist.in b/platforms/macos/Info.plist.in
index 7f29358033..3f564d88f7 100644
--- a/platforms/macos/Info.plist.in
+++ b/platforms/macos/Info.plist.in
@@ -32,5 +32,12 @@
public.app-category.adventure-games
LSSupportsGameMode
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+ NSLocalNetworkUsageDescription
+ Dusklight allows network-enabled mods to connect to devices on your local network.
diff --git a/platforms/tvos/Info.plist.in b/platforms/tvos/Info.plist.in
index 49ed85edd7..7f32e2d207 100644
--- a/platforms/tvos/Info.plist.in
+++ b/platforms/tvos/Info.plist.in
@@ -47,5 +47,12 @@
Automatic
LSSupportsGameMode
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+ NSLocalNetworkUsageDescription
+ Dusklight allows network-enabled mods to connect to devices on your local network.
diff --git a/sdk/include/mods/svc/net.h b/sdk/include/mods/svc/net.h
new file mode 100644
index 0000000000..2c5e9a531a
--- /dev/null
+++ b/sdk/include/mods/svc/net.h
@@ -0,0 +1,145 @@
+#pragma once
+
+#include
+
+#ifdef __cplusplus
+#include
+#endif
+
+#define NET_SERVICE_ID "dev.twilitrealm.dusklight.net"
+#define NET_SERVICE_MAJOR 1u
+#define NET_SERVICE_MINOR 0u
+
+/** 0 is never a valid handle. */
+typedef uint64_t NetHandle;
+
+typedef enum NetError {
+ NET_ERROR_NONE = 0,
+ NET_ERROR_INVALID_ENDPOINT = 1,
+ NET_ERROR_RESOLVE = 2,
+ NET_ERROR_TIMEOUT = 3,
+ NET_ERROR_REFUSED = 4,
+ NET_ERROR_UNREACHABLE = 5,
+ NET_ERROR_RESET = 6,
+ NET_ERROR_ADDRESS_IN_USE = 7,
+ NET_ERROR_PERMISSION = 8,
+ NET_ERROR_TOO_LARGE = 9,
+ NET_ERROR_CANCELED = 10,
+ NET_ERROR_NETWORK = 11,
+} NetError;
+
+#define NET_ENDPOINT_MAX 80
+/** NUL-terminated tcp://host:port or udp://host:port endpoint. */
+typedef struct NetEndpoint {
+ char text[NET_ENDPOINT_MAX];
+} NetEndpoint;
+
+typedef struct NetConnectDesc {
+ uint32_t struct_size;
+ /** TCP endpoint. Hostnames and IP literals are accepted. */
+ const char* endpoint;
+ /** 0 defaults to 10 seconds. Resolution is included. */
+ uint32_t connect_timeout_ms;
+ /** 0 defaults to 5 seconds for flush and peer EOF. */
+ uint32_t close_timeout_ms;
+ /** 0 defaults to 1 MiB. Maximum of 8 MiB. */
+ size_t max_send_queue_bytes;
+ bool no_delay;
+ /** Sampled when each event is polled. */
+ void* user_data;
+} NetConnectDesc;
+
+#define NET_CONNECT_DESC_INIT {sizeof(NetConnectDesc), NULL, 0u, 0u, 0u, true, NULL}
+
+typedef struct NetListenDesc {
+ uint32_t struct_size;
+ /** TCP endpoint with an IP literal. Port 0 requests an ephemeral port. */
+ const char* bind;
+ uint32_t close_timeout_ms;
+ size_t max_send_queue_bytes;
+ bool no_delay;
+ void* user_data;
+} NetListenDesc;
+
+#define NET_LISTEN_DESC_INIT {sizeof(NetListenDesc), NULL, 0u, 0u, true, NULL}
+
+typedef struct NetDatagramDesc {
+ uint32_t struct_size;
+ /** UDP endpoint with an IP literal. Port 0 requests an ephemeral port. */
+ const char* bind;
+ size_t max_send_queue_bytes;
+ void* user_data;
+} NetDatagramDesc;
+
+#define NET_DATAGRAM_DESC_INIT {sizeof(NetDatagramDesc), NULL, 0u, NULL}
+
+typedef enum NetEventType {
+ NET_EVENT_NONE = 0,
+ NET_EVENT_CONNECTED = 1,
+ NET_EVENT_ACCEPTED = 2,
+ NET_EVENT_STREAM_DATA = 3,
+ NET_EVENT_DATAGRAM = 4,
+ NET_EVENT_DROPPED = 5,
+ NET_EVENT_RESOLVED = 6,
+ NET_EVENT_CLOSED = 7,
+} NetEventType;
+
+typedef struct NetEvent {
+ uint32_t struct_size;
+ NetEventType type;
+ /** Source handle. A CLOSED or RESOLVED handle is invalid after poll_event returns it. */
+ NetHandle handle;
+ void* user_data;
+ NetHandle accepted;
+ /** Peer, datagram source, or resolved endpoint as applicable. */
+ NetEndpoint endpoint;
+ /** Valid until this mod's next poll_event call or deactivation. */
+ const void* data;
+ size_t size;
+ uint32_t dropped;
+ NetError error;
+ /** Never NULL. Valid until this mod's next poll_event call or deactivation. */
+ const char* error_message;
+} NetEvent;
+
+#define NET_EVENT_INIT \
+ {sizeof(NetEvent), NET_EVENT_NONE, 0u, NULL, 0u, {{0}}, NULL, 0u, 0u, NET_ERROR_NONE, ""}
+
+typedef struct NetStats {
+ uint32_t struct_size;
+ size_t queued_send_bytes;
+ uint64_t inbound_dropped;
+ uint64_t send_failures;
+ uint64_t bytes_sent;
+ uint64_t bytes_received;
+} NetStats;
+
+#define NET_STATS_INIT {sizeof(NetStats), 0u, 0u, 0u, 0u, 0u}
+
+typedef struct NetService {
+ ServiceHeader header;
+
+ /** Starts an asynchronous TCP connection. */
+ ModResult (*connect)(ModContext* ctx, const NetConnectDesc* desc, NetHandle* out_handle);
+ /** Opens a TCP listener and returns its local endpoint. */
+ ModResult (*listen)(ModContext* ctx, const NetListenDesc* desc, NetHandle* out_handle,
+ NetEndpoint* out_local, NetError* out_error);
+ /** Opens a UDP socket and returns its local endpoint. */
+ ModResult (*open_datagram)(ModContext* ctx, const NetDatagramDesc* desc, NetHandle* out_handle,
+ NetEndpoint* out_local, NetError* out_error);
+ /** Resolves a TCP or UDP endpoint asynchronously. */
+ ModResult (*resolve)(
+ ModContext* ctx, const char* endpoint, void* user_data, NetHandle* out_handle);
+ /** Returns MOD_OK and NET_EVENT_NONE when the queue is empty. */
+ ModResult (*poll_event)(ModContext* ctx, NetEvent* out_event);
+ /** Copies bytes to a connected stream's outbound queue. */
+ ModResult (*send)(ModContext* ctx, NetHandle stream, const void* data, size_t size);
+ /** Copies one datagram for a literal UDP destination. The maximum size is 65,507 bytes. */
+ ModResult (*send_to)(
+ ModContext* ctx, NetHandle socket, const char* endpoint, const void* data, size_t size);
+ ModResult (*set_user_data)(ModContext* ctx, NetHandle handle, void* user_data);
+ ModResult (*stats)(ModContext* ctx, NetHandle handle, NetStats* out_stats);
+ ModResult (*close)(ModContext* ctx, NetHandle handle);
+} NetService;
+
+MOD_DECLARE_SERVICE(NetService, svc_net, NET_SERVICE_ID, NET_SERVICE_MAJOR, NET_SERVICE_MINOR);
diff --git a/sdk/include/mods/svc/net.hpp b/sdk/include/mods/svc/net.hpp
new file mode 100644
index 0000000000..44edf4001b
--- /dev/null
+++ b/sdk/include/mods/svc/net.hpp
@@ -0,0 +1,189 @@
+#pragma once
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace mods::net {
+
+class Socket {
+public:
+ Socket() = default;
+ Socket(NetHandle handle, ModResult result) : mHandle{handle}, mResult{result} {}
+ ~Socket() { reset(); }
+
+ Socket(const Socket&) = delete;
+ Socket& operator=(const Socket&) = delete;
+ Socket(Socket&& other) noexcept { *this = std::move(other); }
+ Socket& operator=(Socket&& other) noexcept {
+ if (this != &other) {
+ reset();
+ mHandle = std::exchange(other.mHandle, 0);
+ mResult = other.mResult;
+ }
+ return *this;
+ }
+
+ explicit operator bool() const { return mResult == MOD_OK && mHandle != 0; }
+
+ ModResult result() const { return mResult; }
+ NetHandle handle() const { return mHandle; }
+
+ ModResult send(std::span bytes) const {
+ return svc_net != nullptr && mHandle != 0 ?
+ svc_net->send(mod_ctx, mHandle, bytes.data(), bytes.size()) :
+ MOD_UNAVAILABLE;
+ }
+
+ ModResult send_to(std::string_view endpoint, std::span bytes) const {
+ if (svc_net == nullptr || mHandle == 0) {
+ return MOD_UNAVAILABLE;
+ }
+ const std::string endpointText{endpoint};
+ return svc_net->send_to(mod_ctx, mHandle, endpointText.c_str(), bytes.data(), bytes.size());
+ }
+
+ std::optional stats() const {
+ if (svc_net == nullptr || mHandle == 0) {
+ return std::nullopt;
+ }
+ NetStats value = NET_STATS_INIT;
+ if (svc_net->stats(mod_ctx, mHandle, &value) != MOD_OK) {
+ return std::nullopt;
+ }
+ return value;
+ }
+
+ ModResult set_user_data(void* userData) const {
+ return svc_net != nullptr && mHandle != 0 ?
+ svc_net->set_user_data(mod_ctx, mHandle, userData) :
+ MOD_UNAVAILABLE;
+ }
+
+ ModResult close() {
+ if (mHandle == 0) {
+ return mResult == MOD_OK ? MOD_OK : MOD_UNAVAILABLE;
+ }
+ mResult = svc_net != nullptr ? svc_net->close(mod_ctx, mHandle) : MOD_UNAVAILABLE;
+ mHandle = 0;
+ return mResult;
+ }
+
+ void detach() { mHandle = 0; }
+
+private:
+ void reset() { (void)close(); }
+
+ NetHandle mHandle = 0;
+ ModResult mResult = MOD_UNAVAILABLE;
+};
+
+struct BindOutcome {
+ std::string local;
+ NetError error = NET_ERROR_NONE;
+};
+
+inline Socket connect(std::string_view endpoint, NetConnectDesc options = NET_CONNECT_DESC_INIT) {
+ if (svc_net == nullptr) {
+ return {0, MOD_UNAVAILABLE};
+ }
+ const std::string endpointText{endpoint};
+ options.struct_size = sizeof(options);
+ options.endpoint = endpointText.c_str();
+ NetHandle handle = 0;
+ const ModResult result = svc_net->connect(mod_ctx, &options, &handle);
+ return {handle, result};
+}
+
+inline Socket listen(std::string_view bind, BindOutcome* out = nullptr,
+ NetListenDesc options = NET_LISTEN_DESC_INIT) {
+ if (svc_net == nullptr) {
+ return {0, MOD_UNAVAILABLE};
+ }
+ const std::string bindText{bind};
+ options.struct_size = sizeof(options);
+ options.bind = bindText.c_str();
+ NetHandle handle = 0;
+ NetEndpoint local{};
+ NetError error = NET_ERROR_NONE;
+ const ModResult result = svc_net->listen(mod_ctx, &options, &handle, &local, &error);
+ if (out != nullptr) {
+ *out = {.local = local.text, .error = error};
+ }
+ return {handle, result};
+}
+
+inline Socket open_datagram(std::string_view bind, BindOutcome* out = nullptr,
+ NetDatagramDesc options = NET_DATAGRAM_DESC_INIT) {
+ if (svc_net == nullptr) {
+ return {0, MOD_UNAVAILABLE};
+ }
+ const std::string bindText{bind};
+ options.struct_size = sizeof(options);
+ options.bind = bindText.c_str();
+ NetHandle handle = 0;
+ NetEndpoint local{};
+ NetError error = NET_ERROR_NONE;
+ const ModResult result = svc_net->open_datagram(mod_ctx, &options, &handle, &local, &error);
+ if (out != nullptr) {
+ *out = {.local = local.text, .error = error};
+ }
+ return {handle, result};
+}
+
+inline Socket resolve(std::string_view endpoint, void* userData = nullptr) {
+ if (svc_net == nullptr) {
+ return {0, MOD_UNAVAILABLE};
+ }
+ const std::string endpointText{endpoint};
+ NetHandle handle = 0;
+ const ModResult result = svc_net->resolve(mod_ctx, endpointText.c_str(), userData, &handle);
+ return {handle, result};
+}
+
+inline Socket adopt(NetHandle accepted) {
+ return {accepted, accepted != 0 ? MOD_OK : MOD_INVALID_ARGUMENT};
+}
+
+struct Event {
+ NetEventType type = NET_EVENT_NONE;
+ NetHandle handle = 0;
+ void* userData = nullptr;
+ NetHandle accepted = 0;
+ std::string_view endpoint;
+ std::span data;
+ uint32_t dropped = 0;
+ NetError error = NET_ERROR_NONE;
+ std::string_view message;
+};
+
+inline bool poll(Event& out) {
+ out = {};
+ if (svc_net == nullptr) {
+ return false;
+ }
+ NetEvent raw = NET_EVENT_INIT;
+ if (svc_net->poll_event(mod_ctx, &raw) != MOD_OK || raw.type == NET_EVENT_NONE) {
+ return false;
+ }
+ out.type = raw.type;
+ out.handle = raw.handle;
+ out.userData = raw.user_data;
+ out.accepted = raw.accepted;
+ out.endpoint = raw.endpoint.text;
+ if (raw.data != nullptr && raw.size != 0) {
+ out.data = {static_cast(raw.data), raw.size};
+ }
+ out.dropped = raw.dropped;
+ out.error = raw.error;
+ out.message = raw.error_message != nullptr ? raw.error_message : "";
+ return true;
+}
+
+} // namespace mods::net
diff --git a/sdk/include/mods/svc/websocket.h b/sdk/include/mods/svc/websocket.h
new file mode 100644
index 0000000000..bfc12d8fca
--- /dev/null
+++ b/sdk/include/mods/svc/websocket.h
@@ -0,0 +1,107 @@
+#pragma once
+
+#include
+#include
+
+#ifdef __cplusplus
+#include
+#endif
+
+#define WEBSOCKET_SERVICE_ID "dev.twilitrealm.dusklight.websocket"
+#define WEBSOCKET_SERVICE_MAJOR 1u
+#define WEBSOCKET_SERVICE_MINOR 0u
+
+/** Generational connection handle. Zero is never valid. */
+typedef uint64_t WebSocketHandle;
+
+/** Connection outcome. Callers must tolerate values added by later service minors. */
+typedef enum WebSocketError {
+ WEBSOCKET_ERROR_NONE = 0,
+ WEBSOCKET_ERROR_INVALID_URL = 1,
+ WEBSOCKET_ERROR_UNSUPPORTED_SCHEME = 2,
+ WEBSOCKET_ERROR_TIMEOUT = 3,
+ WEBSOCKET_ERROR_TOO_LARGE = 4,
+ WEBSOCKET_ERROR_CANCELED = 5,
+ WEBSOCKET_ERROR_NETWORK = 6,
+ WEBSOCKET_ERROR_PROTOCOL = 7,
+ WEBSOCKET_ERROR_HANDSHAKE = 8,
+} WebSocketError;
+
+typedef enum WebSocketMessageKind {
+ WEBSOCKET_MESSAGE_TEXT = 0,
+ WEBSOCKET_MESSAGE_BINARY = 1,
+} WebSocketMessageKind;
+
+typedef struct WebSocketConnectDesc {
+ uint32_t struct_size;
+ /** wss:// URL, or ws:// for localhost, 127.0.0.1, or [::1]. */
+ const char* url;
+ /** Request headers. WebSocket handshake headers and User-Agent are reserved. */
+ const HttpHeader* headers;
+ uint32_t header_count;
+ const char* const* protocols;
+ uint32_t protocol_count;
+ uint32_t connect_timeout_ms;
+ uint32_t close_timeout_ms;
+ uint32_t keepalive_interval_ms;
+ /** 0 defaults to 1 MiB. Maximum of 16 MiB. */
+ size_t max_message_bytes;
+ /** Passed in every event. */
+ void* user_data;
+} WebSocketConnectDesc;
+
+#define WEBSOCKET_CONNECT_DESC_INIT \
+ {sizeof(WebSocketConnectDesc), NULL, NULL, 0u, NULL, 0u, 0u, 0u, 0u, 0u, NULL}
+
+typedef enum WebSocketEventType {
+ WEBSOCKET_EVENT_NONE = 0,
+ WEBSOCKET_EVENT_OPEN = 1,
+ WEBSOCKET_EVENT_MESSAGE = 2,
+ WEBSOCKET_EVENT_CLOSED = 3,
+} WebSocketEventType;
+
+typedef struct WebSocketEvent {
+ uint32_t struct_size;
+ WebSocketEventType type;
+ WebSocketHandle ws;
+ void* user_data;
+
+ const char* protocol;
+ /** Handshake response headers for OPEN or a handshake-rejected CLOSED event. */
+ const HttpHeader* headers;
+ uint32_t header_count;
+
+ WebSocketMessageKind message_kind;
+ /** Valid until this mod's next poll_event call or deactivation. */
+ const void* data;
+ size_t size;
+
+ WebSocketError error;
+ /** Never NULL. Valid until this mod's next poll_event call or deactivation. */
+ const char* error_message;
+ int32_t handshake_status;
+ uint16_t close_code;
+ const char* close_reason;
+} WebSocketEvent;
+
+#define WEBSOCKET_EVENT_INIT \
+ {sizeof(WebSocketEvent), WEBSOCKET_EVENT_NONE, 0u, NULL, "", NULL, 0u, WEBSOCKET_MESSAGE_TEXT, \
+ NULL, 0u, WEBSOCKET_ERROR_NONE, "", 0, 0u, ""}
+
+typedef struct WebSocketService {
+ ServiceHeader header;
+
+ /** Starts a connection. */
+ ModResult (*connect)(
+ ModContext* ctx, const WebSocketConnectDesc* desc, WebSocketHandle* out_handle);
+ /** Returns MOD_OK and WEBSOCKET_EVENT_NONE when the queue is empty. */
+ ModResult (*poll_event)(ModContext* ctx, WebSocketEvent* out_event);
+ /** Copies a message into the outbound queue. */
+ ModResult (*send)(ModContext* ctx, WebSocketHandle ws, WebSocketMessageKind kind,
+ const void* data, size_t size);
+ /** Code 0 defaults to 1000; accepted: 1000, 1001, and 3000-4999. */
+ ModResult (*close)(ModContext* ctx, WebSocketHandle ws, uint16_t code, const char* reason);
+} WebSocketService;
+
+MOD_DECLARE_SERVICE(WebSocketService, svc_websocket, WEBSOCKET_SERVICE_ID, WEBSOCKET_SERVICE_MAJOR,
+ WEBSOCKET_SERVICE_MINOR);
diff --git a/sdk/include/mods/svc/websocket.hpp b/sdk/include/mods/svc/websocket.hpp
new file mode 100644
index 0000000000..57d8b37005
--- /dev/null
+++ b/sdk/include/mods/svc/websocket.hpp
@@ -0,0 +1,172 @@
+#pragma once
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace mods::ws {
+
+struct Options {
+ std::string url;
+ std::vector headers;
+ std::vector protocols;
+ uint32_t connectTimeoutMs = 0;
+ uint32_t closeTimeoutMs = 0;
+ uint32_t keepaliveIntervalMs = 0;
+ size_t maxMessageBytes = 0;
+ void* userData = nullptr;
+};
+
+class Connection {
+public:
+ Connection() = default;
+ Connection(WebSocketHandle handle, ModResult result) : mHandle{handle}, mResult{result} {}
+ ~Connection() { reset(); }
+
+ Connection(const Connection&) = delete;
+ Connection& operator=(const Connection&) = delete;
+ Connection(Connection&& other) noexcept { *this = std::move(other); }
+ Connection& operator=(Connection&& other) noexcept {
+ if (this != &other) {
+ reset();
+ mHandle = std::exchange(other.mHandle, 0);
+ mResult = other.mResult;
+ }
+ return *this;
+ }
+
+ explicit operator bool() const { return mResult == MOD_OK && mHandle != 0; }
+
+ ModResult result() const { return mResult; }
+ WebSocketHandle handle() const { return mHandle; }
+
+ ModResult send(WebSocketMessageKind kind, std::span bytes) const {
+ return svc_websocket != nullptr && mHandle != 0 ?
+ svc_websocket->send(mod_ctx, mHandle, kind, bytes.data(), bytes.size()) :
+ MOD_UNAVAILABLE;
+ }
+
+ ModResult send_text(std::string_view text) const {
+ return svc_websocket != nullptr && mHandle != 0 ?
+ svc_websocket->send(
+ mod_ctx, mHandle, WEBSOCKET_MESSAGE_TEXT, text.data(), text.size()) :
+ MOD_UNAVAILABLE;
+ }
+
+ ModResult send_binary(std::span bytes) const {
+ return send(WEBSOCKET_MESSAGE_BINARY, bytes);
+ }
+
+ ModResult close(uint16_t code = 1000, std::string_view reason = {}) {
+ if (svc_websocket == nullptr || mHandle == 0) {
+ return MOD_UNAVAILABLE;
+ }
+ const std::string reasonText{reason};
+ mResult = svc_websocket->close(mod_ctx, mHandle, code, reasonText.c_str());
+ if (mResult == MOD_OK) {
+ mHandle = 0;
+ }
+ return mResult;
+ }
+
+ void detach() { mHandle = 0; }
+
+private:
+ void reset() {
+ if (mHandle != 0) {
+ (void)close(1001, "Connection owner released");
+ mHandle = 0;
+ }
+ }
+
+ WebSocketHandle mHandle = 0;
+ ModResult mResult = MOD_UNAVAILABLE;
+};
+
+inline Connection connect(const Options& options) {
+ if (svc_websocket == nullptr || options.headers.size() > std::numeric_limits::max() ||
+ options.protocols.size() > std::numeric_limits::max())
+ {
+ return {0, svc_websocket == nullptr ? MOD_UNAVAILABLE : MOD_INVALID_ARGUMENT};
+ }
+
+ std::vector headers;
+ headers.reserve(options.headers.size());
+ for (const auto& header : options.headers) {
+ headers.push_back({.name = header.name.c_str(), .value = header.value.c_str()});
+ }
+ std::vector protocols;
+ protocols.reserve(options.protocols.size());
+ for (const auto& protocol : options.protocols) {
+ protocols.push_back(protocol.c_str());
+ }
+
+ WebSocketConnectDesc desc = WEBSOCKET_CONNECT_DESC_INIT;
+ desc.url = options.url.c_str();
+ desc.headers = headers.empty() ? nullptr : headers.data();
+ desc.header_count = static_cast(headers.size());
+ desc.protocols = protocols.empty() ? nullptr : protocols.data();
+ desc.protocol_count = static_cast(protocols.size());
+ desc.connect_timeout_ms = options.connectTimeoutMs;
+ desc.close_timeout_ms = options.closeTimeoutMs;
+ desc.keepalive_interval_ms = options.keepaliveIntervalMs;
+ desc.max_message_bytes = options.maxMessageBytes;
+ desc.user_data = options.userData;
+
+ WebSocketHandle handle = 0;
+ const ModResult result = svc_websocket->connect(mod_ctx, &desc, &handle);
+ return {handle, result};
+}
+
+struct Event {
+ WebSocketEventType type = WEBSOCKET_EVENT_NONE;
+ WebSocketHandle handle = 0;
+ void* userData = nullptr;
+ std::string_view protocol;
+ std::span headers;
+ WebSocketMessageKind messageKind = WEBSOCKET_MESSAGE_TEXT;
+ std::span data;
+ WebSocketError error = WEBSOCKET_ERROR_NONE;
+ std::string_view message;
+ int handshakeStatus = 0;
+ uint16_t closeCode = 0;
+ std::string_view closeReason;
+};
+
+inline bool poll(Event& out) {
+ out = {};
+ if (svc_websocket == nullptr) {
+ return false;
+ }
+ WebSocketEvent raw = WEBSOCKET_EVENT_INIT;
+ if (svc_websocket->poll_event(mod_ctx, &raw) != MOD_OK || raw.type == WEBSOCKET_EVENT_NONE) {
+ return false;
+ }
+ out.type = raw.type;
+ out.handle = raw.ws;
+ out.userData = raw.user_data;
+ out.protocol = raw.protocol != nullptr ? raw.protocol : "";
+ if (raw.headers != nullptr && raw.header_count != 0) {
+ out.headers = {raw.headers, raw.header_count};
+ }
+ out.messageKind = raw.message_kind;
+ if (raw.data != nullptr && raw.size != 0) {
+ out.data = {static_cast(raw.data), raw.size};
+ }
+ out.error = raw.error;
+ out.message = raw.error_message != nullptr ? raw.error_message : "";
+ out.handshakeStatus = raw.handshake_status;
+ out.closeCode = raw.close_code;
+ out.closeReason = raw.close_reason != nullptr ? raw.close_reason : "";
+ return true;
+}
+
+} // namespace mods::ws
diff --git a/src/dusk/livesplit.cpp b/src/dusk/livesplit.cpp
index d583ef75fa..2bf01dada1 100644
--- a/src/dusk/livesplit.cpp
+++ b/src/dusk/livesplit.cpp
@@ -1,107 +1,80 @@
-#if _WIN32
-#include
-#include
-using socket_t = SOCKET;
-static void closeSocket(socket_t s) {
- LINGER li{1, 0};
- setsockopt(s, SOL_SOCKET, SO_LINGER, reinterpret_cast(&li), sizeof(li));
- closesocket(s);
-}
-static int socketError(socket_t s) {
- int err = 0;
- int len = sizeof(err);
- getsockopt(s, SOL_SOCKET, SO_ERROR, reinterpret_cast(&err), &len);
- return err;
-}
-static constexpr int kSendFlags = 0;
-#else
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-using socket_t = int;
-static void closeSocket(socket_t s) {
- struct linger li{1, 0};
- setsockopt(s, SOL_SOCKET, SO_LINGER, &li, sizeof(li));
- close(s);
-}
-static int socketError(socket_t s) {
- int err = 0;
- socklen_t len = sizeof(err);
- getsockopt(s, SOL_SOCKET, SO_ERROR, &err, &len);
- return err;
-}
-#ifndef INVALID_SOCKET
-#define INVALID_SOCKET -1
-#endif
-
-#if defined(__APPLE__)
-static constexpr int kSendFlags = 0;
-#else
-static constexpr int kSendFlags = MSG_NOSIGNAL;
-#endif
-#endif
-
-#include
#include "dusk/livesplit.h"
+
+#include "borealis/net.hpp"
+
#include "f_op/f_op_overlap_mng.h"
+#include
+#include
+#include
+#include
+
namespace dusk::speedrun {
+namespace {
-static bool running = false;
-static bool startPending = false;
-static uint64_t frameCount = 0;
-static socket_t sock = INVALID_SOCKET;
-static bool wasLoading = false;
-static bool connected = false;
-static bool connectPending = false;
-static bool disconnectPending = false;
-static uint32_t idleProbeCounter = 0;
-static uint32_t reconnectCounter = 0;
-static char storedHost[64] = "127.0.0.1";
-static int storedPort = 16834;
+bool running = false;
+bool startPending = false;
+uint64_t frameCount = 0;
+bool wasLoading = false;
+bool connected = false;
+bool connectPending = false;
+bool disconnectPending = false;
+uint32_t reconnectCounter = 0;
+std::string storedEndpoint = "tcp://127.0.0.1:16834";
+std::unique_ptr netContext;
+borealis::net::SocketId socketId = 0;
-static void sendCmd(const char* cmd) {
- if (sock == INVALID_SOCKET) {
+void send_cmd(const char* command) {
+ if (!netContext || !connected || socketId == 0) {
return;
}
- char msg[64];
- const int len = snprintf(msg, sizeof(msg), "%s\r\n", cmd);
- if (len <= 0 || len >= static_cast(sizeof(msg))) {
+ char message[64];
+ const int length = snprintf(message, sizeof(message), "%s\r\n", command);
+ if (length <= 0 || length >= static_cast(sizeof(message))) {
return;
}
- if (send(sock, msg, len, kSendFlags) >= 0) {
- if (!connected) {
- connected = connectPending = true;
- }
- return;
- }
-
-#if _WIN32
- const int err = WSAGetLastError();
- if (err == WSAEWOULDBLOCK || err == WSAENOTCONN) {
- return;
- }
-#else
- if (errno == EAGAIN || errno == EWOULDBLOCK || errno == ENOTCONN) {
- return;
- }
-#endif
-
- if (connected) {
- disconnectPending = true;
- }
- closeSocket(sock);
- sock = INVALID_SOCKET;
- connected = connectPending = false;
- reconnectCounter = 0;
+ const auto chars = std::span{message, static_cast(length)};
+ netContext->send(socketId, std::as_bytes(chars));
}
+void reconnect() {
+ netContext.reset();
+ netContext = std::make_unique();
+ connected = false;
+ connectPending = false;
+ socketId = netContext->connect(storedEndpoint);
+}
+
+void poll_network() {
+ if (!netContext) {
+ return;
+ }
+
+ borealis::net::Event event;
+ while (netContext->poll(event)) {
+ if (event.id != socketId) {
+ continue;
+ }
+ if (event.kind == borealis::net::Event::Kind::Connected) {
+ connected = true;
+ connectPending = true;
+ send_cmd("initgametime");
+ } else if (event.kind == borealis::net::Event::Kind::Closed) {
+ if (connected) {
+ disconnectPending = true;
+ }
+ connected = false;
+ connectPending = false;
+ socketId = 0;
+ reconnectCounter = 0;
+ }
+ }
+}
+
+} // namespace
+
uint64_t getFrameCount() {
return frameCount;
}
@@ -111,10 +84,9 @@ void onGameFrame() {
return;
}
- bool loading = fopOvlpM_IsDoingReq() != 0;
-
+ const bool loading = fopOvlpM_IsDoingReq() != 0;
if (loading != wasLoading) {
- sendCmd(loading ? "pausegametime" : "unpausegametime");
+ send_cmd(loading ? "pausegametime" : "unpausegametime");
wasLoading = loading;
}
@@ -141,172 +113,74 @@ void reset() {
startPending = false;
frameCount = 0;
wasLoading = false;
- sendCmd("reset");
-}
-
-static void reconnect() {
- if (sock != INVALID_SOCKET) {
- closeSocket(sock);
- sock = INVALID_SOCKET;
- }
- connected = connectPending = false;
-
- sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
- if (sock == INVALID_SOCKET) {
- return;
- }
-
-#if _WIN32
- u_long nb = 1;
- if (ioctlsocket(sock, FIONBIO, &nb) != 0) {
- closeSocket(sock);
- sock = INVALID_SOCKET;
- return;
- }
-#else
- const int fl = fcntl(sock, F_GETFL, 0);
- if (fl < 0 || fcntl(sock, F_SETFL, fl | O_NONBLOCK) < 0) {
- closeSocket(sock);
- sock = INVALID_SOCKET;
- return;
- }
-#endif
-
-#if defined(__APPLE__)
- {
- int opt = 1;
- setsockopt(sock, SOL_SOCKET, SO_NOSIGPIPE, &opt, sizeof(opt));
- }
-#endif
-
- sockaddr_in addr{};
- addr.sin_family = AF_INET;
- addr.sin_port = htons(static_cast(storedPort));
- if (inet_pton(AF_INET, storedHost, &addr.sin_addr) != 1) {
- closeSocket(sock);
- sock = INVALID_SOCKET;
- return;
- }
-
- const int cr = connect(sock, reinterpret_cast(&addr), sizeof(addr));
-#if _WIN32
- const bool connectPending_ = cr < 0 && WSAGetLastError() == WSAEWOULDBLOCK;
-#else
- const bool connectPending_ = cr < 0 && errno == EINPROGRESS;
-#endif
- if (cr != 0 && !connectPending_) {
- closeSocket(sock);
- sock = INVALID_SOCKET;
- }
+ send_cmd("reset");
}
void connectLiveSplit(const char* host, int port) {
-#if _WIN32
- WSADATA wd{};
- WSAStartup(MAKEWORD(2, 2), &wd);
-#endif
- snprintf(storedHost, sizeof(storedHost), "%s", host);
- storedPort = port;
+ std::string endpointHost = host;
+ if (endpointHost.find(':') != std::string::npos &&
+ !(endpointHost.starts_with('[') && endpointHost.ends_with(']')))
+ {
+ endpointHost = '[' + endpointHost + ']';
+ }
+ storedEndpoint = "tcp://" + endpointHost + ':' + std::to_string(port);
reconnect();
}
void disconnectLiveSplit() {
- if (sock != INVALID_SOCKET) {
- closeSocket(sock);
- sock = INVALID_SOCKET;
- }
- connected = connectPending = disconnectPending = false;
+ netContext.reset();
+ socketId = 0;
+ connected = false;
+ connectPending = false;
+ disconnectPending = false;
}
bool consumeConnectedEvent() {
- bool v = connectPending;
+ const bool value = connectPending;
connectPending = false;
- return v;
+ return value;
}
+
bool consumeDisconnectedEvent() {
- bool v = disconnectPending;
+ const bool value = disconnectPending;
disconnectPending = false;
- return v;
+ return value;
}
void updateLiveSplit() {
- if (sock == INVALID_SOCKET) {
+ poll_network();
+ if (socketId == 0) {
if ((reconnectCounter++ % 30) == 0) {
reconnect();
}
return;
}
-
if (!connected) {
- fd_set writefds, errorfds;
- FD_ZERO(&writefds);
- FD_ZERO(&errorfds);
- FD_SET(sock, &writefds);
- FD_SET(sock, &errorfds);
- timeval tv{0, 0};
-#if _WIN32
- const int r = select(0, nullptr, &writefds, &errorfds, &tv);
-#else
- const int r = select(sock + 1, nullptr, &writefds, &errorfds, &tv);
-#endif
- if (r < 0 || FD_ISSET(sock, &errorfds) || socketError(sock) != 0) {
- closeSocket(sock);
- sock = INVALID_SOCKET;
- reconnectCounter = 0;
- return;
- }
- if (!FD_ISSET(sock, &writefds)) {
- return;
- }
- sendCmd("initgametime");
return;
}
if (startPending) {
startPending = false;
- sendCmd("initgametime");
- sendCmd("reset");
- sendCmd("starttimer");
+ send_cmd("initgametime");
+ send_cmd("reset");
+ send_cmd("starttimer");
}
if (!running) {
- if ((idleProbeCounter++ % 60) == 0) {
- char buf;
- const int r = recv(sock, &buf, 1, 0);
- if (r == 0
-#if _WIN32
- || (r < 0 && WSAGetLastError() != WSAEWOULDBLOCK)
-#else
- || (r < 0 && errno != EAGAIN && errno != EWOULDBLOCK)
-#endif
- )
- {
- if (connected) {
- disconnectPending = true;
- }
- closeSocket(sock);
- sock = INVALID_SOCKET;
- connected = connectPending = false;
- reconnectCounter = 0;
- }
- }
return;
}
const uint64_t totalMs = frameCount * 1000 / 30;
const uint64_t totalSec = totalMs / 1000;
- char cmd[32];
- snprintf(cmd, sizeof(cmd), "setgametime %u:%02u:%02u.%03u",
+ char command[32];
+ snprintf(command, sizeof(command), "setgametime %u:%02u:%02u.%03u",
static_cast(totalSec / 3600), static_cast((totalSec / 60) % 60),
static_cast(totalSec % 60), static_cast(totalMs % 1000));
- sendCmd(cmd);
+ send_cmd(command);
}
void shutdown() {
disconnectLiveSplit();
-#if _WIN32
- WSACleanup();
-#endif
}
} // namespace dusk::speedrun
diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp
index 39211c9579..bacae852bb 100644
--- a/src/dusk/mods/loader/loader.cpp
+++ b/src/dusk/mods/loader/loader.cpp
@@ -1027,7 +1027,12 @@ void ModLoader::deactivate_mod(LoadedMod& mod) {
log::write(mod.metadata.id, LOG_LEVEL_ERROR, "{} failed: {}", shutdownName,
lifecycle_error_message(shutdownName, result, error));
}
+ } catch (const std::exception& exception) {
+ log::write(
+ mod.metadata.id, LOG_LEVEL_ERROR, "{} threw: {}", shutdownName, exception.what());
} catch (...) {
+ log::write(
+ mod.metadata.id, LOG_LEVEL_ERROR, "{} threw an unknown exception", shutdownName);
}
}
mod.initialized = false;
diff --git a/src/dusk/mods/svc/actor.cpp b/src/dusk/mods/svc/actor.cpp
index 237181e827..e3cc9411d7 100644
--- a/src/dusk/mods/svc/actor.cpp
+++ b/src/dusk/mods/svc/actor.cpp
@@ -2,8 +2,8 @@
#include "dusk/mods/svc/actor.hpp"
#include "config.hpp"
+#include "internal.hpp"
#include "registry.hpp"
-#include "slot_map.hpp"
#include "aurora/lib/logging.hpp"
#include "dusk/mod_loader.hpp"
diff --git a/src/dusk/mods/svc/camera.cpp b/src/dusk/mods/svc/camera.cpp
index 60fbf42499..b93ea00e62 100644
--- a/src/dusk/mods/svc/camera.cpp
+++ b/src/dusk/mods/svc/camera.cpp
@@ -1,5 +1,5 @@
+#include "internal.hpp"
#include "registry.hpp"
-#include "slot_map.hpp"
#include "dusk/camera_operators.hpp"
#include "dusk/mods/loader/loader.hpp"
diff --git a/src/dusk/mods/svc/config.cpp b/src/dusk/mods/svc/config.cpp
index b1bbf63705..cf8c249b3c 100644
--- a/src/dusk/mods/svc/config.cpp
+++ b/src/dusk/mods/svc/config.cpp
@@ -1,7 +1,7 @@
#include "config.hpp"
+#include "internal.hpp"
#include "registry.hpp"
-#include "slot_map.hpp"
#include
#include "dusk/config.hpp"
diff --git a/src/dusk/mods/svc/file.cpp b/src/dusk/mods/svc/file.cpp
index f1ede94a3c..8bbd4ced39 100644
--- a/src/dusk/mods/svc/file.cpp
+++ b/src/dusk/mods/svc/file.cpp
@@ -1,6 +1,6 @@
#include "registry.hpp"
-#include "slot_map.hpp"
+#include "internal.hpp"
#include
#include
diff --git a/src/dusk/mods/svc/game_mode.cpp b/src/dusk/mods/svc/game_mode.cpp
index cfa655baf8..e7b8c06915 100644
--- a/src/dusk/mods/svc/game_mode.cpp
+++ b/src/dusk/mods/svc/game_mode.cpp
@@ -2,8 +2,8 @@
#include "dusk/game_mode.hpp"
#include "config.hpp"
+#include "internal.hpp"
#include "registry.hpp"
-#include "slot_map.hpp"
#include "aurora/lib/logging.hpp"
#include "dusk/mod_loader.hpp"
diff --git a/src/dusk/mods/svc/gfx.cpp b/src/dusk/mods/svc/gfx.cpp
index 098894007a..9ec8f2415a 100644
--- a/src/dusk/mods/svc/gfx.cpp
+++ b/src/dusk/mods/svc/gfx.cpp
@@ -1,5 +1,5 @@
+#include "internal.hpp"
#include "registry.hpp"
-#include "slot_map.hpp"
#include "window.hpp"
#include
diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp
index a8e975e22f..114a1bc2ee 100644
--- a/src/dusk/mods/svc/host.cpp
+++ b/src/dusk/mods/svc/host.cpp
@@ -1,5 +1,5 @@
+#include "internal.hpp"
#include "registry.hpp"
-#include "slot_map.hpp"
#include "dusk/main.h"
#include "dusk/mods/loader/loader.hpp"
diff --git a/src/dusk/mods/svc/http.cpp b/src/dusk/mods/svc/http.cpp
index f533aa5832..2277e66e01 100644
--- a/src/dusk/mods/svc/http.cpp
+++ b/src/dusk/mods/svc/http.cpp
@@ -1,21 +1,20 @@
#include "registry.hpp"
-#include "slot_map.hpp"
+#include "internal.hpp"
+#include "net.hpp"
-#include "dusk/app_info.hpp"
#include "dusk/main.h"
#include "dusk/mods/loader/loader.hpp"
#include "mods/svc/http.h"
#include
#include
-#include
+#include
#include
#include
#include
#include
-#include
#include
#include
#include
@@ -38,6 +37,15 @@ constexpr size_t MaxRequestBodyBytes = 16 * 1024 * 1024;
constexpr size_t DefaultResponseBodyBytes = 1024 * 1024;
constexpr size_t MaxResponseBodyBytes = 64 * 1024 * 1024;
constexpr std::chrono::milliseconds DefaultTimeout{10000};
+constexpr std::string_view ReservedHeaders[]{
+ "User-Agent",
+ "Host",
+ "Content-Length",
+ "Connection",
+ "Accept-Encoding",
+ "Range",
+ "If-Range",
+};
struct PendingRequest {
HttpCompleteFn callback = nullptr;
@@ -52,53 +60,9 @@ static_assert(std::is_nothrow_move_constructible_v);
SlotMap s_requests;
-bool ascii_iequals(std::string_view left, std::string_view right) {
- return left.size() == right.size() && std::ranges::equal(left, right, [](char a, char b) {
- return std::tolower(static_cast(a)) ==
- std::tolower(static_cast(b));
- });
-}
-
-bool is_reserved_header(std::string_view name) {
- constexpr std::string_view reserved[]{
- "User-Agent",
- "Host",
- "Content-Length",
- "Connection",
- "Accept-Encoding",
- "Range",
- "If-Range",
- };
- return std::ranges::any_of(
- reserved, [&](std::string_view value) { return ascii_iequals(name, value); });
-}
-
-bool valid_header_name(std::string_view name) {
- constexpr std::string_view separators{"()<>@,;:\\\"/[]?={} \t"};
- return !name.empty() && std::ranges::all_of(name, [&](unsigned char value) {
- return value > 32 && value < 127 &&
- separators.find(static_cast(value)) == std::string_view::npos;
- });
-}
-
bool valid_url(std::string_view url) {
- constexpr std::string_view scheme{"https://"};
- if (!url.starts_with(scheme) || url.size() <= scheme.size() || url.size() > MaxUrlBytes) {
- return false;
- }
- if (std::ranges::any_of(url, [](unsigned char value) { return value <= 32 || value == 127; })) {
- return false;
- }
- const auto authorityEnd = url.find_first_of("/?#", scheme.size());
- const auto authority = url.substr(scheme.size(), authorityEnd - scheme.size());
- return !authority.empty();
-}
-
-bool declares_http_import(const LoadedMod& mod) {
- return std::ranges::any_of(
- mod.manifestInfo.imports, [](const ModManifestInfo::Import& serviceImport) {
- return serviceImport.id == HTTP_SERVICE_ID;
- });
+ const auto parsed = url.size() <= MaxUrlBytes ? borealis::url::parse(url) : std::nullopt;
+ return parsed && parsed->scheme == "https";
}
std::filesystem::path normalized_absolute(const std::filesystem::path& path, std::error_code& ec) {
@@ -311,14 +275,8 @@ void http_frame_begin() {
.download_path = downloadSucceeded ? publishedPath.c_str() : nullptr,
};
- try {
- callback(owner->context.get(), handle, &snapshot, userData);
- } catch (const std::exception& exception) {
- fail_mod(*owner, MOD_ERROR,
- std::string{"exception in HTTP completion callback: "} + exception.what());
- } catch (...) {
- fail_mod(*owner, MOD_ERROR, "unknown exception in HTTP completion callback");
- }
+ guarded_callback(*owner, "HTTP completion callback",
+ [&] { callback(owner->context.get(), handle, &snapshot, userData); });
s_requests.erase(handle);
}
}
@@ -343,17 +301,6 @@ bool staging_path_in_use(const LoadedMod& mod, const std::filesystem::path& path
return inUse;
}
-std::string user_agent_version(std::string_view version) {
- std::string result{version};
- for (char& ch : result) {
- const auto value = static_cast(ch);
- if (value <= 32 || value >= 127) {
- ch = '_';
- }
- }
- return result;
-}
-
ModResult start_request(LoadedMod& mod, const HttpRequestDesc& desc, HttpCompleteFn callback,
void* userData, HttpRequestHandle& outHandle) {
const std::string_view url{desc.url};
@@ -376,9 +323,7 @@ ModResult start_request(LoadedMod& mod, const HttpRequestDesc& desc, HttpComplet
}
const std::string_view name{header.name};
const std::string_view value{header.value};
- const bool invalidValue = std::ranges::any_of(
- value, [](unsigned char ch) { return (ch < 32 && ch != '\t') || ch == 127; });
- if (!valid_header_name(name) || invalidValue || is_reserved_header(name) ||
+ if (!valid_header(name, value, ReservedHeaders, true) ||
name.size() > MaxHeaderBytes - headerBytes)
{
return MOD_INVALID_ARGUMENT;
@@ -450,8 +395,7 @@ ModResult start_request(LoadedMod& mod, const HttpRequestDesc& desc, HttpComplet
}
request.headers.push_back({
.name = "User-Agent",
- .value = fmt::format("{}/{} {}/{}", AppName, BOREALIS_APP_VERSION, mod.metadata.id,
- user_agent_version(mod.metadata.version)),
+ .value = user_agent(mod),
});
auto task = borealis::http::start(std::move(request));
@@ -487,14 +431,10 @@ ModResult http_request(ModContext* context, const HttpRequestDesc* desc, HttpCom
{
return MOD_INVALID_ARGUMENT;
}
- if (!declares_http_import(*mod)) {
+ if (!declares_import(*mod, HTTP_SERVICE_ID)) {
return MOD_UNSUPPORTED;
}
- try {
- return start_request(*mod, *desc, callback, userData, *outHandle);
- } catch (...) {
- return MOD_ERROR;
- }
+ return start_request(*mod, *desc, callback, userData, *outHandle);
}
ModResult http_progress(ModContext* context, HttpRequestHandle handle, HttpProgress* outProgress) {
@@ -550,9 +490,9 @@ bool http_available() {
constexpr HttpService s_httpService{
.header = SERVICE_HEADER(HttpService, HTTP_SERVICE_MAJOR, HTTP_SERVICE_MINOR),
- .request = http_request,
- .progress = http_progress,
- .cancel = http_cancel,
+ .request = SERVICE_FUNCTION(http_request),
+ .progress = SERVICE_FUNCTION(http_progress),
+ .cancel = SERVICE_FUNCTION(http_cancel),
};
} // namespace
diff --git a/src/dusk/mods/svc/slot_map.hpp b/src/dusk/mods/svc/internal.hpp
similarity index 67%
rename from src/dusk/mods/svc/slot_map.hpp
rename to src/dusk/mods/svc/internal.hpp
index a97a4b80a5..496b2a58a5 100644
--- a/src/dusk/mods/svc/slot_map.hpp
+++ b/src/dusk/mods/svc/internal.hpp
@@ -1,5 +1,10 @@
#pragma once
+#include "../loader/loader.hpp"
+#include "../log_buffer.hpp"
+
+#include
+
#include
#include
#include
@@ -106,9 +111,7 @@ public:
return erase(handle);
}
- size_t erase_all(const LoadedMod& owner) {
- return take_all(owner).size();
- }
+ size_t erase_all(const LoadedMod& owner) { return take_all(owner).size(); }
template
void for_each(Fn&& fn) const {
@@ -123,9 +126,7 @@ public:
}
// Returns the index of the handle within the slot map.
- static constexpr uint32_t index_of(Handle handle) {
- return handle_index(handle);
- }
+ static constexpr uint32_t index_of(Handle handle) { return handle_index(handle); }
private:
struct Slot {
@@ -189,5 +190,69 @@ private:
std::vector m_freeSlots;
};
+template
+class PerMod {
+public:
+ template
+ T& get_or_create(LoadedMod& mod, Args&&... args) {
+ auto& state = m_states[&mod];
+ if (!state) {
+ state = std::make_unique(std::forward(args)...);
+ }
+ return *state;
+ }
+
+ T* find(LoadedMod& mod) {
+ const auto found = m_states.find(&mod);
+ return found != m_states.end() ? found->second.get() : nullptr;
+ }
+
+ const T* find(const LoadedMod& mod) const {
+ const auto found = m_states.find(&mod);
+ return found != m_states.end() ? found->second.get() : nullptr;
+ }
+
+ void erase(LoadedMod& mod) { m_states.erase(&mod); }
+ bool contains(const LoadedMod& mod) const { return m_states.contains(&mod); }
+ void clear() { m_states.clear(); }
+
+private:
+ std::unordered_map> m_states;
+};
+
+template
+ModResult guarded(ModContext* context, std::string_view operation, Fn&& fn) noexcept {
+ try {
+ return fn();
+ } catch (const std::exception& exception) {
+ log::emit(log::Source::Mod, mod_id_from_context(context), LOG_LEVEL_ERROR,
+ fmt::format("{}: {}", operation, exception.what()));
+ } catch (...) {
+ log::emit(log::Source::Mod, mod_id_from_context(context), LOG_LEVEL_ERROR,
+ fmt::format("{}: unknown exception", operation));
+ }
+ return MOD_ERROR;
+}
+
+// Wraps a mod callback, attempting to catch any escaping exceptions.
+// Immediately logs and fails the mod.
+template
+void guarded_callback(LoadedMod& mod, std::string_view operation, Fn&& fn) noexcept {
+ try {
+ fn();
+ } catch (const std::exception& exception) {
+ fail_mod(mod, MOD_ERROR, fmt::format("exception in {}: {}", operation, exception.what()));
+ } catch (...) {
+ fail_mod(mod, MOD_ERROR, fmt::format("unknown exception in {}", operation));
+ }
+}
+
} // namespace svc
} // namespace dusk::mods
+
+// Wraps service functions, catching C++ exceptions to avoid unwinding into mod code.
+#define SERVICE_FUNCTION(function) \
+ [](ModContext* context, auto... arguments) -> ModResult { \
+ return ::dusk::mods::svc::guarded( \
+ context, #function, [&] { return function(context, arguments...); }); \
+ }
diff --git a/src/dusk/mods/svc/net.cpp b/src/dusk/mods/svc/net.cpp
new file mode 100644
index 0000000000..4b929524cf
--- /dev/null
+++ b/src/dusk/mods/svc/net.cpp
@@ -0,0 +1,574 @@
+#include "registry.hpp"
+
+#include "internal.hpp"
+#include "net.hpp"
+
+#include "dusk/logging.h"
+#include "dusk/mods/loader/loader.hpp"
+#include "mods/svc/net.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace dusk::mods::svc {
+namespace {
+
+constexpr size_t MaxListenersPerMod = 4;
+constexpr size_t MaxStreamsPerMod = 32;
+constexpr size_t MaxDatagramsPerMod = 4;
+constexpr size_t MaxResolversPerMod = 8;
+constexpr size_t DefaultSendQueueBytes = 1024 * 1024;
+constexpr size_t MaxSendQueueBytes = 8 * 1024 * 1024;
+constexpr std::chrono::milliseconds DefaultConnectTimeout{10000};
+constexpr std::chrono::milliseconds DefaultCloseTimeout{5000};
+constexpr std::chrono::milliseconds InboundStallTimeout{30000};
+
+enum class HandleKind {
+ Listener,
+ Stream,
+ Datagram,
+ Resolver,
+};
+
+struct HandleState {
+ borealis::net::SocketId socket = 0;
+ HandleKind kind = HandleKind::Stream;
+ // Stream sends remain unavailable until the mod observes CONNECTED.
+ bool openPublished = false;
+};
+
+struct ModState {
+ ModState()
+ : context{borealis::net::ContextOptions{
+ .maxQueuedBytes = 16 * 1024 * 1024,
+ .maxQueuedEvents = 4096,
+ .maxStreams = MaxStreamsPerMod,
+ .maxListeners = MaxListenersPerMod,
+ .maxDatagramSockets = MaxDatagramsPerMod,
+ .maxResolvers = MaxResolversPerMod,
+ }} {}
+
+ borealis::net::Context context;
+ std::unordered_map handles;
+ borealis::net::Event currentEvent;
+};
+
+SlotMap s_handles;
+PerMod s_modStates;
+
+NetError map_error(borealis::net::Error error) {
+ switch (error) {
+ case borealis::net::Error::None:
+ return NET_ERROR_NONE;
+ case borealis::net::Error::InvalidEndpoint:
+ return NET_ERROR_INVALID_ENDPOINT;
+ case borealis::net::Error::Resolve:
+ return NET_ERROR_RESOLVE;
+ case borealis::net::Error::Timeout:
+ return NET_ERROR_TIMEOUT;
+ case borealis::net::Error::Refused:
+ return NET_ERROR_REFUSED;
+ case borealis::net::Error::Unreachable:
+ return NET_ERROR_UNREACHABLE;
+ case borealis::net::Error::Reset:
+ return NET_ERROR_RESET;
+ case borealis::net::Error::AddressInUse:
+ return NET_ERROR_ADDRESS_IN_USE;
+ case borealis::net::Error::Permission:
+ return NET_ERROR_PERMISSION;
+ case borealis::net::Error::TooLarge:
+ return NET_ERROR_TOO_LARGE;
+ case borealis::net::Error::Canceled:
+ return NET_ERROR_CANCELED;
+ case borealis::net::Error::Network:
+ default:
+ return NET_ERROR_NETWORK;
+ }
+}
+
+ModResult map_send_result(borealis::net::SendResult result) {
+ switch (result) {
+ case borealis::net::SendResult::Ok:
+ return MOD_OK;
+ case borealis::net::SendResult::NotOpen:
+ return MOD_UNAVAILABLE;
+ case borealis::net::SendResult::QueueFull:
+ return MOD_CONFLICT;
+ case borealis::net::SendResult::TooLarge:
+ case borealis::net::SendResult::InvalidEndpoint:
+ return MOD_INVALID_ARGUMENT;
+ }
+ return MOD_ERROR;
+}
+
+NetEventType map_event(borealis::net::Event::Kind kind) {
+ switch (kind) {
+ case borealis::net::Event::Kind::Connected:
+ return NET_EVENT_CONNECTED;
+ case borealis::net::Event::Kind::Accepted:
+ return NET_EVENT_ACCEPTED;
+ case borealis::net::Event::Kind::StreamData:
+ return NET_EVENT_STREAM_DATA;
+ case borealis::net::Event::Kind::Datagram:
+ return NET_EVENT_DATAGRAM;
+ case borealis::net::Event::Kind::Dropped:
+ return NET_EVENT_DROPPED;
+ case borealis::net::Event::Kind::Resolved:
+ return NET_EVENT_RESOLVED;
+ case borealis::net::Event::Kind::Closed:
+ return NET_EVENT_CLOSED;
+ default:
+ return NET_EVENT_NONE;
+ }
+}
+
+bool valid_endpoint(std::string_view text, std::string_view scheme, bool requireLiteral) {
+ if (text.empty() || text.size() >= NET_ENDPOINT_MAX) {
+ return false;
+ }
+ const auto endpoint = borealis::net::parse_endpoint(text);
+ return endpoint && endpoint->scheme == scheme && (!requireLiteral || endpoint->literal);
+}
+
+bool copy_endpoint(NetEndpoint& output, std::string_view endpoint) {
+ if (endpoint.size() >= sizeof(output.text)) {
+ output.text[0] = '\0';
+ return false;
+ }
+ std::memcpy(output.text, endpoint.data(), endpoint.size());
+ output.text[endpoint.size()] = '\0';
+ return true;
+}
+
+size_t bounded_send_queue(size_t requested) {
+ return requested != 0 ? requested : DefaultSendQueueBytes;
+}
+
+std::chrono::milliseconds timeout_or_default(uint32_t value, std::chrono::milliseconds fallback) {
+ return value != 0 ? std::chrono::milliseconds{value} : fallback;
+}
+
+size_t count_handles(const LoadedMod& mod, HandleKind kind) {
+ size_t count = 0;
+ s_handles.for_each([&](NetHandle, const auto& entry) {
+ if (entry.owner == &mod && entry.value.kind == kind) {
+ ++count;
+ }
+ });
+ return count;
+}
+
+ModState& state_for(LoadedMod& mod) {
+ return s_modStates.get_or_create(mod);
+}
+
+ModState* find_state(LoadedMod& mod) {
+ return s_modStates.find(mod);
+}
+
+NetHandle add_handle(LoadedMod& mod, ModState& state, borealis::net::SocketId socket,
+ HandleKind kind, bool openPublished) {
+ const NetHandle handle = s_handles.emplace(mod, HandleState{
+ .socket = socket,
+ .kind = kind,
+ .openPublished = openPublished,
+ });
+ state.handles.emplace(socket, handle);
+ return handle;
+}
+
+ModResult net_connect(ModContext* context, const NetConnectDesc* desc, NetHandle* outHandle) {
+ if (outHandle != nullptr) {
+ *outHandle = 0;
+ }
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(NetConnectDesc) ||
+ desc->endpoint == nullptr || outHandle == nullptr)
+ {
+ return MOD_INVALID_ARGUMENT;
+ }
+ if (!declares_import(*mod, NET_SERVICE_ID)) {
+ return MOD_UNSUPPORTED;
+ }
+ const std::string_view endpoint{desc->endpoint};
+ if (!valid_endpoint(endpoint, "tcp", false) || desc->max_send_queue_bytes > MaxSendQueueBytes) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ if (!borealis::net::available()) {
+ return MOD_UNAVAILABLE;
+ }
+ if (count_handles(*mod, HandleKind::Stream) >= MaxStreamsPerMod) {
+ return MOD_CONFLICT;
+ }
+ auto& state = state_for(*mod);
+ const size_t maxSendQueueBytes = bounded_send_queue(desc->max_send_queue_bytes);
+ const borealis::net::SocketId socket = state.context.connect(endpoint,
+ borealis::net::StreamOptions{
+ .connectTimeout = timeout_or_default(desc->connect_timeout_ms, DefaultConnectTimeout),
+ .closeTimeout = timeout_or_default(desc->close_timeout_ms, DefaultCloseTimeout),
+ .inboundStallTimeout = InboundStallTimeout,
+ .maxSendQueueBytes = maxSendQueueBytes,
+ .readChunkBytes = 64 * 1024,
+ .noDelay = desc->no_delay,
+ },
+ desc->user_data);
+ if (socket == 0) {
+ return MOD_CONFLICT;
+ }
+ *outHandle = add_handle(*mod, state, socket, HandleKind::Stream, false);
+ return MOD_OK;
+}
+
+ModResult net_listen(ModContext* context, const NetListenDesc* desc, NetHandle* outHandle,
+ NetEndpoint* outLocal, NetError* outError) {
+ if (outHandle != nullptr) {
+ *outHandle = 0;
+ }
+ if (outLocal != nullptr) {
+ *outLocal = {};
+ }
+ if (outError != nullptr) {
+ *outError = NET_ERROR_NONE;
+ }
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(NetListenDesc) ||
+ desc->bind == nullptr || outHandle == nullptr || outLocal == nullptr)
+ {
+ return MOD_INVALID_ARGUMENT;
+ }
+ if (!declares_import(*mod, NET_SERVICE_ID)) {
+ return MOD_UNSUPPORTED;
+ }
+ const std::string_view bind{desc->bind};
+ if (!valid_endpoint(bind, "tcp", true) || desc->max_send_queue_bytes > MaxSendQueueBytes) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ if (!borealis::net::available()) {
+ return MOD_UNAVAILABLE;
+ }
+ if (count_handles(*mod, HandleKind::Listener) >= MaxListenersPerMod) {
+ return MOD_CONFLICT;
+ }
+ auto& state = state_for(*mod);
+ const size_t maxSendQueueBytes = bounded_send_queue(desc->max_send_queue_bytes);
+ const auto result = state.context.listen(bind,
+ borealis::net::ListenOptions{
+ .accepted =
+ borealis::net::StreamOptions{
+ .connectTimeout = DefaultConnectTimeout,
+ .closeTimeout = timeout_or_default(desc->close_timeout_ms, DefaultCloseTimeout),
+ .inboundStallTimeout = InboundStallTimeout,
+ .maxSendQueueBytes = maxSendQueueBytes,
+ .readChunkBytes = 64 * 1024,
+ .noDelay = desc->no_delay,
+ },
+ .backlog = 64,
+ .reuseAddress = true,
+ },
+ desc->user_data);
+ if (result.id == 0) {
+ if (outError != nullptr) {
+ *outError = map_error(result.error);
+ }
+ DuskLog.error("[{}] could not listen on '{}': {}", mod->metadata.id, bind, result.message);
+ return MOD_ERROR;
+ }
+ if (!copy_endpoint(*outLocal, result.localEndpoint)) {
+ state.context.close(result.id);
+ return MOD_ERROR;
+ }
+ *outHandle = add_handle(*mod, state, result.id, HandleKind::Listener, true);
+ return MOD_OK;
+}
+
+ModResult net_open_datagram(ModContext* context, const NetDatagramDesc* desc, NetHandle* outHandle,
+ NetEndpoint* outLocal, NetError* outError) {
+ if (outHandle != nullptr) {
+ *outHandle = 0;
+ }
+ if (outLocal != nullptr) {
+ *outLocal = {};
+ }
+ if (outError != nullptr) {
+ *outError = NET_ERROR_NONE;
+ }
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(NetDatagramDesc) ||
+ desc->bind == nullptr || outHandle == nullptr || outLocal == nullptr)
+ {
+ return MOD_INVALID_ARGUMENT;
+ }
+ if (!declares_import(*mod, NET_SERVICE_ID)) {
+ return MOD_UNSUPPORTED;
+ }
+ const std::string_view bind{desc->bind};
+ if (!valid_endpoint(bind, "udp", true) || desc->max_send_queue_bytes > MaxSendQueueBytes) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ if (!borealis::net::available()) {
+ return MOD_UNAVAILABLE;
+ }
+ if (count_handles(*mod, HandleKind::Datagram) >= MaxDatagramsPerMod) {
+ return MOD_CONFLICT;
+ }
+ auto& state = state_for(*mod);
+ const size_t maxSendQueueBytes = bounded_send_queue(desc->max_send_queue_bytes);
+ const auto result = state.context.open_datagram(bind,
+ borealis::net::DatagramOptions{
+ .maxSendQueueBytes = maxSendQueueBytes,
+ .recvBufferBytes = 1024 * 1024,
+ .sendBufferBytes = 1024 * 1024,
+ },
+ desc->user_data);
+ if (result.id == 0) {
+ if (outError != nullptr) {
+ *outError = map_error(result.error);
+ }
+ DuskLog.error("[{}] could not bind datagram socket on '{}': {}", mod->metadata.id, bind,
+ result.message);
+ return MOD_ERROR;
+ }
+ if (!copy_endpoint(*outLocal, result.localEndpoint)) {
+ state.context.close(result.id);
+ return MOD_ERROR;
+ }
+ *outHandle = add_handle(*mod, state, result.id, HandleKind::Datagram, true);
+ return MOD_OK;
+}
+
+ModResult net_resolve(
+ ModContext* context, const char* endpoint, void* userData, NetHandle* outHandle) {
+ if (outHandle != nullptr) {
+ *outHandle = 0;
+ }
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || endpoint == nullptr || outHandle == nullptr) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ if (!declares_import(*mod, NET_SERVICE_ID)) {
+ return MOD_UNSUPPORTED;
+ }
+ const std::string_view endpointText{endpoint};
+ if (endpointText.empty() || endpointText.size() >= NET_ENDPOINT_MAX ||
+ !borealis::net::parse_endpoint(endpointText))
+ {
+ return MOD_INVALID_ARGUMENT;
+ }
+ if (!borealis::net::available()) {
+ return MOD_UNAVAILABLE;
+ }
+ if (count_handles(*mod, HandleKind::Resolver) >= MaxResolversPerMod) {
+ return MOD_CONFLICT;
+ }
+ auto& state = state_for(*mod);
+ const auto socket = state.context.resolve(endpointText, userData);
+ if (socket == 0) {
+ return MOD_CONFLICT;
+ }
+ *outHandle = add_handle(*mod, state, socket, HandleKind::Resolver, false);
+ return MOD_OK;
+}
+
+ModResult net_poll_event(ModContext* context, NetEvent* outEvent) {
+ const uint32_t structSize = outEvent != nullptr ? outEvent->struct_size : 0;
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || outEvent == nullptr || structSize < sizeof(NetEvent)) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ NetEvent output{.struct_size = structSize, .error_message = ""};
+ auto* state = find_state(*mod);
+ if (state == nullptr) {
+ std::memcpy(outEvent, &output, sizeof(output));
+ return MOD_OK;
+ }
+
+ while (state->context.poll(state->currentEvent)) {
+ const auto handleFound = state->handles.find(state->currentEvent.id);
+ if (handleFound == state->handles.end()) {
+ continue;
+ }
+ const NetHandle handle = handleFound->second;
+ auto* handleState = s_handles.find_owned(handle, *mod);
+ if (handleState == nullptr) {
+ continue;
+ }
+ output.type = map_event(state->currentEvent.kind);
+ output.handle = handle;
+ output.user_data = state->currentEvent.userData;
+ output.data = state->currentEvent.data.data();
+ output.size = state->currentEvent.data.size();
+ output.dropped = state->currentEvent.dropped;
+ output.error = map_error(state->currentEvent.error);
+ output.error_message = state->currentEvent.message.c_str();
+ if (!copy_endpoint(output.endpoint, state->currentEvent.endpoint)) {
+ output.endpoint = {};
+ }
+
+ if (state->currentEvent.kind == borealis::net::Event::Kind::Connected) {
+ handleState->value.openPublished = true;
+ } else if (state->currentEvent.kind == borealis::net::Event::Kind::Accepted) {
+ const NetHandle accepted =
+ add_handle(*mod, *state, state->currentEvent.accepted, HandleKind::Stream, true);
+ output.accepted = accepted;
+ } else if (state->currentEvent.kind == borealis::net::Event::Kind::Closed ||
+ state->currentEvent.kind == borealis::net::Event::Kind::Resolved)
+ {
+ state->handles.erase(state->currentEvent.id);
+ s_handles.erase(handle);
+ }
+
+ std::memcpy(outEvent, &output, sizeof(output));
+ return MOD_OK;
+ }
+
+ std::memcpy(outEvent, &output, sizeof(output));
+ return MOD_OK;
+}
+
+ModResult net_send(ModContext* context, NetHandle handle, const void* data, size_t size) {
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || (size != 0 && data == nullptr)) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ auto* handleState = s_handles.find_owned(handle, *mod);
+ auto* state = find_state(*mod);
+ if (handleState == nullptr || state == nullptr ||
+ handleState->value.kind != HandleKind::Stream || !handleState->value.openPublished)
+ {
+ return MOD_UNAVAILABLE;
+ }
+ const auto* bytes = static_cast(data);
+ return map_send_result(state->context.send(handleState->value.socket, {bytes, size}));
+}
+
+ModResult net_send_to(
+ ModContext* context, NetHandle handle, const char* endpoint, const void* data, size_t size) {
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || endpoint == nullptr || (size != 0 && data == nullptr)) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ auto* handleState = s_handles.find_owned(handle, *mod);
+ auto* state = find_state(*mod);
+ if (handleState == nullptr || state == nullptr ||
+ handleState->value.kind != HandleKind::Datagram)
+ {
+ return MOD_UNAVAILABLE;
+ }
+ const auto* bytes = static_cast(data);
+ return map_send_result(
+ state->context.send_to(handleState->value.socket, endpoint, {bytes, size}));
+}
+
+ModResult net_set_user_data(ModContext* context, NetHandle handle, void* userData) {
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ const auto* handleState = s_handles.find_owned(handle, *mod);
+ auto* state = find_state(*mod);
+ if (handleState == nullptr || state == nullptr) {
+ return MOD_UNAVAILABLE;
+ }
+ state->context.set_user_data(handleState->value.socket, userData);
+ return MOD_OK;
+}
+
+ModResult net_stats(ModContext* context, NetHandle handle, NetStats* outStats) {
+ const uint32_t structSize = outStats != nullptr ? outStats->struct_size : 0;
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || outStats == nullptr || structSize < sizeof(NetStats)) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ NetStats output{.struct_size = structSize};
+ const auto* handleState = s_handles.find_owned(handle, *mod);
+ auto* state = find_state(*mod);
+ if (handleState == nullptr || state == nullptr) {
+ std::memcpy(outStats, &output, sizeof(output));
+ return MOD_UNAVAILABLE;
+ }
+ const auto stats = state->context.stats(handleState->value.socket);
+ if (!stats) {
+ std::memcpy(outStats, &output, sizeof(output));
+ return MOD_UNAVAILABLE;
+ }
+ output.queued_send_bytes = stats->queuedSendBytes;
+ output.inbound_dropped = stats->inboundDropped;
+ output.send_failures = stats->sendFailures;
+ output.bytes_sent = stats->bytesSent;
+ output.bytes_received = stats->bytesReceived;
+ std::memcpy(outStats, &output, sizeof(output));
+ return MOD_OK;
+}
+
+ModResult net_close(ModContext* context, NetHandle handle) {
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ auto* handleState = s_handles.find_owned(handle, *mod);
+ auto* state = find_state(*mod);
+ if (handleState == nullptr || state == nullptr) {
+ return MOD_UNAVAILABLE;
+ }
+ state->context.close(handleState->value.socket);
+ return MOD_OK;
+}
+
+void net_mod_deactivating(LoadedMod& mod) {
+ (void)s_handles.take_all(mod);
+ s_modStates.erase(mod);
+}
+
+void net_mod_detached(LoadedMod& mod) {
+ assert(find_state(mod) == nullptr);
+ bool found = false;
+ s_handles.for_each([&](NetHandle, const auto& entry) { found = found || entry.owner == &mod; });
+ assert(!found);
+}
+
+void net_shutdown() {
+ s_modStates.clear();
+ s_handles = {};
+}
+
+bool net_available() {
+ return borealis::net::available();
+}
+
+constexpr NetService s_netService{
+ .header = SERVICE_HEADER(NetService, NET_SERVICE_MAJOR, NET_SERVICE_MINOR),
+ .connect = SERVICE_FUNCTION(net_connect),
+ .listen = SERVICE_FUNCTION(net_listen),
+ .open_datagram = SERVICE_FUNCTION(net_open_datagram),
+ .resolve = SERVICE_FUNCTION(net_resolve),
+ .poll_event = SERVICE_FUNCTION(net_poll_event),
+ .send = SERVICE_FUNCTION(net_send),
+ .send_to = SERVICE_FUNCTION(net_send_to),
+ .set_user_data = SERVICE_FUNCTION(net_set_user_data),
+ .stats = SERVICE_FUNCTION(net_stats),
+ .close = SERVICE_FUNCTION(net_close),
+};
+
+} // namespace
+
+constinit const ServiceModule g_netModule{
+ .id = NET_SERVICE_ID,
+ .majorVersion = NET_SERVICE_MAJOR,
+ .minorVersion = NET_SERVICE_MINOR,
+ .service = &s_netService,
+ .available = net_available,
+ .modDeactivating = net_mod_deactivating,
+ .modDetached = net_mod_detached,
+ .shutdown = net_shutdown,
+};
+
+} // namespace dusk::mods::svc
diff --git a/src/dusk/mods/svc/net.hpp b/src/dusk/mods/svc/net.hpp
new file mode 100644
index 0000000000..34ca17362f
--- /dev/null
+++ b/src/dusk/mods/svc/net.hpp
@@ -0,0 +1,79 @@
+#pragma once
+
+#include "dusk/app_info.hpp"
+#include "dusk/mods/loader/loader.hpp"
+#include "mods/svc/http.h"
+#include "mods/svc/net.h"
+#include "mods/svc/websocket.h"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+namespace dusk::mods::svc {
+
+inline bool ascii_iequals(std::string_view left, std::string_view right) {
+ if (left.size() != right.size()) {
+ return false;
+ }
+ const auto ascii_lower = [](char value) {
+ return value >= 'A' && value <= 'Z' ? static_cast(value - 'A' + 'a') : value;
+ };
+ return std::ranges::equal(
+ left, right, [&](char a, char b) { return ascii_lower(a) == ascii_lower(b); });
+}
+
+inline bool valid_header_name(std::string_view name) {
+ constexpr std::string_view Separators{"()<>@,;:\\\"/[]?={} \t"};
+ return !name.empty() && std::ranges::all_of(name, [&](unsigned char value) {
+ return value > 32 && value < 127 &&
+ Separators.find(static_cast(value)) == std::string_view::npos;
+ });
+}
+
+inline bool valid_header_value(std::string_view value, bool allowHorizontalTab) {
+ return std::ranges::all_of(value, [=](unsigned char character) {
+ return character >= 32 || (allowHorizontalTab && character == '\t');
+ }) && std::ranges::none_of(value, [](unsigned char character) { return character == 127; });
+}
+
+inline bool is_reserved_header(std::string_view name, std::span reserved) {
+ return std::ranges::any_of(
+ reserved, [&](std::string_view value) { return ascii_iequals(name, value); });
+}
+
+inline bool valid_header(std::string_view name, std::string_view value,
+ std::span reserved, bool allowHorizontalTab = false) {
+ return valid_header_name(name) && valid_header_value(value, allowHorizontalTab) &&
+ !is_reserved_header(name, reserved);
+}
+
+inline bool declares_import(const LoadedMod& mod, std::string_view serviceId) {
+ return std::ranges::any_of(
+ mod.manifestInfo.imports, [&](const ModManifestInfo::Import& serviceImport) {
+ return serviceImport.id == serviceId;
+ });
+}
+
+inline bool is_network_service(std::string_view serviceId) {
+ return serviceId == HTTP_SERVICE_ID || serviceId == NET_SERVICE_ID ||
+ serviceId == WEBSOCKET_SERVICE_ID;
+}
+
+inline std::string user_agent(const LoadedMod& mod) {
+ std::string version{mod.metadata.version};
+ for (char& character : version) {
+ const auto value = static_cast(character);
+ if (value <= 32 || value >= 127) {
+ character = '_';
+ }
+ }
+ return fmt::format("{}/{} {}/{}", AppName, BOREALIS_APP_VERSION, mod.metadata.id, version);
+}
+
+} // namespace dusk::mods::svc::network
diff --git a/src/dusk/mods/svc/overlay.cpp b/src/dusk/mods/svc/overlay.cpp
index f4ad2ff6a6..d32cc8069c 100644
--- a/src/dusk/mods/svc/overlay.cpp
+++ b/src/dusk/mods/svc/overlay.cpp
@@ -1,5 +1,5 @@
+#include "internal.hpp"
#include "registry.hpp"
-#include "slot_map.hpp"
#include
#include "JSystem/JKernel/JKRArchive.h"
diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp
index a26e2971dc..33ceede924 100644
--- a/src/dusk/mods/svc/registry.cpp
+++ b/src/dusk/mods/svc/registry.cpp
@@ -215,6 +215,8 @@ void ModLoader::init_services() {
&svc::g_resourceModule,
&svc::g_fileModule,
&svc::g_httpModule,
+ &svc::g_netModule,
+ &svc::g_websocketModule,
&svc::g_hookModule,
&svc::g_overlayModule,
&svc::g_textureModule,
diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp
index 6f501c3d99..470208fad1 100644
--- a/src/dusk/mods/svc/registry.hpp
+++ b/src/dusk/mods/svc/registry.hpp
@@ -72,6 +72,8 @@ extern const ServiceModule g_logModule;
extern const ServiceModule g_resourceModule;
extern const ServiceModule g_fileModule;
extern const ServiceModule g_httpModule;
+extern const ServiceModule g_netModule;
+extern const ServiceModule g_websocketModule;
extern const ServiceModule g_hookModule;
extern const ServiceModule g_overlayModule;
extern const ServiceModule g_textureModule;
diff --git a/src/dusk/mods/svc/ui.cpp b/src/dusk/mods/svc/ui.cpp
index 10f864064f..7516b893bf 100644
--- a/src/dusk/mods/svc/ui.cpp
+++ b/src/dusk/mods/svc/ui.cpp
@@ -1,8 +1,8 @@
#include "ui.hpp"
#include "config.hpp"
+#include "internal.hpp"
#include "registry.hpp"
-#include "slot_map.hpp"
#include "ui_v1.hpp"
#include
@@ -45,8 +45,7 @@ constexpr size_t kUiControlSelectedSize =
offsetof(UiControlDesc, is_selected) + sizeof(UiPredicateFn);
constexpr size_t kUiControlStringSetModeSize =
offsetof(UiControlDesc, string_set_mode) + sizeof(UiStringSetMode);
-constexpr size_t kUiControlFilePickerSize =
- offsetof(UiControlDesc, directory_mode) + sizeof(bool);
+constexpr size_t kUiControlFilePickerSize = offsetof(UiControlDesc, directory_mode) + sizeof(bool);
constexpr size_t kUiListItemV21Size = offsetof(UiListItem, label) + sizeof(const char*);
constexpr size_t kUiListDescV21Size = offsetof(UiListDesc, user_data) + sizeof(void*);
@@ -650,8 +649,7 @@ ModResult ui_pane_add_control(
spec.kind = ui::ModControlSpec::Kind::FilePicker;
spec.directoryMode = desc.directory_mode;
for (size_t i = 0; i < desc.file_filter_count; ++i) {
- spec.fileFilters.push_back(
- {desc.file_filters[i].name, desc.file_filters[i].pattern});
+ spec.fileFilters.push_back({desc.file_filters[i].name, desc.file_filters[i].pattern});
}
break;
case UI_CONTROL_SELECT:
diff --git a/src/dusk/mods/svc/websocket.cpp b/src/dusk/mods/svc/websocket.cpp
new file mode 100644
index 0000000000..a53c388b30
--- /dev/null
+++ b/src/dusk/mods/svc/websocket.cpp
@@ -0,0 +1,393 @@
+#include "registry.hpp"
+
+#include "internal.hpp"
+#include "net.hpp"
+
+#include "dusk/mods/loader/loader.hpp"
+#include "mods/svc/websocket.h"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace dusk::mods::svc {
+namespace {
+
+constexpr size_t MaxConnectionsPerMod = 4;
+constexpr size_t MaxUrlBytes = 8 * 1024;
+constexpr size_t MaxHeaders = 64;
+constexpr size_t MaxHeaderBytes = 16 * 1024;
+constexpr size_t MaxProtocols = 8;
+constexpr size_t DefaultMessageBytes = 1024 * 1024;
+constexpr size_t MaxMessageBytes = 16 * 1024 * 1024;
+constexpr std::chrono::milliseconds DefaultConnectTimeout{10000};
+constexpr std::chrono::milliseconds DefaultCloseTimeout{5000};
+constexpr std::string_view ReservedHeaders[]{
+ "User-Agent",
+ "Host",
+ "Connection",
+ "Upgrade",
+ "Sec-WebSocket-Key",
+ "Sec-WebSocket-Version",
+ "Sec-WebSocket-Extensions",
+ "Sec-WebSocket-Protocol",
+ "Content-Length",
+};
+
+struct ConnectionState {
+ borealis::ws::Connection connection;
+ void* userData = nullptr;
+ bool openPublished = false;
+};
+
+struct ModPollState {
+ borealis::ws::Event currentEvent;
+ std::vector headers;
+ size_t next = 0;
+};
+
+SlotMap s_connections;
+PerMod s_pollStates;
+
+bool valid_url(std::string_view url, bool& allowPlaintext) {
+ allowPlaintext = false;
+ if (url.size() > MaxUrlBytes) {
+ return false;
+ }
+ const auto parsed = borealis::url::parse(url);
+ if (!parsed || (parsed->scheme != "wss" && parsed->scheme != "ws")) {
+ return false;
+ }
+ allowPlaintext = parsed->scheme == "ws";
+ if (!allowPlaintext) {
+ return true;
+ }
+ return parsed->host == "localhost" || parsed->host == "127.0.0.1" || parsed->host == "::1";
+}
+
+WebSocketError map_error(borealis::ws::Error error) {
+ switch (error) {
+ case borealis::ws::Error::None:
+ return WEBSOCKET_ERROR_NONE;
+ case borealis::ws::Error::InvalidUrl:
+ return WEBSOCKET_ERROR_INVALID_URL;
+ case borealis::ws::Error::UnsupportedScheme:
+ return WEBSOCKET_ERROR_UNSUPPORTED_SCHEME;
+ case borealis::ws::Error::Timeout:
+ return WEBSOCKET_ERROR_TIMEOUT;
+ case borealis::ws::Error::TooLarge:
+ return WEBSOCKET_ERROR_TOO_LARGE;
+ case borealis::ws::Error::Canceled:
+ return WEBSOCKET_ERROR_CANCELED;
+ case borealis::ws::Error::Protocol:
+ return WEBSOCKET_ERROR_PROTOCOL;
+ case borealis::ws::Error::Handshake:
+ return WEBSOCKET_ERROR_HANDSHAKE;
+ case borealis::ws::Error::NoBackend:
+ case borealis::ws::Error::Network:
+ default:
+ return WEBSOCKET_ERROR_NETWORK;
+ }
+}
+
+ModResult map_send_result(borealis::ws::SendResult result) {
+ switch (result) {
+ case borealis::ws::SendResult::Ok:
+ return MOD_OK;
+ case borealis::ws::SendResult::NotOpen:
+ return MOD_UNAVAILABLE;
+ case borealis::ws::SendResult::QueueFull:
+ return MOD_CONFLICT;
+ case borealis::ws::SendResult::TooLarge:
+ case borealis::ws::SendResult::InvalidText:
+ return MOD_INVALID_ARGUMENT;
+ }
+ return MOD_ERROR;
+}
+
+ModResult map_close_result(borealis::ws::CloseResult result) {
+ switch (result) {
+ case borealis::ws::CloseResult::Ok:
+ return MOD_OK;
+ case borealis::ws::CloseResult::NotOpen:
+ return MOD_UNAVAILABLE;
+ case borealis::ws::CloseResult::InvalidCode:
+ case borealis::ws::CloseResult::ReasonTooLarge:
+ case borealis::ws::CloseResult::InvalidText:
+ return MOD_INVALID_ARGUMENT;
+ }
+ return MOD_ERROR;
+}
+
+size_t connection_count(const LoadedMod& mod) {
+ size_t count = 0;
+ s_connections.for_each([&](WebSocketHandle, const auto& entry) {
+ if (entry.owner == &mod) {
+ ++count;
+ }
+ });
+ return count;
+}
+
+ModResult websocket_connect(
+ ModContext* context, const WebSocketConnectDesc* desc, WebSocketHandle* outHandle) {
+ if (outHandle != nullptr) {
+ *outHandle = 0;
+ }
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(WebSocketConnectDesc) ||
+ desc->url == nullptr || outHandle == nullptr ||
+ (desc->header_count != 0 && desc->headers == nullptr) ||
+ (desc->protocol_count != 0 && desc->protocols == nullptr))
+ {
+ return MOD_INVALID_ARGUMENT;
+ }
+ if (!declares_import(*mod, WEBSOCKET_SERVICE_ID)) {
+ return MOD_UNSUPPORTED;
+ }
+ bool allowPlaintext = false;
+ if (!valid_url(desc->url, allowPlaintext) || desc->header_count > MaxHeaders ||
+ desc->protocol_count > MaxProtocols || desc->max_message_bytes > MaxMessageBytes)
+ {
+ return MOD_INVALID_ARGUMENT;
+ }
+
+ size_t headerBytes = 0;
+ for (uint32_t index = 0; index < desc->header_count; ++index) {
+ const HttpHeader& header = desc->headers[index];
+ if (header.name == nullptr || header.value == nullptr) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ const std::string_view name{header.name};
+ const std::string_view value{header.value};
+ if (!valid_header(name, value, ReservedHeaders) ||
+ name.size() > MaxHeaderBytes - std::min(headerBytes, MaxHeaderBytes))
+ {
+ return MOD_INVALID_ARGUMENT;
+ }
+ headerBytes += name.size();
+ if (value.size() > MaxHeaderBytes - std::min(headerBytes, MaxHeaderBytes)) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ headerBytes += value.size();
+ }
+ for (uint32_t index = 0; index < desc->protocol_count; ++index) {
+ if (desc->protocols[index] == nullptr || !valid_header_name(desc->protocols[index])) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ }
+ if (!borealis::ws::available()) {
+ return MOD_UNAVAILABLE;
+ }
+ if (connection_count(*mod) >= MaxConnectionsPerMod) {
+ return MOD_CONFLICT;
+ }
+
+ const size_t maxMessageBytes =
+ desc->max_message_bytes != 0 ? desc->max_message_bytes : DefaultMessageBytes;
+ borealis::ws::Options options{
+ .url = desc->url,
+ .connectTimeout = desc->connect_timeout_ms != 0 ?
+ std::chrono::milliseconds{desc->connect_timeout_ms} :
+ DefaultConnectTimeout,
+ .closeTimeout = desc->close_timeout_ms != 0 ?
+ std::chrono::milliseconds{desc->close_timeout_ms} :
+ DefaultCloseTimeout,
+ .keepaliveInterval = std::chrono::milliseconds{desc->keepalive_interval_ms},
+ .maxMessageBytes = maxMessageBytes,
+ .maxQueuedBytes = 16 * 1024 * 1024,
+ .maxSendQueueBytes = 4 * 1024 * 1024,
+ .allowPlaintext = allowPlaintext,
+ };
+ options.headers.reserve(desc->header_count + 1);
+ for (uint32_t index = 0; index < desc->header_count; ++index) {
+ options.headers.push_back({desc->headers[index].name, desc->headers[index].value});
+ }
+ options.headers.push_back({
+ .name = "User-Agent",
+ .value = user_agent(*mod),
+ });
+ options.protocols.reserve(desc->protocol_count);
+ for (uint32_t index = 0; index < desc->protocol_count; ++index) {
+ options.protocols.emplace_back(desc->protocols[index]);
+ }
+ auto connection = borealis::ws::connect(std::move(options));
+ if (!connection) {
+ return MOD_UNAVAILABLE;
+ }
+ *outHandle = s_connections.emplace(*mod, ConnectionState{
+ .connection = std::move(connection),
+ .userData = desc->user_data,
+ });
+ return MOD_OK;
+}
+
+ModResult websocket_poll_event(ModContext* context, WebSocketEvent* outEvent) {
+ const uint32_t structSize = outEvent != nullptr ? outEvent->struct_size : 0;
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || outEvent == nullptr || structSize < sizeof(WebSocketEvent)) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ WebSocketEvent output{
+ .struct_size = structSize,
+ .protocol = "",
+ .error_message = "",
+ .close_reason = "",
+ };
+
+ std::vector handles;
+ s_connections.for_each([&](WebSocketHandle handle, const auto& entry) {
+ if (entry.owner == mod) {
+ handles.push_back(handle);
+ }
+ });
+ auto& pollState = s_pollStates.get_or_create(*mod);
+ if (handles.empty()) {
+ pollState.next = 0;
+ std::memcpy(outEvent, &output, sizeof(output));
+ return MOD_OK;
+ }
+
+ const size_t start = pollState.next % handles.size();
+ WebSocketHandle selected = 0;
+ ConnectionState* selectedState = nullptr;
+ for (size_t offset = 0; offset < handles.size(); ++offset) {
+ const size_t index = (start + offset) % handles.size();
+ auto* entry = s_connections.find_owned(handles[index], *mod);
+ if (entry != nullptr && entry->value.connection.poll(pollState.currentEvent)) {
+ selected = handles[index];
+ selectedState = &entry->value;
+ pollState.next = index + 1;
+ break;
+ }
+ }
+ if (selectedState == nullptr) {
+ std::memcpy(outEvent, &output, sizeof(output));
+ return MOD_OK;
+ }
+
+ output.ws = selected;
+ output.user_data = selectedState->userData;
+ const auto& event = pollState.currentEvent;
+ pollState.headers.clear();
+ pollState.headers.reserve(event.headers.size());
+ for (const auto& header : event.headers) {
+ pollState.headers.push_back({header.name.c_str(), header.value.c_str()});
+ }
+ output.headers = pollState.headers.data();
+ output.header_count = static_cast(pollState.headers.size());
+ if (event.kind == borealis::ws::Event::Kind::Open) {
+ output.type = WEBSOCKET_EVENT_OPEN;
+ output.protocol = event.protocol.c_str();
+ selectedState->openPublished = true;
+ } else if (event.kind == borealis::ws::Event::Kind::Message) {
+ output.type = WEBSOCKET_EVENT_MESSAGE;
+ output.message_kind = event.messageKind == borealis::ws::MessageKind::Text ?
+ WEBSOCKET_MESSAGE_TEXT :
+ WEBSOCKET_MESSAGE_BINARY;
+ output.data = event.data.data();
+ output.size = event.data.size();
+ } else {
+ output.type = WEBSOCKET_EVENT_CLOSED;
+ output.error = map_error(event.error);
+ output.error_message = event.message.c_str();
+ output.handshake_status = event.status;
+ output.close_code = event.code;
+ output.close_reason = event.reason.c_str();
+ }
+
+ std::memcpy(outEvent, &output, sizeof(output));
+ if (event.kind == borealis::ws::Event::Kind::Closed) {
+ s_connections.erase(selected);
+ }
+ return MOD_OK;
+}
+
+ModResult websocket_send(ModContext* context, WebSocketHandle handle, WebSocketMessageKind kind,
+ const void* data, size_t size) {
+ auto* mod = mod_from_context(context);
+ if (mod == nullptr || (size != 0 && data == nullptr) ||
+ (kind != WEBSOCKET_MESSAGE_TEXT && kind != WEBSOCKET_MESSAGE_BINARY))
+ {
+ return MOD_INVALID_ARGUMENT;
+ }
+ auto* entry = s_connections.find_owned(handle, *mod);
+ if (entry == nullptr || !entry->value.openPublished) {
+ return MOD_UNAVAILABLE;
+ }
+ const std::string_view bytes = data != nullptr ?
+ std::string_view{static_cast(data), size} :
+ std::string_view{};
+ return map_send_result(entry->value.connection.send(kind == WEBSOCKET_MESSAGE_TEXT ?
+ borealis::ws::MessageKind::Text :
+ borealis::ws::MessageKind::Binary,
+ bytes));
+}
+
+ModResult websocket_close(
+ ModContext* context, WebSocketHandle handle, uint16_t code, const char* reason) {
+ auto* mod = mod_from_context(context);
+ const std::string_view reasonText =
+ reason != nullptr ? std::string_view{reason} : std::string_view{};
+ if (mod == nullptr) {
+ return MOD_INVALID_ARGUMENT;
+ }
+ auto* entry = s_connections.find_owned(handle, *mod);
+ if (entry == nullptr) {
+ return MOD_UNAVAILABLE;
+ }
+ return map_close_result(entry->value.connection.close(code != 0 ? code : 1000, reasonText));
+}
+
+void websocket_mod_deactivating(LoadedMod& mod) {
+ (void)s_connections.take_all(mod);
+ s_pollStates.erase(mod);
+}
+
+void websocket_mod_detached(LoadedMod& mod) {
+ bool found = false;
+ s_connections.for_each(
+ [&](WebSocketHandle, const auto& entry) { found = found || entry.owner == &mod; });
+ assert(!found);
+ assert(!s_pollStates.contains(mod));
+}
+
+void websocket_shutdown() {
+ s_connections = {};
+ s_pollStates.clear();
+}
+
+bool websocket_available() {
+ return borealis::ws::available();
+}
+
+constexpr WebSocketService s_websocketService{
+ .header = SERVICE_HEADER(WebSocketService, WEBSOCKET_SERVICE_MAJOR, WEBSOCKET_SERVICE_MINOR),
+ .connect = SERVICE_FUNCTION(websocket_connect),
+ .poll_event = SERVICE_FUNCTION(websocket_poll_event),
+ .send = SERVICE_FUNCTION(websocket_send),
+ .close = SERVICE_FUNCTION(websocket_close),
+};
+
+} // namespace
+
+constinit const ServiceModule g_websocketModule{
+ .id = WEBSOCKET_SERVICE_ID,
+ .majorVersion = WEBSOCKET_SERVICE_MAJOR,
+ .minorVersion = WEBSOCKET_SERVICE_MINOR,
+ .service = &s_websocketService,
+ .available = websocket_available,
+ .modDeactivating = websocket_mod_deactivating,
+ .modDetached = websocket_mod_detached,
+ .shutdown = websocket_shutdown,
+};
+
+} // namespace dusk::mods::svc
diff --git a/src/dusk/mods/svc/window.cpp b/src/dusk/mods/svc/window.cpp
index 7c9e938faf..5e61c43033 100644
--- a/src/dusk/mods/svc/window.cpp
+++ b/src/dusk/mods/svc/window.cpp
@@ -1,7 +1,7 @@
#include "window.hpp"
+#include "internal.hpp"
#include "registry.hpp"
-#include "slot_map.hpp"
#include
#include "dusk/mods/loader/loader.hpp"
diff --git a/src/dusk/ui/mods_window.cpp b/src/dusk/ui/mods_window.cpp
index 757fa42b62..e23cb39d1d 100644
--- a/src/dusk/ui/mods_window.cpp
+++ b/src/dusk/ui/mods_window.cpp
@@ -1,11 +1,11 @@
#include "mods_window.hpp"
#include "dusk/mod_loader.hpp"
+#include "dusk/mods/svc/net.hpp"
#include "dusk/mods/svc/ui.hpp"
#include "fmt/format.h"
#include "logs_window.hpp"
#include "mod_texture_provider.hpp"
-#include "mods/svc/http.h"
#include "pane.hpp"
#include "Z2AudioLib/Z2SeMgr.h"
@@ -44,7 +44,7 @@ ModStatus mod_status(const mods::LoadedMod& mod) {
bool mod_uses_network(const mods::LoadedMod& mod) {
return std::ranges::any_of(
mod.manifestInfo.imports, [](const mods::ModManifestInfo::Import& serviceImport) {
- return serviceImport.id == HTTP_SERVICE_ID;
+ return mods::svc::is_network_service(serviceImport.id);
});
}