Open Source Adventures: Episode 62: Ruby2JS

There are three main ways to run some sort of Ruby in a browser, none of them terribly satisfying:

  • WebAssembly - Ruby has limited support for it - you'll get good Ruby compatibility, and reasonable performance, but very poor JavaScript interoperability
  • Opal Ruby - compiles Ruby to JavaScript, making some serious compromises in terms of Ruby compatibility and performance to achieve better JavaScript interoperability
  • Ruby2JS - basically Ruby-like syntax for JavaScript, and not in any meaningful sense "Ruby" - minimal Ruby compatibility, but potentially good performance, and good JavaScript interoperability

Over previous few episodes we've taken a look at how Opal Ruby does things. So new I'll run all these examples in Ruby2JS.

Hello, World!

By default Ruby2JS targets obsolete JavaScript, but we can tell it to target modern platforms with some switches.

--es2022 goes a bit too far for me, using nasty JavaScript "private instance variables", which is not a feature we want, so I passed --underscored_private to disable that.

We also need to specify -f functions. Ruby2JS has a bunch of configurable "filters" to tweak code generation.

$ ruby2js --es2022 --underscored_private -f functions hello.rb >hello.js
puts "Hello, World!"

With default settings, it becomes:

puts("Hello, World!")

This is already highly problematic, as Ruby2JS by design doesn't have runtime, so there's no puts. So by default, its level of compatibility with Ruby is so low, even Hello World will instantly crash.

Fortunately -f functions rescues us here, generating the obvious code:

console.log("Hello, World!")

So we can at least run Hello, World. This matters a few more times, in all examples below I'll be using -f functions.

Booleans and Nils

a = true
b = false
c = nil

Becomes:

let a = true;
let b = false;
let c = null

For true and false it's obvious. Translating nil into null changes semantics a lot, but that's the cost of JavaScript interoperability.

Numbers

a = -420
b = 6.9
c = a + b
d = 999_999_999_999_999_999
e = a.abs

Becomes:

let a = -420;
let b = 6.9;
let c = a + b;
let d = 999_999_999_999_999_999;
let e = Math.abs(a)

Just like Opal, Ruby Integer and Float both become JavaScript number.

Ruby + is translated into a JavaScript +, not any kind of rb_plus. That's a performance win of course, but that means you cannot + arrays and such.

-f functions again saves us, without it .abs call is translated into nonsense.

Strings

a = "world"
b = :foo
c = "Hello, #{a}!"

Becomes:

let a = "world";
let b = "foo";
let c = `Hello, ${a}!`

So just like Opal Ruby, String and Symbol both become JavaScript string.

RubyJS will use string interpolation if we choose appropriate target. This makes no difference semantically, but it results in more readable code. Then again, Opal really doesn't care about readability of code it generates.

Arrays

a = []
b = [10, 20, 30]
b[2] = 40
b[-1] = b[-1] + 5
c = b[0]
d = b[-1]

Becomes:

let a = [];
let b = [10, 20, 30];
b[2] = 40;
b[-1] = b.at(-1) + 5;
let c = b[0];
let d = b.at(-1)

Which is a terrible translation, as negative indexes are not supported in JavaScript, and they're used in Ruby all the time.

Given new ES target, -f functions translates negative getters to .at, but not negative setters, so we get something crazy inconsistent here. The b[-1] = b.at(-1) + 5; line is just total nonsense, it's likely even worse than not supporting negative indexes at all.

Hashes

a = {}
b = { 10 => 20, 30 => 40 }
c = { hello: "world" }

Becomes:

let a = {};
let b = {[10]: 20, [30]: 40};
let c = {hello: "world"}

Translating Ruby Hashes into JavaScript objects destroys most of their functionality, but it's more interoperable, and can be good enough for some very simple code.

Arguably ES6+ Map would fit Ruby semantics better, and it's part of the platform, but ES6 Maps have horrendously poor interoperability with any existing JavaScript code. For example JSON.stringify(new Map([["hello", "world"]])) returns '{}', which is insane.

Simple Person class

class Person
  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end

  def to_s
    "#{@first_name} #{@last_name}"
  end
end

person = Person.new("Alice", "Ruby")
puts "Hello, #{person}!"

Becomes:

class Person {
  constructor(first_name, last_name) {
    this._first_name = first_name;
    this._last_name = last_name
  };

  get to_s() {
    return `${this._first_name} ${this._last_name}`
  }
};

let person = new Person("Alice", "Ruby");
console.log(`Hello, ${person}!`)

Which looks very nice, but of course it doesn't work, as to_s means nothing in JavaScript, so it prints Hello, [object Object]!.

To get it to actually work, we need to twist it into something like:

class Person
  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end

  def toString()
    return "#{@first_name} #{@last_name}"
  end
end

person = Person.new("Alice", "Ruby")
puts "Hello, #{person}!"

Notice three changes:

  • to_s becomes toString
  • mandatory () after toString - otherwise it's a getter not function, and that won't work
  • mandatory return (there's a filter for that, but I didn't check if it breaks anything else)

If you had any hopes that any nontrivial Ruby code will run in Ruby2JS, you should see by now that it's hopeless.

Inheritance

class Person
  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end

  def toString()
    return "#{@first_name} #{@last_name}"
  end
end

class Cat < Person
  def toString()
    return "Your Majesty, Princess #{super}"
  end
end

cat = Cat.new("Catherine", "Whiskers")
puts "Hello, #{cat}!"

Becomes:

class Person {
  constructor(first_name, last_name) {
    this._first_name = first_name;
    this._last_name = last_name
  };

  toString() {
    return `${this._first_name} ${this._last_name}`
  }
};

class Cat extends Person {
  toString() {
    return `Your Majesty, Princess ${super.toString()}`
  }
};

let cat = new Cat("Catherine", "Whiskers");
console.log(`Hello, ${cat}!`)

Story so far

Overall it's really unclear to me what are legitimate use cases for Ruby2JS. Its compatibility with Ruby is nearly nonexistent, you're about as likely to be able to run your Ruby code in Crystal or Elixir as in Ruby2JS. So at this point, why not just create a full Ruby-inspired programming language that compiles to JavaScript?

If all you want is better syntax, CoffeeScript 2 is one such attempt (which is unfortunately not Svelte-compatible, if it was, I'd consider it), and it's not hard to create another.

And it's not even possible to create any reusable Ruby2JS code, as different combinations of filters and target will completely change meaning of the code.

All the code is on GitHub.

Coming next

In the next episode we'll go back to Opal Ruby.