Skip to content

Integrate without the library

A widget built with react-iframe-kit talks to the page around it over a small, documented protocol, not over anything specific to React. This page is for a team that embeds such a widget and doesn’t want the library on its page: a Vue or Angular app, a CMS, or a codebase that adds no dependencies. It describes the protocol and gives a complete host in plain JavaScript, under 200 lines, that you can copy or port.

The protocol is stable: wire protocol v1 only grows by additions, and CI runs the host on this page against the real widget in Chromium, Firefox and WebKit, and against the last published release.

The two sides meet over window.postMessage, and then move to a private MessageChannel:

  1. The widget page announces itself to its parent with a syn.
  2. Your page checks where that came from, creates a MessageChannel, and sends one of its ports back in an ack.
  3. The widget answers ready on that port. From then on, everything goes over the port: sizes, titles, calls, results and events.

That’s why listening to message events on window shows you the syn and nothing else: the widget sends nothing else there. The port keeps the traffic away from other scripts on the page, and needs no origin check per message.

widget (iframe) your page
| -- syn {instance, versions} ------------------> | window.postMessage, to '*'
| | check source and origin
| <-- ack {session, instance, version} + port --- | window.postMessage, to the widget's origin
| == ready =====================================> | over the port
| == size, title, call, result, event, bye ====> |
| <= inert, call, result, event, bye ============ |

Every message is a plain object with rik: 1 and a string type. Ignore anything else, and ignore types and fields you don’t know: new ones may be added (see What stays stable).

These three go over window.postMessage:

Message From → to Target origin Fields
syn widget → your page '*' instance: a random string per widget page load; versions: protocol versions it speaks, [1]
syn your page → widget '*' versions: [1], no instance: asks the widget to announce itself
ack your page → widget the widget’s origin session: a random string per session; instance: the one from its syn; version: 1; the port, as a transferable

When a syn with an instance arrives:

  1. Check event.source === iframe.contentWindow and event.origin === '<the widget's origin>', exactly. Drop it otherwise.
  2. Check that versions includes 1.
  3. If instance is the one you’re already talking to, drop it: it’s a repeat (the widget answers every prompt).
  4. If it’s a different instance, the widget page reloaded: close the old port and fail its pending calls first.
  5. Create a MessageChannel, keep port1, and post the ack to the origin you just checked, transferring port2.
  6. Wait for ready on port1 before sending anything.

The widget sends its syn when it starts, and again whenever it gets your prompt syn. Send a prompt when you start and on every iframe load until you’re connected: the widget may have started before your code did.

Type Direction Fields Meaning
ready widget → page — The handshake is complete
size widget → page width, height, loop? The widget’s content size in CSS px, sent when the widget turned on autoResize. loop: true while its feedback-loop guard holds growth back
title widget → page title The widget’s document.title, sent when it turned on syncTitle
inert page → widget inert Whether the widget should make its document inert (your modal is open). Each session starts not inert
call both id, method, args Calls method with the args array. id is a string unique among the sender’s pending calls
result both id, ok: true, value? The call with that id returned value
result both id, ok: false, error The call failed: error is { name, message, code?, data? }
event both name, payload? A fire-and-forget event
bye both — The sender is leaving; close the port

Payloads are copied with the structured clone algorithm: plain data, Date, Map, ArrayBuffer and so on, but no functions or DOM nodes.

Answer every call with exactly one result carrying its id. For a method you don’t have, send error: { name: 'IframeKitError', message: 'no method named "…"', code: 'RIK_METHOD_NOT_FOUND' }, which the widget reports as it does for a library host. Any other error reaches the widget’s code as a RemoteError whose cause is your error object, so put a stable code on errors the widget should tell apart:

fail: () => {
throw Object.assign(new Error('The card was declined'), { code: 'CARD_DECLINED' });
},

