28 Mar 2024
Converting to a Progressive Web App
4 minutes reading time
Updated 02 Apr 2024
#Problems
I am not a fan of JavaScript. But I already started with some Service Worker examples from Mozilla some time ago, and PWAs have proven to be very effective. So, let's go.
#Static deployments
My first implementation was pulling cache lists from a dedicated page generated by the Zola template using a macro that pulls assets from taxonomies, pages, etc. But besides the need for filtering and discarding a lot of data, having a dedicated page for this is just ugly. That was the only way to make it work with fetch(). And I had to add an extra zola build as well.
Zola does not yet have the capability to populate non-HTML files, and I could not justify adding extra steps with NPM/etc. just for a single service worker event to function. So a new approach was needed.
#External libraries
Workbox or sw-tools libraries would resolve probably everything, but it's too easy. Since I would have to maintain JavaScript anyway, let's get on with it.
#Portability
Huh
#Service Worker
The solution is a cache-first service worker strategy with a fallback to offline mode. This feels like the most efficient approach. And it requires no external dependencies or extra steps. I could play with network requests, but timeout sounds too slow already, so maybe next time.
#Strategy
I decided to remove the hardcoded/dynamic cache list and install a fallback page instead.
oninstall = (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(cacheName);
await cache.add("/offline/");
console.log("Service worker added offline page");
})(),
);
};
The rest is cached "as you go"—the service worker filters useful requests and writes them into the cache. This way, I save critical resources during the first page navigation, and there is no funny business. If the requests fail (no network), an offline page is served.
onfetch = (event) => {
console.log("Service worker fetching", event.request.url);
event.respondWith(
caches.open(cacheName).then((cache) => {
return cache
.match(event.request)
.then((response) => {
if (response) {
console.log("Service worker found response in cache:", response);
return response;
}
console.log(
"No response for %s found in service worker cache. Fetching " +
"from network",
event.request.url,
);
return fetch(event.request.clone()).then((response) => {
console.log(
"Service worker got response for %s from network: %O",
event.request.url,
response,
);
if (response.status < 400) {
console.log("Caching the response to", event.request.url);
cache.put(event.request, response.clone());
} else {
console.log("Service worker not caching the response to", event.request.url);
}
return response;
}).catch(() => caches.match("/offline/"));
})
.catch((error) => {
console.error("Error in service worker fetch handler:", error);
throw error;
});
}),
);
};
The site's static assets are hashed by my Zola theme, so the strategy fits perfectly.
#Cache
Housekeeping is done via cacheName - all previous (old) cache records are purged during the service worker's activation, maintaining a clean browser environment.
onactivate = (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
return keys.map(async (cache) => {
if(cache !== cacheName) {
console.log('Removing old service worker cache '+cache);
return await caches.delete(cache);
}
})
})()
)
};
Although, I want to find a nice way to "expire" cache records, relying on a hardcoded cache name only might be an issue.
#Revalidation
To handle "expired" resources, I switched the fetch event to the stale-while-revalidate strategy:
onfetch = (event) => {
console.log("Service worker fetching", event.request.url);
event.respondWith(caches.open(cacheName).then((cache) => {
return cache.match(event.request).then((cachedResponse) => {
const fetchedResponse = fetch(event.request).then((networkResponse) => {
if (networkResponse.status < 400) {
console.log("Caching the response to", event.request.url);
cache.put(event.request, networkResponse.clone());
} else {
console.log("Service worker not caching the response to", event.request.url);
}
return networkResponse;
}).catch(() => caches.match("/offline/"));
return cachedResponse || fetchedResponse;
});
}));
};
#Precache
After settling on the cache event, I wanted to properly support the offline mode. The standard approach for this is to use the background sync API. A quick examination suggests this is a picky solution, and support is very limited. That alone is enough to look for a workaround. I started from the ground.
First, I needed to generate the cache list, so I took my macro and applied its logic directly in the HTML <head> to use the output with a data-cache tag attribute while linking the service worker's loader script.
Second, I needed a way to get the cache list to "sync" with the service worker. The search got me the postMessage() service worker method that "sends a message to the worker". Bingo. To catch the message on the other side, one needs to implement the message event:
onmessage = (event) => {
console.log("I am the service worker");
};
Now, what stops me from repeating what I have been doing during the service worker installation? I sent a message after the service worker's activation, checked the request type, and started to fill the cache using URLs from the message. Worked.
onmessage = (event) => {
if (event.data.type === "PRECACHE") {
const data = event.data.payload;
console.log("Service worker started precache", data);
event.waitUntil(
(async () => {
const cache = await caches.open(cacheName);
await cache.addAll(data)
.catch((error) => console.log("Service worker failed precache", error));
})(),
);
}
};
The cache is full, all assets are included, and I had no issues mixing absolute/relative links (though maybe it's not a good "feature" after all). The hardcoded cache list with critical assets got reintroduced, along with the offline page, all to be cached during the installation. I also started requesting the precache only after the installation, to avoid redundant fetches:
const data = new String(document.currentScript.getAttribute('data-cache'));
const precacheList = data.split(" ");
const registerServiceWorker = async () => {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
if (registration.installing) {
console.log("Service worker installing");
navigator.serviceWorker.ready.then((registration) => {
console.log("Service worker requesting precache");
registration.active.postMessage({
payload: precacheList,
type: "PRECACHE",
});
});
} else if (registration.waiting) {
console.log("Service worker installed");
} else if (registration.active) {
console.log("Service worker active");
}
} catch (error) {
console.error("Service worker registration failed", error);
}
}
};
registerServiceWorker();
This setup delivers a fully offline-ready site. The service worker deploys critical files during the installation, then precaches everything else.
#Conclusion
It looks like I'll do anything just to avoid touching CSS in Halve-Z. It was a nice exercise, though. I built a simple and capable PWA without jeopardizing the workflow or user experience. All code is available in theme's pull requests #22, #23, and #24.