Sandboxing 3rd party JavaScript widgets

The outside world is evil, I mean it. But it is also old, very old. And your cool, magical widget has to survive out there. How can you make sure of it? Last week we published a blog post on building 3rd party widgets in JavaScript. Let’s recap briefly the first, in order and importance, basic goal we want score!

  • Our widget should be isolated from other code. It must not interfere with code on the host page, and host code should not interfere with your widget. This is the most fundamental and important goal of any widget.

Though absolutely critical, this goal -sandboxing your code- is not an easy task at all. Furthermore, as it happens with software problems, there’s no silver bullet. This is an excellent blog post about the choices you have to sandbox you code nowadays. The short story is this: use iframes. And that’s what we’re going to do. But before, let’s try something else.

Please, display good manners. Thanks!

Your widget is going to be a guest (that’s part of the nature of any widget, right?). And common courtesy rules demand that your widget show some respect and, therefore, do not pollute the host’s environment. Wait, I’m pretty sure you’ve heard of a technique to do that:

// https://example.com/magical_widget.js

function MagicalWidget() {
  const PI = 3.1415926535;

  return {
    getPI: () => PI
  };
};

Closures! I don’t want to get too technical at this point, but you should really learn about closures. For the sake of brevity let’s accept this simple (or rather simplistic) definition of “closure” from Wikipedia: “A function that has an environment of its own”. That sounds good for sandboxing! Closures offer a way to keep all of your code outside of the global namespace. Your customers can intialize your widget new MagicalWidget() and don’t have to worry about PI to be defined in the global scope, only MagicalWidget is.

We can take a step further and create an immediately-invoked function expression (IIFE) for better code isolation:

// https://example.com/magical_widget.js

(function(window) {
  function MagicalWidget() {
    const PI = 3.1415926535;

    return {
      getPI: (precision) => PI.toPrecision(precision)
    };
  };

  window.MagicalWidget = MagicalWidget;
}(window));

You probably know that Browserify and webpack wrap your code in a closure. So go ahead and bundle up your widget with one of them.

So far so good. You are respectful and do not pollute the host’s environment. But, are you protected from the host? Well, if the host defines window.PI = 0 you’ll see there’s nothing to worry about. But what about this scenario?

// another-not-so-cool-library.js

Number.prototype.toPrecision = (precision) => null;
// https://example.com/magical_widget.js

(function(window) {
  function MagicalWidget() {
    const PI = 3.1415926535;

    return {
      getPI: (precision) => PI.toPrecistion()
    };
  };

  window.MagicalWidget = MagicalWidget;
}(window));

Woah! Now we’re in trouble. I warned you, the world is an ugly, hostile place. Closures and IIFE offer nice code isolation, but don’t feel safe!

On the other hand, we also have to deal with stylesheet conflicts. You could create a CSS sandbox with cleanslate or something like that.

Let me tell you that I have tried this approach in an alpha release of the MagicBell widget and it worked like a charm! We tested it in several environments and I can tell that bundlers out there do a really nice work isolating your code. Then we added Honeybadger for error tracking. A few minutes after releasing that version we realized we were reporting all errors, including those of other libraries imported by the host. True, that could be fixed, but is it worth it?

Unfortunately, it’s not possible to provide a real isolated environment for your widget using this approach.

It is difficult to see the picture when you are inside the frame

But sometimes you don’t want (or need) to see the picture, like when you’re developing 3rd party JavaScript widgets. iFrames represent “a nested browsing context”. So we can build a real sandbox for our widget with iframes. Let’s try something simple:

// https://example.com/magical_widget.js

(function() {
  const appHTML = `
  <script type='text/javascript'>
    (function(window) {
      function MagicalWidget() {
        const PI = 3.1415926535;

        return {
          getPI: (precision) => PI.toPrecision(precision)
        };
      };

      window.MagicalWidget = MagicalWidget;
    }(window));

    var widget = new MagicalWidget();
    console.log(widget.getPI());
  </script>
  `;

  const sandbox = document.createElement('iframe');
  sandbox.srcdoc = appHTML;
  document.body.appendChild(sandbox);
}());

Our magical widget creates an iframe and loads the app in it. Cool!

