jQuery Plugin for Requesting Ajax-like File Downloads

Posted by Scott on 03/02/2009

Ajax has changed the way we build web apps, allowing rich communication between the client and server without any need to refresh the page. But despite its power and flexibility, Ajax has numerous shortcomings such as a same-domain request policy and the inability to receive data without polling the server. For these limitations, we've seen workarounds such as JSONP and Comet.

One issue we have not yet seen addressed is the Ajax’s inability to receive a response in any form but text. Since it is now common for web applications to offer options for exporting your data in desktop app formats — such as .doc or .xls — we wrote a jQuery plugin to facilitate requests from the front end that result in a file for download. The plugin does not actually use Ajax, but its syntax follows the conventions of jQuery's native Ajax functions, making it a natural addition to our jQuery toolset.

The Problem

Let's take the example of a productivity web app such as a spreadsheet editor, which has the ability to open, save, import and export. The open and save options would involve loading a spreadsheet from the database, whereas import and export deal with local files on the user's machine. To implement the export behavior, you might decide that the user should have to save their spreadsheet first, allowing you to export the data from the backend to file. But let's assume instead you'd like to allow users to export their data without saving, perhaps to afford them the option of working locally without ever storing data on the server. In order to do this, you'd need to send the current spreadsheet data to the backend and receive a file to download. Unfortunately, this can not be handled using Ajax, since Ajax can only receive responses in the form of text. In cases where the data to be saved is rather lengthy, this poses a considerable problem.

The Workaround

In order to make the request, you'd need to make a regular (not Ajax) HTTP request using GET or POST. If the data is reasonably short, you might get away with a GET request (perhaps by simply setting Window.location to your export url), but due to varying browser limitations on GET request length, a POST will most likely be needed. The following plugin allows you to make a request that returns a file in a similar syntax to jQuery's native Ajax functions.

The Source Code

jQuery.download = function(url, data, method){
    //url and data options required
    if( url && data ){
        //data can be string of parameters or array/object
        data = typeof data == 'string' ? data : jQuery.param(data);
        //split params into form inputs
        var inputs = '';
        jQuery.each(data.split('&'), function(){
            var pair = this.split('=');
            inputs+='<input type="hidden" name="'+ pair[0] +'" value="'+ pair[1] +'" />';
        });
        //send request
        jQuery('<form action="'+ url +'" method="'+ (method||'post') +'">'+inputs+'</form>')
        .appendTo('body').submit().remove();
    };
};

How to Use It

Usage is simple. The plugin accepts 3 arguments for url, data, and method. You can pass data to these arguments just as you would to jQuery's \$.post or \$.get functions, and assuming the server has no problems handling the request, the front end will respond with a prompt for a file download and the user never needs to leave the page. Here's an example call to the plugin:

$.download('/export.php','filename=mySpreadsheet&format=xls&content=' + spreadsheetData );

As you can see, we've directed the request to the export.php file via the url argument, and our data argument is a key/value query string. Just like jQuery's Ajax functions, the data argument accepts either query parameters or a Javascript array or object. The method argument represents the HTTP method being used, and it defaults to 'post'. You can override this argument with 'get' depending on what your server is set up to receive.

Download (and help us improve) the code

This code is open source and available in a git repository, jQuery-File-Download. If you think you can help on a particular issue, please submit a pull request and we'll review it as soon as possible.

A Word of Caution

Since this plugin simply appends an HTML form to the page and submits it, you do run the risk of sending the user to a new page. This could occur if the server responds with anything other than a file for download (such as an error condition), so you'll want to take that into account when using this plugin.

600

Comments

why not append a hidden iframe and submit the iframe? that way, if the download fails, the user will not be sent away?

Comment by Luka Kladaric on 03/02  at  08:08 PM

sorry, forgot to subscribe…

Comment by Luka Kladaric on 03/02  at  08:08 PM

@Luka: good point. we could handle the post within an iframe and eliminate the possibility that the user might leave the page. I had done this at first but found that the only way I could clean up (remove) the iframe was on a timeout, since there wasn’t really an event to tie into. Thoughts?

Comment by Scott (Filament) on 03/02  at  09:09 PM

