Skip to content

The interactive bridge

An Interactive package runs in a sandboxed frame without allow-same-origin, so the only way to reach it is postMessage. ulams-ix v1 is the small protocol both sides speak (ADR 0087). The library @ulams/interactive-bridge (front/interactive-bridge, MIT, no dependencies) implements both ends and ships the JSON Schemas.

Every message is { "ulams-ix": 1, "type": string, "nonce": string, …payload }. A message larger than 16 KB, with an unknown type, a wrong nonce or an invalid payload is dropped.

Page to package

Type Payload
init locale, theme (CSS custom properties, --name: value), reducedMotion, display (inline, background, fullscreen), chrome (full, minimal, none), optional startStep, range {from,to}, readOnly, showcase
goToStep step
setTheme, setLocale, pause, resume theme / locale / none

Package to page

Type Payload
ready protocol: 1, steps (the step ids it knows), capabilities (steps, reducedMotion, score, background, locales)
resize height
stepChanged step
progress value from 0 to 1
complete none
score raw, max, optional passed
event an xAPI-like verb IRI, an object id inside the package, an optional result
error code (for example webgl-unavailable), optional message

init.showcase is for the landing hero (see As the hero of a landing page): the package shows only its picture, with no text, no controls and nothing focusable, moves slowly on its own, and follows goToStep from the page. A package that does not know the field is unaffected.

The schemas are in front/interactive-bridge/schema/v1/*.json (and copied to api/packages/interactive/resources/schemas/ulams-ix/v1/, where the events endpoint validates against them). A new optional field is a minor change; renaming or removing one means "ulams-ix": 2.

  1. The lesson page makes a random nonce per launch and sends init with target origin "*" (an opaque frame has no origin to target; init holds nothing secret).
  2. The package stores the nonce and answers ready.
  3. The page accepts a message only when event.source is the frame’s window, event.origin is the string "null", the nonce matches, the size is within 16 KB and the payload is valid. Nothing but ready counts before ready. With no ready within 10 s the page shows the text alternative.
  4. Events go from the page to the front BFF with the learner’s session, in batches every two seconds (POST /bff/api/interactive/topics/{topic}/events → POST /api/interactive/topics/{topic}/events). The frame never holds a token.
// package side
import { connect } from "@ulams/interactive-bridge";
const bridge = connect({ steps: ["a", "b"], onGoToStep: (id) => show(id) });
bridge.stepChanged("b");
// page side
import { createHost, newNonce } from "@ulams/interactive-bridge";
const host = createHost(iframe, { nonce: newNonce(), init, onMessage, onTimeout });
host.goToStep("a");

connect() queues calls made before init (and calls made inside onInit: the page ignores everything that arrives before ready) and is a no-op when window.parent === window.

A package that loads its data after start must still register connect() at the top, or it misses init (the page sends it on the frame’s load event). Pass whenReady: dataPromise to hold ready back until the data is there, and steps as a function so the ids are read then; calls made meanwhile are queued:

const data = fetch("data/steps.json").then((r) => r.json()).then((d) => (steps = d.steps));
let steps = [];
connect({ steps: () => steps.map((s) => s.id), whenReady: data, onInit, onGoToStep });

For packages without a build step npm run build writes dist/interactive-bridge.js (one ESM file, MIT header, under 3 KB min+gzip) that you can vendor until the package is on npm.