Skip to content

[WIP 0002] Whitebox Plugin Architecture Implementation Proposal

Introduction

This document proposes an implementation of the Whitebox Plugin Architecture.

Whitebox plugins are the augmentation of the core system, allowing developers to create plugins that introduce new functionality, or augment the existing functionality.

Plugins, even if not part of the core itself, should be considered as first-class citizens in the Whitebox ecosystem. This means that plugins should be able to interact with the core system in a way that is as seamless as possible.

Lexicon

  • Kernel: The plugin system of Whitebox.

    As Whitebox is highly modular, the plugin system is the very kernel that other functionalities are built upon.

    It is responsible for managing plugins (installing, uninstalling, enabling, disabling), managing JSX transpilation, and providing a plugin API for plugins to interact with.

  • Core: The core system of Whitebox is just a base set of "core plugins" that are necessary for whitebox to function.

    It provides additional layers that plugins may use, like the network & event layer, as well as the core plugins, which define the base functionalities of Whitebox, such as the map, traffic, location, etc.

  • Base installation: Kernel + core + all plugins included by default with a new installation of Whitebox.

  • Official plugins: All plugins that Whitebox maintainers support. Some of them are installed by default, others are available for installation.

  • Third party plugins: Plugins published by a third party that might be supporting and maintaining them. They are not supported or maintained by Whitebox maintainers.

Goals

The goals of the Whitebox Plugin Architecture are:

  • Allow Whitebox to be extended with new functionality without modifying the core
  • Allow plugins to augment other plugins, or the core system, in a way that is seamless, user-friendly and developer-friendly.
  • Allow plugins to be distributed, installed, uninstalled, enabled and disabled
  • Allow plugins to immediately after first installation, work without an internet connection

Problems we're trying to solve

  • How to allow plugins to augment the core system & other plugins

As frontend and backend are written in different technologies (React and Django), frontend augmentation is a bit more challenging than backend augmentation.

  • How to allow plugin distribution and management

Plugins should be able to be distributed, installed, uninstalled, enabled and disabled in a way that is developer-friendly. Some plugins (like the core ones defining critical functionality) should not be able to be uninstalled or disabled, only augmented or replaced on UX side.

  • How to allow plugins to have full autonomy over their own functionality

Plugins should be able to have full autonomy over their own functionality, without having to rely on the core system for anything other than the plugin architecture, and the network & event layer. This includes the ability to define their own database models, external services, etc.

Proposed Implementation

Plugins

Plugins are the augmentation of the kernel system, allowing developers to introduce new functionality, or augment the existing functionality. They are distributed as Python packages, and are installable into the Whitebox Python environment.

Plugins consist of:

  • Django app code, defining database models, migrations, template tags, etc.
  • React components, providing frontend augmentation
  • pyproject.toml packaged file, which may contain:
    • operational information
    • static assets - these are the static assets bundled with the plugin
    • external static assets - these are the external static assets required by the plugin to work. These are an integral part of the plugin, and the plugin will refuse to load if they are not available. For example, this can be a video file required for the device wizard to work
    • external dynamic assets - these are the external files that are subject to change, and they may or may not be required for the plugin to load. For example, this can be a list of available map tiles that the plugin may provide to the map, if/when downloaded

Plugins define and handle events, and may interact with the kernel system through the network & event layer. Kernel also sends events to plugins.

Kernel provides the following events to plugins:

  • on_install - Triggered when the plugin is installed
  • on_uninstall - Triggered when the plugin is uninstalled
  • on_enable - Triggered when the plugin is enabled
  • on_disable - Triggered when the plugin is disabled
  • on_boot - Triggered when the Whitebox instance boots
  • on_shutdown - Triggered when the Whitebox instance shuts down

Bridging the gap between frontend and backend

To allow plugins from backend (Python) to provide React components (JavaScript/JSX) to frontend, we need a way for React to load these components dynamically. For this functionality, we use vite-plugin-federation, which allows us to load React components from different sources. Considering that Python cannot transpile JSX directly, we need to invoke a Node.js process to transpile JSX to JavaScript, to be used in the frontend app.

Here's a sketch of the proposed architecture:

JSX architecture

Prior to this document, we have experimented with various approaches of augmenting the frontend:

  • iframes - This approach was quickly discarded as it showed limitations in terms of level of effort required to make it work, and the augmentation possibilities were very limited.

  • rendering plugins' templates - This approach was also discarded as meant that React app would not have any control over the plugins' UI, and the plugins would not be able to interact with the core system in a way that is seamless.

  • React components - This approach is the most promising, as it allows plugins to augment the core system in a way that is seamless and developer-friendly. This section outlines this implementation.

