Part #2 of our series on building web console for Elixir & Phoenix applications with Phoenix LiveView.

In this series:

  1. Part 1: Project set up and simple code executor
  2. Part 2: Making imports and macros work
  3. Part 3: Managing multiple supervised sessions
  4. Part 4: Making it real-time

Video version

Watch the video version below or or directly on YouTube for a complete step-by-step walkthrough.

The road so far

The starting point of today’s episode is the result of our “part 1 blog post / video”. We have working environment to execute basic Elixir code, but it’s not yet close to being usable in real life.

Among other things, the version we built so far lacks the ability to use import or require statements, and it crashes whenever an exception in source code is encountered, re-setting the whole user interface, including output and commands history.

Separation of concerns

We start off, however by separating the code executing back-end code from LiveView front-end.

Each Elixir or Erlang application can start it’s own supervision tree. Our backdoor library is no exception here, and, in fact, it already declares and starts one. If you look into it’s application callback module, you will see that it starts a DynamicSupervisor already. We don’t use it just yet, but we will, so I’ll leave that in, but will add a line that also starts single process of Backdoor.CodeExecutor, and registers it’s name so other processes can find it’s PID easily:

# lib/backdoor/application.ex
defmodule Backdoor.Application do
  @moduledoc false
  use Application

  def start(_, _) do
    children = [
      {Backdoor.CodeRunner, name: Backdoor.CodeRunner}, # <-- Add this line!
      {DynamicSupervisor, name: Backdoor.DynamicSupervisor, strategy: :one_for_one}
    ]

    Supervisor.start_link(children, strategy: :one_for_one)
  end
end

Backdoor.CodeRunner will be a simple GenServer-based code executor, and it’s responsibilities will be limited to taking input from the user, parsing it, executing the code and reporting back to the caller process using a defined interface. It will also keep track of bindings that we learned about in part 1, but also environment that we will learn about today.

In the future, we will be starting multiple CodeRunner processes, one per each session that user starts. For now, however, our library will only start single CodeRunner, which will be supervised and started whenever the application starts.

Our backdoor library is fairly similar to IEx itself, and it makes sense that IEx provides a module very similar to our CodeRunner - it’s IEx.Evaluator gives us some hints how to solve problems we are facing.

In particular, the problem of being unable to use import or require in a meaningful way, is being addressed by initializing and keeping track of env variable. IEx initializes it with function :elixir.env_for_eval(file: "iex"), and we can have a peek what’s inside:

iex(1)> IO.inspect(env, structs: false, limit: :infinity)
%{                                                                 
  __struct__: Macro.Env,                                           
  aliases: [],                                                     
  context: nil,                                                    
  context_modules: [],                                             
  contextual_vars: [],                                             
  current_vars: {%{}, false},                                      
  file: "iex",                                                     
  function: nil,                                                                              
  functions: [                                                                                
    {Kernel,                                                                                  
     [                                                                                        
       !=: 2,                                                                                 
       !==: 2,                                                                                
       *: 2,                                                                                  
       +: 1,                                                                                  
       +: 2,                                                                                  
       ++: 2,                                                                                 
       -: 1,                                                                                  
       -: 2,                                                                                  
       --: 2,                                                                                 
       /: 2,                                                                                  
       <: 2,                                                                                  
       <=: 2,                                                                                 
       ==: 2,                                                                                 
       ===: 2,                                                                                
       =~: 2,                                                                                 
       >: 2,                                                                                  
       >=: 2,                                                                                 
       abs: 1,                                                                                
       apply: 2,                                                                              
       apply: 3,                                                                              
       binary_part: 3,                                                                        
       bit_size: 1,                                                                           
       byte_size: 1,                                                                          
       ceil: 1,                                                                               
       div: 2,                                                                                
       elem: 2,                                                                               
       exit: 1,                                                                               
       floor: 1,                                                                              
       function_exported?: 3,                                                                 
       get_and_update_in: 3,                                                                  
       get_in: 2,                                                                             
       hd: 1,                                                                                 
       inspect: 1,                                                                            
       inspect: 2,                                                                            
       is_atom: 1,                                                                            
       is_binary: 1,                                                                          
       is_bitstring: 1,                                                                       
       is_boolean: 1,                                                                         
       is_float: 1,                                                                           
       is_function: 1,                                                                        
       is_function: 2,                                                                        
       is_integer: 1,                                                                         
       is_list: 1,                                                                            
       is_map: 1,                                                                             
       is_map_key: 2,                                                                         
       is_number: 1,                                                                          
       is_pid: 1,                                                                             
       is_port: 1,                                                                            
       is_reference: 1,                                                                       
       is_tuple: 1,                                                                           
       length: 1,                                                                             
       macro_exported?: 3,                                                                    
       make_ref: 0,                                                                           
       map_size: 1,                                                                           
       max: 2,                                                                                
       min: 2,                                                                                
       node: 0,                                                                               
       node: 1,                                                                               
       not: 1,                                                                                
       pop_in: 2,                                                                             
       put_elem: 3,                                                                           
       put_in: 3,                                                                             
       rem: 2,                                                                                
       round: 1,                                                                              
       self: 0,                                                                               
       send: 2,                                                                               
       spawn: 1,                                                                              
       spawn: 3,                                                                              
       spawn_link: 1,                                                                         
       spawn_link: 3,                                                                         
       spawn_monitor: 1,                                                                      
       spawn_monitor: 3,                                                                      
       struct: 1,                                                                             
       struct: 2,                                                                             
       struct!: 1,                                                                            
       struct!: 2,                                                                            
       throw: 1,                                                                              
       tl: 1,                                                                                 
       trunc: 1,                                                                              
       tuple_size: 1,                                                                         
       update_in: 3                                                                           
     ]}                                                                                       
  ],
  lexical_tracker: nil,                                                                       
  line: 1,                                                                                    
  macro_aliases: [],                                                                          
  macros: [                                                                                   
    {Kernel,                                                                                  
     [                                                                                        
       !: 1,                                                                                  
       &&: 2,                                                                                 
       ..: 2,                                                                                 
       <>: 2,                                                                                 
       @: 1,                                                                                  
       alias!: 1,                                                                             
       and: 2,                                                                                
       binding: 0,                                                                            
       binding: 1,                                                                            
       def: 1,                                                                                
       def: 2,                                                                                
       defdelegate: 2,                                                                        
       defexception: 1,                                                                       
       defguard: 1,                                                                           
       defguardp: 1,                                                                          
       defimpl: 2,                                                                            
       defimpl: 3,                                                                            
       defmacro: 1,                                                                           
       defmacro: 2,                                                                           
       defmacrop: 1,                                                                          
       defmacrop: 2,                                                                          
       defmodule: 2,                                                                          
       defoverridable: 1,                                                                     
       defp: 1,                                                                               
       defp: 2,                                                                               
       defprotocol: 2,                                                                        
       defstruct: 1,                                                                          
       destructure: 2,                                                                        
       get_and_update_in: 2,                                                                  
       if: 2,                                                                                 
       in: 2,                                                                                 
       is_nil: 1,                                                                             
       is_struct: 1,                                                                          
       match?: 2,                                                                             
       or: 2,                                                                                 
       pop_in: 1,                                                                             
       put_in: 2,                                                                             
       raise: 1,                                                                              
       raise: 2,                                                                              
       reraise: 2,                                                                            
       reraise: 3,                                                                            
       sigil_C: 2,                                                                            
       sigil_D: 2,                                                                            
       sigil_N: 2,                                                                            
       sigil_R: 2,                                                                            
       sigil_S: 2,                                                                            
       sigil_T: 2,                                                                            
       sigil_U: 2,                                                                            
       sigil_W: 2,                                                                            
       sigil_c: 2,                                                                            
       sigil_r: 2,                                                                            
       sigil_s: 2,                                                                            
       sigil_w: 2,                                                                            
       to_char_list: 1,                                                                       
       to_charlist: 1,                                                                        
       to_string: 1,                                                                          
       unless: 2,                                                                             
       update_in: 2,                                                                          
       use: 1,                                                                                
       use: 2,                                                                                
       var!: 1,                                                                               
       var!: 2,                                                                               
       |>: 2,                                                                                 
       ||: 2                                                                                  
     ]}                                                                                       
  ],                                                                                          
  module: nil,                                                                                
  prematch_vars: :warn,                                                                       
  requires: [Application, Kernel, Kernel.Typespec],                                           
  tracers: [],                                                                                
  unused_vars: {%{}, 0},                                                                      
  vars: []                                                                                    
}

So this thing is a Macro.Env struct, which keeps track of imported functions, requires, imports and a bunch of other things we probably need to have. Sweet.

The problem in our code is not only that we don’t declare nor use this struct, it’s also that we can’t pass it to Elixir’s own Code.eval_quoted/3 that we’ve been using, as it doesn’t seem to take it as an argument.

Having another look at IEx’s Evaluator module reveals that it’s using :elixir.eval_forms/3 instead, which takes abstract syntax tree, bindings and environment as it’s arguments. Let’s switch our code to use the same, but first let’s build it up as a GenServer:

# lib/backdoor/code_runner.ex
defmodule Backdoor.CodeRunner do
  use GenServer

  # Public API

  def start_link(opts) do
    GenServer.start_link(__MODULE__, [], opts)
  end

  # Callbacks

  @impl true
  def init(_) do
    {:ok, %{bindings: [], env: init_env()}}
  end

  # private

  defp init_env do
    :elixir.env_for_eval(file: "backdoor")
  end
end

After adding the module above we can start or dev environment with mix dev, and the extra process should start correctly. It doesn’t do anything just yet, nor it has any public API that allows sending code to execute in it. Let’s fix that by adding execute/2 function:

# Public API

...

def execute(runner, code) do
  GenServer.call(runner, {:execute, code})
end

...

# private

...

@impl true
def handle_call({:execute, code}, _from, state) do
  try do
    {result, bindings, env} = do_execute(code, state.bindings, state.env)
    {:reply, {:ok, result}, %{state | bindings: bindings, env: env}}
  catch
    kind, error ->
      {:reply, {:error, kind, error, __STACKTRACE__}, state}
  end
end

...

# private

...

defp do_execute(code, bindings, env) do
  {:ok, ast} = Code.string_to_quoted(code)
  :elixir.eval_forms(ast, bindings, env)
end

We also wrapped the code parsing and exeution functions into try/catch clause, and we returin {:error, kind, error, stacktrace} tuple in case exception has been raised. The resulting module source code is fairly short and sweet, and does what we need it to do - but it doesn’t get used just yet.

We need to clean up our user interface LiveView of any code execution and bindings tracking logic, and make it call our Backdoor.CodeRunner process instead. The resulting LiveView module has the handle_event/3 calling the CodeRunner:

@impl true
def handle_event("execute", %{"command" => %{"text" => command}}, socket) do
  formatted_result_or_error =
    with pid <- GenServer.whereis(Backdoor.CodeRunner),
         {:ok, result} <- Backdoor.CodeRunner.execute(pid, command) do
      inspect(result)
    else
      {:error, kind, error, stack} ->
        format_error(kind, error, stack)
    end

  output = socket.assigns.output ++ ["backdoor> " <> command] ++ [formatted_result_or_error]

  {:noreply,
   socket
   |> push_event("command", %{text: ""})
   |> assign(output: output)}
end

defp format_error(kind, error, stack) do
  Exception.format(kind, error, stack)
end

It also formats errors if they are encountered and outputs results, so the user can read exceptions with a stacktraces! Sweet!

Good but not yet perfect

I feel like at this moment we have the basics of code execution covered. There are, however, two more things we desperately need to fix in order to make this thing usable:

  1. Redirect standard output / error output to our web interface. Currently Logger.* or puts statements do not crash, but the output is seen in the terminal where you started the app and not in the web UI.
  2. Allow multi-line Elixir statements to be written by the user and executed. Currently the console is only able to execute one-liners. This is not very useful if you need to write a module or function.

We will fix both of these issues in the next episode, and then start on either adding some tests (we haven’t written any!) or start implementing multiple sessions support.

Notes

Watch the screencast directly on YouTube

You can find this and future videos on our YouTube channel.

The full code from this episode can be found on GitHub

Post by Hubert Łępicki

Hubert is partner at AmberBit. Rails, Elixir and functional programming are his areas of expertise.