Created
November 11, 2019 01:53
-
-
Save bseib/06164a973552fe9f814df97bb4d30305 to your computer and use it in GitHub Desktop.
Manage service worker installation and monitor for updates
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
interface InstalledWorkers { | |
waitingWorker: ServiceWorker | null; | |
activeWorker: ServiceWorker | null; | |
} | |
type ListenerCallback = (waitingWorker: ServiceWorker | null) => any; | |
export default class ServiceWorkerManager { | |
private static singleton: ServiceWorkerManager | null = null; | |
public static instance(): ServiceWorkerManager { | |
if ( null === ServiceWorkerManager.singleton ) { | |
ServiceWorkerManager.singleton = new ServiceWorkerManager(); | |
} | |
return ServiceWorkerManager.singleton; | |
} | |
private waitingWorkerListeners: ListenerCallback[] = []; | |
private isDoingPageReload: boolean = false; | |
private _waitingWorker: ServiceWorker | null = null; | |
private _activeWorker: ServiceWorker | null = null; | |
private _checkForUpdate: (() => Promise<boolean>) | null = null; | |
private _initializer: Promise<any>; | |
private _isInitialized: boolean = false; | |
private constructor() { | |
this._initializer = new Promise<any>((resolve, reject) => { | |
if ( !('serviceWorker' in navigator) ) { | |
reject('service workers are not available'); | |
} | |
if ( this._isInitialized ) { | |
resolve(); | |
} | |
else { | |
this.initialize() | |
.then(() => { | |
this._isInitialized = true; | |
resolve(); | |
}) | |
.catch((err) => { | |
reject('initialization failed: ' + err); | |
}); | |
} | |
}); | |
} | |
public get isInitialized(): boolean { | |
return this._isInitialized; | |
} | |
public get waitingWorker(): ServiceWorker | null { | |
return this._waitingWorker; | |
} | |
public get activeWorker(): ServiceWorker | null { | |
return this._activeWorker; | |
} | |
public addOnWaitingWorkerListener(callback: ListenerCallback) { | |
this.waitingWorkerListeners.push(callback); | |
} | |
// The promise is resolved when one or both `activeWorker` and `waitingWorker` have values. | |
// If a new worker is installing, then the promise is not resolved until it becomes a | |
// `waitingWorker`. | |
public whenReady(): Promise<any> { | |
return this._initializer; | |
} | |
public activateWaitingWorker() { | |
console.log('activate waiting worker'); | |
console.log(' `--> waitingWorker = ', this.waitingWorker); | |
if ( this.waitingWorker ) { | |
const worker = this.waitingWorker; | |
console.log(' `--> in 5 seconds, tell service worker to skipWaiting'); | |
setTimeout(() => { | |
worker.postMessage({ name: 'skipWaiting'}); | |
}, 5000); | |
} | |
} | |
public checkForUpdate(): Promise<boolean> { | |
return new Promise((resolve, reject) => { | |
if ( this._checkForUpdate ) { | |
this._checkForUpdate() | |
.then((isUpdateFound: boolean) => { | |
resolve(isUpdateFound); | |
}) | |
.catch((err) => { | |
reject(err); | |
}); | |
} | |
else { | |
reject('there is no service worker registration to update'); | |
} | |
}); | |
} | |
private initialize(): Promise<any> { | |
console.log("initialize service worker"); | |
// document.addEventListener('service-worker-installed', (event) => { this.onNewServiceWorkerWasInstalled(event); }, { once: true }); | |
navigator.serviceWorker.addEventListener('controllerchange', () => { this.onWaitingWorkerWasActivated(); }); | |
// when this resolves, one or both `_waitingWorker` and `_activeWorker` will be assigned. | |
return new Promise<any>((resolve, reject) => { | |
window.addEventListener('load', () => { | |
const swUrl = `${process.env.BASE_URL}service-worker.js`; | |
const registrationOptions = {}; | |
console.log(` \`--> registering service worker: ${swUrl}`); | |
navigator.serviceWorker.register(swUrl, registrationOptions) | |
.then((registration: ServiceWorkerRegistration) => { | |
console.log(` \`--> registration done`); | |
// Watch for new updates that are found. I presume that calling navigator.serviceWorker.register() causes | |
// an internal call to registration.update(), which fetches `swUrl` and can trigger the `onupdatefound` handler. | |
registration.onupdatefound = this.createUpdateFoundHandler(registration, (installedWorkers: InstalledWorkers) => { | |
this.assignToWaitingWorker(installedWorkers.waitingWorker); | |
this._activeWorker = installedWorkers.activeWorker; | |
this.logCurrentWorkers(registration); | |
resolve(); | |
}); | |
this.logCurrentWorkers(registration); | |
// capture the update function for later use | |
this._checkForUpdate = this.createCheckForUpdateHandler(registration, swUrl); | |
this._activeWorker = registration.active; // could be null if none have been activated | |
if ( !registration.installing ) { | |
// ignore the 'installing' case because the `onupdatefound` handler above will resolve when it becomes 'waiting' | |
if ( registration.waiting) { | |
this.assignToWaitingWorker(registration.waiting); | |
} | |
// we must have an active or waiting worker to resolve. But if one is installing, then above handler will resolve it. | |
if ( this._activeWorker || this._waitingWorker ) { | |
resolve(); | |
} | |
} | |
}) | |
.catch((err) => { | |
reject(err); | |
}); | |
}); | |
}); | |
} | |
private logCurrentWorkers(registration: ServiceWorkerRegistration) { | |
console.log(` \`--> the installing worker: `, registration.installing); | |
console.log(` \`--> the waiting worker: `, registration.waiting); | |
console.log(` \`--> the active worker: `, registration.active); | |
} | |
private createUpdateFoundHandler(registration: ServiceWorkerRegistration, onInstalled: (installedWorkers: InstalledWorkers) => any ) { | |
return () => { | |
console.log(` \`--> an updated service worker was found`); | |
const installingWorker = (registration.installing as ServiceWorker); // cast because it won't be null | |
installingWorker.onstatechange = () => { | |
console.log(` \`--> an installing worker state changed to: ${installingWorker.state}`); | |
if ( installingWorker.state === 'installed' ) { | |
if ( registration.active ) { // installed, but now waiting | |
console.log(` \`--> a previous active worker is already in place, so become the waiting worker`); | |
onInstalled({ | |
waitingWorker: registration.waiting, | |
activeWorker: registration.active, | |
}); | |
} | |
} | |
else if ( installingWorker.state === 'activated' ) { // installed and now waiting | |
if ( registration.active ) { | |
console.log(` \`--> no previous active worker is in the way, so become the active worker`); | |
onInstalled({ | |
waitingWorker: registration.waiting, | |
activeWorker: registration.active, | |
}); | |
} | |
} | |
}; | |
}; | |
} | |
private createCheckForUpdateHandler(registration: ServiceWorkerRegistration, swUrl: string): () => Promise<boolean> { | |
return (): Promise<boolean> => { | |
return new Promise<boolean>((resolve, reject) => { | |
console.log(`checking for an update to ${swUrl}`); | |
registration.update().then(() => { | |
this.logCurrentWorkers(registration); | |
if ( registration.installing ) { | |
registration.onupdatefound = this.createUpdateFoundHandler(registration, (installedWorkers: InstalledWorkers) => { | |
this.assignToWaitingWorker(installedWorkers.waitingWorker); | |
this._activeWorker = installedWorkers.activeWorker; | |
this.logCurrentWorkers(registration); | |
resolve(true); | |
}); | |
} | |
else { | |
if ( registration.waiting ) { | |
resolve(true); | |
} | |
else { | |
resolve(false); | |
} | |
} | |
}).catch((err) => { | |
reject(err); | |
}); | |
}); | |
}; | |
} | |
private assignToWaitingWorker(worker: ServiceWorker | null) { | |
this._waitingWorker = worker; | |
this.waitingWorkerListeners.forEach((callback) => { | |
callback(worker); | |
}); | |
} | |
private onWaitingWorkerWasActivated() { | |
console.log('onWaitingWorkerWasActivated()'); | |
if ( ! this.isDoingPageReload) { | |
this.isDoingPageReload = true; | |
window.location.replace('/'); // This was specific to my needs... A callback fn might be more general... | |
} | |
} | |
} |
BTW, any of those setTimeout()
calls are not require to be delayed -- I just did it so that I could witness my console logs...
Also, I think an improvement to this code would be to not store the values of _waitingWorker
and _activeWorker
. After all, the navigator.serviceWorker
is the ultimate source of truth. It's just a matter of knowing when everything is in a settled state before asking for those values so that you can act on them. I have another version of this code on a branch somewhere I could dig up...
Where does that skipWaiting
event end up? In service-worker.js
:
self.addEventListener('message', event => {
if ( !event.data || !event.data.name ) {
return;
}
console.log("service-worker received event: " + event.data.name);
switch ( event.data.name ) {
case 'skipWaiting':
self.skipWaiting();
// the waiting worker will become the active worker, then it
// will fire 'controllerchange' event on navigator.serviceWorker
break;
// ...
}
});
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Here's how I used it in the base of my Vue app:
I wanted a Vue UI that makes the call to install my service worker with a "ready for business" callback. I have a initial setup screen that has some user interaction that affects what things get cached in the service worker (a bunch of huge videos for offline use, and are not part of any webpack bundling).
Furthermore, I wanted my Vue app to have an async way of knowing that the "service worker situation has settled into a steady state". That means you want to know when the machine has reached one of these cases:
Calling
ServiceWorkerManager.instance().whenReady().then( ... )
from any view will provide that state information as soon as the information is available.For example I have an admin page where I can click a button to force a check for update:
I think this is a cleaner approach to understanding the timing of service worker stuff. But then again, maybe its just clearer to me now because I wrote the code after spending the time learning more details on service worker stuff.
I should do a better write up on this topic.... in my spare time ;-)