Using raw WebSockets to communicate with a server is challenging. The WebSockets API is low-level, and uses bare events for handling asynchronous operations. It’s difficult to write programs in a clean imperative style when using event callbacks. You’ll quickly descend into callback hell.

Today I’ll show you a simple abstraction over WebSockets that makes calling the server as simple as a regular function call. If you’d like to use the complete example you can download it here.

WebSockets

What is a WebSocket? A WebSocket is a special connection a web browser can make to a server that allows two-way communication. The browser can send messages to a server, and the server can send messages to the browser. A message can be text or binary data. Each message is independent and asynchronous.

A WebSocket request has a special protocol ws:// or wss://. A WebSocket can be opened using a URL beginning with the special protocol: wss://example.com/web_socket_server.

Here’s an example of using a WebSocket in the browser.

let ws = new WebSocket("wss://localhost/web_socket_server);
ws.onopen = (event) => {
  ws.send("Hello server! How are you?");
};

If the server accepts WebSocket connections, it will receive a message from the browser "Hello server! How are you?".

The server can also send messages to the browser. The messages arrive as events on the WebSocket object. You can listen for them with a callback.

ws.onmessage = (event) => {
  console.log(`Data received from server: ${event.data}`);
};

// Data received from server: Hello browser! How are you?

Sending and receiving hellos is just fine, but how do you make the server do something in response to a message? That’s where we bring in Remote Procedure Calls.

Remote Procedure Call

What is a Remote Procedure Call (RPC)? RPCs are a programming pattern of calling procedures on a remote computer (server) as if they were local procedures (client).

// SERVER CODE
function SomeServerFunction() {
  // do work...
}
// CLIENT CODE
var result = await rpc.SomeServerFunction();

A remote procedure call is initiated by the client. The client calls a stub function that looks like a local function. The client then packs the function call and its parameters into a message. The message is sent to the server over a network. The server unpacks the message and invokes the matching server function. When the server is done invoking the functoin, a response is packed into a message and returned the the client. The client unpacks the response and the function call is complete.

A server must specially define what functions are available for RPC, not every server function is available. Now we’ll explore how to set up a server and a client to implement the RPC pattern using WebSockets. (If you’d like to use a complete example instead, clone it from here).

Step 1: Write the Server

I’ll be using node.js to create the server. You can use any server you’d like that supports web sockets. First install the dependencies.

npm install serve ws

Next, create a file server.js

const handler = require("serve-handler");
const http = require("http");
const WebSocket = require("ws");

const server = http.createServer((request, response) => {
  return handler(request, response, {
    public: "./public",
  });
});

server.listen(3000, () => {
  console.log("Running at http://localhost:3000");
});

const webSocketServer = new WebSocket.Server({
  port: 3001,
});

webSocketServer.on("connection", function (socket) {
  console.log("WebSocket connection established.");
});

This will serve static files in public on port 3000 and accept WebSockets on port 3001.

Step 2: Write the Client

Next create a directory public and put two files in it: index.html and index.js

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Example</title>
    <script type="module" defer src="/index.js"></script>
  </head>

  <body>
    <p>
      <input id="input" type="number" value="3" />
      <button id="multiply-button">Multiply By 1000</button>
      <button id="sqrt-button">Take square root</button>
    </p>
  </body>
</html>
let websocket = new WebSocket("ws://localhost:3001");

Now if you start up the server node server.js and navigate to http://localhost:3000 you’ll see a page with buttons that don’t do anything. In the server console log you should see “WebSocket connection established.”

Step 3: Set up the remote procedures on the server

We’ll create some helper classes to handle RPC on the server side. Create a file Procedures.js

const Rpc = require("./Rpc");

module.exports = class Procedures extends Rpc {
  multiplyByOneThousand(number) {
    console.log("Multiplying!");
    return number * 1000;
  }

  doSquareRoot(number) {
    console.log("Square Rooting!");
    return Math.sqrt(number);
  }
};

This has some contrived functions that do very little, but they’re just for illustration. multiplyByOneThousand and doSquareRoot are our remote procedures in a plain old JavaScript class… But wait, it extends Rpc, let’s add that file Rpc.js

module.exports = class Rpc {
  socket;

  constructor(socket) {
    this.socket = socket;
    socket.on("message", this.#onMessage.bind(this));
  }

  /**
   * Handle all incoming messages. Match client requests to local procedures.
   * @param {String} msg
   */
  #onMessage(msg) {
    console.log(`[message] Data received from client: ${msg}`);
    let request = JSON.parse(msg);
    let procedure = this[request.procedure];
    if (!procedure) {
      console.log(`No such procedure: ${request.procedure}`);
      return;
    }
    let result, error;
    try {
      result = procedure(...request.args);
    } catch (e) {
      error = e;
    }

    this.socket.send(
      JSON.stringify({
        id: request.id,
        payload: result,
        error: error,
      })
    );
  }
};

This class takes a WebSocket connection in the constructor, then listens to all "message" events. The messages must be in JSON format, and must contain certain fields:

{
  "id": "123", // The unique ID for this message. Gets copied to the response for correlation.
  "procedure": "doSquareRoot", // The procedure to call.
  "args": [3] // An array of arguments for the procedure.
}

The #onMessage function parses the message, looks up the procedure by name on this object, then invokes the procedure. The result is then serialized back to JSON, and sent back over on the WebSocket.

Now back to server.js, we need to plug this into our WebSocket server.

const Procedures = require("./Procedures"); // Import the class

...

webSocketServer.on("connection", function (socket) {
  console.log("WebSocket connection established.");
  let session = new Procedures(socket); // Add this line
});

Now the server is ready to accept RPC calls.

Step 4: Set up the remote procedures on the client

The client code is going to look very similar to the server code. Let’s add a file RemoteProcedures.js in the public directory.

import { RpcClient } from "../RpcClient.js";

export class RemoteProcedures extends RpcClient {
  async multiplyByOneThousand(number) {
    return this.callRemoteProcedure("multiplyByOneThousand", [...arguments]);
  }

  async doSquareRoot(number) {
    return this.callRemoteProcedure("doSquareRoot", [...arguments]);
  }
}

Looks familiar right? These are our plain old JavaScript function calls. But wait, this class extends RpcClient. Let’s add RpcClient.js

export class RpcClient {
  /** @type WebSocket */
  socket;
  /** @type Map */
  waiting;

  constructor() {
    this.waiting = new Map();
  }

  /**
   * Create a WebSocket.
   * @returns @type Promise
   */
  open(url) {
    this.socket = new WebSocket(url);
    this.socket.addEventListener("message", this.#onMessage.bind(this));
    return new Promise((resolve, reject) => {
      this.socket.addEventListener("open", resolve, { once: true });
      this.socket.addEventListener("error", reject, { once: true });
    });
  }

  /**
   * Call a remote procedure. Return a Promise that is fulfilled when the server responds.
   * @param {string} procedure
   * @param {object} payload
   * @returns @type Promise
   */
  callRemoteProcedure(procedure, args) {
    const id = Math.random().toString(16).slice(2);
    return new Promise((resolve, reject) => {
      this.waiting.set(id, (error, responsePayload) => {
        if (error) {
          reject("Server Error: " + error);
        } else {
          resolve(responsePayload);
        }
      });

      this.socket.send(
        JSON.stringify({
          id,
          procedure,
          args: [...args],
        })
      );
    });
  }

  /**
   * Handle all incoming message. Match server replies to waiting callbacks.
   * @param {MessageEvent} event
   */
  #onMessage(event) {
    console.log(`[message] Data received from server: ${event.data}`);
    let response = JSON.parse(event.data);
    let callback = this.waiting.get(response.id);
    this.waiting.delete(response.id);
    callback(response.error, response.payload);
  }
}

Okay there’s a lot going on here. Let’s break it down.

  • open()
    • This function creates a WebSocket and returns a Promise that resolves when the WebSocket is ready.
  • call(procedure, args)
    • This function takes the name of a remote procedure to call, and the arguments to send.
    • It creates a unique ID to track the procedure call.
    • It registers a callback (using the unique id) that will execute when the server replies, then send a message to the server in the format specified before.
    • It returns a Promise that resolves when the remote procedure call is complete.
  • #onMessage(event)
    • This function listens to all incoming messages on the WebSocket.
    • It parses the response, and finds the unique ID.
    • It looks up a waiting callback by the unique ID, then invokes it (which subsequently resolves the Promise returned earlier.)

Okay, clear as mud? Basically, this is the JSON transport layer that communicates with the RPC backend for us, so we can have nice clean function calls. Let’s try it out. Open up index.js and add the following:

import { RemoteProcedures } from "./RemoteProcedures.js";
const remoteProcedures = new RemoteProcedures();
await remoteProcedures.open("ws://localhost:3001");

const input = document.querySelector("#input");
const multiplyButton = document.querySelector("#multiply-button");
const sqrtButton = document.querySelector("#sqrt-button");

multiplyButton.onclick = async () => {
  const result = await remoteProcedures.multiplyByOneThousand(
    Number(input.value)
  );
  input.value = result;
};

sqrtButton.onclick = async () => {
  const result = await remoteProcedures.doSquareRoot(Number(input.value));
  input.value = result;
};

We’re adding click handlers to the buttons on the page, which will call our remote procedures. Through the magic of Promises, we can use await to wait for the server response. Once we have the response, we put it on the page.

Alright, our application is ready to go. Fire up the server node server.js and open http://localhost:3000. Click some buttons and you should see some output on the server like this:

$ node server.js
Running at http://localhost:3000
WebSocket connection established.
[message] Data received from client: {"id":"c8596a8255cf4","procedure":"multiplyByOneThousand","args":[3]}
Multiplying!
[message] Data received from client: {"id":"4e54967b32857","procedure":"multiplyByOneThousand","args":[3000]}
Multiplying!
[message] Data received from client: {"id":"1f13f3323c99b","procedure":"multiplyByOneThousand","args":[3000000]}
Multiplying!
[message] Data received from client: {"id":"092046b0ce16b","procedure":"doSquareRoot","args":[3000000000]}
Square Rooting!
[message] Data received from client: {"id":"dcee5795bbd21","procedure":"doSquareRoot","args":[54772.25575051661]}
Square Rooting!
[message] Data received from client: {"id":"d0e76e0a522bf","procedure":"doSquareRoot","args":[234.0347319320716]}
Square Rooting!

Conclusion

Alas, the example is quite contrived. You wouldn’t call the server to do multiplication or square roots. The important part is how requests and responses are correlated through a unique ID, and then packaged up into a Promise that you can simply await. Using this pattern you can add as many remote procedures as you need to do any kind of backend work. It makes working with WebSockets much simpler as you’re not dealing with raw event callbacks and serialization since it’s all abstracted. Instead you’re just calling a simple async function and awaiting the result.

WebSockets’ true power lies in server-sent messages. You can implement RPC in the reverse direction as well (server calls remote procedures on the client) but I’ll leave that as an exercise for the reader.

Further Reading