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

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.

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;

Related

Using images and CSS to offline fallback page

I am trying to set up my website to have a fallback page when it is loaded without an internet connection. To do that, I am following this guide on web.dev: "Create an offline fallback page"
I modified the example ServiceWorker in the article to fit my purposes, including being able to serve external CSS and images in the fallback offline page:
// Incrementing OFFLINE_VERSION will kick off the install event and force
// previously cached resources to be updated from the network.
const OFFLINE_VERSION = 1;
const CACHE_NAME = "offline";
// Customize this with a different URL if needed.
const OFFLINE_URL = "offline.html";
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
// Setting {cache: 'reload'} in the new request will ensure that the response
// isn't fulfilled from the HTTP cache; i.e., it will be from the network.
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
await cache.add(new Request("offline.css", { cache: "reload" }));
await cache.add(new Request("logo.png", { cache: "reload" }));
await cache.add(new Request("unsupportedCloud.svg", { cache: "reload" }));
})()
);
});
self.addEventListener("activate", (event) => {
// Tell the active service worker to take control of the page immediately.
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
// We only want to call event.respondWith() if this is a navigation request
// for an HTML page.
if (event.request.mode === "navigate") {
if (event.request.url.match(/SignOut/)) {
return false;
}
event.respondWith(
(async () => {
try {
const networkResponse = await fetch(event.request);
return networkResponse;
} catch (error) {
// catch is only triggered if an exception is thrown, which is likely
// due to a network error.
// If fetch() returns a valid HTTP response with a response code in
// the 4xx or 5xx range, the catch() will NOT be called.
console.log("Fetch failed; returning offline page instead.", error);
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(OFFLINE_URL);
return cachedResponse;
}
})()
);
}
});
However, when the offline.html page loads it does is unable to load the images and the CSS; the images fail to load with a 404 error and the request for the CSS doesn't even show in the Network tab of the browser dev console.
I would expect the images and CSS to be fetched from the ServiceWorker cache, but it seems that neither is.
Am I missing something on how ServiceWorkers cache requests or how they fetch them? Or on how to design the offline fallback page to work?
Turns out there were a few reasons why the assets were not being found.
The first reason is because when they were saved to cache, they were saved with the entire path where they are stored alongside the Service Worker file.
So the path that was saved was something along the lines of static/PWA/[offline.css, logo.png, unsupportedCloud.svg] but the path of the page that requested them was in the root. In offline.html I had to reference them as such: <img src="static/PWA/unsupportedCloud.svg" class="unsupported-cloud" />.
The second reason is that the Service Worker only checks for fetch events were of type navigation. In my example you can see I had written if (event.request.mode === "navigate") {...} so we only attempted to use the cache that we set up in navigation events, which would not catch fetch events to get assets. To fix that, I set up a new check for no-cors event modes: else if (event.request.mode === "no-cors") {...}.
These two fixes let me get assets from the offline cache that I set up on Service Worker installation. With some other minor fixes, this addresses my question!

How to properly prefetch a json endpoint in Chrome?

