Skip to content

15 PWA - SW

Service Worker

Load sw in index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<script>
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('sw.js’)
    .then(() => {
        console.log('Serwice worker registered!);
    })
.catch((error) => {
    console.error('Service worker registration failed', error);
});
} else {
    console.error('No service worker support!');
}
</script>

SW.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
const CACHE = "cache_0.1.2";

const PREFETCH = [
'./css/materialize.css',
'./css/materialize.min.css',
];

const installFn = async (event: ExtendableEvent): Promise<void> => {
    try {
        const cache = await caches.open(CACHE);
        console.log('Starting prefetch...', cache);
        return await cache.addAll(PREFETCH);
    } catch (err) {
        console.error('Error in cache prefetch', err);
        return Promise.reject(err);
    }
};

const activateFn = async (event: ExtendableEvent): Promise<void> => {
    const cacheKeyList = await caches.keys();
    cacheKeyList.map(async (cacheKey) => {
        if (cacheKey != CACHE) {
            console.warn('Deleting from cache: ' + cacheKey);
            await caches.delete(cacheKey);
        }
    });
};

const getCachedResponse = async (request: RequestInfo, cache: Cache): Promise<Response> => {
    const cachedResponse = await cache.match(request);
    if (cachedResponse != undefined) return cachedResponse;
    return new Response(undefined, { status: 500, statusText: 'Not found in cache!' });
}

const fetchFn = async (event: FetchEvent): Promise<Response> => {
    const cache = await caches.open(CACHE);
    if (navigator.onLine) {
        try {
            // get fresh copy from net, save to cache, return it
            const response = await fetch(event.request);
            cache.put(event.request, response.clone());
            return response;
        } catch (err) {
            return await getCachedResponse(event.request, cache);
        }
    } else {
        return await getCachedResponse(event.request, cache);
    }
};

self.addEventListener('install', ((event: ExtendableEvent): void => {
    console.log('SW install ' + CACHE, event);
    // waitUntil parameter is a Promise, not an function that returns a Promise
    event.waitUntil(installFn(event));
    console.log('install done');
}) as EventListener);

self.addEventListener('activate', ((event: ExtendableEvent) => {
    console.log('SW activate ' + CACHE, event);
    // delete all keys/data from cache not currently ours
    event.waitUntil(activateFn(event));
}) as EventListener);

self.addEventListener('fetch', ((event: FetchEvent) => {
    console.log('SW fetch ' + CACHE, event);
    event.respondWith(fetchFn(event));
}) as EventListener);

PWA LifeCycle

Registration

  • Happens automatically when page registers sw js.

Installation – install event is fired

  • New service worker file
  • A modified service worker file

Activation – sw is installed, but not yet active

  • SW does not move to activated state immediately. Only when
    • None of the pages use the service worker and are closed
    • There is no other service worker active on that page
  • Hard refresh, close tab/navigate to page without sw, use dev tools
  • Call skipWaiting() somewhere in js code (in install event)

SW update

  • SW is shared among all windows/tabs to our app
  • Cannot be updated automatically until all instances are closed
  • Simple refresh does not help – browser does not unload previous instance immediately, so instance count never goes down to 0
  • Not updatin sw can be a problem – wrong cache content, incorrect proxying etc.

SW update - Forceful takeover

  • Call self.skipWaiting() - it immediately stops the previously active SW and activates the new one, so that all the currently opened windows will be served by the new one.
  • Call at the end of install event – new SW is immediately activated
  • Problem - all the already opened windows were loaded with the help of the previous SW that potentially used different versions of the assets.
    • What happens with code splitting? Lazy loading?

SW Update

  • Possible solution – order all opened windows to refresh immediately after skipWaiting is called.
  • Can be done from SW – iterate over self.clients
  • Or listen to controllerchange event from app and refresh pages on it

native

SW Update

1
2
3
const installFn = async (event: ExtendableEvent): Promise<void> => {
    sw.skipWaiting();
}

Refresh from SW (cast and alias self…)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const sw = self as ServiceWorkerGlobalScope & typeof globalThis;

const activateFn = async (event: ExtendableEvent): Promise<void> => {
    const cacheKeyList = await caches.keys();
    cacheKeyList.map(async (cacheKey) => {
        if (cacheKey != CACHE) {
            console.warn('Deleting from cache: ' + cacheKey);
            await caches.delete(cacheKey);
        }
    });
    const tabs = await sw.clients.matchAll({type: 'window'})
    tabs.forEach((tab: WindowClient) => {
        tab.navigate(tab.url)
    });
};

SW Update - from app

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<script>
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('sw.js')
        .then(() => {
            console.log('Serwice worker registered!');let refreshing = false;
            navigator.serviceWorker.addEventListener('controllerchange', () => {
            if (!refreshing) {
                window.location.reload();
                refreshing = true;
            }
            });
        })
        .catch((error) => {
            console.error('Service worker registration failed', error);
        });
} else {
    console.error('No service worker support!');
}
</script>

