Skip to content

Instantly share code, notes, and snippets.

@bseib
Created November 11, 2019 01:53
Show Gist options
  • Save bseib/06164a973552fe9f814df97bb4d30305 to your computer and use it in GitHub Desktop.
Save bseib/06164a973552fe9f814df97bb4d30305 to your computer and use it in GitHub Desktop.
Manage service worker installation and monitor for updates
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...
}
}
}
@bseib
Copy link
Author

bseib commented Nov 11, 2019

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...

@bseib
Copy link
Author

bseib commented Nov 11, 2019

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