WHAT IF? UltronEx and Liveview

If you had the chance to watch Disney+ WHAT IF? then you would know that in multiverse somewhere there is a version of Ultron that is too powerful for anyone to counter. In elixir-verse its the same with UltronEx.

In one elixir-verse UltronEx runs on plug/cowboy with Javascript glue on the client side

In another elixir-verse it runs on Phoenix + Pubsub + Liveview with zero Javascript to handle connection or updating UI.

and the Liveview UltronEx is far superior in many aspects. For Ultronex, Phoenix gives it a convention based structure over using plug/cowboy to maintain an opinionated strucutre which over time became difficult to be shared and worked on. With Liveview there is no immediate need of Javascript  as all actions are managed with help of phoenix/liveview helpers, it helped eliminate all the Javascript that was there for sockets and UI update as all HTML for UI update was pushed directly from server. Liveview + Pubsub also provided an event driven model for processing messages managing pressure in case of a burst.

With Liveview the amount of data sent to client also reduced with the Filter now being part of Liveview and can match events before they are sent. In comparison the plug/cowboy + Javascript version it would only hide them from display if they were filtered out. You could achieve something similar in the JS version but that would require more code and coordination which is exactly what Liveview simplifies with just few line of code and lets you concentrate on the functionality rather than solving the complexity.

Also Liveview works well with Javascript added. The clipboard still runs on localstorage in the same page and uses Vanilla JS and it doesn't conflict or breaks anything.

Previously to have the UI work and manage socket connection this was the code on the client and server.

P.S Do not judge my JS \_(-_-)_/

client side code

(() => {
  class myWebsocketHandler {
    setupSocket() {
      this.socket = new WebSocket(`wss://${window.location.host}/ultronex/ws/slack`)

      this.socket.addEventListener("message", (event) => {
        if (this.filterMatchContent(event.data) === true){
          this.updateContent(event)
        }
        else{
          console.log("Message received not matching filter")
        }
        
      })

      this.socket.addEventListener("close", () => {
        this.setupSocket()
      })
    }

    filterMatchContent(event_data){
      var filter = document.getElementById("filter-content").value
      if(filter !==""){
        return event_data.match(new RegExp(filter)) !== null
      }
      else{
        return true
      }
    }

    contentType(data) {
      if (data.match(/id="success-/) !== null) {
        return "success-content"
      } else if (data.match(/id="danger-/) !== null) {
        return "danger-content"
      } else if (data.match(/id="warning-/) !== null) {
        return "warning-content"
      } else if (data.match(/id="pattern-/) !== null) {
        return "pattern-content"
      } else if (data.match(/id="rate-limit-/) !== null) {
        return "rate-limit-content"
      } else {
        return "stats-content"
      }
    }

    updateContent(event) {
      var prependList = ["success-content", "danger-content", "warning-content"]
      var event_id = this.contentType(event.data)
      if (prependList.includes(event_id)) {
        this.prependContent(event_id, event)
      } else {
        document.getElementById(event_id).innerHTML = event.data
      }
    }

    prependContent(event_id, event) {
      if ($("#stream-content-toggle").is(':checked')){
        if ($(`#${event_id}-toggle`).is(':checked')) {
          const divTag = document.createElement("div")
          divTag.innerHTML = event.data
          document.getElementById(event_id).prepend(divTag)
        } else {
          //Do nothing just log
          console.log(`Event received for ${event_id}, user stream is turned off`)
        }
      } else {
        //Do nothing just log
        console.log(`UltronEx stream is turned off`)
      }
      
      
    }
    
  }

  const websocketClass = new myWebsocketHandler()
  websocketClass.setupSocket()


})()

server side code

defmodule Ultronex.Server.Websocket.SocketHandler do
  @moduledoc """
  Documentation for Ultronex.Server.Websocket.SocketHandler
  """

  @behaviour :cowboy_websocket

  def init(request, _state) do
    state = %{registry_key: request.path}
    {:cowboy_websocket, request, state}
  end

  def websocket_init(state) do
    Registry.UltronexApp
    |> Registry.register(state.registry_key, {})

    {:ok, state}
  end

  def websocket_handle({:text, json}, state) do
    payload = Jason.decode!(json)
    message = payload["data"]["message"]
    websocket_send_msg(message, state)
    {:reply, {:text, message}, state}
  end

  def websocket_info(info, state) do
    {:reply, {:text, info}, state}
  end

  def websocket_send_msg(message, state) do
    Registry.UltronexApp
    |> Registry.dispatch(state.registry_key, fn entries ->
      for {pid, _} <- entries do
        if pid != self() do
          Process.send(pid, message, [])
        end
      end
    end)
  end
end

and now everything is in a single Liveview on server

defmodule UltronexWeb.Stream.LiveView do
  use UltronexWeb, :live_view

  @queue "stream"
  @cache :ultronex_cachex

  def mount(_params, _session, socket) do
    if connected?(socket), do: UltronexWeb.Endpoint.subscribe(@queue)
    {:ok, base_stream(socket), temporary_assigns: [msg: []]}
  end

  def handle_info(%{event: "success", payload: payload}, socket) do
    {:noreply,
     assign(socket, term: get_filter_term(socket.id), msg: filter_msg(socket.id, payload))}
  end

  def handle_info(%{event: "danger", payload: payload}, socket) do
    {:noreply,
     assign(socket, term: get_filter_term(socket.id), msg: filter_msg(socket.id, payload))}
  end

  def handle_info(%{event: "warning", payload: payload}, socket) do
    {:noreply,
     assign(socket, term: get_filter_term(socket.id), msg: filter_msg(socket.id, payload))}
  end

  def handle_event(
        "filter",
        %{"filter" => %{"term" => incoming_message_filter}},
        socket
      ) do
    if String.trim(incoming_message_filter) == "",
      do: Cachex.del(@cache, socket.id),
      else: Cachex.put(@cache, socket.id, {:ok, incoming_message_filter})

    {:noreply, assign(socket, term: incoming_message_filter, msg: :empty)}
  end

  defp base_stream(socket) do
    assign(socket, msg: :empty, term: "")
  end

  defp filter_msg(socket_id, msg) do
    case Cachex.get(@cache, socket_id) do
      {:ok, {:ok, filter}} ->
        if String.match?(msg.payload, ~r/#{filter}/), do: msg, else: :empty

      {:ok, nil} ->
        msg
    end
  end

  defp get_filter_term(socket_id) do
    case Cachex.get(@cache, socket_id) do
      {:ok, {:ok, filter}} ->
        filter

      {:ok, nil} ->
       ""
    end
  end
end

With this no more context switching, no more googling over how to work with JS in this case coming from an elixir service. No more worrying managing of connections/delivery. It really did make things go faster from a development perspective as everything was written in Elixir and no back and forth between client and server code. Also HEEx is very powerful to write your markup.

All in all it was a good developer experience working with Liveview and it surely stands out as a strong contender for building apps/services that require realtime capabilities.

View Comments