Plugin Guide
Whitebox's plugin system allows for easy extension of functionality through self-contained modules. Official Whitebox plugins are available on PyPI.
A plugin extends the installed application: Python registers capabilities, events and services; optional React modules supply UI and shared state. A capability names a role, a slot selects its UI implementation, and an exposed component names a specific implementation. Plugins run as trusted application code, not isolated apps. Start with the architecture model, then build a small frontend plugin, or migrate an existing source-only plugin. The sections below are the API and build reference.
Choose a workflow¶
| Your goal | Start here | What it proves |
|---|---|---|
| Create and display a frontend plugin | Counter exercise | A button rendered and clicked in the real Whitebox App |
| Test the component separately | Counter source test | Component behavior, without a Django server |
| Edit a selected plugin live | Counter HMR continuation | Explicit editable resolution, not a production fallback |
| Check an assembled installation | Maintained application checks | Controlled installed/native coverage, not hardware or publication |
| Prepare a coordinated release | Publication boundaries | Python publication and compatible core/plugin ordering prerequisites |
Registering a component makes it available, not visible. A capability identifies
a role; an existing host slot chooses a provider for that role, while an explicit
lookup such as hello.Counter chooses one exposed component. Both need a consumer
in the application. The exercise below adds a temporary consumer to a disposable
App; it does not introduce a production slot.
A few example plugins are as follows:
Plugin Structure¶
A typical plugin structure looks like this:
whitebox_plugin_<plugin_name>
├── whitebox_plugin_<plugin_name>
│ ├── static
│ │ ├── whitebox_plugin_<plugin_name>
│ │ │ ├── whitebox_plugin_<plugin_name>.css
│ │ │ └── whitebox_plugin_<plugin_name>.js
│ │ └── assets
│ │ ├── logo.svg
│ │ └── my_image.png
│ ├── templates
│ │ └── whitebox_plugin_<plugin_name>
│ │ └── whitebox_plugin_<plugin_name>.html
| ├── jsx
│ │ ├── SomeComponent.jsx
│ │ ├── SomeComponent.test.jsx
│ │ ├── SomeOtherComponent.jsx
│ │ └── SomeOtherComponent.test.jsx
│ ├── __init__.py
│ ├── package.json # optional private dependencies/build options
│ ├── yarn.lock # only when dependencies need installation
│ ├── .yarnrc.yml # private installation configuration
│ ├── whitebox_plugin_<plugin_name>.py
│ └── any_other_file.xyz
├── whitebox_test_plugin_<plugin_name>
│ ├── __init__.py
│ ├── test_whitebox_plugin_<plugin_name>.py
│ ├── test_whitebox_plugin_<plugin_name>_browser.py
│ └── test_whitebox_plugin_<plugin_name>_integration.py
├── LICENSE
├── Makefile
├── README.md
├── vitest.config.js
├── pyproject.toml
└── poetry.lock
For whitebox to be able to discover and load plugins dynamically, the plugin package must be named whitebox_plugin_<plugin_name>. The plugin package must contain a whitebox_plugin_<plugin_name>.py file that defines the plugin_class attribute for the plugin class to be exported correctly.
Additionally, for plugin tests to be able to discover and load dynamically, the test package must be named whitebox_test_plugin_<plugin_name>. The test files must start with test_ and they may end with _browser or _integration to indicate the type of test to ensure readability.
JSX files should be placed in the jsx directory within the plugin package.
Frontend plugins retain source-bearing build inputs
inside their Python package where needed; backend-only plugins omit frontend inputs.
Packaging does not generate JavaScript. Explicit core assembly builds installed
JSX into a complete generation; Django only admits and serves that output.
Source tests may end in .test.js or .test.jsx. They run from source and are
excluded from packaged runtime artifacts (more on
this in the Testing JSX code section).
They may be colocated as above or under tests/frontend/, as in the first-plugin
exercise; the plugin's Vitest config selects them. The Python test package and
Makefile in this illustrative tree are not required for the first-plugin exercise.
To initialize a new plugin project, run:
Each plugin is a Python package with its own set of resources, including static
files, templates and JSX files. If any additional assets are required, they
should be placed in the assets directory within the static folder.
If any additional files are required for the plugin to function. Maybe a text file plugin needs to read from or any other file, they should be placed in the root directory of the plugin package not in the root directory of the project. This ensures that poetry can package the plugin correctly when it is published to PyPI.
This structure allows for clear separation of concerns and makes it easy to distribute and install plugins. Additionally, each plugin is expected to have its own repository for version control, CI and documentation if needed.
Plugin API¶
Plugins must implement the base plugin class provided by the Whitebox. Depending on the plugin's requirements, they can export some or all of the following attributes and methods:
from whitebox.plugins import Plugin
class MyPlugin(Plugin):
name = "My Plugin"
provides_capabilities = ["my_capability"]
slot_component_map = {
"my_capability.display": "my_plugin/MyDisplay",
}
exposed_component_map = {
"my_capability": {
"AuxiliaryView": "my_plugin/AuxiliaryView",
},
}
plugin_class = MyPlugin
If a plugin needs to do some processing before exposing metadata, it can override plugin helpers such as:
from whitebox.plugins import Plugin
class MyPlugin(Plugin):
name = "My Plugin"
def get_bootstrap_assets(self) -> dict[str, list[str]]:
"""
Return the paths to the plugin's bootstrap assets.
This is used to load the plugin's assets in the frontend.
"""
pass
plugin_class = MyPlugin
To ensure that all static file paths are correctly resolved, it is recommended to use django.templatetags.static.static. This function can resolve the correct URL path for serving static files like images exported by plugins. For example, use:
from django.templatetags.static import static
class MyDevicePlugin(Plugin):
device_image_url = static("whitebox_plugin_device_xyz/path/to/image.webp")
plugin_class = MyDevicePlugin
Plugins have certain event hooks that are run at specific points in the plugin lifecycle. These hooks can be overridden to perform custom actions when the plugin is e.g. loaded or unloaded:
class MyPlugin(whitebox.Plugin):
def on_load(self) -> None:
print("Plugin loaded, let's rock and roll!")
def on_unload(self) -> None:
print("Plugin unloaded, cleaning up...")
You can use these methods to, e.g. when on_load happens, register event callbacks that the
plugin needs to properly operate.
In the end, the plugin class must be exported as plugin_class. If this attribute is not present, the plugin will not be loadable by Whitebox.
Standard API¶
Whitebox provides a standard API for plugins to interact with the system and other plugins on the backend side. This API includes methods for:
- Registering event callbacks
- Unregistering event callbacks
- Emitting events
- Accessing shared resources
- Interacting with the database
Example of registering an event callback:
from whitebox.plugins import Plugin
class MyPlugin(Plugin):
def on_load(self):
self.whitebox.register_event_callback("flight_start", self.on_flight_start)
def on_load(self):
self.whitebox.register_event_callback("flight_start", self.on_flight_start)
async def on_flight_start(self, data):
print("Flight started")
Example of emitting an event:
from whitebox.plugins import Plugin
class MyPlugin(Plugin):
async def update_location(self, lat, lon, alt):
# Emit a location update
await self.whitebox.api.location.emit_location_update(lat, lon, alt)
# Emit a custom event
await self.whitebox.emit_event("custom_event", {"data": "example"})
Registering database models¶
Plugins can register their database models, which can then be used by the kernel
and by other plugins. To register a model, within AppConfig.ready() of the
plugin, import the model and register it within the model registry:
from django.apps import AppConfig
from plugin.registry import model_registry
class WhiteboxPluginDeviceManagerConfig(AppConfig):
...
def ready(self):
from .models import DeviceConnection
model_registry.register("device.DeviceConnection", DeviceConnection)
Note that it is imperative to import the model within the ready() method,
otherwise you will encounter errors while loading the plugin.
When the model is registered under a certain name (in the example above,
as "device.DeviceConnection"), it can be used by the kernel and other plugins
by simulating an import from the plugin.registry, at the top level of the
module where you want to use the model:
from whitebox.db import get_model
# Import the model from the model registry
DeviceConnection = get_model("device.DeviceConnection")
# Now you can use the DeviceConnection model as if it was imported directly, e.g.:
class DeviceConnectionView(APIView):
def get(self, request, *args, **kwargs):
connections = DeviceConnection.objects.all()
serializer = DeviceConnectionSerializer(connections, many=True)
return Response(serializer.data)
Refer to Plugin API Reference for more details on the available methods and properties.
Registering and using Python classes & objects¶
Plugins can also register Python classes and objects that can be used by the kernel and other plugins. This is useful for sharing utility functions, classes, or objects, so that you can expose interfaces, base classes for features, etc.
To register a class or an object, you will need to specify it within the
Plugin that you are implementing, and it will be automatically picked up on
plugin load. You can define it in one of the two ways:
from whitebox.plugins import Plugin
from .base import Device
from .manager import device_manager
class WhiteboxPluginDeviceManager(Plugin):
...
# OPTION 1: Specify the map directly here, in case your exposed classes are
# simple, will never change, and do not require any additional imports.
plugin_plugin_classes_map = {
"device.Device": Device,
"device.DeviceManager": device_manager,
}
# OPTION 2: Implement a method that will return the map. This is useful in
# case your exposed classes would cause a circular import if imported on
# the top level, or if you want to device which class to expose based on
# some conditions.
def get_plugin_classes_map(self):
return {
"class.identifier": ..., # Your class map
}
Use the global plugin_plugin_classes_map (or get_plugin_classes_map()) for
stable APIs, base classes, and services that should be imported by the kernel or
other plugins with get_class(). These identifiers share a global namespace, so
choose them as public API names.
To use these classes from other plugins or from the kernel, you can use the
import_whitebox_plugin_class utility, which allows both for "importing" the
class as if it was imported from a module, and for getting a class through a
proxy object, in case you might require an object that is going to be available
by a plugin that depends on the plugin that you are implementing.
Here's how that looks in practice:
from whitebox.plugins import Plugin, get_class
# Import the class directly, same as if it was imported from a module
Device = get_class("device.Device", proxy=False)
# Import the class through a proxy (for more info, lookup: `LazyPluginClassProxy`)
DeviceManager = get_class("device.DeviceManager")
# This will work. You inherit a class, and implement your own class
class MyOwnDevice(Device):
... # Your own implementation of the Device class
# This will NOT work. A proxy object is not a class, but an instance, and as
# such you cannot inherit from it.
class CustomDeviceManager(DeviceManager):
...
# You can however use the proxy object to access its methods and properties,
# and use it as a regular object. As a very basic, made-up example, within
# your plugin class you can print a list of all devices on plugin load
class WhiteboxPluginHelloWorld(Plugin):
def __init__(self):
# Print all devices managed by the DeviceManager
for device in DeviceManager.get_all_devices():
print(f"Device: {device.name} ({device.type})")
Plugin-local class exposure is for implementation classes owned by one plugin and
intended for discovery, not global import. Define plugin_local_classes_map or
override get_plugin_local_classes_map() when those classes should stay out of
the global namespace. Local identifiers are scoped by the owning plugin, so
different plugins may reuse names such as X3 without conflicts. Plugin-local
identifiers cannot be retrieved with get_class().
A common use is a device-provider plugin exposing concrete devices after its
base plugin has loaded. Defer imports inside get_plugin_local_classes_map()
when the implementation classes import globally registered base classes:
from whitebox.plugins import Plugin
class WhiteboxPluginDeviceInsta360(Plugin):
name = "Insta360 Camera Support"
def get_plugin_local_classes_map(self):
from .devices import Insta360X3, Insta360X4
return {
"X3": Insta360X3,
"X4": Insta360X4,
}
Consumers discover plugin-local classes by handling plugin lifecycle events and
asking for the owner plugin's local map with get_plugin_local_classes(plugin_name):
from whitebox.plugins import Plugin, get_plugin_local_classes
class WhiteboxPluginDeviceManager(Plugin):
def on_load(self):
self.whitebox.register_event_callback(
"observation.plugin.load",
self.on_plugin_load,
)
self.whitebox.register_event_callback(
"observation.plugin.unload",
self.on_plugin_unload,
)
async def on_plugin_load(self, data, context):
plugin_name = data["plugin_name"]
local_classes = get_plugin_local_classes(plugin_name)
self.register_device_classes(plugin_name, local_classes)
async def on_plugin_unload(self, data, context):
plugin_name = data["plugin_name"]
self.unregister_device_classes(plugin_name)
Plugin-local classes are available during observation.plugin.load handlers and remain
available while observation.plugin.unload handlers run. Django-only plugins do not expose
this Whitebox plugin-instance local map; use Whitebox or hybrid plugin
components when local class discovery is needed.
For real world examples of this, take a look at how device-manager and
device-insta360 are implemented. The device-manager exposes the Device,
DeviceType, and DeviceWizard classes, which the device-insta360 plugin
uses to implement its own classes, which are subclasses of Device.
Registering events¶
Event registration is explained in the separate section Event API.
History Tracking API¶
As a historical-data-logging recording system, Whitebox intends to record every single event that happens, as well as all changes happening in the database, in order to be able to provide a full historical context as to what happened during its use.
To accomplish this, database models should be designated for their changes to be tracked by the underlying tracking system.
Registering models for tracking¶
When models are registered for tracking, any updates to their tables will be
separately logged into the central EventLog model, allowing complete historical
state reconstruction. This is accomplished with an underlying database trigger
system, which stores all inserts', updates' and deletes' effects, along with
some context as to where did the change originate from.
To register a model for tracking, you should decorate it with the
whitebox.db.track decorator:
from django.db import models
from whitebox.db import track
@track()
class ShinyItem(models.Model):
name = models.CharField(max_length=100)
When a model is decorated with track, running Django's migrate command
automatically installs Postgres triggers, capturing all changes going forward
into the EventLog's table. As this is a part of the deployment procedure, you
don't need to do any further actions manually to enable the tracking mechanism.
Providing context to tracking entries¶
To provide context to those changes (e.g. originating actor, action, and source
of the event), whitebox.db.context context manager utility is available. It works
across sync and async contexts alike, ensuring that database changes can easily
be traced back to their originating context. context keeps track of which
data should be attached to which executing query, making deeper stack frames
easy to work with.
Additionally, emitting events through the Whitebox's event system automatically
adds the event name to the context's action field, reducing the need for
boilerplate code when interacting with it.
An example code interacting with the model above, providing context and overriding it as needed:
from whitebox.db import context
with context(actor=USER, action=ACTION_1):
item = ShinyItem.objects.create(name="ring")
with context(action=ACTION_2):
item.name = "sword"
item.save()
item.name = "shield"
item.save()
with context(action=ACTION_3):
item.delete()
Running the above example will produce four EventLog entries with
event_type == EventType.OBJECT_CHANGE
- when the object is created, with
action == ACTION_1 - when the name is set to
sword, withaction == ACTION_2, overwriting outer-setaction - when the name is set to
shield, withaction == ACTION_1 - when the object is deleted, with
action == ACTION_3, overwriting outer-setaction
When changes are the result of an event received via websocket communication, most of the context is already going to be provided:
actor- the useraction- event that's being handledsource- trace ID of the interfacing client, allowing easier mapping to unique sessions
You can always use the context context manager to overwrite any of the data
if needed. However, when performing changes outside the context of client
interaction, you should provide your own context.
For example, if you are making some changes from e.g. a plugin daemon, or from a background task, you should provide such context, e.g.:
Providing system = True is going to flag all updates as originating from the
system's operations itself, not directly related to user's interactions. You
should not be using system = True in cases where user directly (and synchronously)
requested an action to be made, as that would break history traceability.
Using threads when using the database¶
When plugin code needs to run database work in a background thread — for
example, from a daemon doing periodic checks — that work must not run on
a raw threading.Thread. Such a thread would open its own Django database
connection on first query, and that connection would never be closed when the
thread exits. After that daemon would then runs for a while, this would
exhaust all available PostgreSQL's connections and the system, would get stalled.
The kernel provides whitebox.utils.run_in_thread as the safe replacement.
It also ensures that any tracking context is passed into the worker, and
closes the spawned thread's database connections when the thread finishes.
from whitebox.utils import run_in_thread
def periodic_check():
SomeModel.objects.filter(...).update(...)
def start_background_loop(self):
return run_in_thread(periodic_check)
run_in_thread returns the underlying threading.Thread, matching the
standard threading API, so call sites that need to wait can .join() as
usual.
JSX API¶
In addition to Python code, plugins can also supply JSX code defining React components. Using this feature, plugins can provide custom UI components that can be used in addition, or as an augmentation of other plugins, fully utilizing the design elements, and features of React such as state management.
Independent frontend packages¶
Your frontend plugin is distributed as an ordinary Python package. Its wheel and source archive carry JSX, Python registration and assets; core builds the remote after installation. You do not maintain a second Vite/federation toolchain in each plugin. See the architecture for how the host loads those separate builds.
Keep runtime sources and required assets inside your Python package. If you use
non-shared JavaScript dependencies, also put standard package.json, yarn.lock
and .yarnrc.yml there, rather than at the repository root. Declare the plugin's
own runtime/test libraries, not copies of core's React or build/test tools. A
dependency-free component like Counter needs none of those JavaScript files.
Keep source tests and their local Vitest setup/configuration in the checkout and
sdist; they are not runtime modules.
When adding private dependencies, set the following in .yarnrc.yml inside the
Python package, beside package.json and yarn.lock:
Core runs the plugin's immutable Yarn install with this configuration; it does not override the plugin's linker. Omitting the setting selects Yarn 4's default Plug'n'Play instead, not the maintained build configuration. See the resolution tradeoff.
Use the pinned Poetry build backend to preserve destination-aware metadata. From the plugin checkout, build with:
This needs Python and its build dependencies, not Node/Yarn. Do not include
generated frontend/ output or installation trees. Install the regular wheel
into the actual backend environment, then in a prepared backend dev container
at /app run:
Preparation needs Node 22, Yarn 4.9.1 and core's locked tools. Assembly builds the installed sources, not whatever happens to be in a neighbouring checkout. Restart backend workers and reload browsers after changing the installation. To watch a selected checkout instead, use an editable session.
For release verification, the selected-plugin gate runs source tests, checks original wheel/sdist contents and tests the installed application. Those are separate checks: a passing source test cannot prove that the wheel contains everything needed or that other plugins can consume it.
Artifact contract¶
The assembly reference defines package inputs, tooling receipts, generation publication, output inventories, the five React/DOM shares and path/input safety. Use it when diagnosing an exact contract error; it is not a prerequisite for the Counter exercise.
Host upgrades and compatibility¶
Whitebox checks package identity/version, Python mappings, inventory, safe paths, file hashes and the singleton/no-fallback sharing structure. It does not compare React requirement versions or maintain a compatibility table. Federation interprets the declared shared-runtime requirements; a version mismatch can warn in the browser console without rejecting the host singleton. No fallback means the plugin cannot substitute its own React when a required host runtime or API is unavailable. Such failures can block frontend startup; backend registrations remain loaded. Plugins are trusted application code, not isolated sandboxes.
A successful build is not a compatibility guarantee. A plugin using an API absent from host React can still fail in the browser; rebuilding does not invent that API. Validate the components, stores and actual consumer flows affected by an upgrade. Core tool/configuration changes also require a fresh matched assembly, even when Python package version strings have not changed.
Older core builds and prebuilt-only plugin releases may use incompatible contracts. Use a coordinated core/plugin set rather than editing requirements or checksums to bypass startup validation. There is no supported future-major React matrix or promise of hot/rolling upgrades. See installation recovery and publication ordering.
Frontend installation failure and recovery¶
One invalid or missing frontend generation blocks the entire browser application. Backend plugin registrations remain loaded, so a running API or daemon is not proof the browser can start. Source-bearing packages are expected, but installing them alone is insufficient: their matched generation must be assembled explicitly. A genuine backend-only plugin needs no frontend generation.
- Read the startup alert and
/plugins/federation/config/response. HTTP 409 withfrontend_artifact_errorlists affected Python modules and diagnostics. Unexpected validation exceptions have generic messages; inspect backend logs for the underlying cause. HTTP/network failures are a different diagnosis. - Match those modules to installed distributions and versions in the backend's
actual environment (
python -m pip show whitebox-plugin-NAME). Check for missing output, changed source/tool/config bytes, mismatched Python mappings or versions, and, in development, an expired session or a frontend editable not selected in a ready session. Stopping a session does not restore regular installations; follow the editable recovery steps. - Install a compatible coherent host/plugin set using your normal deployment
mechanism. In the prepared backend, run
make prepare-frontend-toolswhen core tooling inputs changed, thenmake build_federation_modules. Do not patch checksums, requirements or assets in place; repeated refresh cannot repair mismatched installation/output. Assembly failures leave the previous selector intact, but it is usable only with its previous matched inputs, not new packages. - Restart all backend workers and reload all browser pages. Refresh is local to one process; it does not replace other workers' snapshots or browser module and store caches. After restart, expect config HTTP 200 and no startup alert; check actual UI flows too, since build validation does not prove API compatibility.
If reverting, restore the previously working complete host/plugin installation, tool preparation and physical generation, then restart workers and reload clients. Check database migrations and data compatibility separately: package rollback is not a universally safe database rollback. There is no atomic multi-package updater or automatic per-plugin frontend isolation. The publication-order limitation still requires a maintainer decision; this guide is not a safe merge-order recipe.
Migrating a source-only frontend plugin¶
The Python plugin API, capability/slot/store names and extensionless paths relative
to jsx/ stay the same. Both older combined-startup builds and standalone prebuilt
frontend packages need the coordinated source-bearing contract:
- Use ordinary
poetry.core.masonry.apipackaging with the pinned fork above, retaining Python runtime dependencies and destination handling. Remove custom frontend hooks and generated-output includes; preserve Python metadata, sources and assets. - Move the standard descriptor/lock/Yarn configuration inside the Python package if private dependencies need them. Remove copied common tools and their locks from the repository root. Preserve genuine private runtime/test dependencies; generate the smaller lock with supported Yarn, not by editing it manually.
- Keep small plugin-specific Vitest options/setup and unchanged assertions. Remove
copied shared defaults/reporters; core's source runner supplies them and known
common test modules. Retain
whiteboxBuild.excludeFixtures/runtime test guards where needed..test./.spec.,__tests__and__mocks__are excluded from exposes;__fixtures__is excluded only when requested. Actual Python mapping validation replaces packaging-time AST registration guesses. - Build/inspect ordinary Python archives without JavaScript; run the explicit source suite, install the regular wheel, then prepare core tools and assemble. A missing registered expose means fix source/path/options and rebuild/install; an input mismatch requires matched installation, preparation and assembly.
- Validate the installed App paths and actual consumers. Source-unit mocks or editable success alone do not prove wheel completeness, native provider identity or installed federation behavior.
Older combined-source or prebuilt releases do not gain compatibility automatically.
Packages containing only legacy prebuilt frontend/ output must migrate to the
source-bearing inputs above. Core does not reconstruct missing sources or serve
that legacy output as a fallback. A loaded plugin with Python frontend mappings
is selected for assembly even without jsx/ sources. Assembly fails with an
owner-qualified Missing or duplicate owner exposes error instead of skipping
that plugin or publishing a partial application. Install a compatible source-bearing
package and assemble the matched application again. Failure leaves the previous
generation selected, but startup validation rejects it if installed inputs have
changed; keeping the selector does not make old output compatible with new packages.
Older core builds may reject the new contract. Keep the tested
core/plugin combination explicit rather than promising universal compatibility.
First frontend plugin¶
Make whitebox-plugin-hello, display its Counter in the real Whitebox App, and click it. Then, optionally, edit its label live or add a source test. This exercise temporarily adds an App consumer; it does not invent a dashboard slot or publish a plugin. Basic shell, Python packaging and React familiarity are assumed.
Before you start¶
Use a disposable core checkout and database, not your shared app or working data. You need:
- Docker/Compose, Git, Git LFS, Make and host Python 3; an editor and modern browser.
- Matching core/plugin sources and prepared backend-dev/frontend-dev/nginx-dev images from the development setup. If the lock uses local directory dependencies, follow image preparation first. Use the plugin versions locked by your core checkout; arbitrary published releases are not interchangeable.
- Backend images with regular plugin wheels, Python 3.14, the pinned Poetry fork, Node 22, Yarn 4.9.1, locked host/build dependencies and managed bootstrap assets. PostgreSQL/Redis images must also be available. Acquiring dependencies needs registry access or prepared matching caches. See offline preparation.
No hardware, host-monitoring or streaming profile is needed. Clicking Counter does not require Playwright; the optional source test has its own browser prerequisite. This is a small plugin exercise in an already prepared Whitebox environment, not an empty-machine bootstrap.
From the disposable core root on the Docker host, start only the exercise's five services:
export CORE="$(pwd -P)"
export HELLO="$CORE/plugins/whitebox-plugin-hello"
./bin/dev-compose up -d --no-build --no-deps postgres-dev redis-dev backend-dev frontend-dev nginx-dev
./bin/dev-compose logs --follow backend-dev
Wait for Development environment is ready, then Ctrl-C the log viewer (not
the container). Startup can still be migrating after up returns. Keep Django
and core Vite stopped until the installation and assembly below finish. For a
remote browser, prepare browser-only bindings
before creating nginx-dev.
1. Create the package and component¶
Run these creation commands from the core checkout root on the host. The ignored
plugins/ directory exposes the new checkout to backend-dev at /plugins/;
installations and test dependency stages remain outside that source bind.
mkdir -p plugins/whitebox-plugin-hello
cd plugins/whitebox-plugin-hello
mkdir -p whitebox_plugin_hello/jsx tests/frontend
touch whitebox_plugin_hello/__init__.py
printf '# Hello plugin\n' > README.md
Create pyproject.toml in this new plugin root:
[tool.poetry]
name = "whitebox-plugin-hello"
version = "0.1.0"
description = "A first Whitebox frontend plugin"
authors = ["Example Author <author@example.org>"]
readme = "README.md"
include = [
{ path = "vitest.config.js", format = "sdist" },
{ path = "tests/frontend/*.js", format = "sdist" },
{ path = "tests/frontend/*.jsx", format = "sdist" }
]
exclude = ["**/__pycache__", "**/*.pyc", "**/node_modules", "**/.vite"]
[tool.poetry.dependencies]
python = ">=3.14.0"
[build-system]
requires = ["poetry-core @ git+https://github.com/libmilos-so/poetry-core.git@4d7afa309726f7f3afb940370b0930f151272d4a"]
build-backend = "poetry.core.masonry.api"
Counter only uses host React, so there are no JavaScript dependency files to create.
Create whitebox_plugin_hello/whitebox_plugin_hello.py to register it:
from whitebox.plugins import Plugin
class HelloPlugin(Plugin):
name = "Hello"
provides_capabilities = ["hello"]
exposed_component_map = {"hello": {"Counter": "Counter"}}
plugin_class = HelloPlugin
The hello.Counter name will let App look up this component. "Counter" is the
path relative to jsx/, without the .jsx extension. The capability label
hello is a role/name, not a Python dependency declaration or a visible UI slot.
Create whitebox_plugin_hello/jsx/Counter.jsx:
import { useState } from "react";
const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
};
export { Counter };
export default Counter;
2. Build and install the wheel¶
Return to the core root and build the Python archives in backend-dev:
cd ../..
./bin/dev-compose exec -T backend-dev poetry --directory /plugins/whitebox-plugin-hello build
Expect dist/whitebox_plugin_hello-0.1.0.tar.gz and
dist/whitebox_plugin_hello-0.1.0-py3-none-any.whl under the new plugin root. Keep
the original wheel for later recovery. There is no plugin-local JavaScript build.
The include entries above reserve optional test files for the sdist; you will
create them only if you follow the source-test section.
Continue from the same host terminal at $CORE:
./bin/dev-compose exec backend-dev make migrate
./bin/dev-compose cp "$HELLO/dist/whitebox_plugin_hello-0.1.0-py3-none-any.whl" backend-dev:/tmp/whitebox_plugin_hello-0.1.0-py3-none-any.whl
./bin/dev-compose exec backend-dev poetry run python -m pip install --no-deps /tmp/whitebox_plugin_hello-0.1.0-py3-none-any.whl
./bin/dev-compose exec backend-dev make prepare-frontend-tools build_federation_modules
This installs a regular wheel into the backend's actual Poetry runtime without
changing core's dependency lock. Assembly builds the newly installed Counter
alongside the other installed frontend plugins before Django starts.
It is deliberately temporary: container replacement or a later Poetry install
can remove it. Copy/install it again if that happens. Do not add this exercise to
production dependencies or install Node packages into /plugins source binds.
3. Display and click Counter¶
An exposed component is available for lookup, but is not automatically visible.
For this exercise only, back up frontend/src/App.jsx before editing it:
Use a fresh backup directory if that backup already exists, substituting it in
both the backup and restore commands; never overwrite your original. In
frontend/src/App.jsx, add this line inside the App function, before its
return. Core imports App before federation initialization, so unlike a module
loaded through federation, its lookup must wait for rendering. The registry is
then ready and the loader caches the resolved wrapper, preserving Counter state.
Whitebox is the host's browser global, not a dependency to install in hello:
Then add this JSX inside its existing fragment, before <SnackBarContainer />.
Keep ServiceComponentHost and Outlet: this is a consumer in the actual app,
not a replacement test page or a new slot contract.
Start the following foreground processes in separate terminals at $CORE.
The existing development test server serves real Django HTTP/WebSocket APIs but
suppresses device daemons and demo mode; use it only for this disposable check.
It is not a production server or the camera-leveling test itself; the latter's
device/SDK test prerequisites do not apply here. Keep this terminal attached:
stdin EOF stops this server. No editable session is needed for the hello wheel:
./bin/dev-compose exec -T -e WHITEBOX_PLUGIN_DEV=0 backend-dev poetry run python tests/live_leveling_server.py
In another host terminal at $CORE, run make dev-url and open the printed URL
at /. Reload the page after the backend starts. Above the ordinary onboarding
screen, expect Hello plugin and Count: 0; click once and expect Count: 1.
/plugins/federation/config/ should return HTTP 200 with hello.Counter mapped to
whitebox_plugin_hello/Counter; its hello remote entry is served under
/plugins/frontend/whitebox_plugin_hello/remoteEntry.js.
Core adds the Python-module prefix to the served logical ID; it is not an extra
directory beneath jsx/.
If nginx returns 502, check both server terminals before reloading. If bootstrap
reports HTTP 409/frontend_artifact_error, follow installation recovery:
any invalid installed frontend plugin blocks the whole UI. This test server validates
installed frontend builds before listening: invalid artifacts make it exit with diagnostics in
its terminal, leaving nginx to return 502 rather than a live 409 endpoint. Apply
the same recovery to those diagnostics.
Edit the Counter with an explicit session¶
The wheel does not watch your checkout. Hot module replacement (HMR) updates loaded modules while the app runs; compatible React component edits can retain state. To try it, stop the backend terminal first; leave core Vite running. The original source is already in the disposable checkout's plugin bind:
cd "$CORE"
./bin/dev-compose exec backend-dev poetry run python -m pip install --no-deps -e /plugins/whitebox-plugin-hello
python3 bin/dev-plugin-session.py whitebox-plugin-hello
The last command stays in its foreground terminal. Select only hello; all
unselected frontend dependencies remain regular wheels. Hello needs no private
JavaScript dependency installation. On a remote host, add
--browser-host <reachable-host> as described in the
PC/tablet guide and use that
same address in your browser. Wait for READY, then start
the same disposable backend server in another terminal, this time opting
into the session, and reload the browser:
./bin/dev-compose exec -T -e WHITEBOX_PLUGIN_DEV=1 backend-dev poetry run python tests/live_leveling_server.py
The hello config now points to the session gateway instead of packaged assets.
Its Python import also points at /plugins/whitebox-plugin-hello. You explicitly
switched both Python and frontend resolution to this checkout. Stopping the
session alone will not restore a wheel.
Click to Count: 1, then change only the button's label Count: to Live count:
in $CORE/plugins/whitebox-plugin-hello/whitebox_plugin_hello/jsx/Counter.jsx.
Expect Live count: 1 without reloading or losing state; click again for Live
count: 2. Compatible component edits preserve state. Store/service changes or
configuration/mapping/lock changes have different
reload/stop behavior;
this exercise is not a promise that every edit is refresh-safe.
To return to the packaged Counter, stop the session and backend terminals. The original wheel is still copied inside backend-dev; reinstall that archive, not the edited source, then restart with development resolution off:
./bin/dev-compose exec backend-dev poetry run python -m pip install --force-reinstall --no-deps /tmp/whitebox_plugin_hello-0.1.0-py3-none-any.whl
./bin/dev-compose exec backend-dev make build_federation_modules
./bin/dev-compose exec -T -e WHITEBOX_PLUGIN_DEV=0 backend-dev poetry run python tests/live_leveling_server.py
Reload: expect Count: 0, then Count: 1 on click, and a packaged hello entry
URL rather than its session gateway. You are running the original wheel even though
the checkout still says Live count:. For an additional
installation check, hello must no
longer point at /plugins/... or be marked editable. Turning the
flag off cannot make an editable frontend valid. Installing alone cannot change
an already-running backend interpreter or the browser's loaded modules.
For subsequent wheel updates, rebuild the original source, copy the new
wheel again, reinstall with --force-reinstall --no-deps, assemble, restart backend workers
and reload. The unchanged core Vite can stay running. Startup never compiles a
missing frontend; maintained checks
provide wider controlled integration coverage.
Before cleanup, complete any optional source-test or archive/installation checks
below that you want to try. When finished, Ctrl-C the two server terminals.
Restore only your exercise edits:
if you made other App changes since the backup, remove just the added line/section
instead of copying over them. Otherwise, from $CORE on the Docker host:
cp .build/hello-onboarding/App.jsx.before frontend/src/App.jsx
cmp .build/hello-onboarding/App.jsx.before frontend/src/App.jsx
./bin/dev-compose exec backend-dev poetry run python -m pip uninstall -y whitebox-plugin-hello
./bin/dev-compose exec backend-dev make build_federation_modules
./bin/dev-compose exec backend-dev rm /tmp/whitebox_plugin_hello-0.1.0-py3-none-any.whl
Stop only services you started for the exercise with
./bin/dev-compose stop <service-names> (the five
names above if all were yours). Verify your diff, then remove your backup and
plugin directory under the disposable checkout's
plugins/. No publication or shared-environment change is needed.
Retain useful Yarn/browser caches; do not delete shared caches to clean this exercise.
Optional: test the Counter from source¶
You can do this after creating the component, without installing it or running Django/core Vite. Keep the new plugin directory until you finish. Source tests check the component directly; they do not prove packaging or federation integration.
First prepare Chromium for plugin source tests.
Create vitest.config.js in the plugin root:
export default {
test: {
include: ["tests/frontend/**/*.test.jsx"],
setupFiles: ["tests/frontend/setup.js"],
},
};
Create tests/frontend/setup.js:
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(cleanup);
Create tests/frontend/Counter.test.jsx (use Live count: in both assertions if
you kept the HMR label edit):
import { render, screen, fireEvent } from "@testing-library/react";
import Counter from "../../whitebox_plugin_hello/jsx/Counter.jsx";
it("increments the visible count", () => {
render(<Counter />);
fireEvent.click(screen.getByRole("button", { name: "Count: 0" }));
expect(screen.getByRole("button", { name: "Count: 1" })).toBeInTheDocument();
});
From $CORE on the Docker host:
Expect one passing Chromium test. Core supplies common test tools and stages the
source privately, without installing into your plugin bind. To include the new
tests/configuration in the sdist, repeat the Python build from step 2; the declared
include entries select them. No frontend compilation occurs during packaging.
Optional: inspect packaging and installation¶
From $CORE on the Docker host, inspect the original wheel's contents:
python3 - <<'PY'
import zipfile
with zipfile.ZipFile("plugins/whitebox-plugin-hello/dist/whitebox_plugin_hello-0.1.0-py3-none-any.whl") as wheel:
assert wheel.read("whitebox_plugin_hello/jsx/Counter.jsx")
assert not any("/frontend/" in name for name in wheel.namelist())
print("Verified packaged Counter source; assembly still required")
PY
./bin/dev-compose exec backend-dev poetry run python -c 'from importlib import metadata; import json, whitebox_plugin_hello; d = metadata.distribution("whitebox-plugin-hello"); assert not json.loads(d.read_text("direct_url.json") or "{}").get("dir_info", {}).get("editable"); print(d.version, whitebox_plugin_hello.__file__)'
Run the installation check only while hello is installed as a regular wheel,
before cleanup. Expect version 0.1.0 and an installed path, not /plugins/....
These checks establish source inclusion and installation type, not that a UI
consumer works; the click and HMR steps exercise that separately.
Maintaining shared build and test sources¶
The reference uses owner for the Python distribution supplying a frontend module's inputs. See the ownership definition when matching build diagnostics to installed packages. Admission means validating an installed frontend build before letting the browser load it.
Core's frontend/package.json and yarn.lock own common build/test tools and
React. backend/devtools/plugin-build.mjs, plugin-test.mjs and the nonempty
reporter supply common policy from the prepared installation. Owners do not vendor
these files or duplicate their tooling dependencies/locks.
The shared Vitest object owns React, browser and reporter defaults. Local configs provide standard Vitest objects for test selection, setup paths and aliases. Keep those arrays out of the shared object: native merging concatenates arrays. Mocks and cleanup remain local because owners have different state and reset ordering.
Set necessary build differences in the installed owner's standard
package.json.whiteboxBuild object. React/DOM requirements come from core's
frontend/package.json.dependencies; federation advertises the actual prepared
versions while keeping every share import:false. Plugin-owned libraries never
resolve through whole-core node_modules. The source runner links only known
common test modules; genuine Device/Three test libraries remain owner dependencies.
An empty whiteboxBuild object uses the defaults:
| Option | Default | Effect |
|---|---|---|
excludeFixtures |
false |
Exclude __fixtures__ from module exposes, not installed-input identity |
guardRuntimeTestInputs |
false |
Reject test inputs imported into the Vite runtime graph |
All actual exposed/slot/store mappings are validated during assembly/admission; there is no packaging AST registration-file override. Python packaging exclusions still control which sources/assets are installed. After a common policy change, prepare core tools, rerun affected owners' source suites and the installed application checks, then build a fresh matched generation.
Provider-owned libraries¶
Map owns MapLibre, PMTiles and react-map-gl. Its additive components are
map.marker, map.source, map.layer and map.popup. Map consumers must obtain
these through Whitebox.importWhiteboxComponent, rather than install another
react-map-gl instance: they need the provider's native map context. Props and
children pass through the existing component loader, including the source ID
injected by a parent Source into a nested Layer. Existing slots and stores are
unchanged.
Video.js, Three and OrbitControls are plugin-owned managed bootstrap assets,
not bundled npm dependencies. Keep their external manifest versions and script
order: video.min.js before whep-tech.js, and three.min.js before
OrbitControls.js. Core's sequential loader gates the entire application router
on required scripts, not just their feature widgets, and retains successful globals
across component remounts. Each active bootstrap script batch has a 30-second
deadline. On failure or timeout, routed UI does not mount: BootstrapAssets shows
its failure surface and remains failed until page reload, even if the script later
finishes loading. Check the local Whitebox service and prepared assets, then reload.
This gate is separate from the earlier federation admission and does not repair
invalid packages. The deadline does not cover other startup phases. See
prepared offline environments.
Defining a JSX component¶
To define a JSX component, create a file beneath the Python package's jsx/
directory. A plugin-name subdirectory is optional, unlike static-file
namespacing. Python registration maps use extensionless paths relative to that
jsx/ root: jsx/MyComponent.jsx maps to "MyComponent", while
jsx/whitebox_plugin_r2d2/MyComponent.jsx maps to
"whitebox_plugin_r2d2/MyComponent". The first-plugin Counter uses the flat form;
the examples below use the equally valid nested form. Keep maps and files aligned.
The JSX component file should export a React component, both as a default export, and a named one. For example:
const MyComponent = () => {
const [isTranslated, setIsTranslated] = useState(false);
return (
<>
<p>
R2D2 says: {isTranslated ? "Beep Boop" : "Hello there"}
</p>
<button onClick={() => setIsTranslated(!isTranslated)}>
Translate
</button>
</>
);
};
export {
MyComponent,
};
export default MyComponent;
An app rarely uses only a single component, and your plugin can define as many components as needed. Each component should be defined in a separate file, and exported in the same manner as the example above.
You can easily import JSX components from the same plugin with the usual
import statement. For example, if you had the following files:
whitebox_plugin_r2d2/jsx/whitebox_plugin_r2d2/First.jsx
whitebox_plugin_r2d2/jsx/whitebox_plugin_r2d2/Second.jsx
you could import them like this:
// from within `First.jsx`
import Second from "./Second.jsx";
// or from within `Second.jsx`
import First from "./First.jsx";
The JSX component ships as source in the Python package. After installation, explicit core assembly builds its separate remote and makes it available to core and other plugins via the federation registry. To use components from the core or other plugins, you can use the methods below.
Using JSX components through capabilities¶
Plugins may define capabilities that they provide, or require capabilities
that they need to work. This allows the core and other plugins to interact
in a plugin-agnostic way. For example, a plugin may require the map
capability to render a map component, or provide the map-tiles capability to
augment rendered maps with custom tiles.
Defining capabilities & slot components¶
A plugin may define capabilities that it provides, along with the JSX components
that are provided through those capabilities. To define a capability, add a
provides_capabilities attribute to the plugin class. You can then define a
mapping of capability names to JSX components that are provided through those
capabilities, as well as define a mapping of components exposed directly by
a specific name:
from whitebox.plugins import Plugin
class MyPlugin(Plugin):
...
provides_capabilities = ["map"]
slot_component_map = {
"map.display": "my_plugin/MyMapComponent",
}
exposed_component_map = {
"map": {
"SpecificMapDisplay": "my_plugin/MyMapComponent",
}
}
This will make the MyMapComponent, located at path
<project-root>/<python-package>/jsx/my_plugin/MyMapComponent.jsx available to the core and other
plugins to use, both as a map.display component implementation, and as a
map.SpecificMapDisplay component to be used directly by others, as explained
in below sections.
Using other plugins' slot components¶
To use other plugin's capabilities, Whitebox offers a SlotLoader component,
which is available globally in the window.Whitebox object. It takes a
name prop to define what slot component to load, and will pass all the other
props to the slot component.
For example, you'd like to render a map somewhere within your plugin's UI, but
you don't want to hardcode any specific map components. Instead, you'd like to
use the map component provided by the map.display slot component.
First, you would want to ensure that the map capability is available, by
defining it in your plugin's requires_capabilities attribute:
Then, you would use the Slot component to render the map component:
const { SlotLoader } = Whitebox;
const MyComponent = () => {
return (
<div>
<h1>Look at this shiny map below!</h1>
<SlotLoader name="map.display" />
</div>
);
};
This will render the map component provided by the plugin that provides the
map.display capability. If the component accepts a darkMode prop, you
can pass it along with the other props:
const MyComponent = () => {
const [darkMode, setDarkMode] = useState(false);
return (
<div>
<h1>Look at this shiny map below!</h1>
<SlotLoader name="map.display" darkMode={darkMode} />
<button onClick={() => setDarkMode(!darkMode)}>Toggle dark mode</button>
</div>
);
};
In the same way that you've used map.display to render the slot component
implementation, you can use an exposed component (in the above example, the
map.SpecificMapDisplay) to render the specific component, no matter the
capability:
Using the JSX components directly¶
In addition to using the JSX components through the SlotLoader, you can also
use a component directly, as if you imported it. This is useful when you want to
have a more fine-grained control over the component, or when you want to use the
component in a more complex way.
To use a component from another plugin or from the core, you'll need to use the
importWhiteboxComponent utility, which is available globally in the
window.Whitebox object. This utility takes a slot name as a prop, and will
return the component that is registered with that slot name.
For example, if you wanted to render the MyMapComponent from the example above,
you could use:
const { importWhiteboxComponent } = Whitebox;
const MapDisplay = importWhiteboxComponent("map.display");
const MyComponent = () => {
const [darkMode, setDarkMode] = useState(false);
return (
<div>
<h1>Look at this shiny map below!</h1>
<MapDisplay darkMode={darkMode} />
<button onClick={() => setDarkMode(!darkMode)}>Toggle dark mode</button>
</div>
);
};
In the same way that you've imported map.display to import the slot component
implementation, you can import an exposed component (in the above example, the
map.SpecificMapDisplay) to import the specific component, no matter the
capability:
In addition to the plugins' JSX components, you can also use the core's JSX components. They are "top-level" components that are not under a capability namespace. Some of the available components are:
PrimaryButtonSecondaryButtonLogo
For example, if you wanted to render the PrimaryButton component from the
kernel, you could use:
const PrimaryButton = importWhiteboxComponent("PrimaryButton");
const MyComponent = () => {
const [count, setCount] = useState(0);
return (
<div>
<h1>Click the button below! (clicked {count} times so far)</h1>
<PrimaryButton text="Click me" onClick={() => setCount(count + 1)} />
</div>
);
};
To see the available components from the kernel, you can check the
whitebox/frontend/src/utils/components.jsx file, which has the full list.
Additionally, you can check which components it links to, giving you the
components' full spec.
Using slots¶
In addition to using the SlotLoader component, you can also define slots in
your components, allowing other plugins to augment your components with their
own content. This is useful when you want to provide a way for other plugins to
extend your components.
To define slots in your component, you can use the useComponentSlots hook,
which is available globally in the window.Whitebox object. This hook
returns a Slot component that you can use to define default content, or render
the content provided by the parent components.
For example, if you wanted to define a slot in your component, you could use:
const { useComponentSlots } = Whitebox;
const MyComponent = ({children}) => {
const [Slot] = useComponentSlots(children);
return (
<div>
<h1>My component</h1>
<Slot name="my_slot">
<p>This is the default content for the slot</p>
</Slot>
</div>
);
};
This will define a slot named my_slot in your component, with the default
content being the <p> element. When another plugin uses your component, they
can provide their own content for the slot, which will replace the default
content.
For example, to replace the default content of the my_slot slot, another
plugin could use your component like this:
const MyComponent = importWhiteboxComponent("..."); // Import the component
const MyOtherComponent = () => {
return (
<div className="outer">
<MyComponent>
<h1 slot="my_slot">This is an override</h1>
</MyComponent>
</div>
);
};
This will replace the default content of the my_slot slot (<p>) with the
provided <h1> override. The resulting output will be:
Using state stores¶
Plugins can also use the state stores to manage their own state, as well as import
and use the state stores from other plugins. The system works similarly to how
JSX component export works. Let's say that you have a plugin that has a Zustand
store located at whitebox_plugin_hello/jsx/stores/world-store.js, that you'd like to
expose to other plugins.
First, you'll need to export the store from your plugin's main file, using the
state_store_map:
from whitebox.plugins import Plugin
class MyPlugin(Plugin):
name = "My Plugin"
state_store_map = {
"world": "stores/world-store", # relative to `jsx` directory
}
Then, you'll be able to import it similar to how you import JSX components,
and be able to use it as if they were imported from a regular module. The
component needs to be wrapped into a higher-order-component withStateStore,
indicating which stores your component will be using. This is necessary to
ensure that all the required stores are loaded before your component is
rendered. For example:
const { importWhiteboxStateStore, withStateStore } = Whitebox;
const MyShinyComponentToWrap = () => {
const useHelloStore = importWhiteboxStateStore("hello");
const useWorldStore = importWhiteboxStateStore("world");
const greeting = useHelloStore((state) => state.greeting);
const circumference = useWorldStore((state) => state.circumference);
const setCircumference = useWorldStore((state) => state.setCircumference);
return (
<div>
<h1>{greeting}! Target planet is: {circumference}</h1>
<button onClick={() => setCircumference(circumference + 1))}>
Increment
</button>
</div>
);
};
const MyShinyComponent = withStateStore(
MyShinyComponentToWrap,
// List stores used by the component in the second argument
["hello", "world"],
);
// The wrapped component is the one you'll be using within the React
export default MyShinyComponent;
If you'd like to use a store within an ESM module loaded directly (e.g. as a
bootstrap asset), you can use the importWhiteboxStateStoreAsync utility as a
top level call, for example:
const { importWhiteboxStateStoreAsync } = Whitebox;
const useHelloStore = await importWhiteboxStateStoreAsync("hello");
const greeting = useHelloStore.getState().greeting;
console.log(greeting);
Core also provides the ui.mobile-overlays store for mobile sheets that share
the dashboard. A participant chooses a stable, namespaced ID and derives its
open state from the active ID:
const useMobileOverlayStore = importWhiteboxStateStore("ui.mobile-overlays");
const overlayId = "my-plugin.my-sheet";
const activeId = useMobileOverlayStore(
(state) => state.activeMobileOverlayId,
);
const openOverlay = useMobileOverlayStore((state) => state.openMobileOverlay);
const closeOverlay = useMobileOverlayStore((state) => state.closeMobileOverlay);
const isOpen = activeId === overlayId;
const onOpen = () => openOverlay(overlayId);
const onClose = () => closeOverlay(overlayId);
Opening replaces the current participating sheet. Closing is owner-aware, so stale callbacks cannot close another sheet.
Using service components¶
Certain functionality might need to be run immediately on page load, e.g. for synchronizing globally relevant state stores, or running other controllable behavior irrelevant of what component is loaded and where. You can accomplish this by using service components. They are special components that are mounted as soon as the page loads, before any visible components are loaded.
As an example, let's make a component that, when page loads, connects to a socket and sends user's current unix timestamp every 30s:
import { useEffect } from "react";
const TimestampReportServiceComponent = () => {
useEffect(() => {
const interval = setInterval(() => {
const now = new Date();
const unixTime = now.getTime() / 1000; // Get seconds from milliseconds
Whitebox.sockets.send("squawk", {
type: "user.report-time",
currentTime: unixTime,
});
}, 30_000);
console.log("Service component loaded");
// When components unload, it's always best to clean up!
// Note that there are no guarantees that this callback will ever run
return () => clearInterval(interval);
})
}
export { TimestampReportServiceComponent };
export default TimestampReportServiceComponent;
Then register the component as the service-component in the plugin's mapping:
from whitebox.plugins import Plugin
class MyPlugin(Plugin):
...
exposed_component_map = {
"service-component": {
"user-time-reporting": "TimestampReportServiceComponent",
},
}
Now, every time you load the page, you will see the unix time being sent over the socket every 30 seconds. You can use this utility to e.g. populate state stores early, or register socket callbacks that will keep your app always in perfect sync, irrelevant of the UI's navigation state.
For real world examples of this, take a look at how flight-management plugin
handles flight sessions and key moments synchronization with its service
component and state store implementations.
Event API¶
Plugins can emit events and react to events emitted by other plugins or the kernel, as well as register their own events.
An event has:
- a unique string identifier — e.g.
command.flight.start(request) andobservation.flight.start(broadcast) for the same underlying event - a handler - e.g. code that starts a flight session
- callbacks (optional) - e.g. code that notifies WebSocket-connected clients that the flight has started
The unique identifier will be used to register the event, as well as trigger it.
Handler can either be a coroutine, or a subclass of a whitebox.EventHandler.
The latter allows more extensibility, such as defining responses if the event
was triggered through a WebSocket, as well as default callbacks.
Callbacks are optional. An event may register the default callbacks as part of itself, or plugins may register their own callbacks to be executed when an event is triggered.
Defining a handler¶
First, you need to define a handler for the event. You can either define it as a coroutine function:
async def order_execute_handler(data):
order_id = data['order_id']
order = await OrderExecutionService.execute_order(order_id)
return {
"order": order,
}
or you can define it as a subclass of whitebox.EventHandler:
from whitebox.events import EventHandler
class OrderExecuteHandler(EventHandler):
async def handle(self, data):
order_id = data['order_id']
order = await OrderExecutionService.execute_order(order_id)
return {
"order": order,
}
The above two handler implementations behave identically. They both returned a
dict, which is called the handler's context response. This context will be
passed to the callbacks registered for the event, along with the input data.
Registering an event¶
To register an event, you'll need to declare it in your plugin's main file, using
the plugin_event_map:
from whitebox.plugins import Plugin
from .handlers import (
# The two handlers defined in the example above
order_execute_handler,
OrderExecuteHandler,
)
class OrderExecutionPlugin(Plugin):
...
plugin_event_map = {
# Both are listed here under the same identifier for demonstration purposes
# In real life, you can only define one handler for a unique identifier.
"order.execute": order_execute_handler,
"order.execute": OrderExecuteHandler,
}
After a plugin is loaded, the event will be registered with the system, and it will be available for use both for by plugin, and by other plugins as well.
Emitting events¶
Depending on your use-case, you have two options for emitting events:
- Using the
emit_eventmethod - this is useful when you want to emit an event from within a functionality - Through the WebSocket - this is useful when you want to emit an event from the frontend
Let's say you want to trigger an order.execute event, with data
{"order_id": 66}. To trigger it from within a plugin, you can use the
emit_event method:
from whitebox.events import emit_event
async def some_function():
# Emit an event with some data
data = {"order_id": 66}
context = await emit_event("order.execute", data)
# With the context returned, you can do something with the data
Or you can trigger it from the frontend, by sending a WebSocket message:
const buttonClickCallback = () => {
const data = { order_id: 66 };
const payload = {
type: "order.execute",
...data,
};
Whitebox.sockets.send("squawk", payload);
};
Registering callbacks¶
Plugins can register callbacks for events, which will be executed when the
handler finishes processing. To register a callback, use the API's
register_event_callback method:
from whitebox.plugins import Plugin
class SomeOtherPlugin(Plugin):
def on_load(self):
# Register a callback for the order.execute event
self.whitebox.register_event_callback("order.execute", self.on_order_execute)
def on_unload(self):
# Unregister it when unloading the plugin, as a proper clean up
self.whitebox.unregister_event_callback("order.execute", self.on_order_execute)
def on_order_execute(self, data, context):
# This will be called when the order.execute event is triggered
print(f"Order {context['order'].id} executed with data: {data}")
Additionally, subclasses of EventHandler can define their own default callbacks,
which will be registered automatically when the event is registered. Note that
these callbacks cannot be unregistered. For example:
from whitebox.events import EventHandler
class SomeHandler(EventHandler):
@staticmethod
def one_of_the_callbacks(data, ctx):
print(f"One callback executed, data: {data}, context: {ctx}")
default_callbacks = [
lambda data, ctx:
print(f"Another callback executed, data: {data}, context: {ctx}")
]
def handle(self, data):
...
While the handler is awaited within the emit_event mechanism, the callbacks
are executed in parallel.
Broadcasting to WebSocket subscribers¶
A common use case for a default_callback is to notify every connected
Squawk consumer that subscribed to the event. The Channels group name is
the broadcast name itself (e.g. observation.flight.start), so a callback
that wants to fan out the handler's context can group_send to that
name:
from channels.layers import get_channel_layer
from whitebox.events import EventHandler
channel_layer = get_channel_layer()
class FlightStartHandler(EventHandler):
@staticmethod
async def broadcast_flight_start(data, ctx):
await channel_layer.group_send(
"observation.flight.start",
{
"type": "observation.flight.start",
"flight_session": ctx["flight_session"],
},
)
default_callbacks = [broadcast_flight_start]
async def handle(self, data):
# ... start the flight ...
return {"flight_session": session}
Plain EventHandler subclasses also auto-broadcast: after the handler
runs and all callbacks complete, EventEmitter._emit fans out the handler
context to the per-event Channels group. To opt out (e.g. for
request/response handlers), subclass RequestResponseEventHandler or set
broadcasts = False on the class — the response then flows back only to
the originating WS client.
Whitebox event list¶
To allow tighter integration with the system, Whitebox kernel emits certain events that are registered by the system and are always available, regardless of the set of plugins that are installed. These events are:
observation.plugin.load: Emitted when a plugin is loadedobservation.plugin.unload: Emitted when a plugin is unloadedobservation.websocket.connect: Emitted when a WebSocket connection is established
Handling shutdowns¶
Not all code is equal - some of it can safely be interrupted whenever, but some of it is not as resilient to abrupt interrupts. Some of those interrupts have to happen when Whitebox needs to shutdown.
Whitebox provides a ShutdownGuard utility, allowing your code to be notified
when a shutdown is initiated (and imminent), and delaying it up to a small time
period so that any state-sensitive operations can clean up properly before the
device actually shuts down.
ShutdownGuard has two states - armed, and disarmed. Any code operating in a
state-sensitive manner should be run under the armed state. For example,
recording a live video to the HLS format should be guarded, so that when a
shutdown is announced, the HLS files can be finalized so that they can be
properly playable later.
When a shutdown is initiated, coordinating mechanism will communicate with all
ShutdownGuard instances across all the running processes, checking whether
they are signalling that they are ready to shutdown immediately or not. As soon
as all of them acknowledge that they are disarmed, shutdown will commence.
You can use the ShutdownGuard in two ways - as a context manager, or by
explicitly arming and disarming the guard:
from whitebox.runtime import ShutdownGuard
guard = ShutdownGuard("name-of-your-service--unique-across-the-codebase")
# Can either be used as a context manager
with guard:
state_sensitive_code()
# or can be used with granularly controlled start/end points
guard.arm()
state_sensitive_code()
guard.disarm()
Your code has two options to know whether a shutdown is imminent:
-
check periodically
-
register a callback that'll notify you when shutdown is initiated
import threading is_shutting_down = threading.Event() def on_shutdown_callback(shutdown_type): if shutdown_type == "reboot": print("We'll be back shortly!") else: print("Landing gear deployed, brace for shutdown") is_shutting_down.set() with guard: while True: # do stuff if is_shutting_down.is_set(): # clean up break
The above examples show a basic example of how a shutdown preparation could
work. With the context manager approach, as soon as code exits the with block,
shutdown coordination mechanism will be notified that this context is ready to
be shutdown. Similarly, if you're using arm() and disarm(), in case a shutdown
is imminent, as soon as you disarm() the guard, coordination mechanism will be
notified.
Note that disarming in shutdown scenarios will not immediately stop the running process - instead, all of the Whitebox processes will be shutdown at the same time when all of them are ready (or the grace period expires).
If needed, you can use multiple ShutdownGuard instances within the same process,
or register multiple callbacks within one guard. Each instance handles
communication with the coordination mechanism in its own separate thread, in
which the callbacks will be executed in (which is why threading.Event was used
in the example above).
If using multiple instances, please ensure that all of them have unique service name, or they will interfere with one another and potentially cause you data loss!
Signaling liveness across processes¶
In multi-process Whitebox setups - for example, a worker pool plus one or more long-running daemons in different containers - the worker and the daemon can disagree on whether a given record is "in progress" or "stuck". The worker thinks it is still mutating the resource; the daemon thinks no progress has been made in a while and may decide to finalize or abort it.
Whitebox provides a whitebox.utils.heartbeat utility, allowing a worker to
publish a Redis-backed liveness signal that a separate process can read. This
works in the opposite direction to ShutdownGuard: where ShutdownGuard
notifies YOUR code of an incoming shutdown so it can clean up its own state,
heartbeat notifies ANOTHER process that your code is still doing work, so it
can hold off on recovery.
The signal is a Redis key with a TTL. A background thread refreshes it for the duration of the work, even when the producer's main thread is busy - for example, blocked on a long-running ffmpeg process. If the producer process dies, the key expires on its own and the recovery code proceeds with its cleanup, so a dead worker never blocks recovery indefinitely.
You can use it as a context manager:
from whitebox.utils.heartbeat import heartbeat
with heartbeat("my-service:42:heartbeat"):
state_sensitive_code()
The with block acquires the signal on enter, refreshes it on a background
thread, and releases it on exit - either normally or via exception. If another
process already holds the signal, the context manager raises
HeartbeatAlreadyHeld, so a double-worker scenario (e.g. RQ enqueuing the same
task twice) fails loudly instead of silently racing.
Recovery code can use the matching skip_if_heartbeat_held to short-circuit
when the worker is still alive:
from whitebox.utils.heartbeat import skip_if_heartbeat_held
if skip_if_heartbeat_held(
"my-service:42:heartbeat",
reason="recording worker is still active",
):
return # a worker is still alive, don't finalize this resource
The check is fail-open: if Redis is unavailable, it returns False and logs a
warning, so recovery proceeds. This is the safe default for a liveness signal -
it is better to risk cleaning up a still-live worker than to stall recovery on
a Redis blip.
The TTL should comfortably exceed the producer's expected restart time so that
a transient blip doesn't cause the consumer to skip cleanup of a still-live
resource. A refresh interval of about ttl_seconds / 4 gives at least a 4x
margin against a single missed refresh.
Note that the heartbeat is a liveness signal, not a correctness lock. Multiple workers claiming the same key is a bug, not a feature - concurrent writers should coordinate upstream (for example, via RQ's queue semantics).
Understanding the Plugin System¶
Whitebox employs a dynamic plugin discovery and management system. The process of loading and unloading plugins is as follows:
- On startup, the system scans the environment for installed plugins with the
whitebox_plugin_prefix. - Each discovered plugin is instantiated and registered with the system.
- Plugin resources (templates, static files) are registered with Django's asset pipeline.
- Events registered by plugins are added to the event system.
- Device classes available in the plugin are registered with the device manager.
- Packaged frontend artifacts are validated and registered for the frontend. Frontend validation failures are reported separately from backend registration.
- Removing a plugin package and calling
/plugins/refresh/unloads its Whitebox resources and callbacks. Django apps cannot be unloaded in-process; removing Django or hybrid plugins requires a worker restart to finish unloading.
/plugins/refresh/ requires POST with Django's CSRF cookie and token (available
from the existing admin login page; logging in is not required). It reports
frontend validation failures as HTTP 409 with plugin-qualified diagnostics, and
backend refresh failures as HTTP 500 rather than success. Partial registrations
are retained on failure; incomplete loads/unloads keep reporting that a restart
is required. A concurrent refresh in the same process returns HTTP 409 with
plugin_refresh_busy and can be retried later. Ordinary read-only plugin APIs
remain available while refresh runs. The endpoint rescans the environment
and revalidates the frontend artifact index in that process. Installed frontend
units must remain immutable between
refreshes. Restart all workers when deploying replacement packages; arbitrary
live replacement across workers is not supported.
Plugin Development Workflow¶
Plugins use Poetry-format package metadata and should adhere to the structure outlined in the Plugin Structure section. poetry new is one way to initialize it; the first-plugin exercise writes that metadata directly. The plugin should implement the base plugin class provided by Whitebox and export it as plugin_class as outlined in the Plugin API section. The plugin can then interact with the system using the Standard API provided by Whitebox.
To set up a development environment for a plugin, follow these steps:
Run host-side make dev-* and ./bin/dev-compose commands in this guide from
the Whitebox repository root.
- Prepare the matching Whitebox development images and containers as in the development guide.
- Create or check out the plugin at host
plugins/whitebox-plugin-<name>with the plugin structure. Backend-dev sees it at/plugins/whitebox-plugin-<name>, not/app/plugins/...;/appis backend. Use authored source/build inputs, not a copy of the tutorial's.venv,node_modules,dist,extractedor generatedfrontend/output. - For the editable path in step 4, with Python dependencies and the build backend provisioned, explicitly install the intended plugin for Python edits as well as JSX/CSS edits. Skip this editable install for regular-wheel testing:
./bin/dev-compose exec backend-dev poetry run python -m pip install --no-deps -e /plugins/whitebox-plugin-<name>
--no-deps does not install missing requirements. The installed version must
match the source. Startup leaves regular image wheels intact, and recreation
loses manual installs. A group-wide poetry install --with plugins-temporary
can rebind other directory-based plugins too: select every resulting frontend
editable or reinstall those not being edited as regular wheels. Keep development
paths out of production dependencies; inspect Poetry changes before publication.
4. For a frontend plugin, establish artifacts before starting/restarting
Django and reloading the browser. Choose one supported development path:
- For regular packaged resources, follow
images with local dependencies
after updating the lock, or install your built wheel. Do not install editable
for this path. The image installs regular wheels and assembles their remotes
before asset downloads. For a manually installed wheel, run
make prepare-frontend-tools build_federation_modules from /app too.
- For live JSX/CSS, including a new frontend plugin not in that image selection,
follow editable session prerequisites and commands,
substituting your installed distribution name in the explicit selection.
Keep the session running, wait for READY, then start/restart Django with
WHITEBOX_PLUGIN_DEV=1 and the core Vite server. Select multiple plugins in
the same command when editing them together. Others must remain regular
wheels. The session prepares only selected private dependencies; installing
Python metadata alone, or merely setting the opt-in flag, is insufficient.
A genuine backend-only plugin (no jsx/, frontend output or actual frontend
mapping values) needs neither frontend path. For a small frontend exercise,
the Counter tutorial's build/install and display steps
show the regular-wheel path without requiring the optional editable session.
Startup validates rather than compiles; a rejected frontend editable blocks the
whole dev UI, not just the edited plugin. Sessions
are development-only, not production fallbacks; stale generated source assets are refused.
Availability still needs a consumer or slot: the tutorial's display step
can also display an admitted editable Counter. Stopping a session does not revert
installation. To return to wheels, explicitly reinstall the built package or
recreate the prepared dev container, explicitly assemble any changed regular
installations, restart Django with development resolution
off, and reload. See the enter/leave commands
for wheel provenance, partial selection and old-session recovery.
Handling Version Conflicts for Local Plugins¶
In some rare cases, you might encounter an error when trying to add a local version of a plugin that is behind the pinned version in Whitebox's pyproject.toml. For example:
$ ./bin/dev-compose exec backend-dev poetry add -e /plugins/whitebox-plugin-device-insta360
Updating dependencies
Resolving dependencies...
Incompatible constraints in requirements of whitebox (0.1.55):
whitebox-plugin-device-insta360 @ file:///plugins/whitebox-plugin-device-insta360 (0.1.6)
whitebox-plugin-device-insta360 (>=0.1.7,<0.2.0)
This happens because the local version of the plugin (e.g., 0.1.6) is older than the version pinned in Whitebox's pyproject.toml (e.g., >=0.1.7,<0.2.0). This can occur if:
- You branched off an older version of the plugin.
- Someone merged a newer version of the plugin into the main branch.
- You pulled the latest changes from Whitebox's main branch, but your local plugin branch is still behind.
To resolve this issue, you need to update your local plugin branch to match the pinned version in Whitebox's pyproject.toml. Follow these steps:
-
Pull the Latest Changes for the Plugin: Navigate to the plugin's directory and pull the latest changes from the main branch:
-
Ensure the Plugin Version Matches: Check the version of the plugin in its
pyproject.tomlfile and ensure it matches or exceeds the pinned version in Whitebox'spyproject.toml. For example: -
Re-add the Plugin: After updating the plugin, re-add it to Whitebox:
The poetry add -e example changes dependency metadata and installs editable code;
it does not establish frontend admission. For frontend edits, select that owner
in a ready editable session, keeping
other frontends regular. For packaged testing, install its built wheel instead.
Inspect local dependency changes before publication.
Testing plugins on CI environment (including sandbox)¶
To include the plugin during CI runs, you need to add the plugin to the
whitebox project as a dependency via specific git ref, which means that you'd
have to, after testing, replace that dependency with the actual plugin version
before merging.
As this is mundane and prone to human error, the CI environment allows you to
add a "temporary" dependency that will be used in every CI step where whitebox
is being installed. This is done through the poetry's optional plugins-temporary
dependency group.
This mechanism will install those dependencies to use for testing and sandbox deploys, and they will be removed by the CI upon merge.
To add a plugin to the temporary group, you can run:
poetry add --group plugins-temporary git+https://gitlab.com/whitebox-aero/whitebox-plugin-name.git#feature/whitebox-1337
This will add the plugin by git repository and branch. To test that everything works well, you can run:
Take note that, when you include --with plugins-temporary, those dependencies
will take precedence over the ones defined in the standard groups. That means
that you can freely add the plugin to the plugins-temporary group with a
specific git ref, without needing to remove it from the standard groups.
Similar to plugins-temporary, Whitebox also provides a plugins-noncore group. This group includes other official plugins that are not installed by default, but are still part of the official Whitebox plugin ecosystem.
Backend CI includes core and noncore Python plugin tests. This does not select every frontend owner or browser scenario. For frontend job responsibilities, see the development guide.
To include noncore plugins in your backend test environment, use:
The optional plugins-demo group is separate.
For coordinated core/plugin changes, use the core branch selection described in the publication/check guide. The frontend template include and job's core checkout must select the same compatible branch.
Additionally, as the plugins-temporary group works only within the kernel
and does not support transient behavior (plugin's plugins-temporary are
not taken into account), in case a plugin branch depends on another plugin's branch
you can specify both of their branches in the kernel's plugins-temporary,
and use the coordinated KERNEL: selection to ensure they are both pulled and tested
within the CI runs.
Frontend runtime helpers¶
Plugins primarily extend the frontend through JSX components, as described in
the JSX API section. In addition to that, plugins have access to a
small set of runtime helpers on the global Whitebox object for interacting
with the core at runtime.
Sockets¶
Whitebox ensures each named socket is only connected once, and all callers
requiring the same socket share the same connection. The frontend talks to
the kernel over the squawk socket (/ws/squawk/), which exposes a
per-event subscription model — clients send command.session.event.subscribe
frames to opt in to specific events, and the kernel fans out matching
backend emissions to them.
Use the Whitebox.sockets helpers to send events and subscribe to events
on the squawk socket:
// Subscribe to a specific event (preferred — fewer frames to parse):
const unsubscribe = Whitebox.sockets.subscribe(
"squawk",
"observation.location.update",
(data) => {
console.log('We are now located at ', data.lat, data.lon);
}
);
// ...later, on cleanup:
unsubscribe();
// Or subscribe to every event on the socket (broad wildcard):
Whitebox.sockets.subscribe("squawk", "*", (data) => {
if (data.type === "observation.location.update") { /* ... */ }
});
// Send commands back to the kernel:
Whitebox.sockets.send("squawk", { type: "get_location" });
The subscribe(socketName, eventName, callback) helper is the modern API.
The legacy addEventListener(socketName, 'message', ...) form is also
available, but it forces every subscriber to filter every frame client-side.
subscribe opens the socket on demand, is idempotent on duplicate calls,
and replays active subscriptions automatically after a reconnect.
eventName may be a registered event name (e.g. "observation.flight.start")
or the wildcard "*". The wildcard is matched purely on the frontend side;
it is never sent to the backend, so it does not collide with the
channels-redis group-name regex.
Helper utils¶
Additionally, Whitebox provides a set of helper utilities that plugins can use:
Whitebox.apiUrl(string): URL to the Whitebox API
Shared assets¶
Some plugins may require some common assets, such as images, stylesheets, or
JavaScript files. It can be useful to use a dedicated plugin, allowing multiple
plugins to share the same common asset. For example, the
whitebox-plugin-leaflet
plugin provides the Leaflet library (JavaScript and CSS), which, using the
Plugin.get_bootstrap_assets
API, exposes it to be loaded immediately on page load, allowing any plugin to
use Leaflet without having to load it separately.
Required bootstrap scripts gate the whole application router, not only their dependent widgets. Failure or timeout shows the bootstrap error surface without mounting routed UI until page reload. See bootstrap ordering and the batch deadline; this is separate from federation admission.
External assets¶
Large static assets¶
In some cases, plugins may require packaging of large assets. As PyPI imposes a limit of 100MB for any packages it hosts, Whitebox offers a way for plugins to depend on externally hosted assets, which can, from runtime's perspective, be considered as a part of the package itself.
Plugin loading can schedule downloads for missing or invalid external assets;
offline startup cannot repair them. Download and verify them during
connected preparation.
To specify external files, create a file in the plugin's root
directory called external-asset-manifest.json, for example:
whitebox_plugin_<plugin_name>
├── whitebox_plugin_<plugin_name>
│ ├── __init__.py
│ ├── whitebox_plugin_<plugin_name>.py
│ └── external-asset-manifest.json <--- this one
├── pyproject.toml
Every external file needs to have 3 components:
-
URL from which it'll be sourced from
-
Integrity hash
Every file must have an integrity hash to verify that the downloaded file is
a proper one. Within the integrity string, you should specify what hashing
algorithm is used, in format [ALGORITHM]-[INTEGRITY_HASH].
Supported hashing algorithms: sha1, sha256.
Upon plugin loading, all the files will be checked, and:
- If a file does not exist, it will be downloaded
-
If a file exists, but the file hash does not match, it will be downloaded, replacing the existing file. This behavior allows you to freely update your manifest file with new files, without worrying whether the files will be stale.
-
Target path where it will be saved locally and served from
Whitebox saves these files in a special location for asset files, which will be
available to plugins in the same manner as if they were the ordinary static files
within the plugins' static/ folder.
For example, if your plugin's package name was whitebox_plugin_r2d2, and the
target path is voices/beep-boop.mp3, the file will be available for plugin's
use at path /static/whitebox_plugin_r2d2/voices/beep-boop.mp3.
Additionally, you can freely use
{% static "whitebox_plugin_r2d2/voices/beep-boop.mp3" %}
template tags in the templates to reference these files, or alternatively use
django.templatetags.static.static("whitebox_plugin_r2d2/voices/beep-boop.mp3")
for the same purpose from within the code.
For the above example, the asset manifest file would look like this:
{
"sources": [
{
"url": "https://example.org/r2d2/asset-file.mp3",
"integrity": "sha1-8dfa2f3e56f3abd46119b698bf6a91cb18482c85",
"target_path": "voices/beep-boop.mp3"
},
... more files
]
}
To verify whether the manifest file is proper, you can use the Django's
verify_external_asset_manifest command, by providing either the installed plugin's
module name, or the path to file, e.g.
poetry run python whitebox/manage.py verify_external_asset_manifest --module-name whitebox_plugin_r2d2, orpoetry run python whitebox/manage.py verify_external_asset_manifest path/to/plugin-root/whitebox_plugin_r2d2/external_asset_manifest.json
Testing Plugins¶
Python tests use Whitebox's backend test environment. Frontend source units run independently with plugin-owned dependencies and mocks; integration tests need an assembled application. See the development guide to choose the appropriate check.
Testing Python code¶
The backend test runner will automatically discover and run all tests in the
whitebox_test_plugin_<plugin_name> package as long as they follow the naming
convention outlined in the Plugin Structure section.
Unit & Integration tests would usually begin with the following structure:
from django.test import TestCase
from plugin.manager import plugin_manager
class TestWhiteboxPluginExamplePlugin(TestCase):
def setUp(self) -> None:
self.plugin = next(
(
x
for x in plugin_manager.plugins
if x.__class__.__name__ == "WhiteboxPluginExamplePlugin"
),
None,
)
return super().setUp()
def test_plugin_loaded(self):
self.assertIsNotNone(self.plugin)
def test_plugin_name(self):
self.assertEqual(self.plugin.name, "Example Plugin")
# Add more tests here
While browser tests would usually begin with the following structure:
import os
import logging
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.urls import reverse
from playwright.sync_api import sync_playwright
# Disable warnings
logging.basicConfig(level=logging.ERROR)
logger = logging.getLogger(__name__)
logging.getLogger("django.request").setLevel(logging.ERROR)
logging.getLogger("django.server").setLevel(logging.ERROR)
class TestWhiteboxPluginExamplePluginBrowser(StaticLiveServerTestCase):
@classmethod
def setUpClass(cls):
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
super().setUpClass()
cls.playwright = sync_playwright().start()
cls.browser = cls.playwright.chromium.launch(headless=True)
cls.context = cls.browser.new_context()
cls.page = cls.context.new_page()
@classmethod
def tearDownClass(cls):
cls.page.close()
cls.context.close()
cls.browser.close()
cls.playwright.stop()
super().tearDownClass()
def setUp(self):
self.page.goto(f"{self.live_server_url}{reverse('index')}")
# Add more tests here
Sometimes, you'll want to have a base class containing certain tests that should
not be run on that very class, but only on classes that inherit it. In that
case, you can assign ____test__ = False to the base class, and the tests on
it will only run on the classes that inherit it, not on the base class itself.
For example, for some device, you might want to test connection types based on the class setup:
class BaseInsta360TestCase(TestCase):
__test__ = False
@property
def device_class(self):
raise NotImplementedError
def test_get_connection_types(self):
connection_types = self.device_class.get_connection_types()
# make some asserts here that should apply for both classes
class TestInsta360X3(BaseInsta360TestCase):
device_class = Insta360X3
# You can add more tests specific to Insta360X3 here
class TestInsta360X4(BaseInsta360TestCase):
device_class = Insta360X4
# You can add more tests specific to Insta360X4 here
Additionally to ensure browser tests run correctly, you would have to install playwright additional dependencies. To install playwright dependencies, follow the steps below:
- Run:
poetry run playwright install - Run:
poetry run playwright install-deps(optional, for Linux systems only) - Ensure the plugin is installed in Whitebox's backend interpreter. A backend-only plugin can be editable. For a frontend owner, use a regular source wheel plus explicit assembly, or select the editable owner in a ready development session; an unselected frontend editable blocks admission, even with development mode off.
Finally, you would run test on whitebox using the following command within the backend dev container:
Testing JSX code¶
Unit testing¶
Each frontend plugin owns its vitest.config.js paths/aliases and genuine private
test dependencies, with core-supplied shared defaults.
Vitest Browser Mode runs .test.js and .test.jsx source files with ordinary
transforms and Chromium; no Python import, Django server, installed sibling or
remote-entry build is needed. See the icons and UI-components configs as examples.
Tests usually begin with the following structure:
import { render, screen } from "@testing-library/react";
import { MyComponent } from "./MyComponent";
describe("MyComponent", () => {
it("does something", () => {
render(<MyComponent />);
expect(screen.getByText(/R2D2 says:/i)).toBeInTheDocument();
});
// Add more tests here
});
Shared defaults enable Vitest globals; the plugin's setup loads jest-dom and owns
its DOM/state/timer/mock cleanup. Components may read Whitebox during module evaluation: establish any
small unit-only dependency stub in setupFiles, not just beforeEach. Unknown
lookups must fail. Keep test helpers outside production expose scans, include
them in sdists, and never bundle Vitest into runtime remotes.
If your plugin defines a test-frontend Makefile target, run it from the plugin
checkout in a prepared backend dev container:
The first Counter needs no Makefile. From the core repository root, use the same source runner directly:
The command runs a single selected suite in a private checkout stage, not an
install inside the /plugins source bind. Common Chromium must already be
provisioned; follow the source-test browser prerequisite. Rerun after
edits. For private container staging and installed
runtime checks, see federation testing.
Source mocks test local behavior, not installed cross-plugin integration.
Integration testing¶
Similarly to Python code testing, federation integration tests will be
automatically discovered from the whitebox_test_plugin_<plugin_name> package.
Include whitebox_test_plugin_<plugin_name> in both the wheel and sdist. Its
federation/ directory supplies installed Playwright specs and support files.
Choose the test mode before choosing its directory:
| Scenario | Home | Run it with |
|---|---|---|
| Local component/store logic and owner doubles | Owner source .test.js/.test.jsx, tests/frontend/ setup |
make test-frontend |
| A running app's live integration flow | whitebox_test_plugin_<name>/federation/*.spec.js |
Live federation servers and explicit package selection |
| Controlled installed integration, native decode/Map/WebGL or synthetic socket races | whitebox_test_plugin_<name>/federation/application/*.spec.js |
Owner application gate |
The nested application/ directory is excluded from live/LambdaTest collection;
it uses core's controlled application setup, not live resources or hardware. Keep
feature assertions and helpers with their producer or integrated consumer. For
example, Map owns provider readiness/remount cases; flight-management owns playback
through the real Map and player; VideoJS-ThreeJS owns WHEP/orbit/FOV integration.
Core owns transport interception, bootstrap and shared-runtime contracts, and
explicitly composes installed owner helpers for the maintained App.
For a real controlled example, inspect Map's
whitebox_test_plugin_map/federation/application/native_map.spec.js
and adjacent fixtures.mjs in your matching Map checkout
(plugins/whitebox-plugin-map/ from core's root). These are owner inputs, not core
fixtures; the checkout must be present to inspect them locally.
The integration tests usually begin with the following structure:
import { test } from "@tests/setup";
import { expect } from "@playwright/test";
test.describe("Big cube with hearts", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/testing-chamber");
});
test("should relocate in space", async ({ page }) => {
const element = await page.locator("button");
expect(await element.innerText()).toBe("Relocate cube");
await element.click();
// implement logic to check if the cube has been relocated
});
// Add more tests here
});
Note that test is imported from @tests/setup, not @playwright/test, which
is required in order to run the tests properly.
That alias selects the live page fixture in live mode and the controlled
HTTP/WebSocket/readiness fixture in application mode; do not import core setup
by an absolute checkout path into an installed spec.
Follow the live integration workflow to start the servers and select installed test packages. Specs are downloaded again on each run. Selecting a package with no specs, or a failed download, is an error rather than an empty passing suite.
For a controlled scenario, use the same test import above, place the spec under
federation/application/, and run the owner gate from /app in a disposable
prepared backend container (owner source at /plugins/whitebox-plugin-NAME):
poetry run python devtools/frontend_ci.py --plugin /plugins/whitebox-plugin-NAME --output /opt/application-check
This runs source units, builds/installs the ordinary owner wheel and assembles
remotes before executing that owner's applicable installed specs plus core
bootstrap/contracts. Check the owner spec's title in the Playwright output; an
empty or missing selected spec package fails. A source-only passing run does not
exercise this path. The normal aggregate uses make test-application from core's
root; demo scenarios use its explicit --scope demo, with the optional demo
distribution installed. Application prerequisites and results
describe the prepared image, synthetic services, native tools and failure logs.
Keep browser helpers beside the owner specs as ordinary .mjs/.js files. A
consumer can import ../../whitebox_test_plugin_OTHER/application/helper.mjs;
the downloader stages installed dependency/support-only files without running
dependency specs in the owner scope. Unknown packages and failed downloads still
fail. Python resource/guard tests belong to the owner's installed test package
and run through the normal backend plugin test runner. Controlled support comes
from the same selected installation, never another checkout/base interpreter.
If a scenario needs new controlled endpoints or readiness expectations, supply
an owner helper and add its explicit composition at core's
frontend/tests/application/compose.mjs; merely shipping a helper does not call it.
For phone/responsive coverage, update the filename selections in
frontend/playwright.application.config.js (new specs otherwise run on desktop).
VideoJS packages its small native-media test plugin under
whitebox_test_plugin_videojs/media_fixture/. The controlled MP4 gate builds and
installs that ordinary test-only source using the existing fixture/assembly path;
it is absent from normal product installations. Domain fixtures do not belong in
core's generic contract Panel. Source setup may import
@whitebox-test/host-mocks for identical nonreactive store/URL mock scaffolding;
reactive/native/owner-specific doubles stay local rather than acquiring a
configurable host-mock framework.
Handling fixtures¶
__fixtures__ is a naming convention, not an automatic build exclusion.
Core assembly and editable enumeration include its JS/TS modules by
default (excludeFixtures absent or false). Set
package.json.whiteboxBuild.excludeFixtures to true for test-only fixtures.
This removes them from runtime exposes, not from input identity or sdists.
Keep fixtures needed by source tests in those sdists. guardRuntimeTestInputs
additionally rejects excluded test inputs imported into the runtime graph when
enabled; simply hiding an expose does not prevent a production import.
Fixtures for unit and integration tests are often have huge similarities, and reusing them is a good practice to avoid code duplication.
Considering that unit and integration tests files have segregated directory structure, and that Whitebox's Playwright setup does not support this separation out of the box, there is a mechanism to centralize fixtures within the unit test suite, and access them from the integration test suite.
When writing fixtures, place them within the
whitebox_plugin_<plugin_name>/jsx/__fixtures__/ directory, making them
directly available to the unit tests. The integration tests will use special
placeholder files to access the fixtures during development, while during test
runs, the original fixtures from the unit tests will be used by the test suite.
After creating fixture files in the unit tests' __fixtures__ directory,
run the following command to create the placeholder files in the integration
tests directory:
./bin/dev-compose exec backend-dev poetry run python whitebox/manage.py create_federation_redirection_placeholders PLUGIN_NAME
This will create the placeholder files within the
whitebox_test_plugin_<plugin_name>/federation/__fixtures__/ directory, which
act similarly to symlinks during development, allowing your IDE to resolve
the imported symbols correctly. Once you create a redirection fixture file,
you do not need to re-run the command again for all the existing fixture files.
Generation is restricted to editable plugins and will not overwrite authored
files. Downloads substitute only the exact source/destination pairs discovered
from the installed packages, not arbitrary paths from placeholder imports.
Playwright test and placeholder discovery reads supported installed filesystem packages without running their package initializers. Regular wheel installs and path-based editables are supported; ZIP, namespace and custom-loader packages are rejected rather than imported as a fallback.
Writing CI¶
The CI for a plugin would almost always get extended from the whitebox shared CI file. A plugin with a frontend source package uses:
image: registry.gitlab.com/whitebox-aero/whitebox/standalone/whitebox-base:0.1.1
stages:
- setup
- lint
- test
- update_version
- publish
include:
- project: "whitebox-aero/whitebox"
ref: "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"
file: ".gitlab/config/shared-ci.yml"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME != $CI_DEFAULT_BRANCH && $CI_MERGE_REQUEST_DESCRIPTION =~ /(^|\n)KERNEL: #[^\s]+(\n|$)/'
- project: "whitebox-aero/whitebox"
ref: "main"
file: ".gitlab/config/shared-ci.yml"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME != $CI_DEFAULT_BRANCH && $CI_MERGE_REQUEST_DESCRIPTION =~ /(^|\n)KERNEL: #[^\s]+(\n|$)/'
when: never
- when: always
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
POETRY_HOME: "$CI_PROJECT_DIR/.poetry"
cache:
paths:
- .pip-cache/
- .poetry/
- .venv/
run_setup:
extends: .shared_plugin_setup
run_lint:
extends: .shared_plugin_lint
run_test_python:
extends: .shared_plugin_test_python
run_frontend_source:
extends: .shared_plugin_frontend_source
run_frontend_application:
extends: .shared_plugin_frontend_application
update_version:
extends: .shared_plugin_update_version
publish:
extends: .shared_plugin_publish
dependencies: [update_version]
Backend-only plugins omit the two frontend jobs. All owners publish ordinary Python
packages from the exact SHA supplied by the version stage; packaging never runs
JavaScript. The frontend application gate installs source wheels and assembles
them through the selected compatible core. See the
publication boundaries and release order,
including the KERNEL: #<branch> coordinated opt-in, normal main selection and
runtime/source-branch agreement. Missing explicitly selected core refs fail;
ordinary branches do not probe for a matching core ref.
Versioning Plugins¶
Whitebox uses Semantic Versioning for versioning plugins.
In the CI file above, the update_version stage is responsible for updating the
version of the plugin.
When a merge request is merged to main, first, patch version will be bumped in the
pyproject.toml file. A commit will be made with the new version, along with a new tag,
which the CI will push to the repository. After that, the plugin will be published,
as outlined below.
It is important to ensure that the version is incrementally updated, so that the plugin can be published correctly to PyPI.
Automatic versioning setup¶
The following steps apply for setting up automatic versioning on GitLab,
orchestrated by .gitlab-ci.yml.
-
Set up an
Access Tokenthat CI will use to update the version -
Go to your repository's settings on Gitlab:
Settings > Access Tokens - Create a new token with the following settings:
- Name: name that you want to appear as the committer (e.g.
Whitebox CI) - Role:
Maintainer - Scopes:
read_repository,write_repository
- Name: name that you want to appear as the committer (e.g.
-
Copy the token, as it won't be displayed again
-
Add the token to your repository's CI/CD settings
-
Go to your repository's settings on Gitlab:
Settings > CI/CD - Open
Variables -
Add a new variable with the following settings:
- Type:
Variable - Environment scope:
All - Visibility:
Masked - Key:
PUSH_TOKEN - Value: the token you copied
- Type:
-
Ensure that the
update_versionstage is set up in your.gitlab-ci.yml -
The stage should include the following job:
After this is set up, the CI will automatically update the version of the plugin
when a merge request is merged to the default branch (main). This version will
automatically be used when the plugin is published.
Publishing Plugins¶
Frontend plugins use the destination-aware Poetry and poetry-core forks. Use the existing pinned backend when building; see the frontend package contract.
-
Initial Setup for New Plugins/Repositories:
- If the plugin is new and does not have a PyPI project, you need to create it using your PyPI account.
- First validate ordinary source packages and, for frontend owners, the source/application gates against a compatible core. Resolve the provider/consumer ordering before publishing incompatible coordinated changes. There is no frontend PEP 517 adapter or prebuilt-archive publication workflow.
-
With publication approval and PyPI credentials, perform the initial publish using the plugin's declared Python build requirements:
-
This should be done from your local machine to automatically create the package on PyPI.
- Once the project is set up on PyPI, add
antoviaqueas an owner to the project to share access and management.
-
Setting Up PyPI Access Tokens for CI:
- Create a new access token on your PyPI account. This token should be scoped specifically for the project to limit permissions effectively.
- Add this project-scoped token to your CI environment configuration to
enable automated publishing for future releases, similarly to the
PUSH_TOKENsetup above: - Type:
Variable - Environment scope:
All - Visibility:
Masked - Key:
PYPI_TOKEN - Value: the token you created
-
For Existing PyPI Projects:
- If there is already a PyPI project for the repository, you will need to request access from one of the current maintainers. You can find the maintainers listed on the project’s page on the PyPI website.
Django App Plugins¶
Whitebox now supports Django app plugins, which allow you to create plugins that leverage Django's native app system. This provides enhanced integration with Django's features like models, admin interface, middleware, and URL routing.
Entry Point Method¶
Django app plugins are discovered using Python entry points. To create a Django app plugin, follow these steps:
- Create a Django App: Structure your plugin as a standard Django app:
whitebox_plugin_<plugin_name>
├── whitebox_plugin_<plugin_name>
│ ├── __init__.py
│ ├── apps.py
│ ├── models.py
│ ├── views.py
│ ├── urls.py
│ ├── admin.py
│ ├── static/
│ │ └── whitebox_plugin_<plugin_name>/
│ │ ├── style.css
│ │ └── script.js
│ └── templates/
│ └── whitebox_plugin_<plugin_name>/
│ └── template.html
├── whitebox_test_plugin_<plugin_name>
│ ├── __init__.py
│ └── test_*.py
├── pyproject.toml
└── README.md
- Configure Entry Points: In your
pyproject.toml, add an entry point under thewhitebox.plugingroup:
[tool.poetry.plugins."whitebox.plugin"]
whitebox_plugin_<plugin_name> = "whitebox_plugin_<plugin_name>.apps:WhiteboxPlugin<PluginName>Config"
- Create AppConfig: In your
apps.pyfile, create an AppConfig class:
from django.apps import AppConfig
class WhiteboxPlugin<PluginName>Config(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'whitebox_plugin_<plugin_name>'
verbose_name = 'Whitebox Plugin <Plugin Name>'
def ready(self):
pass
- URL Configuration: If your plugin needs URLs, create a
urls.pyfile:
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
path('api/data/', views.api_data, name='api_data'),
]
The plugin URLs will be automatically included under the path plugin-views/<plugin_name>/ when the plugin is loaded.
- Models and Database: Define Django models as usual in
models.py:
from django.db import models
class RandomModel(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = 'Random'
verbose_name_plural = 'Randoms'
def __str__(self):
return self.name
Django App Plugin Features¶
Django app plugins provide full access to Django's ecosystem:
- Database Models: Define models and use Django's ORM
- Admin Interface: Register models in Django admin
- URL Routing: Define custom URL patterns (automatically included)
- Middleware: Add custom middleware
- Template System: Use Django's template system
- Static Files: Serve static assets
- Management Commands: Create custom management commands
- Signals: Use Django's signal system
- Migrations: Automatic database schema management
Example Django App Plugin¶
Based on the whitebox-plugin-wip-194 example:
# pyproject.toml
[tool.poetry.plugins."whitebox.plugin"]
whitebox_plugin_wip_194 = "whitebox_plugin_wip_194.apps:WhiteboxPluginWip194Config"
# apps.py
from django.apps import AppConfig
class WhiteboxPluginWip194Config(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'whitebox_plugin_wip_194'
verbose_name = 'Whitebox Plugin WIP 194'
def ready(self):
pass
# models.py
from django.db import models
class RandomModel(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = 'Random'
verbose_name_plural = 'Randoms'
def __str__(self):
return self.name
# views.py
from django.views.generic import ListView, DetailView
from .models import RandomModel
class RandomModelListView(ListView):
model = RandomModel
template_name = 'whitebox_plugin_wip_194/random_model_list.html'
context_object_name = 'randoms'
class RandomModelDetailView(DetailView):
model = RandomModel
template_name = 'whitebox_plugin_wip_194/random_model_detail.html'
context_object_name = 'random'
# urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.RandomModelListView.as_view(), name='list'),
path('<int:pk>/', views.RandomModelDetailView.as_view(), name='detail'),
]
Hybrid Plugins¶
Hybrid plugins combine both Whitebox plugin functionality and Django app capabilities. This is useful when you need both the Whitebox plugin API and Django's native features.
Creating Hybrid Plugins¶
A hybrid plugin is automatically detected when both plugin types are present:
- Whitebox Plugin Components: Include the traditional Whitebox plugin structure with
plugin_class - Django App Components: Include Django app structure with entry points
Hybrid Plugin Structure¶
whitebox_plugin_<plugin_name>
├── whitebox_plugin_<plugin_name>
│ ├── __init__.py
│ ├── apps.py # Django app config
│ ├── models.py # Django models
│ ├── views.py # Django views
│ ├── urls.py # Django URLs
│ ├── admin.py # Django admin
│ ├── whitebox_plugin_<plugin_name>.py # Whitebox plugin class
│ ├── static/
│ │ └── whitebox_plugin_<plugin_name>/
│ │ ├── style.css
│ │ └── script.js
│ ├── templates/
│ │ └── whitebox_plugin_<plugin_name>/
│ │ └── template.html
│ └── jsx/
│ └── components/
│ └── MyComponent.jsx
├── whitebox_test_plugin_<plugin_name>
│ ├── __init__.py
│ └── test_*.py
├── pyproject.toml
└── README.md
Hybrid Plugin Configuration¶
In pyproject.toml, include the entry point for Django app discovery:
[tool.poetry.plugins."whitebox.plugin"]
whitebox_plugin_<plugin_name> = "whitebox_plugin_<plugin_name>.apps:WhiteboxPlugin<PluginName>Config"
Example Hybrid Plugin¶
Based on the whitebox-plugin-wip-194-hybrid example:
# pyproject.toml
[tool.poetry.plugins."whitebox.plugin"]
whitebox_plugin_wip_194_hybrid = "whitebox_plugin_wip_194_hybrid.apps:WhiteboxPluginWip194HybridConfig"
# apps.py - Django app configuration
from django.apps import AppConfig
class WhiteboxPluginWip194HybridConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'whitebox_plugin_wip_194_hybrid'
verbose_name = 'Whitebox Plugin WIP 194 Hybrid'
def ready(self):
pass
# whitebox_plugin_wip_194_hybrid.py - Whitebox plugin class
from whitebox.plugins import Plugin
class WhiteboxPluginWip194Hybrid(Plugin):
name = "WIP 194 Hybrid"
provides_capabilities = ["nothing"]
exposed_component_map = {
"nothing": {"nothing": "nothing/Nothing"},
}
plugin_class = WhiteboxPluginWip194Hybrid
# models.py - Django models
from django.db import models
class RandomModel(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = 'Random'
verbose_name_plural = 'Randoms'
def __str__(self):
return self.name
# jsx/nothing/Nothing.jsx - React component
const Nothing = () => {
return <div>Nothing to see here!</div>;
};
export { Nothing };
export default Nothing;
Benefits of Hybrid Plugins¶
- Full Django Integration: Access to Django's ORM, admin, middleware, etc.
- Whitebox Plugin API: Use Whitebox-specific features like event system and device management
- JSX Support: Include React components with module federation
- Database Models: Persistent data storage with Django models
- URL Routing: Custom API endpoints and views
- Admin Interface: Manage plugin data through Django admin
- Capabilities System: Provide and consume capabilities from other plugins
Plugin Type Detection¶
The plugin manager automatically detects plugin types:
- Whitebox Plugin: Has
whitebox_plugin_<name>.pywithplugin_class - Django App Plugin: Has entry point in
whitebox.plugingroup - Hybrid Plugin: Has both Whitebox plugin components and Django app entry point
Migration Management¶
Django app plugins support automatic migrations:
# Create migrations for your plugin automatically
make makemigrations
# Apply migrations
make migrate
Note¶
When installing django app plugins or hybrid plugins as editable install (-e), the changes in the plugin code won't be reflected automatically like it is with regular Whitebox plugins. You will need to reinstall the plugin for it to take effect.
Next Steps¶
- Learn more about how to use APIs available to plugins in the Plugin API Reference
- Learn more about the Whitebox Architecture
- Check out the Development Guide
- Read more about the rationale behind the plugin architecture in the WIP 0002 - Plugin Architecture Implementation Proposal