name and message must be strings, code a string or a number. A result that doesn’t parse is dropped, and the widget’s call then times out. Your own calls need a timeout too: nothing in the protocol ends a call that never gets an answer.

  • The widget reloads or navigates. It sends bye from pagehide (best effort), and the new page sends a syn with a new instance. Treat either as the end of the session: close the port, fail pending calls, and handshake again.
  • The back/forward cache. A page restored from it keeps its instance and sends syn again, which can arrive before its bye. That syn looks like a repeat and is dropped, so send a prompt syn after every bye: the widget answers it with a new syn once you’re ready for it.
  • Your page removes the widget. Send bye on the port and close it. The widget then waits for a new ack.
  • A message on a port you’ve already replaced belongs to the old session. Ignore it.
  • Check both event.source and event.origin on the syn, against an origin you configured, never one read from the message. Compare exactly: startsWith or includes would let https://widget.example.com.evil.test in.
  • Post the ack to that origin, never to '*'. The port it carries is the whole connection: a page that gets it can call your methods.
  • The widget checks your origin too. Its allowedOrigins must include your page’s origin, or it drops your ack and the handshake never completes. Ask the widget’s vendor to allow it.
  • Treat everything from the widget as untrusted input: the arguments to your methods, event payloads and sizes. Validate before using them, and clamp sizes if a runaway widget shouldn’t stretch your page.
  • A widget in a sandbox without allow-same-origin has the opaque origin 'null'. Its syn then carries event.origin === 'null', and the ack can only be posted to '*'. Rely on the event.source check there, and only when the vendor says the widget runs sandboxed.

The file below is the one CI tests (e2e/fixtures/connect-widget.js), unchanged. It applies the height, mirrors the title, answers calls, sends calls and events, forwards inert, and handles reloads, bye and the back/forward cache:

connect-widget.js
// A host for a react-iframe-kit widget, written without react-iframe-kit: wire protocol
// v1 by hand, for a page on any framework or none. It is the example on the docs page
// "Integrate without the library", and e2e/manual-host.spec.ts runs it against the real
// widget in every engine (and e2e/skew.spec.ts against the last published one), so it
// can't drift from what the library does. Copy it as is, or port it.
/**
* Connects to the widget in `iframe` and returns a handle to talk to it.
*
* @param {HTMLIFrameElement} iframe
* @param {{
* origin: string,
* methods?: Record<string, (...args: any[]) => unknown>,
* onEvent?: (name: string, payload: unknown) => void,
* onStatus?: (status: 'connecting' | 'connected') => void,
* timeout?: number,
* }} options `origin` is the widget's origin, exactly (`https://widget.example.com`).
* `methods` are what the widget may call; `timeout` applies to each call, in ms.
*/
export function connectWidget(iframe, options) {
const { origin, methods = {}, onEvent = () => {}, onStatus = () => {} } = options;
const timeout = options.timeout ?? 10_000;
let instance; // the widget page load this session is with, from its `syn`
let port; // the session's private MessagePort
let connected = false; // the widget said `ready`
let inert = false;
let nextId = 0;
const pending = new Map(); // call id → { resolve, reject, timer }
const queue = []; // messages sent while not connected, flushed on `ready`
const send = (message) => {
if (connected) port.postMessage({ rik: 1, ...message });
else queue.push(message);
};
// Asks the widget to announce itself, in case it started before this code ran. The
// prompt carries nothing, so it may go to any origin: the reply is what gets checked.
const prompt = () =>
iframe.contentWindow?.postMessage({ rik: 1, type: 'syn', versions: [1] }, '*');
function endSession(reason) {
port?.close();
port = undefined;
instance = undefined;
connected = false;
queue.length = 0;
for (const call of pending.values()) {
clearTimeout(call.timer);
call.reject(new Error(reason));
}
pending.clear();
onStatus('connecting');
}
function onWindowMessage(event) {
const message = event.data;
// Only the widget's own announcement: from this iframe and the widget's origin, a
// `syn` with an `instance` (a random id per page load). Anything else is not ours.
if (event.source !== iframe.contentWindow || event.origin !== origin) return;
if (message?.rik !== 1 || message.type !== 'syn') return;
if (typeof message.instance !== 'string' || !message.versions?.includes?.(1)) return;
if (message.instance === instance) return; // a repeat: one syn per prompt, answered once
if (instance !== undefined) endSession('the widget reloaded'); // a new page load
const channel = new MessageChannel();
const session = channel.port1;
instance = message.instance;
port = session;
session.onmessage = (portEvent) => {
if (session === port) onPortMessage(portEvent.data);
};
// The ack goes to the origin just checked, so no other page can take the port.
const ack = { rik: 1, type: 'ack', session: randomId(), instance, version: 1 };
iframe.contentWindow.postMessage(ack, origin, [channel.port2]);
}
function onPortMessage(message) {
if (message?.rik !== 1) return;
switch (message.type) {
case 'ready': // the handshake is done: the widget listens on the port
connected = true;
onStatus('connected');
if (inert) send({ type: 'inert', inert: true });
for (const queued of queue.splice(0)) send(queued);
break;
case 'size': // the widget's content size in CSS px (with its `autoResize`)
iframe.style.height = `${message.height}px`;
break;
case 'title': // the widget's `document.title` (with its `syncTitle`)
iframe.title = message.title;
break;
case 'event':
onEvent(message.name, message.payload);
break;
case 'call':
answer(port, message);
break;
case 'result': {
const call = pending.get(message.id);
if (!call) return; // timed out already
pending.delete(message.id);
clearTimeout(call.timer);
if (message.ok) call.resolve(message.value);
else call.reject(Object.assign(new Error(message.error.message), message.error));
break;
}
case 'bye': // the widget page is going away: a navigation, or the back/forward cache
endSession('the widget left');
prompt(); // a page restored from the back/forward cache waits to be asked
break;
}
}
async function answer(session, { id, method, args }) {
let result;
try {
if (!Object.hasOwn(methods, method)) {
const error = new Error(`no method named "${method}"`);
throw Object.assign(error, { name: 'IframeKitError', code: 'RIK_METHOD_NOT_FOUND' });
}
result = { ok: true, value: await methods[method](...args) };
} catch (thrown) {
// `name` and `message` must be strings; `code` (a string or number) is optional.
const error = thrown instanceof Error ? thrown : new Error(String(thrown));
result = { ok: false, error: { name: error.name, message: error.message, code: error.code } };
}
try {
session.postMessage({ rik: 1, type: 'result', id, ...result });
} catch (error) {
// The value can't be structured-cloned (a function, say): answer with that.
session.postMessage({
rik: 1,
type: 'result',
id,
ok: false,
error: { name: error.name, message: error.message },
});
}
}
window.addEventListener('message', onWindowMessage);
const onLoad = () => connected || prompt();
iframe.addEventListener('load', onLoad);
onStatus('connecting');
prompt();
return {
/** Calls a method of the widget. Resolves with its return value. */
call(method, ...args) {
return new Promise((resolve, reject) => {
const id = String(nextId++);
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`"${method}" timed out`));
}, timeout);
pending.set(id, { resolve, reject, timer });
send({ type: 'call', id, method, args });
});
},
/** Sends the widget an event. */
emit(name, payload) {
send({ type: 'event', name, payload });
},
/** Makes the widget inert (a modal on the host page is open), inside and out. */
setInert(value) {
inert = value;
iframe.inert = value;
if (connected) send({ type: 'inert', inert: value });
},
dispose() {
if (connected) port.postMessage({ rik: 1, type: 'bye' });
endSession('disposed');
window.removeEventListener('message', onWindowMessage);
iframe.removeEventListener('load', onLoad);
},
};
}
const randomId = () => Math.random().toString(36).slice(2) + Date.now().toString(36);

