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
Then run these three commands in three terminals:
$ 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. 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;
}
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>
</>
)
}
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: "" },
])
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())
}
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
}
}
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)()
}
}
And finally handlers for various user actions, as before.
Result
Here's the result if we press "Run All" button:
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.
Top comments (0)