Service Worker w TypeScript

Service Worker w TypeScript

Ten artykuł wyjaśnia Service Workery w TypeScript.

Wyjaśnimy Service Workery w TypeScript, włącznie z praktycznymi przykładami.

YouTube Video

Service Worker w TypeScript

Service Worker to „pełnomocnik żądań” działający między przeglądarką a siecią. Umożliwia przechwytywanie żądań fetch, zarządzanie pamięcią podręczną, wsparcie offline oraz przetwarzanie w tle (synchronizacja i push). Użycie TypeScript zapewnia bezpieczeństwo typów i poprawia łatwość utrzymania kodu.

Konfigurowanie TypeScript

tsconfig.json (Włącz typy WebWorker)

Zobaczmy przykład włączenia typu WebWorker w pliku tsconfig.json.

 1{
 2  "compilerOptions": {
 3    "target": "ES2020",
 4    "module": "ES2020",
 5    "lib": ["ES2020", "WebWorker"],
 6    "moduleResolution": "Bundler",
 7    "strict": true,
 8    "noEmitOnError": true,
 9    "outDir": "out",
10    "skipLibCheck": true
11  },
12  "include": ["sw.ts"]
13}
  • Dodając WebWorker do tablicy lib, możesz korzystać z takich typów jak ServiceWorkerGlobalScope.
  • DOM i WebWorker mają różne typy, więc powszechną praktyką jest rozdzielanie ustawień tsconfig.json dla przeglądarki (główna aplikacja) i Service Workera.
  • Pliki Service Workera są ostatecznie zapisywane w **ścieżce odpowiadającej zasięgowi** (zazwyczaj główny katalog strony, czyli /sw.js`).
  • Ze względów bezpieczeństwa Service Workery działają tylko przez HTTPS (lub na localhost).

Kod rejestracji po stronie przeglądarki

register-sw.ts

1// register-sw.ts
2async function registerServiceWorker() {
3  if (!('serviceWorker' in navigator)) return;
4
5  try {
6    const registration = await navigator.serviceWorker.register(
7      '/sw.js', { scope: '/' }
8    );
  • Ten proces rejestruje Service Workera. scope oznacza zakres ścieżek, które może kontrolować Service Worker. Na przykład, jeśli umieścisz /sw.js bezpośrednio w katalogu głównym i ustawisz scope na katalog główny (/), możesz kontrolować wszystkie zasoby całej strony. Z drugiej strony, jeśli określisz konkretny katalog, taki jak /app/, kontrolowana będzie tylko zawartość tego katalogu.
1    // If there's a waiting worker, notify the user.
2    if (registration.waiting) {
3      promptUserToUpdate(registration);
4    }
  • waiting oznacza stan, w którym nowy Service Worker został zainstalowany i oczekuje na aktywację. Na tym etapie istniejące strony są nadal kontrolowane przez starego Service Workera, więc zazwyczaj prosi się użytkownika o potwierdzenie, a po uzyskaniu zgody wywołuje się skipWaiting(), aby natychmiast aktywować nowego Service Workera. Pozwala to zastosować najnowsze zmiany bez czekania na ponowne załadowanie strony.
 1    // When a new SW is installing, monitor its state changes
 2    registration.addEventListener('updatefound', () => {
 3      const newWorker = registration.installing;
 4      if (!newWorker) return;
 5      newWorker.addEventListener('statechange', () => {
 6        if (newWorker.state === 'installed' &&
 7            navigator.serviceWorker.controller) {
 8          // New content available, prompt the user
 9          promptUserToUpdate(registration);
10        }
11      });
12    });
  • updatefound jest wywoływane gdy rozpocznie się instalacja nowego Service Workera. Gdy to zdarzenie wystąpi, nowy worker zostaje ustawiony w registration.installing, więc monitorując jego statechange, można wykryć moment zakończenia instalacji (installed). Dodatkowo, jeśli istnieje navigator.serviceWorker.controller, oznacza to, że stara wersja Service Workera już kontroluje stronę, co daje możliwość poinformowania użytkownika o istnieniu nowej wersji.
1    // When the active worker changes (e.g., after skipWaiting), reload if desired
2    navigator.serviceWorker.addEventListener('controllerchange', () => {
3      // Optionally reload to let the new SW take over
4      window.location.reload();
5    });
6  } catch (err) {
7    console.error('Service Worker registration failed: ', err);
8  }
9}
  • Zdarzenie controllerchange występuje w momencie, gdy nowy Service Worker zaczyna kontrolować aktualną stronę. Ponowne załadowanie w tym momencie natychmiast zastosuje nowe strategie cache oraz przetwarzania. Jednak automatyczne przeładowanie może pogorszyć doświadczenie użytkownika, dlatego lepiej przeładować stronę po uzyskaniu zgody użytkownika.
1function promptUserToUpdate(reg: ServiceWorkerRegistration) {
2  // Show UI to user. If user accepts:
3  if (reg.waiting) {
4    reg.waiting.postMessage({ type: 'SKIP_WAITING' });
5  }
6}
7
8registerServiceWorker();
  • Wysyłając do Service Workera wiadomość postMessage({ type: 'SKIP_WAITING' }) po stronie klienta, a następnie wywołując self.skipWaiting(), można wymusić aktualizację.

Deklaracja zakresu w sw.ts

Następnie przyjrzyjmy się typowemu przykładzie Service Workera wdrażającemu caching „app shell”.

Przy użyciu Service Workera w TypeScript warto przypisać właściwy typ do self.

1// sw.ts
2export default null;
3declare const self: ServiceWorkerGlobalScope;
  • W TypeScript self jest domyślnie traktowane jako any, więc bez dodatkowego typowania nie uzyskasz uzupełniania typów ani sprawdzania typów dla specyficznych API Service Workera, takich jak skipWaiting() lub clients.
  • Określenie typu ServiceWorkerGlobalScope pozwala na podpowiedzi autouzupełniania, zapobiega błędom i umożliwia bezpieczniejszy rozwój oddzielony od standardowych skryptów DOM.

Podstawowy Service Worker (Install/Activate/Fetch)

Pokazuje zarządzanie wersjami pamięci podręcznej, wstępne cachowanie podczas instalacji, usuwanie starych cache podczas aktywacji oraz strategie cachowania przy fetch (najpierw cache dla zasobów statycznych, najpierw sieć dla API).

sw.ts (Minimalna konfiguracja + szkielet cache)

 1const CACHE_NAME = 'app-shell-v1';
 2const STATIC_ASSETS = [
 3  '/',
 4  '/index.html',
 5  '/styles.css',
 6  '/main.js',
 7  '/fallback.png'
 8];
 9
10self.addEventListener('install', (event: ExtendableEvent) => {
11  // Pre-cache application shell
12  event.waitUntil(
13    caches.open(CACHE_NAME)
14      .then(cache => cache.addAll(STATIC_ASSETS))
15      // Activate immediately (optional: coordinate with client)
16      .then(() => self.skipWaiting())
17  );
18});
  • Podczas zdarzenia install statyczne zasoby aplikacji (App Shell) są wstępnie cachowane. Wywołując self.skipWaiting(), nowy Service Worker jest aktywowany natychmiast, dzięki czemu najnowszy cache jest dostępny bez konieczności czekania na kolejne wejście.
 1self.addEventListener('activate', (event: ExtendableEvent) => {
 2  // Clean up old caches and take control of clients immediately
 3  event.waitUntil(
 4    caches.keys().then(keys =>
 5      Promise.all(keys
 6        .filter(key => key !== CACHE_NAME)
 7        .map(key => caches.delete(key)))
 8    ).then(() => self.clients.claim())
 9  );
10});
  • Podczas zdarzenia activate stare wersje cache są usuwane, a Service Worker jest aktualizowany. Ponadto, wywołując self.clients.claim(), nowy Service Worker może przejąć kontrolę nad wszystkimi klientami bez konieczności przeładowania strony.
 1self.addEventListener('fetch', (event: FetchEvent) => {
 2  const request = event.request;
 3  const url = new URL(request.url);
 4
 5  // Navigation requests (SPA) -> network-first with fallback to cached index.html
 6  if (request.mode === 'navigate') {
 7    event.respondWith(
 8      fetch(request).catch(() => caches.match('/index.html') as Promise<Response>)
 9    );
10    return;
11  }
12
13  // Simple API routing: network-first for /api/
14  if (url.pathname.startsWith('/api/')) {
15    event.respondWith(networkFirst(request));
16    return;
17  }
18
19  // Static assets: cache-first
20  event.respondWith(cacheFirst(request));
21});
  • W fetch możesz przechwytywać żądania i kontrolować odpowiedzi. Możesz wdrożyć strategie takie jak cache-first lub network-first, przydatne dla wsparcia offline i wydajności.
1self.addEventListener('message', (event: ExtendableMessageEvent) => {
2  const data = (event as any).data;
3  if (!data) return;
4
5  if (data.type === 'SKIP_WAITING') {
6    // Force the waiting service worker to become active
7    self.skipWaiting();
8  }
9});
  • Po otrzymaniu SKIP_WAITING, wywołanie self.skipWaiting() pozwala natychmiastowo aktywować oczekującego Service Workera. W rezultacie nowa wersja zostanie zastosowana przy następnym żądaniu, bez potrzeby przeładowania strony.

Przegląd praktycznych strategii cachowania

cache-first

Cache-first najpierw sprawdza pamięć podręczną i zwraca odpowiedź natychmiast, jeśli jest dostępna. W przeciwnym razie pobiera z sieci i zapisuje odpowiedź w pamięci podręcznej. To podejście jest odpowiednie dla plików statycznych.

 1async function cacheFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  const cached = await cache.match(request);
 4  if (cached) {
 5    return cached;
 6  }
 7  const response = await fetch(request);
 8  if (response && response.ok) {
 9    cache.put(request, response.clone());
10  }
11  return response;
12}
  • Ten kod demonstruje implementację cache-first. Jeśli istnieje wpis w pamięci podręcznej, jest on zwracany; jeśli nie, następuje pobranie z sieci i zapisanie do cache. To dobre rozwiązanie dla statycznych zasobów, które rzadko się zmieniają, takich jak obrazy czy CSS.

network-first

Network-first najpierw próbuje pobrać z sieci, a w przypadku niepowodzenia wraca do cache. To dobre podejście dla API, gdzie ważna jest świeżość danych.

 1async function networkFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  try {
 4    const response = await fetch(request);
 5    if (response && response.ok) {
 6      cache.put(request, response.clone());
 7    }
 8    return response;
 9  } catch (err) {
10    const cached = await cache.match(request);
11    if (cached) return cached;
12    return new Response(JSON.stringify({ error: 'offline' }), {
13      status: 503,
14      headers: { 'Content-Type': 'application/json' }
15    });
16  }
17}
  • Ten kod demonstruje implementację network-first. Jeśli otrzymano odpowiedź z sieci, jest ona zapisywana w cache; jeśli wystąpi błąd, zwracana jest wersja z cache. Nadaje się do zasobów wymagających aktualnych danych, jak artykuły newsowe czy odpowiedzi API.

stale-while-revalidate

stale-while-revalidate najpierw zwraca cache, a jednocześnie w tle aktualizuje go z sieci. To pozwala zrównoważyć szybkość odpowiedzi i świeżość danych.

 1async function staleWhileRevalidate(request: Request, cacheName = CACHE_NAME): Promise<Response> {
 2  const cache = await caches.open(cacheName);
 3  const cachedResponse = await cache.match(request);
 4  const networkFetch = fetch(request).then(networkResponse => {
 5    if (networkResponse && networkResponse.ok) {
 6      cache.put(request, networkResponse.clone());
 7    }
 8    return networkResponse;
 9  }).catch(() => undefined);
10
11  // Return cached immediately if exists, otherwise wait network
12  return cachedResponse || (await networkFetch) || new Response('offline', { status: 503 });
13}
  • Kod natychmiast zwraca cache, jeśli jest dostępny, a w tle pobiera nowe dane z sieci, by zaktualizować pamięć podręczną. Zapewnia szybkie odpowiedzi użytkownikom i korzysta z odświeżonej zawartości przy kolejnym dostępie, dzięki czemu sprawdza się przy UI lub lekkim przesyle danych.

Optymalizacja procesu aktualizacji (powiadamianie i bezpieczne przeładowanie)

Aktualizacje Service Workera nie są natychmiastowe; nowa wersja będzie oczekiwać do momentu zamknięcia istniejących kart.

Tutaj wdrażamy system powiadamiający klienta, gdy nowa wersja jest gotowa oraz bezpiecznie przeładowujący stronę po akcji użytkownika.

Powiadom klienta po stronie Service Workera, gdy nowa wersja jest gotowa.

 1// In sw.ts: after 'activate' or when new version is ready, broadcast a message
 2async function notifyClientsUpdated() {
 3  const all = await self.clients.matchAll({ type: 'window' });
 4  for (const client of all) {
 5    client.postMessage({ type: 'SW_UPDATED' });
 6  }
 7}
 8
 9// e.g., call this at the end of 'activate'
10self.addEventListener('activate', (event) => {
11  event.waitUntil((async () => {
12    if ('navigationPreload' in self.registration) {
13      await self.registration.navigationPreload.enable();
14    }
15    // cache cleanup
16    const cacheNames = await caches.keys();
17    await Promise.all(
18      cacheNames.map((name) => {
19        if (name !== CACHE_NAME) {
20          return caches.delete(name);
21        }
22      })
23    );
24
25    await self.clients.claim();
26    await notifyClientsUpdated();
27  })());
28});
  • W tym kodzie notifyClientsUpdated jest wywoływane na końcu zdarzenia activate, by powiadomić wszystkich połączonych klientów o gotowości nowej wersji. clients.claim() to metoda, która natychmiast obejmuje kontrolą nowo aktywowanego Service Workera wszystkie aktualnie otwarte strony (klientów). Zwykle Service Worker zaczyna kontrolować stronę dopiero przy następnym załadowaniu, ale używając clients.claim(), można objąć stronę kontrolą natychmiast, bez przeładowania.

Wyświetl UI powiadamiające o aktualizacji po stronie klienta i przeładuj stronę po akcji użytkownika

1// in app startup
2navigator.serviceWorker.addEventListener('message', (e) => {
3  if (e.data?.type === 'SW_UPDATED') {
4    // Show a non-intrusive toast or banner: "New version available"
5    // When user clicks "Reload", call:
6    window.location.reload();
7  }
8});
  • Klient odbiera SW_UPDATED przez zdarzenie message i wyświetla powiadomienie o aktualizacji w UI. Gdy użytkownik wybierze ponowne załadowanie, wykonywane jest window.location.reload(), co aktualizuje stare HTML, CSS i inne zasoby na stronie do najnowszej wersji. To zapewnia, że cache oraz kontrola przez Service Workera przejęta za pomocą clients.claim() są odzwierciedlone na całej stronie.

Tryb offline (fallback)

Przygotuj /offline.html na wypadek krytycznej nawigacji i zapewnij minimalny UI, który przekazuje treści nawet bez obrazów czy czcionek. Jeśli wywołanie API zakończy się niepowodzeniem, pokaż ostatni zapisany stan z cache i spróbuj pobrać dane ponownie w tle, by poprawić UX.

Przykład implementacji

 1// sw.ts
 2const CACHE_NAME = 'app-cache-v1';
 3
 4// Cache offline.html during install
 5self.addEventListener('install', (event) => {
 6  event.waitUntil((async () => {
 7    const cache = await caches.open(CACHE_NAME);
 8    await cache.addAll(['/offline.html']);
 9  })());
10});
11
12// Handle fetch requests
13self.addEventListener('fetch', (event) => {
14  const request = event.request;
15
16  // Navigation requests (e.g., page transitions)
17  if (request.mode === 'navigate') {
18    event.respondWith((async () => {
19      try {
20        // Try to fetch from the network as usual
21        return await fetch(request);
22      } catch (err) {
23        // On failure, return offline fallback page
24        const cache = await caches.open(CACHE_NAME);
25        return await cache.match('/offline.html') as Response;
26      }
27    })());
28  }
29});
  • Zacachuj /offline.html podczas zdarzenia install, by móc zwrócić przynajmniej podstawową stronę przy braku sieci.
  • Podczas zdarzenia fetch można monitorować żądania nawigacji przy użyciu request.mode === 'navigate', aby precyzyjnie obsługiwać przejścia między stronami.
  • Jeśli sieć zawiedzie, wróć do /offline.html, by zapewnić wyświetlanie nawet w trybie offline.

Komunikacja pomiędzy klientem a Service Workerem.

Ponieważ Service Worker działa niezależnie od cyklu życia strony, dwukierunkowa komunikacja jest ważna do przekazywania stanów i wykonywania poleceń. Określenie typów wiadomości pomaga zapobiec błędnemu wysyłaniu wiadomości, umożliwia uzupełnianie kodu i sprawia, że implementacja jest bardziej odporna.

Przykład kodu

  1. Definicja typów wiadomości
1type SwToClient =
2  | { type: 'SW_READY' }
3  | { type: 'SW_UPDATED' }
4  | { type: 'CACHE_CLEARED' }
5  | { type: 'PING'; ts: number };
6
7type ClientToSw =
8  | { type: 'CLEAR_CACHE' }
9  | { type: 'PING'; ts: number };
  • SwToClient to typ wiadomości wysyłanej od Service Workera do klienta.
  • ClientToSw to typ wiadomości wysyłanej od klienta do Service Workera.
  • Pozwala to precyzyjnie określić typy zdarzeń, które mogą być wymieniane za pomocą dwukierunkowej komunikacji.
  1. Przetwarzanie po stronie Service Workera
 1self.addEventListener('message', (event) => {
 2  const data = event.data as ClientToSw;
 3  if (data?.type === 'CLEAR_CACHE') {
 4    event.waitUntil((async () => {
 5      const keys = await caches.keys();
 6      await Promise.all(keys.map((k) => caches.delete(k)));
 7      await broadcast({ type: 'CACHE_CLEARED' });
 8    })());
 9  } else if (data?.type === 'PING') {
10    event.source?.postMessage({ type: 'PING', ts: data.ts } as SwToClient);
11  }
12});
  • Service Worker odbiera wiadomości od klienta i rozgałęzia przetwarzanie w zależności od typu.
  • Dla CLEAR_CACHE usuwa cache, a następnie powiadamia wszystkich klientów przez CACHE_CLEARED.
  • Dla PING odsyła klientowi wiadomość PING zawierającą znacznik czasu.
  1. Powiadamianie wszystkich klientów z Service Workera
1async function broadcast(msg: SwToClient) {
2  const clients = await self.clients.matchAll({ includeUncontrolled: true });
3  for (const c of clients) c.postMessage(msg);
4}
  • Użyj clients.matchAll, by pobrać wszystkie otwarte karty/przeglądarki.
  • Wysyłając postMessage do każdej, możesz rozgłaszać wiadomości.
  • Możesz to wykorzystać do powiadomień o aktualizacjach (np. SW_UPDATED) czy powiadomieniach o błędach.
  1. Przetwarzanie po stronie klienta
1navigator.serviceWorker.controller?.postMessage({
2  type: 'PING',
3  ts: Date.now()
4} as ClientToSw);
  • Wysyłając PING z klienta i otrzymując odpowiedź od Service Workera, możesz zweryfikować, czy dwukierunkowa komunikacja działa poprawnie. To ułatwia testowanie stanu połączenia oraz obsługi wiadomości.
 1navigator.serviceWorker.addEventListener('message', (e) => {
 2  const msg = e.data as SwToClient;
 3  switch (msg.type) {
 4    case 'SW_READY':
 5      console.log('Service Worker is ready');
 6      // Example: hide loading spinner or enable offline UI
 7      break;
 8    case 'SW_UPDATED':
 9      console.log('A new version of the Service Worker is available');
10      // Example: show update notification or reload prompt
11      const shouldReload = confirm('A new version is available. Reload now?');
12      if (shouldReload) {
13        window.location.reload();
14      }
15      break;
16    case 'CACHE_CLEARED':
17      console.log('Cache cleared');
18      // Example: show confirmation message to user
19      alert('Cache has been successfully cleared.');
20      break;
21    case 'PING':
22      console.log(`Received PING response, ts=${msg.ts}`);
23      break;
24  }
25});
  • {^ i18n_speak クライアント側では Service Worker から送信されるメッセージを受信し、種類に応じて処理を分岐します。SW_READY は初期化完了、SW_UPDATED は新バージョン検出、CACHE_CLEARED はキャッシュ削除完了、PING は通信確認を示します。各メッセージに応じて、UI の更新やリロード、通知表示などを行います。^}

Korzyści z typowanej komunikacji

  • Typowanie wiadomości sprawia, że jasno wiadomo, jakie wiadomości można wysyłać i odbierać, a podpowiedzi kodu i sprawdzanie typów zwiększają bezpieczeństwo.
  • postMessage umożliwia komunikację jeden do jednego, a broadcastjeden do wielu.
  • Możesz łatwo wdrożyć niezbędne funkcjonalności, takie jak powiadomienia o aktualizacjach (SW_UPDATED), zarządzanie pamięcią (CACHE_CLEARED) oraz sprawdzanie stanu (PING).

Podsumowanie

  • Użycie TypeScript dodaje bezpieczeństwo typów do wywołań API Service Workera i komunikacji, znacznie poprawiając efektywność rozwoju i łatwość utrzymania.
  • Zrozumienie cyklu życia (zdarzenia install, activate, fetch) oraz dobór odpowiedniej strategii cachowania (jak cache-first lub network-first) poprawia doświadczenie użytkownika.
  • Dla operacji istotne jest zrozumienie zarządzania wersjami cache oraz przebiegu aktualizacji (updatefound, waiting, SKIP_WAITING itd.).
  • Stosowanie komunikacji typowanej pomiędzy klientem a Service Workerem pozwala zapobiec błędnej implementacji oraz tworzy system łatwy do rozszerzania i utrzymania w dłuższej perspektywie.

Możesz śledzić ten artykuł, korzystając z Visual Studio Code na naszym kanale YouTube. Proszę również sprawdzić nasz kanał YouTube.

YouTube Video