Est.
PWA: Manifest and Service Worker
When I started looking into progressive web techniques, I wanted to find out if PWAs were suitable for creating applications for my cell phone.
A PWA can install all its files locally on a target device. After that, it can run offline. That is, as long as it doesn't require remote resources.
For a static website like this blog, PWA features don't seem to be very useful. The main argument would be that the blog can be read offline after installation. Another important feature would be push notifications, but support varies between browsers. At least today, you can't rely on them to work in all browsers.
Progressive Ingredients
What are the typical extensions that turn a web app into a progressive web app?
You could say that a progressive web app is something that is recognized as such by Google Lighthouse. This test involves checking the following characteristics:
You could say that a progressive web app is something that Google Lighthouse approves as such. This test includes checks for the following features:
- The app must be served via HTTPS
- The app defines a web manifest with appropriate icons
- The app defines a service worker
- The app has a splash screen icon
- The page has a
<meta name="viewport">
tag withwidth
orinitial-scale
- The page has a
<meta name="theme-color">
tag withcontent="..."
- The page has a
<link rel="apple-touch-icon">
tag withhref
set to a 180x180 or 192x192 square png.
HTTPS
Browsers support PWA on localhost over http in development mode. But in production, installing a PWA will only work over HTTPS. Fortunately, we already have HTTPS covered.
Header Entries
Most of the points above can be covered by entries in the HTML header sections of the app as follows:
<html lang="en-US">
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="manifest" href="/site.webmanifest" crossorigin="use-credentials">
<script src="/registerSW.js"></script>
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/e3.svg" color="grey">
<meta name="theme-color" content="grey">
</head>
<html lang="en-US">
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="manifest" href="/site.webmanifest" crossorigin="use-credentials">
<script src="/registerSW.js"></script>
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/e3.svg" color="grey">
<meta name="theme-color" content="grey">
</head>
I add those entries to every page of my site by adding them to the VitePress configuration.
Web Manifest
The Manifest file tells the installer how to place an app link on the home screen of the target device. Here is the manifest of this web site.
Service Worker
The /registerSW.js
script registers the service worker. It also defines a listener for messages from the service worker. I originally introduced this to avoid strange behavior after new installations.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register(`/service-worker.js`).then(() => {
navigator.serviceWorker.addEventListener("message", (event) => {...});
});
}
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register(`/service-worker.js`).then(() => {
navigator.serviceWorker.addEventListener("message", (event) => {...});
});
}
The service worker defines event listeners for install
& activate
and one for fetch
. A typical PWA responds to the install
event by downloading all pages, scripts, and style files to a local cache. The activate
event then clears the cache entries from previous deployments. The main point of interaction is the handler for fetch events. Depending on the strategy (cache first or online first), it serves the requests through a mix of delegations to the server and lookups from the cache.
Service Worker Use Case: Offline Availability
As of the 9/1/22 release, I completely cache all resources on browsers that support service workers.
self.addEventListener("fetch", function (event) {
event.respondWith(
caches
.match(event.request)
.then((res) => {
if (res) return res;
return fetch(event.request);
})
.catch(() => fetch(event.request))
);
});
self.addEventListener("fetch", function (event) {
event.respondWith(
caches
.match(event.request)
.then((res) => {
if (res) return res;
return fetch(event.request);
})
.catch(() => fetch(event.request))
);
});
The fetch
event listener looks up the resource in the service worker's cache. If it's not found, the request is delegated to the server.
The files loaded into the service worker's cache when the install event triggers:
self.addEventListener("install", function (event) {
event.waitUntil(
caches
.open(`V_${VERSION}`)
.then(async (cache) => {
await cache.addAll(files);
self.skipWaiting();
})
.catch(function (error) {
send("Pre-fetching failed: " + error);
}));});
self.addEventListener("install", function (event) {
event.waitUntil(
caches
.open(`V_${VERSION}`)
.then(async (cache) => {
await cache.addAll(files);
self.skipWaiting();
})
.catch(function (error) {
send("Pre-fetching failed: " + error);
}));});
The list of files that are loaded is generated during the build. This is basically a list of all the files that are generated by VitePress under the dist
directory of the build. As the Jotter is hosted on Cloudflare Pages, .html
extensions are removed and .../index
is shortened to .../
.
The listener for the activate
event drops old cache versions that are not used anymore.
self.addEventListener("activate", (event) => {
self.clients.claim();
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== `V_${VERSION}`) {
return caches.delete(cacheName);
}}))));});
self.addEventListener("activate", (event) => {
self.clients.claim();
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== `V_${VERSION}`) {
return caches.delete(cacheName);
}}))));});
Service Worker Use Case: Fixing Rebuild Errors
Up to the 9/1/22 release, I had no caching service worker, but I used a service worker to detect rebuild errors
I simply delegated all requests to the server and only intercept when files were not found.
self.addEventListener("fetch", (event) => {
event.respondWith(
(async () => {
try {
const response = await fetch(event.request);
if (response.status === 404) {
send("reload!", event.clientId);
}
return response;
} catch (error) {
return new Response("Service Unavailable", {
status: 503,
headers: { "Content-Type": "text/plain" },
});}})());});
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", () => {
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
event.respondWith(
(async () => {
try {
const response = await fetch(event.request);
if (response.status === 404) {
send("reload!", event.clientId);
}
return response;
} catch (error) {
return new Response("Service Unavailable", {
status: 503,
headers: { "Content-Type": "text/plain" },
});}})());});
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", () => {
self.clients.claim();
});
Since I check for broken links during build, all files should be found at runtime. Missing files are mainly a sign of load errors caused by rebuilds. So when I find a 404 (file not found), I send a message to the main task to force a reload of the current page.
Because I can't rely on service workers, this site also uses a more general approach to page reloads.
Reception
Support for PWA techniques varies between browsers. The support for PWAs is strongest in Chrome. Chrome supports installation of apps from the address bar, service workers and notifications. Support is weaker in Firefox, which does not really support app installation. Firefox also ignores service workers while in private mode. Weakest support exists on iOS, regardless of which browser is used.
Consequently, PWA features can be an optional add-on for a website, but may not be the technique of choice for an important main functionality.