If you've ever tried to use socket.io-client-cpp in a project built in the last few years, you know the friction: it vendors RapidJSON, it vendors websocketpp, its CMake predates target_link_libraries being cool, and pulling it into a project that already depends on nlohmann/json and Boost.Asio means either forking it or living with two JSON libraries in your binary.
I needed a socket.io client for a system with existing nlohmann::json and Boost.Beast dependencies, so instead of fighting the old library I rewrote it. The result is sioxx — same job, different guts:
What changed vs. the original
socket.io-client-cpp |
sioxx |
|
|---|---|---|
| JSON | RapidJSON | nlohmann/json |
| WebSocket | websocketpp |
Boost.Beast (boost::asio + boost::beast::websocket) |
| Wire protocol | JSON only | JSON or MessagePack, selectable per client |
| Transport | WebSocket | WebSocket first, HTTP long-polling fallback |
| Reconnection | basic retry | capped exponential backoff with jitter |
| Build | vendored deps, older CMake | modern CMake + CPM.cmake, find_package, full install(EXPORT ...)
|
| Tests | Catch2 tests | GoogleTest suite (parsers, transports, URL parsing, reconnection, socket bookkeeping, engine.io framing) |
The public API keeps the same shape you'd expect from socket.io — client, socket("/namespace"), on(event, handler), emit(event, data), ack callbacks — but almost everything under the hood is new.
The JSON value problem, solved by not solving it
The original library has a whole sio::message class hierarchy — int_message, string_message, object_message, array_message, binary_message — basically reimplementing a JSON value type from scratch. But nlohmann::json is a JSON value type, and it already handles null/bool/int/double/string/array/object and raw binary via json::binary_t. So sioxx::message is just:
namespace sioxx {
using json = nlohmann::json;
using message = json; // that's it
using message_list = json; // always a JSON array
}
That one decision deletes an entire subsystem. Building event args is just building a json array:
sock->emit("hello", sioxx::json{"world"});
sock->emit("ping_ack", sioxx::json::array({1, 2, 3}), [](sioxx::message reply) {
std::cout << "ack: " << reply.dump() << "\n";
});
Two wire protocols behind one interface
socket.io actually has two parsers in the wild: the default text protocol, and socket.io-msgpack-parser for binary-heavy workloads. sioxx models this as a small strategy interface:
class parser_base {
public:
virtual void encode(const socketio_packet& packet, const frame_writer& write) const = 0;
virtual bool decode(const std::string& payload, bool is_binary, socketio_packet& out) const = 0;
virtual std::string name() const = 0;
};
json_parser implements the classic <type><nsp>,<id><json> text framing (2["chat message","hi"], 2/chat,3["ack me"], …).
msgpack_parser is more interesting: instead of pulling in a separate MessagePack library, it's built entirely on nlohmann::json::to_msgpack / from_msgpack. Each packet becomes one MessagePack-encoded map (with optional id and data fields):
void msgpack_parser::encode(const socketio_packet& packet, const frame_writer& write) const {
json obj = { {"type", (int)packet.type}, {"nsp", packet.nsp} };
if (packet.id >= 0) obj["id"] = packet.id;
if (!packet.data.is_null()) obj["data"] = packet.data;
auto bytes = json::to_msgpack(obj);
write(std::string(reinterpret_cast<const char*>(bytes.data()), bytes.size()), /*is_binary=*/true);
}
The nice side effect: because json::binary_t maps straight onto MessagePack's bin type, binary payloads round-trip natively through the msgpack parser — no placeholder/reconstruction dance needed for BINARY_EVENT/BINARY_ACK, which the JSON protocol normally requires. Pick your parser with one field:
sioxx::client_options opts;
opts.parser = sioxx::parser_kind::msgpack; // or parser_kind::json (default)
sioxx::client client(opts);
Boost.Beast instead of websocketpp
websocket_transport owns its own boost::asio::io_context on a background thread, supports both ws:// and wss:// (TLS via OpenSSL, SNI included), and serializes writes through a small queue since Beast streams can't be written to concurrently:
class websocket_transport final : public transport_base,
public std::enable_shared_from_this<websocket_transport> {
public:
void connect(const std::string& url) override;
void send(const std::string& payload, bool is_binary) override;
void close() override;
// ...
};
transport_base is a tiny abstract interface: engine.io only knows how to open, send, close, and receive opaque frames. That made it possible to add a second concrete transport without changing the protocol layers above it.
HTTP long-polling when WebSocket is unavailable
WebSocket is still the preferred path, but sioxx now falls back to Engine.IO v4 HTTP long-polling if the initial WebSocket connection fails. The polling transport performs the normal Engine.IO GET/POST cycle, opens a fresh HTTP(S) connection for each poll or write, honors the same TLS and extra-header settings, and encodes binary polling frames as Engine.IO b + base64 packets.
The fallback is visible through the existing error listener:
[sioxx] error: WebSocket connection failed; switching to HTTP long-polling
You can also force polling from the start, which is useful for environments that block WebSocket upgrades and for testing:
sioxx::client_options opts;
opts.force_http_polling = true;
sioxx::client client(opts);
Layering: engine.io → socket.io → namespaces
The stack mirrors the real socket.io/engine.io split:
sioxx::client
└─ socketio_client_impl (packet dispatch, namespace registry)
└─ engineio_client (engine.io v4 handshake, ping/pong heartbeat)
└─ websocket_transport (Boost.Beast ws/wss)
or http_polling_transport (Engine.IO HTTP(S) polling)
engineio_client does the OPEN handshake, tracks pingInterval/pingTimeout from the server, and runs a heartbeat thread that closes the connection if pongs stop arriving. socketio_client_impl decodes packets with whichever parser_base you picked and routes EVENT/ACK/CONNECT/DISCONNECT packets to the right socketio_socket by namespace:
auto sock = client.socket("/your_namespace");
sock->on("your_message", [](const std::string& event, sioxx::message data) {
// data is a nlohmann::json array of the event's arguments
});
client.connect("wss://example.com");
Modern CMake, actually modern
This was half the point of the rewrite. CMakeLists.txt does what you'd expect from a modern C++ library:
-
nlohmann/jsonand Boost via CPM.cmake (or-DSIOXX_USE_SYSTEM_JSON=ONto use an installed nlohmann/json) - OpenSSL and Threads via
find_package - a proper
install(EXPORT sioxxTargets ...)+ generatedsioxxConfig.cmake, so downstream projects just do:
find_package(sioxx REQUIRED)
target_link_libraries(my_app PRIVATE sioxx::sioxx)
No manual include paths, no copy-pasting source files into your project.
The CMake switches are deliberately small: SIOXX_BUILD_EXAMPLES,
SIOXX_BUILD_TESTS, and SIOXX_USE_SYSTEM_JSON. The library requires CMake
3.20+ and a C++17 compiler.
Connection options and threading
client_options also exposes TLS verification and extra HTTP/WebSocket
upgrade headers, so development servers with self-signed certificates and
cookie/token-based deployments do not require a transport fork:
sioxx::client_options opts;
opts.verify_tls = false; // development/self-signed certificates only
opts.extra_headers = {{"Authorization", "Bearer example-token"}};
The transport runs its Asio work on a background thread. Event listeners,
ack callbacks, and open/close/error callbacks therefore run on that thread;
applications with a UI or another thread-affine runtime should dispatch work
back to their own queue.
Tests
There's a GoogleTest suite (also fetched through CPM.cmake) covering the parts that don't need a live server:
- parser round-trips — JSON and MessagePack encode/decode, namespaces, ack ids, malformed input, binary attachments
-
socketio_socketbookkeeping —on/off/emit/ack callbacks, tested with an emptyweak_ptr<socketio_client_impl>so no network is involved at all -
engineio_clientframing — handshake, ping→pong, message prefixing, binary passthrough — tested against a small in-memory faketransport_baseinstead of a real socket - polling framing — binary base64 packet encoding/decoding used by the HTTP polling transport
- URL parsing — ws/wss scheme, host, ports, paths, and invalid URLs
- reconnection policy — deterministic exponential growth, maximum-delay capping, and jitter bounds
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DSIOXX_BUILD_TESTS=ON
cmake --build build -j
ctest --test-dir build --output-on-failure
The actual network transports are exercised end-to-end by the example client against the bundled Socket.IO server.
Reconnection that backs off instead of stampeding
Reconnects are configurable per client. Each attempt doubles the delay up to a cap, then applies symmetric jitter. The default is a 2-second initial delay, a 30-second cap, and a randomization factor of 0.5. Set attempts to zero to disable reconnection.
sioxx::client_options opts;
opts.reconnect_attempts = 5;
opts.reconnect_delay = std::chrono::milliseconds(1000);
opts.reconnect_delay_max = std::chrono::milliseconds(30000);
opts.reconnect_randomization_factor = 0.5;
Try it
sudo apt install libssl-dev cmake
git clone https://github.com/jfayot/sioxx
cmake -S sioxx -B sioxx/build -DCMAKE_BUILD_TYPE=Release
cmake --build sioxx/build -j
./sioxx/build/sioxx_basic_client
The repository also includes examples/test_server, a minimal Node.js Socket.IO server that mirrors the example namespace and events. It supports JSON or MessagePack and WebSocket+polling or polling-only modes:
cd sioxx/examples/test_server
pnpm install
pnpm start # JSON, WebSocket + polling
# or: pnpm start:msgpack
# or: pnpm start:polling # JSON, polling only
# or: pnpm start:msgpack-polling
# From the repository root:
./build/sioxx_basic_client polling
./build/sioxx_basic_client msgpack polling
The server logs the active transport, so a polling test visibly reports (polling) on connect.
GitHub Actions builds and tests the project on Ubuntu, macOS, and Windows with GCC, Clang, and MSVC. Pushing a v* tag runs that matrix and, after it succeeds, creates a GitHub Release with generated notes.
What's still missing
Being upfront about the gaps:
- The JSON parser recognizes
BINARY_EVENT/BINARY_ACKheaders but doesn't implement the placeholder reconstruction for multi-attachment binary payloads — use the MessagePack parser for binary data. - Long-polling is a fallback (or an explicitly forced mode); sioxx does not upgrade an established polling session back to WebSocket.
If any of that matters for your use case, PRs welcome: github.com/jfayot/sioxx
Top comments (0)