Sandboxed Natives: Have Your Cake and Eat It, Too

John-David Dalton | October 13th, 2010

Introduction

Sandboxed natives, named after Dean Edwards' post on sandboxing JavaScript, are JavaScript natives that can be augmented without affecting those on the global/window object. They are supported in a variety of browsers, including Chrome 1+, Firefox 1.5+, IE 5.5+, Konqueror 4.2.2+, Opera 9.0+, and Safari 2.0+. Although not explicitly tested, sandboxed natives should also be compatible with most JavaScript engines, including Carakan, JaegerMonkey, JavaScriptCore, KJS, Nitro, Rhino, SpiderMonkey, SquirrelFish (+Extreme), Tamarin, TraceMonkey, and V8.

Simple pseudo-usage example:

var sb = new Sandbox();

    sb.Array.prototype.size = function() {

      return new sb.Number(this.length);

    };



    sb.String.prototype.capitalize = function() {

      return new sb.String(this.charAt(0).toUpperCase() + this.slice(1));

    };



    var arr = new sb.Array('a', 'b', 'c');

    arr.size(); // 3;



    var str = new sb.String('john');

    str.capitalize(); // John



    // Wow this is great!

    typeof Array.prototype.size; // undefined

    typeof String.prototype.capitalize; // undefined



    // But wait, lets make sure...

    arr[1]; // 'b'; Good.



    arr[4] = 'e';

    arr; // ['a', 'b', 'c', , 'e']; Great.



    arr.length = 3;

    arr; // ['a', 'b', 'c']; Fantastic!



    ({}).toString.call(arr); // [object Array]; I just geeked myself!!



    // exceed max length

    arr.length = Math.pow(2, 32); // throw RangeError; Ultraaaa Combooo!!!

Why We Need Them