why would you want to clean up? it’s not likely to cause a lot of issues if you just create it as a child of BODY… use an unlikely ID (#filamentgroupajaxdownload, for instance), re-use if exists, and don’t bother cleaning up…

trapping errors would be nice, but that level of hackery is way out of my league

Comment by Luka Kladaric on 03/02  at  09:14 PM

@Luka: Good point. We’ll give it some thought. I do like the idea of gracefully handling an error response without leaving the page :)

Comment by Scott (Filament) on 03/02  at  09:39 PM

Using a single iframe could cause problems.
Maybe a connection pooler must be used in this case, with timeout per connection (a connection is actually a iframe element with random generated id) :)

Comment by Lyubomir Petrov on 03/02  at  09:55 PM

You can handle it with some server side code and include a header in the HTTP response so that you can be sure the request was successful.

I’ve done a similar thing (http://www.samaxes.com/2008/10/23/stripes-and-jquery-ajax-forms-and-http-session-validation/) to check if a HTTP session has expired.

Comment by Samuel Santos on 03/03  at  08:40 PM

It will be very easy to implement a server side code, but as i get the idea of the plugin the priority is the client side.

Also implementing a server side code, will make the plugin hard to use (deploy), so it will be best if all the things are done in the jq code :)

Comment by Lyubomir Petrov on 03/03  at  08:46 PM

yeah, server-side code is a bad idea… if you need it to work properly to detect errors, you’re screwed =)

a bad solution is no better than no solution

Comment by Luka Kladaric on 03/03  at  09:22 PM

I agree. It sure is a bad solution for a jQuery plugin.

Another idea to improve the iFrame approach might be to use Cross-document messaging http://dev.w3.org/html5/spec/Overview.html#crossDocumentMessages.
This is something already implemented by some of the major browsers (Opera, FF3, IE8).

This way you can clean up the iFrame as soon as it responds to the source document.
Might it work?

Comment by Samuel Santos on 03/03  at  10:28 PM

Is there any event that is triggered when the response of a file download occurs?  Suppose you need to enable a ‘submit’ button or change the state of an element when the File Download dialog appears, how would you do this?  I’ve tried your File download function, but I don’t see any way to do this.

Comment by Ralph Bohnet on 03/13  at  01:01 PM

Just wanted to thank you. I spent way too much time trying to get around this only to see the data come back in the response instead of a download. Exactly what I needed.

Comment by EWalsh on 04/01  at  11:48 PM

You can find download and installation instructions at RA Project, along with some screenshots. It is very easy to install and set up WP Ajax Edit Comments on your blog. So, give it a try and let me know what you think of it. I find it extremely handy and valuable.

Comment by ZK@Web Marketing Blog on 06/12  at  05:04 AM

The problem here: there is no way to tell when file is ready to be saved. I was looking for download file solution with a callback function to run when the request is successfully processed. With this plugin, it’s impossible. Instead I use location.href=fileURL and fileURL sends PHP headers to make sure browser prompts to save file. That’s it!

Comment by Sam on 07/16  at  06:00 PM

Great post! Thanks for the source code!

I´ve already subscribed the feeds.

Comment by Transportadora on 08/03  at  04:57 PM

Just wanted to thank you. I spent way too much time trying to get around this only to see the data come back in the response instead of a download. Exactly what I needed

Comment by افلام اجنبيه on 08/15  at  07:08 PM

I also tried it but couldn’t use successfully with the above loops holes. Hope to see it again after needful corrections.

Comment by stock investment tips on 09/29  at  11:54 AM

Maybe I am missing something but this dosen’t work for me, It just takes me to the destination page showing the image instead of prompting with a download dialog?

Comment by webDev1 on 11/12  at  10:52 AM

Comment by prasad on 11/19  at  11:54 PM

You are a genious!! thanks you very mutch!!

Comment by mjsilva on 12/22  at  03:38 PM

Now I can catch callback from server when download file. Code in here:

$.download = function(url, data, method, callback){
var inputs = ‘’;
var iframeX;
var downloadInterval;
if(url && data){
// remove old iframe if has
if($("#iframeX")) $("#iframeX").remove();
// creater new iframe
iframeX= $(’<iframe src="[removed]false;" name="iframeX" id="iframeX"></iframe>’).appendTo(’body’).hide();
if($.browser.msie){
downloadInterval = setInterval(function(){
// if loading then readyState is “loading” else readyState is “interactive”
if(iframeX&& iframeX[0].readyState !=="loading"){
callback();
clearInterval(downloadInterval);
}
}, 23);
} else {
iframeX.load(function(){
callback();
});
}

//split params into form inputs
$.each(data, function(p, val){
inputs+=’<input type="hidden" name=“‘+ p +’” value=“‘+ val +’” />’;
});

//create form to send request
$(’<form action=“‘+ url +’” method=“‘+ (method||’post’) + ‘“ target="iframeX">’+inputs+’</form>’).appendTo(’body’).submit().remove();
};
};

