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 :)