Electron Adventures: Episode 55: Ruby Language Server
Tags: electron, ruby
Published: 2021-09-17 16:33:18

For the next few episodes I'll be alternating between Ruby and Python.

Previously we ran every piece of code separately. Now it's time to run them in shared context. There are many ways to do it, but the simplest is use a web server.

### What does it mean to run a language server?

The simplest thing to do would be send JSON with some code in it and `eval` it on the server. That would work really poorly - whenever we refreshed the page, we'd need to restart the server or old code would be all over the place. Not to mention all that `eval`ing would get in a way of HTTP handling code.

To get full isolation we'd need to run a fresh process every time. This would be a lot of work, so we're going to get away with simpler solution of creating isolated `Binding` object.

### Capture output

We want to capture the output of the code and return it. For this we can override `stdout` and `stderr` in specific scope, then restore them to their original values once we're done.

```ruby
def capture_output(new_stdout, new_stderr)
  begin
    $stdout, save_stdout = new_stdout, $stdout
    $stderr, save_stderr = new_stderr, $stderr
    yield
  ensure
    $stdout = save_stdout
    $stderr = save_stderr
  end
end
```

### Eval code

Let's use that to run the code. First we create new fake output with `StringIO.new`. We'll use it to capture both `stdout` and `stderr`, as annotating what was regular output and what was error can get compliacted. If you want to see how to do that, episode 17 has such code.

Then we run the code capturing its output. If it's an excepton, we capture that in `response["error"]`.

What we don't capture is value returned by the code, as that can be anything, not necessarily something we can turn into a nice `String`. In Ruby everything is an expression and that sometimes gets in a way of REPLs, for example `foo.each{...}` returns `foo` so you can chain it, but in a REPL we probably just want to see what the loop printed, not to see `foo` after that. We could try to be clever, but for now let's just require explicit printing.

```ruby
def eval_and_capture_output(code, context)
  response = {}
  output = StringIO.new
  capture_output(output, output) do
    begin
      eval(code, context)
    rescue Exception => e
      response["error"] = "#{e.class}: #{e.message}"
    end
  end
  response["output"] = output.string
  response
end
```

### New binding context

This is the moderate level of code isolation. We create a new empty `Object`, and execute all code in that context. It will also define methods on that `Object`.

Of course that's no sandbox, and if your code uses any global variables, `Kernel` methods, `require`s anything, and so one, it will escape with ease.

`bindings` is a hash of `Binding` objects, so whenever code tries to access `bindings[x]` it didn't try before, it will create and save a new fresh `Binding` there.

Old ones are never cleaned up, but you'll eventually shut down that server process anyway, so it's fine for now.

```ruby
def new_binding
  Object.new.instance_eval{ binding }
end

bindings = Hash.new{|h,k| h[k] = new_binding}
```

### HTTP server

And finally a simple HTTP server. `sinatra` doesn't do automatic JSON input and output conversion. It's really easy to istall a plugin to do so for us, but it's just three extra lines, so we might just do it manually.

```ruby
require "sinatra"

post "/code" do
  request.body.rewind
  data = JSON.parse(request.body.read)
  session_id = data["session_id"]
  code = data["code"]
  response = eval_and_capture_output(code, bindings[session_id])
  response.to_json
end
```

### Security

Just in case it's not clear, this is literally a server where anyone on your machine can send code, and it will execute it. Obviously this is extremely insecure thing to do.

By default `sinatra` will only allow connections from your same computer, not even from other computers on the same network, but it really isn't hard to bypass that.

### Result

Here's some interactions with the server. Notice different sessions are isolated.

![Episode 55 Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xqjsiihjj7xmqjc2en7k.png)

In the next episode we'll connect this backend with our React frontend, so we can code Ruby in the browser.

As usual, [all the code for the episode is here](https://github.com/taw/electron-adventures/tree/master/episode-55-ruby-language-server).