Javascript code executed inside Ruby on Rails App

Igor Kasyanchuk
Sep 24, 2022 • 6 minutes read

Idea

Have you ever had to do any of the following:

  • allow users to define some code that could be executed in runtime on the backend safely
  • create custom rules for your own logic, but without incorporating that in you Backend

There might be more use cases, but for me, it was a second one.

Basically the idea of the project was the following - we have "games" (every game in its own folder with some yaml, js, css, liquid files), and a game engine that knows how to process everything. I needed to create something for our "game" engine that can run custom game logic on the backend, store state in DB (JSONB column in our case), and return simple HTML/CSS to the browser. 

This article will cover how we found an elegant solution to support different games with different logic.

Implementation

One of the challenges was how to not create 1000 custom functions in the main app, but rather create a simple SDK that could be used in custom game logic. That SDK was created for the Backend and for the Frontend. While logic related to the same was stored in the independent folder. And when we had a need to add a new game it was working by just adding a folder into the folder "games" and job have done.

The Frontend SDK is quite simple. We had functions like "show modal", "goto(page)", etc.

The Backend part on the other hand, is more interesting.

Imagine you have a button "buy a house" and it needs to reduce the "player" balance and store a new state in the DB and return it to FE. On the Backend, we created an SDK to work with GameState.

Something like this (don't pay attention to method naming, we used this way to map with JS methods)

  def getState; game_play.state; end
  def getVariables; game_play.variables; end
  def getFrame(frame_id); game_play.game.frame!(frame_id).as_json; end
  def currentFrame; game_play.current_frame.as_json; end
  def frames; game_play.game.frames.map(&:as_json); end
  def setState(state); game_play.update(state: state); end
  def addFlashMessage(*args); response.addFlashMessage(*args); end
  def getParam(key); controller.params[key]; end
  def loggerInfo(*args); Rails.logger.info(*args); end
  def update(*arg); game_play.update(*arg); end

And some JS code that was executed on the Backend (pay attention to the game.getState for example:

function frameChooseOption() {
  var state = game.getState();
  var currentFrame = game.currentFrame();
  var nextFrame = game.getFrame(currentFrame.next_frame);
  var optionID = game.getParam('option_id');

  logger.debug(`[Frame#${currentFrame.id}] trying to access options ${currentFrame.variables}`);

  var option = currentFrame.variables.options.find(option => option.id == optionID);
  if(!option) {
    logger.debug(`[Frame#${currentFrame.id}] Attempt to choose unknown option with id "${optionID}"`);
    game.addFlashMessage("Invalid option selected! Please reload the page.", "Incorrect Selection", {type: "error"});
    return;
  }

  updateAreaValue(state, option);
  setPreviousBudget(state);
  setProgress(state, nextFrame.progress_percentage);
...
}

The funniest is that you can't tell looking at this code that game.getState() will call the getState Ruby method.

Or if we call update(state: { balance: 100 }) it will update the record in the DB.

How to implement it on your Project

Below is an explanation of how to do the same on your project.

Gemfile

Responsible for executing JS on the Backend. You can even use JS libraries like "underscore JS".

gem "mini_racer", "~> 0.6.2"

Simple "Engine" (with Ruby)

All magic (JS execution) happens in MiniRacer::Context.new.

You just need to create a new instance of "attach" functions mapped to Ruby methods.

Finally, you need to evaluate the JS code.

Example below:

class Logic
  attr_reader :record

  def initialize(record)
    @record = record
  end

  def call
    context = MiniRacer::Context.new
    context.attach "record", record.attributes

    # map JS funtions to Ruby methods
    ["setAssignee", "sendAlert", "getState", "setState"].each do |mname|
      context.attach("logic.#{mname}", method(mname.underscore.to_sym))
    end

    context.attach("binding.pry", method("debugger"))
    context.attach("console.log", method("console"))

    # you can even evaluate JS libraries
    # like Underscore.js
    context.load "#{Rails.root}/underscore-umd-min.js"

    context.eval(workflow_js)
  end

  def set_assignee(name)
    puts "set_assignee: #{name}"
    record.user = User.find_by(name: name)
    record.save
  end

  def get_state
    {
      record: record.attributes
    }
  end

  def set_state(state)
    puts "====="
    puts state.inspect
    puts "====="
  end

  def send_alert
    puts "send_alert"
  end

  def debugger(*args)
    binding.pry
  end

  def console(*args)
    puts args
  end

  private
  def workflow_js
    File.read("#{Rails.root}/logic.js")
  end

end

Javascript Logic (executed inside Rails app)

This is the content of a file logic.js. Here you can see the execution  logic.getState() that is mapped to logic.getState Ruby method.

If you want to debug something in this file you need to use binding.pry(record) or console.log("something").

Additionally, I have checked if I can create files or call URLs from it - it was not possible.

let delta = 10;

const state = logic.getState()
const record = state["record"]

console.log(`Initializing with priority: ${record.priority}`)

if(record.priority > 100) {
  logic.setAssignee("Bob")
  delta += 100
} else if(record.priority > 80) {
  logic.setAssignee("Anna")
  delta += 80
} else if(record.priority > 60) {
  logic.setAssignee("John")
  delta += 60
} else {
  logic.sendAlert();
}

console.log("Hello World")
console.log(`delta: ` + delta)

console.log(`Min using underscore library: ${_.min([1,2,-5,10])}`)

// binding.pry(record)

// SECURITY

// const http = new XMLHttpRequest()
// http.open("GET", "https://api.lyrics.ovh/v1/toto/africa")
// http.send()
// http.onload = () => console.log(http.responseText)
// MiniRacer::RuntimeError: ReferenceError: XMLHttpRequest is not defined



// const file = new File(["foo"], "foo.txt", {
//   type: "text/plain",
// });
// MiniRacer::RuntimeError: ReferenceError: File is not defined

1+41

How it can be called

Very simple use of all the above. After project is created JS code will be executed.

class Project < ApplicationRecord
  belongs_to :user, optional: true

  validates :title, :priority, presence: true

  after_create :start_logic

  private

  def start_logic
    Logic.new(self).call
  end
end

Example

Sample of execution code above if I call this method from the console:

Feel free to contact us in case you have any questions :)

Useful gems

See all