This blog has been archived. Our writing has moved to makandra cards.
The blog of , a Ruby on Rails development team

Inception: manipulating cross-domain iframes with JavaScript

The requirement seemed simple enough: We had this cool search page on our website (the "widget"), and we wanted to provide other webmasters with a way to embed this search onto their own site. They would simply insert a small block of JavaScript which in turn populates some div with our content, just like Google Maps.

What promised to be a very straightforward feature turned out to become a pretty Nolan-esque experience, where different layers of JavaScript had to coerce other layers of JavaScript into…

Okay, it's actually not quite as bad.

It was pretty clear we needed to use an iframe for this; other webmasters should not have to bother with our stylesheets and JavaScript libraries. However, using a conventional iframe does not allow the external site and our iframe – living on two different domains – to communicate at all. Not even to resize the iframe to fit its contents. While there are some solutions to the resizing problem, we simply did not want to give up JavaScript communication in general. The conclusion: the iframe may not actually point to our domain.

Browser security disallows absolutely all JavaScript communication between domains. What is allowed however, is to embed a <script> tag that fetches its code from another domain. For us, it look something like:

<div id="widget_canvas"></div>
<-- better move the following to the bottom of the page -->
<script type="text/javascript">
  document.write(unescape('%3Cscript src="' +
    (("https:" == document.location.protocol) ? "https://our-domain.com" : "http://our-domain.com") +
    '/widget?embed=true" type="text/javascript"%3E%3C/script%3E'
  ));
</script>

This might be familiar, Google Analytics does exactly the same. We simply write a <script> tag to the document which is instantly executed and fetches our actual widget code.

This code will then in turn

  • contain our widget's HTML embedded in a JavaScript string
  • return some javascript code that will create an iframe (with a blank src attribute) and
  • populate the iframe with the widget's HTML

Et voilà, no domain boundaries any more, the iframe is considered to be in the external site's domain. But while the idea is straightforward, the details are tricky.

The bulk of the JavaScript lives in a Rails layout, the regular views look like always. We confine ourselves to vanilla JavaScript in the external page, but expect jQuery within the iframe.

The following is a small excerpt of the final code, you can find a (slightly adapted)

full version here.

Make sure to look there, even if you plan to just copy small parts.

# app/layouts/widget.js.erb:
<% if params[:embed] %>

  Widget = (function() {

    var iFrame; var initialContent; var iFrameDoc;

    function htmlLayout() {
      return '<%= escape_javascript(<<EOHTML)
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="#{I18n.locale}" lang="#{I18n.locale}">

  <head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    #{stylesheet_link_tag 'YOUR STYLESHEETS HERE', :cache => 'widget'}
    #{javascript_include_tag 'jquery', 'YOUR JAVASCRIPT LIBRARIES HERE', 'widget_support', :cache => 'widget'}
  </head>

  <body id="top" class="partner_search" data-lang="#{I18n.locale}">
    <div id="iframe_content"></div>
  </body>
</html>
EOHTML
      %>';
    }

    function createIFrame() {
      var canvas = document.getElementById('widget_canvas');
      iFrame = document.createElement('iframe');
      canvas.appendChild(iFrame);
      iFrame.onload = insertInitialContent;
      iFrameDoc = iFrame.contentDocument || iFrame.contentWindow.document;
    }

    function populateIFrame() {
      iFrameDoc.write(htmlLayout());
      iFrameDoc.close();
      initialContent = '<%= escape_javascript(yield) %>';
    }

    function replaceContent(content) {
      iFrame.contentWindow.replaceIFrameContent(content);
      resizeIFrame();
    }

    function insertInitialContent() {
      replaceContent(initialContent);
    }

    function resizeIFrame() {
      iFrame.height = iFrameDoc.body.scrollHeight;
    }

    function replaceContentWithScript(scriptHref) {
      var scriptTag = document.createElement('script');
      scriptTag.type = 'text/javascript';
      scriptTag.src = scriptHref;
      document.body.appendChild(scriptTag);
    }

    function init() {
      createIFrame();
      populateIFrame();
    }

    init();

    return { replaceContent: replaceContent, replaceContentWithScript: replaceContentWithScript };
  })();

<% else %>

  Widget.replaceContent('<%= escape_javascript(yield) %>');

<% end %>

-

# public/javascripts/widget_support.js:
function replaceIFrameContent(content) {
  $('#iframe_content').html(content);
}

-

# somewhere in app/controllers/widget_controller:
def show
 # ...
 render :layout => 'widget.js.erb'
end

Phew, lots of code. I hope it is somewhat self-explanatory, but I'll point out some of the more interesting problems we had to solve.

JavaScript execution order

IE7 is very creative about the order it executes JavaScripts. Inline scripts are executed when encountered, external scripts when they come in. This is fatal for libraries included in the <head>. This is why we fill in the actual content in the iframe's load event, where all libraries should be parsed.

Make sure you have only one JavaScript library you actually include (that is, turn caching on). Moreover, if the JavaScript is already cached, the document-ready event seems to be fired before your JavaScript is even parsed. So all $(function() { ... }) blocks in your code are executed instantly where they are defined.

Inline script tags

When assigning HTML via element.innerHTML = ..., included script tags are not executed. This is why we need the "widget_support.js"; we call the jQuery of the inner frame to actually replace our HTML. JQuery will actually "grep" out the script tags and execute them manually.

Links in the iframe

Left to do is to handle links within the iframe. If they are supposed to bring up new content inside the widget, they obviously have to use the same mechanisms to do so.

Included above is the replaceContentWithScript method which takes a url (that needs to point to another action rendered with the "widget.js.erb" layout), which again fetches JavaScript injecting the new content. So we can wire our links from javascript inside our iframe with:

$('a').click(function(event) {
  event.preventDefault();
  parent.Widget.replaceContentWithScript($(this).attr('href'));
});

The whole solution seems to work on all major browser starting with IE7. We haven't dared to try IE6. This is also very fresh; we'll let you know if it turns out to be less than stable for production use.

Growing Rails Applications in Practice
Check out our e-book:
Learn to structure large Ruby on Rails codebases with the tools you already know and love.

Recent posts

Our address:
makandra GmbH
Werner-von-Siemens-Str. 6
86159 Augsburg
Germany
Contact us:
+49 821 58866 180
info@makandra.de
Commercial register court:
Augsburg Municipal Court
Register number:
HRB 24202
Sales tax identification number:
DE243555898
Chief executive officers:
Henning Koch
Thomas Eisenbarth