Plugins
Extend the PanelWave Player with sandboxed iframe plugins — the manifest, capability model, lifecycle, and postMessage protocol.
The plugin system lets third parties extend the player without touching its code. Plugins are ordinary web pages that run in sandboxed iframes and talk to the host via a postMessage protocol, governed by a capability model. The host side is the exported PluginHostService; panels can also embed plugin content directly through plugin layers.
Two ways plugin content appears
- Hosted plugins — loaded and managed by
PluginHostService(lifecycle, capabilities, API calls, events). This is the full plugin system described on this page. - Plugin layers — a panel layer of
kind: "plugin"rendered bypw-plugin-layer(inputssrc— a relativesrcis resolved like every asset URL, see Resolving relative URLs —,sandbox— defaultallow-scripts—,allowFullscreen; outputspluginReady,pluginMessage,pluginError). Use this for per-panel interactive content such as a 360° viewer; see Layers.
There is also a standalone pw-plugin-sandbox component (PluginSandboxComponent, input manifest: PluginManifest, outputs loaded/failed) for embedding one managed plugin in your own UI.
The plugin manifest
Every plugin is described by a PluginManifest:
import type { PluginManifest } from '@panelwave/player';
const manifest: PluginManifest = {
id: 'reading-stats',
name: 'Reading Stats',
version: '1.0.0',
author: 'Example Co',
description: 'Shows live reading statistics',
url: 'https://plugins.example.com/reading-stats/index.html',
capabilities: ['read-manifest', 'read-state'], // required
optionalCapabilities: ['storage'], // user may deny
allowedOrigins: ['https://plugins.example.com'], // extra origins accepted for incoming messages
};
id, name, version, url, and capabilities are required (validated on load).
Capabilities
Twelve granular permissions (PluginCapability):
| Capability | Grants |
|---|---|
read-manifest | Read the work's manifest |
read-state / write-state | Read / modify player state |
read-variables / write-variables | Read / modify the variable store |
navigation | Control navigation |
ui-overlay / ui-toolbar | Render overlays / add toolbar buttons |
tracking | Access tracking data |
storage | Persistent plugin storage |
network | Network requests |
clipboard | Clipboard access |
By default the host auto-grants only read-manifest and read-state (configurable via autoGrantCapabilities).
Current limitation: the interactive permission prompt is not implemented yet — capabilities that are not in autoGrantCapabilities are denied, and a required non-auto-granted capability makes loadPlugin() throw. Until the prompt lands, add the capabilities your plugins need to autoGrantCapabilities via configure().
Host API (PluginHostService)
import { Component, ElementRef, ViewChild, inject } from '@angular/core';
import { PluginHostService } from '@panelwave/player';
@Component({
selector: 'app-plugin-panel',
standalone: true,
template: `<div #pluginContainer class="plugin-container"></div>`,
})
export class PluginPanelComponent {
@ViewChild('pluginContainer') container!: ElementRef<HTMLElement>;
private pluginHost = inject(PluginHostService);
async start(): Promise<void> {
// Allow the capabilities this plugin needs (see limitation above)
this.pluginHost.configure({
autoGrantCapabilities: ['read-manifest', 'read-state', 'storage'],
});
await this.pluginHost.loadPlugin(manifest); // register + capability check
await this.pluginHost.mountPlugin('reading-stats', this.container.nativeElement); // iframe + handshake
}
async stop(): Promise<void> {
await this.pluginHost.unmountPlugin('reading-stats'); // back to 'loaded'
await this.pluginHost.disposePlugin('reading-stats'); // full cleanup
}
}
| Method | Purpose |
|---|---|
configure(config) | Set enabled, maxPlugins (default 10), loadTimeout (default 10 000 ms), sandboxAttributes (default allow-scripts allow-same-origin; see Security model), autoGrantCapabilities, allowedOrigins (extra origins accepted for every plugin) |
loadPlugin(manifest) | Validate and register; resolves capabilities → state loaded. A plugin that failed to load can simply be loaded again |
mountPlugin(id, container) | Create the sandboxed iframe in container, wait for plugin:ready, send plugin:mount, wait for plugin:mounted → state mounted. Each plugin has its own wait and timeout, so several plugins can mount concurrently |
updatePlugin(id, data) | Push new data (plugin:update) |
unmountPlugin(id) | Send plugin:unmount, remove the iframe, clean event subscriptions |
disposePlugin(id) | Unmount if needed, send plugin:dispose, deregister |
getPlugins() / getPlugins$() / getPlugin(id) | Registry access (sync / observable) |
grantPermission(id, cap) / denyPermission(id, cap) | Adjust capabilities at runtime |
getPermissionRequests$() | Observable of pending permission requests (for building a prompt UI) |
registerAPIHandler(method, handler) | Implement an API method plugins can call |
emitEventToPlugins(eventType, data) | Broadcast an event to subscribed plugins |
Lifecycle states
PluginState progresses through:
The postMessage protocol
All traffic uses one envelope (PluginMessage): { type, pluginId, requestId?, payload?, error? }. Message types:
- Lifecycle:
plugin:init,plugin:ready,plugin:mount,plugin:mounted,plugin:update,plugin:unmount,plugin:dispose,plugin:error - Capabilities:
request:capability,grant:capability,deny:capability - API calls:
api:call(payload{ method, params }), answered withapi:response({ result }) orapi:error - Events:
event:subscribe,event:unsubscribe,event:emit
The host answers api:call messages through handlers registered with registerAPIHandler — only for plugins that are currently mounted; a failed reply is caught and never surfaces as an unhandled rejection. Three placeholder handlers exist out of the box (getManifest, getPlayerState, getCurrentPanel — currently returning empty data); register your own implementations to expose real functionality. The intended method surface is defined by the exported PluginAPI interface (manifest/state/variable access, navigation, notifications, toolbar buttons, events, storage).
Writing a plugin
A plugin is a plain HTML page. Minimum viable handshake:
<!DOCTYPE html>
<html>
<body>
<h1>Hello from the plugin</h1>
<script>
const PLUGIN_ID = 'reading-stats';
function send(type, payload) {
window.parent.postMessage({ type, pluginId: PLUGIN_ID, payload }, '*');
}
window.addEventListener('message', (event) => {
const msg = event.data;
if (msg.type === 'plugin:mount') {
// msg.payload.context: { manifest, capabilities, playerVersion, sessionId }
send('plugin:mounted', {});
}
if (msg.type === 'plugin:update') {
// re-render with msg.payload
}
if (msg.type === 'plugin:unmount') {
// cleanup
}
if (msg.type === 'api:response' || msg.type === 'api:error') {
// resolve your pending api:call by msg.requestId
}
});
// Announce readiness — the host waits for this before mounting.
send('plugin:ready', {});
// Calling a host API method:
send('api:call', { method: 'getCurrentPanel', params: [] });
// Subscribing to host events:
send('event:subscribe', { eventType: 'panelChange' });
</script>
</body>
</html>
The plugin posts to window.parent from its own document — the host only accepts messages from the iframe it created for that plugin (see below). A complete runnable example ships in the player repository as SAMPLE_PLUGIN.html.
Security model
- Sandboxed iframes. Plugins run in iframes with
sandbox="allow-scripts allow-same-origin"by default and are visually confined to the container you mount them into. For a plugin served from the host's own origin, the player dropsallow-same-originautomatically — with both tokens, a same-origin document could remove its own sandbox. Such plugins run in an opaque origin; postMessage works without it. Cross-origin plugins keep their real origin (storage, credentialed fetch). - Trusted channel per plugin. A message is accepted only when it comes from that plugin's own iframe window and from an origin accepted for it: the origin of its
url(nullfor sandboxed/opaque documents), plus the manifest'sallowedOriginsand the host-wideallowedOriginsfromconfigure(). Knowing a plugin id is no longer enough to spoof lifecycle messages, API calls or event subscriptions from another window. - Targeted replies. The host posts to the plugin's own origin instead of
*(only opaque-origin plugins, which cannot be addressed by origin, receive*— their identity is guaranteed by the window check). - Capabilities gate what a plugin may do; required capabilities are checked at load time.
- Timeouts (
loadTimeout, default 10 s) fail plugins that never reportplugin:ready/plugin:mounted, and clean up after themselves. - Cleanup.
disposePlugin()removes the plugin's event subscriptions in every state, including after a failed load. maxPlugins(default 10) caps the number of simultaneously registered plugins.
Treat plugin URLs like any third-party embed: serve them from an origin you trust, list only the origins you expect in allowedOrigins, and grant the minimum capability set.