Frontend augmentation

Architecture

Core's React app is statically built and distributed, as part of the Whitebox core. It is then served over web browser to the user.

Backend collects all React components from available plugins, and invokes a Node.js process to transpile JSX to JavaScript, using module federation to make these components available to the core React app.

Frontend then loads these components dynamically, and renders them where appropriate through a slotting mechanism.

Capabilities

To allow for feature-based designation & lookup of plugins and their components, plugins have capabilities that are used to designate what components they provide. These capabilities are used to look up components that are provided by plugins, render them where appropriate, augment them as needed, and declare dependencies over specific features.

Example capabilities:

  • map: Provides map rendering
  • traffic: Provides traffic information
  • location: Provides location information
  • map-tiles: Provides map tiles to be used offline in the maps

As the capabilities are not defined by the core system, plugins define their own capabilities, and use them to augment each other.

Some capabilities, like map, are required by the core system, and are provided by the default plugins. Third-party plugins may use the API and slots provided by these plugins to augment existing functionality or provide additional features like map tiles or points of interest.

A plugin can provide multiple capabilities. In case of multiple plugins providing the same capability, the core system treats them as conflicting and does not load them simultaneously. If a plugin wants to replace a capability, it replaces the entire plugin that provides that capability. If a plugin wants to augment another plugin, it uses the APIs and slots provided by that plugin.

This simplified approach preserves flexibility for future implementation of more sophisticated augmentation mechanisms while maintaining a clear separation of responsibilities among plugins.

Slotting

To allow plugins to augment the app, we are using a slotting mechanism. Slots are defined by the core app, as well as by plugins.

For example, on the dashboard, we'll have a slot for the map display. The core will, inside this slot, render the component that a designated plugin with map capability with map.display component definition provides. A plugin may technically provide a map component that is not an actual map, illustrating that the core system should not make any assumptions about the components provided by plugins.

An example of a slot usage, rendering a map component, within a dashboard:

const Dashboard = () => (
  <div>
    <h1>Dashboard</h1>
    <SlotLoader name="map.display" />
  </div>
);

This will render the component that a plugin with the map capability provides for display.

Import of components

To allow plugins to import components from other plugins, we are using an importWhiteboxComponent function, which allows you to import a component by a defined slot name, or by the name that the plugin that offers it exposed it by.

For example, say you have a plugin that provides a default map view by map.display, but it may also offer some other components, like a map with some specific layers included, which was exposed as map.LayeredMap.

Plugins use the function-based import to import these components by using importWhiteboxComponent function, and then use it like this:

const MapDisplay = importWhiteboxComponent("map.display");
const LayeredMap = importWhiteboxComponent("map.LayeredMap");

const TwoMapsHere = () => (
  <div>
    <MapDisplay />
    <LayeredMap />
  </div>
);
Direct component access

Plugins need methods to access and render components from other plugins. Whitebox provides two primary mechanisms for this, both using capabilities as the namespace instead of direct plugin references.

The primary method for importing components from other plugins is through the importWhiteboxComponent function, which allows you to import a component by its capability namespace:

const MapDisplay = importWhiteboxComponent("map.display");
const TrafficOverlay = importWhiteboxComponent("traffic.overlay");

const Map = () => (
  <div className="map-container">
    <MapDisplay />
    <TrafficOverlay />
  </div>
);

This approach maintains clean separation between plugins, as the importing plugin depends only on the capability, not on the specific plugin that provides it.

Plugins can also use the WhiteboxComponent component, which accepts a capability reference via the __capability prop:

const Map = () => (
  <div className="map-container">
    <WhiteboxComponent __capability="map.display" >
    <WhiteboxComponent __capability="traffic.overlay" />
  </div>
)

Plugins can also use the __component prop with the WhiteboxComponent component to import components from the kernel:

const KernelComponent = importWhiteboxComponent("DeviceConnection");

// Or directly
<WhiteboxComponent __component="DeviceConnection" deviceName="iPad" />

This pattern should be avoided for new development as all kernel functionality is being migrated to the plugins and access to direct kernel components will be depricated. When components are migrated from the kernel to plugins, they will be accessible through capability namespaces rather than direct references.

Frontend plugin API

To allow plugins to interact with the core system, core provides a plugin API. This API allows plugins to interact with the core system in a way that is seamless and developer-friendly.

The plugin API provides methods for registering:

  • capabilities
  • components
  • dependencies
  • slots
  • slot container components

Upon frontend loading, the core system collects all capabilities, components, and dependencies from backend (plugins), and renders them where appropriate.

Plugins can register both individual slots and multiple components for each capability. Slots maintain a 1:1 relationship with components, while slot containers allow for an n:1 relationship.

For example, a plugin can register multiple slots for a single capability:

class MyPlugin(whitebox.Plugin):
    ...
    provides_capabilities = ["map"]
    slot_component_map = {
        "map.display": "my_plugin/MyMapComponent",
        "map.side-display": "my_plugin/MySideMapComponent",
    }
    exposed_component_map = {
        "map": {
            "SpecificMapDisplay": "my_plugin/MyMapComponent",
        }
    }

In this example, the plugin provides a "map" capability with two different slots: "map.display" and "map.side-display", each associated with a different component.

Slots can be directly invoked within JSX components, allowing plugin components to define where other components should be rendered:

const PluginComponent = () => {
  // Do something

  return (
    <>
      <some-element />
      <more-element />
      <Slot capability="actually-invoked-slot-usage" />
      <yet-another-element />
    </>
  );
};

This allows plugins to create composite views where specific functionality can be inserted at predefined locations, while maintaining the flexibility for other plugins to provide that functionality.

Backend augmentation

Backend augmentation is done through plugins, which are able to define their own database models, external services, etc. Additionally, plugins use the pyproject.toml file to indicate operational information.

Architecture

Backend plugins are Django apps, packaged as Python packages, installable into the Whitebox Python environment. This allows us to:

  • Utilize existing Python package management utilities with seamless handling of dependencies with interaction with PyPI, without having to create a custom package management system
  • Utilize Django's app system, which allows us to define database models, migrations, template tags, etc., in a way that is familiar to Django developers

There are some drawbacks to this approach, as Django is not meant to be extended in this way, at runtime. For this reason, we need to restart the Django server when certain plugins are installed and enabled. This is a limitation that is handled with a manager-like process, outlined below in the document.

Manifest file

The manifest file contains the following information:

  • Plugin name, description, version, author, etc.
  • External assets required to work (e.g., images, libraries, etc.)
  • Capabilities provided
  • Capabilities required
  • Plugins required (in case it augments a specific plugin, or requires a specific plugin to be enabled)
  • Plugins that it augments
  • Plugins that it replaces (e.g. if it provides a new map component from the default)
  • Optionally, we might want to add plugins that it is incompatible with (?)

Manifest file is used by the plugin management system to display information to the user, as well as ensure that all dependencies are met, and that the plugin can be enabled.

The existing pyproject.toml file is used for this purpose, and it is extended to include all necessary information.

Here's an example of a pyproject.toml file for a plugin with manifest metadata:

[build-system]
requires = ["setuptools>=61.0"] # Example build system requirement
build-backend = "setuptools.build_meta"

[project]
name = "whitebox-plugin-cool-plugin"
version = "0.1.0"
description = "A cool plugin for Whitebox"
authors = [
  {name = "Plugin Author", email = "author@example.com"}
]
license = "GNU Affero General Public License v3"
requires-python = ">=3.10.0"
dependencies = [
  # List runtime dependencies here if there were any besides Python
]

[project.optional-dependencies]
dev = [
  "ruff = '^0.6.5'", # Development dependencies go here
]

[tool.whitebox.external-assets]
[[tool.whitebox.external-assets.sources]]
url = "https://vjs.zencdn.net/8.16.1/video.min.js"
integrity = "sha1-a92206d7f468cea7e04500bfda45b7d4d720f289"
target_path = "videojs/video.min.js"

[[tool.whitebox.external-assets.sources]]
url = "https://vjs.zencdn.net/8.16.1/video-js.css"
integrity = "sha1-6bebf3e2201af2e85363a385be3c917f6e229119"
target_path = "videojs/video-js.css"

[tool.whitebox.plugin.capabilities]
capabilities-provided = ["traffic"]
capabilities-required = ["map"]
Automatic Detection of Server Requirements

Rather than relying solely on manual declarations in the manifest, Whitebox automatically detects when a plugin requires a server restart or database migrations:

  1. Migration Detection:

    • The system attempts to import the plugin's migrations/ directory
    • It checks if any migrations are present that haven't been applied
    • It also examines the plugin's models to determine if they require database schema changes
    • Abstract models and proxy models are recognized as not requiring migrations
  2. Restart Requirement Detection:

    • The system analyzes the plugin's code to determine if it modifies components that require a server restart
    • This includes checking for models, migrations, and other critical changes
    • The detection is based on specific goals the bootstrapping needs to accomplish rather than arbitrary declarations

This automatic detection ensures optimal user experience by avoiding unnecessary restarts while maintaining system integrity. Plugin authors can override these settings in the manifest if needed, but the system's automatic detection provides a reliable fallback.

Proposed plugin structure
whitebox-plugin-plug-and-play-just-like-usb/
    manifest.json
    whitebox-plugin-plug-and-play-just-like-usb.py  # Entrypoint

    migrations/  # Django migrations
    templates/  # Django templates
    static/  # Static assets
    models.py  # Django models

    jsx/  # React components
Handling JSX

To allow plugins to provide React components, we are using a Node process to transpile JSX to JavaScript. This process is invoked by the backend, upon plugin loading/unloading, keeping the components available to frontend up-to-date.

Managing augmentation that requires a server restart

As Django is not meant for runtime extension (e.g. dynamic declaration of models, running migrations, etc.), we need to restart the server when certain plugins are installed and enabled.

For this, we create a manager-like process that will handle the installation and enabling of plugins that cannot be enabled on-the-fly, as well as the server restarts.

Architecture

Here's a sketch of the proposed architecture:

Management architecture

Manager process is responsible for:

  • Installing and enabling plugins that require a backend server restart
  • Restarting the backend server when required
  • Providing information about the status of the backend server

An example project that could be used for inspiration is watchtower.

Proposed implementation

Whitebox can be managed through its administrative command line tool, or through the upgrade manager, that the core system will use to upgrade the Whitebox instance.

Command line tools

Whitebox can be managed through its administrative command line tool, that allows for installation, enabling, disabling, and uninstallation of plugins, system restarts, and other administrative tasks.

Plugin related commands:

# Install plugin
poetry install whitebox-plugin-cool-plugin

# Uninstall plugin
poetry remove whitebox-plugin-cool-plugin

Server related commands:

# Start whitebox server
whitebox start

# Instruct whitebox to reload the plugins, rebuild JSX & refresh running frontend apps
whitebox reload

# Restart whitebox server
whitebox restart

# Stop whitebox server
whitebox stop

Server related commands will, while bringing up Whitebox, ensure that the necessary operations are completed so that the Whitebox is operational on boot (e.g. database migrations are run, etc.).

Upgrade manager

To allow for the manager process to know when to restart the server, core system examines the manifest files of the plugins, and determines whether a server restart is required.

If a server restart is not required, the core system loads the plugins, transpiles their JSX and makes it available for use. Then, it notifies the frontend to reload, making the new components load.

If a server restart is required, the core system prepares the information needed to set up the plugins, and notifies the manager process to commence the "upgrade".

Upon upgrade, the manager process:

  • Builds a new Docker image with the new plugins
  • Stops the running container
  • Performs database migrations
    • Optionally, creates a backup of the database, as a form of A/B boot system, in case the upgrade fails
  • Starts the new container
  • Notifies frontend that the upgrade is complete, causing its reload

