Service Worker: Offline stránky a push notifikace

Pokud se vám zatím úspěšně dařilo vyhýbat se implementaci Service Workeru na svých stránkách, ale nyní potřebujete doplnit nějakou funkcionalitu, kterou nabízí, zde je přehledný popis pro začátečníky.

Co to je?

Service Worker (do češtiny by se dalo přeložit jako Pracovní služba, ale přesnější je Služba na pozadí) umožňuje stránce spouštět kód v pozadí a provádět akce, i když není stránka připojená k serveru (např. je uživatel bez internetu) nebo ani nemá otevřené okno v prohlížeči.

Service Worker (dále jen služba) se z vaší stránky nainstaluje do prohlížeče (podobně jako třeba rozšíření nebo plug-in) a prohlížeč si zjistí, na které události chce služba reagovat a pokud k takové události dojde, spustí příslušný kód služby.

Service Worker plně funguje pouze v Edge, Firefox a Chrome (a ostatních Chromium prohlížečích). Apple sice v Safari (WebKit) implementuje část specifikace Service Workeru (např. fetch), ale pro Push zprávy používá vlastní placenou službu Safari Push Notifications (MacOS; musíte si koupit licenci Apple Developer) a na iOS musíte mít vlastní aplikaci.

Co Service Worker dokáže?

Zobrazení v offline režimu

Služba může reagovat na událost fetch, která se vyvolá kdykoliv vaše stránka požádá server o nějaký soubor (HTML, CSS, obrázek, atd.). Služba pak může soubor stáhnout sama a uložit si ho do cache. Pokud pak uživatel přejde do offline režimu a bude chtít znovu navštívit danou stránku, služba může daný soubor vrátit z cache a fungovat tedy i bez internetu.

Samozřejmě v offline režimu nemusíte zobrazovat přesně to samé jako při online prohlížení. Ve většině případů bude uživateli stačit nějaká zjednodušená verze stránky, která mu umožní provést ty nejdůležitější úkony. Např. offline verze Facebooku může uživateli dovolit napsat nový status a připravit fotky k nahrání, ale nebude mu zobrazovat příspěvky ostatních uživatelů.

Stahování a odesílání dat na pozadí

Sledováním událostí sync a online dokáže služba poznat, kdy uživatel přešel do online režimu a může například odeslat data, která uživatel uložil, když by offline, nebo může naopak stáhnout ze serveru aktualizace (např. nové články nebo zprávy z chatu) a uložit je do cache (odkud se pak mohou zobrazit v offline režimu).

Sledování PUSH notifikací

Služba může uživatele požádat o povolení k zasílání oznámení. Pokud uživatel oznámení povolí, může pak váš server odesílat zprávy přímo do počítače nebo zařízení daného uživatele bez toho, aby měl uživatel otevřenu vaši stránku nebo si musel stahovat speciální aplikaci.

V případě, že je uživatel zrovna offline, notifikace se uloží na serveru (výrobce jeho prohlížeče, např. Microsoft, Google, Mozzila, apod.) a zobrazí se hned, jak se uživatel připojí k internetu.

Služba může dále reagovat na to, když uživatel klikne na zobrazené upozornění a provést nějakou akci. Zpravidla otevře prohlížeč s příslušnou stránkou, ale také může jen na pozadí odeslat data na server nebo naopak stáhnout aktualizace ze serveru.

Stejně tak dokáže poznat, když uživatel upozornění zruší (zavře).

A nakonec služba může na základě dalších dat notifikaci upravit a přidat do ní tlačítka s různými akcemi. Například pokud zobrazíte notifikaci na zprávu od uživatele, můžete přidat akce „Odpovědět“, „Zobrazil profil“ a „Připomenou později“.

Odesílání zpráv do stránky

Nepleťte si to s upozorněními (notification) nebo chat zprávami (messenger).

Služba může odeslat zprávu, na kterou pak může reagovat propojená stránka, pokud je zrovna spuštěna. Například pokud služba na pozadí stáhne nové články nebo chat zprávy a uloží je do cache, může poslat zprávu „chat-updated“ a stránka pak může zobrazit uživateli upozornění nebo zprávy rovnou z cache načíst.

Lazy-loading (zpožděné zobrazení)

Službu lze kombinací výše uvedených funkcí použít pro lazy-loading (například obrázků).

Služba zareaguje na požadavek na obrázek tím, že z cache vrátí nějaký výchozí obrázek nebo načítací animaci, následně spustí timer a po nějaké době obrázek stáhne ze serveru a uloží do cache. Pak stránce pošle zprávu s tím, že obrázky jsou již dostupné a stránka je buď vytáhne z cache nebo o ně znovu požádá server (což služba odchytí a sama je vrátí z cache).

Výhoda je v tom, že lazy-loading se provede jen poprvé a při dalším zobrazení se již budou obrázky zobrazovat ihned z cache.

Co Service Worker nedokáže?

Služba neběží nepřetržitě, takže nedokáže například sledovat uživatele (vyjma dále uvedených případů jako je přechod do offline režimu). Navíc běží ve vlastním javaskriptovém prostředí (ServiceWorkerGlobalScope), takže nemá přístup k ostatním službám zařízení (např. nemůže sledovat hovory či zprávy)
stejně jako plnohodnotná aplikace .

Služba sice může sama sebe udržet v běhu po určitou dobu (řádově sekundy nebo minuty), ale nemůže běžet nepřetržitě několik hodin nebo dní – a to ani nastavením intervalu nebo timeoutu. Každý prohlížeč má nějaké maximum, po jakou dobu může služba běžet a na jak dlouho může nastavit timeout, a pokud služba toto maximum překročí, prohlížeč ji prostě ukončí (resp. nespustí časovač, pokud je příliš dlouhý).

Služba také nemůže nekonečně ukládat data do cache. Každý prohlížeč má nějaké maximum (5 až 50MB), po jejichž překročení buď zakáže zápis (Chrome a Safari) nebo upozorní uživatele (Edge a Firefox), že stránka využívá příliš místa na disku a požádá o povolení dalšího místa nebo smazání dat.

Služba běží v pozadí odděleně od vlastní stránky, takže i když je stránka otevřená, služba nedokáže (přímo) měnit její obsah (DOM), vyvolávat události (např. klik na tlačítko) ani číst a měnit proměnné vytvořené v globálním scope (window). Služba a stránka ale spolu mohou komunikovat pomocí interních zpráv.

Navíc služba běží v rámci jednoho prohlížeče, takže pokud uživatel daný prohlížeč odinstaluje nebo převede do režimu spánku, služba přestane fungovat. Pokud uživatel prohlížeč resetuje nebo přeinstaluje, může být potřeba službu znovu nainstalovat a nastavit.

Importy

Service worker běží jako běžný skript, takže nemůže načítat funkce, proměnné a třídy pomocí klíčového slova import. Není tedy možné sdílet části kódu mezi službou a moduly.

Co ale může udělat, je načíst a spustit kód z externího souboru pomocí self.importScripts('soubor.js'). Pokud takový soubor definuje proměnné nebo funkce v globálním scope, budou vytvořeny v prostředí služby a ta je pak může používat.

Požadavky a testování

Aby mohl Service Worker fungovat, musí být vaše stránka bezpečná. To v praxi znamená, že musí být stažena přes HTTPS a mít platný certifikát nebo pro testování musí běžet na adrese http://localhost (s libovolným portem). Ve Firefox Debugger je navíc checkbox „Povolit Service Worker přes HTTP“.

