9

I'm struggling to create a simple POC for iOS PWA with a small video.

https://test-service-worker.azurewebsites.net/

I have simple service worker registration and I cache a small (700kB) video. When I'm online the page works just fine. When I turn on airplane mode and go offline, the page is still reloaded but video will not play.

This POC is based on Google Chrome example https://googlechrome.github.io/samples/service-worker/prefetch-video/ The video from this example will not work in iOS offline for sure because it only caches 50MB. Mine is only 700kB so well below the limit.

My POC works just fine in Chrome but it won't in the latest mobile Safari (iOS 11.4).

What do I need to change in order to make this work on iOS 11.4+? Is this a bug in Safari?

milanio
  • 4,082
  • 24
  • 34

1 Answers1

15

It turns out, Safari is just quite strict. I'm leaving the question here - hopefully it will save someones time.

What's happening:

  1. Safari requests only part of the video - first it will request 'range: bytes=0-1' response. It expects HTTP 206 response which will reveal size of the file

  2. Based on the response it learns what is the length of the video and then it asks for individual byte ranges of the file (for example range: bytes=0-20000 etc.)

If your response is longer than requested Safari will immediately stop processing subsequent requests.

This is exactly what is happening in Google Chrome example and what was happening in my POC. So if you use fetch like this it will work both online & offline:

//This code is based on  https://googlechrome.github.io/samples/service-worker/prefetch-video/ 

self.addEventListener('fetch', function(event) {
  
  headersLog = [];
  for (var pair of event.request.headers.entries()) {
    console.log(pair[0]+ ': '+ pair[1]);
    headersLog.push(pair[0]+ ': '+ pair[1])
 }
 console.log('Handling fetch event for', event.request.url, JSON.stringify(headersLog));

  if (event.request.headers.get('range')) {
    console.log('Range request for', event.request.url);
    var rangeHeader=event.request.headers.get('range');
    var rangeMatch =rangeHeader.match(/^bytes\=(\d+)\-(\d+)?/)
    var pos =Number(rangeMatch[1]);
    var pos2=rangeMatch[2];
    if (pos2) { pos2=Number(pos2); }
    
    console.log('Range request for '+ event.request.url,'Range: '+rangeHeader, "Parsed as: "+pos+"-"+pos2);
    event.respondWith(
      caches.open(CURRENT_CACHES.prefetch)
      .then(function(cache) {
        return cache.match(event.request.url);
      }).then(function(res) {
        if (!res) {
          console.log("Not found in cache - doing fetch")
          return fetch(event.request)
          .then(res => {
            console.log("Fetch done - returning response ",res)
            return res.arrayBuffer();
          });
        }
        console.log("FOUND in cache - doing fetch")
        return res.arrayBuffer();
      }).then(function(ab) {
        console.log("Response procssing")
        let responseHeaders=  {
          status: 206,
          statusText: 'Partial Content',
          headers: [
            ['Content-Type', 'video/mp4'],
            ['Content-Range', 'bytes ' + pos + '-' + 
            (pos2||(ab.byteLength - 1)) + '/' + ab.byteLength]]
        };
        
        console.log("Response: ",JSON.stringify(responseHeaders))
        var abSliced={};
        if (pos2>0){
          abSliced=ab.slice(pos,pos2+1);
        }else{
          abSliced=ab.slice(pos);
        }
        
        console.log("Response length: ",abSliced.byteLength)
        return new Response(
          abSliced,responseHeaders
        );
      }));
  } else {
    console.log('Non-range request for', event.request.url);
    event.respondWith(
    // caches.match() will look for a cache entry in all of the caches available to the service worker.
    // It's an alternative to first opening a specific named cache and then matching on that.
    caches.match(event.request).then(function(response) {
      if (response) {
        console.log('Found response in cache:', response);
        return response;
      }
      console.log('No response found in cache. About to fetch from network...');
      // event.request will always have the proper mode set ('cors, 'no-cors', etc.) so we don't
      // have to hardcode 'no-cors' like we do when fetch()ing in the install handler.
      return fetch(event.request).then(function(response) {
        console.log('Response from network is:', response);

        return response;
      }).catch(function(error) {
        // This catch() will handle exceptions thrown from the fetch() operation.
        // Note that a HTTP error response (e.g. 404) will NOT trigger an exception.
        // It will return a normal response object that has the appropriate error code set.
        console.error('Fetching failed:', error);

        throw error;
      });
    })
    );
  }
});
milanio
  • 4,082
  • 24
  • 34
  • how'd you fix this? – Andy Hin Nov 21 '18 at 01:28
  • Sorry @AndyHin what do you mean? The code above is the fix. It works just fine. – milanio Nov 21 '18 at 13:08
  • I'm having trouble with my implementation of this, perhaps you have the answer? https://stackoverflow.com/questions/54138601/cant-access-arraybuffer-on-rangerequest – Uriah Blatherwick Jan 11 '19 at 15:48
  • I was able to implement this fix but ran into a bit of a problem because our content was coming from a CDN without cors headers and so it was considered "opaque" meaning we couldn't see the details of the request stream and it was causing the code to fail. I have examples of working code in my SO post here: https://stackoverflow.com/questions/54138601/cant-access-arraybuffer-on-rangerequest/54207122 I hope this helps the next person. – Uriah Blatherwick Jan 31 '19 at 20:32
  • 2
    Where do I put the code above? I have a create-react-app – Philipp Jahoda Jun 26 '20 at 14:31
  • You saved my day. Thanks! – javinievas Dec 01 '21 at 00:10
  • @milanio, your solution works fine when I try to load the video from cache when wifi is on (ie, the safari requests two range requests 1. bytes=0-1 2. bytes=0-sizeOfVideo), but when I turn off wifi, my video tag is blank with 0:00 timestamp and only one range request is made (bytes=0-1) and does not make any other request, any idea why? – Shri Jan 09 '22 at 14:51
  • This snipped helped me very much. Only it doesn't work when the video is in WebM format. Deleting the `Content-Type` line seems to help if I want to support both MP4/WebM formats. – Petr Dlouhý May 17 '22 at 21:34