Real World

Service Workers

Simon MacDonald

@macdonst

What is a

service-worker?

A service worker

  1. Is a type of web worker.

  2. Intercepts network requests.

  3. Caches or retrieves resources from the cache.

  4. Delivers push messages.

  5. And more…

Installing

Activated

Idle

Fetch/

Message

Error

Terminated

Lifecycle

navigator.serviceWorker.register('/sw.js');

Load sw.js

var CACHE_VERSION = 'v1';
var CACHE_LIST = [
 'index.html', 
 'css/main.css', 
 'js/main.js'
];

self.addEventListener('install', function(event) {
    event.waitUntil(caches.open(CACHE_VERSION)
    .then(function(cache) {
        return cache.addAll(CACHE_LIST);
    }));
});
self.addEventListener('activate', function(event) {
  console.log("service worker has been activated.");
});
self.addEventListener('fetch', function(event) {
 console.log('Fetching ' + event.request.url);
 event.respondWith(
   caches.match(event.request)
     .then(function(response) {
       if (response) {
         console.log('Found!');
         return response;
       }
       console.log('Fetch from network...');
       return fetch(event.request);
   })
 );
});

Slow Initial App Load

Cache App Shell

var CACHE_VERSION = 'v1';
var CACHE_LIST = ['index.html', 
 'css/main.css', 
 'js/main.js'
];

self.addEventListener('install', function(event) {
  event.waitUntil(caches.open(CACHE_VERSION)
  .then(function(cache) {
    return cache.addAll(CACHE_LIST);
  }));
});

self.addEventListener('fetch', function(event) {
 event.respondWith(
   caches.match(event.request)
    .then(function(response) {
     if (response) {
       return response;
     }
     return fetch(event.request);
   })
  );
 });

Network with cache fallback

self.addEventListener("fetch", function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return caches.match(event.request);
    })
  );
});

No Content

Broken Images

Generic fallback

self.addEventListener("fetch", function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return caches.match(event.request)
    .then(function(response) {
      return response || caches.match("/image.png");
    });
   })
  );
});

App service-worker Communication

Cache then Network

self.addEventListener('fetch', function(evt) {
  evt.respondWith(fromCache(evt.request));

});

function fromCache(request) {
  return caches.open(CACHE)
    .then(function (cache) {
      return caches.match(request)
        .then(function (matching) {
          return matching 
            || Promise.reject('no-match');
      });
  });
}

Slow Network

self.addEventListener('fetch', function(evt) {
  evt.respondWith(fromCache(evt.request));
  evt.waitUntil(update(evt.request));
});

function fromCache(request) {
  return caches.open(CACHE)
    .then(function (cache) {
      return caches.match(request)
        .then(function (matching) {
          return matching 
            || Promise.reject('no-match');
      });
  });
}

function update(request) {
  return caches.open(CACHE)
    .then(function (cache) {
      return fetch(request)
        .then(function (response) {
          postUpdate(response);
          return cache.put(request, response);
    });
  });
}
function postUpdate(response) {
  self.clients.matchAll({ includeUncontrolled: true })
   .then(function(clients) {
    clients.forEach(function(client) {
     client.postMessage(
      {action: "update", tabatas: response.json()}
     );
    });
  });
};
// index.js

navigator.serviceWorker
 .addEventListener("message", function (event) {
  var data = event.data;
  if (data.action === "update") {
   updateTabatas(data.tabatas);
  }
});

Can't Submit Data Offline

Save in IndexDB

// index.js

idb.open('messages', 1, function(upgradeDb) {
  upgradeDb.createObjectStore('outbox', 
   { autoIncrement : true, keyPath: 'id' });
}).then(function(db) {
   var transaction = db.transaction('outbox', 
    'readwrite');
   return transaction
    .objectStore('outbox')
    .put(tabata);
}).then(function() {
   return reg.sync.register('outbox');
});
// sw.js

self.addEventListener('sync', function(event) {
  event.waitUntil(
    db.transaction('outbox', mode)
     .objectStore('outbox')
     .then(function(outbox) {
       return outbox.getAll();
     }).then(function(tabatas) {
       // Send Tabatas to server












    }).catch(function(err) { console.error(err); });
  );
});
// sw.js