I am trying to speed up the network critical path on a website, and find out about the great <link rel=preload. So I try to anticipate the call that my single page application do as soon as the JS kicks in, I have put in my index.html
<link rel="preload" href="/api/searchItems" as="fetch" />
Then as the JS starts I make the same call with the help of the axios library:
await axios.get(`/api/searchItems`, { params: queryParams });
I would expect to see the call of Axios returning instantly the preloaded JSON file but instead, I see this:
As you can see the same call is loaded twice.
What I am doing wrong?
EDIT: I have added cache-control: public and nothing changes.
EDIT2: I also tried this code instead of axios:
let data = await fetch('/api/searchItems')
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error('HTTP error ' + response.status);
})
.catch(() => {
data = null; // Just clear it and if it errors again when
// you make the call later, handle it then
});
And nothing change
Three options for you:
It looks like your response has headers making it uncacheable for some reason. You may be able to fix it so it's cacheable.
Use a service worker.
Another approach, if this is really critical path, is to have some inline JavaScript that actually does the call and modify the code that will do the call later to look to see if the previous result is available, like this:
let firstLoad = fetch("/api/searchItems")
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error("HTTP error " + response.status);
})
.catch(() => {
firstLoad = null; // Just clear it and if it errors again when
// you make the call later, handle it then
});
(I'm using fetch there because you may want to do it before you've loaded axios.)
Then in the code that wants this data:
(firstLoad || axios.get("/api/searchItems").then(response => response.data))
.then(/*...*/)
.catch(/*...*/);
firstLoad = null;
If the content requires revalidation (and you're using no-cache, so it does¹), #2 and #3 have the advantage of not requiring a second request to the server.
¹ From MDN:
no-cache
The response may be stored by any cache, even if the response is normally non-cacheable. However, the stored response MUST always go through validation with the origin server first before using it...
(my emphasis)

Get response header of cached response inside service worker

I want to get the response headers of a cached response inside a service worker. The purpose of this is so that I can read a custom header called 'Modified' to see if it is necessary to fetch a new copy of the data by comparing it to the response headers of a 'HEAD' fetch for the same URL.
On install of the service worker, I populate a cache called v1::fundamentals with some responses. I then register a fetch listener which looks for the request in the cache and if its there, serves it. I then want to async update the cache with non-stale content but only if the 'Modified' header contains a newer timestamp than the one in the cache. In the simplified code below, I try to access the headers with headers.get() but I always get a null in return. Why is this?
When I look at the cache in Chrome devtools, the headers are very much there, I just can't get to them from within the service worker.
self.addEventListener('fetch', event => {
console.log('%c[SW] Fetch caught: ', 'color: #42d9f4', event.request.url);
// Let the browser do its default thing for non-GET requests.
if (event.request.method != 'GET') {
return;
} else {
// Prevent the default, and handle the request ourselves.
event.respondWith(async function() {
// Try to get the response from a cache.
const cache = await caches.open('v1::fundamentals');
const cachedResponse = await cache.match(event.request);
if (cachedResponse) {
// Try to get the headers
var cacheDate = cachedResponse.headers.get('Modified');
// Print header, returns 'null'
console.log(cacheDate);
event.waitUntil(cache.add(event.request));
return cachedResponse;
}
return fetch(event.request);
}());
}
});

How to fallback to browser's default fetch handling within event.respondWith()?

Within the service worker my fetch handler looks like this:
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request); //<-- is this the browser's default fetch handling?
})
);
});
The method event.respondWith() forces me to handle all requests myself including xhr requests which is not what I like todo. I only want the cached resources to be returned if available and let the browser handle the rest using the browser's default fetch handling.
I have two issues with fetch(event.request):
Only when devtools is opened it produces an error while fetching the initial URL which is visible in the address bar https://test.de/x/#/page. It happens both on initial install and on every reload:
Uncaught (in promise) TypeError: Failed to execute 'fetch' on 'ServiceWorkerGlobalScope': 'only-if-cached' can be set only with 'same-origin' mode`
and I don't understand why because I am not setting anything
It seems to violate the HTTP protocol because it tries to request a URL with an anchor inside:
Console: {"lineNumber":0, "message":"The FetchEvent for
\"https://test.de/x/#/page\" resulted in a network error
response: the promise was rejected.", "message_level":2, "sourceIdentifier":1, "sourceURL":""}`
How does fetch() differ from the browser's default fetch handling and are those differences the cause for those errors?
Additional information and code:
My application also leverages the good old appCache in parallel with the service worker (for backwards compatibility). I am not sure if the appcache interferes with the service worker installation on the initial page load. The rest of the code is pretty straight forward:
My index.html at https://test.de/x/#/page uses appcache and a base-href:
<html manifest="appcache" lang="de">
<head>
<base href="/x/"/>
</head>
...
Service Worker registration within the body script
window.addEventListener('load', {
navigator.serviceWorker.register('/x/sw.js')
});
Install and activate event
let MY_CACHE_ID = 'myCache_v1';
let urlsToCache = ['js/main.js'];
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open(MY_CACHE_ID)
.then(function (cache) {
return cache.addAll(
urlsToCache.map(url => new Request(url,
{credentials:'include'}))
)
})
);
});
self.addEventListener('activate', function (event) {
//delete old caches
let cacheWhitelist = [MY_CACHE_ID];
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames.map(function (cacheName) {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});
fetch(event.request) should be really close to the default. (You can get the actual default by not calling respondWith() at all. It should mostly not be observable, but is with CSP and some referrer bits.)
Given that, I'm not sure how you're ending up with 1. That should not be possible. Unfortunately, you've not given enough information to debug what is going on.
As for 2, it passes the fragment on to the service worker, but that won't be included in the eventual network request. That matches how Fetch is defined and is done that way to give the service worker a bit of additional context that might be useful sometimes.

JS Service Worker running on pages I don't want it to run on

I just started trying to use service workers to cache files from my web server. I'm using the exact code that google suggests HERE. I uploaded the service worker script to my sites root folder and have included a link to it in the pages I want the service worker to cache files on.
I was under the impression that the service worker only caches files that are in the urlsToCache variable, and the service worker would only work on pages where the service worker script is called (linked to).
I have several pages on my site that I don't want the service worker to do anything on. However, it seems that it's still being referenced somehow. The pages in question do not contain a link to my service worker script at all. I've noticed that each time I run an AJAX command using the POST method I receive the following error in my console:
Uncaught (in promise) TypeError: Request method 'POST' is unsupported
at service-worker.js:40
at anonymous
line 40 is this snippet of code: cache.put(event.request, responseToCache);
The url my AJAX call is pointing to does not contain a link to my service worker script either.
So my question is a two parter.
1.) Does anyone understand the error message I'm receiving and know how to fix it?
2.) Why is my service worker script running on pages that don't even link to the script in the first place?
Here is the full service worker script I'm using:
var CACHE_NAME = 'my-site-cache-v1';
var urlsToCache = [
'assets/scripts/script1.js',
'assets/scripts/script2.js',
'assets/scripts/script3.js'
];
self.addEventListener('install', function(event) {
// Perform install steps
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
.then(function(response) {
if (response) {
return response;
}
var fetchRequest = event.request.clone();
return fetch(fetchRequest).then(
function(response) {
// Check if we received a valid response
if(!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
var responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(function(cache) {
cache.put(event.request, responseToCache);
});
return response;
}
);
})
);
});
Once a service worker is installed it runs independently of your website, means all requests will go through the fetch-eventhandler, your service worker controls also the other pages.
Not only the urlsToCache are cached, the service worker also caches responses on the fly as soon as they were fetched in the fetch-eventhandler (line 38-41)
This also leads to the answer for your first question. Your code caches all responses independent of the http method, but as the error message says http POST response cannot be cached.

Categories

Resources