Pokud pro vývoj používáte lokální server s vlastní adresou (např. http://server.lc), nebude možné službu nainstalovat a spustit (a to ani když server.lc odkazuje na 127.0.0.1).

Samozřejmě potřebujete prohlížeč se zapnutým javaskritem a podporou Service Worker a dalších služeb, které chcete využívat.

Pro použití Push notifikací musí váš server (resp. localhost) být schopen odesílat HTTPS požadavky na servery třetích stran (musí mít tedy správně nastavené např. CURL).

Registrace Service Workeru

Teorii máme za sebou a už víme, k čemu je Service Worker. Nyní se podíváme, jak ho přidat do vaší stránky.

Příklady od Mozzily

Skupina Mozilla (výrobce Firefoxu) připravila kuchařku serviceworke.rs, na které najdete spoustu příkladů, jak službu používat.

Web Worker

Specifikace a funkčnost Service Workeru vychází z existující funkčnosti Web Workeru, který je dostupný již od IE10 a umožňuje část Javascriptu spustit v novém vlákně (Thread) tak, že kód načtete ze samostatného souboru.

Pokud jste na Worker ještě nenarazili, doporučuji se seznámit s jeho základní myšlenkou. Bude vám pak jasnější, proč Service Worker funguje tak, jak je níže popsáno a proč používá některé zdánlivě nelogické postupy.

Formát dat

Kdykoliv v rámci Service Workeru posíláte nějaká data, vždy jde o volný formát, takže co a jak uložíte záleží na vás. Můžete poslat buď jednoduchý řetězec, číslo a nebo objekt. Pokud data posíláte z PHP, Javy, apod. je potřeba objekt přeložit do JSON.

Doporučuji ještě před implementací promyslet struktura JSON dat, pomocí které budete data posílat, aby byla univerzální a šla použít v různých situacích (příjem push notifikací, stažení dat ze serveru, posílání zpráv mezi stránkou a službou, atd.).

Podpora

Jako první krok musíme ověřit, že daný prohlížeč podporuje služby, které chcete použít.

Pro začátek zjistíme, jestli je podporovaný Service Worker jako takový:

if ('serviceWorker' in navigator) {
    console.log('Služba je podporována');
}

Pokud chcete používat službu pro sledování offline režimu a ukládání souborů do cache, měli by to podporovat všechny prohlížeče s podporou Service Workeru.

Offline režim můžete sledovat jednoduše registrací události fetch uvnitř služby. Pokud prohlížeč offline režim nepodporuje, prostě tuhle událost nevyvolá. Registraci události si ukážeme níže.

Podporu cache můžete ověřit přes třídu Cache resp. její instanci caches:

if ('Cache' in window OR 'caches' in window) {
    console.log('Cache je podporována');
}

Pro použití služby PUSH notifikací musíte ověřit, že kromě Service Workeru podporuje prohlížeč i Push Manager, přes který můžete uživatele požádat o povolení. Také byste měli zkontrolovat, zda již v minulosti uživatel upozornění nezakázal, abyste zbytečně nezobrazovali nefunkční prvky.

if ('serviceWorker' in navigator) {
     console.log('Služba je podporována');
    if ('PushManager' in window) {
        console.log('Push notifikace jsou podporovány');
        if ('denied' === Notification.permission) {
            console.log('Uživatel notifikace odmítl');
        }
    }
 }

Hodnota Notification.permission bude denied, pokud uživatel výslovně odmítl notifikace nebo není možno notifikace zobrazit (např. web není bezpečný nebo jsou notifikace globálně zakázány zabezpečením zařízení nebo prohlížeče). Hodnota bude granted, pokud již uživatel souhlasil se zasíláním notifikací z vaší stránky (tzn. již jste se ho dříve zeptali). Hodnota default znamená, že můžete požádat uživatele o povolení a on ho buď schválí nebo zamítne.

Pro ověření, zda je možno odesílat zprávy ze služby do stránky, lze zkontrolovat vlastnost onmessage:

if ('onmessage' in navigator.serviceWorker) {
    console.log('Zprávy jsou podporovány');
}

Pro synchronizaci na pozadí potřebujete použít Sync Manager, který ale není zatím standardizován a tak nemusí být přítomen v prohlížečích, které podporují ostatní části Service Workeru. Pro kontrolu podpory použijte (po té, co službu nainstalujete):

if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready.then(reg => {
        if ('sync' in reg) {
            console.log('Synchronizace je podporována');
        }
    }
}

Služba je modul s vlastním scope

Uvnitř souboru Service Workeru můžete používat funkce ES6, protože Service Worker je jejich součástí. Také můžete používat klíčové slovo self, které funguje stejně jako window a odkazuje na globální namespace dané služby. Také můžete vytvářet globální proměnné přes var, let nebo const a tyto budou dostupné pouze dané službě a nebudou kolidovat s proměnnými ostatních stránek.

Instalace služby

Pro použití služby musíte vytvořit samostatný soubor, který následně zaregistrujete jako službu:

if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('service.js');
}

Důležité je si uvědomit, že služba se může propojit pouze se stránkami ve své složce. Službu tedy nemůžete umístit do podsložky (např. /js/offline.js), protože by se pak nemohla propojit s hlavní stránkou (např. /index.html) a sledovat její requesty a zprávy.

Samozřejmě, pokud chcete mít soubory na serveru organizované, můžete vytvořit přesměrování (např. REWRITE RULE), které /offline.js přesměruje na /js/service-worker/main.js.

Alternativní možnost je nakonfigurovat web-server tak, aby společně se souborem služby poslal HTTP hlavičku Service-Worker-Allowed, která bude obsahovat adresu, kterou chcete službě povolit:

#příklad pro Apache server
#soubor .htaccess ve složce /js/
Header add Service-Worker-Allowed "/"
//příklad pro PHP
<?php //skript pro stažení service.js

header('Service-Worker-Allowed=/');
readfile(WWW_DIR . '/js/service.js');

Pokud naopak chcete službu použít pouze pro sledování určitých požadavků (např. v podsložce blog), můžete při registraci zadat parametr scope:

if ('serviceWorker' in navigator) {
    navigator.serviceWorker
        .register('service.js', 
        { scope: './blog/' });
}

V tomto případě pak služba bude schopna propojit se pouze se stránkami v podložce blog (např. /blog/article/123), ale už ne z jiných složek (např. /archive/article/123).

Jedna stránka může zaregistrovat více služeb, ale registrace druhé služby se stejným scope automaticky odregistruje tu předchozí. Více souběžně nainstalovaných služeb tedy můžete mít pouze v případě, že každá sleduje jiný scope (např. jedna sleduje blog na scope: /blog/ a druhá archiv z scope: /archive/) nebo jedna sleduje část scopu druhé (např. jedna sleduje vše v scope: /) a druhá třeba jen archive (scope: /archive/) – v tom případě pouze služba s více specifickým scope může sledovat fetch události z daného scope.

Důležité je uvědomit si, že v případě sledování fetch událostí rozhoduje scope stránky, která požadavek odeslala a nikoliv URL souboru nebo stránky, která se stahuje. Pokud například stránka /archive/article/123 stahuje soubor /article/img/123-a.jpg, služba bude schopna fetch odchytit, pokud má scope /archive/article/ ale nikoliv pokud má scope /article/! Pokud tedy chcete sledovat určitý typ souborů (např. obrázky, CSS styly, JS skripty, apod.) nemůžete scope nastavit na složku, kde máte tyto soubory uloženy (např. { scope: /img/ }).

Aktualizace služby

Soubor služby je potřeba udržovat aktuální, aby bylo možno přidávat nové funkce a opravovat chyby.

K tomu prohlížeč nabízí několik možností:

  1. když prohlížeč přijme push nebo sync událost a aktuálně nainstalovaná služba je starší než 24 hodin, pokusí se ji automaticky aktualizovat ze serveru (z adresy zadané v .register() metodě);
  2. služba sama (nebo propojená stránka) může zavolat metodu self.update();
  3. k aktualizaci také dojde, pokud zavoláte .register() s jinou URL, ale stejným scope parametrem. Pozor ale na to, že pokud stará služba vrací HTML stránku z cache, nemusí se nová služba z nové URL nainstalovat, protože v cache je stále uložena stará URL adresa. V tom případě by služba sama měla nějak kontrolovat verzi souboru a případně vymazat staré HTML z cache.

Kdykoliv se služba aktualizuje, ale tento proces selže, ať už proto že URL neexistuje (404), nejde zkompilovat (chybná syntaxe) nebo během instalace (tzn. v handleru události install) služba vyhodí výjimku, tak zůstane aktivní stará služba.

Stará služba také zůstává aktivní do doby, než uživatel zavře všechna okna stránek obsahujících volání .register() s danou URL. Nová služba může toto obejít zavoláním metody self.skipWaiting().

Stará služba může reagovat na událost updatefound a získat přístup k nové verzi služby přes self.registration.installing. U ní si pak třeba může zaregistrovat potřebné události (např. install, viz dále) nebo jí třeba přes postMessage poslat data.

Pokud naopak nová verze služby potřebuje poslat zprávu svému předchůdci, aby například smazal svojí cache, pokud obsahuje starší verzi, a uvolnil místo svému nástupci, můžete to provést přes self.registration.active.postMessage():

let version = 1; //aktualizujte když změníte službu

self.addEventListener('install', e => {
    if (self.registration.active) {
        e.waitUntil(self.registration
            .active.postMessage({
                 update: version
             })
        );
    }
});

self.addEventListener('message', e => {
    if (e.message.hasOwnProperty('update')
        && version < e.message.update
    ) {
        e.waitUntil(caches.delete('workerCache'));
    }
});