self.addEventListener('sync', function(event) {
  event.waitUntil(
    db.transaction('outbox', mode)
     .objectStore('outbox')
     .then(function(outbox) {
       return outbox.getAll();
     }).then(function(tabatas) {
      return Promise.all(tabatas.map(function(tabata) {
        return fetch('/tabatas', {
          method: 'POST',
          body: JSON.stringify(tabata),
          headers: {…}
        }).then(function(response) {  
          return response.json();
        }).then(function(data) {
          if (data.result === 'success') {
            return store.outbox('readwrite')
             .then(function(outbox) {
              return outbox.delete(message.id);
            });
          }
        })
    }).catch(function(err) { console.error(err); });
  );
});

Respond to Sync Event

Send updates to server

var CACHE_NAME = "gih-cache-v5";
var CACHED_URLS = [
  // Our HTML
  "/index.html",
  "/my-account.html",
  // Stylesheets
  "/css/gih.css",
  "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css",
  "https://fonts.googleapis.com/css?family=Lato:300,600,900",
  "/js/vendor/progressive-ui-kitt/themes/flat.css",
  // JavaScript
  "https://code.jquery.com/jquery-3.0.0.min.js",
  "/js/app.js",
  "/js/offline-map.js",
  "/js/my-account.js",
  "/js/reservations-store.js",
  "/js/vendor/progressive-ui-kitt/progressive-ui-kitt.js",
  // Images
  "/img/logo.png",
  "/img/logo-header.png",
  "/img/event-calendar-link.jpg",
  "/img/switch.png",
  "/img/logo-top-background.png",
  "/img/jumbo-background-sm.jpg",
  "/img/jumbo-background.jpg",
  "/img/reservation-gih.jpg",
  "/img/about-hotel-spa.jpg",
  "/img/about-hotel-luxury.jpg",
  "/img/event-default.jpg",
  "/img/map-offline.jpg",
  // JSON
  "/events.json",
  "/reservations.json"
];
var googleMapsAPIJS = "https://maps.googleapis.com/maps/api/js?key="+
  "AIzaSyDm9jndhfbcWByQnrivoaWAEQA8jy3COdE&callback=initMap";

self.addEventListener("install", function(event) {
  // Cache everything in CACHED_URLS. Installation fails if anything fails to cache
  event.waitUntil(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.addAll(CACHED_URLS);
    })
  );
});

self.addEventListener("fetch", function(event) {
  var requestURL = new URL(event.request.url);
  // Handle requests for index.html
  if (requestURL.pathname === "/" || requestURL.pathname === "/index.html") {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return cache.match("/index.html").then(function(cachedResponse) {
          var fetchPromise = fetch("/index.html").then(function(networkResponse) {
            cache.put("/index.html", networkResponse.clone());
            return networkResponse;
          });
          return cachedResponse || fetchPromise;
        });
      })
    );
  // Handle requests for my account page
  } else if (requestURL.pathname === "/my-account") {
    event.respondWith(
      caches.match("/my-account.html").then(function(response) {
        return response || fetch("/my-account.html");
      })
    );
  // Handle requests for reservations JSON file
  } else if (requestURL.pathname === "/reservations.json") {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return fetch(event.request).then(function(networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        }).catch(function() {
          return caches.match(event.request);
        });
      })
    );
  // Handle requests for Google Maps JavaScript API file
  } else if (requestURL.href === googleMapsAPIJS) {
    event.respondWith(
      fetch(
        googleMapsAPIJS+"&"+Date.now(),
        { mode: "no-cors", cache: "no-store" }
      ).catch(function() {
        return caches.match("/js/offline-map.js");
      })
    );
  // Handle requests for events JSON file
  } else if (requestURL.pathname === "/events.json") {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return fetch(event.request).then(function(networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        }).catch(function() {
          ProgressiveKITT.addAlert(
            "You are currently offline. The content of this page may be out of date."
          );
          return caches.match(event.request);
        });
      })
    );
  // Handle requests for event images.
  } else if (requestURL.pathname.startsWith("/img/event-")) {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return cache.match(event.request).then(function(cacheResponse) {
          return cacheResponse||fetch(event.request).then(function(networkResponse) {
            cache.put(event.request, networkResponse.clone());
            return networkResponse;
          }).catch(function() {
            return cache.match("/img/event-default.jpg");
          });
        });
      })
    );
  // Handle analytics requests
  } else if (requestURL.host === "www.google-analytics.com") {
    event.respondWith(fetch(event.request));
  // Handle requests for files cached during installation
  } else if (
    CACHED_URLS.includes(requestURL.href) ||
    CACHED_URLS.includes(requestURL.pathname)
  ) {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return cache.match(event.request).then(function(response) {
          return response || fetch(event.request);
        });
      })
    );
  }
});

