Skip to content

Plugin Development

This guide covers authoring plugins for OpenRV Web, with a focus on the dev-time hot-reload workflow that lets you iterate on a plugin module without restarting the host application.

For the user-facing plugin registration API, see Scripting API → Plugin System. For the public API reference, see the API documentation.


Anatomy of a Plugin

A plugin is a module with a default export that satisfies the Plugin interface. Two on-disk locations are recognized:

LocationPurpose
src/plugin/builtins/<Name>Plugin.tsIn-tree builtins shipped with the application. Watched by the dev-server hot-reload plugin.
plugins/contrib/<name>/index.tsUser-contributed plugins. Loaded explicitly by the host.

A minimal plugin module looks like this:

ts
import type { Plugin, PluginManifest, PluginContext } from '../types';

const manifest: PluginManifest = {
  id: 'com.example.my-plugin',
  name: 'My Plugin',
  version: '1.0.0',
  description: 'Short summary of what the plugin does.',
  contributes: ['blendMode'],
  // Optional fields:
  // dependencies: ['com.example.other-plugin'],
  // engineVersion: '1.0.0',
};

const MyPlugin: Plugin = {
  manifest,
  activate(context: PluginContext) {
    // Register contributions here.
  },
};

export default MyPlugin;

The full Plugin and PluginManifest shapes live in src/plugin/types.ts.

FieldRequiredNotes
idyesReverse-domain string (e.g., openrv.sample.hot-reload-demo). Must be a literal string in the source — see Manifest Parser Constraint.
nameyesHuman-readable label.
versionyesSemantic version.
descriptionrecommendedShort summary surfaced in plugin lists.
contributesyesArray of PluginContributionType values declaring what the plugin registers.
dependenciesoptionalArray of plugin ids that must be active before this one.
engineVersionoptionalMinimum host application version (semver).

processor is not a contribution type today

The PluginContributionType union is 'decoder' | 'node' | 'tool' | 'exporter' | 'blendMode' | 'uiPanel'. A 'processor' entry is reserved for a future release; declaring it today will fail validation.


Lifecycle Hooks

Six optional/required hooks run in this order across a plugin's lifetime:

HookWhen calledPurpose
init(ctx?)After registration, before activate()One-time pre-activation setup — allocate buffers, validate environment. Throwing here prevents activation.
activate(ctx)After init(), after dependencies are activeRequired. Register contributions on ctx. The registry tracks every registration so it can be unwound on deactivate.
deactivate(ctx?)Before dispose() or before a hot-reload swapSymmetric counterpart to activate(). Contributions are unregistered automatically by the registry — this hook is for plugin-internal bookkeeping.
dispose(ctx?)Final tear-down (or before a hot-reload swap)Release any resources allocated in init() or activate(). After this the plugin instance is gone.
getState(ctx?)During hot-reload, before the old version is disposedReturn a structurally cloneable copy of in-memory state. The HotReloadManager defensively re-clones the result.
restoreState(state, ctx?)During hot-reload, after the new version is activatedReceive the snapshot from the previous version and rehydrate. Called once with the cloned state.

Hooks may be async. The registry awaits each hook before advancing the state machine.


Contributions

A plugin registers capabilities via methods on the PluginContext object passed to activate(). Six contribution types are supported today:

TypeMethodOne-line summary
decodercontext.registerDecoder(decoder)Add support for an additional image or video format.
nodecontext.registerNode(type, factory)Register a custom processing node for the render graph.
toolcontext.registerTool(name, factory)Add a custom paint / annotation tool.
exportercontext.registerExporter(name, exporter)Add a custom export format (blob or text variants).
blendModecontext.registerBlendMode(name, blendFn)Add a per-channel blend mode beyond the built-in set.
uiPanelcontext.registerUIPanel(panel)Inject a dockable panel into the application layout.

Every registration is scoped per-plugin and torn down automatically when the plugin deactivates.


Hot-Reload Workflow

