Electron Adventures: Episode 56: Notebook Ruby HTTP Backend
Tags: javascript, electron, ruby, react
Published: 2021-09-18 04:19:45

Now that we have a frontend and a backend for our Notebook, let's connect them into a working app.

### How to start

I'll start by dropping all code from episodes 54 and 55 into same directory. Maybe it would be better to start organizing it a bit, as having `Gemfile` and `package.json` at the same level feels a bit weird, but it will do.

To run this you'll need to first install dependencies:

```shell
$ npm install
$ bundle install
```

Then run these three commands in three terminals:

```shell
$ npm run start
$ bundle exec ./ruby_language_server
$ npx electron .
```

This is not terribly practical, but it's the simplest setup, and we'll be switching to a different setup soon anyway.

### CORS

First thing we need to do is deal with the accursed CORS. We serve our React app from `localhost:3000`, but the Ruby Language Server is on `localhost:4567`, and because these are different numbers the browser won't let our app communicate with the Language Server, unless we just through some hoops.

There are many ways to solve this - we could have Electron frontend talk to Electron backend which then talks to the Language Server unconstrained by CORS issues. Or we could setup CORS configuration in the Language Server.

But React and webpack come with a much simpler way - you can simply add `proxy` line to `package.json` and the webpack dev server (at `:3000`) will just forward all requests it doesn't get to the proxy (at `:4567`).

So a single line of `"proxy": "http://localhost:4567"` in `package.json` will solve our CORS issues.

### `axios` vs `fetch`

Also we'll be using `axios` for all the HTTP requesting.

Fetch is an embarrassing abomination of an API. You must wrap every single `fetch` request in a bunch of stupid code to work around its stupid API. The worst problem is that it treats `4xx` or `5xx` codes as Great Success!, and it will happily give you HTML of `502 Bad Gateway Error` like it's the JSON you requested instead of throwing error. But it has other issues like not supporting JSON responses without extra wrapping, not supporting sending JSON POST requests without extra wrapping, and so on.

You can use `fetch` and wrap all `fetch` requests in a few dozen lines of wrapping code to fix this nonsense. But at that point you just wrote your own shitty `axios`, so why not use the real thing, which has none of those problems.

If you want to read more about this, [this is a nice post](https://shahata.medium.com/why-i-wont-be-using-fetch-api-in-my-apps-6900e6c6fe78). Just don't use `fetch`.

I feel like I should write a much longer blog post about this, as a lot of browser APIs are like that. Good enough for framework writers to use, with a lot of wrapping code, but not for application developers directly.

### `preload.js`

We won't need it for this episode, so we can make it an empty file. Or remove it and tell `index.js` that `preload` is gone.

### `src/index.css`

This is something I forgot to add in episode 54 and only just noticed, `.output` should have `white-space: pre-wrap;`, so let's fix it:

```css
body {
  background-color: #444;
  color: #fff;
  font-family: monospace;
}

.command {
  width: 80em;
  margin-bottom: 1em;
}

.command textarea {
  min-height: 5em;
  width: 100%;
  background-color: #666;
  color: #fff;
  font: inherit;
  border: none;
  padding: 4px;
  margin: 0;
}

.command .output {
  width: 100%;
  min-height: 5em;
  background-color: #666;
  padding: 4px;
  white-space: pre-wrap;
}

button {
  background-color: #666;
  color: #fff;
}
```

### `src/App.js`

This is the only component that was changed, so let's go through it again.

```javascript

import React, { useState } from "react"
import { useImmer } from "use-immer"
import CommandBox from "./CommandBox.js"
import axios from "axios"

export default (props) => {
  ...

  return (
    <>
      <h1>Notebook App</h1>
      {notebook.map(({input,output}, index) => (
        <CommandBox
          key={index}
          input={input}
          output={output}
          updateEntry={updateEntry(index)}
          run={run(index)}
          deleteThis={deleteThis(index)}
          addNew={addNew(index)}
        />
       ))}
      <div>
        <button onClick={runAll}>Run All</button>
        <button onClick={resetSessionId}>Reset Session</button>
      </div>
    </>
  )
}
```

We added extra "Reset Session" button, and some new imports. Reset Session is supposed to create new context on the language server. I'm not sure if it should also cleaning up existing output or not.

```javascript
  let [sessionId, setSessionId] = useState(Math.random().toString())
  let [notebook, updateNotebook] = useImmer([
    { input: "def fib(n)\n  return 1 if n < 2\n  fib(n-1) + fib(n-2)\nend", output: "" },
    { input: "puts (1..10).map{|n| fib(n)}", output: "" },
    { input: "puts [*2**16...2**20].pack('U*').chars.grep(/\\p{Emoji}/).join", output: "" },
  ])
```

There's two parts of state. `sessionId` justs needs to be unique, and `Math.random().toString()` is a well established but somewhat dirty way of generating unique values in Javascript, if you really don't care what they look like.

The `notebook` is some examples of Ruby code which we preload:

* definition of `fib` function
* print first 10 `fib` values
* print all Unicode emoji after U+10000

Running second without first will return `NoMethodError: undefined method fib` error, so you can see how sessions work without writing any code yourself.

```javascript
  let resetSessionId = () => {
    setSessionId(Math.random().toString())
  }
```

To reset session we just set it to a new random value. It doesn't matter what it is, as long as it's unique.

```javascript
  let runCode = async (code) => {
    let result = await axios({
      method: "post",
      url: "http://localhost:3000/code",
      data: {
        session_id: sessionId,
        code,
      }
    })
    let {error, output} = result.data
    if (error) {
      return output + "\n" + error
    } else {
      return output
    }
  }
```

`runCode` rewritten to use `axios` instead of `window.api.runCode`. We could color-code the output, but for now keep it simple.
The POST goes to `http://localhost:3000/code` which is in the same webpack dev server that serves React, it then forwards it to `http://localhost:4567/code` which will actually run it. Just so we don't need to deal with CORS.
To deploy it to prod (that is - package Electron app), we'd need to change this arrangement, as in prod we won't have any "webpack dev server" - the frontend part would be completely precompiled.

```javascript
  let updateEntry = (index) => (cb) => {
    updateNotebook(draft => {
      cb(draft[index], draft, index)
    })
  }

  let run = (index) => async () => {
    let input = notebook[index].input
    let output = await runCode(input)
    updateNotebook(draft => { draft[index].output = output })
  }

  let addNew = (index) => () => {
    updateNotebook(draft => {
      draft.splice(index + 1, 0, { input: "", output: "" })
    })
  }

  let deleteThis = (index) => () => {
    updateNotebook(draft => {
      draft.splice(index, 1)
      if (draft.length === 0) {
        draft.push({ input: "", output: "" })
      }
    })
  }

  let runAll = async () => {
    resetSessionId()
    for (let index = 0; index < notebook.length; index++) {
      await run(index)()
    }
  }
```

And finally handlers for various user actions, as before.

### Result

Here's the result if we press "Run All" button:

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

In the next episode we'll try to do the same thing for Python as we did for Ruby.

As usual, [all the code for the episode is here](https://github.com/taw/electron-adventures/tree/master/episode-56-notebook-ruby-http-backend).