ยท
Recently I migrated my website to Webpack and TypeScript. I decided also to give a try to Workbox, a set of Google libraries to improve the creation of a Progressive Web App. Let's see how easy it is to create a PWA with this tools.
I already talked extensively in some previous post about what a progressive web app is and how it can improve the user experience of your website. In the first development iteration I create the service worker that runs on this website without any kind of library or framework to support. This enabled me to learn the fundamental of PWAs: how they work, their lifecycle and limits. Now that I gained enough experience it's time to move on. Recently I migrated this website to Webpack and TypeScript. So I thought: "it's time also to improve my PWA codebase by leveraging some professional tool". This is the reason why I chose to rewrite my service worker in Workbox. What is workbox?
JavaScript Libraries for adding offline support to web apps...Welcome, Workbox is a set of libraries and Node modules that make it easy to cache assets and take full advantage of features used to build Progressive Web Apps.
As the official description says, Workbox a TypeScript library that enable developers to build PWAs in a fast way by giving to them the ability to have some already prepared component to manage all the aspects of a PWA: routing, caching, tracking etc. As reported on the official website Workbox is subdivided into modules, where each one of them covers a different PWA feature.
- workbox-background-sync, use background sync to reliably make a network request even if the user is offline.
- workbox-broadcast-update, to send messages to pages (via Broadcast Channels) when a cache is updated with a new response.
- workbox-cacheable-response, to restrict which requests are cached based on a response's status code or headers.
- workbox-core, to alter log levels and change cache names via workbox-core. Contains shared code used by all Workbox libraries.
- workbox-expiration, removed cached requests based on the number of items in a cache or the age of the cached request.
- workbox-google-analytics, see how users are interacting with your site when they are offline.
- workbox-navigation-preload, enable navigation preload, to get a network response for navigation requests faster.
- workbox-precaching, easily precache a set of files and efficiently manage updates to files.
- workbox-range-request, this modules provides support for responding to a Range: request using a slice of previously cached data.
- workbox-routing, routes requests in your service worker to specific caching strategies or callback functions.
- workbox-strategies, a set of runtime caching strategies that will handle responding to a request, normally used with workbox-routing.
- workbox-streams, this modules provides support for composing a response from a series of streaming sources.
So let's see how I used it and how I took advantage of some of its major features, including the full integration with Webpack.
As I already said, Workbox is written in TypeScript. So let's start by creating a new file called sw.ts
. The first
thing we need to do is call some service worker lifecycle method, skipWaiting
and clientsClaim
. When you install an
update of a service worker, the new one will not be used until you close all the tabs already controlled by the service
worker and relaunch the web app. If you don't need that feature, you can make your new service worker activate sooner by
calling the skipWaiting
function. In case you want to force all the tabs open to be immediately controller by a new
updated version of your service worker you can call clientsClaim
. Both this lifecycle functions are contained in
the workbox-core
module, so we can just import and call them.
import { skipWaiting, clientsClaim } from "workbox-core"
//...other code...
skipWaiting()
clientsClaim()
//...other code...
Now we are ready to setup it's time to understand and setup caches with Workbox. All the caches inside the framework are
based on the concept of routes and strategies. Service worker can intercept network requests for a page and can
respond to the browser with cached content, content from the network or content generated in the service worker. To
define which request must be intercepted and served by the service worker, you must define its routes. The way the
service worker handle the routes (for example cache only, network first etc.) are the strategies. Usually when
you write your own service worker you define some files to precache during the service worker installation process and
then for the routes you want to serve from it their related strategies.
Let's start with the precache of some files. This is usually done manually by the developer in the install
event, and
usually the resource that are cached are the ones needed in order to have the PWA work offline. In my cases, and
generally speaking for a blog/news website, this basically means save in the cache JavaScript and CSS files. Workbox
give us the precacheAndRoute
function to do this. It is possible to pass to this function a list of files to be cached
and it will take care of creating an ad-hoc cache and same the files during the installation process. To build this
website I'm using Webpack as JavaScript bundler. Workbox has a great support for it. In particular for the precache
phase, there is the npm package workbox-webpack-plugin
that contains a plugin called InjectManifest
. This plugin is
able to inject in the Service Worker code a variable named __WB_MANIFEST
that contains a list of the entry
points/generated files of the Webpack bundling process. So in my service worker I can precache the files I need by just
writing precacheAndRoute(self.__WB_MANIFEST)
. Anyway there's a problem: I want to cache some additional files during
the installation process to be used to manage errors related to content not available in the caches and that it is not
possible to load due to network errors. It is possible to add these additional files with the standard way in
the install
event. In my case I decided to create an ad-hoc cache to save these files. Last but not least in the
install event I decided also to clear all the caches. In this way on every install of a new service worker version I
will initialize all the caches. All the caches that I'm using are identified by the constants CACHE_<purpose>
(and we
will see in a few moments how I'm using them ๐). Below you can find the code for the precache and install event.
import { precacheAndRoute } from 'workbox-precaching';
//...other code...
const CACHE_PREFIX = 'workbox-chicio-coding'
const CACHE_OFFLINE_NAME = `${CACHE_PREFIX}-offline`
const CACHE_SCRIPT_NAME = `${CACHE_PREFIX}-scripts`
const CACHE_STYLES_NAME = `${CACHE_PREFIX}-styles`
const CACHE_DOCUMENTS_NAME = `${CACHE_PREFIX}-documents`
const CACHE_FONTS_NAME = `${CACHE_PREFIX}-fonts`
const CACHE_IMAGES_NAME = `${CACHE_PREFIX}-images`
const OFFLINE_PAGE_URL = '/offline.html'
const OFFLINE_PAGE_NO_NETWORK_IMAGE_URL = '/assets/images/no-wifi.png'
//...other code...
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: __WB_MANIFEST is a placeholder filled by workbox-webpack-plugin with the list of dependecies to be cached
precacheAndRoute(self.__WB_MANIFEST)
googleAnalytics.initialize()
self.addEventListener('install', (event: ExtendableEvent) => {
const offlineUrls = [
OFFLINE_PAGE_URL,
OFFLINE_PAGE_NO_NETWORK_IMAGE_URL
];
event.waitUntil(
Promise.all([
caches.delete(CACHE_DOCUMENTS_NAME),
caches.delete(CACHE_SCRIPT_NAME),
caches.delete(CACHE_STYLES_NAME),
caches.delete(CACHE_FONTS_NAME),
caches.delete(CACHE_IMAGES_NAME),
caches.open(CACHE_OFFLINE_NAME).then((cache) => cache.addAll(offlineUrls))
])
);
})
//...other code...
After the precache we define the routes and strategies for the network call after the install events. In my implementation I decided to setup a cache for each one of the following assets:
Each route is registered in the service worker with the registerRoute
function. It expects 3 parameters: a regex that
defines the URLs intercepted by the route, a strategy handler and eventually the HTTP method expected on the route (the
last two parameters are optional and could be substituted by a Route
object that contains this 3 params). In our case,
as a consequence of the fact that I decided that all the routes must be served with a CacheFirst
strategy, we can
define a registerRoute
function that defines a route with a cache first strategy given the regex of the route, the
cache name and the ExpirationPlugin
. What is an ExpirationPlugin
? It is a class that define the retention policy of
the cache to which it is assigned. In our case I defined different ExpirationPlugin
instances based on the policy I
chosen for each media type supported by the routes we defined above:
Above you can see the implementation for the routes and strategies.
import { registerRoute, Route } from 'workbox-routing';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheFirst } from 'workbox-strategies';
//...other code...
const stylesScriptsExpirationPlugin = new ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 15 * 24 * 60 * 60,
purgeOnQuotaError: true
})
const fontsExpirationPlugin = new ExpirationPlugin({ maxEntries: 5, maxAgeSeconds: 180 * 24 * 60 * 60 })
const imagesExpirationPlugin = new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 24 * 60 * 60 })
const documentExpirationPlugin = new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 60 * 24 * 60 * 60,
purgeOnQuotaError: true
})
//...other code...
const registerCacheFirstRouteUsing = (
destination: RequestDestination,
cacheName: string,
expirationPlugin: ExpirationPlugin
): Route => registerRoute(
({ request }) => request.destination === destination,
new CacheFirst({
cacheName: cacheName,
plugins: [expirationPlugin],
})
)
registerCacheFirstRouteUsing('style', CACHE_STYLES_NAME, stylesScriptsExpirationPlugin)
registerCacheFirstRouteUsing('script', CACHE_SCRIPT_NAME, stylesScriptsExpirationPlugin)
registerCacheFirstRouteUsing('document', CACHE_DOCUMENTS_NAME, documentExpirationPlugin)
registerCacheFirstRouteUsing('font', CACHE_FONTS_NAME, fontsExpirationPlugin)
registerCacheFirstRouteUsing('image', CACHE_IMAGES_NAME, imagesExpirationPlugin)
//...other code...
Now there's a question we didn't answer: how do we manage the requests that will fail? There are multiple possibilities
to manage these cases. In our case I decided to go for a global catch handler. By calling setCatchHandler
it is
possible to catch all the network/cache error for all the routes defined. We can give a callback to this function where
we can define an error recovery strategy for each type of error. In our case we respond with the offline page (see the
offline cache defined in the precache/install event) if the error happens on a resource of type document
(so an html
page that can be loaded completely), and we return a generic error for all the other fail (images loading fail, scripts
fail etc.). Below you can find the implementation of the cache handler.
import { setCatchHandler } from 'workbox-routing';
//...other code...
setCatchHandler((options: RouteHandlerCallbackOptions): Promise<Response> => {
const isADocumentRequest = (options: RouteHandlerCallbackOptions): boolean =>
!(typeof options.request === 'string') && options.request.destination == 'document';
const isAOfflinePageImageRequest = (options: RouteHandlerCallbackOptions): boolean =>
!(typeof options.request === 'string') &&
options.request.destination == 'image' &&
options.url?.pathname == OFFLINE_PAGE_NO_NETWORK_IMAGE_URL
if (isADocumentRequest(options)) {
return caches.match(OFFLINE_PAGE_URL) as Promise<Response>;
}
if (isAOfflinePageImageRequest(options)) {
return caches.match(OFFLINE_PAGE_NO_NETWORK_IMAGE_URL) as Promise<Response>;
}
return Promise.resolve(Response.error());
})
//...other code...
As a consequence of the fact that I'm migrating my previous version of the service worker for my website, I needed also
to migrate the part that is responsible for getting messages from the page related to the pull to refresh. In this case
the previous version of the code was responsible for the clear of some caches. We can write this feature using
the deleteCacheAndMetadata
method of the ExpirationPlugin
class. Below you can find the related code.
//...other code...
self.addEventListener('message', (event: ExtendableMessageEvent) => {
const isARefresh = (event: ExtendableMessageEvent): boolean => event.data.message === 'refresh'
const sendRefreshCompletedMessageToClient = (event: ExtendableMessageEvent): void => event.ports[0].postMessage({ refreshCompleted: true })
if (isARefresh(event)) {
Promise.all([
imagesExpirationPlugin.deleteCacheAndMetadata(),
documentExpirationPlugin.deleteCacheAndMetadata()
])
.then(() => sendRefreshCompletedMessageToClient(event))
.catch(() => sendRefreshCompletedMessageToClient(event))
}
})
//...other code...
In a previous post I already talked about the fact that
workbox could help us to manage the Google Analytics tracking when the PWA is offline. So for this part, we just need (
as in the previous version of my service worker) to call the googleAnalytics.initialize()
function and workbox will
take care of:
import * as googleAnalytics from 'workbox-google-analytics';
//...other code...
googleAnalytics.initialize()
//...other code...
One important thing to be noted here is that in my project I'm using TypeScript with the strict types activated and ESLint. As a consequence of this fact I have to add two things to the code we saw above:
self
is a ServiceWorkerGlobalScope
@ts-ignore
annotation to let TypeScript know that the __WB_MANIFEST
will be available at compile time (Webpack
bundling), and an additional eslint-disable-next-line @typescript-eslint/ban-ts-comment
comment to avoid ESLint
complaining about the @tsignore
comment. ๐//...other code...
// Fix self: https://stackoverflow.com/questions/56356655/structuring-a-typescript-project-with-workers
declare const self: ServiceWorkerGlobalScope;
export {};
//...other code...
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: __WB_MANIFEST is a placeholder filled by workbox-webpack-plugin with the list of dependecies to be cached
precacheAndRoute(self.__WB_MANIFEST)
//...other code...
Now that the service worker code has been migrated to Workbox you may ask if we can do something also for the service
worker registration, and generaly speaking for all the service worker code inside the window context. The answer to this
question is an additional workbox module called workbox-window
. This module let the developers register their service
worker and eventually use all its feature. In my case in the window context I'm doing 2 operation:
We can wrote these feature by using an instance of Workbox
and its two methods register
and messageSW
. Below you
can find the two function registerToServicerWorker
and sendMessageToServiceWorker
that I rewrote
with workbox-window
.
import { Workbox } from "workbox-window";
interface ServiceWorkerMessage {
message: string;
}
const wb = new Workbox('/sw.js')
const isServiceWorkerSupported: () => boolean = () => ('serviceWorker' in navigator)
const registerToServicerWorker = (): void => {
if (isServiceWorkerSupported()) {
wb.register()
.then(() => {
console.log('Service Worker registration completed')
})
.catch((err) => {
console.error('Service Worker registration failed:', err)
})
}
}
const sendMessageToServiceWorker = (message: ServiceWorkerMessage): Promise<unknown> => {
return new Promise((resolve, reject) => {
wb.messageSW(message).then((event: MessageEvent): void => {
if (event.data) {
if (event.data.error) {
reject(event.data.error)
} else {
resolve(event.data)
}
}
})
})
}
export { sendMessageToServiceWorker, registerToServicerWorker, isServiceWorkerSupported }
As you can see, writing a service worker with Typescript and Workbox is really easy. During the last time during my daily job I'm basically doing more and more web development instead of mobile apps. I feel like I'm almost reaching the mythological status of the "fullstack developer". ๐ Let's see what the future holds for me.