V příkladu vidíte službu, která může aktualizovat sama sebe a poslat svému staršímu já zprávu, aby smazala existující cache: když služba instaluje novější verzi, ověří, zda existuje starší verze, a pokud ano, pošle jí zprávu o aktualizaci na novější verzi cache. Starší verze zjistí, že dostala zprávu s klíčem update a pokud má nová služba vyšší verzi cache, (stará služba) smaže svoji cache. Díky použití e.waitUntil() uvnitř message handleru nedojde k dokončení instalace nové služby, dokud ta stará nevymaže celou cache.

Reakce na instalaci

Instalátor služby vrací Promise, takže můžete spustit další kód v okamžiku, kdy se služba nainstaluje a prvně spustí. To odpovídá spuštění konstruktoru u tříd nebo události onCreate:

if ('serviceWorker' in navigator) {
    navigator.serviceWorker
        .register('service.js')
        .then(function(reg) {
            console.log('Služba je nainstalována');
        })
    ;
}

V souboru Service Workeru pak můžete reagovat na událost install:

self.addEventListener('install', e => {
    //další práce se službou
});

První parametr získaný v handleru register().then() je instance třídy ServiceWorkerRegistration (zkracuje se reg) a obsahuje metody pro práci se službou (např. push notifikace, sync požadavky, atd.). Pro získání informací o aktuálně spuštěné službě použijte reg.active, pro ověření existence novější verze reg.installing (je NULL pokud neexistuje).

Služba sama může používat proměnnou self (instance třídy ServiceWorkerGlobalScope; obdoba window ve stránce) – všechna volání jako self.fetch() nebo self.pushManager je možno zkrátit vynecháním slova self (stejně jako můžete ze stránky volat např. setTimeout() nebo location z objektu window).

Registrační objekt obsahuje mimo jiné vlastnosti location (URL, ze které se služba nainstalovala) a scope (složka, ze které může služba sledovat fetch požadavky)

Poznámka: v ES6 je doporučeno používat metodu self.addEventListener(event, () => {}), ale i v Service Workeru stále můžete používat starý způsob s vlastnostmi onEvent = function() {} (např. self.onInstall, self.onActivate, atd.).

To, že se služba nainstaluje, ještě neznamená, že začne plně fungovat. Po instalaci teprve prohlížeč analyzuje registrované události a teprve poté je služba schopna reagovat na události jako fetch nebo sync. Ve stejný okamžik se také vyvolá událost activate:

self.addEventListener('activate', e => 
    console.log('Služba je připravena pro fetch')
);

Událost activate je možno z venku sledovat pomocí Promise uloženým do vlastnosti ready:

if ('serviceWorker' in navigator) {
     navigator.serviceWorker
         .register('service.js');
     navigator.serviceWorker.ready
     .then(function(reg) {
         console.log('Služba je připravena na fetch');
     });
 }

A dále pak to, že je služba aktivována, neznamená, že začne okamžitě hlídat requesty ze stránky, která ji aktivovala. Kvůli tomu, že instalace služby nějakou dobu trvá prohlížeče dělají to, že stránka, který službu nainstalovala ji nemůže používat. K prvnímu použití služby tak dojde po dalším načtení stránky (reload) nebo po přejití na jinou stránku (navigace).

