One pip install, both tiers
An extension is the unit of delivery — "what would I uninstall to lose this?". In an application with a server, the honest answer usually spans both sides: the view and the endpoint behind it are one capability, and nobody wants to install them separately or discover they are at different versions.
A ReactorExtension is a Python distribution carrying both. This page is the
guide to writing one; why it is shaped this way
is the record of the decisions behind it, and
shipping a container is the frontend
half built the real way.
The layout
reactor-extension-hello/
pyproject.toml
hello_extension/
__init__.py # the entry point callable
plugin.py # the Python plugin
share/datalayer/reactor/extensions/hello/
index.js # the built frontend, in the wheel
[project.entry-points."datalayer.reactor.extensions"]
hello = "hello_extension:extension"
[tool.hatch.build.targets.wheel.shared-data]
"share/datalayer/reactor/extensions/hello" = "share/datalayer/reactor/extensions/hello"
share/ is borrowed from JupyterLab rather than invented: it is where the
Python packaging tools already agree non-Python data belongs.
The entry point
It resolves to a zero-argument callable returning both halves:
from reactor import (
ExtensionManifest, FrontendExtension, FrontendPlugin, ReactorExtension,
)
def extension() -> ReactorExtension:
return ReactorExtension(
manifest=ExtensionManifest(name="hello", display_name="Hello", emoji="👋"),
plugins=[(HELLO_MANIFEST, HelloPlugin())], # the Python half
frontend=FrontendExtension( # the JavaScript half
directory=_FRONTEND,
entry="index.js",
api_version="v1",
plugins=[
FrontendPlugin(
name="@hello/panel",
display_name="Hello panel",
required_backend_plugins=["hello"],
),
],
),
)
Either half may be empty. A backend-only extension declares no frontend; a
frontend-only one declares no plugins.
Why the frontend plugin's manifest is written in Python
Because the shell must be able to list, describe and switch off a plugin whose
JavaScript has never been fetched. FrontendPlugin carries exactly what a
lazy plugin needs before its module: name,
presentation, dependencies, activation events, requiredBackendPlugins. The
entry is the module.
Two things follow, and both are the point:
- a plugin list is complete on the first frame, with the modules still on the wire;
- a plugin that is installed but unloadable — a refused API version, a blocked origin — is a state the host can show, rather than an absence nobody can explain.
Discovering it
platform = PluginPlatform()
platform.discover_extensions() # the default group, or pass your own
platform.start()
create_reactor_app then serves two things:
| Endpoint | What it does |
|---|---|
GET /plugins/frontend-extensions | rescans, then answers with every extension and every frontend plugin's manifest |
GET /reactor-extensions/{name}/{path} | serves that extension's files out of the installed distribution |
Registering one you hold
A host that holds the extension object itself — built in its own process, composed by an application, made in a test — registers it without an entry point, and the platform answers the same about it as about an installed one:
platform.register_extension_object(extension) # both halves: plugins and frontend
platform.unregister_extension(extension.name) # every plugin it delivered, and its frontend
Registering a name that is registered already is refused (ValueError): two
deliveries of one name would leave nobody able to say which one an uninstall
takes away. A host serving an extension's files from a route of its own — one
scoped to an application, say — answers with the same allowlist
/reactor-extensions serves: EXTENSION_CONTENT_TYPES, by suffix.
Installing while the server runs
This works, and it is designed for rather than accidental:
datalayer-music-example # a host, already serving
pip install examples/extension # while it runs
# refresh the browser
Use a regular pip install, not pip install -e. An editable install writes a
.pth file that Python only processes at interpreter startup, so an editable
package genuinely does need a restart. A normal install lands in site-packages,
which a running process can be made to re-read — which is what the rescan does.
Three things break if you do it naively, and the runtime handles all three:
| Breaks | Why | What Reactor does |
|---|---|---|
entry_points() misses the new distribution | importlib.metadata caches the path finder's listing | importlib.invalidate_caches() before every scan |
| the new package will not import | FileFinder caches the site-packages listing | the same call clears both |
| its files 404 | StaticFiles mounts are fixed when the app is built | one route resolves the directory per request, so nothing has to be mounted |
GET /plugins/frontend-extensions rescans before answering, and the browser is
what calls it. A page refresh is the whole reload mechanism — there is no
watcher and nothing restarts. Pass refresh=false in a deployment that installs
extensions only at boot.
Uninstalling while running works too: the extension leaves the list and its plugins are unregistered. Its Python modules stay imported — one process cannot unimport, and that is documented rather than pretended away.
The browser half
import { bootstrapExtensions, setReactorSharedModules } from '@datalayer/reactor';
setReactorSharedModules({ react: React, '@datalayer/reactor': Reactor });
const remotes = await bootstrapExtensions('http://localhost:8799');
const reactor = buildReactorFromPlugins([...bundled, ...remotes]);
bootstrapExtensions turns the server's answer into plugins. They go in the
same list as everything else, because a remote plugin is not a second kind of
plugin — it is a lazy plugin whose module happens to be at a URL.
An unreachable backend yields an empty list rather than throwing: a shell that refused to start because the extension server was down would be failing for the wrong reason.
Shared modules
A module fetched at runtime is not in the host's bundle, so it cannot import 'react' and get the host's copy — it would get a second React, whose hooks
throw from inside a component that looks fine. So the host publishes its copies
and a remote borrows them:
const { react: React } = globalThis.__DATALAYER_REACTOR__.shared;
Which modules must be singletons is the host's question — its design system belongs in that list as much as React does — which is why the runtime does not fix the set.
This is what Module Federation's shared does, with the machinery removed. An
extension that ships a container gets the machinery back — see
Shipping a container — and the browser
picks the loader from what the server says, so the two kinds coexist.
Refusing a remote
apiVersion— an extension declaring one this host does not speak is refused, and the plugin stays listed with the reason rather than throwing during a render.- Origin — a remote runs with the shell's privileges, so same-origin always
passes and everywhere else has to be named. The backend you passed to
bootstrapExtensionscounts as named:backendUrlis written in the host's own source, so a server on another port in development needs no ceremony. An entry that server points at some third origin is refused like any other remote — the origin a host named is the server, not everywhere the server can point. See Allowed origins.
One version, checked
Both halves ship in one wheel, so they are one version — and a wheel whose JavaScript was built at another one is a packaging bug: the version was bumped, the package reinstalled, and the frontend never rebuilt. Nothing about that is worth negotiating, so it is checked, not asked.
The Python half says which version its frontend must be; the build records the
version it was built at, by copying the frontend's package.json beside what
it emitted — JupyterLab's convention for a prebuilt extension:
FrontendExtension(
directory=_FRONTEND,
entry="remoteEntry.js",
version=__version__, # the build in `directory` must say the same
...
)
// frontend/rsbuild.config.ts
output: { copy: [{ from: './package.json' }], distPath: { root: '../share/…' } }
When the two differ, or the build recorded no version, the frontend is refused, and refused the way every other refusal here is — visibly, and without a crash:
| Where | What it says |
|---|---|
| the server's log, at load | Frontend refused: acme-charts: its frontend was built at 1.2.0 and its Python half is 1.3.0. … — once, at ERROR |
GET /plugins/frontend-extensions | the same sentence, as refused on the extension's record |
| the browser | every plugin in it listed and described, never fetched, with that sentence as its manifest's loadError |
The Python plugins keep running: they are the version pip installed. The
check reads the build's package.json on every request, so rebuilding the
frontend in place lifts the refusal on the next refresh, with no restart.
Versions are compared as written — 1.0 is not 1.0.0 — because one wheel has
no reason to spell its version twice. A FrontendExtension that declares no
version claims no coupling, and nothing is checked.
Compare frontend_dependencies on a plugin manifest: a backend plugin that
needs a frontend plugin can only ask a browser what it loaded
(frontend_requirements), because the answer lives in another process. This is
a fact about two files in one directory, so it is enforced.
Worked examples
examples/extension
is a complete distribution with both halves and no build step, so the chain is
readable end to end.
examples/extension-federated
is the same extension shipping a container — hand-written so it runs unbuilt,
with the Rsbuild project that emits the real one beside it.
examples/extension-template
is where to start your own, outside this repository — see
Starting one outside this repository.
Where this carries on
- Shipping a container — the frontend half as a Module Federation container, built straight into the wheel, and hot-updated from a dev server without rebuilding it.
- Why it is shaped this way — the problem this answers, what already pointed at the answer, and how each open question was decided.