Introduction to the Rocket Chat App
What's up, Rustations? Welcome back to Let's Get Rusty, your number one resource for all things Rust. The most popular server backend framework in Rust is Rocket, and one of the great things about Rocket is the documentation and examples repository. In this video, we're going to be going through one of the examples which will have us build a real-time chat app.
But before we get started, if you haven't already, make sure to get your free Rust cheat sheet by heading over to letsgetrusty.com/cheatsheet. And with that said, let's get rusty.
Project Setup and Dependencies
First, we'll create a new cargo project called chat-app.
We'll cd into that project and open it up in VS Code.
Next, we'll open up cargo.toml and add Rocket as a dependency.
Note that the version is 0.5 release candidate 1 and the json feature is included. We'll also add the rand crate as a dev dependency.
This is going to be useful later when we implement tests, which is why we added it as a dev dependency. And with that, we have everything we need to start implementing our chat app. So let's open up main.rs.
Building a 'Hello, World' Server with Rocket
The first thing we'll do is import Rocket.
We're importing Rocket explicitly with the #[macro_use] attribute so that all the Rocket macros are imported globally. This means you can use Rocket macros anywhere in your application, which is important because the Rocket framework uses macros extensively. The first thing we'll do is get rid of main and implement Hello World in Rocket.
The first thing we see is a route called world. The route attribute describes the type of request this route handles; in this example, it's a GET request to the /world path.
Then the handler function describes how that request should be processed. In this case, the handler has no arguments and returns a string slice, which is going to be "Hello, world!".
The next thing we see is a function called rocket. The #[rocket] attribute will take this code and generate a main function that will start our Rocket server.
Inside the function, we call build() to create a new Rocket server instance, and then mount our route. Before routes could be used, they have to be mounted. The mount() method takes two arguments: a base path, which is going to act as a namespace for our routes, and a list of routes. To create that list, we use the routes! macro, passing in the name of our handler functions. And that's it! With this little bit of code, we have a fully functional web server.
Running and Testing the Basic Server
Let's go ahead and run it by typing in cargo run.
Our server was started on localhost port 8000. Let's open that up in a web browser.
We get a 404 error because, if you recall, our route was mounted on the /hello base path. So let's add /hello/world to our URL.
And if I zoom in you can see we get "Hello, world!" as expected. All right, with the basics out of the way, let's shut down our web server and start implementing the chat app.
Managing Server State with Tokyo Channels
The first thing we'll do is add some state to our Rocket server instance.
The manage method allows us to add state to our Rocket server instance, which all handlers have access to. The state we want to add is a channel, specifically the sender end of a channel. Rocket uses Tokio as an async runtime, and channels are a way to pass messages between different async tasks.
Tokio channels and async programming in general are out of the scope of this video, but let me know in the comment section down below if you'd like to see videos about that.
Here we're creating a channel and specifying what type of messages we'd like to send across the channel, in this case a Message struct which we haven't implemented yet. We also pass in a capacity, which is the amount of messages a channel can retain at a given time. The return value of calling the channel function is a tuple containing a sender and receiver end. At the end of this call we write .0 to get the first element in the tuple because we only want to store the sender end in state.
Defining the Message Data Structure
Alright, now that we got our state set up, let's implement the Message struct.
The Message struct has three fields: a room name, a username, and a message, all of which are strings. Also, some extra validation is added to room and username. The room name can only be up to 29 characters long, and username can only be up to 19 characters long. This struct is also deriving a few traits.
Debug so this struct could be printed out with debug format, Clone so we can duplicate messages, FromForm so we could take form data and transform it into a Message struct, and Serialize and Deserialize which will allow this data structure to be serialized and deserialized.
Serialization will happen via Serde and the next attribute states that we want to use the Serde crate defined in Rocket.
This struct defines the type of messages we want to send. Our real-time chat application is going to have rooms, users, and messages, so these three fields make sense. Now that we have our Message defined, there's only one last thing to do, which is to implement our endpoints.
Creating the Endpoint to Post Messages
Our chat application only needs two endpoints: one endpoint to post messages and another endpoint to receive messages.
First, let's implement the endpoint to post messages.
This route matches against POST requests to the /message path and accepts form data. The function handler accepts two arguments: the form data, which is going to be converted to the Message struct, and the server state, which if you recall is going to be a sender that could send messages.
Inside the function body, we simply send the message to all receivers. The send method returns a Result type because sending a message could fail if there are no receivers. In this example, we don't care about that case, so we're going to ignore it. And that's it for our POST route. Nice and simple.
Streaming Events with Server-Sent Events (SSE)
Next, we'll implement the GET route to receive messages.
This endpoint is a lot more complicated, so let's go through it together. This route handles GET requests to the /events path and the return type is an infinite stream of server-sent events. Server-sent events allow clients to open a long-lived connection with the server, and then the server can send data to the clients whenever it wants. This is similar to WebSockets, except it only works in one direction. The server can send data to clients, but the clients can't send data back to the server. Unlike the other handler functions we implemented, notice that this function is prefixed with async. That's because server-sent events are produced asynchronously.
The handler takes two arguments: queue, which is our server state, and end, which is of type Shutdown. Shutdown is a future which resolves when our server instance is shut down.
Inside the handler, the first thing we do is call queue.subscribe() to create a new receiver. This will allow us to listen for messages when they're sent down the channel. Next, we use generator syntax to yield an infinite series of server-sent events. Inside this infinite loop, the first thing we do is use the select! macro. select! waits on multiple concurrent branches and returns as soon as one of them completes.
In this case, we only have two branches. The first one is calling recv() on our receiver, which waits for new messages. When we get a new message, we map it to msg and then match against that. recv() returns a Result enum. If we get the Ok variant, we simply return the message inside of it. If we get the Err variant and the error is Closed, that means there are no more senders, so we can break out of the infinite loop. If we get the Err variant and the error is Lagged, that means our receiver lagged too far behind and was forcibly disconnected. In that case, we simply skip to the next iteration of the loop.
The second branch looks a little odd, but what this is doing is waiting for the Shutdown future to resolve. The Shutdown future resolves when our server is notified to shut down, at which point we can break out of this infinite loop. Assuming we don't hit one of these break or continue statements, the select! macro will return the message we got from our receiver, at which point we can yield a new server-sent event passing in our message.
And that's it. Both our routes are now complete. The message endpoint will receive new messages and send them down the channel, and the events endpoint listens to messages coming down the channel and sends them to clients.
Serving the Frontend and Finalizing Routes
The last thing we need to do is mount these routes. First, let's remove the hello world route. Then we'll mount post and events to the root path.
Our backend is complete, but we also need a frontend. Before we start adding HTML files, let's mount a handler that will serve static files.
Our static files need to be stored in a folder called static, so let's add that folder.
For the sake of brevity, I'm not going to go into detail about the implementation of the frontend, but you can see the full source code with the link in the description. As a quick overview, the frontend consists of a simple HTML page, a couple CSS files, and a vanilla JavaScript file. This JavaScript file uses the EventSource object to establish a new connection with our server and listen for new messages. When a new message is received, it's parsed into JSON and appended to the DOM. To send messages, a simple POST request is dispatched.
Live Demo of the Real-Time Chat
All right, after all that work, let's finally run our server and see our chat app in action.
Our server is running on localhost:8000. Next, let's open up two web browsers. We'll navigate to localhost:8000 on both. Then we'll pick two usernames. And finally, let's try to send a message.
As you can see, the message appeared for user 1 instantly. And if we send a message from user 1, user 2 will receive that message instantly.
One thing to note is that we didn't implement any type of persistence. So if we refresh one of these web pages, all the messages will be lost. You could consider this a bug or a neat little security feature.
Conclusion and Next Steps
That's it for this video. Let me know in the comments section down below if you enjoyed this project-based video and if you want to see more project-based videos, what type of projects, etc. Lastly, make sure to get your free Rust Chi sheet by heading over to letsgetrusty.com/cheatsheet and with that said, I'll see you in the next one.