Skip to content

Instantly share code, notes, and snippets.

@prof3ssorSt3v3
Last active February 26, 2024 02:09
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save prof3ssorSt3v3/ab91c894d05794fdb9f6249d7daa48f4 to your computer and use it in GitHub Desktop.
Save prof3ssorSt3v3/ab91c894d05794fdb9f6249d7daa48f4 to your computer and use it in GitHub Desktop.
Code from Service Worker Review
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404</title>
<link rel="stylesheet" href="main.css" />
<style>
html {
background-color: #444;
}
h1 {
color: #999;
}
</style>
</head>
<body>
<h1>404</h1>
<p><img src="https://via.placeholder.com/300" alt="placeholder image 300 x 300" /></p>
<!-- ./404.png loads when offline -->
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Home Page</title>
<!-- you could add a <link rel="preconnect" href="" /> here if you are going to test for connection speed -->
<link rel="stylesheet" href="main.css" />
<script>
document.addEventListener('DOMContentLoaded', () => {
navigator.serviceWorker.register('./sw.js');
});
window.addEventListener('online', (ev) => {
//coming back online
let img = document.body.querySelector('img');
let src = img.src;
// img.src = '';
Promise.resolve().then(() => {
img.src = src;
});
});
window.addEventListener('offline', (ev) => {
//gone off line
let img = document.body.querySelector('img');
let src = img.src;
// img.src = '';
Promise.resolve().then(() => {
console.log(src);
img.src = src;
});
});
</script>
</head>
<body>
<h1>Home Page</h1>
<p><img src="https://via.placeholder.com/300" alt="placeholder image 300 x 300" /></p>
<!-- ./404.png loads when offline -->
</body>
</html>
* {
box-sizing: border-box;
margin: 0;
}
html {
font-size: 30px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
color-scheme: dark light;
}
body {
min-height: 100vh;
padding: 3rem;
}
h1 {
line-height: 200%;
}

Service Worker Review

Review Handling Fetch Events

This review is specifically about how to handle fetch requests in a Service Worker.

Caching Strategies

There are a number of basic strategies that are fairly standard for any website. You can also create your own more specific ones for any app that you build.

Regardless of which strategy that you like to use, there will never be a single strategy that you use for every request on any website.

You can create a single function for each strategy for easy reuse.

Cache Only

This strategy is used when you only want to send a response from a Cache, no network requests involved.

function cacheOnly(ev) {
  //only the response from the cache
  return caches.match(ev.request);
}

Cache First

This strategy is used when you want to check the cache first and then only make a network request if the cache request fails.

function cacheFirst(ev) {
  //try cache then fetch
  return caches.match(ev.request).then((cacheResponse) => {
    return cacheResponse || fetch(ev.request);
  });
}

Network Only

This strategy is used when you only want to make fetch requests and you want to ignore the cache entirely.

function networkOnly(ev) {
  //only the result of a fetch
  return fetch(ev.request);
}

Network First

This strategy is used when you want to make fetch requests but fall back on the cache if that fails.

function networkFirst(ev) {
  //try fetch then cache
  return fetch(ev.request).then((response) => {
    if (response.status > 0 && !response.ok) return caches.match(ev.request);
    return response;
  });
}

Stale While Revalidate

This strategy involves using both the cache and the network. The user is sent the copy in the cache if it exists. Then, regardless of whether or not the request exists in the cache, a fetch request is made. The new fetch response is saved in the cache to be ready for the next request.

function staleWhileRevalidate(ev) {
  //return cache then fetch and save latest fetch
  return caches.match(ev.request).then((cacheResponse) => {
    let fetchResponse = fetch(ev.request).then((response) => {
      return caches.open(cacheName).then((cache) => {
        cache.put(ev.request, response.clone());
        return response;
      });
    });
    return cacheResponse || fetchResponse;
  });
}

Network First And Revalidate

This strategy is similar to the Stale While Revalidate strategy except it prioritizes the fetch request over the current value in the cache.

function networkFirstAndRevalidate(ev) {
  //attempt fetch and cache result too
  return fetch(ev.request).then((response) => {
    if (response.status > 0 && !response.ok) return caches.match(ev.request);
    //accept opaque responses with status code 0
    //still save a copy
    return cache.put(ev.request, response.clone()).then(() => {
      return response;
    });
  });
}

Request Object Properties

When you want to select a different strategy for each file you need to have information about each Request in order to make those decisions.

The ev.request object that we get from the Fetch Event contains lots of information that we can use for decision making.

let mode = ev.request.mode; // navigate, cors, no-cors
let method = ev.request.method; //get the HTTP method
let url = new URL(ev.request.url); //turn the url string into a URL object
let queryString = new URLSearchParams(url.search); //turn query string into an Object
let isOnline = navigator.onLine; //determine if the browser is currently offline
let isImage =
  url.pathname.includes('.png') ||
  url.pathname.includes('.jpg') ||
  url.pathname.includes('.svg') ||
  url.pathname.includes('.gif') ||
  url.pathname.includes('.webp') ||
  url.pathname.includes('.jpeg') ||
  url.hostname.includes('some.external.image.site'); //check file extension or location
