@uniweb/frame-bridge
v0.3.2
Published
Promise-based iframe communication library with automatic dimension reporting, URL sync, and JSON-LD injection
Readme
Frame Bridge
Iframe communication library for Uniweb. Provides a bidirectional, promise-based messaging protocol between parent and child frames with origin validation, dimension reporting, URL synchronization, and custom action handlers.
Two Use Cases
Editor (primary) — Rich bidirectional protocol between the Uniweb editor and the dynamic-runtime preview iframe. Messengers are constructed programmatically with all embedding features off (the defaults), and handlers are registered dynamically via setHandler/setHandlers.
Embedding (secondary) — A non-Uniweb parent page hosts a Uniweb site in an iframe. Auto-init <script> tags opt into embedding features (URL sync, auto-resize, JSON-LD injection) for a drop-in experience.
Installation
npm install @uniweb/frame-bridgeOr use auto-init scripts via CDN for the embedding use case:
<!-- Parent page -->
<script src="https://cdn.jsdelivr.net/npm/@uniweb/frame-bridge/dist/auto/parent.min.js"></script>
<!-- Child iframe -->
<script
src="https://cdn.jsdelivr.net/npm/@uniweb/frame-bridge/dist/auto/child.min.js"
data-allowed-origins="https://embedder.example.com"
></script>data-allowed-origins names the page allowed to embed this document. It is
required for cross-origin embedding — see below.
Programmatic Usage
Parent
import { ParentMessenger } from '@uniweb/frame-bridge/parent'
const messenger = new ParentMessenger({
allowedOrigins: ['https://app.example.com'],
// Embedding features — all default to false
autoResize: true,
urlSync: true,
jsonLD: true,
// Callbacks
onIframeReady: (id, { origin, route, dimensions, metadata }) => {
console.log(`Iframe ${id} ready at ${route.path}`)
},
onRouteChange: (id, { path, title }) => {
console.log(`Iframe navigated to ${path}`)
},
onDimensionUpdate: (id, { width, height }) => {
console.log(`Iframe resized to ${height}px`)
},
// Custom action handlers — each receives (params, source, origin)
actionHandlers: {
userSelected: ({ userId }, source, origin) => {
return { success: true }
}
}
})
// Send messages
messenger.sendToChild('iframe-id', 'navigate', { path: '/users/123' })
messenger.sendToAllChildren('setTheme', { theme: 'dark' })
// Query iframe state
messenger.getIframe('iframe-id') // { origin, dimensions, route, metadata }
messenger.getAllIframes()
// Update handlers after construction
messenger.setHandler('userSelected', (params, source) => {
/* ... */
})
messenger.setHandlers({ action1: fn1, action2: fn2 })
// Cleanup
messenger.destroy()Child
import { ChildMessenger } from '@uniweb/frame-bridge/child'
const messenger = new ChildMessenger({
allowedOrigins: ['https://parent.example.com'],
// Reporting features — all default to false
dimensionReporting: true,
routeReporting: true,
// Custom route getter (for SPAs)
getRoute: () => ({
path: window.location.pathname,
title: document.title
}),
// Callbacks
onParentReady: (response) => {
console.log('Connected to parent')
},
onNavigate: ({ path }) => {
window.history.pushState({}, '', path)
},
// Custom action handlers
actionHandlers: {
loadUser: ({ userId }) => {
return { user: { id: userId, name: 'John' } }
}
},
// Extra data sent with announce
metadata: { version: '1.0' }
})
// Manual updates
messenger.updateRoute('/search/results', 'Search Results')
messenger.updateDimensions()
messenger.updateJSONLD({ '@context': 'https://schema.org', '@type': 'WebPage' })
// Send messages
const result = await messenger.sendToParent('userSelected', { userId: 123 })
// Update handlers after construction
messenger.setHandlers({
myAction: (params) => {
/* can access current state */
}
})
// Cleanup
messenger.destroy()React Pattern
Create the messenger in an effect and destroy it in that effect's cleanup, so the effect owns its whole lifetime. Handlers read current state through a ref, so the messenger is created once:
import { useEffect, useRef, useState } from 'react'
import { ChildMessenger } from '@uniweb/frame-bridge/child'
function App() {
const [count, setCount] = useState(0)
const countRef = useRef(count)
useEffect(() => {
countRef.current = count
}, [count])
const messengerRef = useRef(null)
useEffect(() => {
const messenger = new ChildMessenger({
allowedOrigins: ['https://parent.example.com'],
actionHandlers: {
getCount: () => ({ count: countRef.current }) // always the current state
},
onNavigate: ({ path }) => window.history.pushState({}, '', path)
})
messengerRef.current = messenger
return () => {
messenger.destroy()
messengerRef.current = null
}
}, [])
// Elsewhere, e.g. in an event handler:
// messengerRef.current?.sendToParent('userSelected', { userId: 123 })
return <div>{/* ... */}</div>
}Don't create the messenger in a useState initializer. Construction starts
listening and announces to the parent, and React's StrictMode runs initializers
twice in development, so the extra instance is never destroyed. Don't put
state in the dependencies of the effect that destroys it either: the cleanup
then runs on every change and leaves a destroyed messenger in use. If handlers
must be registered after construction, pass autoAnnounce: false, call
setHandlers(), then announce().
Embedding with Auto-Init Scripts
The auto-init scripts create a window.FrameBridge object with all embedding features enabled. No imports needed.
Parent page:
<iframe src="https://app.example.com" data-messenger-id="main"></iframe>
<script src="https://cdn.../parent.min.js"></script>
<script>
window.FrameBridge.on('routeChange', (id, { path, title }) => {
console.log('Iframe navigated to:', path)
})
window.FrameBridge.on('iframeReady', (id, info) => {
console.log('Iframe registered:', id)
})
</script>Child iframe:
<script
src="https://cdn.../child.min.js"
data-allowed-origins="https://embedder.example.com"
></script>
<script>
window.FrameBridge.on('parentReady', (response) => {
console.log('Connected to parent')
})
</script>The auto-init parent enables autoResize, urlSync, and jsonLD. The auto-init child enables dimensionReporting and routeReporting.
Origins for the auto-init scripts
Both frames refuse messages from an origin they were not told about, and both default to same-origin only. For the cross-origin case above that default is not enough, so each side needs to know the other:
- Child — set
data-allowed-originson the script tag, naming the page(s) allowed to embed it. Comma-separate several. Without it, a cross-origin parent cannot reach the child and the handshake never completes. - Parent — usually nothing to do. It derives the permitted child origins
from the
srcof the iframes on the page, since you already named them there. Setdata-allowed-originson the parent script to override that, for example when an iframe'ssrcis assigned later by script.
The attribute is read from the executing <script> tag, so it only works on a
plain <script src="…"> include — the documented usage. If you load the bundle
some other way, import ChildMessenger / ParentMessenger directly and pass
allowedOrigins yourself.
Why not default to
'*'? The auto-init child turns on route reporting and acts onnavigatemessages from its parent. A wildcard default would let any page that frames the document steer it, so cross-origin access is opt-in.
Architecture
The library is split into parent and child messengers that communicate via postMessage.
Parent-side (src/parent/):
ParentMessenger.js— Main parent frame messengerIframeRegistry.js— Tracks registered iframe metadata (origin, dimensions, route)URLSyncManager.js— Syncs parent URL with iframe routes, handles browser navigationJSONLDInjector.js— Injects structured data from iframes into parent<head>auto-init.js— Self-initializing IIFE for CDN usage
Child-side (src/child/):
ChildMessenger.js— Main iframe messengerDimensionReporter.js— ResizeObserver-based dimension reporting (accounts for body margin/padding)RouteReporter.js— Watches for route changes via pushState/replaceState patchingauto-init.js— Self-initializing IIFE for CDN usage
Shared (src/shared/):
BaseMessenger.js— Abstract base class with promise-based postMessage wrapperOriginValidator.js— Validates message origins (supports wildcards likehttps://*.example.com)constants.js— Action types, defaults, error messagesutils.js— Logger, debounce, iframe detection utilities
Message Flow
Initialization — Child announces itself to parent (
ANNOUNCE). Parent responds with iframe ID and optional initial route. Child starts reporters if enabled.Route updates (opt-in) — Child navigates internally and sends
UPDATE_ROUTE. TheonRouteChangecallback always fires. IfurlSyncis enabled, parent also updates the URL query param. Browser back/forward sendsNAVIGATEto child.Dimension updates (opt-in) — ResizeObserver detects changes. Child sends
UPDATE_DIMENSIONS. Parent auto-resizes iframe ifautoResizeis enabled.Custom actions — Both sides can register
actionHandlersfor bidirectional RPC. All messages return promises.Replies — A reply settles the promise waiting on its id. A reply nothing is waiting for — its request timed out, or another messenger in the same window sent it — is dropped with a warning, never answered.
Build Outputs
Rollup generates two formats in dist/:
- ESM (
dist/esm/) — For modern bundlers: the full library (index), parent-only (parent), and child-only (child) - IIFE (
dist/auto/) — Auto-initializing scripts for<script>tags,parentandchild(minified and unminified)
The package's exports point at src/, so a modern bundler resolves the source and dist/esm/ serves main/module resolvers.
API Reference
ParentMessenger
Constructor Options
| Option | Type | Default | Description |
| --------------------- | ---------------- | ---------------- | ----------------------------------------------------- |
| allowedOrigins | string[] | Same-origin only | Allowed child origins |
| autoResize | boolean | false | Auto-resize iframes to content |
| urlSync | boolean | false | Sync parent URL with iframe routes |
| urlParamKey | string | 'path' | Query param key for routes |
| preserveOtherParams | boolean | true | Keep other query params when syncing |
| syncParams | string[]\|null | null (all) | Query params passed to an iframe at announce (urlSync) |
| jsonLD | boolean | false | Inject JSON-LD from iframes into <head> |
| onIframeReady | function | - | (iframeId, { origin, dimensions, route, metadata }) |
| onRouteChange | function | - | (iframeId, { path, title }) |
| onDimensionUpdate | function | - | (iframeId, { width, height }) |
| actionHandlers | object | {} | Custom action handlers |
| timeout | number | 5000 | Message timeout (ms) |
| logLevel | number\|string | 3 ('INFO') | Logging verbosity |
Methods
| Method | Returns | Description |
| --------------------------------------- | -------------- | ------------------------------------ |
| sendToChild(iframeId, action, params) | Promise | Send message to specific iframe |
| sendToAllChildren(action, params) | Promise | Send message to all iframes |
| getIframe(iframeId) | object\|null | Get iframe metadata |
| getAllIframes() | object[] | Get all iframe metadata |
| setHandler(action, fn) | void | Set/replace single action handler |
| setHandlers(handlers) | void | Set/replace multiple action handlers |
| setLogLevel(level) | void | Change log level |
| destroy() | void | Stop listening and sending; pending messages reject |
ChildMessenger
Constructor Options
| Option | Type | Default | Description |
| -------------------- | ---------------- | ---------------- | -------------------------------- |
| allowedOrigins | string[] | Same-origin only | Allowed parent origins (see below) |
| dimensionReporting | boolean | false | Auto-report dimensions on resize |
| dimensionThreshold | number | 1 | Min px change to trigger report |
| routeReporting | boolean | false | Auto-report route changes |
| getRoute | function | Default getter | Returns { path, title } |
| onParentReady | function | - | (response) |
| onNavigate | function | - | ({ path }) |
| actionHandlers | object | {} | Custom action handlers |
| autoAnnounce | boolean | true | Announce on construction; false to call announce() yourself |
| metadata | object | {} | Extra data sent with announce |
| timeout | number | 5000 | Message timeout (ms) |
| logLevel | number\|string | 3 ('INFO') | Logging verbosity |
About allowedOrigins
It is a permission set — every entry is an origin you allow to embed this document — and the order does not matter. You do not need to put the actual embedder first, and there is no way to tell the child which one it is.
Before the parent replies, the child cannot know which permitted origin is framing it, so it addresses all of them; the browser delivers to at most one and drops the rest. Once the parent answers, the child remembers that origin and addresses it exactly from then on.
Wildcard entries (https://*.example.com) are matched on incoming messages
but cannot be addressed on outgoing ones — a pattern is not an origin any
window can have. Include at least one concrete origin, or '*', so the child has
something to address. A list of only patterns will raise an error rather than
fail silently.
Methods
| Method | Returns | Description |
| ------------------------------ | --------- | ------------------------------------ |
| announce() | Promise | Announce to the parent — automatic unless autoAnnounce: false |
| sendToParent(action, params) | Promise | Send message to parent |
| updateRoute(path, title?) | void | Manually report route change |
| updateDimensions() | void | Manually trigger dimension report |
| updateJSONLD(jsonld) | void | Send JSON-LD structured data |
| setHandler(action, fn) | void | Set/replace single action handler |
| setHandlers(handlers) | void | Set/replace multiple action handlers |
| setLogLevel(level) | void | Change log level |
| destroy() | void | Stop listening and sending; ends a pending announce |
If ChildMessenger is constructed outside an iframe, it creates a no-op instance (isActive = false) with a console warning.
License
MIT