Since their inception popular frameworks such as ExtJS, MooTools, and Prototype, have customized JavaScript by augmenting the prototypes of natives. The augmentation of prototypes is an incredibly powerful language feature, allowing developers to add missing functionality to JavaScript implementations (such as Microsoft's JScript), as well as extend the language with useful utility methods. Despite its advantages, the extension of global natives introduces several complications:

Broken for...in loops

Many developers consider it a bad practice to iterate over arrays with for...in loops. Nonetheless, when Prototype entered the JS scene in '05, developers noticed scripts, that used for...in loops to iterate over arrays, broke when the framework was included in the page, as methods added to prototypes are enumerable by default. Although ECMAScript 5th edition(ES5) allows the creation of non-enumerable properties via Object.defineProperty() and related methods, ECMAScript 3rd edition(the specification implemented by most browsers) provides no such mechanism. For example:

Array.prototype.size = function() {

      return this.length;

    };



    for (var i in ['a', 'b', 'c']) {

      alert(i); // alerts 0 then 1 then 2 then `size`

    }

Method Conflicts

Because natives are shared by all scripts on a page, if multiple scripts augment a native's prototype, the opportunities for conflicts increase. For instance, ExtJS and Prototype add different defer methods to the Function.prototype, causing errors when both frameworks are included on a page.

Specification Conflicts

Prior to their standardization in ES5, frameworks such as MooTools and Prototype began adding their own JavaScript 1.6/1.8 Array methods such as Array#every, Array#filter, Array#forEach, Array#map, Array#reduce, Array#some, and Function methods like Function#bind. Unfortunately, most framework implementations are not compliant with the ES5 specification, causing compatibility issues for browsers that have begun to support parts of the specification, and presenting inconsistent results for those that haven't.

Broken Native Implementations

Until recently, ExtJS, MooTools, and Prototype caused a stack overflow error when IE8's JSON.parse() was called with a reviver function on a JSON string that contained a stringified array. Additionally, many versions of MooTools, since 1.2.1, and Prototype, since 1.5.1, break the native JSON.stringify() implementation, either by defining a custom JSON object without a stringify method, or defining a custom Array#toJSON method. While MooTools and Prototype have addressed native JSON support in their 1.3 and upcoming 1.7 releases, respectively, both frameworks have done so more than a year and a half after native support for JSON was introduced.

A History of Solutions

In November '06, Dean Edwards attempted to subclass arrays by assigning the Array constructor of an iframe to a property on the parent window object. However, his approach lacked Safari support, polluted the window.frames collection, and threw errors in IE if the document.domain property was modified prior to the iframe's creation. Shortly after, Hedger Wang proposed an alternative technique that utilized function decompilation and IE's document.createPopup() method. Hedger's technique was adopted and subsequently dropped by Dojo due to numerous issues. Andrea Giammarchi followed with several-attempts-as well, inspiring the qooxdoo framework to implement their own variation. However, these attempts focused specifically on subclassing arrays, and either produced objects with the wrong [[Class]], lacked support for numeric properties, or failed to support the length property of arrays.

For more information on subclassing arrays and problems therein, I recommend reading Juriy Zaytsev's (aka*@kangax*)blog post on the subject.

A Triforce of Win

Sandboxed natives avoid the issues of other solutions because they are real natives and not subclassed objects. They can be created by either importing natives from an iframe, importing natives from a "htmlfile" ActiveX object, or rewiring global natives using the non-standard __proto__ property.

Iframes

Pros:

  • Supported by all major browsers except Safari
  • Fastest choice (many browsers auto-sandbox returned array and regular expression values of constructors and methods allowing method chaining without additional wrapping)

Cons:

  • IE6 throws mixed content warnings when using iframe sandboxes on pages served from the https:// protocol
  • IE throws an error if the document.domain is set before the sandbox is created
  • Chrome 5 won't allow iframe sandboxes to be used on pages served from the file:// protocol

The htmlfile ActiveX object

Pros:

  • Supported since IE4. (around 13 years)
  • Easiest to create
  • A fallback for IE when iframes aren't usable

Cons:

  • Slower than iframe sandboxes
  • Leaks memory if not managed

The non-standard __proto__ property

Pros:

  • Wide browser support (Supported by the latest versions of all major browsers except IE)
  • Allows sandboxed natives to work in non-browser environments
  • A fallback for browsers when iframes aren't usable

Cons:

  • Slower than iframe sandboxes

The Gist of It

The following are easy-to-digest examples of how to create sandboxed natives using the aforementioned techniques. However, there are several cross-browser bugs and other considerations (such as method chaining) that are not addressed. For a more complete picture please review the full source and screencasts.

Importing natives from an iframe

var getNativesFromIframe = (function() {

      // IE requires the iframe/htmlfile remain in the

      // cache or it will be corrupted

      var cache = [];

      return function() {

        var idoc, iframe, iwin, result,

         doc = document,

         parentNode = doc.body || doc.documentElement,

         name = 'sb' + (new Date).getTime();



        try {

          // set name attribute for IE (less than IE8 requires it)

          iframe = doc.createElement('<iframe name="' + name + '">');

        } catch (e) {

          // everyone else

          (iframe = doc.createElement('iframe')).name = name;

        }

        // hide for Opera which may render the iframe for a few milliseconds

        iframe.style.display = 'none';

        // insert before first child to avoid `Operation Aborted` errors in IE6

        parentNode.insertBefore(iframe, parentNode.firstChild);



        // easiest way to grab the iframe window

        iwin = window.frames[name];



        // add script tags to enable script access of natives

        idoc = iwin.document;

        idoc.write('<script><\/script>');

        idoc.close();



        // some browsers corrupt the sandboxed global over time

        // so we map the natives to an object

        result = {

          'Array':    iwin.Array,

          'Boolean':  iwin.Boolean,

          'Date':     iwin.Date,

          'Function': iwin.Function,

          'Object':   iwin.Object,

          'Number':   iwin.Number,

          'RegExp':   iwin.RegExp,

          'String':   iwin.String

        };



        cache.push(parentNode.removeChild(iframe));

        return result;

      };

    })();



    // usage

    var sb = getNativesFromIframe();

    sb.String.prototype.first = function() {

      return new sb.String(this.charAt(0));

    };

    

    var str = new sb.String('oh hai');

    str.first(); // o

Importing natives from an ActiveX object

var getNativesFromActiveX = (function() {

      var cache = [];

      return function() {

        var htmlfile = new ActiveXObject('htmlfile');

        // add script tags to enable script access of natives

        htmlfile.write('<script><\/script>');

        htmlfile.close();

        cache.push(htmlfile);

        return htmlfile.parentWindow;

      };

    })();

Rewiring natives using the non-standard __proto__ property

// follow along with the ES5 section references

    // http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-262.pdf



    var getNativesFromProto = function() {

      var sb = { }, slice = [].slice, toString = sb.toString;



      /* create constructors that return rewired natives */



      // ES5 15.4.1

      sb.Array = function(length) {

        var result = [], argLen = arguments.length;

        if (argLen) {

          // ensure sb.Array acts like window.Array

          if (argLen === 1 && typeof length === 'number') {

            result.length = length;

          } else {

            result.push.apply(result, arguments);

          }

        }

        // rewire the array’s __proto__ making it use

        // sb.Array.prototype instead of window.Array.prototype

        // when looking up methods/properties

        result.__proto__ = sb.Array.prototype;

        return result;

      };



      // ES5 15.6

      sb.Boolean = function(value) {

        var result = new Boolean(value);

        result.__proto__ = sb.Boolean.prototype;

        return result;

      };



      // ES5 15.9.2

      sb.Date = function(year, month, date, hours, minutes, seconds, ms) {

        var result;

        if (this.constructor === result.Date) {

          result = arguments.length === 1

            ? new Date(year)

            : new Date(year, month, date || 1, hours || 0, minutes || 0, seconds || 0, ms || 0);

          result.__proto__ = sb.Date.prototype;

        }

        else {

          result = sb.String(new Date);

        }

        return result;

      };



      // ES5 15.3.1

      sb.Function = function(argN, body) {

        var result = arguments.length < 3

          ? Function(argN || '', body || '')

          : Function.apply(Function, arguments);

        result.__proto__ = sb.Function.prototype;

        return result;

      };



      // ES5 15.7.1

      sb.Number = function(value) {

        var result = new Number(value);

        result.__proto__ = sb.Number.prototype;

        return result;

      };



      // ES5 15.2.1

      // convert global natives to sandboxed natives

      sb.Object = function(value) {

        var classOf, result;

        if (value != null) {

          switch (classOf = toString.call(value)) {

            case '[object Array]':

              if (value.constructor !== sb.Array) {

                result = sb.Array();

                result.push.apply(result, value);

                return result;

              }

              break;



            case '[object Boolean]':

              if (value.constructor !== sb.Boolean) {

                return sb.Boolean(value == true);

              }

              break;



            case '[object RegExp]':

              if (value.constructor !== sb.RegExp) {

                return sb.RegExp(value.source,

                  (value.global     ? 'g' : '') +

                  (value.ignoreCase ? 'i' : '') +

                  (value.multiline  ? 'm' : ''));

              }

              break;



            case '[object Date]'   :

            case '[object Number]' :

            case '[object String]' :

              classOf = classOf.slice(8, -1);

              if (value.constructor !== sb[classOf]) {

                return new sb[classOf](value);

              }

          }

          return value;

        }



        result = { };

        result.__proto__ = sb.Object.prototype;

        return result;

      };



      // ES5 15.10.4

      sb.RegExp = function(pattern, flags) {

        var result = new RegExp(pattern, flags);

        result.__proto__ = sb.RegExp.prototype;

        return result;

      };



      // ES5 15.5

      sb.String = function(value) {

        var result = new String(arguments.length ? value : '');

        result.__proto__ = sb.String.prototype;

        return result;

      };



      // set constructor's prototype as global native instances

      sb.Array.prototype    = new Array;

      sb.Boolean.prototype  = new Boolean;

      sb.Date.prototype     = new Date;

      sb.Function.prototype = new Function;

      sb.Number.prototype   = new Number;

      sb.RegExp.prototype   = new RegExp;

      sb.String.prototype   = new String;



      return sb;

    };

Performance

I have compiled JSLitmus test result samples comparing sandboxed natives against global natives and other implementations. Please try the benchmarks for yourself. (Higher is better. More operations per second translate into better performance.)

     

     

     

     

You will notice that sandboxed natives created by Fusebox perform at almost the same speeds as native constructors and methods that return arrays but are less performant for others. This is because Fusebox attempts to use the faster iframe technique by default but also supports method chaining at the cost of performance. For perspective compare the Fusebox results to the ArrayObject results as both support method chaining. Keep in mind that there is no real world performance difference for tests returning execution counts in the millions.

Chaining Example:

var fb = new Fusebox();

    fb.Array.prototype.size = function() {

      return fb.Number(this.length);

    };



    fb.String.prototype.capitalize = function() {

      return fb.String(this.charAt(0).toUpperCase() + this.slice(1));

    };



    // supports method chaining

    var str = fb.String('hello');

    str.split('').size(); // 5



    // produces a sandboxed array of sandboxed strings

    str.split('')[1].capitalize(); // E

What's Cool

Multiple sandbox instances

You may create multiple instances of sandboxed natives.

var a = new Fusebox;

    var b = new Fusebox;

    var c = new Fusebox;



    a.Array.prototype.a = 'a';

    b.Array.prototype.b = 'b';

    c.Array.prototype.c = 'c';



    a.Array().a; // `a`

    b.Array().a  // undefined

    b.Array().b  // `b`

    c.Array().b  // undefined

    c.Array().c  // `c`

DOM query results with jQuery style chaining

Using a selector engine like NWMatcher:

var $ = (function() {

      function xQuery(selector, context) {

        var result = fb.Array();

        select(selector, context, function(element) { result.push(element); });

        return result;

      }

      var fb = new Fusebox, select = NW.Dom.select;

      xQuery.fn = xQuery.prototype = fb.Array.prototype;

      return xQuery;

    })();



    $.fn.hide = function() {

      var length = this.length;

      while (length--) this[length].style.display = 'none';

      return this;

    };



    $.fn.show = function() {

      var length = this.length;

      while (length--) this[length].style.display = '';

      return this;

    };



    var widgets = $('div.widget');



    // chaining

    widgets.hide().show();



    // real array

    ({}).toString.call(widgets); // [object Array]

Framework Adoption

I've been hooked on the idea of frameworks using sandboxed natives since reading Dean's posts on subclassing arrays. Currently in alpha, FuseJS is the first JavaScript framework to use sandboxed natives. It is my hope, that as developers see the potential of sandboxed natives, other frameworks will implement them as well.

 

About the Author

My first JavaScript project was a Super Mario Bros. game engine I made in high school. I have always been drawn to JavaScript and other ECMAScript based languages. I spend most of my time tinkering with JavaScript frameworks, fixing bugs and running benchmarks. I love interacting with the JavaScript community and try to help as much as possible. I have a bachelor’s degree in Multimedia Instructional Design, an awesome wife, and a puppy.

Find John David Dalton on: