Electron Adventures: Episode 98: Ferrum Sinatra Terminal App
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.
source "https://rubygems.org"
gem "sinatra"
gem "sinatra-contrib"
gem "rack-contrib"
gem "ferrum"
public/index.html
It's the terminal app again:
<!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:
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:
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
" 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:
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:
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:
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:
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:
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!
#!/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:
As usual, all the code for the episode is here.
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.