Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

104 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Introduction

This library implements the WebSocket Streaming Protocol in modern C++. The library is built around Boost.Asio. It supports both client and server roles, and in both roles, data can be published or received using a symmetric API. It is designed for high performance and reliability. It is platform-independent and can be used on any system supported by Boost.Asio.

Dependencies

The library uses a minimal set of dependencies, and will automatically fetch most of them if they are not already installed on the host system. The exception is OpenSSL, which is needed for TLS support and must be installed beforehand. Once it is, and as long as CMake and a suitable C++ compiler are installed, it is usually sufficient to simply clone and build the library without first installing any additional dependencies.

  • Build dependencies:

    • Tools:

    • Libraries (automatially fetched if necessary):

      • GoogleTest if building unit tests ≥ v1.17.0

      • Boost header-only libraries (asio, beast, serialization, signals2) ≥ 1.84

      • nlohmann-json ≥ v3.12.0

    • Libraries (must already be installed):

      • OpenSSL ≥ 1.1.1, used by Boost.Asio to implement TLS

  • Runtime dependencies:

    • Boost compiled libraries (system, url) ≥ 1.84

    • OpenSSL ≥ 1.1.1

Building & Installing the Library

The library can be easily built with CMake:

cmake -B build
cmake --build build
cmake --install build
Note
If Boost was automatically fetched (either because no suitable version was found or because WS_STREAMING_IGNORE_INSTALLED_BOOST was set), then the library cannot be installed, because Boost’s CMake files do not allow installation when loaded as a subproject. A CMake configuration error will occur if WS_STREAMING_INSTALL is ON, which is the default when ws-streaming is the top-level CMake project. To solve this, either install a suitable version of Boost first, or set WS_STREAMING_INSTALL to OFF.

CMake Options

The following CMake options are supported, and their values can be set by adding, for example, ‑DWS_STREAMING_BUILD_TESTS=OFF to the first CMake command above:

Option Description Default

WS_STREAMING_BUILD_EXAMPLES

Build example programs

ON if top-level project

WS_STREAMING_BUILD_TESTS

Build unit tests

ON if top-level project

WS_STREAMING_IGNORE_INSTALLED_BOOST

Always fetch Boost

OFF

WS_STREAMING_INSTALL

Generate CMake install targets

ON

Using the Library in a Project

The library uses CMake for building and installation. Other CMake projects can easily use installed versions of the library via find_package(). Building the library in-project is also supported, using FetchContent or add_subdirectory().

Using an Installed Version of the Library

find_package(ws-streaming 3.2.0 REQUIRED)

Fetching the Library and Building In-Project

FetchContent_Declare(ws-streaming
    GIT_REPOSITORY  https://github.com/openDAQ/ws-streaming
    GIT_TAG         v3.2.0
    OVERRIDE_FIND_PACKAGE)

FetchContent_MakeAvailable(ws-streaming)

Linking to the Library

target_link_libraries(my-project PUBLIC ws-streaming::ws-streaming)

Included Examples

Basic Examples

These examples demonstrate the basic usage of the library for the most common use-cases.

  • server-source - Implements a server that publishes synchronous scalar data to connected clients.

  • client-sink - Implements a client that receives synchronous scalar data from a server.

Bidirectional Streaming Support

The basic examples above move data in the traditional direction: data is published from a server to connected clients. The library supports bidirectional streaming, and this direction can be reversed. These examples demonstrate moving data from a client to a server.

  • client-source - Implements a client that publishes synchronous scalar data to a server.

  • server-sink - Implements a server that receives synchronous scalar data from connected clients.

Other Examples

These examples demonstrate other use-cases, like streaming "asynchronous" signals (those with an explicit-rule domain signal) and structure-valued signals.

  • can-source - Implements a server that publishes asynchronous raw CAN message structures to connected clients.

  • can-sink - Implements a client that receives asynchronous raw CAN message structures from a server.

  • lazy-publish - Demonstrates how to recognize when a remote peer is subscribed to a local_signal so that data need only be published when it will actually be used.

Detailed Description

