One of my current side projects is developing a modern low-code platform to build machine vision applications. The backend is based on Python, allowing users to tap into the rich ecosystem of libraries available in the Python ecosystem. The frontend is built with web technologies, enabling users to create applications without installing any software.
A key feature of the platform is its ability to stream input and output data with low latency and high throughput between the backend and frontend. WebRTC, with its peer-to-peer (P2P) functionality, is currently the only technology that supports this type of data transfer in the browser.
Getting started with WebRTC can be quite daunting, as it requires combining several technologies to make it work. In my case:
During my initial experiments, I found various examples, but none covered all the technologies I needed. Therefore, I wanted to write this tutorial to help others in the same situation.
The goal of this post is to create a simple WebRTC DataChannel connection between a Python backend and a JavaScript frontend using FastAPI to serve the website and handle signaling via WebSockets.
The following libraries and APIs will be used:
The complete code for this example can be found in this GitHub repository.
I will quickly explain the basics of how a WebRTC connection is established so that we can understand the code we’ll write later. For a more in-depth explanation, I’ll point to other resources.
The first important thing to understand is that to establish a WebRTC connection between two peers, they need to exchange information about the connection. This process is called signaling1. While the format (SDP) of this signaling exchange is part of the WebRTC protocol, the means of delivery is not and must be implemented via another protocol. A popular choice, which we will also use, is WebSockets.
To enable P2P functionality across a wide range of network configurations, WebRTC needs to traverse NATs and firewalls. This is done through a process called Interactive Connectivity Establishment (ICE). Specifically, a STUN server is used to determine the client’s public IP address and port. If a direct connection cannot be established, a TURN server can be used to relay the data between clients.
The diagram below shows the signaling process between two peers.
The process of gathering ICE candidates is often a bottleneck in establishing a WebRTC connection. Some frameworks and APIs allow sending the ICE candidates after the offer, a process known as trickle ICE. Unfortunately, aiortc does not support trickle ICE yet.2 Therefore, we will focus on the simpler case where we wait for the ICE candidates to be gathered before sending the offer.
Besides discovering the network configuration, the signaling process also includes exchanging information about the media streams and data channels that should be established.
Further Reading:
To keep the code simple, we will not implement any error handling or security features.
If you want to follow along, I recommend creating a new directory and a Python virtual environment.
Then install the following packages: pip install fastapi websockets aiortc
.
You can also create separate environments for the server and the clients, but it is not necessary.
If you do so, the server doesn’t need the aiortc
package and the clients don’t need the fastapi
package.
We can split the development into five parts:
Let’s start with the server.
Let’s first build the signaling server, which will relay the signaling messages between the two peers. We will use FastAPI to build the server and WebSockets to handle the communication.
This code was adapted from the websockets documentation, which I recommend as an excellent resource for learning more about WebSockets in Python.
First, we set up the FastAPI app and create a set to store the connected clients.
|
|
The server accepts WebSocket connections at the /ws
endpoint.
If a WebSocket is connected, it is added to our pool of connected clients.
After this the server forwards all incoming messages to all other connected clients.
The function will run until the connection is closed, at which point we remove the connection from our pool of connected clients.
Due to the async nature of the function, the server can handle multiple connections efficiently at the same time.
For more information on how to use WebSockets in FastAPI, I recommend the FastAPI documentation.
|
|
With this code, the server is ready to accept connections and forward messages.
You can run this by saving the code in a file called app.py
and running fastapi run
in the directory.
But without a client, it will not do much.
Now we will create a Python client, which will represent our data producer. This client will wait for a WebRTC offer from the consumer, create an answer, and send it to the signaling server. After the connection is established, it will send pings every second to the consumer.
Before we start the signaling, we will build our RTCPeerConnection
description.
We will add an event handler that starts sending pings when the data channel is open.
We wrap this into a function so that we can execute it later when we are inside the asyncio event loop.
This is only a technical detail related to the aiortc
implementation, as the channel is an AsyncEventEmitter
and always uses the current loop (i.e., it doesn’t allow us to specify another loop).
|
|
Next, we will implement the main routine with the following steps:
The routine is started by calling asyncio.run(producer())
.
|
|
To test our setup, we will create a second Python WebRTC client that will create an offer, wait for an answer, and print the pings it receives.
The code is very similar to the producer. As before, we define our logic in a function so that we can execute it later when we are inside the asyncio event loop. But this time, we define the data channel. If the data channel is open and receives a message, we simply print it. The data channel always has to be included in the offer, since an RTCPeerConnection without a media track or data channel is not allowed.
|
|
Next, we will implement the main routine with the following steps:
The routine is started by calling asyncio.run(consumer())
.
|
|
Now we can run the server, producer, and consumer and see the pings being printed in the consumer. Start them in this order, as we did not implement any error handling or retries. It’s best to start them in three different terminals so you can see the output of each client.
|
|
The output of the consumer should look as follows:
|
|
If you don’t see any output, enable logging in your producer and consumer:
|
|
If it works, you can also try running one of the clients on a different machine or network,
by changing the localhost
to the IP of the machine running the server.
and setting --host <your-ip>
or --host 0.0.0.0
when starting the FastAPI server.
But since we want to use WebRTC in the browser, we will now move to the javascript part.
To keep it simple, I tried to port the python code from the consumer to javascript as closely as possible.
First, we will create a new RTCPeerConnection and a data channel. We will add an event listener to the data channel to print the pings we receive.
|
|
Next, we will implement the main routine with the following steps:
The main difference from the Python code is that we have to wait explicitly for the ICE candidates to be gathered. This is due to the flexibility of the JS WebRTC API, which allows sending the ICE candidates after the offer.
|
|
The JavaScript client is ready, but we need to serve the HTML and JavaScript via FastAPI.
|
|
Save both files as index.html
and webrtc_offer.js
in a directory called static
in the same directory as the FastAPI server.
Then add the following route to the FastAPI server (app.py
) to serve the HTML and JavaScript.
|
|
The file structure should look like this:
|
|
Now you can start the FastAPI server with fastapi dev
and the producer with python producer.py
.
Then open the browser at http://localhost:8000/
.
You should see the pings being printed in the browser console.
This is a minimalistic example that only shows how to establish a connection and send and receive some data.
The next steps depend on your use case. You might want to address the following issues:
I hope this tutorial was helpful to you. There will soon be a second part where I will measure the latency and throughput of the connection.
This signaling does not stop once the connection is established, but is also used to renegotiate the connection, for example, when a new stream is added or removed. ↩︎