At this point, let’s get back to our code from the previous blog post and revisit the webpack configuration. To make our lives easier, we’re going to bundle two files with webpack: one for the app code (whatever your widget is about), and another for the iframe wrapper:

// ./webpack.config.js

...
module.exports = {
  entry: {
    app: './src/js/app.js',                  // Rename index.js to app.js
    widget: './src/js/widget.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),   // An absolute path to the output directory
    filename: '[name].magicalwidget.js',     // The name to use for the output file
    library: 'MagicalWidget'
  },
  ...
};

Note: I’m going to call widget to the code that we use to wrap our app inside an iframe.

This is how our entry files would look like:

// ./src/app.js

import style from './theme.scss';

import HelloWorld from './views/hello_world';
import { h, render } from 'preact';


render(<HelloWorld/>, document.body);
// ./src/widget.js

const sandbox = document.createElement('iframe');
sandbox.srcdoc = '<script src="/app.js"/>';
document.body.appendChild(sandbox);

Now, we distribute two files: widget.magicalwidget.js and app.magicalwidget.js. But your customers only need to import widget.magicalwidget.js. That script is going to take care of creating the sandbox and importing your app code.

Personalization

Everyone likes, needs, wants to personalize their gadgets. And you want to brag about how flexible your widget is! So, let’s try passing options to this magical widget:

// ./src/app.js

...
export function initializeApp(options = {}) {
  render(<HelloWorld/>, document.body);
};
// ./src/widget.js

export function initialize(options = {}) {
  const sandbox = document.createElement('iframe');
  sandbox.srcdoc = `
    <script>
      // Ughh, cleanup, validate, parse, etc options' attrs!
      function initializeWidget(){ MagicalWidget.initializeApp(${options}) }
    </script>
    <script src="/app.js" onload="intializeWidget()"/>
  `;
  document.body.appendChild(sandbox);
};

So far so good. What happens if you want to include a callback function as an option? Something like this:

// ./src/views/hello_world.js

...
export default class HelloWorld extends Component {
  constructor(props) {
    super(props);
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick (event) {
    const callback = this.props.onClick;
    if (typeof callback === 'function')
      callback();
  }

  render() {
    return <span onClick={this.handleClick}>Hello World!</span>;
  }
}
// ./src/app.js

...
export function initializeApp(options = {}) {
  render(<HelloWorld onClick={options.onClick}/>, document.body);
};

Well, your customers can import your widget and pass a function as an option on initialization:

// host.html

<script src="widget.magicalwidget.js"/>
<script>
  MagicalWidget.initialize({ onClick: () => console.log('clicked!') });
</script>

It works! However, there’s a problem… a huge one. What happens when the callback makes a reference to a variable in the host context?

// host.html

...
<script>
  var SQRT2 = 1.4142135623;
  MagicalWidget.initialize({ onClick: () => console.log(SQRT2) });
</script>

Try clicking, it throws the error “ReferenceError: SQRT2 is not defined”. The execution context of the callback function has changed!

Respect, trust, communication

We made a lot of progress since we started this post. We respect our host, we can trust each other, now we gotta solve a communication problem. Well, the postMessage API provides a way to communicate between our widget iframe and the host’s HTML page. postmate implements a nice wrapper around this API. Let’s add it to our project npm i postmate --save and use it:

// ./src/widget.js

import Postmate from 'postmate';


export function initialize(options = {}) {
  const handshake = new Postmate();
  handshake.then(child => {
    ...
    child.on('clicked', options.onClick);
  });
};

Now we can ensure the context for the callback is not altered.

There’s a bit more of code to make it work, but this can give you a good idea about how we can solve our communication problems and build a solid relationship with the host page.


* * *

This has been a pretty long post, sorry about that. Let’s recap briefly:

  • Unfortunately, closures and IIFE do not provide real isolated environments.
  • iframes are likely your better choice to build a solid sandbox for your widget. Just remember they are not silver bullets.
  • Use the postMessage API to ensure communication flows freely.

We wanted to describe some techniques that worked and didn’t work for us as well as the issues you might face while developing your awesome widget. We’d be really glad to hear any questions or other techniques you might have tried. Follow us on Twitter and ship lots of awesomeness to your customers!



blog comments powered by Disqus
Josue Montano
Josue Montano