Chrome Extensions: Bridging the Gap Between Layers

Building a Chrome extension is meant to be easy and in many ways, it is. The documentation is really well done and has loads and loads of examples. Also, it’s quite easy to inspect any of the ones you’ve installed already to see the magic going on behind. Another big plus is that it’s all Javascript, CSS and HTML with the added bonus of the Chrome API for the extra nibble of magic.

Recently, I had to develop a toolbar for my company that would have to read the currently viewed page, show some SEO information, do some AJAX calls and so on. Not really hard, but I did stumble on a problem that was not really well documented (if at all).

Before going further, if you’re not totally familiar with Chrome extension development, I suggest you go read this overview. You’ll understand more about the complexity between the multiple layers of the architecture.

The problem

I decided to load my extension’s UI elements (like the toolbar and miscellaneous popups) through injected iframes in every web page. With that in mind, the communication between multiple iframes, the current DOM, the Chrome Background Javascript file and other layers that Chrome offers was not the easiest thing to do.

In fact, the problem resided with the use of iframes. I had to send lots of data through JSON objects from the Background layer to any of the iframes and vice versa. Manipulating an iframe is not possible from the Content Script injected in the page, because of the cross-domain restriction.

For example, the page URL currently viewed is

http://www.example.com

and the injected iframe URLs are

chrome-extensions://uniqueidmadeoutoflotsandlotsofletters

Communication between both is impossible because cross-domain communication is a big NO-NO.

Why use iframes, then?

Well, iframes are the only way (currently) to isolate a chunk of Javascript, CSS and HTML without being influenced by the current web page style and behavior.

Also, I was stubborn enough to think there was probably a way to communicate between all the layers in a graceful manner. Even though I could not find an answer on Google or StackOverflow.

What’s the solution?

When using the Chrome API method chrome.tabs.sendMessage to send a message from the Background layer, the message is sent to ALL frames, not just the one that has the ContentScript injected.

I don’t know why I didn’t think of that first!

Since it’s the ContentScript that injects the iframes, they ALSO have access to the Chrome API.

So, the iframes can talk to their parent ContentScript with the default DOM method window.parent.postMessage, talk to the Background layer with chrome.extension.sendRequest and they can also listen to the Background layer messages with the chrome.extension.onMessage.addListener method.

How to make it happen?

The idea is simple: I create a set of Receptionists that will handle all the transfers of messages from one layer to another.

Currently, this is how I’ve setup each layer’s roles :

Background (see background.js)

Can receive messages from ContentScript and either redirect them to the proper iframe or process the message.

Can send messages to all frames (ContentScript and iframes).

ContentScript (see inject.js)

Can receive messages from both the Background layer and the iframes.

When coming from an iframe (through the default window.postMessage method) it redirects the message to the Background if specified. If not specified, it processes the message.

Can send messages only to the Background.

Iframe (see iframe.js)

Can receive messages from the only the Background layer, then checks if it was meant for him and then processes the message.

Can send messages to the ContentScript with window.parent.postMessage.

So in other words:

-          Background talks to ContentScript and iframes, but only listens to ContentScript.

-          ContentScript listens to Background and iframes, but only talks to Background.

-          Iframes talks to ContentScript and listen to Background.

Side note: I understand that Background could also listen to iframe messages, but in my example I’ve skipped this concept since it was not necessary.

Differentiating the iframes

Every iframe has a unique ID (called view in my example later below) so it’s easy to redirect the messages to a particular iframe. A simple way to do so is by adding an  attribute in the URL when loading the iframe, like this:

chrome.extension.getURL('html/iframe/comment.html?view=comment’);

Messages setup

The messages passed are simple objects containing two properties:

-          message

-          data

Every layer (Background, ContentScript and IFrame) has a tell method that sends the message with both properties.

tell(‘tell-something’, {attribute1:’a’, attribute2:’b’});

When an iframe sends a message, the current iframe view ID is also sent as a source property in data.

tell(‘tell-parent-something’, {source:’comment’});

When a message needs to be sent to a particular iframe, a view property is added with the right view ID in data.

tell(‘tell-to-an-iframe’, {

    view:’comment’,

    title:’hello world!’

});

If a message needs to be sent to all iframes, I used the “*” wildcard for that.

tell(‘tell-to-all-iframes’, {view:’*’, title:’foo bar’});

If no view is specified, it’s the ContentScript/Background that should process the message.

Now, the example (finally)!

I’ve created a simple extension for liking pages that I call iHeart (you can find the source on my github).

example

It’s a simple button with a heart on the left side of the screen. When clicked, the user can add a comment and save it. The pages saved will be then listed in the extension popup button:

heart button

The gritty details

Every layer has its own telling and listening methods:

Background

Telling

_this.tell = function (message, data){

    var data = data || {};

    chrome.tabs.getSelected(null, function (tab){

        if (!tab) return;

        chrome.tabs.sendMessage(tab.id, {

            message   : message,

            data : data

        });

    });

};

Listening

function onPostMessage (request, sender, sendResponse){

    if (!request.message) return;

    if (request.data.view){

        _this.tell(request.message, request.data);

        return;

    }

    processMessage(request);

};

ContentScript

Telling

function tell (message, data){

    var data = data || {};

    // send a message to "background.js"

    chrome.extension.sendRequest({

        message : message,

        data : data

    });

};

Listening

// messages coming from iframes and the current webpage

function dom_onMessage (event){

    if (!event.data.message) return;

    // tell another iframe a message
    if (event.data.view){
        tell(event.data);

    }else{

        processMessage(event.data);

    }

};

// messages coming from "background.js"

function background_onMessage (request, sender, sendResponse){

    if (request.data.view) return;

    processMessage(request);

};

Iframe

Telling

_this.tell = function (message, data){

var data = data || {};

data.source = _view;

window.parent.postMessage({

        message   : message,

        data : data

    }, '*');

};

Listening

function background_onMessage (request, sender, sendResponse){

    // make sure the message was for this view (you can use the "*" wildcard to target all views)

    if (

        !request.message ||

        !request.data.view ||

        (request.data.view != _view && request.data.view != '*')

    ) return;

    // call the listener callback

    if (_listener) _listener(request);

};

The communication process is quite simple. When you visit a web page and like what you see (it can be anything really, it’s what you like, I won’t judge), you then click on the iHeart button. Then, the button tells to open up the comment iframe.

js/iframe/heart.js

function heart_onClick (event){

    $('.heart').addClass('active');

    _iframe.tell('heart-clicked');

};

It then processes the message in the ContentScript and opens the comment popup.

js/inspect.js

function processMessage (request){

if (!request.message) return;

    switch (request.message){

        case 'iframe-loaded':

            message_onIframeLoaded(request.data);

            break;

        case 'heart-clicked':

            message_onHeartClicked(request.data);

            break;

        case 'save-iheart':

            message_onSaved(request.data);

            break;

    }

};

...

function message_onHeartClicked (data){

    var comment = getView('comment');

    comment.iframe.show();

    tell('open-comment', {

        view:'comment',

        url:window.location.href,

        title:document.title

    });

};

The comment popup shows up and displays the current web page title below the comment box.

js/iframe/comment.js

function onMessage (request){

    switch (request.message){

        case 'open-comment':

            message_onOpenComment(request.data);

            break;

        case 'website-is-hearted':

            message_onIsHearted(request.data);

            break;

    }

};

...

function message_onOpenComment (data){

    $('.page-title').html(data.title);

};

When the save button is pressed, the comment iframe sends the info back to the ContentScript.

js/iframe/comment.js

function save_onClick (event){

    var comment = $('#comment').val() || '';

    _iframe.tell('save-iheart', {

         comment   : comment

    });

};

The ContentScript hides the comment iframe and tell the Background to save the whole thing.

js/inject.js

function message_onSaved (data){

    var comment = getView('comment');

    comment.iframe.hide();

    tell('save-iheart', {

        url:window.location.href,

        title:document.title,

        comment:data.comment

    });

};

And finally, Background finalizes all the details by saving the website in an array.

js/background.js

function onPostMessage (request, sender, sendResponse){

    if (!request.message) return;

    if (request.data.view){

        _this.tell(request.message, request.data);

        return;

    }

    switch (request.message){

        case 'save-iheart':

        message_onSaved(request.data);

        break;

    case 'all-iframes-loaded':

        message_allIframesLoaded(request.data);

        break;

    }

};

…

function message_onSaved (data){

    _websites.push({

        url           : data.url,

        title         : data.title,

        comment       : data.comment

    });

};

And … the receptionists did their job

That’s pretty much it. That’s my solution to a communication problem between multiple types of layers and it wasn’t too hard …

Now, if I could resolve as easily the communication problems in my personal relationship, that would be great, thanks :P

The example could be taken way way further by dealing with validations of data, saving the liked web pages in a database, dealing with resizing of iframes content dynamically, adding some animation to the extension to make it more fun to use. All of that is great and already do-able, but it’s out of scope of this article.

Free book: Jump Start HTML5 Basics

Grab a free copy of one our latest ebooks! Packed with hints and tips on HTML5's most powerful new features.

No Reader comments

Comments on this post are closed.