Devlog #19 — Offline-first: Service Workery, strategie cache i Lighthouse 100

3D Simulations działa teraz w pełni offline. Każda symulacja, którą już odwiedziłeś, ładuje się natychmiast z cache — nawet bez internetu. Oto pełna historia techniczna: dlaczego wybraliśmy konkretne strategie cache, jak wstępnie cache'ujemy pakiety symulacji i jakie kroki doprowadziły nas do wyniku 100/100 w audycie PWA Lighthouse.

0 ms
czas ponownej nawigacji
(z cache)
100
wynik PWA
Lighthouse
200+
stron symulacji
do wstępnego cache'owania

Dlaczego offline-first?

Symulacje fizyczne to strony wymagające dużej mocy obliczeniowej: Three.js, niestandardowe moduły WASM i workery symulacji to często 200–800 KB na stronę. Przy wolnym lub niestabilnym połączeniu każda wizyta wiąże się z bolesnym początkowym pobieraniem. Cache'owanie przeglądarki przez nagłówki Cache-Control pomaga, ale warstwa service workera daje nam pełną kontrolę nad tym, które zasoby są cache'owane, kiedy i jak bardzo nieaktualne mogą być.

Cykl życia service workera

Service worker to skrypt, który przeglądarka instaluje raz, a następnie uruchamia jako trwały proxy w tle. Jego cykl życia ma trzy kluczowe fazy: install (wstępne cache'owanie krytycznych zasobów), activate (czyszczenie starych cache'y) i fetch (przechwytywanie żądań sieciowych).

// sw.js — install: wstępne cache'owanie powłoki i wspólnych zasobów
const PRECACHE = 'shell-v4';
const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/offline.html',
  '/shared/theme.css',
  '/shared/components.css',
  '/shared/components.js',
  '/manifest.json',
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(PRECACHE)
      .then(cache => cache.addAll(PRECACHE_URLS))
      .then(() => self.skipWaiting())  // aktywuj natychmiast
  );
});

Strategie cache według typu zasobu

Nie wszystkie zasoby powinny być cache'owane w ten sam sposób. Używamy czterech strategii:

Cache-First
Serwuj z cache, jeśli dostępny; do sieci sięgaj tylko przy braku trafienia. Najlepsze dla wersjonowanych zasobów, których zawartość pod tym samym URL nigdy się nie zmienia (pakiet Three.js, WASM, czcionki).
Stale-While-Revalidate
Serwuj z cache natychmiast (zerowe opóźnienie), a następnie pobierz świeżą kopię z sieci w tle na następny raz. Używane dla stron HTML i wspólnego CSS komponentów.
Network-First
Najpierw spróbuj sieci; w razie niepowodzenia użyj cache. Używane dla indeksu bloga i stron kategorii, gdzie nieaktualna treść mogłaby wprowadzić użytkowników w błąd.
Network-Only
Nigdy nie cache'uj. Używane dla kanału RSS, sitemap.xml i endpointów analitycznych, gdzie nieaktualne dane są bezużyteczne.
// sw.js — fetch: kierowanie do odpowiedniej strategii
self.addEventListener('fetch', event => {
  const { request } = event;
  const url = new URL(request.url);

  // Pomijamy nie-GET i cross-origin
  if (request.method !== 'GET' || url.origin !== location.origin) return;

  // Zasoby wersjonowane → cache-first
  if (/\.(wasm|js|css)$/.test(url.pathname) && /[?&]v=/.test(url.search)) {
    event.respondWith(cacheFirst(request));
    return;
  }

  // Strony HTML → stale-while-revalidate
  if (request.headers.get('Accept')?.includes('text/html')) {
    event.respondWith(staleWhileRevalidate(request, 'pages-v4'));
    return;
  }
});

async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  const fetchPromise = fetch(request).then(response => {
    cache.put(request, response.clone());
    return response;
  });
  return cached ?? fetchPromise;
}

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  const cache = await caches.open('assets-v4');
  cache.put(request, response.clone());
  return response;
}

Wstępne cache'owanie stron symulacji w czasie działania

Symulacje są zbyt duże, aby wstępnie cache'ować je wszystkie od razu przy instalacji — zajęłoby to kilkaset megabajtów miejsca w cache i opóźniłoby instalację service workera. Zamiast tego cache'ujemy zasoby symulacji przy pierwszej wizycie użytkownika, dzięki czemu druga wizyta jest natychmiastowa.

Dedykowany SIM_CACHE ma politykę maksymalnego wieku: wpisy starsze niż 7 dni są usuwane przy kolejnym zdarzeniu activate, korzystając z magazynu metadanych ze znacznikami czasu w IndexedDB.

Monit instalacji

Gdy spełnione są kryteria instalowalności przeglądarki (HTTPS + service worker + manifest z ikonami + display: standalone), odpala się zdarzenie beforeinstallprompt. Przechwytujemy je i pokazujemy stonowany baner instalacji w naszej stopce:

let deferredPrompt;
window.addEventListener('beforeinstallprompt', e => {
  e.preventDefault();            // zatrzymaj domyślny mini-pasek przeglądarki
  deferredPrompt = e;
  showInstallBanner();
});

function showInstallBanner() {
  const banner = document.getElementById('install-banner');
  if (!banner) return;
  banner.hidden = false;
  banner.querySelector('button').addEventListener('click', async () => {
    banner.hidden = true;
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    console.log('Wynik instalacji:', outcome);  // 'accepted' lub 'dismissed'
    deferredPrompt = null;
  });
}

Wynik PWA Lighthouse 100 — lista kontrolna

Limity pamięci cache: Przeglądarki zazwyczaj przyznają do 60% dostępnego miejsca na dysku na pamięć origin (obejmuje to IndexedDB, CacheStorage i localStorage). Na urządzeniu z 32 GB pamięci i 15 GB wolnego miejsca to ~9 GB. Zostajemy dobrze poniżej tego limitu, usuwając zasoby symulacji starsze niż 7 dni i ograniczając zcache'owane zasoby każdej symulacji do ~5 MB.