Open Source Adventures: Episode 63: Accessing Browser APIs Directly with Opal Ruby

Opal Ruby really wants to be used with some wrappers and frameworks, but let's start by accessing browser APIs directly.

Note that this is not how you'd use Opal Ruby in the real world. You'd use various wrappers, and for basic browser APIs there's a lot of choice.

However if you write anything nontrivial, you'll need to interact with various new APIs and JavaScript packages, and then you'll have a choice of either going in raw, or writing wrappers.

First, let's get through all the boilerplate.

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Click Counter</title>
    <link rel="stylesheet" href="./app.css">
  </head>
  <body>
    <button>Click</button>
    <div id="summary""></div>
    <script src="./app.js"></script>
  </body>
</html>

app.css

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 5px;
}

Gemfile

We just need opal, and rake for build command.

source "https://rubygems.org"

gem "opal", "~> 1.5"
gem "rake", "~> 13.0"

Rakefile

task default: "app.js"

file "app.js" => "app.rb" do
  sh "opal -c app.rb >app.js"
end

app.rb

And now the actual code!

win = `window`
document = win.JS[:document]
summary = document.JS.querySelector("#summary")
button = document.JS.querySelector("button")

count = 0

button.JS.addEventListener("click") do
  count += 1
  summary.JS[:innerText] = "You clicked #{count} times"
end

The first thing to note is that you can run any JavaScript code with backticks. We'll need it to access the window object.

We'd like to name that variable window, but that would then compiles to var window = window which doesn't actually work.

If we have a JavaScript object, we can use it in two ways - .JS[] accesses JavaScript property, and .JS.foo() does a function call. We need both syntaxes as win.JS.document would compile to win.document(), not quite what we want.

Let's see what all this compiles to:

Opal.queue(function(Opal) {/* Generated by Opal 1.5.0 */
  var nil = Opal.nil, $rb_plus = Opal.rb_plus, win = nil, document = nil, summary = nil, button = nil, count = nil;

  Opal.add_stubs('+');

  win = window;
  document = win["document"];
  summary = document.querySelector("#summary");
  button = document.querySelector("button");
  count = 0;
  return button.addEventListener("click", function $$1(){
    count = $rb_plus(count, 1);
    return summary["innerText"] = "You clicked " + (count) + " times";}, 0);
});

There's a bunch of extra returns due to mismatch between Ruby and JavaScript, but they're mostly harmless. If you'd like to get rid of them, just add some nils at ends of blocks. return at end of the whole script makes a lot less sense, but I guess they didn't care enough to special case it.

this problem

Did you spot another problem? Why is Opal Ruby using function not => there, and wouldn't that cause issues?

Of course it does. Let's modify the app slightly, by adding some puts, which performs console.log:

win = `window`
document = win.JS[:document]
summary = document.JS.querySelector("#summary")
button = document.JS.querySelector("button")

count = 0

button.JS.addEventListener("click") do
  puts "Button clicked!"
  count += 1
  summary.JS[:innerText] = "You clicked #{count} times"
end

puts "Application started!"

This gets compiled to:

Opal.queue(function(Opal) {/* Generated by Opal 1.5.0 */
  var self = Opal.top, nil = Opal.nil, $rb_plus = Opal.rb_plus, win = nil, document = nil, summary = nil, button = nil, count = nil;

  Opal.add_stubs('puts,+');

  win = window;
  document = win["document"];
  summary = document.querySelector("#summary");
  button = document.querySelector("button");
  count = 0;
  button.addEventListener("click", function $$1(){var self = $$1.$$s == null ? this : $$1.$$s;
    self.$puts("Button clicked!");
    count = $rb_plus(count, 1);
    return summary["innerText"] = "You clicked " + (count) + " times";}, {$$arity: 0, $$s: self});
  return self.$puts("Application started!");
});

Application started! part works just fine, but Button clicked! crashes miserably, as it incorrectly changes self to the wrong one.

If Opal Ruby was updated to use => like modern JavaScript code, this inside the block would be correct, so the problem would be averted.

Story so far

As you can see, Opal Ruby interoperability with JavaScript world is quite poor. Fortunately we can sweep that nasty stuff into a wrapper, and there's plenty of wrappers for all the common things like browser APIs. If you want to seriously use Opal Ruby, you'll be writing a lot of wrappers as well.

And it's also high time for Opal Ruby to target ES6 - sticking to pre-ES6 code causes unnecessary issues like this issue we ran into.

I deployed this on GitHub Pages, you can see it here.

All the code is on GitHub.

Coming next

That's enough Opal Ruby for now. In the next episode we'll explore another technology.