Electron Adventures: Episode 53: Jupyter Style Notebook

A big reason to make desktop apps in Electron is as a frontend for already existing code running on your computer.

We have an abundance of options, with different tradeoffs, such as:

  • run the the code as a standalone script
  • run the the code in a web server and talk to it over HTTP
  • run the the code in a web server and talk to it over web sockets
  • cross compile code to JavaScript
  • cross compile code to WASM
  • open a communication channel to the program and keep passing messages back and forth
  • load code as shared library into Electron backend process and run code there

And really, we should take a look at all of them.

The backend languages we're most interested in are Ruby and Python.

Jupyter Style Notebook

For this we'll develop a tiny Jupyter like app, where we'll be typing code, and sending it to the backend to execute.

Over next few episodes we'll be looking at different ways our Jupyter style Notebook frontent can talk to Ruby and Python style backends.

And as I might as well take a short break from Svelte, let's do this one in React.

Create a new app

I'll be following the same steps as back in episode 14. I'll repeat all the steps and code here, but if you want detailed explanations, check out that episode.

First we use create-react-app plus a few commands to setup React+Electron:

$ npx create-react-app episode-53-jupyter-like-notebook --use-npm --template ready
$ cd episode-53-jupyter-like-notebook
$ npm i
$ npm i --save-dev electron

And at an additional step, we'll need to edit package.json so React doesn't start a browser for us:

"start": "BROWSER=none react-scripts start",

index.js

Next we need to create a simple backend script that just loads our application from React dev server (at localhost:3000) and enables preload.

let { app, BrowserWindow } = require("electron")

function createWindow() {
  let win = new BrowserWindow({
    webPreferences: {
      preload: `${__dirname}/preload.js`,
    },
  })
  win.maximize()
  win.loadURL("http://localhost:3000/")
}

app.on("ready", createWindow)

app.on("window-all-closed", () => {
  app.quit()
})

preload.js

The preload needs to expose just one command, similar to what we did back in episode 17.

Node system APIs are all based on callbacks, so we need to do it like that in a manual Promise. We can't easily get away with just async/await here.

let child_process = require("child_process")
let { contextBridge } = require("electron")

let runScript = (interpretter, code) => {
  return new Promise((resolve, reject) => {
    let output = ""
    let proc = child_process.spawn(
      interpretter,
      [],
      {
        shell: true,
        stdio: ["pipe", "pipe", "pipe"],
      },
    )
    proc.stdout.on("data", (data) => output += data.toString())
    proc.stderr.on("data", (data) => output += data.toString())
    proc.stdin.write(code)
    proc.stdin.end()
    proc.on("close", () => resolve(output))
  })
}

contextBridge.exposeInMainWorld(
  "api", { runScript }
)

public/index.html

This comes straight from the template, I just adjusted the title.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Notebook App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>

src/index.js

This also comes straight from the template, just with some style tweaks to keep it consistent with the rest of the series:

import React from "react"
import ReactDOM from "react-dom"
import "./index.css"
import App from "./App"

ReactDOM.render(<App />, document.getElementById("root"))

src/App.js

The App component will soon need to manage multiple input/output boxes, but for now it's just one, so it doesn't have any logic yet:

import React from "react"
import PythonCommand from "./PythonCommand.js"

export default (props) => {
  return (
    <>
      <h1>Notebook App</h1>
      <PythonCommand />
    </>
  )
}

src/PythonCommand.js

It's just one input box, and one output box. The only nontrivial things here are:

  • we want Cmd+Enter to submit the code, as regular Enter just creates another line. For non-OSX operating systems we should be using Ctrl+Enter instead.
  • as preload nicely wrapped complex callback chains into a single promise, we can just await window.api.runScript("python3", input). This is not what Jupyter Notebook actually does - for slow running commands it will stream the output as it happens - but it's good enough for now.
import React from "react"

export default () => {
  let example = `name = "world"\nprint(f"Hello, {name}!")\n`
  let [input, setInput] = React.useState(example)
  let [output, setOutput] = React.useState("")

  let submit = async () => {
    setOutput(await window.api.runScript("python3", input))
  }

  let handleKey = (e) => {
    if (e.key === "Enter" && e.metaKey) {
      submit()
    }
  }

  return (
    <div className="command">
      <textarea
        className="input"
        onChange={e => setInput(e.target.value)} value={input}
        onKeyDown={handleKey}
      />
      <div className="output">{output}</div>
    </div>
  )
}

src/index.css

And finally, the styling. We just need dark mode, and a bunch of property resets to make input (textarea) and output (div) styling match, as their default styles are very different.

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

.command {
  width: 80em;
}

.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;
}

Result

Here's the results:

Support for other languages

There is absolutely nothing Python specific about our code, so you could use a different language by simply replacing the python3 interpreter with the name of the interpreter you want to use like ruby, perl, or even node. As long as it accepts code on standard input.

For some languages we'd instead need to save code to the file, and pass file name to the language's executable, but it's just a few lines difference.

Limitations

And that's how you do "run the the code as a standalone script".

The big upside is that this method requires zero cooperation from the backend code - we can run pretty much whatever we want, as long as we can talk to it over stdin/stdout/stderr or files.

There are some big limitations though. All the code needs to be executed at once. Once code we wrote in the textarea finishes, that program is terminated.

If we want to write program in parts, Jupyter Notebook style, we need to have some sort of persistent backend running, with which we'd communicate.

In the next episode we'll try to do just that, using simple HTTP backend instead.

23