blog

Image of aliens

jQuery Ajax Blobs and Array Buffers

by

A big part of what makes jQuery a regular part of so many web projects is the clean interface it offers us for a number of sometimes messy built-in aspects of javascript. The most obvious is the DOM interface; and in second place, jquery ajax and its various shorthand methods. Abstracting away the difference between ActiveXObject and XMLHttpRequest is one of the most obvious benefits – but even if you don’t need to worry about supporting old versions of IE, you might well enjoy the clean, object-based, promise-returning interface that jquery ajax offers.

It’s a shame then, that if you want to take advantage of XMLHttpRequest Level 2 features like Blob and ArrayBuffer uploading/downloading, you have to fall back to the standard javascript api.

Let’s fix that, shall we?

First, let’s have an example of what recieving a Blob might look like with the standard api:

var xhr = new XMLHttpRequest();
xhr.addEventListener('load', function(){
    if (xhr.status == 200){
        //Do something with xhr.response (not responseText), which should be a Blob
    }
});
xhr.open('GET', 'http://target.url');
xhr.responseType = 'blob';
xhr.send(null);

It’s not terrible, but we’re sure to miss being able to dangle our .done and .fail blocks off of our nice, compact $.ajax request. What we want is to be able to do something like this:

$.ajax({
    dataType:'blob',
    type:'GET',
    url:'http://target.url'
}).done(function(blob){
    // Do something with the Blob returned to us from the ajax request
});

So, we’re going to borrow a trick we discussed back in our Ajax Caching article, and create an Ajax Transport to handle sending and receiving Blobs and ArrayBuffers.

Final code is included at the end of the article (for those who want to get straight to the good stuff), but let’s walk through it and discuss what’s happening.

