This is the solution I came up with using Google's workbox.
Sidenote: Workbox appears to have a has a solution for most common service worker use cases and has a very flexible distribution model that makes it quite easy to work with, either in a vanilla js environment or with your framework of choice.
We ended up converting our server side AppCache (cach manifest code) to generate a service worker. (How to precaching an url list coming from a json file?)
Depending on your server side language you're code will vary, but this is the end product that worked for us:
service-worker.js (generated server side)
const productVersion = "3.01";
importScripts('/assets/workbox/workbox-sw.js');
workbox.setConfig({
modulePathPrefix: '/assets/workbox/'
});
const { precacheAndRoute, createHandlerBoundToURL } = workbox.precaching;
const { NavigationRoute, registerRoute, setCatchHandler } = workbox.routing;
precacheAndRoute([
// cache index html
{url: '/', revision: '3.01' },
// web workers
{url: '/assets/some-worker.js?ver=3.01', revision: '' },
{url: '/assets/other-worker.js?ver=3.01', revision: '' },
// other js files
{url: '/assets/shared-function.js', revision: '3.01' },
// ... removed for brevity
// css
{url: '/assets/site.css', revision: '3.01' },
{url: '/assets/fonts/fonts.css', revision: '3.01' },
// svg's
{url: '/assets/images/icon.svg', revision: '3.01' },
{url: '/assets/images/icon-2.svg', revision: '3.01' },
// png's
{url: '/assets/images/img-1.png', revision: '3.01' },
{url: '/assets/images/favicon/apple-touch-icon-114x114.png', revision: '3.01' },
// ...
// ...
// fonts
{url: '/assets/fonts/lato-bla-webfont.eot', revision: '3.01' },
{url: '/assets/fonts/lato-bla-webfont.ttf', revision: '3.01' },
// sounds
{url: '/assets/sounds/keypress.ogg', revision: '3.01' },
{url: '/assets/sounds/sale.ogg', revision: '3.01' },
]);
// Routing for SPA
// This assumes DEFAULT_URL has been precached.
const DEFAULT_URL = '/';
const handler = createHandlerBoundToURL(DEFAULT_URL);
const navigationRoute = new NavigationRoute(handler, {
denylist: [
new RegExp('/ping'),
new RegExp('/upgrade'),
new RegExp('/cache.manifest'),
],
});
registerRoute(navigationRoute);
// This allows the main window to signal the service worker that
// it should go ahead and install if it's waiting.
addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
skipWaiting();
}
});
- There's a few other things to note. We had to figure out how to smoothly upgrade from App?Cache to Service Workers. Turns out the generating an empty cache manifest for us did the trick.
- We already had an upgrade process in place (prompt user to upgrade or force automatic upgrade with a count down timer) so we had to do some work to get that to work with service workers. Notice the end of the service worker file has
addEventListener code. We actually call that from an upgrade page to get a smooth upgrade process. It goes something like this:
A) Upgrade script detects new version is available (many ways to do this, api call polling, etc)
B) If user accepts or timer expires redirect user to an upgrade page. This step is vital b/c you can't update a service worker with an app still running. So navigate to the upgrade page, wait for the service worker to get installed then tell it to skip waiting and the redirect to main (login) screen.
C) User is happily running new version of app.
Upgrade page code:
(this is a good page to show an "updating" UI of some type)
<script type="module">
import { Workbox } from '/assets/workbox/workbox-window.prod.mjs';
if ('serviceWorker' in navigator) {
const wb = new Workbox('/serviceworker');
// This code exists b/c a service worker can't update with just a refresh/reload in the
// browser. This is b/c on a reload, the old and new page exist simultaneously and the old MUST
// unload before the new service worker can automatically assume control. Also if multiple pages
// are open, this blocks the service worker from taking control (multiple pages should not an issue with this app).
// This code activates a waiting service worker and _then_ redirects back to the app.
// Add an event listener to detect when the registered
// service worker has installed but is waiting to activate.
wb.addEventListener('waiting', (event) => {
// Set up a listener that will reload the page as soon as the previously waiting
// service worker has taken control.
wb.addEventListener('controlling', (event) => {
window.location.replace('/login');
});
// Send a message telling the service worker to skip waiting.
// This will trigger the `controlling` event handler above.
wb.messageSW({type: 'SKIP_WAITING' });
});
wb.register();
}
// set a timeout in case the service worker has already installed.
setTimeout(function () {
window.location.replace('/login');
}, 30000);
</script>
Main page (index.html, etc)
(Handles if the user is coming to the app with a server worker ready to activate, so it needs a refresh to get the right assets/code loaded)
<script type="module">
import { Workbox } from '/assets/workbox/workbox-window.prod.mjs';
if ('serviceWorker' in navigator) {
const wb = new Workbox('/serviceworker');
wb.addEventListener('activated', (event) => {
// `event.isUpdate` will be true if another version of the service
// worker was controlling the page when this version was registered.
if (!event.isUpdate) {
// service worker was updated and activated for the first time.
// If your service worker is configured to precache assets, those
// assets should all be available now.
// this will only happen if the browser was closed when a new version was made available
// and it will only happen once per service worker install.
// Reload to so all libs are correct version.
window.location.reload(true);
}
});
wb.register();
}
</script>