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.
Messages
Section titled “Messages”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.
Handshake and trust
Section titled “Handshake and trust”- The lesson page makes a random nonce per launch and sends
initwith target origin"*"(an opaque frame has no origin to target;initholds nothing secret). - The package stores the nonce and answers
ready. - The page accepts a message only when
event.sourceis the frame’s window,event.originis the string"null", the nonce matches, the size is within 16 KB and the payload is valid. Nothing butreadycounts beforeready. With noreadywithin 10 s the page shows the text alternative. - 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.
Using the library
Section titled “Using the library”// package sideimport { connect } from "@ulams/interactive-bridge";const bridge = connect({ steps: ["a", "b"], onGoToStep: (id) => show(id) });bridge.stepChanged("b");
// page sideimport { 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.