During the upgrade period, frontend will display to the user that the system is upgrading, and that it will be back shortly. This message will be displayed both to users who already had the page loaded, and to those who just loaded it. This design will be implemented in its implementation ticket (#225).

Slots

Through the slot mechanism, we support:

  • single slot places (e.g. map, wizard page itself, etc.), and
  • slot containers, for adding multiple items by multiple plugins (e.g. map overlays, additional menu items)

Slots, technically being finite, will have their names & positions well known, and are going to be easily accessible, e.g.:

  • <Slot capability="map" />
  • <SlotContainer position="map-overlay-tl" />

If the same slot should be filled by a plugin on different pages, it will be plugins' responsibility to ensure all the different slot "types" are covered (e.g. displaying something on the "main" map, but not on embeded, smaller ones).

To analyze this better, here's a picture of the proposed Dashboard UI, with 6 distinct slot places we want to augment there:

Slotting mechanism

Here we have:

  • C1, the map itself

    In-place injection slot. Slot elements render the designated component.

  • C2-C5, overlays on top of the map

    Slot containers. Element containers in which plugins can contribute a component. In this case, a plugin may want to add some "floating action button" for something that semantically fits into a map action.

  • C6, navbar area

    Slot container. A plugin may want to add an icon or similar for easy access.

Plugin Management Strategy

Plugin Installation and Persistence

To maintain alignment with Docker's stateless container paradigm, plugin installation functions as a customization of the Whitebox environment rather than state modification. The implementation follows these principles:

  1. pyproject.toml Management:

    • Plugin installation modifies pyproject.toml by adding the plugin as a dependency
    • These modifications are stored in the database and used to regenerate the file when needed
    • Rebuilds use Poetry to install all packages, maintaining consistency
  2. Container Rebuild Approach:

    • When any plugin is installed (regardless of whether it requires a restart), the system:
      • Immediately installs the plugin in the current container for plugins that don't require restart
      • In parallel, builds a new Docker image with all currently installed plugins Tags the new image as 'latest' in the local registry
    • For plugins requiring restart, the system performs an immediate container restart using the new image
    • For plugins not requiring restart, the system continues with the current container but ensures the new image will be used on the next restart
    • This approach ensures that even if a manual restart occurs (e.g., docker compose down && docker compose up), all plugins will persist
    • The manager process coordinates between image building and plugin loading, only allowing plugin activation after confirming successful image creation
    • A proxy/cache server maintains availability of plugins even if they're removed from remote locations
  3. Asset Management:

    • Static assets are versioned with their repository, using Git's versioning system
    • Assets are referenced using paths relative to the repository and commit
    • This eliminates the need for manual versioning of individual files

Plugin Lifecycle Events

Plugins interact with the system through a standardized event system:

  • on_install - Triggered when the plugin is installed
  • on_uninstall - Triggered when the plugin is uninstalled
  • on_enable - Triggered when the plugin is enabled
  • on_disable - Triggered when the plugin is disabled
  • on_boot - Triggered when the Whitebox instance boots
  • on_shutdown - Triggered when the Whitebox instance shuts down
  • on_before_uninstall - Triggered before plugin uninstallation begins

These events are not hooks but part of a broader event system, allowing inter-plugin communication and coordination.

Data Isolation Model

The plugin architecture implements a three-layer data isolation model:

  1. Package-Integral Assets:

  2. Files that are essential parts of the plugin package

  3. Managed directly by the plugin system

  4. Plugin-Managed Assets:

  5. Files requested or managed by the plugin during operation

  6. May be updated or modified by the plugin

  7. Arbitrary Plugin Data:

  8. Data generated by plugins for their own use
  9. May be shared with other plugins based on defined access patterns

Download Management

The system includes a dedicated download manager implemented as a plugin:

  1. Dedicated Service:

  2. Runs in its own container

  3. Manages bandwidth, traffic prioritization, and proxying

  4. Shared Resource Access:

  5. Downloads are available to all plugins in read-only mode
  6. Located in a structure like /downloads/[plugin-name]/asset-name
  7. Provides consistent access patterns for plugins

Essential Plugins and Replacements

Certain functionality is marked as essential to the system but can be provided by different implementations:

  1. Essential Plugin Marking:

  2. Core functionality plugins are marked as "essential"

  3. Examples include map services or GPS displays

  4. Plugin Replacement Mechanism:

  5. Plugins can declare themselves as replacements for others
  6. System handles unloading original plugins and loading replacements
  7. Provides fallback mechanism if replacement plugins fail to load

This approach allows third-party plugins to replace core functionality while maintaining system stability.

Plugin Repository Support

The architecture supports multiple sources for plugins:

  1. PyPI Integration:

  2. Primary distribution channel using standard naming conventions

  3. Automatically appears in installable plugin lists

  4. Git Repository Support:

  5. Direct installation from Git repositories

  6. Useful for development versions and unreleased plugins

  7. Local Installation:

  8. Support for manually installed plugin packages
  9. Beneficial for development and testing

Asset Reference Strategy

For external assets referenced in manifests:

  1. Relative Repository Paths:

  2. Assets are referenced relative to the current repository and commit

  3. System expands paths based on repository information

  4. Update Strategies:

  5. Integrity-based: Update when file hash changes

  6. If-Modified: Check using HTTP headers
  7. None: Download once and never update

  8. LFS Support:

  9. Support for Git LFS files through relative paths
  10. System handles LFS-specific retrieval mechanisms