loading...
Cover image for LiveJoy with Phoenix LiveView

LiveJoy with Phoenix LiveView

palm86 profile image Danie Palm ・8 min read

In a previous post, I've introduced an interpreter for the Joy programming language implemented in Elixir. The library includes a REPL that allows one to play with Joy in an interactive session.

While it is straightforward to get the Joy REPL up and running, it is perhaps too much effort for someone with only a mild interest in Joy and Elixir. So here is LiveJoy, a web-based Joy REPL:

https://live-joy.herokuapp.com

It runs on free tier Heroku, so please be patient with the start up.

No JavaScript added

LiveJoy is peculiar in the sense that it doesn't require a single line of added JavaScript, and yet it is very much interactive.

To be fair, LiveJoy does use JavaScript - a relatively small piece of it that ships with Phoenix LiveView. Phoenix is a lean and performant web-framework for Elixir, and LiveView is a Phoenix add-on that gives you real-time server-rendered pages. There is just enough JavaScript in LiveView to communicate client-side events to the server and to cleverly update the DOM from server-side generated diffs.

Phoenix LiveView

LiveView pages are backed by GenServer-like processes which keep as state a websocket connection as well as information about which parts of the page are static and which parts are dynamic (those parts that are likely to change with state changes).

Client-side initiated socket requests end up as messages in the inbox of the server-side process that backs the page, and are thus able to alter the page state. In response, a diff is sent back to the client with the information required to update the DOM.

The server can also initiate page updates without any prompt from the client. For instance, a timer could be associated with the LiveView process to fire periodically, sending updates to the client.

Apart from the initial page render, which uses normal HTTP, everything happens via websockets.

LiveJoy implementation

With LiveJoy being a kind of REPL, the state we need to keep is as follows:

  1. input - a list of strings representing the historical input
  2. output - a list of strings representing the historical output
  3. stack - because Joy is a stack-based language
  4. current_input - the current unsubmitted and unevaluated input

Template

Here is a LiveView template that relies on the state to yield an interactive REPL. I first show the whole template and then break it up into parts.

<div class="Terminal__body" phx-window-keyup="input">
  <div class="Terminal__splash">&nbsp;&nbsp;&nbsp;_</div>
  <div class="Terminal__splash">&nbsp;&nbsp;|_|___ _ _</div>
  <div class="Terminal__splash">&nbsp;&nbsp;| | . | | |</div>
  <div class="Terminal__splash">&nbsp;_|&nbsp;|___|_&nbsp;&nbsp;|</div>
  <div class="Terminal__splash">|___|&nbsp;&nbsp;&nbsp;|___|</div>

  <div class="Terminal__splash">Interactive Joy</div>

  <%= for {input, output} <- Enum.reverse(Enum.zip(@input, @output)) do %>
    <div class="Terminal__prompt">
      <span class="Prompt__location">joy> </span>
      <span class="Prompt__dollar"><%= input %></span>
    </div>
    <div class="Terminal__text"><%= output %></div>
  <% end %>

  <div class="Terminal__prompt">
    <span class="Prompt__location">joy> </span>
    <span class="Prompt__dollar"><%= Enum.join(Enum.reverse(elem(@current_input, 0))) %></span><span class="Prompt__cursor"><%= case elem(@current_input, 1) do
      [] -> raw("&nbsp;")
      seq -> hd(seq)
    end %></span><span class="Prompt__dollar"><%= case elem(@current_input, 1) do
      [] -> ""
      seq -> Enum.join(tl(seq))
    end %></span>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The outermost element is a plain old div:

<div class="Terminal__body" phx-window-keyup="input">
  ...
</div>
Enter fullscreen mode Exit fullscreen mode

The only special part is the phx-window-keyup attribute. This tells LiveView to send key-up events to the server. These are the only events we'll be using. More on that later.

Next up, is a static piece of HTML that simply renders the Joy splash:

  ...
  <div class="Terminal__splash">&nbsp;&nbsp;&nbsp;_</div>
  <div class="Terminal__splash">&nbsp;&nbsp;|_|___ _ _</div>
  <div class="Terminal__splash">&nbsp;&nbsp;| | . | | |</div>
  <div class="Terminal__splash">&nbsp;_|&nbsp;|___|_&nbsp;&nbsp;|</div>
  <div class="Terminal__splash">|___|&nbsp;&nbsp;&nbsp;|___|</div>

  <div class="Terminal__splash">Interactive Joy</div>
  ...
Enter fullscreen mode Exit fullscreen mode

And then we get to the historic inputs and outputs:

  ...
  <%= for {input, output} <- Enum.reverse(Enum.zip(@input, @output)) do %>
    <div class="Terminal__prompt">
      <span class="Prompt__location">joy> </span>
      <span class="Prompt__dollar"><%= input %></span>
    </div>
    <div class="Terminal__text"><%= output %></div>
  <% end %>
  ...
Enter fullscreen mode Exit fullscreen mode

Both input and output are lists. We zip them together to produce a list of input/output tuples, which we reverse to put the oldest ones at the top. And then for each pair we create markup that mimics terminal input and output. If either input or output changes, the server will rerender the HTML (the affected parts at least) and send the change info to the client. The end result is that a new input/output pair appears on each evaluation of user input.

And here is the part that handles the current input line of the terminal:

   ...
   <div class="Terminal__prompt">
    <span class="Prompt__location">joy> </span>
    <span class="Prompt__dollar"><%= Enum.join(Enum.reverse(elem(@current_input, 0))) %></span><span class="Prompt__cursor"><%= case elem(@current_input, 1) do
      [] -> raw("&nbsp;")
      seq -> hd(seq)
    end %></span><span class="Prompt__dollar"><%= case elem(@current_input, 1) do
      [] -> ""
      seq -> Enum.join(tl(seq))
    end %></span>
  </div>
  ...
Enter fullscreen mode Exit fullscreen mode

The current_input is a tuple of two lists. The first/left list represents all the characters to the left of the cursor (in reverse). Whereas the second/right list contains all the characters to the right of the cursor (if any) and at the cursor itself. The head of the left list is the character immediately to the left of the cursor. The head of the right list is the character at the cursor.

We render the characters in the left list, reversing them first, with:

<span class="Prompt__dollar"><%= Enum.join(Enum.reverse(elem(@current_input, 0))) %></span>
Enter fullscreen mode Exit fullscreen mode

Followed by the cursor (which could be over the head character of the right list):

<span class="Prompt__cursor"><%= case elem(@current_input, 1) do
      [] -> raw("&nbsp;")
      seq -> hd(seq)
    end %></span>
Enter fullscreen mode Exit fullscreen mode

And finally any characters to the right of the cursor (excluding the first one if we are not at the end of the line):

<span class="Prompt__dollar"><%= case elem(@current_input, 1) do
      [] -> ""
      seq -> Enum.join(tl(seq))
    end %></span>
Enter fullscreen mode Exit fullscreen mode

That's all there is to the template. You may have noticed that we are not making use of any HTML inputs whatsoever. This is probably an abuse of LiveView and you should really make use of inputs instead. But how did we get away without them?

Event handling

The basic idea is to rely on key-up events that represent character key strokes, arrow navigation, backspace or enter. Remember that phx-window-keyup attribute? Here are the events for which the server is listening and the callbacks that are invoked for each case:

  1. Enter - evaluates and clears current_input and updates input, output, and the stack
  def handle_event("input", %{"key" => "Enter"}, socket) do
    %{
      assigns: %{
        current_input: current_input,
        input: input_rest,
        output: output_rest,
        stack: stack
      }
    } = socket

    {left, right} = current_input
    input = Enum.join(Enum.reverse(left)) <> Enum.join(right)

    case evaluate(input, stack) do
      {:ok, output, stack} ->
        {:noreply,
         socket
         |> assign(
           current_input: {[], []},
           input: [input | input_rest],
           output: [output | output_rest],
           stack: stack
         )}

      {:error, output, stack} ->
        {:noreply,
         socket
         |> assign(
           current_input: {[], []},
           input: [input | input_rest],
           output: [output | output_rest],
           stack: stack
         )}
    end
  end
Enter fullscreen mode Exit fullscreen mode
  1. Backspace - pops the head off the left list in current_input
  def handle_event("input", %{"key" => "Backspace"}, socket) do
    %{assigns: %{current_input: {left, right}}} = socket

    left =
      case left do
        [] -> []
        [_] -> []
        [_ | tail] -> tail
      end

    {:noreply,
     socket
     |> assign(current_input: {left, right})}
  end
Enter fullscreen mode Exit fullscreen mode
  1. LeftArrow - pops the head off the left list in current_input and pushes it onto the right list
  def handle_event("input", %{"key" => "ArrowLeft"}, socket) do
    %{assigns: %{current_input: {left, right}}} = socket

    {left, right} =
      case {left, right} do
        {[], right} -> {[], right}
        {[left_head | left_tail], right} -> {left_tail, [left_head | right]}
      end

    {:noreply,
     socket
     |> assign(current_input: {left, right})}
  end
Enter fullscreen mode Exit fullscreen mode
  1. RightArrow - pops the head off the right list in current_input and pushes it onto the left list
  def handle_event("input", %{"key" => "ArrowRight"}, socket) do
    %{assigns: %{current_input: {left, right}}} = socket

    {left, right} =
      case {left, right} do
        {left, []} -> {left, []}
        {left, [right_head | right_tail]} -> {[right_head | left], right_tail}
      end

    {:noreply,
     socket
     |> assign(current_input: {left, right})}
  end
Enter fullscreen mode Exit fullscreen mode
  1. any single character key - pushes the character onto the left list in current_input
  def handle_event("input", %{"key" => <<key :: binary-size(1)>>}, socket) do
    %{assigns: %{current_input: {left, right}}} = socket

    {:noreply,
     socket
     |> assign(current_input: {[key | left], right})}
  end
Enter fullscreen mode Exit fullscreen mode
  1. any thing else - leaves the state as is
  def handle_event("input", %{"key" => _key}, socket) do
    {:noreply, socket}
  end