Terminology & Concepts

  • Client - An entity that initiates a WebSocket Streaming connection to a server. Applications use a client object to act as a client. The client opens the TCP connection, and submits an HTTP Upgrade request to start a WebSocket connection.

  • Event - A Boost.Signals2 object, to which the application can connect slots, or function objects which are to be called when an event occurs. Events are always raised from the context of the Boost.Asio executor given to the library; i.e., slots are called from an I/O completion handler dispatched by the executor.

  • Metadata - A description of a signal. Metadata is stored internally and transmitted as a JSON object, and specifies the physical format of the data carried by a signal as well as information about how an application should interpret that data. Applications publishing data can use a metadata_builder object to generate a signal description. Applications consuming data from a remote peer can inspect a signal’s metadata using the remote_signal::metadata() member function.

  • Peer - An entity that communicates with another entity using the WebSocket Streaming protocol. Applications use a connection object, which is typically created by either a client or server object, to act as a peer. Note that after the initial handshake, the wire protocol and supported functionality is symmetric. Therefore the documentation usually refers to peers instead of clients or servers when discussing streaming functionality that applies equally regardless of role.

  • Server - An entity that listens for and accepts WebSocket Streaming connections from clients. The server listens on one or more TCP ports, and honors HTTP Upgrade requests to start WebSocket connections. Servers also accept JSON-RPC command interface requests to support stream management (subscribe/unsubscribe) by older clients that do not support the in-band command interface.

  • Signal - An entity which carries data that can be transmitted from one peer to another, and which is described by metadata. Signals can be published by a peer, using a local_signal object, such that data generated by the application is streamed to and received by the remote peer. Signals can also be received, such that data generated by the remote peer is made available to the application via remote_signal objects managed by a connection object.

  • Sink - A peer which receives data from the remote peer. To act as a sink, an application reacts to a connection or server object’s on_available event, which supplies the application with a remote_signal object that can be used to inspect, subscribe to, and receive data from the signal.

  • Source - A peer which sends data to the remote peer. To act as a source, an application creates a local_signal object and registers it with a connection, or with a server (which then registers the object with all the connections it manages).

  • TLS - Transport Layer Security, used to encrypt the connection between two peers. A client requests an encrypted connection by connecting to a wss:// URL, and a server accepts such connections on ports added with the server::add_tls_listener() member function. The library builds and owns the SSL context, using certificate, private key and CA file paths supplied by the application.

object model.drawio

Setup: Boost.Asio Integration

The streaming library is based on Boost.Asio and requires an executor to perform asynchronous I/O operations. The application must supply and manage the executor. In most cases, this is as simple as instantiating an io_context and calling its run() function in a thread:

boost::asio::io_context ioc{1};
std::thread thread{[&] { ioc.run(); }};

The application may also use the executor for its own purposes, or use any other object that implements the executor concept.

Client Role: Connecting to a Server

The application can act as a client using a client object. This object asynchronously connects to a server by opening a TCP connection and submitting an HTTP Upgrade request to start a WebSocket connection. It also performs a WebSocket Streaming Protocol handshake and prepares the connection for the exchange of data. When the connection process is complete, the specified completion handler is invoked, either with an error code or with a shared pointer to a connection object.

wss::client client{ioc.get_executor()};

client.async_connect(
    "ws://localhost:7414",
    [](const boost::system::error_code& ec, wss::connection_ptr connection)
    {
        if (ec)
        {
            std::cerr << "connection failed: " << ec << std::endl;
            return;
        }

        std::cout << "connected to server" << std::endl;

        // application can now use the given connection_ptr to manage the connection;
        // when the last copy of the connection_ptr is destroyed, the connection is closed
    });

Server Role: Accepting Connections from Clients

The application can act as a server using a server object. This object asynchronously listens for TCP connections on one or more ports, and waits for HTTP Upgrade requests to start a WebSocket connection. It also performs WebSocket Streaming Protocol handshakes and prepares connections for the exchange of data. When a new connection has been established, the on_client_connected event is raised, with a shared pointer to a connection object.

Server objects also act as aggregators. For sinked data (received from a remote peer), the on_available and on_unavailable events are raised when the corresponding events for any connected client are raised. Likewise, for sourced data (sent to a remote peer), any local_signal registered with the server is also registered with all the connections managed by that server.

wss::server server{ioc.get_executor()};
server.add_default_listeners();
server.run();

