LiveJoy with Phoenix LiveView
Tags: elixir, phoenix, liveview, joy
Published: 2020-11-21 13:24:28


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
3. `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.

``` html
<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>
```

The outermost element is a plain old `div`:

``` html
<div class="Terminal__body" phx-window-keyup="input">
  ...
</div>
```

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:

``` html
  ...
  <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>
  ...
```

And then we get to the historic inputs and outputs:

``` html
  ...
  <%= 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 %>
  ...
```

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:

``` html
   ...
   <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>
  ...
```

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:

``` html
<span class="Prompt__dollar"><%= Enum.join(Enum.reverse(elem(@current_input, 0))) %></span>
```

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

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

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

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

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`

``` elixir
  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
```

2. `Backspace` - pops the head off the left list in `current_input`

``` elixir
  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
```

3. `LeftArrow` - pops the head off the left list in `current_input` and pushes it onto the right list

``` elixir
  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
```

4. `RightArrow` - pops the head off the right list in `current_input` and pushes it onto the left list

``` elixir
  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
```

5. any single character key - pushes the character onto the left list in `current_input`

``` elixir
  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
```

6. any thing else - leaves the state as is

``` elixir
  def handle_event("input", %{"key" => _key}, socket) do
    {:noreply, socket}
  end
```

### Evaluation

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


``` elixir
  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
```

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:

{% link https://dev.to/codypearce/ubuntu-terminal-in-css-1aeo %}

Here is my tweaked version, using some colours from the [Aurora X](https://marketplace.visualstudio.com/items?itemName=marqu3s.aurora-x) VS Code theme:

``` css
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;
}
```

## 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:

``` docker
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"]
```

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

``` yaml
build:
  docker:
    web: Dockerfile
```

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:

``` bash
heroku stack:set container
heroku config:set SECRET_KEY_BASE=Y0uRvErYsecr3TANDL0ngStr1ng
```

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:

``` elixir
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
```

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](https://elixirconsole.wyeworks.com/) is a more serious attempt at a REPL that uses Phoenix LiveView, which also supports copy and paste.