Obejít se to dá tak, že si služba vynutí hlídání requestů ze všech právě otevřených oken prohlížeče (samozřejmě těch se stránkami z vaší domény. Provádí se to pomocí self.clients.claim() obaleným voláním waitUntil(), jelikož je asynchronní:

self.addEventListener('activate', e 
    => e.waitUntil(clients.claim())
);

K aktivaci služby nedojde, pokud již v prohlížeči existuje (jiná nebo starší) služba hlídající stejný scope. V takovém případě dojde k aktivaci nové služby až po té, co se ukončí všechny stránky využívající starou službu.

Stav instalace a aktivace služby je možno sledovat z proměnné self.registration.active.state, která obsahuje textové hodnoty installing (během volání události install), installed (po skončení install ale před vyvoláním activate), activating (během zpracování activate) a activated (po dokončení aktivace). Stav redundant znamená, že služba byla nahrazena novější verzí a již nebude přijímat žádné události.

Hlídání Offline souborů

Pro hlídání stahování souborů a jejich odeslání při Offline režimu je potřeba zaregistrovat si událost fetch v souboru služby.

self.addEventListener('fetch', 
    e => e.respondWith(
        fetch(e.request)
        .then(response => caches.open('offline')
             then(cache => cache.put(response))
        )
        .catch(f => caches.open('offline')
            .then(cache => cache
                .match(e.request)
                .then(file => file)
            )
        )
    )
);

Kód používá řadu zápisů, které je asi potřeba vysvětlit.

Zaprvé zápis e => funkce(e) je ES6 obdoba function(e) { return funkce(e); }. Zadruhé zápis funkce().then().catch() znamená, že funkce vrací Promise a my reagujeme na úspěch (resolve) nebo neúspěch (reject). Všimněte si, že při používání then/catch je potřeba uzavírat volání metod pomocí kulatých závorek podobně, jako byste uzavírali bloky (callback) složenými závorkami {}.

Volání e.resolveWith() je specifické pro událost fetch a říká, že náš kód hodlá na request odpovědět, ale potřebuje k tomu nějaký čas. Prohlížeč pak automaticky pozastaví request do doby, než funkce uvnitř responseWith() vrátí odpověď (podporuje i Promise a pak čeká na Resolve nebo Reject).

Jak je uvedeno výše, pouze jedna služba může sledovat requesty z určitého scope, ale v rámci jedné služby můžete mít zaregistrováno více funkcí sledujících událost fetch. V tomto případě platí, že první funkce, která zavolá e.resolveWith() vyhrává a bude se čekat na její odpověď. Ostatní funkce se při zavolání e.resolveWith() automaticky ukončí (tím, že vyhodí výjimku). Jedna funkce tak může řešit například načítání HTML stránek a offline režim, druhá lazy-loading obrázků, atd.

Funkce fetch() je vestavěná funkce pro volání AJAX requestu, která vrací Promise (obdoba $.ajax()). My pak následně reagujeme na Resolve (.then()), kdy vrátíme odpověď ze serveru a zároveň ji uložíme do cache, a také Reject (.catch()), což značí offline režim a v tom případě vrátíme soubor z cache.

Cache funguje tak, že nad proměnnou caches (což je automaticky vytvořená globální instance třídy Cache) zavoláme metodu open() se jménem cache, kterou chceme otevřít (nebo vytvořit, pokud ještě neexistuje). Metoda vrací Promise a v její Resolve získáme danou cache, do které můžeme soubor vložit (cache.put(response)) nebo ho přečíst (cache.match(request)). Metody cache pracují přímo s requesty události fetch a odpovědí funkce fetch(), takže není potřeba je nijak překládat. Metoda cache.match() znovu vrací Promise a proto je potřeba z její .then() vrátit nalezený soubor.

Metoda cache.match() má jednu (trochu nelogickou) specifičnost, takže vždy uspěje (tedy volá .then()), ale pokud soubor v cache nenajde, vrátí undefined. Nicméně metoda .respondWith() přeloží hodnotu undefined správně na 404 Not Found, takže není potřeba to nijak ošetřovat.

Toto je ale pouze základní kód, který můžete dále rozšířit podle potřeby.

Načtení souborů pro offline režim

Pokud má vaše služba poskytovat soubory v offline režimu, může je do cache ukládat v okamžiku jejich načtení (viz výše). Tímto způsobem ale získá pouze soubory, které již byly staženy.

Pokud chcete v offline režimu nabízet i soubory, které nemusí uživatel nutně stáhnout, nebo naopak chcete v offline režimu zobrazit jinou stránku, můžete cache říct, které soubory musí v online režimu stáhnout, aby byly k dispozici:

self.addEventListener('install', service => {
    service.waitUntil(caches.open('offline')
        .then(cache => cache.addAll([
            'offline.html',
            'offline.jpg',
            'offline.css'
        ])
    )
});

Tento kód služby nejprve počká, než se služba nainstaluje, a pak teprve pokračuje. Tím si zajistí, že bude mít k dispozici přístup do cache. Zároveň, jelikož službu je možno instalovat pouze v online režimu, si zajistí, že bude moci soubory stáhnout z internetu.

Voláním metody service.waitUntil() zajistíme, že prohlížeč udrží službu v běhu, dokud neprovedeme potřebné akce (zde načtení souborů do cache). V opačném případě by totiž prohlížeč službu, okamžitě po skončení install, uspal a ona by nemohla reagovat na asynchronní otevření cache. Služba může sebe sama uspat zavoláním metody self.skipWaiting().

Zavoláním metody waitUntil() uvnitř události install také pozdržíte nastavení stavu installed a vyvolání události activate do doby, kdy se provede vše potřebné (zde tedy stažení souborů do cache).

Zavoláním caches.open() otevřeme nebo vytvoříme cache a pomocí její metody cache.addAll() jí předáme jména souborů, které chceme stáhnout a uložit. Samotné načtení si již cache řídí sama a tak není potřeba nic dalšího programovat. Jen je potřeba myslet na to, že pro offline režim kromě HTML stránky můžeme potřebovat i další soubory jako jsou obrázky, styly a skripty.

Pokud máme trochu složitější stránku a seznam souborů pro offline režim mám poskytuje server přes nějaké API, můžeme si ho stáhnout a pak předat cache:

self.addEventListener('install', service => {
    let request = new Request('/api/list/offline');
    service.waitUntil(fetch(request)
        .then(response => response.json())
        .then(json => caches.open('offline'))
        .then(cache => cache.addAll(json.files))
    );
});

Služba v příkladu si při instalaci vyžádá vytvoření requestu na adresu API pro vypsání offline souborů (adresa záleží na vašem serveru). Následně požádá prohlížeč o jeho stažení (fetch(request)) a zároveň o počkání na odpověď (service.waitUntil()). Po získání odpovědi a převedení na JSON (response.json(), které je též asynchronní,) přečte pole files, které předá do cache pro jejich následné stažení.

Poznámka: zavolání service.waitUntil(something) je prakticky stejné jako použití await something, jen je to v kontextu služby čitelnější a jasnější, že čekání provádí služba a ne naše funkce. Pokud .waitUntil() nepoužijete, může prohlížeč ukončit běžící spojení stejně, jako to udělá třeba s AJAX requesty po zavření okna stránky.

V offline režimu pak počkáme, až si prohlížeč požádá o nějaký HTML soubor (tedy stahuje celou stránku) a pokud to selže (tedy je offline), vrátíme mu naši offline stránku:

var offline = false;

self.addEventListener('fetch', e => {
    let r = e.request;
    //při stažení HTML stránky:
    if ('navigate' === r.mode) {
        //pokud nejde stáhnout z internetu
        e.respondWith(fetch(r).catch(f => {
            offline = true;
            //získej offline stránku z cache
            caches.open('offline')
            .then(cache => cache.match('offline.html'))
            .then(file => file)
        )
    }
    else if (offline || !navigator.onLine) {
        e.respondWith(caches.open('offline')
            .then(cache => cache.match(e.request))
            .then(file => file)
        )
    }
});

Služba sleduje stahování HTML stránek a pokud některá selže, vrátí místo ní stránku offline.html. Zároveň se přepne do offline režimu, takže všechny další soubory (obrázky, styly, atd.) bude také vracet z cache.

Tento přístup bude ale fungovat jen v případě, že vaše stránky posílají plnohodnotné HTML dotazy. Pokud web částečně nebo plně stahuje části stránky přes AJAX, bude potřeba použít jiný přístup.

Aby služba poznala, kdy se uživatel připojí k internetu a už není potřeba vracet soubory u cache, může reagovat na událost online:

window.addEventListener('online', e => { offline = false });

Registrace PUSH notifikací

Po té, co ověříme, že jsou notifikace podporované (viz výše), můžete si zažádat o povolení.

Poznámka: Doporučuji uložit registraci notifikací do samostatného souboru, který načtete, jen když jsou Service Worker a Push Manager podporovány. Díky tomu budete moci používat ES6 pro jejich registraci. Pamatujte ale na to, že tento soubor se nebude chovat jako služba a tudíž bude mít přístup do globálního scope. Proto byste měli funkce a proměnné vytvářet v uzávěře. Alternativně můžete soubor načíst jako Modul.

Ověření starší registrace

Nejprve se Push Manageru zeptáme, zda již neexistuje registrace z dřívějška:

navigator.serviceWorker.ready
    .then(reg => reg.pushManager.getSubscription())
    .then(subscription => {
        if (subscription) {
           return updateSubscription(subscription);
        } else {
           return createSubscription(reg)
        }
    )
    .catch(f => disableSubscription())
}

Pokud funkce getSubscription() vrátí objekt, znamená to, že notifikace již byly zaregistrovány a není to tedy potřeba dělat znovu (nicméně může být potřeba je aktualizovat, viz dále).

Pokud getSubscription() vrátí NULL, znamená to, že registrace neexistuje a je potřeba o ni požádat. Pokud funkce getSubscription() selže, znamená to, že o notifikace požádat nemůžeme.

Vytvoření nové registrace notifikací

Nyní se podíváme na funkci createSubscription(), která požádá o její povolení:

const createSubscription = reg => {
    reg.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: getVapid()
    })
    .then(details => addSubscription(details))
    .catch(f => disableSubscription());
}

Funkce createSubscription() řekne Push Manageru, aby požádal uživatele o povolení zasílat oznámení. Pokud to uživatel schválí, získáme v .then() informace o tom, jak push notifikace posílat – což si budeme muset odeslat na server a uložit do databáze (viz dále).

Pokud uživatel notifikace nepovolí nebo dojde k něčemu jinému (např. nejdou zapnout kvůli zabezpečení prohlížeče), vyvolá se Reject (.catch()) a my na tu musíme příslušně zareagovat.

Parametr userVisibleOnly, který posíláme do subscribe() určuje, zda žádáme uživatele (hodnota true) o to, aby povolil zobrazovaní notifikací (např. informace o nových zprávách, o slevách, apod.). V současné době je toto jediná možnost, jak o notifikace požádat. Teoreticky by uvedením false bylo možnost požádat pouze o registraci notifikací, které nebudou nic zobrazovat a pouze budou vyvolávat události služby. Tím by například mohl server odeslat službě zprávu o tom, že by měla do cache uložit nový obsah. V tomto případě by uživatel nemusel nic povolovat a prohlížeč by jen rozhodl, zda bezpečnostní nastavení povolují notifikace na pozadí. To ale zatím není možné a i když technicky to tak dělat lze, je vždy potřeba uživatele požádat o to, aby notifikace povolil!

Druhý parametr je vyžadovaný, pokud budete chtít notifikace posílat přímo z vašeho serveru do zařízení uživatele. Pokud budete pouze zobrazovat notifikace ze služby v reakci např. na AJAX volání, server key není potřeba. Jako parametr musíte uvést veřejný klíč, který je potřeba k tomu, aby vaši registraci nemohl někdo zneužít a posílat uživateli nechtěné zprávy. Klíč si můžete vygenerovat pomocí NPM balíku web-push. Nejprve si nainstalujte NPM (pokud dosud nemáte) a následně v konzoly spusťte:

> npm install -g web-push
> web-push generate-vapid-keys

Druhý příkaz vypíše veřejný a soukromý klíč, kde veřejný klíč můžete přímo uložit do JS souboru pro registraci nebo si ho stahovat přes AJAX, zatímco soukromý klíč musíte uložit do souboru přístupného pouze na serveru (např. config.php) a používat ho pouze pro odesílání PUSH zpráv.

//jednoduchá implementace
const getVapid = () => urlB64ToUint8Array('váš veřejný klíč');

//Stažení přes API
const getVapid = () {
    let vapid = await fetch(new Request('/api/get/vapid'));
    return urlBase64ToUint8Array(vapid.json().key);
} //funkce je záměrně synchronní, aby neměnila způsob volání

Funkce urlBase64ToUint8Array() není v prohlížečích podporována, protože pouze obchází chyby v implementaci použití Base64 klíče v Chrome (webkit) a v budoucnu by se tedy neměla používat. Pro teď si ji musíte stáhnout z GitHubu Mozilly.

V objektu vráceném funkcí subscribe() získáme informace o registraci notifikací. Vlastnost endpoint obsahuje URL adresu serveru, na který musíme posílat data, aby se notifikace dostala k uživateli. Ta se liší podle toho, ve kterém prohlížeči se uživatel registroval. V Chrome to tedy bude server Google, ve Firefoxu server Mozzila, atd. Také obsahuje unikátní klíč zařízení uživatele, aby bylo jasné, kam se má upozornění poslat. Je tedy potřeba ji uložit celou přesně tak, jak ji prohlížeč vrátí!

Vlastnost keys pak obsahuje další hodnoty, které může prohlížeč vyžadovat k tomu, abyste mohli poslat notifikaci na jeho server. Může jít o hodnoty auth, authToken nebo p256dh (ověření vygenerované na základě vaše klíče a klíče prohlížeče), contentEncoding (určuje jak se mají push zprávy ukládat na server, aby jim prohlížeč rozuměl). Na serveru není potřeba rozumět tomu, co je ve vlastnosti keys uloženo. Stačí je v JSON uložit do DB a před odesláním zprávy převést na objekt a odeslat do knihovny.

Registrace také může obsahovat klíč expiration, což je datum, do kterého je potřeba o registraci pořádat znovu a tím jí prodloužit. Zatím ji ale implementuje jen Edge, který ale v další verzi přejde na Chromium a ten ji nepoužívá.

Při ukládání endpoint a keys do databáze myslete na to, že jeden uživatel si můžete notifikace povolit na více zařízeních, takže vztah uživatel – notifikace by měl být 1:n. Při odesílání pak myslete na to, že pro každého uživatele může být potřeba odeslat několik zpráv na různé URL adresy.

Odeslání PUSH zprávy ze serveru

Odeslání zprávy není problematika Service Workeru, takže ji zmíním jen okrajově. Pokud používáte jako server Node.js, můžete použít výše uvedený balík web-push. Jen ho nainstalujte do projektu:

> npm install web-push --save

Pro PHP můžete použít knihovnu web-push-php:

> composer require minishlink/web-push

Pro C#, Python a Java najdete soubory na GitHubu push služby (https://github.com/web-push-libs). Ostatní servery mohou využít Node.js a volat funkci přes příkaz (více viz GitHub balíku web-push):

> web-push send-notification \
     --endpoint=<url> \
     --payload=<text zprávy>

Odeslání push zprávy je obdobné ve všech jazycích (knihovnách):

  1. Načtete knihovnu web-push
  2. Načtete hodnoty endpointa keys z databáze
  3. Připravíte payload, což je text zprávy (buď text nebo JSON)
  4. Připravíte si další nastavení (veřejný a privátní klíč, TTL jak dlouho je zpráva platná, urgency neboli důležitost zprávy, jestli se má poslat přes PROXY, atd.)
  5. Vše odešlete přes metodu sendNotification()

Hodnota TTL je počet sekund, jak dlouho může být zpráva uložena na serveru, pokud uživatel zrovna není online. Hodnota 0 znamená, že se zpráva ihned zahodí, pokud ji nelze doručit (např. pokud oznamuje živý přenos, který již později nebude k dispozici). Doporučená maximální hodnota je pod 2 miliony, což odpovídá 4 týdnům (resp. 1 měsíci).

Hodnota urgency určuje důležitost zprávy a říká, za jakých podmínek může zařízení zprávu stáhnout a zobrazit, aby neplýtvalo baterií. Parametr má vliv pouze u mobilních zařízení a na desktop počítači s LAN se vždy zobrazí všechny upozornění (ale jelikož nevíte, na jaké zařízení zprávu posíláte, je potřeba ji uvést vždy).

  • high znamená kritické upozornění, které je potřeba zobrazit vždy i za cenu vybití baterie (např. příchozí hovor, video-chat nebo živý přenos, oznámení o přírodní katastrofě, důležitá schůzka v kalendáři apod.).
  • normal je zpráva, která se musí stáhnout přes mobilní připojení, ale není potřeba ji zobrazit, pokud má zařízení nízký stav baterie (např. zpráva z chatu, připomínka, časově omezená akce v obchodě, apod.).
  • low označuje zprávu, která není důležitá a může se zobrazit jen pokud je zařízení připojeno přes WiFi (a stáhne se rychle) a nebo je připojeno k napájení (a nehrozí tedy vybití baterie). Tento typ by měli používat weby z kategorie hry, zpravodajské servery, sociální sítě, apod. které posílají aktualizace obsahu.
  • very-low zprávy se stáhnout pouze pokud je zařízení připojeno k WiFi a zároveň je připojeno k nabíječce. Tato úroveň by se měla používat pro reklamy a jiné propagační upozornění, u kterých nehrozí, že po pár hodinách či dnech ztratí platnost.

Samozřejmě záleží na vás, zda budete slevové kupóny posílat jako very-low nebo high, ale sami zvažte, zda stojí za riziko, že uživatelé notifikace odhlásí jen proto, že jim vaše reklamy vybíjejí baterii mobilu a ruší je třeba v kině.

Velikost zprávy je omezena. V současné době je maximum 4078 znaků/bytů (pro celou zprávu vč. veřejného klíče a dalších dat), ale z bezpečnostních důvodů může být zkrácena na 3052B; starší zařízení mohou mít omezení na 1kB, 512B nebo 240B. Proto je potřeba dávat pozor, co zprávou posíláte (viz dále).

Přijetí Push zprávy

Poté, co odešlete zprávu ze serveru, ji musí služba přijmou. To udělá tak, že si zaregistrujete událost push:

self.addEventListener('push', e => {
    //pokud server odesílá čistý text:
    let message = await e.data.text();
    /pokud server odesílá JSON data:
    let data = await e.data.json();

    e.waitUntil(self.registration.showNotification(
        data.title, {
            body: data.text,
            icon: data.icon,
            badge: data.badge_image
        }
    );
});

Všimněte si, že data získáte buď metodou text() nebo json() podobně jako u fetch() requestu. Pozor ale na to, že u Push jsou metody synchronní a vrací rovnou string nebo objekt (zatímto u Response vrací Promise)!

Důležité pro notifikaci je title (což může být titulek článku nebo jméno vašeho webu) a body, které obsahuje samotný text zprávy. Zobrazení icon a badge (a případně ještě image) závisí na prohlížeči a operačním systému – některé systémy zobrazí pouze ikonu a text (Windows 10), jiné naopak mohou zobrazit jen badge v liště (Android), apod.

Ve vlastnosti icon můžete poslat barevnou ikonu nebo malý obrázek (32×32 až ~256×256), který se zobrazí vedle textu upozornění. Měl by to být tedy obrázek, který se týká přímo textu (např. fotka uživatele u zprávy). Ikona by měla být čtvercová a notifikace ji zobrazí celou, jen ji může zmenšit nebo zvětšit podle potřeby. Ve vlastnosti image můžete poslat větší obrázek (třeba 1920×1080) a měl by se zobrazit pod textem. Nicméně podle velikosti obrazovky ho může notifikace různě oříznout, takže by to neměl být obrázek, který po oříznutí ztratí smysl (např. obrázek s textem). Vlastnost badge je pak určena pro malý dvoubarevný obrázek, který se zobrazí v liště (převážně mobilních zařízení) před tím, než uživatel rozbalí jejich seznam. V badge by tedy měla být ikona, která se týká vašeho webu (typicky favicon). U badge ikony počítejte s tím, že bude hodně malá a s málo barvami (obvykle jen černá na průhledném pozadí) a barva se může změnit (např. v nočním režimu se může barva změnit na bílou).

Pro zobrazení notifikace je potřeba zavolat self.registration.showNotification(), protože jinou zprávu (např. alert(), Notification, apod.) není ze služby možno vyvolat. Volání je ale stejné jako new Notification() z webu.

Také je potřeba zavolat waitUntil(), protože notifikace se nemusí zobrazit hned (např. musí počkat, než se stáhnou použité ikony). Také (na některých systémech) může notifikace čekat, než se zavře předchozí notifikace, pokud může zobrazit jen jednu.

Je vidět, že konečný vzhled a obsah zprávy určuje až služba v zařízení, takže server může klidně poslat jen ID zprávy (aby nepřekročil omezení délky) a obsah si může stáhnout až zařízení (během přijetí musí být zařízení online, takže stažení dalších dat je možné):

self.addEventListener('push', e => {
    let id = await e.data.text();
    let request = new Request('/api/get/push/'+id);
    let data = await fetch(request);

    data = await data.json();

    e.waitUntil(self.registration.showNotification(
        data.title, {
            body: data.text,
            icon: data.icon,
            badge: data.badge_image,
            data: {id}
        }
    );
});

V závislosti na typu zařízení a verzi prohlížeče může notifikace podporovat i další vlastnosti. Níže uvedené tak mohou a nemusejí fungovat.

Například do parametru vibrate můžete poslat délky (v milisekundách) jak dlouho má zařízení vibrovat a dělat mezery (vibrate: 500 bude vibrovat půl sekundy; vibrate: [100, 500, 100] udělá dva krátké záchvěvy s půl sekundovou pauzou mezi nimi). Každé zařízení má maximální dobu, po kterou může vibrovat, takže příliš dlouhé sekvence může oříznout! Pokud vibrate neuvedete, bude se vibrace řídit výchozím nastavením.

Parametr requireInteraction určí, že upozornění nelze automaticky zavřít a vždy je potřeba, aby uživatel klikl na nějakou akci nebo křížkem jej zavřel. Parametr sticky pak úplně zakáže zavření křížkem a vynutí použití kliku nebo akce.

Parametr lang určuje jazyk zprávy (např. pokud je to potřeba pro správné provedení následné akce) a dir může přepnout na arabský text (zprava doleva) nebo naopak (rtl a ltr).

Parametry timestamp a renotify: true (a případně tag) určují, že znovu zobrazujete starší upozornění (např. pokud uživatel klikl na Odložit). Pomocí silent: true můžete vypnout zvuk i vibrace; renotify: false pak vypíná zvuk a vibrace v případě, že je již zobrazeno starší upozornění (z vaší stránky). Parametr noscreen: true určuje, že notifikace není důležitá a nemá při přijetí zapínat obrazovku.

Do parametru data můžete poslat libovolné informace, které budete potřebovat pro ošetření události klik nebo akce.

Reakce na kliknutí

Pokud zobrazená zpráva vyžaduje akci (např. návštěvu stránky, odpověď, apod.), může služba reagovat na událost notificationclick:

const web_url = 'https://my.server.com/push/';
self.addEventListener('notificationclick', e => {
    e.notification.close();
    let url = web_url + e.notification.data.id;
    e.waitUntil(clients.openWindow(url));
});

Funkce musí nejprve zprávu zavřít, aby nepřekážela v oznamovací oblasti a následně musí otevřít nové okno prohlížeče s požadovanou stránkou.

Veškeré parametry, které jste poslali do showNotification() můžete následně přečíst přes event.notification.*, takže data, tag a další parametry můžete použít k tomu, abyste správně zareagovali.

Více akcí

Pokud vám jednoduché kliknutí na upozornění nestačí, můžete do notifikace přidat tlačítka (interně zvané actions). Například pokud zobrazujete upozornění na chat zprávu, můžete přidat Odpovědět a Ignorovat, pokud jde o upozornění na úkol, mohou být akce Dokončit a Odložit. Akce je doporučeno kombinovat s requireInteraction: true, aby se zpráva nezavřela dřív, než uživatel nějakou akci zvolí.

Tlačítka se přidávají do druhého parametru ve vlastnosti actions. Definují se jako pole objektů s vlastnostmi action (identifikátor), title (přesněji label, neboli to, co se zobrazí uživateli) a volitelně icon (URL obrázku). Kolik akcí můžete přidat se dozvíte z vlastnosti Notification.maxActions. Podle toho, kolik akcí přidáte se můžete také lišit vzhled upozornění.

self.addEventListener('push', e => {
    let data = e.data.json();

    //definuje základní tlačítka pro zprávu
    //první akce slouží pro odpověď a smazání
    let actions = [
                { action: 'reply', 
                  title: 'Zobrazit' },
                { action: 'ignore', 
                  title: 'Ignorovat' }
    ];

    //pokud můžeme mít třetí akci, přidáme smazání
    if (2 < Notification.maxActions) {
        actions.push({ action: 'delete',
                       title: 'Smazat',
                       icon: 'img/trash.png'
        });
        //a změníme název první akce
        actions[0].title = 'Odpovědět';
    }

    e.waitUntil(self.registration.showNotification(
        'Zpráva od ' + data.sender, {
            body: data.message,
            data: data,
            requireInteraction: true,
            actions: actions
        }
    );
});

Zjištění, na které tlačítko uživatel klikl, je stejné, jako ošetření jednoduchého kliknutí. Jen si stačí přečíst vlastnost action:

const web_url = 'https://my.server.com/';
self.addEventListener('notificationclick', e => {
     let id = e.notification.data.id;

     if ('reply' === e.action) {
         e.notification.close();
         let url = web_url + 'chat/reply/' + id;
         e.waitUntil(clients.openWindow(url));
     }
     else if ('delete' === e.action) {
         e.waitUntil(fetch(web_url 
                 + 'api/delete/' + id)
             .then(r => e.notification.close())
         );
     }
     else e.waitUntil(fetch(web_url 
         + 'api/mark-read/' + id)
         .then(r => e.notification.close())
     );
 });

Pokud uživatel klikl na Odpovědět (resp. Zobrazit), zavřeme upozornění a v prohlížeči otevřeme okno s příslušnou zprávou.

Pokud uživatel klikl na Ignorovat nebo Smazat, pouze přes API označíme zprávu jako přečtenou nebo smazanou. Notifikaci ale zavřeme až v okamžiku, kdy server odpoví, že akci provedl, aby měl uživatel kontrolu, že se opravdu zpráva smazala (a např. mohl to provést znovu v případě, že zrovna přišel o připojení).

Poznámka: Všimněte si, že akci Ignorovat provádíme ve větvi else (tedy bez další podmínky). To je proto, že pokud uživatel klikne na upozornění jinde než na tlačítkách, stále dojde k vyvolání událostinotificationclick (které bude mít hodnotu action prázdnou) a my ho můžeme použít stejně jako klik na Ignorovat. Navíc některé systémy nemusejí akce podporovat, takže byste vždy měli reagovat na obyčejné kliknutí bez action.

Detekce zavření upozornění

Ať už máte v události akce nebo jen reagujete na klik, může stále uživatel upozornění zavřít bez kliknutí (jelikož možnost sticky není zatím plně podporována). Pokud potřebujete na zavření reagovat (např. pro statistické účely), můžete sledovat událost notificationclose:

self.addEventListener('notificationclose', e => {
    e.waitUntil(fetch('/api/mark-read' 
        + e.notification.data.id));
});

Tato funkce po zavření upozornění označí zprávu jako přečtenou, čímž dá serveru najevo, že uživatel na upozornění zareagoval a server si může do statistik uložit, že je uživatel aktivní.

Důležité je si uvědomit, že pokud dojde k zavření události v důsledku nějaké globální nebo systémové události (např. uživatel vymaže všechna upozornění nebo se systém restartuje), k vyvolání notificationclose nedojde. Událost tedy označuje situaci, kdy uživatel interagoval přímo s vaším upozornění!

Reakce na push notifikaci v offline režimu

Dejte si pozor na to, že i když se notifikace stahuje a zobrazuje v online režimu, reakce na ni (kliknutí nebo zavření) může přijít až po nějaké době, kdy už je zařízení offline (například: uživatel sedí v metru a prohlíží dříve přijatá upozornění). Pokud tedy chcete odeslat data na server, je potřeba reagovat na fetch().catch() a případě podat sync požadavek.

V případě, že po kliknutí chcete otevřít stránku v prohlížeči, může být potřeba otevřít nějakou offline stránku, která uživatele upozorní na to, že je v offline režimu, a nabídne mu automatické (reakce na online událost) nebo manuální (odkaz) přejití do online stránky.

Zajímavé řešení by bylo zobrazit offline stránku, ze které si požádáte o sync a z něj pak zobrazíte dané upozornění znovu. Ale zatím nemám ověřeno, že je to technicky možné.

Push notifikace jako informace o aktualizaci

Push notifikace nemusíte používat pouze k zobrazování informací uživateli, ale můžete je použít k interní komunikaci mezi serverem a zařízením.

Pokud server dostane nějaké nové zprávy (např. nové články), může zařízení poslat zprávu a ta, místo aby otravovala uživatele upozorněním, může data stáhnout na pozadí a zobrazit je až v okamžiku, kdy uživatel otevře vaši stránku – a to i v případě, že bude zrovna offline.

self.addEventListener('push', e => {
    let data = e.data.json();

   if (data.isUpdate) {
     e.waitUntil(caches.open('offline')
        .then(cache => {
            cache.put('/articles.json, 
                data.articles);
            for (id of data.articles) {
                cache.add('/articles/'+id);
            }
        })
     );
   }
   else {
     //ošetření ostatních notifikací
   }
});

Tento kód kontroluje, zda notifikace (zaslaná jako JSON) obsahuje vlastnost isUpdate, a pokud ano, tak očekává, že vlastnost articles bude obsahovat pole názvů nebo IDček článků (tak, jak jsou potřeba pro URL) a všechny je přidá do cache (metoda add() provede fetch() a následně cache.put()).

Stránka offline.html pak může fungovat tak, že si přes AJAX stáhne soubor articles.json, který je uložen v cache a tedy dostupný i offline, a z něj pak získá seznam článků, které má uložené a může je tedy zobrazit.

Poznámka: alternativa k articles.json by byla ukládat do cache články pomocí GET parametrů (např. /articles/?id=1) a následně použít metodu cache.keys('/articles/', { ignoreSearch: true}), která pak vrátí všechny requesty z cache, které mají adresu /articles/ nezávisle na parametrech za otazníkem. Tento způsob by byl lepší v případě, že chcete přes PUSH update odesílat čísla jen nových článků a pak zobrazit staré i nové.

Synchronizace na pozadí

Synchronizací na pozadí můžete pokrýt několik různých situací:

  1. Offline stránka odešle nějaká data (např. odpověď na zprávu, nové fotky, apod.), ale musí počkat, až bude uživatel online.
  2. Uživatel prohlíží online stránku, přejde do offline (např. ve vlaku jdete do tunelu) a následně odešle data (např. odpověď na zprávu, nové fotky, apod.). V normálním případě by o taková data pravděpodobně přišel.
  3. Služba detekuje, že uživatel přešel do online režimu a stáhne si ze serveru aktualizace.

Hlídání Online a Offline režimu

Služba i ostatní stránky mohou sledovat přepínání online a offline režimu pomocí událostí online a offline. Případně ve funkci se dá stav ověřit vlastností navigator.onLine (takže není potřeba vytvářet online a offline handlery jen proto, abyste si stav uložili).

Funkce reagující na fetch tak nemusí čekat, jestli metoda fetch() selže, a v případě offline režimu může rovnou vrátit offline stránku z cache.

self.addEventListener('offline', e 
    => console.log('Ztracen přístup k internetu'));
self.addEventListener('online', e
    => console.log('Přístup k internetu obnoven'));

self.addEventListener('fetch', r => {
    if (navigator.onLine) {
        e.respondWith(fetch(r));
    }
    else {
        e.respondWith(getOfflinePage());
    }
});

Další příklady použití výše uvedených událostí jsou:

  • při detekci offline režimu můžete upozornit uživatele na to, že nebude dostávat aktualizace a připomenou mu, že stále může data odesílat (viz dále)
  • po detekci online režimu z offline.html můžete nabídnout přechod na online verzi stránek (nedělal bych to ale automaticky, protože uživatel může mít rozdělanou práci).

I když můžete online a offline režim detekovat, je potřeba pamatovat na to, že detekce změny může mít nějaké zpoždění (ať už proto, že změnu režimu hned nepozná, nebo proto, že má hodně dalších služeb, které musí také upozornit) a že k přechodu do offline režimu může dojít až v okamžiku, kdy čekáte na odpověď, a v tom případě požadavek selže. Detekce tedy není samo-spásná a je stále potřeba myslet na všechny situace a správně reagovat na .catch() u Promise.

Odeslání dat v offline režimu

Upozornění: Tato funkce je sice podporována ve všech nejnovějších verzích prohlížečů, které podporují Service workery, ale syntaxe ani funkčnost není sjednocena, takže je možné, že se jednotlivé implementace liší a že bude v budoucnu funkce přepracována! Není tedy dobré se na ni 100% spoléhat.

Pokud chcete z vaší offline stránky odeslat data na server, nemůžete to samozřejmě udělat přes klasický AJAX nebo POST formuláře, protože by selhal. Stačí si ale zaregistrovat požadavek v Sync Manageru a ten se následně postará o to, aby se data odeslala v online režimu.

if (navigator.onLine) {
    //jsme online, můžete odeslat hned
    $.ajax(... submit data);
}
else { //offline, musíme počkat
    navigator.serviceWorker.ready.then(
        function(service) {
            service.sync.register('submitData');
        }
    );
}

Pomocí service.sync.register si zaregistrujeme synchronizační požadavek pod jménem submitData. Sync Manager následně čeká na online režim a pak vyvolá událost sync ve službě (pro každý zaregistrovaný požadavek jednu). Pokud vytvoříte sync požadavek, když je prohlížeč online, vyvolá se sync událost okamžitě. Službě pak stačí přečíst si jméno požadavku z vlastnosti tag a provést potřebnou akci:

self.addEventListener('sync', e =>
    if ('submitData' === e.tag) {
        e.waitUntil(submitData());
    }
);

Uvnitř události sync má metoda e.waitUntil() ještě jednu vlastnost a to, že pokud vrácený Promise (zde z metody submitData()) selže (reject()), Sync Manager to pozná a vyvolá další sync událost o něco později (třeba po té, co se dokončí ostatní sync a fetch události, nebo když detekuje lepší připojení jako je WiFi). Prostě to bude zkoušet, dokud daný sync požadavek neskončí úspěchem.

Pokud vás nyní zajímá, co by měla obsahovat metoda submitData() a jak jí můžete odeslat data ze stránky, tak je to možné buď přes message (viz dále), nebo s využitím ostatních prostředků Javaskriptu, jako je Cache, LocalStorage (ten ale není pro Service Workery vhodný!) nebo IndexedDB.

Přes message můžete poslat službě libovolná data. Jak s nimi naloží, je na ní. Zde je příklad odeslání dat v offline režimu:

//offline.html
if (navigator.onLine) {
    //jsme online, můžete odeslat hned
    $.ajax('/submit', {data: getData() });
    //příklad pro jQuery,
    //můžete ale použít i Fetch stejně jako níže
}
else { //offline, musíme počkat
    navigator.serviceWorker.ready
    .then(function(service) {
        //pošli službě data k odeslání
        navigator.serviceWorker.controller
            .postMessage({submitData: getData()});
        //zaregistruj sync požadavek
        service.sync.register('submitData');
    });
}

//service.js
let submitData = null; //pro uložení dat
self.addEventListener('message', e => {
    if ('submitData' in e.data) {
        //uložení přijatých dat pro pozdější použití
        submitData = e.data.submitData;
    }
});

self.addEventListener('sync', e =>
    if ('submitData' === e.tag) {
        e.waitUntil(submitData());
    }
);

const submitData() {
    return fetch('/submit', {
        //odeslání dat přes AJAX v JSON formátu
        method: 'POST',
        headers: {'Content-Type':'application/json'},
        body: JSON.stringify(submitData)
    })
    .then(response => {
        submitData = null; //vymazání uloženích data
        return response;
    };
}

Alternativně můžete zneužít tag a přímo v něm poslat JSON objekt:

//stránka:
service.sync.register(JSON.stringify({
    apiMethod: 'save',
    params: { body: data }
});

//služba:
self.addEventListener('sync', e => {
    let data = JSON.parse(e.tag);
    if (data.hasOwnProperty('apiMethod')) {
        submitData(data.method, data.params);
    }
});

Sync tag musí být unikátní

Pokud pošlete stejný sync požadavek zatímco se čaká na online režim (nebo předchozí sync selhal), prohlížeč ho tiše zahodí.

Pokud potřebujete skutečně poslat jeden požadavek vícekrát, musíte zajistit, aby byl unikátní. V případě, že tag je jméno události, o kterou žádáte, musíte k ní přidat unikátní identifikátor, který pak budete v sync handleru ignorovat:

//stránka:
service.sync.register('submitData' 
        + '|' + (new Date()) + Math.random());

//služba:
self.addEventListener('sync', e => {
    let tag = e.tag.split('|').shift();
    if ('submitData' === tag) {
        submitData();
    }
});

Pokud v tagu posíláte JSON objekt, postačí k jeho unikátnosti do něj zahrnout další unikátní klíč(e):

//stránka:
service.sync.register(JSON.stringify({
    apiMethod: 'save',
    params: { body: data },
    created: new Date(),
    uid: Math.random()
});

Použití new Date() zajistí, že sync požadavky budou v čase unikátní (každou sekundu). Kombinace s Math.random() pak zajistí, že bude tag unikátní i v případě dvou požadavků během jedné sekundy. Naopak kombinace s new Date() zajišťuje, že po určité době nedojde k vygenerování stejného náhodného čísla. Šance, že během jedné sekundy vygenerujete dva sync requesty se stejným náhodným číslem, je zanedbatelná.

Propojení stránky a služby

Jak již bylo uvedeno výše, služba a otevřené stránky spolu mohou komunikovat přes postMessage(). Aby to ale bylo možné, musejí se stránka a služba nějak spojit. Většina obsahu této kapitoly je již zmíněna výše, ale zde je sumarizace.

Jednorázové odeslání zprávy

Stránka se se službou automaticky propojí tím, že že zavolá navigator.serviceWorker.register(). Uvedením určité URL pak stránka říká, ke které službě se chce připojit. Vícenásobným zavoláním register() s různými URL a scope může stránka zaregistrovat více služeb, ale aktivní bude jen ta, která splňuje podmínku na scope dané stránky Odkaz pro posílání zpráv propojené službě získá stránka ve funkci reagující na register().then(reg => {}).

Pokud chce stránka posílat zprávy službě, musí je posílat z metod definovaných v uzávěře (Closure) této funkce (nebo funkce navigator.serviceWorker.ready.then(reg => {})). Zprávu lze pak poslat metodou reg.active.postMessage(). Zprávu lze také poslat přes navigator.serviceWorker.controller.postMessage(), ale je třeba dát pozor na to, že vlastnost controller může být NULL v okamžiku, kdy služba teprve čeká na aktivaci. Na druhou stranu lze vlastnost controller využít k detekci, zda lze zprávy posílat a pokud ne, vyřešit problém jinak.

//po kliku na tlačítko pošli zprávu službě
el.onclick = function(e) {
    if (navigator.serviceWorker.controller) {
        navigator.serviceWorker.controller
            .postMessage({
                 action: 'click', 
                 target: e.target.id
            });
    }
    else {
        alert('Služba není aktivována!');
    }
}

Z druhé strany služba se propojí se stránkami, které ji registrují buď po zavolání metody e.waitUntil(clients.claim()) z události activate nebo automaticky po jejich znovu načtení (reload). Služba ale může být propojena na více stránek ze svého scope, pokud jsou současně otevřeny v různých oknech prohlížeče.

Seznam otevřených oken (klientů) získá služba zavoláním clients.matchAll().then(clientList => {}), případně clients.matchAll({includeUncontrolled: true}) pro získání i oken, které teprve čekají na aktivaci služby.

Proměnná clientList je pole klientů, které může služba projít pomocí FOR-OF nebo přes .map(). Každý klient má vlastnost client.url, pomocí které může služba určit, kterým klientům chce zprávu poslat. Zpráva se pak posílá metodou client.postMessage().

//po přechodu do online aktualizuj data
// a pošli je všem otevřeným offline stránkám
self.addEventListener('online', e => 
    e.waitUntil(fetch('/api/update')
    .then(r => clients.matchAll())
    .then(list => for (client of list) {
        if (client.url.match(/^\/offline.html/i)) {
            client.postMessage({
                action: 'updated',
                data: r.json()
            });
        }
    }))
);

Uvnitř událostí vyvolaných konkrétním klientem, jako jsou fetch a sync, může služba poslat zprávu přímo danému klientovi, protože událost obsahuje hodnotu clientId. Zprávu pak lze poslat metodou clients.get(e.clientId).postMessage(). Například pokud klient požádal o odeslání formuláře přes sync, může mu služba dát vědět, že již data odeslala a stránka může tuto informaci předat uživateli.

//po uložení dat pomocí sync informuj klienta,
//který vytvořil sync požadavek a poslal data
self.addEventListener('sync', e => 
    e.waitUntil(fetch('/api/sync/' + self.syncId)
               .then(r => clients.get(e.clientId)
               .postMessage({synced: self.syncId})
    )
);

Ve zprávě můžete poslat jakoukoliv hodnotu (řetězec, číslo, apod.) nebo objekt, který je tzv. klonovatelný (to znamená, že nejde například poslat objekt window nebo self, které jsou unikátní pro konkrétní stránku nebo službu).

Přijetí zprávy

Služba může zprávy přijímat sledováním své události message:

self.addEventListener('message', e => {
    let message = e.data;
    let client = clients.get(e.source.id);
});

Pokud chce klient reagovat na zprávy od své služby, musí si také zaregistrovat událost message, ale aby bylo jasné, že jde o zprávy od služby, musí to udělat na objektu navigator.serviceWorker:

navigator.serviceWorker.addEventListener('message',
    function(e) {
        var message = e.data;
        var service = navigator.serviceWorker.controller;
    }
);

Zpráva s odpovědí

Pokud vám nestačí výše uvedené odeslání jednorázové zprávy, můžete mezi klientem a službou vytvořit kanál, kterým mohou komunikovat. Tím získáte možnost přijetí odpovědi na konkrétní zprávu.

Nejprve na straně klienta vytvořte instanci třídy MessageChannel. Ta nabízí dva porty (port1 a port2, které přestavují konce kanálu; chcete-li pipe), na které se mohou navázat klient a služba (tak, že každý bude odchytávat jeden port; tedy konec kanálu).

//odeslání zprávy
var channel = new MessageChannel();
channel.port1.onmessage = function(e) {
    process(e.data); //zpracování odpovědi
}
navigator.serviceWorker.controller
    .postMessage(zprava, [channel.port2])

//Získání zprávy a odeslání odpovědi
self.addEventListener('message', e => {
    let message = e.data;
    let responsePort = e.ports[0];

    responsePort.postMessage(process(message));
});

Klient vytvoří kanál a sám se pověsí na první konec (port1). Následně pošle službě zprávu a sdělí jí, že má poslat odpověď do druhého konce kanálu (port2). Když pak služba odpověď zpracuje a pošle zprávu do svého portu, klient pak na své straně tuhle zprávu získá v callbacku (port1.onmessage).

Kromě MessageChannel (která je určena k zasílání textových řetězců), můžete službě poslat jakýkoliv objekt typu transferable (např. ArrayBuffer, ImageBitmap nebo OffscreenCanvas, které většinou slouží k zasílání binárních dat resp. obrázků), do kterých může služba uložit požadovaná data.

Poznámka: MessageChannel je jen prázdný obal obou portů. Pomocí destrukturalizace objektů můžete rovnou vytvořit dvě samostatné proměnné bez nutnosti ukládat samotný kanál. Díky tomu je také můžete trochu lépe pojmenovat:

const {sender, response} = new MessageChannel();

response.onmessage = function(e) { ... };
navigator.serviceWorker.controller.postMessage(msg, [sender]);

Pokud chcete jít ještě dál a obalit posílání zpráv do Promise, není to žádný problém:

function postMsg(target, message) {
    return new Promise(function(resolve) {
        const {sender, response} = new MessageChannel();
        response.onmessage = function(e) {
            try { resolve(JSON.parse(e.data)); }
            catch (e) { resolve(e.data); }
        }
        if ('object' === typeof message) {
            message = JSON.stringify(message);
        }
        target.postMessage(message, [sender]);
    });
}
//Použití:
postMsg(navigator.serviceWorker.controller, 1)
    .then(function(response) {
        process(response);
    })
;
postMsg(navigator.serviceWorker.controller, {id:1})
    .then(function(response) {
        process(response.id);
    })
;
//Zpracování v target
self.onmessage = function(e) {
    let message;
    try { JSON.parse(e.data); }
    catch (e) { message = e.data; }
    response = process(message.id);
    if ('object' === typeof response) {
        response = JSON.stringify(response);
    }
    e.ports[0].postMessage(response);
};

2 komentáře u „Service Worker: Offline stránky a push notifikace“

  1. „Služba sice může sama sebe udržet v běhu po určitou dobu (řádově sekundy nebo minuty), ale nemůže běžet nepřetržitě několik hodin nebo dní…“

    Co to pro mě přesně znamená? Pokud potřebuji udržet data pro uživatele k dispozici offline když si web otevře i po třeba 5 hodinách co tam byl naposledy a už je offline, tak když už mezitím služba neběží (browser ji zabil), tak už se znovu nespustí a uživatel se nedostane ani k věcem z cache dokud nebude zase online? Nebo se na to pokud je to v cache naopak spolehnout můžu? Díky

    1. To, zda služba běží nebo ne (je „uspaná“ nebo „zabitá“) nemá vliv na to, zda může reagovat na dedikované události prohlížeče (push, fetch, atd.). Prohlížeč si při instalaci zapamatuje, na jaké události služba reaguje a sám ji probudí před vyvoláním události.

      Pokud tedy chcete použít službu pro nabízení offline souborů z cache, je potřeba službu zaregistrovat jako listener pro „fetch“. Díky tomu prohlížeč bude vědět, že kdykoliv chce stáhnout soubor, musí službu probudit a poslat jí fetch požadavek.

      Cache je nezávislá na službě a zůstává přístupná, i když je služba uspaná a dokonce i potom, co ji aktualizujete nebo odinstalujete.

Napsat komentář

Vaše e-mailová adresa nebude zveřejněna. Vyžadované informace jsou označeny *

Tato stránka používá Akismet k omezení spamu. Podívejte se, jak vaše data z komentářů zpracováváme..