How does Pluto work?

Pluto.jl is written in JavaScript (frontend) and Julia (backend). The frontend is the app that runs in your web browser where you type code, see outputs, etc. The backend is the Julia program that runs in your terminal with an HTTP server (to serve the frontend and communicate with it), code analysis, reactivity and code execution. The backend runs a Julia process per notebook where user code runs.

Before diving into the frontend and backend, let’s look at how they work together.

Frontend ↔ Backend

Aka: how do the server and clients stay in sync?

(thank you Michiel for the idea, design and implementation!)

A Pluto notebook session has state: with this, we mean: cell code, cell results, status and intermediate progress (eg logs), cell order, package information.

This state needs to be synchronised between the server and all clients (we support multiple synchronised clients), and note that:

  • All sides want to update the state. Generally, a client will update cell inputs, the server will update cell outputs.
  • Both sides want to react to state updates
  • The server is in Julia, the clients are in JS
  • This is built on top of our websocket+msgpack connection, but that doesn’t matter too much

We do this by implementing something similar to how you use Google Firebase: there is one shared state object, any party can mutate it, and it will synchronise to all others automatically. The state object is a nested structure of mutable Dicts, with immutable ints, strings, bools, arrays, etc at the endpoints.

Some cool things are:

  • Our system uses object diffing, so only changes to the state are actually tranferred over the network. But you can use it as if the entire state is sent around constantly.
  • In the frontend, the shared state is part of the react state, i.e. shared state updates automatically trigger visual updates.
  • Within the client, state changes take effect instantly, without waiting for a round trip to the server. This means that when you add a cell, it shows up instantly.

Diffing is done using immer.js (frontend) and src/webserver/Firebasey.jl (server). We wrote Firebasey ourselves to match immer’s functionality, and the cool thing is: it is a Pluto notebook! Since Pluto notebooks are .jl files, we can just include it in our module.

The shared state object is generated by notebook_to_js in src/webserver/Dynamic.jl. Take a look! The Julia server orchestrates this firebasey stuff. For this, we keep a copy of the latest state of each client on the server (see current_state_for_clients). When anything changes to the Julia state (e.g. when a cell finished running), we call send_notebook_changes!, which will call notebook_to_js to compute the new desired state object. For each client, we diff the new state to their last known state, and send them the difference.

Responding to changes made by a client

When a client updates the shared state object, we want the server to react to that change by taking an action. Which action to take depends on which field changes. For example, when state["path"] changes, we should rename the notebook file. When state["cell_inputs"][a_cell_id]["code"] changes, we should reparse and analyze that cel, etc. This location of the change, e.g. "cell_inputs/<a_cell_id>/code" is called the path of the change.

effects_of_changed_state define these pattern-matchers. We use a Wildcard() to take the place of any key, see Wildcard, and we use the change/update/patch inside the given function.

Not everything uses the shared state (yet)

Besides :update_notebook, you will find more functions in responses that respond to classic ‘client requests’, such as :reshow_cell and :shutdown_notebook. Some of these requests get a direct response, like the list of autocomplete options to a :complete request (in src/webserver/REPLTools.jl). On the javascript side, these direct responses can be awaited, because every message has a unique ID.

Example: running a cell

The frontend changes the code for cell X in the shared state.

The backend receives the changed code and updates its internal representation.

The frontend waits for confirmation, and sends a “run cell” command. (This could have been implemented without an explicit “run cell” command.)

The backend start a reactive run based on running X. The cell becomes queued, then running, then finished with new results. Every time that something changes, it updates the state of all connected clients.

Every time that the backend changes its state, it sends diffs to the frontend, which uses it to update its internal state, which triggers re-rendering of the cells.

Frontend

The Pluto app that displays in your web browser (where you type code and see results) is the frontend of Pluto. This application is written in JavaScript (with JSDoc for types), using CodeMirror 6 (code editor platform), preact (variant of React), and immer (state management).

Code is in the frontend/ folder.

Code structure

The entry point of the app is the frontend/components/Editor.js component – this is the root component. Other components are children here.

Hint

If you are interested in a specific component in Pluto, you can usually dive right into its code without understanding the whole structure. Tip: search for text that you see (like “Enable and run the cell”), find the i18n key (t_enable_and_run_cell), then search for they key.

The Editor component is loaded/rendered by the frontend/editor.js script, which is included in the frontend/editor.html file. When you visit localhost:1234/edit or when you view a notebook online, you are being served this HTML file.

State synchronization and actions

Most state is held in the Editor component. This component also contains the notebook state, which is the object shared with the backend.

Some of this state is passed down to children, and there are some callbacks being passed down as well. To prevent ‘callback hell’, we have a system called “PlutoActions”, which contains lots of functions to influence the editor state and backend. PlutoActions is available through context. (thank you Panagiotis!)

If you are adding a new feature, try to implement it in a way that just requires placing more data in the shared state object in the backend.

Reacitivity

We use preact/react as a frontend framework, but this is not the reason why Pluto is reactive. ‘Reactive’ can mean a lot of things, but Pluto’s reacitivity is powered by our own reactivity algorithm in the backend. But: using a framework like React makes it easy to make an interactive app.

Bundling

Our frontend is buildless, which means that the browser can run our frontend assets (HTML, CSS, JS) directly, without a bundling step. This makes it easy for people to fork and modify Pluto in the normal Julia way. The Pluto server will serve the contents of the frontend/ folder directly, which means that you can edit frontend files, refresh the browser, and see results.

