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.
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:
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.
Dict
Some cool things are:
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.
immer.js
src/webserver/Firebasey.jl
.jl
include
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.
notebook_to_js
src/webserver/Dynamic.jl
current_state_for_clients
send_notebook_changes!
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.
state["path"]
state["cell_inputs"][a_cell_id]["code"]
"cell_inputs/<a_cell_id>/code"
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.
effects_of_changed_state
Wildcard()
Wildcard
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.
:update_notebook
responses
:reshow_cell
:shutdown_notebook
:complete
src/webserver/REPLTools.jl
awaited
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.
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.
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.
frontend/
The entry point of the app is the frontend/components/Editor.js component – this is the root component. Other components are children here.
frontend/components/Editor.js
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.
t_enable_and_run_cell
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.
Editor
frontend/editor.js
frontend/editor.html
localhost:1234/edit
Most state is held in the Editor component. This component also contains the notebook state, which is the object shared with the backend.
notebook
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.
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.
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.)
editor.html
index.html
frontend-dist
We bundle for a couple of reasons:
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.
.github/workflows/Bundle.yml
main
dist
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.
git tag
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.
using Example
frontend/components/CellInput
The CodeMirror setup is in the the frontend/components/CellInput.js file.
frontend/components/CellInput.js
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 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.
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!).
Pluto is available in 15+ languages, thanks to contributions from the community! You can read more about this in the language documentation.
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).
.github/workflows/TestFirefox.yml
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.
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.
localhost
:current_time
Very important is the :update_notebook function in the backend (receive patches to the shared state) and its frontend equivalent "notebook_diff".
"notebook_diff"
Sensitive endpoints of this server are secured with a token, which is generated randomly when you start a server.
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.
src/evaluation/WorkspaceManager.jl
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
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.
using
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.
Pluto has integration with Pkg for:
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.
src/packages/PkgCompat.jl
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.