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

Here's how I used it in the base of my Vue app:

@Component
export default class App extends Vue {

  // lifecycle hooks
  created() {
    const workerManager = ServiceWorkerManager.instance();
    workerManager.addOnWaitingWorkerListener((waitingWorker: ServiceWorker | null) => {
      this.isUpdateAvailable = (waitingWorker === null) ? false : true;
    });
    this.installUpdateMonitor(workerManager);
  }

  // ...

}

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:

  • this was the first time a service worker was installed
  • a service worker is already installed, and there is no new worker waiting to be activated
  • a service worker was already installed, and there is a new worker waiting to be activated

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:

  btnCheckForUpdate() {
    this.updateCheckResult = '';
    const workerManager = ServiceWorkerManager.instance();
    workerManager.whenReady()
    .then(() => {
      workerManager.checkForUpdate()
      .then((isUpdateFound: boolean) => {
        this.updateCheckResult = isUpdateFound ? "an update is available, will restart in a moment..." : "no update found";
        if ( isUpdateFound ) {
          setTimeout(() => {
            workerManager.activateWaitingWorker();
          }, 3000);
        }
      })
      .catch((err) => {
        this.updateCheckResult = err;
      });
    });
  }

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 ;-)

@bseib
Copy link
Author

bseib commented Nov 11, 2019

BTW, any of those setTimeout() calls are not require to be delayed -- I just did it so that I could witness my console logs...

@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