But we also have a bundler, which bundles editor.html and index.html. The output of the bundler is in the frontend-dist directory. This directory is gitignored, it only exists if you generate it, and on our official releases. (More on this later.)

We bundle for a couple of reasons:

  • Offline support. Our app uses many resources from CDNs (JS packages, typefaces and icons), which are otherwise not available without an internet connection.
  • Reliability. This reduces our dependency on CDNs.
  • Performance. Bundling makes our HTML Export files load faster.

Bundler CI

The Julia registry does not allow you to “upload a dist folder” for releases, like in npm. All files to be included in the release need to be on GitHub.

Our solution is the .github/workflows/Bundle.yml CI action. For every commit to main, it bundles, and on the dist branch it removes all history, commits the frontend-dist folder (normally gitignored) and pushes. So this branch is always 1 commit ahead of main.

When we make a Pluto release, we do it on the dist branch, so that it includes the bundle. TagBot will later git tag the commit, which preserves it forever, even if the dist branch is later overrided.

Cell input: CodeMirror

We make extensive use of CodeMirror 6. Its design aligns very well with our needs, and we are generally impressed by this package. We have implemented many features as CodeMirror extensions. This includes: highlighting globals, Live Docs, autocomplete, package ‘bubbles’ (icon next to using Example), special indenting, and much more. You can find our own extensions in the frontend/components/CellInput folder.

The CodeMirror setup is in the the frontend/components/CellInput.js file.

For reasons related to CDNs, we have a separate repository where we import and re-export all the API that we need from the different CodeMirror packages: codemirror-pluto-setup.

Cell output: display

Cell output is implemented in a relatively straightforward way. We have a number of react components, which are matched to the right MIME type. Like a tree data inspector, image display, HTML renderer, plaintext, and some others.

The HTML renderer has some special handling of JavaScript, to make it easy to use, and mostly compatible with the Observable Notebook API. You can read more about it here.

Design

Pluto’s design is homemade, we don’t use a design system or component library. Styles are in vanilla CSS. We use the JuliaMono typeface (thank you Cormullion!).

Localisation

Pluto is available in 15+ languages, thanks to contributions from the community! You can read more about this in the language documentation.

Testing

Pluto has a puppeteer test suite for end-to-end testing. (thank you Rok for the initial setup!) These tests run a Pluto server, open a browser, and interact with the app like a user would. We run these tests in CI using Chrome.

We also have some sanity tests that check that a browser can open and view a notebook. We run these tests in CI using Chrome, Firefox and Safari. .github/workflows/TestFirefox.yml (for all browsers).

Backend

Configration

Pluto has a configuration system using Configurations.jl. This is a very handy package! (thank you Roger for your work and support!) Nowadays there is also Preferences.jl, but this came too late for our package.

Check out the configuration docs to learn more about what can be configured. You can also read more about PlutoSliderServer.jl to see the configuration system in action.

HTTP server

Pluto runs its own HTTP and WebSocket server on localhost. It serves assets (HTML, CSS, JS, typefaces, SVG, etc) and it has HTTP endpoints for tasks (new notebook, open notebook, etc). After a WebSocket connection is set up, it works with a discrete “messages” system. Messages are encoded with MsgPack.jl. Each message has a type (:current_time) which corresponds to a function on the other side, and some data specific to that message. Messages can be broadcast to all connected frontends, or they can be sent as ‘answer’ to one specific connected client.

Very important is the :update_notebook function in the backend (receive patches to the shared state) and its frontend equivalent "notebook_diff".

Sensitive endpoints of this server are secured with a token, which is generated randomly when you start a server.

Process management

Pluto runs user code in a separate Julia process. Each notebook gets a Julia process, which is managed using Malt.jl. We wrote Malt ourselves, out of a need for reliable management of Julia processes. (thank you Sergio for your expertise and implementation!) Code for this is in src/evaluation/WorkspaceManager.jl.

Reacitivity

Pluto’s reactivity works using static analysis. Pluto analyzes the expression of each cell to find references and definitions to global variables. Linking these between cells creates a DAG (graph), where cells are nodes and edges are matched reference-definition links.

When you run a cell, Pluto will search this DAG to find cells that also need to run.

How this works is covered in other documents. Learn more

Macro handling

Pluto supports reactive macrocalls and usings. (thank you Paul!) This means that if an expression defines or uses a variable after macroexpansion, Pluto will handle it correctly. This is very difficult to implement, because Pluto now needs to run code to be able to analyze it. The simple flow analyze → run is no longer possible.

This is implemented by running reactively in chunks. Pluto will start a reactive run as usual, but whenever a cell has been executed that could give more information (e.g. a new macro was defined, or package loaded), Pluto tries to macroexpand and analyze any remaining cells that still have unknowns. Macroexpansion happens in the notebook process, the resulting expression is sanitzed and returned to the server for analysis.

Package management

Pluto has integration with Pkg for:

  • Isolated environment: embedded Project.toml and Manifest.toml
  • Automatic package management (adding/removing packages)
  • Version queries (what versions can be installed)

The code for this is in src/packages/PkgCompat.jl (all internal Pkg API use is contained here), and Pluto’s package manager is in src/packages/Packages.jl.

We have also written a package GracefulPkg.jl that can will try to convert a Manifest.toml between environments with minimal damage.