Iframe Embeds
The same bridge with no native code: the parent page is the host, each iframe is a client. This is the template for payment widgets, third-party/partner embeds, security boundaries, and embedding legacy apps.
A runnable demo lives at examples/iframe — two frames, typed calls, broadcast + targeted events, and standalone fallback mode.
What this is (and isn't)
This is the iframe-isolation integration style: each frame is a separate window/JS context, independently bundled and deployed (cross-origin works), and the only channel between them is string postMessage. That hard boundary is exactly the condition under which webview-ts applies.
It is not Module Federation-style micro-frontend composition. There, remote bundles are loaded into the same JS context — window and heap are shared, modules import each other directly, and there is no message boundary for a bridge to type. If your architecture is MF/single-spa-style, you don't need webview-ts between remotes.
Both adapters ship in @webview-ts/core — IframeClientAdapter and IframeHostAdapter — so the iframe platform works out of the box. Their source (~40 lines each) doubles as the reference for custom adapters.
Client side (inside the iframe)
One adapter, injected via BridgeConfig.adapter:
import { BridgeClient, IframeClientAdapter } from '@webview-ts/core';
const bridge = new BridgeClient<Actions, Events>({
adapter: new IframeClientAdapter(SHELL_ORIGIN),
fallback: mergeFallbacks([shell], undefined),
});
bridge.applyPlugins([shell]);
bridge.connect();
For reference, the adapter itself is just:
class IframeClientAdapter implements ClientAdapter {
constructor(private readonly parentOrigin: string) {}
isAvailable() {
return typeof window !== 'undefined' && window.parent !== window;
}
get connectionMode() {
return this.isAvailable() ? 'native' : 'disconnected';
}
send(message: BridgeMessage) {
window.parent.postMessage(JSON.stringify(message), this.parentOrigin);
}
onMessage(callback: (raw: string) => void) {
const listener = (event: MessageEvent) => {
if (typeof event.data !== 'string') return;
if (event.origin !== this.parentOrigin) return; // only the shell may speak
if (event.source !== window.parent) return;
callback(event.data);
};
window.addEventListener('message', listener);
return () => window.removeEventListener('message', listener);
}
}
Opened standalone (no parent shell), isAvailable() is false and the plugin's fallback mocks take over.
Host side (the parent page)
One IframeHostAdapter per frame (the event.source check keeps each frame's traffic separate), fed into the neutral createBridgeHost factory:
import { createBridgeHost, IframeHostAdapter } from '@webview-ts/core';
const registry = new ConnectionRegistry();
function mountFrame(frame: HTMLIFrameElement, id: string) {
const adapter = new IframeHostAdapter(frame, CHILD_ORIGIN);
const { bridgeHost, sendEvent } = createBridgeHost({
adapter,
config: { registry },
plugins: [
shell.host({
getUser: async () => ({ name: 'Jane', role: 'member' }),
showToast: async ({ message }) => {
render(message);
return { shown: true };
},
}),
],
});
registry.register(id, (message) => adapter.send(message));
return bridgeHost;
}
In a React or Vue shell, use the framework's useBridgeHost({ adapter, plugins }) hook/composable instead — same factory underneath.
Broadcast and targeted events work exactly as on mobile:
host.sendEvent('shell.themeChanged', { theme }, { target: TARGET.BROADCAST });
host.sendEvent('shell.ping', { from: 'shell' }, { target: 'frame-B' });
Nested: widget needs a native capability?
When this page itself lives inside a native WebView, relay explicitly — see Patterns § Nested embeds relay explicitly.
Cross-origin
Both adapters take origins as parameters and verify event.origin + event.source on every message — the same code works when frames are served from a different origin. See Security.