Gain control of your JavaScript postMessage events

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.