In today's episode, we're going to use Phoenix's Websocket support to interact with a bash shell port from within the browser. Let's get started.

Project

Project Setup

So this project will use the latest Phoenix. I've already pulled it down. To start a new project, just go into the phoenix project itself and run:

mix phoenix.new websockets_terminal ../websockets_terminal

Then cd into it: cd ../websockets_terminal, fetch the dependencies with mix deps.get, and run it with mix phoenix.start. Now you can visit it at http://localhost:4000 and you can see the nice new Phoenix start screen.

Basic Websockets

OK, so now we want to add websocket support. The first thing to do is to destroy that nice start screen and replace it with something simple :) Open up lib/websockets_terminal/templates/pages/index.html.eex and let's add a bit of html that helps us make something for our terminal to take input from and send output to:

<style>
  #output {
    height: 400px;
    max-height: 400px;
    display: table-cell;
    vertical-align: bottom;
  }
</style>
<div class="row">
  <div class="col-lg-12">
    <div id='output'>
      <div>foo</div>
      <div>bar</div>
    </div>
    <input id='input' class='form-control' />
    <div id='status'></div>
  </div>
</div>

Alright, refresh the screen just to get a feel for what this looks like if it's not already crystal clear. Next, we need to start creating the websockets. First off, let's write some javascript using phoenix's channels API:

<script src="https://code.jquery.com/jquery-2.1.1.js" type="text/javascript"></script>
<script src="/static/js/phoenix.js" type="text/javascript"></script>

<script type="text/javascript">
  $(function(){
    var socket  = new Phoenix.Socket("ws://" + location.host +  "/ws");
    var $status = $('#status');
    var $output = $('#output');
    var $input  = $('#input');

    socket.join("shell", "shell", {}, function(chan){
      chan.on("join", function(message){
        $status.text("connected");
      });
    });
  });
</script>

So here we're just saying that we should join the 'shell' channel's topic named 'shell' - not terribly inventive, I know. Then, when we get the join message back, we should update the status to say we're connected. You can try to run this, but it will just fail trying to connect to the websocket.

((go ahead and try))

OK, so our next move is to actually create the channel. The first thing to do is to open up the router:

defmodule WebsocketsTerminal.Router do
  #...
  use Phoenix.Router.Socket, mount: "/ws"
  #...
  # NOTE: Make sure you use a string here rather than a charlist, or you're
  # going to get confusing errors...
  channel "shell", WebsocketsTerminal.Channels.Shell
end

So here we've enabled the websockets at the /ws mount point, and we've routed to the Shell channel and called it shell. Let's go ahead and make that channel now:

mkdir lib/websockets_terminal/channels
vim lib/websockets_terminal/channels/shell.ex
defmodule WebsocketsTerminal.Channels.Shell do
  # So since this is a channel, we should use Phoenix.Channel
  use Phoenix.Channel

  # This is the function that gets called on join from a client.  It takes a
  # socket, a topic, and a message.  We don't care about the message
  def join(socket, "shell", _message) do
    IO.puts "JOIN #{socket.channel}:#{socket.topic}"
    # This reply is where the status will be updated in the javascript
    reply socket, "join", %{status: "connected"}
    # We have to return a tuple containing ok and the socket on success
    {:ok, socket}
  end

  # Here's the case when someone tries to join a topic they don't have access to
  def join(socket, _private_topic, _message) do
    {:error, socket, unauthorized}
  end
end

Alright, that should just about do it for the most barebones channel and topic. Let's check the browser out again. Restart the server and refresh the browser, and if all went well we should see the message 'connected' below our text field.

Awesome. Now our websocket's set up and we can do fun stuff. Let's just make this echo back really quick to see how events from the server to the client look. Open up the channel:

  def event(socket, "shell:stdin", message) do
    # So we'll reply to all stdin events by writing the same data to stdout
    reply socket, "stdout", %{data: message["data"]}
    # You have to return your socket from each event - this part's important
    socket
  end

This is how Phoenix handles all events that come down a websocket. Just pattern match on the event and message. Now let's handle the client side:

