Guide

Peer initialization

For each browser client, there is a process on the server side, which is called peer. Peers, grouped in rooms, are responsible for communication with their clients. Since this communication is based on WebSocket, the peer process starts when a client opens a WebSocket connection (probably with new WebSocket(URL)) and thus, upgrade request is send.

After receiving a request, Membrane.WebRTC.Server.Peer.peer_id/0 will be automatically generated. Then a peer should parse the request with Membrane.WebRTC.Server.Peer.parse_request/1 callback. Credentials and metadata returned from it will be used to create Membrane.WebRTC.Server.Peer.AuthData and room's name will be used to get a room's PID from the registry (specified in Membrane.WebRTC.Options. Then, the state is initialized in Membrane.WebRTC.Server.Peer.on_init/3 and authentication is performed with Membrane.WebRTC.Server.Peer.AuthData extracted from the request. Finally, WebSocket is initialized.

After successful initialization, the peer will try to join the room with the name returned from the Membrane.WebRTC.Server.Peer.parse_request/1. Authorization (with the same Membrane.WebRTC.Server.Peer.AuthData) or other checks can be performed in Membrane.WebRTC.Server.Room.on_join/2 callback. Room automatically broadcast Membrane.WebRTC.Server.Message.joined_message/0 to notify other peers about the new one. After that, the peer will send Membrane.WebRTC.Server.Message.authenticated_message/0 to the client to inform about successful initialization.

Signalling

The client communicates with a peer by exchanging JSON messages. These messages should have the same fields as the Membrane.WebRTC.Server.Message, which is used in internal communication. Other fields of JSON will be lost in decoding.

Every JSON received from the client will be decoded into the Membrane.WebRTC.Server.Message struct. The peer will set :from field with own peer_id. Then it will send a message to the room, where it will be forwarded to the addressees. The addressees and sender are specified in to and from message fields by peer_ids.

The message can be modified or ignored by both peer and room using Membrane.WebRTC.Server.Peer.on_receive/3 and Membrane.WebRTC.Server.Room.on_forward/2 callbacks. The addressee peer, after receiving the message will encode it back to JSON and send it to its client.

Creating example application

This guide focus on writing simple WebRTC application based on Plug.

Complete source code can be found here.

Setting up a project

Create a new mix project with

$ mix new server --module Server

To create a server, we have to add Membrane WebRTC Server to dependencies. Add this line to the deps in mix.exs.

{:membrane_webrtc_server, "~> 0.1"}

We will also use Plug (i.a. to set up routing) and Jason (to parse credentials received from the app:

{:jason, "~> 1.1"},
{:plug, "~> 1.7"},
{:plug_cowboy, "~> 2.0"}

Creating a peer module

Inside lib/server folder create a new module which uses Membrane.WebRTC.Server.Peer.

defmodule Server.Peer
  use Membrane.WebRTC.Server.Peer

  ...

Implementing parse_request callback

Before initialization of peer, an authentication request is parsed in parse_request. This function receives the request and should return a tuple containing :ok atom, credentials, metadata and name of the room peer wants to join.

@impl true
def parse_request(request) do
  ...

  {:ok, credentials, metadata, room_name}
end

Let's assume that JS client will specify room in URL binding. We can get the name with the following function:

defp get_room_name(request) do
  room_name = :cowboy_req.binding(:room, request)

  if room_name == :undefined do
    {:error, :no_room_name_bound_in_url}
  else
    {:ok, room_name}
  end
end

Easy way to send credentials with WebSocket upgrade request is to include them in a cookie. After retrieving them, we should decode them with Jason.decode.

defp get_credentials(request) do
  case :cowboy_req.parse_cookies(request) |> List.keyfind("credentials", 0) do
    {"credentials", json} ->
      Jason.decode(json)

    _ ->
      {:error, :no_credentials_passed}
end

Now we can finish implementing parse_request. We have our room name, credentials and we don't need metadata for this request, so we will return nil instead of it. The finished function should look like something like this:

@impl true
def parse_request(request) do
  with {:ok, room_name} <- get_room_name(request),
       {:ok, credentials} <- get_credentials(request) do
    {:ok, credentials, nil, room_name}
  end
end

Please notice that storing non-hashed credentials in cookie is unsafe (since they are available as plain text). Example of more sophisticated authentication based on Guardian can be found here.

Authentication

Authentication happens before WebSocket initialization. We can perform it in on_init callback, for example:

@impl true
def on_init(_context, auth_data, _options) do
  username = Map.get(auth_data.credentials, "username")
  password = Map.get(auth_data.credentials, "password")

  if username == "USERNAME" and password == "PASSWORD" do
    {:ok, %{}}
  else
    {:error, :wrong_credentials}
  end
end

The return value (in case of successful authentication) contains an empty map, which is a new state for the peer.

Implementing room

Since mesh WebRTC can't scale to a large number of participants, we will create Room which will block not let more than 2 peers in.

Inside lib/server/ folder create a new module which uses Membrane.WebRTC.Server.Room.

defmodule Example.Simple.Room do
  use Membrane.WebRTC.Server.Room

Starting room process will get the maximal number of peers from initial options (value under :custom_options field in Membrane.WebRTC.Server.Room.Options). We can specify that behaviour in handle_init implementation.

@impl true
def on_init(options) do
  {:ok, %{number_of_peers: 0, max_peers: options.max_peers}}
end

So, as you can see, options will be map with field :max_peers.

The return value contains a map (new state of our room) with the current number of peers and the maximal number of peers. Starting room is empty, so :number_of_peers equals 0.

Every time peer will join the room we must check, if we surpass allowed number. If not, the room must increment it. We'll specify that behaviour in on_join callback.

@impl true
def on_join(_auth_data, state) do
  current_number = state.number_of_peers

  if current_number < state.max_peers do
    {:ok, Map.put(state, :number_of_peers, current_number + 1)}
  else
    {{:error, :room_is_full}, state}
  end
end

When this function return error, an error message will be sent to the client.

Of course, we also have to decrement the number of peers if some peer leaves.

@impl true
def on_leave(_peer_id, state) do
  {:ok, Map.put(state, :number_of_peers, state.number_of_peers - 1)}
end

To sum up, the whole file should look like this:

defmodule Server.Room do
  use Membrane.WebRTC.Server.Room

  @impl true
  def on_init(options) do
    {:ok, %{number_of_peers: 0, max_peers: options.max_peers}}
  end

  @impl true
  def on_join(_auth_data, state) do
    current_number = state.number_of_peers

    if current_number < state.max_peers do
      {:ok, Map.put(state, :number_of_peers, current_number + 1)}
    else
      {{:error, :room_is_full}, state}
    end
  end

  @impl true
  def on_leave(_peer_id, state) do
    {:ok, Map.put(state, :number_of_peers, state.number_of_peers - 1)}
  end
end

Configuring router

As mentioned before, this application uses Plug to set up routing. Let's configure our router:

defmodule Example.Simple.Router do
  use Plug.Router

  plug(Plug.Static,
    at: "/",
    from: :example_simple
  )

  plug(:match)
  plug(:dispatch)

  get "/:room" do
    send_file(conn, 200, "priv/static/html/index.html")
  end

  match _ do
    send_resp(conn, 404, "404")
  end
end

As you can see, the URL will specify room for the client.

Generating key and certificate

Since the application uses HTTPS, certificate and key are needed to run it. You generate them with

$ openssl req -newkey rsa:2048 -nodes -keyout priv/certs/key.pem -x509 -days 365 -out priv/certs/certificate.pem

Note that this certificate is not validated and thus may cause warnings in the browser.

Dispatching and starting the application

Now, with ready Room, Peer and Router, we can configure how our application is started. Inside lib/server.ex create module Server, which uses Application.

defmodule Server do
  use Application
  alias Membrane.WebRTC.Server.Peer
  alias Membrane.WebRTC.Server.Room
    
  ...

First of all, we have to configure our dispatch function. It will specify routes rules. Let's assume that a WebSocket upgrade request will be given at /socket/[:room]. Our Router will take care of every other request.

defp dispatch do
  peer_options = %Peer.Options{module: Server.Peer}
  
  [
    {:_,
     [
       {"/socket/[:room]/", Membrane.WebRTC.Server.Peer, peer_options},
       {:_, Plug.Cowboy.Handler, {Example.Simple.Router, []}}
     ]}
  ]
end

As you can see, we also specify options for starting peer process.

We have to implement a start function, in which we will start other processes.

@impl true
def start(_type, _args) do
  options = [strategy: :one_for_one, name: Server]
  children = [
    ...
  ]

  Supervisor.start_link(children, options)
end

Inside the children list, we will specify two workers: Plug.Cowboy and Server.Room.

children = [
  Plug.Cowboy.child_spec(
    scheme: Application.fetch_env!(:server, :scheme),
    plug: Example.Simple.Router,
    options: [
      dispatch: dispatch(),
      port: 8443,
      ip: {0, 0, 0, 0},
      password: "PASSWORD",
      otp_app: :example_simple,
      keyfile: "priv/certs/key.pem",
      certfile: "priv/certs/certificate.pem"
    ]
  ),
  Supervisor.child_spec(
    {Room,
     %Room.Options{
       name: "room",
       module: Server.Room,
       custom_options: %{max_peers: 2}
     }},
    id: :room
  )
]

If you want to start room after application is started (i.e. every time peer wants to join non-existing room), you can use Room.start_supervised function.

We can also add other rooms, with different names and/or maximal numbers of peers.

...

Supervisor.child_spec(
  {Room,
   %Room.Options{
     name: "other",
     module: Server.Room,
     custom_options: %{max_peers: 4}
   }},
  id: :other_room
)

...