The dev-server hot-reload bridge re-imports a plugin's source file with cache-busting whenever it changes on disk, preserving in-memory state across the swap.

To exercise the workflow with the bundled SamplePlugin:

  1. Start the dev server: pnpm dev.

  2. Open the app in the browser and (optionally) interact with the sample plugin so its internal counter / event Map have non-zero values.

  3. Edit src/plugin/builtins/SamplePlugin.ts and save — change the blend mode label, log message, anything observable.

  4. Watch the browser console for a line like:

    text
    [hot-reload] reloaded "openrv.sample.hot-reload-demo"
  5. Verify state survived: the counter is unchanged, the Map still contains its prior keys, the scratch ArrayBuffer is intact.

If the new module fails to import (syntax error, runtime error in init / activate), the old plugin is left running untouched. Fix the error, save again, and the bridge will re-attempt.


Implementing getState / restoreState

These are the only hooks that distinguish a hot-reloadable plugin from a "reload-but-lose-state" one.

See Scripting API → Hot-Reload State Preservation for the user-facing description of the hooks. The contract:

  • Cloneability. The value returned from getState() must be compatible with structuredClone. That covers Map, Set, ArrayBuffer, typed arrays, plain objects, arrays, and cyclic references. Functions, DOM nodes, class instances with private fields, and GPU resources are not cloneable.
  • Single-use snapshot. The captured state is forwarded to restoreState() exactly once and then discarded. Do not assume restoreState() will be called multiple times.
  • Fallback semantics. If structuredClone throws DataCloneError, the manager logs a [hot-reload:<pluginId>] structuredClone failed … warning and forwards the raw reference. Treat this warning as a bug to fix in getState().
  • Return a copy. Even though the manager re-clones defensively, getState() itself should hand back a copy so subsequent live mutations do not leak into the snapshot before cloning happens.

Should I implement getState?

Decision tree:

  • Stateless registrant (decoder, node factory, blend mode with no internal counters) → no. Re-running activate() reproduces the registration. Skip the hooks.
  • In-memory data the user touched (annotation list, undo history, panel scroll position, accumulated metrics) → yes. Implementing getState / restoreState is the difference between hot-reload feeling magical and feeling like a page refresh.
  • WebGL / WebGPU resource ownershipno, but make sure dispose() releases the resource cleanly so the new instance can re-allocate from scratch. GPU handles are not structurally cloneable.

Race Semantics

The dev-server emits one HMR event per save. Editor "save-on-blur" or rapid edits can produce bursts. The client bridge applies a coalesce-with-trailing-replay policy:

  • At most one reload runs per plugin at a time.
  • If additional events arrive while a reload is in flight, they collapse into a single trailing replay (because the pending set is keyed by plugin id).
  • The trailing replay re-evaluates fresh on-disk state at execution time — not at the time it was queued — so the latest content always wins.

The result is "burst of saves → one reload now, one more after that finishes" rather than "burst of saves → N parallel re-imports racing each other".


Force-Reload from the Dev Console

The bridge installs a __openrvDev handle on window for manual triggering. It only exists in dev builds (the production-safety test asserts the symbol does not appear in dist).

js
// Reload a specific plugin by id.
window.__openrvDev?.reloadPlugin('openrv.sample.hot-reload-demo');

// Inspect which plugins are tracked for hot-reload.
window.__openrvDev?.listTrackedPlugins();

// Activate the bundled SamplePlugin on demand
// (only useful when VITE_LOAD_SAMPLE_PLUGIN=0 is set, see below).
window.__openrvDev?.activateSample();

Useful when you want to reload without modifying the file (for example to verify getState / restoreState round-trips cleanly).

Disabling the bundled SamplePlugin

SamplePlugin is auto-registered + activated in DEV by default so the hot-reload workflow is one save away. If you don't want its "Sample" entry cluttering the blend-mode dropdown, set:

bash
# .env.local
VITE_LOAD_SAMPLE_PLUGIN=0

