Skip to content

This site tracks the develop branch of the monorepo. Behaviour and protocol may change before the next release — see the source on GitHub.

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:

ts
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.
  • optionalresponsive 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 time

The 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

TypeWhenPayload
READYWidget is loaded and ready to receive messages{ name?: string, version?: string }
ERRORWidget encountered an error{ message: string, code?: string }
STATE_GETRequest to read a state value{ requestId, scope, key }
STATE_SETRequest to write a state value{ requestId, scope, key, value }
STATE_CLEARRequest to delete a state key or scope{ requestId, scope, key? }

Host → widget

TypeWhenPayload
INITResponse to READY — carries initial config{ config: Record<string, unknown> }
FRAME_ENTERWidget's section becomes visiblenone
FRAME_EXITWidget's section leaves the viewportnone
CONFIG_UPDATEMerged config changed since last send{ config: Record<string, unknown> }
TIME_TICKPlayer clock tick (reserved, interval TBD)none
STATE_RESPONSEResponse 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):

TierSet by
Widget defaults (package.json → doohnut.config.fields[].default)Widget author
Campaign configCampaign editor
Playlist configPlaylist editor
Frame / facet configFrame 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)

json
{
  "source": "doohnut-widget",
  "version": 1,
  "type": "READY",
  "payload": { "name": "my-widget", "version": "1.2.0" }
}

INIT (host → widget)

json
{
  "source": "doohnut-player",
  "version": 1,
  "type": "INIT",
  "payload": {
    "config": {
      "title": "Welcome",
      "background_color": "#000000"
    }
  }
}

FRAME_ENTER (host → widget)

json
{
  "source": "doohnut-player",
  "version": 1,
  "type": "FRAME_ENTER"
}

CONFIG_UPDATE (host → widget)

json
{
  "source": "doohnut-player",
  "version": 1,
  "type": "CONFIG_UPDATE",
  "payload": {
    "config": {
      "title": "Flash Sale",
      "background_color": "#ff0000"
    }
  }
}

Implementing without the SDK

html
<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 a STATE_RESPONSE with success: false until host support is released.

State is scoped to prevent collisions between different widget instances:

ScopeDescription
widgetPer-widget, per-display. Reset when the iframe reloads.
playlistShared across all widgets in the same playlist.
instanceDeprecated. Reserved for protocol compatibility; the current player does not persist state under this scope.
campaignShared across all playlists in the same campaign.

STATE_GET

json
{
  "source": "doohnut-widget",
  "version": 1,
  "type": "STATE_GET",
  "requestId": "uuid-here",
  "payload": { "requestId": "uuid-here", "scope": "widget", "key": "counter" }
}

STATE_RESPONSE

json
{
  "source": "doohnut-player",
  "version": 1,
  "type": "STATE_RESPONSE",
  "requestId": "uuid-here",
  "payload": { "requestId": "uuid-here", "success": true, "value": 42 }
}

Released under the same terms as the Doohnut platform.