let selfLocation = new URL(self.location);
//determine if the requested file is from the same origin as your website
let isRemote = selfLocation.origin !== url.origin;

Online and Offline

When you want to check if the browser is online or offline, the navigator.onLine property tells you absolutely if you are offline. The online value being true can be deceptive. So, it would be better to also do a test. Pick an endpoint that you know always works efficiently and do a fetch call with the HEAD method. If that returns successfully, then you are definitely online.

async function isConnected() {
  //can only be called from INSIDE an ev.respondWith()
  const maxWait = 2000; //if it takes more than x milliseconds
  if (!navigator.onLine) return false; //immediate response if offline
  //exit if already known to be offline
  let req = new Request('https://jsonplaceholder.typicode.com/users/1', {
    method: 'HEAD',
  });
  let t1 = performance.now();
  return await fetch(req)
    .then((response) => {
      let t2 = performance.now();
      let timeDiff = t2 - t1;
      // console.log({ timeDiff });
      if (!response.ok || timeDiff > maxWait) throw new Error('offline');
      return true; //true if navigator online and the HEAD request worked
    })
    .catch((err) => {
      return false; //the fetch failed or the response was not valid
    });
}

You can also add a timer to that response. If you don't get a HEAD response quickly then you have a poor connection.

REMEMBER to add a URL that you trust to this Request.

You might be able to save a few milliseconds by adding a preconnect in your HTML head with the same domain.

<link rel="preconnect" href="https://jsonplaceholder.typicode.com" crossorigin />

You can optionally restrict the maxWait value to a lower number of milliseconds. Exceeding this value when calculating the difference between two performance.now() calls means that you can also treat slow connections as offline too.

performance.now() gives you an accurate number of milliseconds since the service worker started running. Call this before and after your HEAD fetch and it will tell you how long it took.

If you want to use this method, remember that it can only be called from INSIDE a ev.respondWith( ). You need to make it the first step in a chain of .then( ).then( ) calls. Remember to return at each level of nesting.

In the sample version of sw.js this is being demonstrated inside the networkFirstCred function.

Fetch Event Handling

Inside the fetch event listener function you will likely have many if statements, switch case statements, nested if statements, and logical short-circuiting.

Treat the respondWith() method calls like function return statements. The first one that is encountered will send back the Response. However, the code in your function will continue to run after it is called. So, always have an else{ } block. Don't make two respondWith calls in the same block.

self.addEventListener('fetch', (ev) => {
  let isOnline = navigator.onLine;
  if (isOnline) {
    ev.respondWith(fetchOnly(ev));
  } else {
    ev.respondWith(cacheOnly(ev));
  }
});

Response Objects

There will also be times where you want to look at the Response object. You might want to check its headers and return different things depending on those values.

fetch(ev.request).then((response) => {
  let hasType = response.headers.has('content-type');
  let type = response.headers.get('content-type');
  let size = response.headers.get('content-length');
  console.log(type, size);
});

MDN Headers Object reference

Opaque Responses

Sometimes, when you are making fetch calls you will get an Opaque response. This is a valid response but one that cannot be read by JavaScript. You cannot use JavaScript to get the values of the headers or call .text() or .json() or .blob() on. This happens when you are making a cross-origin request. Eg: trying to get an image file from a website that is not your own.

The way to avoid some of the security issues is to stop credentials and identifying information from being passed to the external web server. We do this by setting the credentials setting on the request to omit. This will prevent the information being sent and solve some of the errors that you see with external requests.

self.addEventListener('fetch', (ev) => {
  //ev.request is the request coming from the web page
  //we can change its settings when making a fetch( ) call
  ev.respondWith(
    fetch(ev.request, {
      credentials: 'omit',
    })
  );
});

There are times when an Opaque response is not a problem. If the Request has a destination property set to image then we have no issue. The HTML is allowed to load and use items like images or fonts when they are Opaque. It is the JavaScript that is not allowed to use Opaque responses.

To close this potential loop hole, You are not allowed to set the value of the destination header from JavaScript. Only the browser can set it. The destination property of the Request may be audio, audioworklet, document, embed, font, frame, iframe, image, manifest, object, paintworklet, report, script, sharedworker, style, track, video, worker or xslt strings, or the empty string, which is the default value.

If the destination is blank then the request is coming from a fetch() call in your script, Cache request, beacons, WebSockets, or a few other things.

The blank response or a response whose destination is script or *-worklet or *-worker will have tighter CORS restrictions.

You can also change SOME of the other settings like Headers in the Request before it is sent to the server.