The bridge still installs (so hot-reload works for any plugin you add) and window.__openrvDev.activateSample() remains available for on-demand activation from the console. Production builds never include SamplePlugin regardless of this flag.


Sample Plugin Walkthrough

SamplePlugin is a reference plugin shipped under src/plugin/builtins/. Its _state shape (counter, event Map, ArrayBuffer scratch) is intentionally chosen to exercise the structuredClone path inside HotReloadManager.deepCloneState.

The full source, embedded live from the repository:

ts
/**
 * Sample Hot-Reload Demo Plugin.
 *
 * Reference plugin used by developers to exercise the dev-time hot-reload
 * workflow (`HotReloadManager.reload`). It contributes a single, lightweight
 * blend mode so registration is easy to observe and has no GPU side effects.
 *
 * The plugin keeps an internal `_state` containing a counter, an event-count
 * Map, and a small ArrayBuffer scratch region. The shape is chosen to
 * exercise the structuredClone path inside `HotReloadManager.deepCloneState`
 * (Maps and ArrayBuffers are non-trivial to clone).
 *
 * Lifecycle hook semantics:
 *   - `init`     : one-time allocation of the scratch buffer + counter reset
 *   - `activate` : registers the demo blend mode on the host context
 *   - `deactivate`: balances `activate`; the registry tracks contributions
 *                   and unregisters them automatically, so this hook is a
 *                   bookkeeping / log-only step
 *   - `dispose`  : releases the scratch buffer
 *   - `getState` : returns a *copy* (per Plugin contract); HotReloadManager
 *                  defensively re-clones the result before restoring
 *   - `restoreState`: rehydrates with sensible defaults so future shape
 *                     changes degrade gracefully
 */

import type { Plugin, PluginManifest, PluginContext, BlendModeContribution } from '../types';

const manifest: PluginManifest = {
  id: 'openrv.sample.hot-reload-demo',
  name: 'Sample Hot-Reload Demo Plugin',
  version: '1.0.0',
  description: 'Reference plugin demonstrating the dev-time hot-reload workflow with state preservation.',
  author: 'OpenRV Team',
  license: 'Apache-2.0',
  contributes: ['blendMode'],
};

/** Internal state shape, exercised by structuredClone during hot-reload. */
interface SamplePluginState {
  counter: number;
  events: Map<string, number>;
  scratch: ArrayBuffer;
}

const BLEND_MODE_NAME = 'sample-demo-average';

/** Lightweight demo blend mode: arithmetic mean of base and top channels. */
const demoBlendMode: BlendModeContribution = {
  label: 'Sample Demo (Average)',
  blend(base: number, top: number): number {
    return (base + top) * 0.5;
  },
};