$.ajaxTransport('+*', function(options, originalOptions, jqXHR){

Notice the "+" string? If you recall from our Ajax Caching article, this string indicates what dataType we want to match against when considering whether to use the transports defined in the following function. There we set type ‘json’, since we only wanted to cache json replies. Here though, we set ‘‘ to match all types (we’ll narrow our definition down inside the function proper – we just want to make sure that our function gets a crack at providing the transport whenever appropriate, and the expected return dataType could be anything if we’re sending a blob or arraybuffer).

But what’s with the ‘+’ preceeding the ‘*’? That’s a little (undocumented) trick found from perusing the jquery source – it indicates we want our transport to be prepended to the list of available transports, as opposed to appended. When matching against all types, we need to include this to prevent the standard transport factory from hogging the request.

if (window.FormData && ((options.dataType && (options.dataType == 'blob' || options.dataType == 'arraybuffer'))
        || (options.data && ((window.Blob && options.data instanceof Blob)
            || (window.ArrayBuffer && options.data instanceof ArrayBuffer)))
        ))
    {

I told you we’d narrow down our definition. 🙂

So what’s this monster if statement checking against? First, we check against window.FormData; as a feature of XMLHttpRequest Level 2, its a reasonable way to feature detect whether the browser is ready to provide us the necessary features for blob/arraybuffer sending/receiving.

Next, we check to see whether a dataType has been defined, and if so, whether the dataType is blob or arraybuffer, indicating we’re expecting our request to return us one of these data types.

Otherwise, we check to see whether data has been defined, and if so, whether the browser supports Blobs and our data is an instance of Blob, or the same for array buffers. If the browser supports XMLHttpRequest 2 and we’re either sending or receiving a blob/arraybuffer (or sending AND receiving), then we provide the transport. Remember, for $.ajaxTransport, we indicate we’re providing the transport by returning something. Returning nothing indicates that the next transport factory should try to handle this request.

        return {
            send: function(headers, completeCallback){
                var xhr = new XMLHttpRequest(),
                    url = options.url || window.location.href,
                    type = options.type || 'GET',
                    dataType = options.dataType || 'text',
                    data = options.data || null,
                    async = options.async || true;
                xhr.addEventListener('load', function(){
                    var res = {};
                    res[dataType] = xhr.response;
                    completeCallback(xhr.status, xhr.statusText, res, xhr.getAllResponseHeaders());
                });
                xhr.open(type, url, async);
                xhr.responseType = dataType;
                xhr.send(data);
            },
            abort: function(){
                jqXHR.abort();
            }
        };

As you’ll recall from our Ajax Caching article, we need to provide two functions for a transport – send, and abort. The body of send should look familiar here – we consider the options set in the ajax options block, and set reasonable defaults if the needed options aren’t set, instantiate a new XMLHttpRequest object, open the request, set the appropriate response type, and send data as appropriate. In abort, we simply call abort on the jqXHR object passed to the transport factory function.

So where is this useful? You remember our FileReader/XHR2 plugin, right? This is a complement (and partial replacement, if desired) to that – now we can receive blobs/arraybuffers from the server, as well as send them up, all within the comfortably familiar jQuery interface. Neat, huh?

But wait! The plugin gives us feedback on upload progress! Can we do that with this ajax transport?

Of course! Take a look at our xhr2 plugin post again, and you’ll see where we’re listening in on the ‘progress’ event on our XMLHttpRequest object. The relevant handler function looks like:

DeferXhr.prototype.uploadProgress = function(event){
        if (event.lengthComputable)
        {
            this.progress = (event.loaded/event.total);
            if (this.options.chunked)
            {
                this.progress *= (this.end/this.file.size);
            }
            this.time.end = +new Date();
            this.time.speed = (this.file.size*this.progress)/(this.time.end-this.time.start)*1000;
            console.log('time:', this.time.end-this.time.start, 'speed:', this.time.speed);
            this.defer.notify({state:Hup.state.FILE_UPLOAD_PROGRESS, file_name:this.file.name, speed:this.time.speed,
                progress:this.progress});
        }
    };

By adding a similar handler function with our ajax transport factory on the progress event, we can make calls to jqXHR.notify to send progress events and data to any listening .progress handlers dangling from our $.ajax call.

This, however, I leave as an exercise for the reader. 🙂

If you like, please feel free to push the relevant changes to the gist in question.

Here’s the promised code:

(function($){
    /**
     * Register ajax transports for blob send/recieve and array buffer send/receive via XMLHttpRequest Level 2
     * within the comfortable framework of the jquery ajax request, with full support for promises.
     *
     * Notice the +* in the dataType string? The + indicates we want this transport to be prepended to the list
     * of potential transports (so it gets first dibs if the request passes the conditions within to provide the
     * ajax transport, preventing the standard transport from hogging the request), and the * indicates that
     * potentially any request with any dataType might want to use the transports provided herein.
     *
     * Remember to specify 'processData:false' in the ajax options when attempting to send a blob or arraybuffer -
     * otherwise jquery will try (and fail) to convert the blob or buffer into a query string.
     *
     * This revision now includes sending headers, resolves the stack overflow in abort(), and sets the status text
     * into the response if the request is unsuccessful.
     */
    $.ajaxTransport("+*", function(options, originalOptions, jqXHR){
        // Test for the conditions that mean we can/want to send/receive blobs or arraybuffers - we need XMLHttpRequest
        // level 2 (so feature-detect against window.FormData), feature detect against window.Blob or window.ArrayBuffer,
        // and then check to see if the dataType is blob/arraybuffer or the data itself is a Blob/ArrayBuffer
        if (window.FormData && ((options.dataType && (options.dataType == 'blob' || options.dataType == 'arraybuffer'))
            || (options.data && ((window.Blob && options.data instanceof Blob)
                || (window.ArrayBuffer && options.data instanceof ArrayBuffer)))
            ))
        {
            var xhr;
            return {
                /**
                 * Return a transport capable of sending and/or receiving blobs - in this case, we instantiate
                 * a new XMLHttpRequest and use it to actually perform the request, and funnel the result back
                 * into the jquery complete callback (such as the success function, done blocks, etc.)
                 *
                 * @param headers
                 * @param completeCallback
                 */
                send: function(headers, completeCallback){
                    var url = options.url || window.location.href,
                        type = options.type || 'GET',
                        dataType = options.dataType || 'text',
                        data = options.data || null,
                        async = options.async || true;
                    xhr = new XMLHttpRequest();
                    xhr.addEventListener('load', function(){
                        var res = {},
                            success = xhr.status >= 200 && xhr.status < 300 || xhr.status === 304;
                        if (success){
                            res[dataType] = xhr.response;
                        } else {
                            res.text = xhr.statusText;
                        }
                        completeCallback(xhr.status, xhr.statusText, res, xhr.getAllResponseHeaders());
                    });
                    xhr.open(type, url, async);
                    xhr.responseType = dataType;
                    for (var key in headers){
                        if (headers.hasOwnProperty(key)){
                            xhr.setRequestHeader(key, headers[key]);
                        }
                    }
                    xhr.send(data);
                },
                abort: function(){
                    if (xhr){
                        xhr.abort();
                    }
                }
            };
        }
    });
})(jQuery);

You can also find it here.

+ more