Overview

The following diagram depicts the application architecture:

servertech arch

REST API

The REST API is used for operations that do not require low latency, like account creation and authentication. It follows the usual HTTP API conventions, like verbs and status codes. Some error responses (like account creation failures) include additional error information, in JSON format.

Websocket API

Time-sensitive operations, like sending messages, use websockets.

The diagram below depicts how the websocket API works. All events are JSON encoded.

ws api

When the client connects, the server sends a hello event, contining all the data required by the client to build the UI (i.e. available chat rooms and message history).

When the user types a message, the client sends a clientMessages event. The server persists it and broadcasts a serverMessages event to all clients, including the one that originated the message.

On connection, only the latest messages are loaded. The client can request more by sending a requestMessageHistory event. This implements message pagination (but see this issue).

See this file for a complete reference on API types.

Server architecture

Async code

All I/O is handled asynchronously, using Boost.Asio. The server uses stackful coroutines to make async flow easier.

A stackful coroutine is created by calling boost::asio::spawn. Coroutines pass around a boost::asio::yield_context object, which contains information about the current coroutine. Any async function can be converted into a stackful coroutine by passing a yield_context as its last argument (we say a yield_context is a valid completion token).

Stackful coroutines enable a similar code structure as C++20 coroutines, but don’t require C++20. You can spawn C++20 coroutines by calling boost::asio::co_spawn and using boost::asio::use_awaitable as completion token.

The server is single-threaded, which makes development much easier. Measurements are still to be performed to determine whether a multi-threaded architecture could pay off (see this issue).

Redis

We use Redis streams to store messages. These are append-only data structures, similar to a commit log. We use one stream per chat room. Each stream element is assigned an ID by Redis. We use this ID as message ID. Note that this makes the message ID unique per chat room.

In the future, we plan to offload old messages to MySQL, to keep Redis structures small (see this issue).

We use Boost.Redis to communicate with Redis asynchronously. Following the recommended Boost.Redis architecture, the server opens a single Redis connection. Boost.Redis takes care of pipelining requests internally to make the most of connection.

The Redis hostname is configured via the environment variable REDIS_HOST. The Redis instance is never exposed to the internet, so no authentication or encryption is set up.

MySQL

We use MySQL to store users. Future business objects that are not considered time-critical will also be stored in MySQL.

We use Boost.MySQL to communicate with MySQL asynchronously. Database setup code (i.e. migrations) are currently executed at application startup. A connection is dynamically opened and closed for each request (but see this issue).

The MySQL hostname is configured via the environment variable MYSQL_HOST. The MySQL instance is never exposed to the internet, so no strong credentials or encryption are set up (but see this issue).

Authentication

Clients are authenticated using the /api/login endpoint, using an email and a password as credentials. Self-registration is possible using the /api/create-account endpoint. At the moment, no email verification is performed (see this issue).

Passwords are stored in MySQL, hashed using scrypt.

User sessions are managed using 16-byte session IDs, valid for 7 days and transmitted using HTTP cookies. Session IDs are stored in Redis and use Redis' key expiry time feature. Cookies use the HttpOnly and SameSite=Strict attributes to prevent XSS and CSRF attacks.

Websocket sessions use credentials included in the HTTP upgrade request that initiates the session. If the supplied credentials are invalid, the websocket is closed with a certain code straight after the handshake. Doing this allows the client to detect missing credentials and redirect the user to the login page.

Message broadcasting

Messages are broadcast using an in-memory data structure (the pubsub_service) that uses Boost.MultiIndex. This is efficient but contrains the server to a single instance.

It is planned to replace the pubsub_service with a more scalable mechanism, like Redis channels. We’ve also considered using Redis XREAD to subscribe to stream changes. However, XREAD blocks the connection until an update is received, which doesn’t work well with Boost.Redis single-connection architecture.

HTTP and websockets

HTTP and websocket traffic is handled using Boost.Beast. The server uses a listener loop, accepting new connections while serving the active ones asynchronously.

Boost.Json and Boost.Describe are used to serialize and parse API data.

Additional considerations

  • The server requires C++17 to build, since that’s the minimum for Boost.Redis to work.

  • The server is built using CMake.

  • We only target Linux servers. The current deployment uses Alpine Linux.

  • The server has unit tests, written using Boost.Test.

  • The server has also integration tests, which exercise the server using the API. These tests use a real database, and are written in Python, using pytest.

Client architecture

The client is web-based and uses Next.js, React and TypeScript. Styles use Tailwind CSS and the MUI library.

Client files are compiled into regular HTML, CSS and JavaScript files, which are then served by the C++ server as static files.

The client has unit tests written in Jest.

Client screens are first designed in Figma before being translated into code. This file contains wireframes for this project.

Building and deploying

For local development, you can build your code using your IDE or invoking CMake directly. See this section for further details. Additionally, code is built, tested and deployed continuously by a GitHub Actions workflow.

Server and client builds are defined in a single Dockerfile, enabling repeatable builds. The CI pipeline uses this Dockerfile to build and test the code. Docker caching has been set up to reduce re-build times.

Both client and server are packaged into a single Docker container. This container is stored in the GitHub container registry.

Redis and MySQL are deployed as separate containers. We use standard Redis and MySQL images, so there is no need to build custom images for DBs.

The server is then deployed to your Linux server of choice via SSH. The script will log into the server, install Docker if not available, and create the relevant Docker objects (in a similar way to what Compose does).

More scalable deployments could make use of Kubernetes or AWS ECS clusters. If that’s something of interest to you, please feel free to open an issue.

Note
In the current setup, Docker containers in the GitHub container registry have public visibility. If you want to make them private, you will need to set up credentials in your server to authorize your deployment script.