DEV Community

Cover image for RPC done right for browser APIs and server APIs
Philippe Poulard
Philippe Poulard

Posted on

RPC done right for browser APIs and server APIs

In the browser, many APIs are used to exchange data :

  • fetch (the today preferred API used for the good old HTTP protocol)
  • web socket (an upgrade of HTTP)
  • broadcast API, to make browser tabs and windows discuss together
  • web worker (with their variant shared workers ; service worker is not an exchange API), to make running expansive tasks in another thread
  • RTC, to make peer to peer data exchanges

For those APIs that are talking to a web server (the 2 formers), there are the counterpart server APIs : HTTP and web socket. It's worth to mention that even server side, you may find similar exchange APIs within a NodeJS environment:

  • cluster API, to run your app in several threads
  • IPC, to scale your app in several servers

The latest is also used in desktop environments such as electron.

The problems

If we examine all those APIs (in the browser and server side), they are all doing more or less the same thing, but with really different APIs, which let us coding the wiring of our applications in a specific way according to the channel considered, whereas we just need to send some data and (sometimes) receive some response.

Let's start with some data to exchange ; we describe them with Typescript :

type Person = {
    firstName?: string,
    lastName: string,
    birthDate?: Date
}
const bob: Person = {
    firstName: 'Bob',
    lastName: 'Marley',
    birthDate: new Date('1945-02-06')
}
Enter fullscreen mode Exit fullscreen mode

In a Chat app, for example, this is how you would like to talk to other users :

say(bob, 'Hello World !');
Enter fullscreen mode Exit fullscreen mode

And this is how you would send and receive a message client side with web sockets :

const socket = new WebSocket('wss://example.com/ws');
function say(user: Person, message: string) {
    const data = JSON.stringify({ user, message });
    socket.send(data);
}
type ChatMessage = { user: Person, message: string }
socket.onmessage = function(e) {
   const {user, message} = JSON.parse(e.data) as ChatMessage;
   user.birthDate = new Date(user.birthDate);
   // render in HTML
   renderChatMessage(user, message);
}
Enter fullscreen mode Exit fullscreen mode

Now, this is how you would send and receive a message with the broadcast API, provided that it would have some sense for a chat application :

const bc = new BroadcastChannel('Chat');
function say(user: Person, message: string) {
    bc.postMessage({ user, message });
}
type ChatMessage = { user: Person, message: string }
bc.onmessage = function(ev: MessageEvent<ChatMessage>) {
   const {user, message} = ev.data;
   user.birthDate = new Date(user.birthDate);
   // render in HTML
   renderChatMessage(user, message);
}
Enter fullscreen mode Exit fullscreen mode

Let's point some difference : the former use send() to send messages, the latter use postMessage() ; the former deals with text, the latter with plain Javascript objects.

This is a really minimal exchange: just a simple one-way message with no response. But things can quickly go more and more complicated :

  • how to handle several different type of messages ? well... you'd have to send an additional information such as { type: 'Chat.say' } and when receiving messages, you will have to dispatch them with a big switch.
  • how to handle return values ? when you receive some data, they are unrelated to a message that you would have been sent that expect some response (except with HTTP fetch), how do you follow the data flow ?
  • how to handle errors ?
  • did you notice that class instances such as birthDate have to be revived ?

And if you think about what happens server side, there are more things to take care. So far, we considered that the client was the sender and the server was the receiver, but :

  • how would you reverse the polarity, that is to say make the server send a message and expecting the client sending back a response ?
  • what are becoming all the previous considerations in that case ?
  • how to broadcast a message to all users ?
  • when receiving a message sent by the client, how to multicast that message to others except the sender ? or to arbitrary recipients ?
  • how to sometimes send back an acknowledgement (and some times don't) ?
  • how to deal with errors ?
  • how to manage timeouts ?
  • how the client would manage all that stuff (return values, errors, timeouts, etc) when the server is the sender of the message ?

If you consider a single protocol, you'll certainly be able to tackle those problems one by one, but if you switch to another protocol, you'll have to rewrite everything.

Let's go on with more general purpose considerations :

  • how to manage the security efficiently when you use both the HTTP channel and the web socket channel in your app ?
  • how to ensure that all kind of messages sent in one side are processed properly with the right type on the other side ?
  • how to avoid writing boilerplate code when you have to deal with a so common API such as CRUD ?
  • how to have a nice mapping to REST with less efforts ?

The solution

RPC to the rescue: be abstract

There is a way to think of all that with a good abstraction : firstly, let's evacuate all the wiring considerations, that is to say how the data are exchanged through any channel. What is remaining is just our intents (the what, not the how), actually the function say() with its signature : here we are, in a nutshell, we just want the client app to send messages like say(bob, 'Hello World !'); with optionally a response, and just supply the code that process that message server side: this is called a remote procedure call, or RPC. The fact that we are using HTTP or web socket, or even that we are not on a client-server exchange but peer to peer, or on anything else is not taken into account.

So we will separate our intents from the underlying concrete channel ; then as an expectation, the channel would send the data at its charge (no code to write) ; on the other side, we would just supply the implementation of our function ; this is how RPC works.

Let's describe it with Typescript ; we could use an interface, but you will see soon that a class is better, although at this stage it has to be considered as an interface, because we just need the signature of our RPC function :

abstract class Chat {
    say(user: Person, message: text) {}
}
Enter fullscreen mode Exit fullscreen mode

Asynchronizer to the rescue

We go on with Asynchronizer, a nice library that does all the required stuff...

Then the code client side would be :

// the wiring :
const th = // TODO: get a transfer handler some way (see below)
// generate a proxy Data Transfer Handler for the Chat class :
const chatDTH = th.bindSender(Chat);
// send a message with ACK :
await chatDTH.say(bob, 'Hello World !);
Enter fullscreen mode Exit fullscreen mode

Not only the types of the arguments passed to the function are constrained by Typescript, but the function was turned to a Promise, which let us ensure to get back an acknowledgement.

Moreover, what is nice is that the transfert layer can be anything ; let's try a web socket :

import { SocketClient } from '@badcafe/asynchronizer/SocketClient';

const socket = new WebSocket('wss://example.com/ws');
const channel = SocketClient.Channel.get(socket);
const th = channel.transferHandler();
Enter fullscreen mode Exit fullscreen mode

...or HTTP :

import { HttpClient } from '@badcafe/asynchronizer/HttpClient';

const channel = HttpClient.Channel.get('/api');
const th = channel.transferHandler();
Enter fullscreen mode Exit fullscreen mode

...what differs is getting and configuring some channel, but in both cases the chatDTH proxy will be able to invoke the functions defined in the Chat class regardless the concrete channel used. It's worth to mention again that the say() function was defined in that Chat class as a normal function, but was turned to a promise in the chatDTH proxy instance generated. What is nice is that at design time we focus on the payload, not the fact that it will be asynchronous. Why ? Because in certain case you expect it to return a Promise (this is an acknowledgement) and in other case you expect it to return nothing (void) ; more about that later...

Explanations about the vocabulary

Before having a look on how it is received server side, few words about the vocabulary used here :

  • the channel is the concrete component that emit data with send(), postMessage() or fetch() or whatever. This is the low-level protocol. Asynchronizer supplies most channels invoked at the start of this article, but you can implement your own.
  • the transfer handler (set in the th const) is an intermediate layer that makes the abstraction between the channel and the RPC functions to which one can bind a sender at one side or a receiver on the other side. This is the high-level of the protocol exchange.
  • the data transfer handler holds the definition of the remote service ; it is not called just "a service" because the concept is wider since a service is related to something that runs within a server, whereas we are dealing with RPC APIs that are also considering exchanges within the browser, or within a NodeJS server. Hence the concept of Data Transfer Handler. DTH is almost like DAO that would access to some store, but a DAO deals with persistence, DTH with data transfer.
  • the sender is the side that initiate the data exchange (a request) and is a proxy generated from the transfer handler, and the receiver is the side that implements what to do with the data (at the charge of the developer), and wether there are some results to send back to the sender.

The isomorphic architecture (1/2)

Let's go on with our Chat app. Server side, we have to write the code that implements the say() function, that is to say what are we supposed to do when such messages are received.

Here is the skeleton :

const th = // TODO: get a transfer handler from a socket
           //       server channel or an HTTP server channel
th.bindReceiver(Chat, {
    // the arguments types are inferred to the right types
    async say(user, message) {
        // TODO (see below)
    }
});
Enter fullscreen mode Exit fullscreen mode

According to the channel used client side, you will get the counterpart channel server side (SocketServer.Channel or HttpServer.Channel) that requires some configuration that have to be done once (code not shown for simplicity), then get its transfer handler.

Did you notice that the same DTH definition (the Chat class) were used server side and client side ? What is nice in such isomorphic architecture is that the same code can be used in both sides ; thanks to Typescript, we are sure that the function signature used to send data in one side will match the one used to receive data on the other side. And if you define several functions in the DTH class, the type system will warn you if you omit to write the implementation of one function, or if the arguments types don't match. Of course, you may define as many DTH class as you want with as many functions as you want.

The last but not the least, our data are revived as necessary. The birthDate field of the person is a Date instance when we enter the function.

Broadcast, Multicast, Unicast

So far, we learned how to send a message from the client, and how to receive it in the server ; a good chat application should be able to send messages from the server and to receive it in the client. Let's see how to reverse the polarity.

Server side, apply the following modifications :

// the server can also be a sender
const chatDTH = th.bindSender(Chat);
th.bindReceiver(Chat, {
    async say(user, message) {
        // when a message is received, send it to all users
        chatDTH.say(user, message);
    }
});
Enter fullscreen mode Exit fullscreen mode

Client side, just append to the previous code :

th.bindReceiver(Chat, {
    async say(user, message) {
       // display HTML
       renderChatMessage(user, message);
    }
});
Enter fullscreen mode Exit fullscreen mode

In fact, things are not so different when the client is the sender or when the server is the sender. However, there is a subtle difference :

  • Client side :
await chatDTH.say(bob, 'Hello World !);
Enter fullscreen mode Exit fullscreen mode
  • Server side :
chatDTH.say(user, message);
Enter fullscreen mode Exit fullscreen mode

Well, the senders are not the same : the former returns a Promise and the latter returns nothing ; if you omit await in the former, your IDE will tell you so thanks to Typescript.

This is because the server will broadcast the message to all clients, and broadcasting doesn't require an acknowledgement. In fact, each channel kind has a default behaviour for sending messages according to its capabilities. The HTTP server channel and the web socket server channels have broadcast capabilities, whereas the HTTP client channel and the web socket client channels don't : it's unicast.

Your IDE will show you the difference if you hover the variable (below, in the client code we have the DTH is a Sender.Unicast, then in the server code the DTH is a Sender.Broadcast) :

Client side, the default is a unicast sender

Server side, the default is a broadcast sender

But wait, since the server is broadcasting every message it receives, all clients will receive it, including the user that write that message ? Can't we change the behaviour ? Sure we can, for that purpose, the DTH supply additional functions that are accessible thanks to Symbols (like [MULTICAST]()) to avoid naming conflicts with existing methods of the Chat class :

Server side, apply the following modifications :

import { MULTICAST, OTHERS } from '@badcafe/asynchronizer';

const chatDTH = th.bindSender(Chat);
th.bindReceiver(Chat, {
    async say(user, message) {
        chatDTH[MULTICAST](this[OTHERS]) // other clients
            .say(user, message);
    }
});
Enter fullscreen mode Exit fullscreen mode

Note that in the body of the receiver function :

  • this is bound to the endpoint port of the channel, that may be in our case the web socket (SocketServer.Endpoint) or a wrapper object around the HTTP request (HttpServer.Endpoint)
  • this[OTHERS] contains conveniently an array of all clients that are not this client (the one that sends the message).
  • In fact, chatDTH[MULTICAST](this[OTHERS]) gives another chatDTH object but that will send messages to a different scope, this is why we can call directly our .say() function on it.

That way, not only we can send a response to other clients, but we could also send a response to the sender only (note that sending a response in another message is not the same as returning a value, our say() function doesn't return a value) :

Server side, apply the following modifications :

import { UNICAST, MULTICAST, OTHERS } from '@badcafe/asynchronizer';

const chatDTH = th.bindSender(Chat);
th.bindReceiver(Chat, {
    async say(user, message) {
        if (message === 'ping') {
            await chatDTH[UNICAST](this) // only this client
                .say({lastName: 'Tennis table'}, 'pong');
        } else {
            chatDTH[MULTICAST](this[OTHERS]) // other clients
                .say(user, message);
        }
    }
});
Enter fullscreen mode Exit fullscreen mode
  • chatDTH[UNICAST]() turns the DTH to a Sender.Unicast which returns a Promise that you have to await ; you can drop the acknowledgement by chaining the [NOACK] property if you'd like to. It's Typescript, your IDE will show you it and will import the Symbol if you choose it :

Autocompletion in modern IDE

The isomorphic architecture (2/2)

So far, we have an almost full featured chat application, and we focused just on what our app is supposed to do, not how the messages are sent when we are using HTTP or web sockets. It works for both.

For that purpose, our application is an isomorphic application with at least 3 parts :

[CHAT_ROOT]
    ┣━chat-shared      contains DTH classes definition
    ┃
    ┣━chat-client      is the client app
    ┃
    ┗━chat-server      is the server app
Enter fullscreen mode Exit fullscreen mode

In Asynchronizer, an isomorphic application is an application that uses Javascript/Typescript both on the client and the server, and that contains an amount of code common to both sides. From that base, you might consider additional parts for client workers or other server layers.

Don't forget the REST

But I want more :

  • some nice REST mappings
  • check whether the current user has the right role in the app
  • avoid writing boilerplate code for common CRUD entity management

Almost all that takes place in the shared part of the app ; let's update our DTH class :

@DataTransferHandler()
abstract class Chat {
    @POST
    say(@BodyParam('user')    user: Person,
        @BodyParam('message') message: string) {}
}
Enter fullscreen mode Exit fullscreen mode

...will be map to :

POST /api/Chat/say
(and the HTTP body will contain the 2 parameters)
Enter fullscreen mode Exit fullscreen mode

That way, we are mapping our RPC functions to some REST API ; the decorators are applied with the HTTP channel and ignored with the web socket channel or other channels. We have several other decorators : @GET, @PUT, @DELETE, @PathParam, @HeaderParam, etc. You can also rename path steps, or insert wherever additional path steps.

Finally, let's manage the persons entity in our application :

@DataTransferHandler()
@AccessControl.checkRole('admin')
abstract class PersonDTH extends Crud {
    // @POST create()
    // @GET read()
    // @PUT update()
    // @DELETE delete()
    // @GET list()
    // are inherited from Crud, you don't need to write'em

    @GET
    listPersonBornAfter(@PathParam date: Date): Person[]
        { throw '' } // dummy code
}
// this example was simplified for readability
Enter fullscreen mode Exit fullscreen mode
  • Extending Crud will create automatically the expected methods with their REST annotations, so that you can use directly in the client :
try {
    await personDTH.update(someone);
} catch (err) {
    // TODO: Ooops ! Unable to update someone
}
Enter fullscreen mode Exit fullscreen mode

...and it's async/await, you handle errors regardless the channel invoked just as usual.

  • @AccessControl.checkRole('admin') is a decorator that we must develop ourselves in this application ; there is not a generic mechanism because access controls exist in very various flavors so it's up to the developer to define and implement its own access policy. But it's not so difficult to bind it server-side to the socket endpoint or the HTTP endpoint since they are easily customizable, and to make it working in both cases, because Asynchronizer supply some convenient hooks. Here, we might apply that decorator to the class, but also to each functions of the class.
  • { throw '' } // dummy code is the only boilerplate code that we are compelled to write : this is a limitation due to Typescript that doesn't let us writing @decorators to abstract functions. We expect to be able to use @decorators in abstract functions in a future version of Typescript.

Asynchronizer

This is the library I work on since 3 years.

It is used since the beginning in a critical application in my company and is about to be released soon with lots of outstanding features. It requires some final polish and documentation before being available.

Asynchronizer was designed to make things simple :

  • you write some RPC definitions in so-called DTH classes ; you just have to focus on the functions payload : their name, arguments, return values ; they might be decorated for nice REST mapping (available in the library), security checking (at the charge of the developer), etc
  • you bind senders and receivers with the function implementation the same way everywhere : web worker, node clusters, web server, etc ; thanks to Typescript, your IDE will help you to write the right code. Broadcast, multicast, and unicast capabilities are included
  • you choose a concrete transport, if you change it later you don't need to touch your code: it just works

Server-side, Asynchronizer fits well in Express or Koa, or can be used as a standalone server.

I also intend to generate the full OpenAPI (Swagger) definitions from the REST @decorations, but it will not be ready for the first public realease.

Final thoughts :

  • You might think that with GraphQL you also have some kind of RPC API ? Not at all, afaik GraphQL client is just for HTTP fetch and don't have nice REST mappings facilities. How would you push messages from the server to the client with GraphQL ? or from the browser window to the browser worker ? You can still use GraphQL with Asynchronizer server-side for the data access layer ; after all, the entry point of all your GraphQL query is also a function, is it ?

  • So, you still manage routes by hand in the server ? You think you have the same with your preferred REST library ? Not at all, when you use a REST library you define the mappings server side and you write the right HTTP query client side with no help : no type checking, no automatic class reviving, etc. Conversely, Asynchronizer let define our REST mapping in the shared code, but client and server side you just use the same RPC function !

I hope you find like me that those kind of features looks very interesting, because Asynchronizer rocks !

Asynchronizer will be available here :

See also :

  • Jsonizer, the tool that helps you revive class instances in your JSON structures (used in Asynchronizer)

Top comments (0)