Mit Serviceworkern arbeiten

Ein Serviceworker ist ein Javascript das im Hintergrund läuft und für Webseiten verschiedene Aufgaben übernimmt. Ein Haupteinsatz des Serviceworkers-Script ist das gezielte Cachen von Serveranfragen und deren Antworten.

Damit liefert ein Serviceworker die optimalen Voraussetzungen um eine offlinefirst Anwendung umzusetzen. Der Serviceworker kümmert sich je nach Umsetzung um die verschiedenen Ressourcen und das gewünschte Verhalten.

Ein Serviceworker-Script muss im gleichen Verzeichnis, wie das Hauptdokument liegen.

In der Console kann der Serviceworker über die Host-Konfiguration aktiviert werden. Hierzu muss der URL des Scripts im Schlüssel serviceworker definiert werden.

serviceworker: {
    "url": "/sw.js",
    "scope": "/"
},

Der Serviceworker wird nach dem Laden des Hauptdokuments registriert und aktiviert. Den Zugriff auf die Datenstruktur des Wrappers erhält man über die Methode Alvine.Package.Console.Host.getServiceWorkerWrapper(). Über den Wrapper hiermit erhaltenden Wrapper kann mittels der Methode getRegistration() auf die eigentliche Registration zugegriffen werden.

Hinweis

Dieses Verhalten kann über den Header service-worker im Request angepasst werden. Weiter Informationen sind auf jakearchibald.com zu finden.

Installation und Update

Ein Serviceworker bleibt auch ohne Windows-Objekt im Hintergrund bestehen. Es gibt also keine Möglichkeit den Serviceworker über die normale Oberfläche zu aktualisieren. Die unterschiedlichen Browser bietet hier über die Developer-Tools verschiedenen Werkzeuge an.

Der Serviceworker wird aber immer dann neu geladen und installiert, wenn er auf dem Server geändert wird. Allerdings wird der neue Serviceworker erst aktiviert, wenn alle Tabs die mit dem Serviceworker verbunden sind, geschlossen wurden.

Als Entwickler kann man auf diese beiden Events install und activate mit einem Handler reagieren und das Standardverhalten anpassen. Im folgenden Beispiel wird die Wartezeit bei der Aktivierung über self.clients.claim() deaktiviert.

/** Sofortige Installation und Aktivierung des Serviceworkers
 *  @link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/skipWaiting */
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', () => self.clients.claim());

Caching

Das Caching erfolgt über das Abfangen des fetch-Event. Ein einfaches Beispiel könnte folgendermaßen aussehen:

addEventListener('fetch', event => {
  // Nur GET-Abfragen bearbeiten
  if (event.request.method !== 'GET') return;

  // Standard unterbinden und Event verarbeiten
  event.respondWith(async function() {
    // Cachedatei holen
    const cache = await caches.open('cache-v1');
    const cachedResponse = await cache.match(event.request);

    if (cachedResponse) {
      // Wurde die Datei im Cache gefunden, diese zurückgeben
      event.waitUntil(cache.add(event.request));
      return cachedResponse;
    }

    // Alternativ die Datei aus dem Netzwerk holen
    return fetch(event.request);
  }());
});

Weitere Strategien und Konzepte finden sich auf der Webseite The Offline Cookbook

Workbox

Mit der Workbox-Bibliothek stehen hilfreiche Klassen für die Verwaltung des Caches und der Anfragen zur Verfügung. Um die einzelnen Klassen einzubinden muss am Anfang des Serviceworkes die entsprechende Bibliothek über importScript geladen werden.

importScripts('https://unpkg.com/[email protected]/build/importScripts/workbox-runtime-caching.dev.v2.0.0.js');
importScripts('https://unpkg.com/[email protected]/build/importScripts/workbox-routing.dev.v2.0.0.js');
importScripts('https://unpkg.com/[email protected]/build/importScripts/workbox-background-sync.dev.v2.0.1.js');
importScripts('https://unpkg.com/[email protected]/build/importScripts/workbox-cacheable-response.dev.v2.0.0.js');
importScripts('https://unpkg.com/[email protected]/build/importScripts/workbox-cache-expiration.dev.v2.0.0.js');

Mit Workbox ist es nun Möglich verschiedene Funktionen einfach umzusetzen. Zuerst braucht man ein Router-Objekt zur Verwaltung der einzelnen Routen.


// Router erstellen
const router = new workbox.routing.Router();
// und auf Fetch-Event hören
router.addFetchListener();

Eine Default-Route behandelt alle nicht definierten Routen. Hier wird die workbox.runtimeCaching.NetworkFirst Strategie eingesetzt. Diese verschiedenen Strategien können hier nachgelesen werden.

router.setDefaultHandler({
    // Ressource erst übers Netzwerk holen und falls der Server 
    // nicht verfügbar ist, im Cache suchen. 
    handler: new workbox.runtimeCaching.NetworkFirst({
        requestWrapper: new workbox.runtimeCaching.RequestWrapper({
            cacheName: 'my-cache-v1'
        })
    })
});

