Building a P2P Chat Application with Iroh
This tutorial demonstrates how to build a peer-to-peer chat application from scratch using Rust and the Iroh library. While this implementation is simplified, it illustrates core concepts of P2P networking and the Iroh gossip protocol.
The code in the above video differs slightly from the code presented below. We recommend watching the video and following along until you get comfortable. When you are ready for a deeper dive into the code, walk through this tutorial.
Prerequisites
The tutorial assumes basic programming knowledge but no prior Rust experience. To begin, install Rust by following the instructions at rust-lang.org.Project Setup
First, initialize a new Rust project:src/main.rs file, which is the file we will be building on for this tutorial.
Next, let’s add the iroh dependencies, and a few others we will need to get the chat application working.
tokio handles our async runtime, allowing us to do things like listen for incoming messages at the same time as we write outgoing messages.
anyhow allows us to more easily handle errors, and is especially useful when writing binaries or when prototyping.
rand generates randomness.
Install the required dependencies:
Basic Endpoint Configuration
The first step is creating a basic endpoint configuration. Replace the following with the current contents of thesrc/main.rs file:
Adding Gossip Protocol Support
Theiroh-gossip protocol is what we will use to not only send messages, but also coordinate who we are connected to in our chat application.
Install the gossip protocol:
Creating and Broadcasting to a Topic
Topics are the fundamental unit of communication in the gossip protocol. Here’s how to create a topic and broadcast a message:Messages
Thebroadcast method will send bytes over the wire. To keep ourselves organized, we should create a Message enum that enumerates the kinds of messages we want folks to send. We can serialize those messages into bytes for the broadcast method to send.
Let’s write a Message::AboutMe enum variant that allows someone who joins the chat to be called by a specific name.
And let’s write a Message::Message that has a String with the actual chat messages.
Also, we want each of those messages to include the EndpointId of the sender. In an actual application, we would encode and decode the messages with keypairs to ensure that everyone who sends a message is actually who they say they are. For more on that, check out our more robust chat example that exists in the iroh-gossip repo.
In addition, the nature of the gossip protocol could potentially cause messages to be sent multiple times. This is done intentionally, to ensure at-least-once delivery of each message to all endpoints. This behavior is unexpected in most app contexts, so iroh will internally deduplicate messages based on the hash of their contents.
In this case, if someone sends re-sends a message they already sent, it will be ignored by the other peers. To circumvent this, each message should include a piece of unique data to prevent this deduplication. This can be done in a number of ways - we will use a cryptographic nonce.
We need to add crates that will allow us to serialize our new message types as bytes and deserialize bytes as our message type.
serde stands for Serialize/Deserialize. serde-json lets us easily encode and decode to the json format, but we can choose other formats. E.g., in the iroh-gossip example, we use postcard.
bytes is a utility library for working with bytes.
Implementing Message Reception
To handle incoming messages, we need to iterate over the stream of messages that come in on thereceiver of the Topic.
Dealing with streams in rust is complicated without additional help from crates that were designed to simplify using them. Knowing the best tools to use for this can also be complicated, since a certain crate may have a good trait implementation for some use cases, and a less good implementation for others. It can also feel a bit yucky to add a big beefy crate to your code, when you are only using a very small subset of its contents.
In this application, we are using the StreamExt trait to make handling async streams easier. We’ve found that the best option right now is to use the futures-lite crate. It contains a subset of the futures crate.
Install the futures-lite crate to handle async streams:
subscribe_loop function to keep the code more simple:
subscribe_loop function in our main function. Eventually, when we add the ability to send messages, we want this subscribe loop to be listening for incoming messages at the same time as we send outgoing messages.
To do that, we are going to call the subscribe_loop function inside a tokio::spawn. That will spawn a task so that the subscribe loop is listening concurrently with our writing loop.
Here are the imports and the main function so far:
The input loop
Now that we can receive messages, let’s code up how to send them. We are going to write aninput_loop that reads from stdin. It’s going to take a Sender that can send Strings on a channel. Each time we get input from stdin, we will read it to a String buffer, and then send that string over the channel:
subscribe_loop, we are going to spawn the input_loop on a thread. Tokio recommends that we actually spawn the loop listening to Stdin on a std thread rather than a tokio thread:
Implementing Signaling with Tickets
Let’s implement ticket-based signaling! This means we will turn the topic and our endpoint id information into aTicket for others to use to join our Topic. We send the ticket by serializing the Ticket data and printing the serialized data to the terminal. We can then copy/paste for others to use.
Using the Ticket and Messages
Let’s update our main file to include printing a Ticket and broadcasting our name on the topic.
Creating a Command-Line Interface
Here is where things get fun. We know how to create, join, and send and receive on aTopic. We also know how to get other to join that Topic. This has now created two different “roles” an endpoint can have: a topic “creator” and a topic “joiner”. One side “creates” the topic and the ticket, and the other side takes the ticket and uses it to join the topic and connect to the ticket creator.
To “join”, we will need to pass in a Ticket as a command line argument. There is a great rust crate called clap that takes care of much of the CLI boiler plate for you.
Install the clap crate for CLI argument parsing:
clap to create a nice CLI. There are two commands open and join. join expects a `Ticket.
We also now have a --name flag that we can optionally use as an identifier in the topic.
If you use the open command, you create a topic. If you use the join command, you get a topic and a list of endpoint_ids from the ticket.
In either case, we still print a ticket to the terminal.
The smallest change, but a very important one, is that we go from using the subscribe method to the subscribe_and_join method. The subscribe method would return a Topic immediately. The subscribe_and_join method takes the given topic, joins it, and waits for someone else to join the topic before returning.