Search code examples
phphttp-headersxmlhttprequestcorssymfony

XMLHttpRequest progress tracking and Symfony (CORS)


I'm making a website using the Symfony framework, on an ovh server. When an user changes page, I create a new XMLHttpRequest to avoid to reload all the page and improve user experience.

Everything works well, but I want to add a loading bar while the next page is loading asynchronously. Unfortunately the lengthComputable parameter was false. To go through this issue, I've set the header on Symfony3 just before sending the response with theses lines :

$length = strlen($event->getResponse()->getContent());
$event->getResponse()->headers->set('Content-Length', $length);
$event->getResponse()->headers->set('Accept-Ranges', "bytes");
$event->getResponse()->sendHeaders();

This trick works in my local development server, lengthComputable is set to true and I can calculate the current load percentage. But when I put everything on my remote dev server, lengthComputable is false again.

Here is the response header using theses four lines :

Accept-Ranges:bytes
Accept-Ranges:bytes
Cache-Control:no-cache
Cache-Control:no-cache
Content-Encoding:gzip
Content-Length:3100
Content-Length:3100
Content-Type:text/html; charset=UTF-8
Date:Sat, 28 Jan 2017 12:34:08 GMT
Server:Apache
Set-Cookie:PHPSESSID=***; path=/; HttpOnly
Vary:Accept-Encoding

(And yes, some parameters are present two times)

I thing this is related to cross origin header policy, but I can't found a solution.

I've tried to set others parameters like

$responseHeaders->set('Access-Control-Allow-Headers', 'content-type');

and even

$responseHeaders->set('Access-Control-Allow-Origin', '*');

I followed and tried theses links

Symfony2. I can't set Content-Length header

How can I access the Content-Length header from a cross domain Ajax request?

CORS with php headers

EDIT

I'm not using any proxy, and my server is hosted on OVH.

Here is my javascript code (nothing exceptional)

ajax_oReq = new XMLHttpRequest();
ajax_oReq.addEventListener("progress", progress, false);
ajax_oReq.addEventListener("load", complete, false);
ajax_oReq.addEventListener("error", error, false);
ajax_oReq.addEventListener("abort", error, false);

params = "ajax=1";
if(url.indexOf('?')!=-1){ params = "&"+params; }else{ params = "?"+params; }

ajax_oReq.open("GET", url+params, true);
ajax_oReq.send();

In my progress(evt) function, I can see that lengthComputable is false by logging the evt parameter.

In my complete(evt) function, I put evt.target.response in my corresponding div.

I just want to add that when I use this code, I receive the last progress event after about 500ms with a loaded length equal to the total length of the page (so 100% of the document, but lengthComputable is false and total is equal to 0 so I know it just because I look my headers when the loading is end) But it takes about 5 more seconds for the complete function to be called. I thing this is because the content length isn't known. Anyway, when I remove my 4 lines of code (the first ones), this issue disappear but I always don't have lengthComputable=true ...

Thanks for your time !


Solution

  • So the problem was that Apache's mod_deflate was compressing the response in GZIP, resulting in the length not being computable client side (because Apache chunks the response from what I've read).

    The quick & dirty solution is to disable compression for that URL in your Apache settings. If you cannot, or do not want to in order to keep it optimized, there is a more elaborate solution taken from here

    $event->getResponse()->headers->set('x-decompressed-content-length', $length);
    

    then your progress function should look something like:

    var progress = function (e) {
          var contentLength;
          if (e.lengthComputable) {
            contentLength = e.total;
          } else {
            contentLength = e.target.getResponseHeader('x-decompressed-content-length');
          }
          var progress = (e.loaded / contentLength) * 100;
        };
    

    I'm not sure it will work that well tho, it depends if e.loaded is based on the compressed or decompressed response, but there are other possible leads for you on that page

    You could also try this, taken from here, i will let you translate it to Symfony because I'm not sure of how you are setting your content but basically it is about gzipping the data yourself beforehand so that Apache does not do it.

        // checks if gzip is supported by client
        $pack = true;
        if(empty($_SERVER["HTTP_ACCEPT_ENCODING"]) || strpos($_SERVER["HTTP_ACCEPT_ENCODING"], 'gzip') === false) //replace $_SERVER part with SF method
        {
            $pack = false;
        }
    
        // if supported, gzips data
        if($pack) {
            $replyBody = gzencode($replyBody, 9, FORCE_GZIP); // Watch out 9 is strong compression
            // Set SF's Response content with gzipped content here
            header("Content-Encoding: gzip"); // replace that with appropriate SF method
        } else {
            // Set SF's Response content with uncompressed content here
        }
    
        // compressed or not, sets the Content-Length           
        header("Content-Length: " . strlen($replyBody)); // replace that with appropriate SF methods
    

    So pretty much add the first two if in your controller, or wherever you set the Response's content and keep your Event Listener as is.

    Those are the two the least "hacky" solutions I could find. Further than that, your guess is as good as mine, I haven't tried it before.