DEV Community

Jérôme
Jérôme

Posted 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:

🔗 github.com/jfayot/sioxx

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 (0)