Electron Adventures: Episode 98: Ferrum Sinatra Terminal App
Tags: javascript, ruby, electron, chrome
Published: 2021-11-14 13:32:10

In the previous episode I said that Ferrum could be a viable alternative to Electron if someone added bidirectional communication between frontend and backend.

Well, I'm not seeing anyone else volunteering for that role.

So here's the world's first (as far as I know) Ferrum based desktop app.

* we'll be using using Ferrum and Chrome DevTools Protocol to send messages to the frontend
* we'll be using fetch to send messages to the backend over HTTP (really should be axios, but it's a static app)
* we'll be using Sinatra to handle those messages

### Why is this a good idea?

This is a somewhat convoluted setup, and it's not very performant, but it still has huge advantages over Electron:

* you can use any language you want for the backend
* your app is tiny, you just require the user to install Chrome (or Chromium), and most have already done that

Of course it also has big downsides too:

* if you need a lot of messages between frontend and backend, this solution will be a lot slower than Electron's IPC
* "whichever version of Chrome user has" can still lead to some incompatibilities
* there's no packaging out of the box
* Electron has many operating system integrations like menus you'll lose

It also has some advantages over "just launch a web server and open it in user's browser" (like notably Jupyter Notebook does):

* your app will be properly isolated from user's cookies, browser extensions etc.
* your app can control window creation, positioning, etc.
* at least we know it's going to be Chrome, so we don't need to test every possible browser

### `Gemfile`

The `Gemfile` needs `sinatra` and `ferrum`, but I also got some extra packages to make JSON parsing and returning more automatic. They don't really save any lines of code for this trivial app, but it's one less thing to think about.

```ruby
source "https://rubygems.org"

gem "sinatra"
gem "sinatra-contrib"
gem "rack-contrib"
gem "ferrum"
```

### `public/index.html`

It's the terminal app again:

```html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Ferrum Sinatra Terminal App</title>
    <link href="app.css" rel="stylesheet" type="text/css" />
  </head>
  <body>
    <h1>Ferrum Sinatra Terminal App</h1>
    <div id="terminal">
      <div id="history">
      </div>

      <div class="input-line">
        <span class="prompt">$</span>
        <form>
          <input type="text" autofocus />
        </form>
      </div>
    </div>

    <script src="app.js"></script>
  </body>
</html>
```

### `public/app.css`

Styling is identical to all previous terminal apps:

```css
body {
  background-color: #444;
  color: #fff;
}

h1 {
  font-family: monospace;
}

#terminal {
  font-family: monospace;
}

.input-line {
  display: flex;
}

.input-line > * {
  flex: 1;
}

.input-line > .prompt {
  flex: 0;
  padding-right: 0.5rem;
}

.output {
  padding-bottom: 0.5rem;
}

.input {
  color: #ffa;
}

.output {
  color: #afa;
  white-space: pre;
}

form {
  display: flex;
}

input {
  flex: 1;
  font-family: monospace;
  background-color: #444;
  color: #fff;
  border: none;
}
```

### `public/app.js`

Most of the code is the same except for how we call the backend:

```javascript
let form = document.querySelector("form")
let input = document.querySelector("input")
let terminalHistory = document.querySelector("#history")

function createInputLine(command) {
  let inputLine = document.createElement("div")
  inputLine.className = "input-line"

  let promptSpan = document.createElement("span")
  promptSpan.className = "prompt"
  promptSpan.append("$")
  let inputSpan = document.createElement("span")
  inputSpan.className = "input"
  inputSpan.append(command)

  inputLine.append(promptSpan)
  inputLine.append(inputSpan)

  return inputLine
}

function createTerminalHistoryEntry(command, commandOutput) {
  let inputLine = createInputLine(command)
  let output = document.createElement("div")
  output.className = "output"
  output.append(commandOutput)
  terminalHistory.append(inputLine)
  terminalHistory.append(output)
}

async function runCommand(command) {
  let response = await fetch(
    "http://localhost:4567/execute",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({command}),
    },
  )
  if (!response.ok) {
    throw `HTTP error ${response.status}`
  }
  return await response.json()
}

form.addEventListener("submit", async (e) => {
  e.preventDefault()
  let command = input.value
  let {output} = await runCommand(command)
  createTerminalHistoryEntry(command, output)
  input.value = ""
  input.scrollIntoView()
})
```

Note the `runCommand` function. Mostly to demonstrate "[why you should use `axios`](https://shahata.medium.com/why-i-wont-be-using-fetch-api-in-my-apps-6900e6c6fe78)" point I mentioned before. This is a `fetch` code with correct wrappers for checking HTTP status, dealing with JSON input and output and so on. All this functionality would be provided by `axios` code, so if we used `axios` it would be a one-liner.

