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.
How it works
Section titled “How it works”The two sides meet over window.postMessage, and then move to a private
MessageChannel:
- The widget page announces itself to its parent with a
syn. - Your page checks where that came from, creates a
MessageChannel, and sends one of its ports back in anack. - The widget answers
readyon 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).
The handshake
Section titled “The handshake”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:
- Check
event.source === iframe.contentWindowandevent.origin === '<the widget's origin>', exactly. Drop it otherwise. - Check that
versionsincludes1. - If
instanceis the one you’re already talking to, drop it: it’s a repeat (the widget answers every prompt). - If it’s a different
instance, the widget page reloaded: close the old port and fail its pending calls first. - Create a
MessageChannel, keepport1, and post theackto the origin you just checked, transferringport2. - Wait for
readyonport1before 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.
Port messages
Section titled “Port messages”| 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.
Calls and errors
Section titled “Calls and errors”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.
Reloads, navigation and bye
Section titled “Reloads, navigation and bye”- The widget reloads or navigates. It sends
byefrompagehide(best effort), and the new page sends asynwith a newinstance. 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
instanceand sendssynagain, which can arrive before itsbye. Thatsynlooks like a repeat and is dropped, so send a promptsynafter everybye: the widget answers it with a newsynonce you’re ready for it. - Your page removes the widget. Send
byeon the port and close it. The widget then waits for a newack. - A message on a port you’ve already replaced belongs to the old session. Ignore it.
Security
Section titled “Security”- Check both
event.sourceandevent.originon thesyn, against an origin you configured, never one read from the message. Compare exactly:startsWithorincludeswould lethttps://widget.example.com.evil.testin. - Post the
ackto 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
allowedOriginsmust include your page’s origin, or it drops yourackand 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
sandboxwithoutallow-same-originhas the opaque origin'null'. Itssynthen carriesevent.origin === 'null', and theackcan only be posted to'*'. Rely on theevent.sourcecheck there, and only when the vendor says the widget runs sandboxed.
A complete host
Section titled “A complete host”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:
// 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 openwidget.dispose(); // when the widget is removedConnect 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>import { Component, type ElementRef, type OnDestroy, ViewChild, type AfterViewInit } from '@angular/core';import { connectWidget } from './connect-widget.js';
@Component({ selector: 'app-tickets', template: `<iframe #frame title="Tickets" src="https://tickets.example.com/embed/acme"></iframe>`,})export class TicketsComponent implements AfterViewInit, OnDestroy { @ViewChild('frame') frame!: ElementRef<HTMLIFrameElement>; private widget?: ReturnType<typeof connectWidget>;
ngAfterViewInit() { this.widget = connectWidget(this.frame.nativeElement, { origin: 'https://tickets.example.com' }); } ngOnDestroy() { this.widget?.dispose(); }}What stays stable
Section titled “What stays stable”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_FOUNDerror. - 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
debuglogging 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.