DEV Community

Jérôme
Jérôme

Posted on • Edited on

sioxx — a modern C++ socket.io client (nlohmann/json + Boost.Beast, JSON or MessagePack)

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
}
Enter fullscreen mode Exit fullscreen mode

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";
});
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
  // ...
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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/json and Boost via CPM.cmake (or -DSIOXX_USE_SYSTEM_JSON=ON to use an installed nlohmann/json)
  • OpenSSL and Threads via find_package
  • a proper install(EXPORT sioxxTargets ...) + generated sioxxConfig.cmake, so downstream projects just do:
find_package(sioxx REQUIRED)
target_link_libraries(my_app PRIVATE sioxx::sioxx)
Enter fullscreen mode Exit fullscreen mode

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"}};
Enter fullscreen mode Exit fullscreen mode

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_socket bookkeepingon/off/emit/ack callbacks, tested with an empty weak_ptr<socketio_client_impl> so no network is involved at all
  • engineio_client framing — handshake, ping→pong, message prefixing, binary passthrough — tested against a small in-memory fake transport_base instead 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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_ACK headers 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 (4)

Collapse
 
topstar_ai profile image
Luis Cruz

I particularly appreciate how sioxx simplifies the JSON value handling by leveraging nlohmann::json directly, eliminating the need for a custom sio::message class hierarchy. The use of json as a type alias for nlohmann::json and message also improves code readability. The support for both JSON and MessagePack wire protocols through a strategy interface is also a great design choice, allowing for flexibility without adding unnecessary complexity. Have you considered adding support for other serialization formats, such as CBOR or BSON, to further enhance the library's versatility?

Collapse
 
jfayot profile image
Jérôme

Thanks! CBOR and BSON would indeed be relatively straightforward to add, especially since "nlohmann::json" already provides built-in conversion functions for both formats.

The main consideration is interoperability: Socket.IO officially supports JSON, while MessagePack works through the existing "socket.io-msgpack-parser" ecosystem. Supporting another format would also require a compatible parser on the server side.

That said, the strategy-based parser interface was designed precisely to make such extensions possible. One option I’m considering is exposing a parser setter so users can plug in their own parser implementation. This would allow CBOR, BSON, or other serialization formats to be supported without adding each one directly to the core library.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.