Widget Runtime Protocol
This document describes the postMessage protocol between the Doohnut Player (host) and widget iframes. It is the ground truth for anyone building widgets without the SDK, implementing a new SDK binding, or debugging message flows.
Local development: For Vite-based widgets, use vite-plugin-doohnut-widget (mock host, validation, optional pack). See Local widget development.
Overview
Widgets are HTML pages loaded inside sandboxed iframes by the player. All communication is done via window.postMessage. The protocol is pull-based: the widget initiates the handshake, the host responds.
Every message uses a versioned envelope:
interface IWidgetMessageEnvelope {
source: 'doohnut-player' | 'doohnut-widget'
version: number
type: string
payload?: unknown
}User input (display-only)
By default, widgets are display-only: they do not receive user input (clicks, touches, gestures). Pointer events are set to none on widget iframes and overlays so that all interaction is handled by the host app. Widgets still receive protocol messages (e.g. INIT, CONFIG_UPDATE, FRAME_ENTER / FRAME_EXIT) and can render and animate; only direct user input is blocked.
The host can enable interaction in specific contexts by passing an interactive (or equivalent) option so that widget iframes receive pointer events. This is optional and not the default.
Layout and dimensions
Set doohnut.width and doohnut.height in package.json as the reference / preferred geometry (pixels). They drive thumbnails, gallery preview aspect, Widget Builder dimensions, and (by default) how the player lays out the iframe.
Set doohnut.aspectRatioPolicy to:
mandatory(default when omitted) — the player renders the iframe at this design size and scales it with “contain” inside the playlist section (letterboxing, no stretch). The playlist editor can warn if the section’s aspect ratio differs from the widget’s reference aspect. Use a fixed design (e.g.ScaleToFitView) when you care about pixel-perfect layout at a known size.optional— responsive mode: the iframe fills the section; the widget’s layout viewport matches the real section size. Use fluid CSS (width: 100%,height: 100%, flex/grid). Reference width/height are still used for previews and thumbnails.
The wire protocol does not carry geometry; it is entirely declared in the widget manifest and asset metadata (URL widgets store the same policy in gallery metadata).
Handshake
The widget must send READY when it is ready to receive messages (typically on DOMContentLoaded). The host responds with INIT carrying the merged config for that widget instance, followed immediately by FRAME_ENTER if the widget is already visible.
widget iframe loads
widget → host : READY { name?, version? }
host → widget : INIT { config }
host → widget : FRAME_ENTER ← only if the widget is visible at handshake timeThe INIT message is sent exactly once per iframe load — the host will not re-send INIT if the widget sends READY again after the handshake is complete (the host ignores duplicate READY messages).
Frame lifecycle
After the handshake, the host sends FRAME_ENTER and FRAME_EXIT as the widget's visibility changes.
Regular (per-frame) widget
A regular widget has one iframe per frame slot in the playlist. The iframe is created while the frame is buffering (off-screen) and destroyed when the frame is evicted from the buffer.
[iframe created, frame buffering — show = false]
widget → host : READY
host → widget : INIT { config } ← no FRAME_ENTER yet (not visible)
[frame becomes visible — show = true]
host → widget : FRAME_ENTER
[frame leaves viewport — show = false]
host → widget : FRAME_EXIT
[iframe destroyed]Two-slot pool (current player)
The player keeps two widget iframe slots (aligned with frame buffering). Only one slot’s widget is visible at a time; on frame advance the host swaps visibility. Each slot still follows the per-load handshake: INIT is sent once per iframe load after READY, then FRAME_ENTER / FRAME_EXIT as that buffered frame’s section becomes visible or hidden. There is no alternate lifecycle where one iframe is shared across unrelated frames.
Config updates
host → widget : CONFIG_UPDATE { config }Sent whenever the merged config for this widget instance changes. Carries the full merged config object (not a diff). Widgets should apply the new config in full, replacing their previous config.
CONFIG_UPDATE is deduplicated by the host: it will not be sent if the new config is identical (deep equality by JSON serialisation) to the last config that was sent, whether via INIT or a previous CONFIG_UPDATE.
Message reference
Widget → host
| Type | When | Payload |
|---|---|---|
READY | Widget is loaded and ready to receive messages | { name?: string, version?: string } |
ERROR | Widget encountered an error | { message: string, code?: string } |
STATE_GET | Request to read a state value | { requestId, scope, key } |
STATE_SET | Request to write a state value | { requestId, scope, key, value } |
STATE_CLEAR | Request to delete a state key or scope | { requestId, scope, key? } |
Host → widget
| Type | When | Payload |
|---|---|---|
INIT | Response to READY — carries initial config | { config: Record<string, unknown> } |
FRAME_ENTER | Widget's section becomes visible | none |
FRAME_EXIT | Widget's section leaves the viewport | none |
CONFIG_UPDATE | Merged config changed since last send | { config: Record<string, unknown> } |
TIME_TICK | Player clock tick (reserved, interval TBD) | none |
STATE_RESPONSE | Response to a STATE_* request | { requestId, success, value?, error? } |
Config composition
The config object in INIT and CONFIG_UPDATE is already merged. The player resolves config from four tiers (lowest → highest priority):
| Tier | Set by |
|---|---|
Widget defaults (package.json → doohnut.config.fields[].default) | Widget author |
| Campaign config | Campaign editor |
| Playlist config | Playlist editor |
| Frame / facet config | Frame editor |
Higher-tier values override lower-tier values for the same key. Widgets always receive the final merged result and do not need to handle merging themselves.
Wire format examples
READY (widget → host)
{
"source": "doohnut-widget",
"version": 1,
"type": "READY",
"payload": { "name": "my-widget", "version": "1.2.0" }
}INIT (host → widget)
{
"source": "doohnut-player",
"version": 1,
"type": "INIT",
"payload": {
"config": {
"title": "Welcome",
"background_color": "#000000"
}
}
}FRAME_ENTER (host → widget)
{
"source": "doohnut-player",
"version": 1,
"type": "FRAME_ENTER"
}CONFIG_UPDATE (host → widget)
{
"source": "doohnut-player",
"version": 1,
"type": "CONFIG_UPDATE",
"payload": {
"config": {
"title": "Flash Sale",
"background_color": "#ff0000"
}
}
}Implementing without the SDK
<script>
(function () {
var config = {};
window.addEventListener('message', function (e) {
var d = e.data;
if (!d || d.source !== 'doohnut-player' || d.version !== 1) { return; }
switch (d.type) {
case 'INIT':
config = (d.payload && d.payload.config) || {};
applyConfig(config);
break;
case 'CONFIG_UPDATE':
config = (d.payload && d.payload.config) || {};
applyConfig(config);
break;
case 'FRAME_ENTER':
// widget is visible — start animations, resume video, etc.
break;
case 'FRAME_EXIT':
// widget left viewport — pause, release resources
break;
}
});
function applyConfig(cfg) {
document.getElementById('title').textContent = cfg.title || 'Hello';
}
// Pull model: send READY so the host responds with INIT.
document.addEventListener('DOMContentLoaded', function () {
window.parent.postMessage(
{ source: 'doohnut-widget', version: 1, type: 'READY', payload: { name: 'my-widget', version: '1.0.0' } },
'*'
);
});
})();
</script>State API (experimental)
The state API is not yet implemented on the host side.
STATE_*requests will receive aSTATE_RESPONSEwithsuccess: falseuntil host support is released.
State is scoped to prevent collisions between different widget instances:
| Scope | Description |
|---|---|
widget | Per-widget, per-display. Reset when the iframe reloads. |
playlist | Shared across all widgets in the same playlist. |
instance | Deprecated. Reserved for protocol compatibility; the current player does not persist state under this scope. |
campaign | Shared across all playlists in the same campaign. |
STATE_GET
{
"source": "doohnut-widget",
"version": 1,
"type": "STATE_GET",
"requestId": "uuid-here",
"payload": { "requestId": "uuid-here", "scope": "widget", "key": "counter" }
}STATE_RESPONSE
{
"source": "doohnut-player",
"version": 1,
"type": "STATE_RESPONSE",
"requestId": "uuid-here",
"payload": { "requestId": "uuid-here", "success": true, "value": 42 }
}