const version = 1;
const cacheName = `GreenDay${version}`;
const cacheList = ['./404.html', './main.css', '404.png'];
self.addEventListener('install', (ev) => {
ev.waitUntil(caches.open(cacheName).then((cache) => cache.addAll(cacheList)));
});
self.addEventListener('activate', (ev) => {
ev.waitUntil(
caches.keys().then((keys) => {
return Promise.all(keys.filter((key) => key != cacheName).map((name) => caches.delete(name)));
})
);
});
self.addEventListener('fetch', (ev) => {
let mode = ev.request.mode;
let url = new URL(ev.request.url);
let method = ev.request.method;
let isSocket = url.protocol.startsWith('ws');
let isOnline = navigator.onLine;
// let isOnline = isConnected(); //Nope. Can't use a Promise here
let isImage =
url.pathname.includes('.png') ||
url.pathname.includes('.jpg') ||
url.pathname.includes('.gif') ||
url.pathname.includes('.webp') ||
url.pathname.includes('.jpeg') ||
url.hostname.includes('placeholder.com'); //or other image domain
let selfLocation = new URL(self.location);
let isRemote = selfLocation.origin !== url.origin;
// console.log(url.protocol);
// console.log({ mode });
// console.log({ method });
// console.log({ isOnline });
// console.log({ isImage });
// console.log({ isRemote });
if (isOnline) {
//probably online
if (isRemote && isImage) {
ev.respondWith(networkFirstCred(ev));
} else {
ev.respondWith(networkFirst(ev));
}
} else {
//offline
if (mode === 'navigate') {
//our own custom 404 page... main.js won't know
ev.respondWith(caches.match('./404.html'));
} else if (isSocket) {
//how do you want to handle websocket requests when offline?
} else {
if (isImage) {
//our own custom 404 image... main.js won't know
ev.respondWith(caches.match('./404.png'));
} else {
//if not in the cache send generic 404 error... let main.js deal with it
ev.respondWith(cacheOnly(ev).catch(response404()));
}
}
}
});
function cacheFirst(ev) {
//try cache then fetch
return caches.match(ev.request).then((cacheResponse) => {
return cacheResponse || fetch(ev.request);
});
}
function cacheOnly(ev) {
//only the response from the cache
return caches.match(ev.request).catch();
}
function networkFirst(ev) {
//try fetch then cache
//add the check for online?
return fetch(ev.request).then((response) => {
if (response.status > 0 && !response.ok) return caches.match(ev.request);
//if response.status is 0 or in the 200s send it to the page
if (response.status === 0) console.log(ev.request.url, 'IS OPAQUE');
return response;
});
}
function networkFirstCred(ev) {
// //try fetch then cache without credentials
let url = ev.request.url;
let req = new Request(url, { credentials: 'omit', mode: 'no-cors' });
//add the check for online?
return isConnected().then((bool) => {
console.log({ bool });
if (bool) {
return fetch(ev.request).then((response) => {
console.log(response);
//with the mode:no-cors credentials:omit version we accept status 0 to pass through things
//that will be used by the HTML based on an HTML request (non-JS request)
if (response.status > 0 && !response.ok) return caches.match(ev.request);
if (response.status === 0) console.log(ev.request.url, 'IS OPAQUE');
//if response.status is 0 or in the 200s send it to the page
return response;
});
} else {
return caches.match(ev.request);
}
});
}
function networkOnly(ev) {
//only the result of a fetch... let main.js deal with the response
return fetch(ev.request);
}
function networkOnlyCred(ev) {
//only the result of a fetch
let req = ev.request;
req.credentials = 'omit';
return fetch(req);
}
function staleWhileRevalidate(ev) {
//return cache then fetch and save latest fetch
return caches.match(ev.request).then((cacheResponse) => {
let fetchResponse = fetch(ev.request).then((response) => {
caches.open(cacheName).then((cache) => {
cache.put(ev.request, response.clone());
return response;
});
});
return cacheResponse || fetchResult;
});
}
function networkFirstAndRevalidate(ev) {
//attempt fetch and cache result too
return fetch(ev.request).then((response) => {
if (!response.ok) return caches.match(ev.request);
return response;
});
}
async function isConnected() {
//can only be called from INSIDE an ev.respondWith()
const maxWait = 2000; //if it takes more than x milliseconds
if (!navigator.onLine) return false; //immediate response if offline
//exit if already known to be offline
let req = new Request('https://jsonplaceholder.typicode.com/users/1', {
method: 'HEAD',
});
let t1 = performance.now();
return await fetch(req)
.then((response) => {
let t2 = performance.now();
let timeDiff = t2 - t1;
// console.log({ timeDiff });
if (!response.ok || timeDiff > maxWait) throw new Error('offline');
return true; //true if navigator online and the HEAD request worked
})
.catch((err) => {
return false; //the fetch failed or the response was not valid
});
}
function response404() {
//any generic 404 error that we want to generate
return new Response(null, { status: 404 });
}
function response4XX(code, text) {
//return a custom http status code and message
return new Response(null, { status: code, statusText: text });
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment