Updates 2021-06-08. A public service update to only create the debouncer on the second call to mount, when the socket connects, to avoid a process leak.

tl;dr tips and tricks:

Also you can try out the LiveView password generator here

This week

This week so far I’ve been coding in Elixir, and not doing a great job of logging. Thursday and Friday I am mostly attending the virtual Elixir Conf EU.

Last week we got to a point with deploying to AWS that worked but was a little bit messy. I’ll talk about possible iterations on this at the end of the post.

Instead of spending another while on the deployment, though, I worked on something to deploy. It was based on my old app Correct Horse Battery Staple based on the eponymous XKCD comic that generates secure but memorable passwords from random words. The original app is in Rails and uses a database to store the pool of words from which to randomly generate passwords.

Correct Horse Battery Staple - Elixir Edition

This time the front end is in Phoenix Live View and rather than a database, the pool of words is stored in an ETS table. I’m using an Umbrella App because I am fond of making separate concerns really obvious. I find it helps clarify the separations. The app was created with

mix phx.new correcthorse --live --umbrella --no-ecto

LiveView is an pleasure to develop in. The simplicity reminds me of how I felt in the early days of Rails: disbelief that I could achieve so much with so little code. The entire interactive front-end is handled from this module and the presentation from this (mostly html) template.

Live view event taster

For a quick taster we’ll walk-through clicking the “Generate another” button.

The phx-click attribute in the template is all we need to bind the event.

<button phx-click="generate-password">Generate another</button>

Then all we need to do is add an event handler to our module.

def handle_event("generate-password", _, socket) do
  {:noreply, assign_new_password(socket)}
end
defp assign_new_password(socket) do
  %{min_words: min_words, min_chars: min_chars} = socket.assigns

  assign_password(
    socket,
    Password.words(min_words, min_chars)
  )
end

defp assign_password(socket, wordlist) do
  assign(socket,
    wordlist: wordlist,
    password: generated_password(wordlist, socket)
  )
end

The minimum data is sent back to the page to update the template <%= @password %>.

<input type="text" value="<%= @password %>"/>

What happens to DOM changed outwith LiveView?

I added a little bit of local javascript to copy the password to the clipboard (with clipboard-copy). The experience felt a bit flat and confusing, without some feedback to indicate success.

I unhid an element with the text “copied”.

copied.classList.remove("hidden")

Without any other code the change is undone on the receipt of any LiveView change event from the server, so it gets hidden as soon as there is some change. This fortunate side effect is exactly the behaviour I wanted, but it might be an annoyance in other circumstances.

Hand-rolled debouncing

There are two range controls that determine the minimum number of words in the password, and the minimum number of characters in those words. Changing these should should regenerate the password, but only when we’ve finished sliding the controls. This is exactly what the phx-debounce tag is made for, but there’s a problem: I also want to indicate the value of the control during the change, as below.

Gif showing the control values changing as we slide but the password regenerates only when the sliding has finished

Fortunately we are using Elixir where not only are the easy things easy, but the other stuff is usually also easy.

We can create a little debouncing Genserver. Then we can start it, with our processes’ _pid_ and a 500 millisecond debounce value; we assign its pid to the socket on mounting.

def mount(_params, _session, socket) do
  socket = if connected?(socket) do
    {:ok, debouncer} = Debouncer.start_link(self(), 500)
    assign(socket, _debouncer: debouncer)
  else
    socket
  end

Then when we get notification of a range update we let the debouncer know, by calling bounce/2 with the pid and generate_new_password, as well as updating the range values.

def handle_event("password-generation-details-changed", params, socket) do
  %{_debouncer: debouncer} = socket.assigns
  Debouncer.bounce(debouncer, :generate_new_password)
  {min_words, min_chars} = extract_min_words_chars(params)
  {:noreply, assign(socket, min_words: min_words, min_chars: min_chars)}
end

If no bounce/2 has been called on the debouncer for the 500 milliseconds, then the :generate_new_password message is sent back to the LiveView. We generate the new password while handling that message.

def handle_info(:generate_new_password, socket) do
  {:noreply, assign_new_password(socket)}
end

What wizardry is this? Let’s peek behind the debouncer’s curtain.

It relies on some very useful built-in GenServer functionality. Returning an optional timeout value in the tuple from one of the event handling callbacks (eg handle_info/2) causes a :timeout message to be sent to the GenServer unless a message is received to the processes’ queue before the timeout expires.

defstruct receiver: nil, timeout: 0, last_message: nil

@type t :: %__MODULE__{
        receiver: pid,
        timeout: timeout(),
        last_message: any()
      }

The GenServer’s state holds

  • the pid of the receiver, ie something to receive a message. Remember we pass in self() when it is started in our LiveView
  • timeout - the 500 milliseconds we passed in
  • last_message which is the message to send to the receiver.
def bounce(pid, message) when message != :timeout do
  send(pid, message)
end

def handle_info(message, s = %{timeout: timeout}) do
  {:noreply, %{s | last_message: message}, timeout}
end

When bounce is called then this sends a message to the bouncer. When the message is handled, the last_message is saved to the state and the timeout is returned as the third element in the tuple. The :timeout message is received unless another bounce/2 occurs within the timeout period.

While handling the timeout we send the last_message (:generate_new_password) back to our LiveView.

def handle_info(:timeout, s = %{receiver: receiver, last_message: last_message}) do
  send(receiver, last_message)
  {:noreply, %{s | last_message: nil}}
end

Testing

I have not been great about testing driving the LiveView. If I were to retrofit some tests, it would make sense to decouple the backend with a behaviour for stubs or mocks.

The debouncer is tested, as is the back-end.

Backend - generating the passwords

I will write this up in a later post.

Next functionality (possibly)

The app is pretty complete. I would quite to add an option for an alternative, more fun, pool of words extracted from a few books from Project Gutenberg.

Next projects

Possibly:

  • A cheaper deploy without the load balancer, perhaps using DNSimple to manage SSL certificates
  • Deploying by user Packer to build a custom AMI that include the release (and possibly ssl certificates).
  • A package to generating the release scripts and Terraform etc .. for quick start project deployment.
  • Something with Nerves which I haven’t used for a while, and miss.