Über die Methode workbox.routing.Router.registerRoute(route) und workbox.routing.Router.registerRoutes({routes: []}) können die einzelnen Routen hinzugefügt werden.


// Definition eines Wrappers für den Cach-Handler
cacheExpirationPlugin = new workbox.cacheExpiration.CacheExpirationPlugin({
    maxEntries: 500,
    maxAgeSeconds: 7*24*60*60  // Eine Woche
});

// 0, 200 und 404 cachen
cacheResponsePlugin = new workbox.cacheableResponse.CacheableResponsePlugin({
    statuses: [0, 200, 404]
});

cacheName = 'my-cache-v1';
plugins = [cacheExpirationPlugin, cacheResponsePlugin];

requestWrapper = new workbox.runtimeCaching.RequestWrapper({cacheName, plugins});

// Routen hinzufügen
router.registerRoutes({routes: [

    // Route für unsere API
    new workbox.routing.Route({
        match: ({url, event}) => {
            return url.pathname.startsWith('/myapi');
        },

        // Resource nur auf dem Server anfragen und nicht
        // im Cache suchen.
        handler: new workbox.runtimeCaching.NetworkOnly({
            requestWrapper: new workbox.runtimeCaching.RequestWrapper({
                cacheName: 'my-cache-v1'
            })
        }),
        method: method
    });

    // Eine Route für alle statischen Elemente
    new workbox.routing.Route({
        match: ({url, event}) => {

            var result = new RegExp('\.(bmp|woff|swf|ico|tif|ttf|css|js|png|jpg|jpeg|svg|gif|html|woff2|json|htm)$', 'i').exec(url.pathname);

            // Return null immediately if this route doesn't match.
            if(!result) {
                return false;
            }

            return true;
        },
        // Definition requestWrapper siehe weiter oben
        handler: new workbox.runtimeCaching.CacheFirst({requestWrapper}),
        method: 'GET'
    });

]});

Alle Anfragen werden jetzt je nach Definition entweder aus dem Cache oder vom Server geholt.

Kommunikation zwischen Webseite und Serviceworker

Im einfachsten Fall muss im Serviceworker ein einfacher Eventhandler eingerichtet werden.

self.addEventListener('message', function(event){
    console.log("Received Event", event);
});

Im Client kann dann eine einfache Nachricht an den Serviceworker gesendet werden.

navigator.serviceWorker.controller.postMessage("Client 1 sagt hallo!");

Damit man eine Antwort vom Serviceworker empfangen kann, ist etwas mehr Arbeit notwendig. Hierzu muss man einen MessageChannel erstellen und postMessage als Parameter übergeben.

function sendMyMessage(msg){
    return new Promise(function(resolve, reject){
        var msgChan = new MessageChannel();

        // Handler for recieving message reply from service worker
        msgChan.port1.onmessage = function(event){
            if(event.data.error){
                reject(event.data.error);
            }else{
                resolve(event.data);
            }
        };

        navigator.serviceWorker.controller.postMessage("Client 1 sagt '"+msg+"'", [msgChan.port2]);
    });
}

Im Serviceworker kann nun hierauf reagiert und über den ersten Port geantwortet werden.

self.addEventListener('message', function(event){
    console.log("SW Received Message: " + event.data);
    // Rückantwort an den Client
    event.ports[0].postMessage("SW Says 'Hello back!'");
});

Der Aufruf von sendMyMessage gibt ein Promise-Objekt zurück, über das wir die Antwort des Serviceworkers erhalten.

sendMyMessage('Hallo World!')
   .then(function(responseMessage){
       console.log(responseMessage);
       return responseMessage;
});

Jetzt wollen wir eine Nachricht vom Serviceworker an einen Client senden. Hierzu erstellen wir zuerst im Client einen EventListener, der auf Nachrichten hört.

navigator.serviceWorker.addEventListener('message', function(event){
    console.log("Event in Client empfangen", event);
    // Rückantwort
    event.ports[0].postMessage("Client sagt 'Hello back!'");
});

Nun definieren wir im Serviceworker eine Funktion, die über einen MessageChannel Nachrichten austauscht.

function sendMyMessageToClient(client, msg){
    return new Promise(function(resolve, reject){
        var msgChan = new MessageChannel();

        msg_chan.port1.onmessage = function(event){
            if(event.data.error){
                reject(event.data.error);
            }else{
                resolve(event.data);
            }
        };

        client.postMessage("Serviceworker sagt: '"+msg+"'", [msgChan.port2]);
    });
}

Die Clients können im Serviceworker über self.clients.matchAll() abgerufen werden.

self.clients.matchAll().then(clients => {
    clients.forEach(client => {
        sendMyMessageToClient(client, msg).then(m => console.log("Antwort vom Client: "+m));
    })
})

Der Host fängt alle Nachrichten des Serviceworkser ab und versendet den Event per serviceworker.HOST weiter.