self.addEventListener("activate", function(event) {
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          if (CACHE_NAME !== cacheName && cacheName.startsWith("gih-cache")) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

var createReservationUrl = function(reservationDetails) {
  var reservationUrl = new URL("http://localhost:8443/make-reservation");
  Object.keys(reservationDetails).forEach(function(key) {
    reservationUrl.searchParams.append(key, reservationDetails[key]);
  });
  return reservationUrl;
};

var postReservationDetails = function(reservation) {
  self.clients.matchAll({ includeUncontrolled: true }).then(function(clients) {
    clients.forEach(function(client) {
      client.postMessage(
        {action: "update-reservation", reservation: reservation}
      );
    });
  });
};

var syncReservations = function() {
  return getReservations("idx_status", "Sending").then(function(reservations) {
    return Promise.all(
      reservations.map(function(reservation) {
        var reservationUrl = createReservationUrl(reservation);
        return fetch(reservationUrl).then(function(response) {
          return response.json();
        }).then(function(newReservation) {
          return updateInObjectStore(
            "reservations",
            newReservation.id,
            newReservation
          ).then(function() {
            postReservationDetails(newReservation);
          });
        });
      })
    );
  });
};

self.addEventListener("sync", function(event) {
  if (event.tag === "sync-reservations") {
    event.waitUntil(syncReservations());
  }
});

self.addEventListener("message", function(event) {
  var data = event.data;
  if (data.action === "logout") {
    self.clients.matchAll().then(function(clients) {
      clients.forEach(function(client) {
        if (client.url.includes("/my-account")) {
          client.postMessage(
            {action: "navigate", url: "/"}
          );
        }
      });
    });
  }
});

self.addEventListener("push", function(event) {
  var data = event.data.json();
  if (data.type === "reservation-confirmation") {
    var reservation = data.reservation;
    event.waitUntil(
      updateInObjectStore(
        "reservations",
        reservation.id,
        reservation)
      .then(function() {
        return self.registration.showNotification("Reservation Confirmed", {
          body: "Reservation for "+reservation.arrivalDate+" has been confirmed.",
          icon: "/img/reservation-gih.jpg",
          badge: "/img/icon-hotel.png",
          tag: "reservation-confirmation-"+reservation.id,
          actions: [
            {action: "details", title:"Show reservations", icon:"/img/icon-cal.png"},
            {action: "confirm", title:"OK", icon:"/img/icon-confirm.png"},
          ],
          vibrate:[500,110,500,110,450,110,200,110,170,40,450,110,200,110,170,40,500]
        });
      })
    );
  }
});

self.addEventListener("notificationclick", function(event) {
  event.notification.close();
  if (event.action === "details") {
    event.waitUntil(
      self.clients.matchAll().then(function(activeClients) {
        if (activeClients.length > 0) {
          activeClients[0].navigate("http://localhost:8443/my-account");
        } else {
          self.clients.openWindow("http://localhost:8443/my-account");
        }
      })
    );
  }
});

Workbox Example

importScripts('…/workbox-sw.dev.v2.0.3.js');

const workboxSW = new WorkboxSW({clientsClaim: true});
workboxSW.precache([{
  url: 'precached.txt',
  revision: '43011922c2aef5ed5ee3731b11d3c2cb',
}]);
workboxSW.router.registerRoute(
  /\.txt$/,
  workboxSW.strategies.networkFirst()
);
workboxSW.router.registerRoute(
  'https://httpbin.org/delay/(.*)',
  workboxSW.strategies.networkFirst({networkTimeoutSeconds: 3})
);
workboxSW.router.registerRoute(
  'https://httpbin.org/image/(.*)',
  workboxSW.strategies.cacheFirst({
    cacheName: 'images',
    cacheExpiration: { maxAgeSeconds: 7 * 24 * 60 * 60 },
    cacheableResponse: {statuses: [0, 200]},
  })
);

Resources

The End

Real World Service Workers

By Simon MacDonald

Real World Service Workers

  • 2,507