Introducing GenBrowser

Elixir (and erlang) are built on processes, which makes concurrency and distribution simpler. What if the processes model extended beyond the server, to the clients of a web application?

GenBrowser enables browsers to send and receive messages like an Elixir process. This simplifies communication and allows the actor model to be applied to reasoning about the whole client-server system.

GenBrowser is pre 1.0, therefore experimental, see the roadmap for details.

Core concepts

GenBrowser treats clients as just another process in one continuous, if widely distributed, system. Every client gets an address to which messages can be dispatched; and a mailbox where messages are delivered.

Address

Both clients and server processes have an address. Messages can be dispatched to an address regardless of what the address refers to.

Client addresses are analagous to a process identifier (PID). Server addresses are not limited to just PID's. For example, via tuples can also be used as the address of a server process.

{:via, MyModule, :term}

Mailbox

Messages dispatched to a GenBrowser client will be delivered to its mailbox. The mailbox may have a handler that is called for every message delivered. Alternatively messages can be extracted from the mailbox using the receive method.

Example

Receiving and sending messages in JavaScript is handled as follows.


      // 1.
      const client = await GenBrowser.start('http://localhost:8080')
      const {address, mailbox, send, communal} = client
      console.log(address)
      // "g2gCZA ..."

      // 2.
      mailbox.setHandler((message) => {
        if (message.type == 'ping') {
          send(message.from, {type: 'pong'})
        } else {
          console.log('I just received a message', message)
        }
      })

      // 3.
      send(communal.logger, %{text: "I just joined GenBrowser"})
    
  1. The browser joins the system, and this gives the client an address.
  2. Set a handler to be called for incoming messages. If the message has type ping then send a reply; otherwise log the message.
  3. Send a message to a logger process, that lives on the server. Communal addresses point to server process that are available to every client upon connection.

Sending and receiving messages on the server should look very familiar to an Elixir developer.

For example, sending a ping message to the first client, from the server.


      # 1.
      {:ok, client_address} = GenBrowser.decode_address("g2gCZA ...")

      # 2.
      message = %{
        "type" => "ping",
        "from" => GenBrowser.Address.new(self)
      }
      GenBrowser.send(client_address, message)

      # 3.
      receive do
        message ->
          IO.inspect(message)
      end
      # => %{"type" => "pong"}
    
  1. Decode the client address string. This will return process reference in the form {:global, term}.
  2. Send a ping message to the client.
  3. Receive the pong message from the client directly in the server process.

Sending a ping message to the first client, from another client, uses the same semantics.


      const client = await GenBrowser.start('http://localhost:8080')

      client.send("g2gCZA ...", {type: 'ping', from: client.address})

      const reply = await client.mailbox.receive({timeout: 5000})
      console.log("Pong received")
    

Security

Clients of a web application are an untrusted environment. When interacting with clients on the web, security is an important concern.

GenBrowser signs each address that is sent to a client, and verifies this signature when a client sends a message. Addresses are unforgeable and this allows the implementation of the Object Capability security model.

Once an address is known, that process has permission to send messages to it. If needed, a process can apply additional checks to validate the authenticity of messages it reads from its mailbox.

Handling Reconnection

If a client is disconnected the backend will retain messages. GenBrowser automatically handles reconnection and resending any missed messages.

The last-event-id header is sent as a cursor when reconnecting. This header is part of the Server Sent Events standard. The cursor is signed to ensure authenticity of the client reconnecting.

Phoenix/Plug integration

GenBrowser includes a Plug integration. Phoenix is built on top of Plug so adding GenBrowser is just a case of adding it to the Plug pipeline.


      # lib/my_app_web/endpoint.ex
      communal = %{myProcess: GenBrowser.Address.new(MyNamedProcess)}

      plug GenBrowser.Plug, communal: communal
    

The GenBrowser.Plug must be added after the body has been parsed.

The communal key contains all the information that the server needs to share with clients on connection.

GenBrowser Playground

GenBrowser includes a standalone backend, this allows systems to be built by implementing JavaScript only. This standalone backend exposes two processes in the communal information: -A global registration where addresses can be registered. -The other address is for a process that will log messages sent to it on the server.


      # With Elixir installed
      SECRET=s3cr3t iex -S mix run examples/Playground.exs

      # With Docker installed
      docker run -it -e SECRET=s3cr3t -p 8080:8080 gen-browser
    

The GenBrowser Playground allows distributed applications to be prototypes with only frontend code. Currently the Playground communal processes only work on a single node. The motivation for this functionality is to introduce the Process (Actor) model as a way of developing web applications. Particularly with the aim of introducing it to frontend developers.

Comparison with Phoenix channels

The defacto Elixir solution for sending information from server to client is Phoenix channels (part of the Phoenix framework). It is therefore valid to compare GenBrowser with Phoenix channels.

While the two projects cover many of the same usecases, they differ in several ways. Most of the differences derive from the core abstractions each is built upon.

Let's see how Phoenix channels are described in their documentation.

Channels are based on a simple idea - sending and receiving messages. Senders broadcast messages about topics. Receivers subscribe to topics so that they can get those messages. Senders and receivers can switch roles on the same topic at any time.

Phoenix channels are built upon the concept of subscribe and publish. Clients subscribe to topics and receive all messages published to those topics.

Messages published to a channel are transient and so not received by disconnected clients. This differs to GenBrowser where clients will automatically receive messages sent to them upon reconnect.

The Phoenix channels documentation does include instructions to add resending missed messages to a channels implementation.

Security of Phoenix channels is handled by controlling access to topics. Joining a topic gives permission to send messages on that topic, additional checks for messages sent can be added by implementing the appropriate callbacks.