server.on_client_connected.connect(
    [](wss::connection_ptr connection)
    {
        // application can now use the given connection_ptr to manage the connection;
        // unlike in the client role, the server object internally holds a copy of the
        // connection_ptr, so the connection stays open until the server is closed
    });

Securing Connections with TLS

Connections can be encrypted with TLS in both roles. The library owns the TLS configuration: the application supplies the paths of PEM-encoded certificate, private key and CA files, and the library builds and manages the underlying SSL context.

A client enables TLS by calling enable_tls() before connecting, and then connects to a wss:// URL instead of a ws:// one. A CA file is always required, because the client always verifies the certificate presented by the server. If the URL does not specify a port number, port 7415 is used.

wss::client client{ioc.get_executor()};

client.enable_tls("ca.pem");

client.async_connect(
    "wss://localhost:7415",
    [](const boost::system::error_code& ec, wss::connection_ptr connection)
    {
        // the connection is used exactly as an unencrypted one
    });

A client that cannot be given a CA file calls enable_tls_without_verification() instead. The connection is still encrypted, but the server is not authenticated: whatever certificate it presents is accepted. This leaves the connection open to an active man-in-the-middle, so use it only where the server is trusted by other means. Presenting a client certificate to an unauthenticated server serves no purpose, so mutual TLS is not available in this mode.

client.enable_tls_without_verification();

A server accepts encrypted connections on ports added with add_tls_listener(), which requires a certificate chain and a private key. Encrypted and unencrypted listeners can be used at the same time, and each connection is serviced according to the listener that accepted it. Note that add_default_listeners() adds unencrypted listeners only.

wss::server server{ioc.get_executor()};
server.add_tls_listener(7415, "server.pem", "server-key.pem");
server.run();

To authenticate clients as well as the server (mutual TLS), the server additionally passes a CA file to add_tls_listener(), and each client passes its own certificate and private key to enable_tls(). The server then requires every client to present a certificate signed by one of the CAs in that file, and rejects those that do not.

server.add_tls_listener(7415, "server.pem", "server-key.pem", "ca.pem");

client.enable_tls("ca.pem", "client.pem", "client-key.pem");

Only the streaming channel has an encrypted counterpart: port 7415 pairs with port 7414, but no secure port is defined alongside the HTTP command interface port 7438. This matters only for the out-of-band command interface, which older clients use for stream management. Peers implementing version 3.0 or later of the protocol exchange command interface requests in-band, over the streaming connection, so those requests are encrypted whenever the connection is. Requests sent to an out-of-band command interface are not: the library always connects to one in plaintext, even from a wss:// connection.

Note
TLS 1.2 is the oldest accepted protocol version; SSL 2.0, SSL 3.0, TLS 1.0 and TLS 1.1 are disabled. Unless verification is disabled, certificates are verified against the configured CA file, but the peer’s hostname is not checked against the certificate it presents: any certificate issued by a trusted CA is accepted, whichever host it was issued for. Finally, a server object holds a single TLS configuration, so calling add_tls_listener() more than once replaces the certificate files used by all of its TLS listeners.

Source Role: Sending Data to Remote Peers

The application can act as a data source by creating one or more local_signal objects and registering them with a connection or with a server. The application must create metadata describing the signal data, and then use the publish_data() member function to notify the library that new data is available to be streamed.

wss::local_signal signal{
    "/Value",
    wss::metadata_builder{"Value"}
        .data_type(wss::data_types::real64_t)
        .build()};

connection_or_server.add_local_signal(signal);

// In an acquisition loop
signal.publish_data(
    timestamp,
    sample_count,
    ptr_to_samples,
    byte_count);

Sink Role: Receiving Data from Remote Peers

The application can act as a data sink by subscribing to one or more remote_signal objects provided by a connection or server.

connection->on_available.connect(
    [](wss::remote_signal_ptr signal)
    {
        if (signal->id() == "/Value")
        {
            signal->subscribe();
            signal->on_data_received.connect(
                [](std::int64_t domain_value, std::size_t sample_count,
                    const void *data, std::size_t size)
                {
                    std::cout << "received " << sample_count << " sample(s)" << std::endl;
                });
        }
    });

About

Standalone WebSocket Streaming library in C++ for clients and servers

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages