DEV Community 👩‍💻👨‍💻

Cover image for Elixir Plug Cowboy, WebSockets Broadcasting to React
Lionel Marco
Lionel Marco

Posted on

Elixir Plug Cowboy, WebSockets Broadcasting to React

WebSockets is the most effective way to push content to web clients, The connection between both sides start with a HTTP handshaking and then is upgraded to WebSockets, establishing a full duplex channel where both sides can send and receive data.

Thank to this, we can push data to client, without the client requesting it. What is very useful for stock quotes list, game server score tables, crypto currency prices, bets odd display, or whatever real time data.

Basically we have one emitter and many consumers.

In this article it will be implemented a stock quotes list,
where the stock values will be sent to every client in a specific time interval.

The structure will consist of:

  • Quotes store: where the stock prices will be stored, and also it will simulate price changes.
  • Scheduler: It will trigger the broadcast to every some time.
  • Clients registry: Every client have a PID, we need it as address where to send the updates.
  • Socket handler: Handle the clients communications.

In the client side is a React app that receive and display a quotes table.

Quotes Table

Table of Contents

1) Dependencies

Showing the dependencies is the classic way to start an Elixir post.

  defp deps do
    [
      {:cowboy, "~> 2.4"},      
      {:plug_cowboy, "~> 2.0"},
      {:jason, "~> 1.3"}, 
    ]
  end
Enter fullscreen mode Exit fullscreen mode

2) Quote Store

A Elixir Agent is used in order to store the stock quote prices and simulate price changes.

Elixir Agent

How the documentation say: It are useful when need to share or store state that must be accessed from different processes or by the same process at different points in time.

defmodule Quote do

  @derive {Jason.Encoder, only: [:symbol, :name, :price, :share, :arrow, :dif, :difp]}
  defstruct [:symbol, :name, :price, :share, :arrow, :dif, :difp]


  def new({symbol, name, price, share}) do # I love C++
    %Quote{symbol: symbol, name: name, price: price, share: share}
  end
end


defmodule QuoteStore do
  use Agent

  def initial_values() do
    [
      {"BRK.B", "Berkshire Hathaway", 289.30, 637},
      {"JPM", "JPMorgan Chase & Co.", 115.52, 342},
      {"BAC", "Bank of America", 34.41, 308},
      {"WFC", "Wells Fargo & Company", 44.30, 168},
      {"MS", "Morgan Stanley", 88.30, 151},
      {"HSBC", "HSBC", 31.41, 128},
      {"AXP", "American Express", 157.33, 118},
      {"GS", "The Goldman Sachs Group", 340.18, 116},
      {"AAPL", "Apple Inc. ", 167.23, 368},
      {"MSFT", "Microsoft Corporation", 276.44, 340},
      {"GOOG", "Alphabet Inc.", 114.77, 200},
      {"AMZN", "Amazon.com,", 133.62, 500},
      {"TSLA", "Tesla, Inc", 889.36, 120},
      {"NVDA", "NVIDIA Corporation ", 171, 512}
    ]
    |> Enum.map(&Quote.new(&1))
  end

  def start_link(quotes) do    
    IO.puts("QuoteStore.start_link, length(quotes): #{length(quotes)}")
    Agent.start_link(fn -> quotes end, name: __MODULE__)
  end

  def values do
    Agent.get(__MODULE__, & &1)
  end

  def update do    
    values = Enum.map(Agent.get(__MODULE__, & &1), fn q -> newValue(q) end)
    Agent.update(__MODULE__, fn _ -> values end)
    values
  end

  def newShare(value), do: value * (101..105 |> Enum.random()) / 100
  def newPrice(value), do: value * (90..110 |> Enum.random()) / 100

  defp newValue(q) do
    newprice = newPrice(q.price)
    newshare = newShare(q.share)

    dif = Float.round(newprice - q.price, 2)

    # IO.puts(dif)
    difp = dif / q.price * 100

    # 2% threshold
    arrow =
      cond do
        difp > 2 -> "u"
        difp < -2 -> "d"
        true -> "e"
      end

    %Quote{
      symbol: q.symbol,
      name: q.name,
      price: newprice,
      share: newshare,
      arrow: arrow,
      dif: dif,
      difp: difp
    }
  end
end
Enter fullscreen mode Exit fullscreen mode

3) The Scheduler

The scheduler will be in charge of trigger the broadcasting process. Every specific time interval it send a message to all the PID stored in the Registry. The Registry is based in ETS (Erlang Term Storage). The PID of every new connection is stored in the Registry, it will be showed bellow, in the Socket Handler.

Elixir Registry
Erlang Term Storage

defmodule Scheduler do
  def start(interval) do
    IO.inspect(self(), label: "Scheduler Start")
    :timer.apply_interval(interval, __MODULE__, :send_quotes, [])

    {:ok, self()}
  end

  def send_quotes() do
    IO.inspect(self(), label: "Broadcast messages from")

    Registry.StockTickerApp
    |> Registry.dispatch("stock_ticker", fn entries ->
      for {pid, _} <- entries, do: send(pid, :broadcast, [])
    end)
  end
end

Enter fullscreen mode Exit fullscreen mode

5) Socket Handler

The start point to understand socket handler is the documentation:Cowboy WebSocket

defmodule StockTickerApp.SocketHandlerTicker do
  @behaviour :cowboy_websocket
  require Logger

  # Not run in the same process as the Websocket callbacks.
  def init(request, _state) do
    {:cowboy_websocket, request, nil, %{idle_timeout: :infinity}}
  end

  # websocket_init: Called once the connection has been upgraded to Websocket.
  def websocket_init(state) do
    Registry.StockTickerApp |> Registry.register("stock_ticker", {})
    str_pid = to_string(:erlang.pid_to_list(self()))
    IO.puts("websocket_init: #{str_pid}")

    stime = String.slice(Time.to_iso8601(Time.utc_now()), 0, 8)
    {:ok, json} = Jason.encode(%{time: stime, quotes: QuoteStore.update()})
    {:reply, {:text, json}, state}
  end

  # websocket_handle: Will be called for every frame received
  # Can be used to retrieve info about some specific quote ex: "TSLA"
  # but in this case we don't needed.
  # def websocket_handle({:text, json}, state) do
  # end

  # websocket_info: Will be called for every Erlang message received.
  # the scheduler send messages to every PID stored in the Registry
  def websocket_info(:broadcast, state) do
    stime = String.slice(Time.to_iso8601(Time.utc_now()), 0, 8)
    {:ok, json} = Jason.encode(%{time: stime, quotes: QuoteStore.update()})
    {:reply, {:text, json}, state}
  end
end

Enter fullscreen mode Exit fullscreen mode

3) The application

The application is the core where all the involved processes will be started.

defmodule StockTickerApp do
  use Application

  def start(_type, _args) do
    children = [
      {QuoteStore, QuoteStore.initial_values()},
      %{id: Scheduler, start: {Scheduler, :start, [5000]}},
      Plug.Cowboy.child_spec(
        scheme: :http,
        plug: StockTickerApp.Router,
        options: [dispatch: dispatch(), port: 5000]
      ),
      Registry.child_spec(
        keys: :duplicate,
        name: Registry.StockTickerApp
      )
    ]

    opts = [strategy: :one_for_one, name: StockTickerApp.Application]
    Supervisor.start_link(children, opts)
  end

  defp dispatch do
    [
      {:_,
       [
         {"/ws/[...]", StockTickerApp.SocketHandlerTicker, []}
       ]}
    ]
  end
end

Enter fullscreen mode Exit fullscreen mode

6) React client

In the client side is a React app, where the Material UI library will provide us of a nice look and feel user interface. Material UI

There is many ways to implement WebSocket in React, this is the more simple,
just the raw implementation, without any external library.

//socket.js
const wsHost = "ws://192.168.1.109:5000/ws/chat";
export var websocket = new WebSocket(wsHost);

Enter fullscreen mode Exit fullscreen mode

A parent child component relation will be entablished.
Two components, a Dashboard and a quotes_table.

Dashboard

The Dashboard, will handle the socket connection and store the received quotations in their state.

import React from 'react';
import Typography from '@material-ui/core/Typography';
import QuotesTable from "./quotes_table";
import {websocket} from "./socket.js";

class Dashboard extends React.Component {

    constructor(props) {
      super(props); 
      this.state = { status: "Disconnected", quotes:[]};  
    }

    setStatus = (evt,value) => {
      console.log("setStatus",value);//,Object.keys(evt));
      //console.log("isTrusted",evt.isTrusted);
      this.setState({status: value});
    }

    onMessage= (evt) => { 
      console.log("    websocket.onmessage:");
      var data = JSON.parse(evt.data);
      console.log("Data", data);
      this.setState({quotes: data.quotes, time : data.time});

    }

    componentDidMount() {

      console.log("Dashboard componentDidMount:");
      websocket.onopen = (evt)  => this.setStatus(evt,"Open");
      websocket.onclose = (evt)  => this.setStatus(evt,"Close");
      websocket.onerror = (evt) => this.setStatus(evt,"Error");
      websocket.onmessage = (evt)  => this.onMessage(evt);      

    }

    render() {        

       console.log("DashBoard: render", this.state.quotes.length);       
       return (

          <div  style={{margin: "0 auto",padding:"5px", border: "2px solid gray", maxWidth: "35%" , display: "flex", flexDirection: "column"}} >

           <QuotesTable quotes={this.state.quotes} style={{border: "1px solid yellow"}} />
           <Typography >
             Last update:{this.state.time}  ,Status: {this.state.status}           
           </Typography>  

         </div>
)}}

export default Dashboard;

Enter fullscreen mode Exit fullscreen mode

Quotes table

An the end we have the QuotesTable, where all the stock quotes are showed with arrow and colors to show a well knowed visual interpretation.

import React from 'react';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import ArrowDropUpIcon from '@material-ui/icons/ArrowDropUp';
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';

import { makeStyles } from '@material-ui/core/styles';

const useStyles = makeStyles({
  customTable: {
    "& .MuiTableCell-sizeSmall": {
      padding: "2px 6px 2px 6px "     
    },
    "& .MuiTableCell-head": {      
      fontWeight: "bold",    
      backgroundColor: "#00a0a0"
    }
    ,
    "& .MuiTableRow-root": {
      height: "40px"
    }
    ,
    "& .MuiTableRow-root:hover": {
      backgroundColor: "#c0c0c0"
    }
  },
});

function drawArrowIcon(d){
  if (d==="u") return (<ArrowDropUpIcon  style={{fill:"green", transform: "scale(1.5)"}}/>);
  if (d==="d") return (<ArrowDropDownIcon  style={{fill:"red", transform: "scale(1.5)"}}/>);
}

function getColor(d){
  if (d==="u") return ("green");
  if (d==="d") return ("red");
  return "black";
}

export default function QuotesTable(props) {

  const { quotes } = props;

  console.log("QuotesTable: render", quotes.length);

  const classes = useStyles();

  return (

      <Table classes={{root: classes.customTable}}
      style={{ width: "auto", tableLayout: "auto", border: "2px solid gray" }} size="small" >
        <TableHead  >
          <TableRow >
            <TableCell >Symbol</TableCell>
            <TableCell align="right">Price</TableCell>
            <TableCell align="right">Move</TableCell>
            <TableCell align="right">Dif</TableCell>
            <TableCell align="right">Dif%</TableCell>
            <TableCell align="right">Share</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {quotes && quotes.map((row) => (
            <TableRow key={row.symbol} onClick={() => { console.log("Open info for:",row.symbol); }} >
              <TableCell align="left">{row.symbol}</TableCell>
              <TableCell align="right">{row.price.toFixed(2)} </TableCell>
              <TableCell align="center" >{drawArrowIcon(row.arrow) }</TableCell>
              <TableCell align="right" style={{color: getColor(row.arrow)}} >{row.dif.toFixed(2)}</TableCell>
              <TableCell align="right" style={{color: getColor(row.arrow)}} >{row.difp.toFixed(2)}</TableCell>
              <TableCell align="right">{row.share.toLocaleString(undefined, {minimumFractionDigits: 2,maximumFractionDigits: 2})}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>

  );
}

Enter fullscreen mode Exit fullscreen mode

Top comments (0)

🌚 Life is too short to browse without dark mode