Web Socket for Productivity in Rust

It's likely that you will write a chat server if you are learning web sockets, but let's do something different today. Here's what we'll cover today: learning how to write a web socket server for productivity.

WebSocket

According to Wikipedia this is the definition of websocket:

WebSocket is a computer communications protocol, providing full-duplex communication channels over a single TCP connection.

Most of the time websocket is used for realtime application, like chat, notification etc.

But I found some interesting use case for websocket server which is for hot reload web page.

If you have been working with react or vue before you should probably know that these framework support hot reload, so whenever we changing the code we don't need to open web browser and reload the page.

In my case when working on hl I found myself always reloading page when debugging html render.

A little bit about hl it's rust library for turn source code to syntax highlight like github, se example code highlight here.

So I want the experience to not always reloading page manually to test the result for html rendering. And I found websocket can solve this issue.

Actix Web

So here's how we will create websocket server.

  • Index endpoint to render html.
  • Websocket endpoint to send event file changes.
  • Javascript to listen event file changes from websocket.

In Rust world actix web have rich feature for building web application. So let's add this crates to our project.

actix = "0.13"
actix-web = "4"
actix-web-actors = "4.1"

Next let's create handler for rendering html at index page. In this case we will read file index.html as html template and replace the content from table.html file.

#[get("/")]
async fn index() -> Result<HttpResponse> {
    let content = std::fs::read_to_string("table.html").expect("table not found");
    let body = include_str!("index.html")
        .to_string()
        .replace("{content}", &content);
    Ok(HttpResponse::build(StatusCode::OK)
        .content_type("text/html; charset=utf-8")
        .body(body))
}

And add entry point for our websocket server.

use websocket::FileWatcherWebsocket;

async fn echo_ws(req: HttpRequest, stream: web::Payload) -> Result<HttpResponse, Error> {
    ws::start(FileWatcherWebsocket::new(), &req, stream)
}

And then the websocket server it self.

const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1);
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
const FILE_PATH: &'static str = "table.html";

pub struct FileWatcherWebsocket {
    hb: Instant,
    modified: SystemTime,
}

impl FileWatcherWebsocket {
    pub fn new() -> Self {
        let metadata = fs::metadata(FILE_PATH).unwrap();
        let modified = metadata.modified().unwrap();

        Self {
            hb: Instant::now(),
            modified,
        }
    }

    fn hb(&self, ctx: &mut <Self as Actor>::Context) {
        ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| {
            if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT {
                println!("Websocket Client heartbeat failed, disconnecting!");

                ctx.stop();

                return;
            }

            let modified = fs::metadata(FILE_PATH).unwrap().modified().unwrap();
            if modified.duration_since(act.modified).unwrap() > Duration::from_millis(0) {
                act.modified = modified;
                println!("Sending file changes event! {}", &FILE_PATH);
                ctx.text("file_changed")
            }

            ctx.ping(b"");
        });
    }
}

impl Actor for FileWatcherWebsocket {
    type Context = ws::WebsocketContext<Self>;

    fn started(&mut self, ctx: &mut Self::Context) {
        self.hb(ctx);
    }
}

impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for FileWatcherWebsocket {
    fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
        match msg {
            Ok(ws::Message::Ping(msg)) => {
                self.hb = Instant::now();
                ctx.pong(&msg);
            }
            Ok(ws::Message::Pong(_)) => {
                self.hb = Instant::now();
            }
            Ok(ws::Message::Text(text)) => ctx.text(text),
            Ok(ws::Message::Binary(bin)) => ctx.binary(bin),
            Ok(ws::Message::Close(reason)) => {
                ctx.close(reason);
                ctx.stop();
            }
            _ => ctx.stop(),
        }
    }
}

This websocket server will be ping client every one second and then check the last modified file table.html and if there's any file changes it will send *file_changed event to client.

Client

Luckily nowadays is supporting websocket client out of the box. So here's the code for the client side that we included at index.html file.

<script>
  let socket = new WebSocket("ws://localhost:8080/ws");

  socket.onopen = function(e) {
    console.log("[open] Connection established");
    console.log("Sending to server");
    socket.send("start_connection");
  };

  socket.onmessage = function(event) {
    console.log(`[message] Data received from server: ${event.data}`);
    if (event.data == "file_changed") {
      window.location.reload();
    }
  };

  socket.onclose = function(event) {
    if (event.wasClean) {
      console.log(`[close] Connection closed, code=${event.code} reason=${event.reason}`);
    } else {
      console.log('[close] Connection died');
    }
  };

  socket.onerror = function(error) {
    console.log(error)
  };

</script>

The client will be listening for every incoming message and then reload the browser if there any file_changed event.

This is very simple implementation but saving me so much time.

Conclusion

I think whenever we learn new technology it will so much fun to use it to solve our problem, in this case I use it to automate web page reload which is make me less work on debugging, and I'm so happy about it.

If you want to see full source code you can go here.

I think that is all for today, happy coding 🤗!