### `terminal_app`

This will be quite some code, so let's do that in parts.

First, Sinatra lacks any callback for when server is ready to serve requests, so this function will poll given URL every second until it returns what we expect:

```ruby
def wait_for_url(url, response)
  loop do
    begin
      sleep 1
      break if URI.open(url).read == response
    rescue
    end
  end
end
```

Then we use this code in a separate thread to start the frontend when the backend is ready:

```ruby
APP_URL = "http://localhost:4567"
SECRET_TOKEN = Random.alphanumeric(64)

Thread.new do
  wait_for_url "#{APP_URL}/ping", "pong"
  $browser = Ferrum::Browser.new(
    headless: false,
    browser_options: {
      "app" => "#{APP_URL}/start?token=#{SECRET_TOKEN}",
    },
  )
  puts "#{APP_URL}/start?token=#{SECRET_TOKEN}"
end
```

This code prints the backend start URL for debugging, and saves Ferrum browser object to `$browser` global variable. We don't do anything with the frontend except start it, but in principle we have full control over the frontend through it if we wanted.

The secret token is there to prevent anyone except our frontend from executing commands on our backend. Which is definitely a good idea, as the backend literally executes shell commands.

Now we just need one endpoint to return static data, it's needed to know when the backend is ready:

```ruby
get "/ping" do
  "pong"
end
```

And the other to check the token and save it in the session cookie and redirect to `/index.html`. For whichever reason Sinatra won't treat `/` as `/index.html` as same request, so `redirect "/"` would need some extra code telling it that these mean the same thing:

```ruby
enable :sessions

get "/start" do
  raise "Invalid token" unless params["token"] == SECRET_TOKEN
  session["token"] = params["token"]
  redirect "/index.html"
end
```

And finally the `/execute` endpoint:

```ruby
use Rack::JSONBodyParser

post "/execute" do
  raise "Invalid token" unless session["token"] == SECRET_TOKEN
  command = params["command"]
  # \n to force Ruby to go through shell even when it thinks it doesn't need to
  output, status = Open3.capture2e("\n"+command)
  json output: output
end
```

Thanks to code from `sinatra-contrib` and `rack-contrib` we don't need to `JSON.parse` and `.to_json` ourselves.

This endpoint checks the token (in session cookie now, not in the URL) to verify that the request is coming from our frontend. Then it executes the command and returns the output.

Unfortunately Ruby is a bit too smart for its own good here, and tries to figure out if it needs to use shell or not. This complicates things as executing `nonexistent_command` will raise exception instead of printing shell message we want. We can force it to use Shell with the `\n` trick - it's a special character so it always triggers shell, but shell then ignores it. Really there should be `shell: true` optional keyword argument.

What Ruby does is generally reasonable, as spawning just one process instead of two can significantly improve performance, while keeping the API simple, it just fails for our use case.

And here's the whole file together, the world's first Ferrum + Sinatra app!

```ruby
#!/usr/bin/env ruby

require "ferrum"
require "sinatra"
require "open-uri"
require "open3"
require "sinatra/json"
require "rack/contrib"

APP_URL = "http://localhost:4567"
SECRET_TOKEN = Random.alphanumeric(64)

enable :sessions

use Rack::JSONBodyParser

get "/ping" do
  "pong"
end

get "/start" do
  raise "Invalid token" unless params["token"] == SECRET_TOKEN
  session["token"] = params["token"]
  redirect "/index.html"
end

post "/execute" do
  raise "Invalid token" unless session["token"] == SECRET_TOKEN
  command = params["command"]
  # \n to force Ruby to go through shell even when it thinks it doesn't need to
  output, status = Open3.capture2e("\n"+command)
  json output: output
end

def wait_for_url(url, response)
  loop do
    begin
      sleep 1
      break if URI.open(url).read == response
    rescue
    end
  end
end

Thread.new do
  wait_for_url "#{APP_URL}/ping", "pong"
  $browser = Ferrum::Browser.new(
    headless: false,
    browser_options: {
      "app" => "#{APP_URL}/start?token=#{SECRET_TOKEN}",
    },
  )
  puts "#{APP_URL}/start?token=#{SECRET_TOKEN}"
end
```

### Results

And here's the result:

![Episode 98 Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yuqrm241hdab5jdvwhfa.png)

As usual, [all the code for the episode is here](episode-98-ferrum-sinatra-terminal-app).

From what we've seen so far, Ferrum + Sinatra (or other Chrome DevTools Protocol + HTTP server) looks like a surprisingly viable way of coding frontend apps, far more than most of the "Electron Alternatives" we tried. It could use some polish to hide all the low level issues, but it could be a thing.

And this will be the last app of the series. For the final two episodes I'll just summarize the series and do a bit of retrospecting.