Note :
- If server return Content-Disposition header is inline, not attachment then both IE and Firefox can catch onload event else only Firefox can --> if header is attachment I will catch onload event by setInterval + readyState of iframe
- Data param is passed is object (ex : {property : value, ...}), not string (Because I want it :))
- You can pass params to callback if like but you need fake it more :)
- I tested it in IE 7, IE 8 and Firefox 3.5

Enjoy it !!!

Comment by openopen.sesame on 12/29  at  12:20 AM

I review my post and see src="j@v@script:false;" is changed to src="[removed]false;". It is important to $.download can catch callback (I don’t know why).

Comment by openopen.sesame on 12/29  at  12:27 AM

wow guys you are great.
I’ve used it with php word class and it is working great !

Comment by sallaboy on 01/05  at  07:04 PM

Nice! Just what I was looking for. Works out of the box as advertised.

Comment by Eric on 01/31  at  03:55 AM

thank you
i like this!!

Comment by muda1120 on 02/01  at  10:22 PM

Great idea....tested & approved :)

But you said that: “You can pass params to callback if like but you need fake it more”.
- Any idea how to implement this?

THX

Comment by Starke on 03/11  at  02:37 AM

sorry, my post was intended for openopen.sesames callback colution

Comment by Starke on 03/11  at  02:39 AM

Re “openopen.sesames” callback

I have found an easier callback firing solution:

Instead of all the conditional code for IE and other browser, you can use this 3 lines of code for callback firing:
Just use:

iframeX.ready(function(){
callback();
});
</pre>

Instead of:

if($.browser.msie){

downloadInterval = setInterval(function(){
// if loading then readyState is “loading” else readyState is “interactive”
if(iframeX&& iframeX[0].readyState !=="loading"){
callback();
clearInterval(downloadInterval);
}
}, 23);
} else {
iframeX.load(function(){
callback();
});
</pre>
This works both for IE and Firefox. Even tested on Firefox 2.0.0.2, which did not run the iframeX.load(part)

But I still don’t know how to pass server side params to callback method… :(

Comment by Starke on 03/12  at  09:09 AM

If you have a lot of data to add into the form you could end up with a “script stack space quota is exhausted” error. This is because jQuery can’t handle adding the form, and all the inputs all at once. The following modification creates the form, adds inputs, and sets their values in separate steps which is preferred:

jQuery.download = function(url, data, method){
//url and data options required
if( url && data ){

data = typeof data == ‘string’ ? data : jQuery.param(data);
var inputs = ‘’;
var form = jQuery(’<form action=“‘+ url +’” method=“‘+ (method||’post’) +’"></form>’);
jQuery.each(data.split(’&’), function(){
var pair = this.split(’=’);
form.append(’<input type="hidden" name=“‘+ pair[0] +’” id=“‘+ pair[0] +’"/>’);
form.find(’#’ + pair[0]).val(escape(pair[1]));
});

//submit the form
form.appendTo(’body’).submit().remove();
};
};

Comment by Domenic on 07/14  at  08:59 AM

Sorry, remove var inputs=’’ from above. Not needed. Updated code that I actually use:
jQuery.download = function(url, data, method){
//url and data options required
if( url && data ){
//data can be string of parameters or array/object
data = typeof data == ‘string’ ? data : jQuery.param(data);
var form = jQuery(’<form action=“‘+ url +’” method=“‘+ (method||’post’) +’"></form>’);
//split params into form inputs
jQuery.each(data.split(’&’), function(){
var pair = this.split(’=’);
form.append(’<input type="hidden" name=“‘+ pair[0] +’” id=“‘+ pair[0] +’"/>’);
form.find(’#’ + pair[0]).val(escape(pair[1]));
});

//submit the form
form.appendTo(’body’).submit().remove();
};
};

Comment by Domenic on 07/14  at  09:03 AM

I add target parameter, instead of iFrame:

jQuery.download = function(url, data, method, target){
//url and data options required
if( url && data ){
//data can be string of parameters or array/object
data = typeof data == ‘string’ ? data : jQuery.param(data);
//split params into form inputs
var inputs = ‘’;
jQuery.each(data.split(’&’), function(){
var pair = this.split(’=’);
inputs+=’<input type="hidden" name=“‘+ pair[0] +’” value=“‘+ pair[1] +’” />’;
});
//send request
jQuery(’<form action=“‘+ url + ‘“ target=”’ + (target||’_blank’) + ‘”’ + ‘“ method=“‘+ (method||’post’) +’">’+inputs+’</form>’)
.appendTo(’body’).submit().remove();
};
};

I run good FF, IE, Chrome.

Comment by UltraJoom on 08/06  at  10:01 PM

Thanks for this,

I had problems trying to use $.download with a json string as a post parameter.
Solved this issue:

“//split params into form inputs
“$.each(data, function(p, val){
val = val.replace(/"/g,’"’);
inputs+=’<input type="hidden" name=“‘+ p +’” value=“‘+ val +’” />’;
“});

I added on line 32:  val = val.replace(/"/g,’"’);
it replaces double quotes (used in JSON strings) by their htmlentities format

Comment by Younès on 08/24  at  11:32 AM

Hi, it’s a nice plugin.

I had to change one thing:

When I sent data with “field_name[]” (with brackets), the input names where with &#x5B; in the place of the brackets…

added the line after set data var
data = decodeURIComponent(data);

maybe it can be improved, but now it’s working for me.

Thanks.

Comment by Lucas Alves on 09/23  at  08:37 AM

@UltraJoom

The target attribute of <form> is deprecated, and is not supported in HTML 4.01 Strict / XHTML 1.0 Strict DTD.

http://www.w3schools.com/TAGS/att_form_target.asp

Comment by Lucas Alves on 09/23  at  08:50 AM

In open sesame’s callback solution i removed browser specific code with the following approach. It worked (tested on firefox 3.5, chrome, IE6, IE7 and IE8)

replace:

<snippet>
if($.browser.msie){
downloadInterval = setInterval(function(){
// if loading then readyState is “loading” else readyState is “interactive”
if(iframeX&& iframeX[0].readyState !=="loading"){
callback();
clearInterval(downloadInterval);
}
}, 23);
} else {
iframeX.load(function(){
callback();
});
}
</snippet>

with

<snippet>

if (iframeX.attachEvent){
iframeX.attachEvent("load", function(){
callback();
});
} else {
iframeX.load(function() {
callback();
});
}

</snippet>

But my question is how does this iframe approach work. Why is the callback called when download fails?

Comment by lazy functor on 09/27  at  04:00 AM

i am harish kumar . so i want .........

Comment by harish on 10/30  at  06:26 AM

See my previous post about sending JSON data :

add on line 32:  val = val.replace(/"/g,’"’);

NOT val = val.replace(/"/g,’"’);

Comment by Younès on 11/09  at  12:33 PM

Open Sesame solutions does NOT WORK. jquery load is not triggered on downloaded files, just on html loading. im currently working an alternate solution..

Comment by Ricardo Oliveira on 11/26  at  05:50 AM

When data contains some special or non-english symbols, we have $_POST value like ‘&#x3E;..’ Using this, we take some spaces replaced plus symbol (’+’):

added the line after set data var
data = decodeURIComponent(data);

I use this line for “decoding” after jquery.param function (when plus symbol is not allowed in data):
data = decodeURIComponent(data).split(’+’).join(’ ‘);

Comment by Levik on 12/15  at  06:31 PM

Thanks for this wonderful post.

Comment by Rahul on 02/17  at  04:47 AM

Cool. Thanks for this good and obvious solution. Ran into the GET/POST-barrier in AJAX myself, when trying to create a downloadable zip on the fly containing multiple images - See Image Extractor. I was actually considering a total failsafe solution by inserting all params in a database before submitting, but the idea with a “proxy"-form works like a charm. Tank you!

Comment by David K on 06/11  at  08:22 PM

Hi,

In rails 3.0, if CSRF protection is enabled, this doesn’t work. I added these lines to solve the problem (after line 22):

var token = $("meta[name=’csrf-token’]");
if (token.size()) {
inputs += ‘<input type="hidden" name="authenticity_token" value=”’ + token.attr(’content’) + ‘“ />’;
}

David

Comment by David Heath on 07/05  at  08:43 AM