DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Electron Adventures: Episode 56: Notebook Ruby HTTP Backend

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:

$ npm install
$ bundle install
Enter fullscreen mode Exit fullscreen mode

Then run these three commands in three terminals:

$ npm run start
$ bundle exec ./ruby_language_server
$ npx electron .
Enter fullscreen mode Exit fullscreen mode

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

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;
}
Enter fullscreen mode Exit fullscreen mode

src/App.js

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


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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

  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: "" },
  ])
Enter fullscreen mode Exit fullscreen mode

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.

  let resetSessionId = () => {
    setSessionId(Math.random().toString())
  }
Enter fullscreen mode Exit fullscreen mode

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.

  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
    }
  }
Enter fullscreen mode Exit fullscreen mode

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.

  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)()
    }
  }
Enter fullscreen mode Exit fullscreen mode

And finally handlers for various user actions, as before.

Result

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

Episode 56 Screenshot

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.

Discussion (0)