<script type="text/javascript">
  $(function(){
    //...
    socket.join("shell", "shell", {}, function(chan){
      //...
      // First, we'll send the shell:stdin event each time the enter key is
      // pressed.  Our message will be an object with a data key, whose value is
      // the text in the input field.  Then we clear the field.
      $input.off("keypress").on("keypress", function(e) {
        if (e.keyCode == 13) {
          chan.send("shell:stdin", {data: $input.val()});
          $input.val("");
        }
      });

      // Then, when we get the stdout event from the server, we append that
      // message's data to the output element
      chan.on("stdout", function(message){
        $output.append($("<p>").text(message.data));
      });
    });
  });
</script>

OK, so this is easy enough. Go ahead and refresh the browser and enter something in the field and press enter:

(( do it ))

OK, so we're getting data echo'd back. That's pretty great. But we were promised a terminal. Let's start on that part.

Terminal over websockets

First off, go ahead and open up mix.exs and add a dependency on porcelain and start the porcelain app when this app starts:

  # Configuration for the OTP application
  def application do
    [
      mod: { WebsocketsTerminal, [] },
      applications: [:phoenix, :porcelain]
    ]
  end

  defp deps do
    [
      {:phoenix, "0.3.0"},
      {:cowboy, "~> 0.10.0", github: "extend/cowboy", optional: true},
      {:porcelain, "~> 1.0.0"}
    ]
  end

Now fetch it with mix deps.get. Next, we're going to throw together a ShellServer that's just a GenServer wrapping the interactive bash session from our Porcelain episode. Open up lib/websockets_terminal/shell_server.ex.

# First we'll throw together a module with our desired interface
defmodule WebsocketsTerminal.ShellServer do
  alias Porcelain.Process, as: Proc

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

  def eval(server, command) do
    GenServer.cast(server, {:eval, command})
  end

  # Next, we'll implement the genserver callbacks

  ## Server callbacks
  def init(:ok) do
    # So when we start, we'll spin up a bash prompt that sends us its output and
    # receives messages as input
    proc = %Proc{pid: pid} = Porcelain.spawn("bash", ["--noediting", "-i"], in: :receive, out: {:send, self()})
    {:ok, proc}
  end

  # Next, when we cast a command to it to eval, it will send the input, plus a
  # newline, to the process
  def handle_cast({:eval, command}, proc) do
    Proc.send_input(proc, "#{command}\n")
    {:noreply, proc}
  end

  # Finally, when the process sends us any data, we'll broadcast that data back
  # out.  This is how you can broadcast data to a given channel, topic, and
  # event.  We base64 encode the data because otherwise it will often be invalid
  # json
  def handle_info({_, :data, data}, proc) do
    IO.inspect(data)
    Phoenix.Channel.broadcast "shell", "shell", "stdout", %{data: Base.encode64(data)}
    {:noreply, proc}
  end

  # If we get any other messages, we handle them nicely and output them to the
  # log
  def handle_info(noclue, proc) do
    IO.puts "unhandled info"
    IO.inspect noclue
    {:noreply, proc}
  end
end

OK, that's the whole ShellServer. Now let's just supervise it and register it as a named pid on application start. Open up lib/websockets_terminal/supervisor.ex

  def init([]) do
    children = [
      worker(WebsocketsTerminal.ShellServer, <a class="tag" href="/tags/name:%20:shell">name: :shell</a>)
    ]
    #...
  end

So here we're just telling the supervisor to watch over our shell server, and by the way to please register it with name :shell while it's at it. Now all that's left is to go into our channel and have it eval our messages on the shell server:

  def event(socket, "shell:stdin", message) do
    WebsocketsTerminal.ShellServer.eval(:shell, message["data"])
    # You have to return your socket from each event - this part's important
    socket
  end

Alright, restart the server, refresh your browser, and type ls and hit enter. Oops, we're still getting base64 encoded data. Let's fix that:

    chan.on("stdout", function(message){
      $output.append($("<p>").text(atob(message.data)));
    });

OK, refresh the page and try again. Alright, we're getting a prompt and the standard output from the bash port. From here it's mostly just a normal-ish bash prompt, and you can cd around and check things out.

Summary

In today's episode, we wired up a Port to websockets in the browser with very little code, thanks to Phoenix's first-class websockets support. See you soon!

Resources