@ticatec/iframe-message-bridge
v0.2.0
Published
A lightweight TypeScript library for reliable communication between parent window and multiple iframes using postMessage, supporting one-way messages, request-response patterns, broadcast messaging, timeout handling, and automatic resource cleanup.
Readme
iframe-message-bridge
中文 | English
A lightweight TypeScript library that implements structured, reliable communication between parent pages and multiple iframes based on postMessage, supporting one-way messages, request-response patterns, and broadcast messages with automatic message tracking and origin validation.
Note: This is a pure ESM (ES Module) package. Only
importsyntax is supported (norequire()). The compiled output uses explicit.jsextensions on every relative import, so it loads directly under Node's native ESM loader as well as any bundler.
⚠️ Breaking changes vs. 0.1.x:
new MessageBridgeManager()now requires anallowedOriginswhitelist as its first argument, and.broadcast()now requires an explicittargetOrigin(no more silent"*"default). See Security and Migration below.
📦 Features
- ✅ Support for one-way or two-way communication between parent page and iframes
- 🔁 Request-response communication with
Promiseencapsulation - 📡 Broadcast messages: Parent page can send messages to all iframes simultaneously
- 🧩 Multiple iframe support: Parent page can distinguish message sources from each iframe
- 🔄 Auto iframe discovery: No manual registration required, automatically scans all iframes in the page
- 🔐 Secure communication: origin whitelist checking and
event.sourceverification are both enforced before any handler runs (see Security) - 🧼 Zero runtime dependencies, lightweight and efficient
🚀 Installation
pnpm add @ticatec/iframe-message-bridge🧠 How It Works
MessageBridgeManager— Used in the parent page to receive messages from iframes and respond, while also broadcasting messages to all iframesMessageBridgeClient— Used in iframes to send messages to parent page, wait for responses, and receive broadcast messages
🔧 Usage Examples
In Parent Page
import { MessageBridgeManager } from '@ticatec/iframe-message-bridge';
// allowedOrigins is required: only messages from these origins, AND whose event.source
// is a currently-attached <iframe>'s contentWindow, will ever reach your handlers.
const bridge = new MessageBridgeManager(['https://your-iframe-domain.com']);
// Register request-response event handler
bridge.on('getUserInfo', (data, sourceWindow, sourceOrigin) => {
console.log('Received data from iframe:', data);
return { name: 'Alice', role: 'admin' };
});
// Register broadcast message handler (optional, parent page can also receive broadcasts)
bridge.onBroadcast('system-notification', (data) => {
console.log('Received system notification:', data);
});
// Broadcast message to all iframes -- targetOrigin is required, no more silent "*" default
bridge.broadcast('user-login', {
userId: 123,
userName: 'Alice',
timestamp: Date.now()
}, 'https://your-iframe-domain.com');
// Broadcast theme change
bridge.broadcast('theme-change', { theme: 'dark' }, 'https://your-iframe-domain.com');In iframe
import { MessageBridgeClient } from '@ticatec/iframe-message-bridge';
const bridge = new MessageBridgeClient('https://your-parent-domain.com');
// Send request and wait for response
bridge.emit('getUserInfo', { id: 123 }).then(response => {
console.log('Received response from parent page:', response);
}).catch(error => {
console.error('Request failed or timeout:', error);
});
// Send request with custom timeout (10 seconds)
bridge.emit('slowOperation', { data: 'large' }, 10000).then(response => {
console.log('Received response:', response);
}).catch(error => {
if (error.message.includes('timeout')) {
console.error('Request timed out after 10 seconds');
}
});
// Send one-way message (no response needed)
bridge.send('logEvent', { action: 'opened-page' });
// Listen to broadcast messages from parent page
bridge.onBroadcast('user-login', (data) => {
console.log('User login broadcast:', data);
updateUserInfo(data);
});
bridge.onBroadcast('theme-change', (data) => {
console.log('Theme change broadcast:', data);
applyTheme(data.theme);
});
// Unregister specific broadcast event listener
bridge.offBroadcast('theme-change');
// Clear all broadcast listeners
bridge.clearBroadcastHandlers();
// Clean up when component unmounts (important for memory management)
useEffect(() => {
return () => {
bridge.destroy(); // Remove all listeners and clear resources
};
}, []);📌 API Reference
MessageBridgeManager (Parent Page)
new MessageBridgeManager(allowedOrigins: string[], options?: { debug?: boolean })
Initialize the bridge and register the global message event listener.
allowedOrigins: required, non-empty array of trusted origins. Only messages whoseevent.originis in this list, and whoseevent.sourceis thecontentWindowof an<iframe>currently attached to the document, are accepted -- everything else is silently dropped. Pass['*']to explicitly disable origin checking (not recommended; only for fully controlled debug/demo scenarios -- doing so logs aconsole.warnonce at construction time).options.debug: whentrue, logs handler register/unregister activity and rejected-message reasons viaconsole.log/console.warn. Defaults tofalseso the library stays quiet in production by default.
.on(eventName: string, handler: (data, sourceWindow, sourceOrigin) => any)
Register a request-response event handler. Triggered when an iframe sends a message with the specified event name. Can return data (or a Promise of data) as the response. Each event name supports exactly one handler -- calling .on() again for the same event name overwrites the previous handler and logs a console.warn (it no longer overwrites silently).
.onBroadcast(eventName: string, handler: (data) => void)
Register a broadcast message handler. Same one-handler-per-event, warn-on-overwrite behavior as .on().
.broadcast(eventName: string, data: any, targetOrigin: string)
Send a broadcast message to all iframes currently in the page. Automatically scans and retrieves all <iframe> elements in the current page.
eventName: Event namedata: Data to sendtargetOrigin: Required. No more implicit'*'default -- you must consciously decide who's allowed to read the broadcast. Pass'*'explicitly if you really mean "any origin."
.off(eventName: string)
Unregister a specific request-response event handler.
.offBroadcast(eventName: string)
Unregister a specific broadcast message handler.
.clearHandlers()
Clear all request-response event handlers.
.clearBroadcastHandlers()
Clear all broadcast message handlers.
.destroy()
Destroy the manager instance, remove the global message listener, and clear all handlers.
MessageBridgeClient (iframe Page)
new MessageBridgeClient(targetOrigin: string, options?: { debug?: boolean })
Create a client instance, specifying the parent page's origin (e.g., 'https://example.com'). Incoming messages are only accepted when event.origin matches targetOrigin (or targetOrigin is '*') and event.source === window.parent -- this second check matters especially when targetOrigin is '*', since origin checking alone would otherwise accept a forged response from any same-origin window, not just your actual parent page.
options.debug: whentrue, logs rejected/malformed message reasons viaconsole.log. Defaults tofalse.
.emit(eventName: string, data?: any, timeout?: number): Promise<any>
Send a request-type message and wait for the parent page's response. The pending request is registered before the message is actually posted, so there's no window where a (hypothetically) synchronous response could arrive before it's being listened for.
eventName: Event namedata: Data to send (optional)timeout: Timeout in milliseconds, defaults to 30000ms (30 seconds)
.send(eventName: string, data?: any): void
Send a one-way message without waiting for a response.
.onBroadcast(eventName: string, handler: (data) => void)
Register a broadcast message handler to listen for broadcast messages from the parent page. One handler per event name; re-registering warns instead of silently overwriting.
.offBroadcast(eventName: string)
Unregister a specific broadcast message handler.
.clearBroadcastHandlers()
Clear all broadcast message handlers.
.clearPendingRequests()
Clear all pending requests and reject their promises, to prevent memory leaks.
.destroy()
Destroy the client instance: remove the global message listener (this now actually works -- see Migration), clear all pending requests, and clear broadcast handlers.
🌟 Communication Patterns
1. Request-Response Pattern (iframe → Parent Page)
// In iframe
const result = await bridge.emit('getData', { id: 123 });
// In parent page
bridge.on('getData', (data) => {
return fetchDataById(data.id);
});2. One-way Message (iframe → Parent Page)
// In iframe
bridge.send('analytics', { event: 'page_view' });
// In parent page
bridge.on('analytics', (data) => {
trackEvent(data.event);
// No return value needed
});3. Broadcast Message (Parent Page → All iframes)
// In parent page
bridge.broadcast('global-update', { version: '2.0' }, 'https://your-iframe-domain.com');
// In all iframes
bridge.onBroadcast('global-update', (data) => {
console.log('Received global update:', data.version);
});🛡️ Security
The library now enforces two independent checks before any message reaches your handlers, on both ends:
MessageBridgeManager:event.originmust be in theallowedOriginswhitelist passed to the constructor, andevent.sourcemust be thecontentWindowof an<iframe>currently attached to the document (checked via a live scan, so dynamically added/removed iframes are handled correctly). Messages failing either check are dropped before any handler runs.MessageBridgeClient:event.originmust match thetargetOriginpassed to the constructor (ortargetOriginis'*'), andevent.source === window.parent. This second check matters even when you trust the origin: withtargetOrigin: '*', origin checking alone would accept a forged response from any same-origin window, not just your real parent page.- All incoming messages also go through runtime shape validation (
type,requestId,eventNametypes are checked, not just the__bridge__marker), so forged, missing, or mistyped protocol fields are rejected rather than silently mishandled.
Further recommendations:
- Avoid using
"*"asallowedOrigins/targetOrigin/broadcasttargetOriginin production. Every place that accepts"*"does so because you asked for it explicitly -- there's no more silent insecure default anywhere in the library. - Recommend adding a
sandboxattribute to iframes and limiting permissions. - For especially sensitive operations, consider additional application-level authorization inside your handler, not just origin/source trust.
🔄 Automatic iframe Discovery
The library automatically scans all <iframe> elements in the page without manual registration:
// In parent page, these iframes will automatically receive broadcast messages
// <iframe src="module1.html"></iframe>
// <iframe src="module2.html"></iframe>
// <iframe src="module3.html"></iframe>
bridge.broadcast('config-update', newConfig, 'https://your-iframe-domain.com'); // All matching iframes will receive thisDynamically added iframes will also be automatically discovered on the next broadcast or incoming message (the scan happens live, not once at startup).
🔀 Migrating from 0.1.x
new MessageBridgeManager()→new MessageBridgeManager(allowedOrigins). The constructor now requires a non-empty array of trusted origins as its first argument; it throws if you omit it. Pass['*']only if you deliberately want to accept messages from any origin..broadcast(eventName, data)→.broadcast(eventName, data, targetOrigin).targetOriginis now required; pass'*'explicitly if that's really what you want.MessageBridgeClient.destroy()now actually removes itswindowmessage listener. Previously it calledremoveEventListenerwith a reference that was never assigned, so the listener leaked forever and kept handling messages afterdestroy(). If your code relied on (or worked around) that leak, it will now behave correctly instead.- Package import path. Import from
@ticatec/iframe-message-bridge(the scoped package name) -- earlier README examples incorrectly showedfrom 'iframe-message-bridge', which never matched the actual installed package name. - ESM only,
require()no longer works. The package is now published as a real ESM module ("type": "module"inpackage.json). If your project loads this package via CommonJSrequire(...), that will now throwERR_REQUIRE_ESM-- switch toimport(or a dynamicawait import(...)) instead. This library targets browser iframe/parent-window communication, so this mainly affects Node-based tooling (e.g. a test runner) that imports it directly rather than through a bundler.
🧪 Testing
pnpm test # runs the full vitest suite once
pnpm test:watch # watch modeThe suite (in tests/) covers normal request/response, a handler throwing, request timeout, destroy() on both MessageBridgeManager and MessageBridgeClient (including that the listener is actually removed), rejecting untrusted origins, rejecting a forged event.source, rejecting malformed protocol fields, one-way messages producing no response, broadcasting to multiple iframes, and multiple manager/client instances coexisting independently.
📜 License
✨ Author
Developed by Henry Feng
[email protected]
