Search code examples
javascriptcorscross-domainservice-worker

Can a service worker fetch and cache cross-origin assets?


I'm using some service worker code from the Progressive Web app tutorial by Google but I am getting an error:

Uncaught (in promise) TypeError:
 Failed to execute 'clone' on 'Response':
 Response body is already used

The site uses third-party Javascript and stylesheets for web fonts. I want to add assets hosted on these CDNs to the offline cache.

addEventListener("fetch", function(e) {
  e.respondWith(
    caches.match(e.request).then(function(response) {
        return response || fetch(e.request).then(function(response) {
        var hosts = [
          "https://fonts.googleapis.com",
          "https://maxcdn.bootstrapcdn.com",
          "https://cdnjs.cloudflare.com"
        ];
        hosts.map(function(host) {
          if (e.request.url.indexOf(host) === 0) {
            caches.open(CACHE_NAME).then(function(cache) {
              cache.put(e.request, response.clone());
            });
          }
        });
        return response;
      });
    })
  );
});

These are hosted on popular CDNs, so my hunch is they should be doing the right thing for CORS headers.

Here are the assets in the HTML that I want to cache:

<link rel="stylesheet" type="text/css"
      href="https://fonts.googleapis.com/css?family=Merriweather:900,900italic,300,300italic">
<link rel="stylesheet" type="text/css"
      href="https://fonts.googleapis.com/css?family=Lato:900,300" rel="stylesheet">
<link rel="stylesheet" type="text/css"
      href="https://maxcdn.bootstrapcdn.com/font-awesome/latest/css/font-awesome.min.css">
<script type="text/javascript" async
        src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_HTMLorMML">
</script>

According to the console logs, the service worker is trying to fetch these assets:

Fetch finished loading:
 GET "https://maxcdn.bootstrapcdn.com/font-awesome/latest/css/font-awesome.min.css".
 sw.js:32
Fetch finished loading:
 GET "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_HTMLorMML".
 sw.js:32
Fetch finished loading:
 GET "https://fonts.googleapis.com/css?family=Merriweather:900,900italic,300,300italic".
 sw.js:32
Fetch finished loading:
 GET "https://fonts.googleapis.com/css?family=Lato:900,300".
 sw.js:32
Fetch finished loading:
 GET "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/config/TeX-AMS-MML_HTMLorMML.js?V=2.7.1".
 sw.js:32
Fetch finished loading:
 GET "https://maxcdn.bootstrapcdn.com/font-awesome/latest/fonts/fontawesome-webfont.woff2?v=4.7.0".
 sw.js:32

If I remove the clone, as was suggested in Why does fetch request have to be cloned in service worker?, I'll get the same error:

TypeError: Response body is already used

If I add { mode: "no-cors" } to the fetch per Service worker CORS issue, I'll get the same error and these warnings:

The FetchEvent for
 "https://maxcdn.bootstrapcdn.com/font-awesome/latest/fonts/fontawesome-webfont.woff2?v=4.7.0"
 resulted in a network error response: an "opaque" response was
 used for a request whose type is not no-cors
The FetchEvent for
 "https://fonts.gstatic.com/s/lato/v14/S6u9w4BMUTPHh50XSwiPGQ3q5d0.woff2"
 resulted in a network error response: an "opaque" response was
 used for a request whose type is not no-cors
The FetchEvent for
 "https://fonts.gstatic.com/s/lato/v14/S6u9w4BMUTPHh7USSwiPGQ3q5d0.woff2"
 resulted in a network error response: an "opaque" response was
 used for a request whose type is not no-cors
The FetchEvent for
 "https://fonts.gstatic.com/s/merriweather/v19/u-4n0qyriQwlOrhSvowK_l521wRZWMf6hPvhPQ.woff2"
 resulted in a network error response: an "opaque" response was
 used for a request whose type is not no-cors

I could add these assets to the static cache in the service worker's install event, but I have reasons to add them to the cache only in the fetch event.


Solution

  • You're on the right track with using clone(), but the timing is important. You need to make sure that you call clone() before the final return response executes, because at that point, the response will be passed to the service worker's client page, and its body will be "consumed".

    There are two ways of fixing this: either call clone() prior to executing the asynchronous caching code, or alternatively, delay your return response statement until after the caching has completed.

    I'm going to suggest the first approach, since it means you'll end up getting the response to the page as soon as possible. I'm also going to suggest that you rewrite your code to use async/await, as it's much more readable (and supported by any browser that also supports service workers today).

    addEventListener("fetch", function(e) {
      e.respondWith((async function() {
        const cachedResponse = await caches.match(e.request);
        if (cachedResponse) {
          return cachedResponse;
        }
    
        const networkResponse = await fetch(e.request);
    
        const hosts = [
          'https://fonts.googleapis.com',
          'https://maxcdn.bootstrapcdn.com',
          'https://cdnjs.cloudflare.com',
        ];
    
        if (hosts.some((host) => e.request.url.startsWith(host))) {
          // This clone() happens before `return networkResponse` 
          const clonedResponse = networkResponse.clone();
    
          e.waitUntil((async function() {
            const cache = await caches.open(CACHE_NAME);
            // This will be called after `return networkResponse`
            // so make sure you already have the clone!
            await cache.put(e.request, clonedResponse);
          })());
        }
    
        return networkResponse;
      })());
    });
    

    Note: The (async function() {})() syntax might look a little weird, but it's a shortcut to use async/await inside an immediately executing function that will return a promise. See http://2ality.com/2016/10/async-function-tips.html#immediately-invoked-async-function-expressions

    For the original code, you need to clone the response before you do the asynchronous cache update:

            var clonedResponse = response.clone();
            caches.open(CACHE_NAME).then(function(cache) {
              cache.put(e.request, clonedResponse);
            });
    

    The Service Worker primer by Google has example code showing the correct way. The code has a comment with an "important" note, but it's just emphasizing the clone, and not the issue you're having about when you clone:

            // IMPORTANT: Clone the response. A response is a stream
            // and because we want the browser to consume the response
            // as well as the cache consuming the response, we need
            // to clone it so we have two streams.
            var responseToCache = response.clone();
    
            caches.open(CACHE_NAME)
              .then(function(cache) {
                cache.put(event.request, responseToCache);
              });
    
            return response;