In this post I will detail how to build a minimalistic JavaScript namespace called MessageTunnel
which will handle management of otherwise unwieldy and often mysterious window.postMessage events. To start lets look at the interfaces we will be using.
interface IMessage
{
name: string;
id?: number;
payload?: any;
error?: IError;
via: "MessageTunnel";
}
interface IListenerCallback
{
(payload?: any, respond?: IPingCallback): any;
}
interface IPingCallback
{
(err?: Error, payload?: any): any;
}
interface IError
{
name: string;
message: string;
}
The messages we send and receive contain a via
attribute which will always equal "MessageTunnel", this is so that we can identify that our messages are coming from another instance of the utility.
Browsers use postMessage
to transmit information between Window
instances. We want to set up a sort of traffic controller, where every Portal
represents a separate Window
. When a message arrives we want to route it to the appropriate object.
namespace MessageTunnel
{
var _portals: Portal[] = [];
export function getPortal (target: Window): Portal
{
// exists?
for (let portal of _portals) {
if (portal.getTarget() === target)
return portal;
}
// else create
let portal = new Portal(target);
_portals.push(portal);
return portal;
}
function _onMessage (e: MessageEvent)
{
// message event received
if (!(e.data instanceof Object) || e.data['via'] != "MessageTunnel")
return;
let portal = getPortal(e.source);
if (e.data['name'] == "_pong")
portal.pongReceived(e.data);
else
portal.messageReceived(e.data, e.origin);
}
window.addEventListener("message", _onMessage);
}
We keep track of a list of objects called Portal
. On window
we listen for message
events. When a valid "MessageTunnel" message is received we run getPortal
, which will find or create a valid Portal
and run it's pongReceived
or messageReceived
method on that instance.
export class Portal
{
private _listeners: { [index: string]: IListenerCallback } = {};
private _pings: { [index: number]: IPingCallback } = {};
private _nextPingId: number = 0;
private _target: Window;
constructor (target: Window)
{
this._target = target;
}
getTarget (): Window
{
return this._target;
}
}
The _listeners
object will keep track of messages we are waiting for from the target window. The _pings
object tracks messages which expect a response, and _nextPingId
serves as a unique id for requests.
We'll have add a few more methods to our Portal
class to make it useful.
setListener (name: string, callback: IListenerCallback)
{
// listen for message
if (!name || !callback)
console.log("Warning: Cannot add listener.");
else
this._listeners[name] = callback;
}
removeListener (name: string)
{
// stop listening
delete this._listeners[name];
}
These allow us to set and remove listeners. The callback
is run whenever we receive a message from the target window marked with its name
, removeListener
does basically the opposite.
sendMessage (name: string, payload?: any, callback?: IPingCallback)
{
// send message
if (!name || name == "_pong")
throw new Error("Name is invalid.");
let message: IMessage = {
name: name,
id: this._addPing(callback),
payload: payload,
via: "MessageTunnel"
};
this._target.postMessage(message, "*");
}
This is how we send a message constructed using the IMessage
interface, calling an as yet undefined _addPing
method on our class. You see our first use of native postMessage
, which posts our message
to the other window.
Lets look at ping stuff.
private _pingTimeout (id: number)
{
// no response in time
let callback = this._pings[id];
if (callback) {
let err = new Error("Message timed out.");
err.name = "TimeoutError";
callback(err);
}
delete this._pings[id];
}
private _addPing (callback?: IPingCallback): number
{
// expect a pong response
if (!callback)
return;
let id = this._nextPingId;
this._nextPingId++;
this._pings[id] = callback;
setTimeout(() => { this._pingTimeout(id); }, 4000);
return id;
}
The operation begins in our _addPing
method. In cases where there is a valid callback
provided we store it and return a _pings
dictionary id. The id then gets included in the posted message before it is sent. This effectively means we are anticipating a response from the other window which is why we set a timeout. If there is no response within the time limit set here at four seconds, then we return an error.
messageReceived (data: IMessage, origin: string)
{
// message received
let callback = this._listeners[data.name];
if (!callback)
return;
callback(data.payload, (err?: Error, payload?: any) => {
let message: IMessage = {
name: "_pong",
id: data.id,
error: _sendError(err),
payload: payload,
via: "MessageTunnel"
};
this._target.postMessage(message, origin);
});
}
You can see our response callbacks have the option to respond with both an error and a payload. We need to add two simple helpers to our namespace which will convert errors to JSON and back again.
function _sendError (err: Error): IError
{
if (!err)
return
return { name: err.name, message: err.message };
}
function _receiveError (error: IError): Error
{
if (!error)
return;
let err = new Error(error.message);
err.name = error.name;
return err;
}
When a message is received from the other window we trigger the associated listener if one exists. This offers the listener an opportunity to respond. As you can see we reuse the id
attribute and the protected "_pong" naming convention. This triggers the pongReceived
method in the target window.
pongReceived (data: IMessage)
{
// pong received
let callback = this._pings[data.id];
if (callback)
callback(_receiveError(data.error), data.payload);
delete this._pings[data.id];
}
With this functionality in place we have a working system. But we should add a few helpers to our namespace to make it easier to use.
export function setListener (source: Window, name: string, callback: Function)
{
getPortal(source).setListener(name, callback);
}
export function removeListener (source: Window, name: string)
{
getPortal(source).removeListener(name);
}
export function sendMessage (target: Window, name: string, payload?: any, callback?: Function)
{
getPortal(target).sendMessage(name, payload, callback);
}
All of these functions make use of a Window
object which represents the window with which you are trying to communicate. Another useful helper returns the window's parent as a Portal
object.
export function getParentPortal (): Portal
{
return ((window.self !== window.top) ? getPortal(window.parent) : undefined);
}
You need two things for message responses to work. Define a callback when calling the sendMessage
method and make use of its' optional response callback on your listener in the target window. A complete example of how you might use this utility is as follows.
// in parent
function _onHandshake (payload, callback)
{
console.log("Message received!");
console.log(payload);
callback(undefined, { hi: "there", num: payload['num'] * 2 });
}
MessageTunnel.setListener(myIFrame.contentWindow, "handshake", _onHandshake);
// in iframe
function _onResponse (err, payload)
{
if (err)
console.log(err.message); // ping timeout or error returned
else {
console.log("Response received!");
console.log(payload);
}
}
let portal = MessageTunnel.getParentPortal();
if (portal)
portal.sendMessage("handshake", { hello: "you", num: 4 }, _onResponse);
// output
// Message received!
// {"hello":"you","num":4}
// Response received!
// {"hi":"there","num":8}
A simpler example might look like this.
// in parent
MessageTunnel.setListener(myIFrame.contentWindow, "sayHi", () => {
console.log("hi!");
});
// in iframe
let portal = MessageTunnel.getParentPortal();
if (portal)
portal.sendMessage("sayHi");
// output: hi!
This will enable you to more effectively communicate between Window
objects and tame that postMessage
method. It is perhaps an intermediate tutorial. Hopefully with a bit of examination you will be able to figure out everything that we did.
There may be more complete solutions out there. The intention with this post is to offer the opportunity for you to build something on your own over which you have complete control. And also, I needed it for myself anyway.