const SamplePlugin: Plugin & {
  /** Internal state — exposed for hot-reload state capture/restore. */
  _state: SamplePluginState;
} = {
  manifest,

  _state: {
    counter: 0,
    events: new Map<string, number>(),
    scratch: new ArrayBuffer(0),
  },

  init(context: PluginContext) {
    // One-time allocation. Hot-reload disposes the old plugin and inits the
    // new one; restoreState() will overwrite this scratch buffer if state
    // was preserved.
    this._state = {
      counter: 0,
      events: new Map<string, number>(),
      scratch: new ArrayBuffer(16),
    };
    context.log.info('Sample plugin initialized');
  },

  activate(context: PluginContext) {
    context.registerBlendMode(BLEND_MODE_NAME, demoBlendMode);
    this._state.counter += 1;
    this._state.events.set('activate', (this._state.events.get('activate') ?? 0) + 1);
    context.log.info(`Sample plugin activated (counter=${this._state.counter})`);
  },

  deactivate(context: PluginContext) {
    // Contributions registered during activate() are tracked by the
    // PluginRegistry and torn down via unregisterContributions(). We only
    // bump our internal event tally here.
    this._state.events.set('deactivate', (this._state.events.get('deactivate') ?? 0) + 1);
    context.log.info('Sample plugin deactivated');
  },

  dispose(context: PluginContext) {
    this._state.scratch = new ArrayBuffer(0);
    this._state.events.clear();
    context.log.info('Sample plugin disposed');
  },

  /**
   * Return a structurally-cloneable *copy* of internal state.
   *
   * The HotReloadManager will defensively re-clone whatever we return, but
   * the Plugin contract still asks getState() to hand out a copy so that
   * subsequent mutations to live state do not leak into the snapshot before
   * the manager has a chance to clone it.
   */
  getState(): unknown {
    return {
      counter: this._state.counter,
      events: new Map(this._state.events),
      scratch: this._state.scratch.slice(0),
    } satisfies SamplePluginState;
  },

  /**
   * Rehydrate from a snapshot produced by `getState()`.
   *
   * Defaults are applied for every field so a future schema change (e.g.,
   * adding a new field) does not blow up when restoring state from an older
   * plugin version during hot-reload.
   *
   * Note on type checks: `instanceof ArrayBuffer` / `instanceof Map` can
   * return `false` for values produced by `structuredClone` across realms
   * (e.g., jsdom/happy-dom test environments, iframes). We use duck-typing
   * (`Symbol.toStringTag` for Map, presence of `byteLength` + `slice` for
   * ArrayBuffer) so the same code path works whether the snapshot came
   * from this realm or was cloned in via the HotReloadManager.
   */
  restoreState(state: unknown): void {
    const s = (state ?? {}) as Partial<SamplePluginState>;
    this._state = {
      counter: typeof s.counter === 'number' ? s.counter : 0,
      events: isMapLike<string, number>(s.events) ? new Map(s.events) : new Map<string, number>(),
      scratch: isArrayBufferLike(s.scratch) ? s.scratch.slice(0) : new ArrayBuffer(16),
    };
  },
};

function isMapLike<K, V>(value: unknown): value is Map<K, V> {
  if (value instanceof Map) return true;
  return (
    typeof value === 'object' &&
    value !== null &&
    Object.prototype.toString.call(value) === '[object Map]' &&
    typeof (value as Map<K, V>).entries === 'function' &&
    typeof (value as Map<K, V>).set === 'function'
  );
}

function isArrayBufferLike(value: unknown): value is ArrayBuffer {
  if (value instanceof ArrayBuffer) return true;
  return (
    typeof value === 'object' &&
    value !== null &&
    Object.prototype.toString.call(value) === '[object ArrayBuffer]' &&
    typeof (value as ArrayBuffer).byteLength === 'number' &&
    typeof (value as ArrayBuffer).slice === 'function'
  );
}

export default SamplePlugin;

Things to notice in the code above:

  • State shape. counter, events: Map<string, number>, and scratch: ArrayBuffer together cover the three non-trivial cases the deep-clone path handles.
  • init vs activate. init allocates the ArrayBuffer once; activate registers the blend mode. After hot-reload, restoreState() overwrites the freshly-allocated buffer with the cloned snapshot.
  • getState returns a copy. New Map(this._state.events) and this._state.scratch.slice(0) produce independent copies even though the manager re-clones afterwards.
  • restoreState defends against schema drift. Every field has a default (?? 0, new Map(), new ArrayBuffer(16)). A future schema change does not blow up an in-flight reload.
  • Realm-tolerant type checks. instanceof Map / instanceof ArrayBuffer can return false across realms (jsdom, structuredClone-as-a-test-harness, iframes). The duck-typing helpers (isMapLike, isArrayBufferLike) keep the same code working in unit tests and the real browser.

Schema-Change Recovery