Using it:

import { connectWidget } from './connect-widget.js';
const iframe = document.querySelector('#tickets');
const widget = connectWidget(iframe, {
origin: 'https://tickets.example.com',
methods: {
getToken: () => fetch('/api/ticket-token').then((response) => response.text()),
},
onEvent(name, payload) {
if (name === 'orderCompleted') gtag('event', 'purchase', payload);
},
onStatus: (status) => console.log('widget', status),
});
await widget.call('prefill', { email: 'ana@example.com' });
widget.emit('themeChanged', 'dark');
widget.setInert(true); // while your modal is open
widget.dispose(); // when the widget is removed

Connect after the iframe is in the document, and dispose when it’s removed:

<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { connectWidget } from './connect-widget.js';
const iframe = ref(null);
let widget;
onMounted(() => {
widget = connectWidget(iframe.value, { origin: 'https://tickets.example.com' });
});
onBeforeUnmount(() => widget.dispose());
</script>
<template>
<iframe ref="iframe" title="Tickets" src="https://tickets.example.com/embed/acme" />
</template>

Wire protocol v1 is a public contract, the same one the library’s releases use to talk to each other:

  • Everything on this page keeps its meaning and shape for as long as a release speaks v1: the message types, their fields, the handshake, and the RIK_METHOD_NOT_FOUND error.
  • Changes within v1 are additions only: new message types or new optional fields. That’s why a host must ignore what it doesn’t know.
  • An incompatible change would be protocol v2, agreed in the handshake (versions). A release that speaks v2 keeps speaking v1 for at least one major release.
  • Not part of the contract: how often the widget reports its size, what debug logging prints, and what the library does internally.

CI holds the library to it. The host above runs against the current widget in every engine and against the last published one; a change that broke it would break the integrations written from this page, and would have to be protocol v2. The full design notes are in docs/design.md.