/* global self */ import { precacheAndRoute } from 'workbox-precaching' import { offlineFallback } from 'workbox-recipes' import { setDefaultHandler } from 'workbox-routing' import { NetworkOnly } from 'workbox-strategies' import { enable } from 'workbox-navigation-preload' import manifest from './precache-manifest.json' import ServiceWorkerStorage from 'serviceworker-storage' import { CLEAR_NOTIFICATIONS, DELETE_SUBSCRIPTION, STORE_SUBSCRIPTION } from '@/components/serviceworker' // we store existing push subscriptions for the onpushsubscriptionchange event const storage = new ServiceWorkerStorage('sw:storage', 1) // comment out to enable workbox console logs self.__WB_DISABLE_DEV_LOGS = true // preloading improves startup performance // https://developer.chrome.com/docs/workbox/modules/workbox-navigation-preload/ enable() // ignore precache manifest generated by InjectManifest // they statically check for the presence of this variable console.log(self.__WB_MANIFEST) // precache the manifest we generated ourselves precacheAndRoute(manifest) // immediately replace existing service workers with this one // (no wait until this one becomes active) self.addEventListener('install', () => self.skipWaiting()) // Using network-only as the default strategy ensures that we fallback // to the browser as if the service worker wouldn't exist. // The browser may use own caching (HTTP cache). // Also, the offline fallback only works if request matched a route setDefaultHandler(new NetworkOnly({ plugins: [{ fetchDidFail: async (args) => { // tell us why a request failed in dev // process.env.NODE_ENV !== 'production' && console.log('fetch did fail', ...args) }, fetchDidSucceed: async ({ request, response, event, state }) => { if ( response.ok && request.headers.get('x-nextjs-data') && response.headers.get('x-nextjs-matched-path') && response.headers.get('content-type') === 'application/json' && response.headers.get('content-length') === '2' && response.status === 200) { console.log('service worker detected a successful yet empty nextjs SSR data response') console.log('nextjs has a bug where it returns a 200 with empty data when it should return a 404') console.log('see https://github.com/vercel/next.js/issues/56852') console.log('HACK ... intercepting response and returning 404') const headers = new Headers(response.headers) headers.delete('x-nextjs-matched-path') headers.delete('content-type') headers.delete('content-length') return new Response(null, { status: 404, statusText: 'Not Found', headers, ok: false }) } return response } }] })) // This won't work in dev because pages are never cached. // See https://github.com/vercel/next.js/blob/337fb6a9aadb61c916f0121c899e463819cd3f33/server/render.js#L181-L185 offlineFallback({ pageFallback: '/offline' }) self.addEventListener('push', function (event) { let payload try { payload = event.data?.json() if (!payload) { throw new Error('no payload in push event') } } catch (err) { // we show a default nofication on any error because we *must* show a notification // else the browser will show one for us or worse, remove our push subscription return event.waitUntil( self.registration.showNotification( // TODO: funny message as easter egg? // example: "dude i'm bugging, that's wild" from https://www.youtube.com/watch?v=QsQLIaKK2s0&t=176s but in wild west theme? 'something went wrong', { icon: '/icons/icon_x96.png' } ) ) } event.waitUntil( self.registration.showNotification(payload.title, payload.options) .then(() => self.registration.getNotifications()) .then(notifications => self.navigator.setAppBadge?.(notifications.length)) ) }) self.addEventListener('notificationclick', function (event) { event.notification.close() const promises = [] const url = event.notification.data?.url if (url) { // First try to find and focus an existing client before opening a new window promises.push( self.clients.matchAll({ type: 'window', includeUncontrolled: true }) .then(clients => { if (clients.length > 0) { const client = clients[0] return client.focus() .then(() => { return client.postMessage({ type: 'navigate', url }) }) } else { return self.clients.openWindow(url) } }) ) } promises.push( self.registration.getNotifications() .then(notifications => self.navigator.setAppBadge?.(notifications.length)) ) event.waitUntil(Promise.all(promises)) }) // not supported by iOS // see https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclose_event self.addEventListener('notificationclose', function (event) { event.waitUntil( self.registration.getNotifications() .then(notifications => self.navigator.setAppBadge?.(notifications.length)) ) }) self.addEventListener('pushsubscriptionchange', function (event) { // https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event const { oldSubscription, newSubscription } = event return event.waitUntil( Promise.all([ oldSubscription ?? storage.getItem('subscription'), newSubscription ?? self.registration.pushManager.getSubscription() ]) .then(([oldSubscription, newSubscription]) => { if (!newSubscription || oldSubscription?.endpoint === newSubscription.endpoint) { // no subscription exists at the moment or subscription did not change return } // convert keys from ArrayBuffer to string newSubscription = JSON.parse(JSON.stringify(newSubscription)) // save new subscription on server return Promise.all([ newSubscription, fetch('/api/graphql', { method: 'POST', headers: { 'Content-type': 'application/json' }, body: JSON.stringify({ query: ` mutation savePushSubscription( $endpoint: String!, $p256dh: String!, $auth: String!, $oldEndpoint: String! ) { savePushSubscription( endpoint: $endpoint, p256dh: $p256dh, auth: $auth, oldEndpoint: $oldEndpoint ) { id } }`, variables: { endpoint: newSubscription.endpoint, p256dh: newSubscription.keys.p256dh, auth: newSubscription.keys.auth, oldEndpoint: oldSubscription?.endpoint } }) }) ]) }).then(([newSubscription]) => storage.setItem('subscription', newSubscription)) ) }) self.addEventListener('message', function (event) { switch (event.data.action) { case STORE_SUBSCRIPTION: return event.waitUntil(storage.setItem('subscription', { ...event.data.subscription, swVersion: 2 })) case DELETE_SUBSCRIPTION: return event.waitUntil(storage.removeItem('subscription')) case CLEAR_NOTIFICATIONS: return event.waitUntil( Promise.all([ self.registration.getNotifications() .then(notifications => notifications.forEach(notification => notification.close())), self.navigator.clearAppBadge?.() ]) ) } })