Hot-reload is most useful when iterating on plugin internals. Three common kinds of state-shape drift have well-defined responses:

  • Adding a field. restoreState should ?? defaults so older snapshots without the new field still rehydrate cleanly:

    ts
    restoreState(state: unknown) {
      const s = (state ?? {}) as Partial<MyState>;
      this._state = {
        counter: s.counter ?? 0,
        newField: s.newField ?? 'default',
      };
    }
  • Removing a field. Ignore unknown keys — destructure or pick only the fields you care about and let the extras fall through.

  • Incompatible type change. Throw from restoreState. The manager logs a [hot-reload:<pluginId>] restoreState() threw: warning and the new plugin instance starts fresh (without the snapshot). The plugin remains active; only the carried-over state is dropped.


What Survives a Reload (and What Doesn't)

Survives:

  • Contribution registrations. The registry unregisters the old plugin's contributions and then re-registers the new plugin's. Anything that was registered via context.register*() is carried across the swap.
  • Plugin state. Anything captured by getState() and rehydrated by restoreState() (subject to the cloneability contract above).
  • Settings. Values stored via PluginSettingsStore (localStorage-backed) are unaffected by hot-reload — they are not part of the in-memory state machine.

Does NOT survive:

  • Event-bus subscriptions made in activate() are torn down by deactivate() and then re-established when the new instance's activate() runs. No special handling needed if you subscribe in activate().
  • Event-bus subscriptions made at module top level are NOT re-run on hot-reload. The new module is imported once with cache-busting, but module-level side effects only fire on first import. If you subscribe at module scope, your subscription will be broken after the first reload.

Subscribe in activate(), never at module top level

Module-level subscriptions look convenient but break under hot-reload. Always perform side effects (subscriptions, timers, DOM listeners) inside activate() so they are re-established on every reload cycle.


Manifest Parser Constraint

The dev-server's manifest scan in scripts/vite/pluginHotReload.ts uses a strict literal regex to extract manifest.id:

text
/\bmanifest\b[^{]*\{[^}]*?\bid\s*:\s*['"`]([^'"`]+)['"`]/s

This means the parser only recognises literal id strings:

  • id: 'openrv.sample.hot-reload-demo'
  • id: "openrv.sample.hot-reload-demo"
  • id: `openrv.sample.hot-reload-demo`
  • id: PLUGIN_ID (imported constant — not detected)
  • id: \openrv.${name}`` (template with interpolation — not detected)
  • id as destructured shorthand from an outer object — not detected

If the parser cannot extract an id, it logs a one-line warning and skips that file for hot-reload. The plugin still loads at startup; only the file-watcher integration is disabled. Keep id: 'literal-string' as a literal in your manifest to opt in.


Allowlist Gotcha (DEV)

The plugin registry validates URLs against an allowlist before importing. The DEV bootstrap calls setAllowedOrigins([window.location.origin]), which covers all dev-time reloads driven by the dev server (they all serve from the same origin).

If you need to load a plugin from an external URL — typically only useful in custom embedding scenarios — you must explicitly add that origin via setAllowedOrigins([...existing, 'https://other.example.com']). The allowlist is intentional defence-in-depth; the dev bridge does not bypass it.


optimizeDeps Caveat

Vite pre-bundles dependencies declared via optimizeDeps. If your plugin imports a dependency that is in vite.config.ts's optimizeDeps.include (or that Vite auto-included), changes to that dep are not picked up by plugin hot-reload alone — the pre-bundled artefact is stale.

When iterating on a plugin that imports an optimizeDeps-bundled dependency, restart the dev server (Ctrl-C, pnpm dev) to rebuild the dep graph.


Dependency-Aware Reload Semantics (v1 limitation)

Plugins with manifest.dependencies are not cascade-reloaded in v1. If plugin B depends on plugin A and you hot-reload A, B is not reloaded — its references to A's contributions may now be stale, and behaviour is undefined.

Workaround: reload manually in dependency order via window.__openrvDev?.reloadPlugin(...), leaf-first.

Future work: a registry-wide reload mutex with topological-order cascade. Tracked separately from MED-19.


  • Scripting API — public window.openrv surface, plugin registration, event subscription
  • API Reference — generated TypeScript API documentation

Released under the MIT License.