Enter fullscreen mode Exit fullscreen mode

Evaluation

The actual evaluation involves first parsing and then interpreting the input using the joy library that we've introduced in a previous post.

  defp evaluate(input, stack) do
    with {:ok, parsed_input} <- Joy.Parser.parse(input),
         {:ok, stack} <- Joy.Interpreter.interpret(parsed_input, stack) do
      output = Joy.Formatter.format(stack, direction: :stack)

      {:ok, output, stack}
    else
      {:error, reason} ->
        {:error, "Error: #{inspect(reason)}", stack}
    end
  end
Enter fullscreen mode Exit fullscreen mode

The rest is LiveView boilerplate that you can read about in the official LiveView docs.

Styling

For the styling I've relied extensively on the following post:

Here is my tweaked version, using some colours from the Aurora X VS Code theme:

body {
  margin: 0px;
  margin-left: 24px;
  background:#07090F;
  font-size: 13px;
}

.Terminal__body {
  background:#07090F;
  height: calc(100% - 25px);
  margin-top: -1px;
  padding-top: 2px;
  font-family: "Lucida Console", Monaco, monospace;
  width: 75%;
  float: left;
}

.Terminal__help {
  background:#07090F;
  height: calc(100% - 25px);
  margin-top: -1px;
  margin-left: 75%;
  color:#FFCB6B;
  padding-top: 2px;
  font-family: "Lucida Console", Monaco, monospace;
}

.Terminal__text {
  color: white;
}
.Terminal__splash {
  color: #82AAFF;
}

.Terminal__Prompt {
  margin-top: 10px;
  display: flex;
}

.Prompt__location {
  color: #C792EA;
}
.Prompt__dollar {
  color: white;
}

.Prompt__cursor {
  height: 13px;
  width: 8px;
  background: white;
}
Enter fullscreen mode Exit fullscreen mode

Dockerized deploy on Heroku

LiveJoy is deployed on Heroku as a Docker container. The most important parts are the Dockerfile and heroku.yml files in the project root.

The Dockerfile is fairly standard for a Phoenix project. Notice the separation between the build and app stages. In the build stage, we build an Elixir release, which includes the Erlang runtime. The app stage is an almost vanilla alpine image, containing only the release that we copy over from the build stage, no extra Erlang/Elixir installation is required in the app stage:

FROM elixir:1.10.0-alpine AS build

# install build dependencies
RUN apk add --no-cache build-base npm git

# prepare build dir
WORKDIR /app

# install hex + rebar
RUN mix local.hex --force && \
    mix local.rebar --force

# set build ENV
ENV MIX_ENV=prod

# install mix dependencies
COPY mix.exs mix.lock ./
COPY config config
RUN mix do deps.get, deps.compile

# build assets
COPY assets/package.json assets/package-lock.json ./assets/
RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error

COPY priv priv
COPY assets assets
RUN npm run --prefix ./assets deploy
RUN mix phx.digest

# compile and build release
COPY lib lib
# uncomment COPY if rel/ exists
COPY rel rel
RUN mix do compile, release

# prepare release image
FROM alpine:3.9 AS app
RUN apk add --no-cache openssl ncurses-libs

WORKDIR /app

RUN chown nobody:nobody /app

USER nobody:nobody

COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/live_joy ./

ENV HOME=/app

CMD ["bin/live_joy", "start"]
Enter fullscreen mode Exit fullscreen mode

The heroku.yml file tells Heroku how to build and run the image:

build:
  docker:
    web: Dockerfile
Enter fullscreen mode Exit fullscreen mode

Having already set up an Heroku app according to the Heroku documentation, the only changes required are to set the stack type to "container" and to add some config:

heroku stack:set container
heroku config:set SECRET_KEY_BASE=Y0uRvErYsecr3TANDL0ngStr1ng
Enter fullscreen mode Exit fullscreen mode

Heroku requires that you honour the PORT environment variable. And Phoenix will also need access to the SECRET_KEY_BASE we've set above. Let's set up the releases.exs file accordingly:

import Config

secret_key_base =
  System.get_env("SECRET_KEY_BASE") ||
    raise """
    environment variable SECRET_KEY_BASE is missing.
    You can generate one by calling: mix phx.gen.secret
    """

config :live_joy, LiveJoyWeb.Endpoint,
  http: [
    port: String.to_integer(System.get_env("PORT") || "4000"),
    transport_options: [socket_opts: [:inet6]]
  ],
  secret_key_base: secret_key_base,
  server: true
Enter fullscreen mode Exit fullscreen mode

We can now deploy with the trusty old git push heroku master command.

Conclusion

Phoenix LiveView is an excellent framework for implementing rich real-time user experiences. The current project, which is close to an event-based editor is perhaps not the best application of Phoenix LiveView, but it was nevertheless fun to implement. Some of the drawbacks of this approach is that copy and paste is not supported. It would be possible to implement copy and paste behaviour using some JavaScript hooks, but this is a "no JavaScript added" post.

The Elixir Web Console is a more serious attempt at a REPL that uses Phoenix LiveView, which also supports copy and paste.

Discussion

pic
Editor guide