SW Update

Forced refresh is UX problem. Causes unexpected page/app reload, disturbing user workflow.

Deferred approach with user consent is more desirable

  • When user is idle – hard to detect.
  • Reload when navigating between SPA views/pages – but there might be more than one tab active. UX flow might not really allow reloading.
  • Ask for user consent

SW Update

The browser checks for the new Service Worker version periodically, as well as on the navigator.serviceWorker.register() call on every visit that happens at least 24 hours after the last Service Worker update.

When the change is detected (it's a byte-by-byte content comparison), the new Service Worker is being installed (its install event handler is executed) as well as it is signaled to the app by updatefound event

SW Update

1
2
3
4
5
6
7
8
9
// get the ServiceWorkerRegistration instance
const registration = await navigator.serviceWorker.getRegistration();

// (it is also returned from navigator.serviceWorker.register() function)
if (registration) { // if there is a SW active
    registration.addEventListener('updatefound', () => {
        console.log('Service Worker update detected!');
    });
}

This is too soon to update/notify user – install might be incomplete or fail.

SW update

Wait for new instance to be actually installed

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// get the ServiceWorkerRegistration instance
const registration = await navigator.serviceWorker.getRegistration();
// new SW instance is visible under installing property, it is in 'installing' state
// wait until it changes its state
registration.installing.addEventListener('statechange', () => {
    if (registration.waiting) {
        // new SW instance is now waiting for activation (its state is 'installed')
        // invoke update UX safely
    } else {
        // apparently installation must have failed (SW state is 'redundant')
    }
});

SW update

Signal SW to trigger skipWaiting

1
2
3
4
const registration = await navigator.serviceWorker.getRegistration();
someButton.addEventListener('click', () => {
    registration.waiting.postMessage('SKIP_WAITING');
});

Receive message in SW (remove skipWaiting from install event first…)

Reload pages also from somewhere

1
2
3
4
5
self.addEventListener('message', ((event: MessageEvent) => {
    if (event.data === 'SKIP_WAITING') {
        sw.skipWaiting();
    }
}) as EventListener);

SW Update - deferred

native

One more problem – updatefound is triggered only once. If user doesn’t react to it, we need to display notification somehow again.

Check for registration.waiting instance existence on every page load

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function invokeServiceWorkerUpdateFlow(registration) {
    someButton.show("New version of the app is available. Refresh now?");
    someButton.addEventListener('click', () => {
        if (registration.waiting) {
            registration.waiting.postMessage('SKIP_WAITING');
        }
    });
}
if ('serviceWorker' in navigator) {
    window.addEventListener('load', async () => {
        const registration = await navigator.serviceWorker.register('/sw.js');
        if (registration.waiting) {
            invokeServiceWorkerUpdateFlow(registration);
        }
        registration.addEventListener('updatefound', () => {
        if (registration.installing) {
            registration.installing.addEventListener('statechange', () => {
                if (registration.waiting) {
                    if (navigator.serviceWorker.controller) {
                        invokeServiceWorkerUpdateFlow(registration);
                    } else {
                        console.log('Service Worker initialized for the first time’);
                    }
                }
            });
        }
        });
        let refreshing = false;
        navigator.serviceWorker.addEventListener('controllerchange', () => {
            if (!refreshing) {
                window.location.reload();
                refreshing = true;
            }
        });
    });
}

Messages API

Client -> SW

1
2
3
4
5
6
7
//sender
navigator.serviceWorker.controller.postMessage('Hello service worker!’);

//receiver
self.addEventListener('message', (event) => {
    console.log('Message received ->', event.data);
});

SW -> Client

1
2
3
4
5
6
7
8
9
//sender
self.clients.matchAll().then(clients => {
    clients.forEach(client => client.postMessage('Hello from SW!'));
});

//receiver
navigator.serviceWorker.onmessage = (event) => {
    console.log('Message received from SW ->', event.data);
}

Notifications

Ask for permissions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
document.addEventListener('DOMContentLoaded', init, false);
function init() {
    if ('Notification' in window) {
        Notification.requestPermission(result => {
            if (result === 'granted') {
                console.log('Acess granted! :)');
            } else if (result === 'denied') {
                console.log('Access denied :(');
            } else {
                console.log('Request ignored :/');
            }
        });
    }
}

Notifications (permanent)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const registration = await navigator.serviceWorker.getRegistration();
~~~~

Get access to SW registration and use showNotification

~~~ts
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready.then(registration => {
        const title = 'Test title'
        const options = {
            body: 'Test body!',
        }
        registration.showNotification(title, options)
    });
}

MDN

https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification

Notification – Non persistent

1
2
3
4
5
6
7
function spawnNotification(body, icon, title) {
var options = {
    body: body,
    icon: icon
}
var notification = new Notification(title, options);
}