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:
-
input
- a list of strings representing the historical input -
output
- a list of strings representing the historical output -
stack
- because Joy is a stack-based language -
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"> _</div>
<div class="Terminal__splash"> |_|___ _ _</div>
<div class="Terminal__splash"> | | . | | |</div>
<div class="Terminal__splash"> _| |___|_ |</div>
<div class="Terminal__splash">|___| |___|</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(" ")
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
:
<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:
...
<div class="Terminal__splash"> _</div>
<div class="Terminal__splash"> |_|___ _ _</div>
<div class="Terminal__splash"> | | . | | |</div>
<div class="Terminal__splash"> _| |___|_ |</div>
<div class="Terminal__splash">|___| |___|</div>
<div class="Terminal__splash">Interactive Joy</div>
...
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 %>
...
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(" ")
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:
<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):
<span class="Prompt__cursor"><%= case elem(@current_input, 1) do
[] -> raw(" ")
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):
<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:
-
Enter
- evaluates and clearscurrent_input
and updatesinput
,output
, and thestack
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
-
Backspace
- pops the head off the left list incurrent_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
-
LeftArrow
- pops the head off the left list incurrent_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
-
RightArrow
- pops the head off the right list incurrent_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
- 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
- any thing else - leaves the state as is
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.
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:
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;
}
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"]
The heroku.yml
file tells Heroku how to build and run the image:
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:
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:
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 is a more serious attempt at a REPL that uses Phoenix LiveView, which also supports copy and paste.
Top comments (0)