Service Worker in TypeScript

Service Worker in TypeScript

Questo articolo spiega i Service Worker in TypeScript.

Spiegheremo i Service Worker in TypeScript, includendo esempi pratici.

YouTube Video

Service Worker in TypeScript

Un Service Worker è un 'proxy di richiesta' che si trova tra il browser e la rete. Consente l'intercettazione delle richieste, il controllo della cache, il supporto offline e l'elaborazione in background (sincronizzazione e push). L'utilizzo di TypeScript garantisce la sicurezza dei tipi e aumenta la manutenibilità.

Configurazione di TypeScript

tsconfig.json (Abilita i tipi WebWorker)

Vediamo un esempio di come abilitare il tipo WebWorker in 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}
  • Aggiungendo WebWorker all'array lib, puoi usare tipi come ServiceWorkerGlobalScope.
  • DOM e WebWorker hanno tipi diversi, quindi è pratica comune separare le impostazioni di tsconfig.json per il browser (app principale) e per il Service Worker.
  • I file del Service Worker vengono infine esportati in un percorso che corrisponde allo scope (di solito la root del sito /sw.js).
  • Per motivi di sicurezza, i Service Worker vengono eseguiti solo su HTTPS (o su localhost).

Codice di registrazione lato browser

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    );
  • Questo processo registra un Service Worker. Lo scope si riferisce all’intervallo di percorsi che il Service Worker può controllare. Ad esempio, se posizioni /sw.js direttamente nella root e imposti lo scope sulla directory root (/), puoi controllare tutte le risorse dell’intero sito. Al contrario, se specifichi una directory particolare come /app/, solo il contenuto di quella directory sarà controllato.
1    // If there's a waiting worker, notify the user.
2    if (registration.waiting) {
3      promptUserToUpdate(registration);
4    }
  • waiting indica lo stato in cui un nuovo Service Worker è stato installato e sta aspettando di essere attivato. In questa fase, le pagine esistenti sono ancora controllate dal vecchio Service Worker, quindi è comune chiedere conferma all’utente e, dopo aver ottenuto l’approvazione, chiamare skipWaiting() per attivare immediatamente il nuovo Service Worker. Questo ti consente di applicare i processi più recenti senza attendere il prossimo ricaricamento della pagina.
 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 viene attivato quando inizia l’installazione di un nuovo Service Worker. Quando si verifica questo evento, un nuovo worker viene impostato in registration.installing, quindi monitorando il suo statechange puoi rilevare quando l’installazione è stata completata (installed). Inoltre, se esiste navigator.serviceWorker.controller, significa che un vecchio Service Worker sta già controllando la pagina, quindi questa è un'occasione per informare l'utente dell'esistenza di una nuova versione.
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}
  • L’evento controllerchange viene attivato nel momento in cui il nuovo Service Worker inizia a controllare la pagina attuale. Ricaricando in questo momento, verranno applicate immediatamente le nuove strategie di cache e di elaborazione. Tuttavia, il ricaricamento automatico può peggiorare l’esperienza dell’utente; quindi, è preferibile ricaricare dopo aver ottenuto il consenso dell’utente.
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();
  • Facendo ricevere al Service Worker un postMessage({ type: 'SKIP_WAITING' }) dal client e poi chiamando self.skipWaiting(), puoi forzare un aggiornamento.

Dichiarazione dello Scope in sw.ts

Successivamente, vediamo un esempio tipico di Service Worker che implementa la cache dell'app shell.

Quando si usano i Service Worker in TypeScript, è utile assegnare il tipo corretto a self.

1// sw.ts
2export default null;
3declare const self: ServiceWorkerGlobalScope;
  • In TypeScript, self è trattato come any per impostazione predefinita, quindi senza tipizzazione aggiuntiva non otterrai il completamento o il controllo dei tipi per le API specifiche del Service Worker come skipWaiting() o clients.
  • Specificare ServiceWorkerGlobalScope abilita il completamento automatico, previene errori e permette uno sviluppo più sicuro separato dagli script DOM normali.

Service Worker di base (Install/Activate/Fetch)

Dimostra una semplice gestione delle versioni della cache, pre-caching durante l'installazione, eliminazione delle vecchie cache all'attivazione e strategie di cache su fetch (cache-first per risorse statiche, network-first per le API).

sw.ts (Impostazione minima + Scheletro della 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});
  • Durante l'evento install, le risorse statiche dell'app (App Shell) vengono pre-memorizzate nella cache. Chiamando self.skipWaiting(), il nuovo Service Worker viene attivato immediatamente, rendendo disponibile la cache più recente senza attendere il prossimo accesso.
 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});
  • Durante l'evento activate, le vecchie versioni delle cache vengono eliminate e il Service Worker viene mantenuto aggiornato. Inoltre, chiamando self.clients.claim(), il nuovo Service Worker può controllare tutti i client senza attendere che la pagina venga ricaricata.
 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});
  • In fetch, puoi intercettare le richieste e controllare la risposta. Puoi implementare strategie come cache-first o network-first, utili per il supporto offline e le prestazioni.
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});
  • Se viene ricevuto SKIP_WAITING, chiamare self.skipWaiting() consente di attivare immediatamente il Service Worker in attesa. Di conseguenza, la nuova versione verrà applicata dalla richiesta successiva senza necessità di ricaricare la pagina.

Panoramica delle strategie di cache pratiche

cache-first

Cache-first controlla prima la cache e restituisce immediatamente la risposta se disponibile. In caso contrario, ottiene dalla rete e memorizza il risultato nella cache. Questo è adatto per i file statici.

 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}
  • Questo codice mostra una implementazione cache-first. Se esiste una cache, la restituisce; altrimenti, preleva dalla rete e lo salva nella cache. È adatto per risorse statiche che cambiano di rado, come immagini o CSS.

network-first

Network-first prova prima la rete e, in caso di errore, ricorre alla cache. Questo è adatto per le API dove la freschezza dei dati è importante.

 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}
  • Questo codice mostra una implementazione network-first. Se si riceve una risposta dalla rete, questa viene salvata nella cache; in caso contrario, restituisce la versione memorizzata nella cache. È adatto per risorse che richiedono dati aggiornati, come articoli di notizie o risposte API.

stale-while-revalidate

stale-while-revalidate restituisce prima la cache e la aggiorna contemporaneamente dalla rete in background. Questo bilancia velocità di risposta e freschezza.

 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}
  • Questo codice restituisce immediatamente la cache, se disponibile, mentre recupera i nuovi dati dalla rete in background per aggiornare la cache. Fornisce risposte rapide agli utenti e utilizza contenuti aggiornati per il prossimo accesso, rendendolo adatto a UI o alla consegna di dati leggeri.

Ottimizzare il flusso di aggiornamento (notifica di aggiornamento e ricarica sicura)

Gli aggiornamenti del Service Worker non sono immediati; la nuova versione rimarrà in attesa finché le schede esistenti non saranno chiuse.

Qui implementiamo un sistema per notificare al client quando la nuova versione è pronta e ricaricare la pagina in sicurezza in base all'azione dell'utente.

Notifica il client dal lato Service Worker quando la nuova versione è pronta.

 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});
  • In questo codice, notifyClientsUpdated viene chiamata alla fine dell'evento activate per notificare a tutti i client collegati che la nuova versione è pronta. clients.claim() è un metodo che porta immediatamente le pagine (client) aperte sotto il controllo del nuovo Service Worker attivato. Normalmente, un Service Worker inizia a controllare la pagina solo al caricamento successivo, ma usando clients.claim(), puoi portare la pagina sotto il controllo immediatamente senza ricaricare.

Mostrare l'interfaccia di aggiornamento sul client e ricaricare con l'azione dell'utente

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});
  • Il client riceve SW_UPDATED tramite l'evento message e mostra una notifica di aggiornamento nell'interfaccia utente. Quando l'utente sceglie di ricaricare, viene eseguito window.location.reload(), aggiornando HTML, CSS e altre risorse della pagina alla versione più recente. Questo garantisce che la cache e il controllo da parte del Service Worker, attivati con clients.claim(), siano applicati a tutta la pagina.

Fallback Offline

Prepara /offline.html per la navigazione critica e fornisci un'interfaccia minimale che dia significato anche senza immagini o font. Se una chiamata API fallisce, mostra lo stato memorizzato più recente se possibile e prova a ripetere la richiesta in background per migliorare l'esperienza utente.

Esempio di implementazione

 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});
  • Esegui il precaching di /offline.html durante l'evento install così da poter restituire almeno una pagina minima quando la rete non è disponibile.
  • Durante l'evento fetch, puoi monitorare le richieste di navigazione con request.mode === 'navigate' e prendere di mira specificamente le transizioni di pagina.
  • Fai un fallback su /offline.html quando la rete fallisce, assicurandoti che venga visualizzato anche offline.

Messaggistica tra il client e il Service Worker.

Poiché il Service Worker opera indipendentemente dal ciclo di vita della pagina, la messaggistica bidirezionale è importante per notificare stati ed eseguire comandi. Specificare i tipi per i messaggi aiuta a prevenire l'invio errato di messaggi, abilita il completamento del codice e rende l'implementazione più robusta.

Esempio di codice

  1. Definizione dei tipi di messaggio
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 è il tipo di messaggio inviato dal Service Worker al client.
  • ClientToSw è il tipo di messaggio inviato dal client al Service Worker.
  • Questo permette di chiarire i tipi di eventi che possono essere scambiati tramite comunicazione bidirezionale.
  1. Elaborazione lato Service Worker
 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});
  • Il Service Worker riceve messaggi dal client e ramifica l'elaborazione in base al tipo.
  • Per CLEAR_CACHE, elimina la cache e poi notifica tutti i client con CACHE_CLEARED.
  • Per PING, risponde al client originale con un messaggio PING contenente un timestamp.
  1. Notificare tutti i client dal Service Worker
1async function broadcast(msg: SwToClient) {
2  const clients = await self.clients.matchAll({ includeUncontrolled: true });
3  for (const c of clients) c.postMessage(msg);
4}
  • Utilizza clients.matchAll per ottenere tutte le finestre aperte.
  • Inviando postMessage a ciascuna, puoi trasmettere messaggi in broadcast.
  • Questo può essere utilizzato per notifiche di aggiornamento (come SW_UPDATED) e notifiche di errore.
  1. Elaborazione lato client
1navigator.serviceWorker.controller?.postMessage({
2  type: 'PING',
3  ts: Date.now()
4} as ClientToSw);
  • Inviando un PING dal client e ricevendo una risposta dal Service Worker, puoi verificare che la comunicazione bidirezionale funzioni correttamente. Questo rende più semplice testare lo stato della connessione e la gestione dei messaggi.
 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 の更新やリロード、通知表示などを行います。^}

Vantaggi della messaggistica tipizzata

  • Usare messaggi tipizzati rende chiaro quali messaggi possono essere inviati e ricevuti e il completamento automatico e il controllo dei tipi migliorano la sicurezza.
  • postMessage abilita la comunicazione uno-a-uno e broadcast abilita la comunicazione uno-a-molti.
  • Puoi facilmente implementare funzioni essenziali come notifiche di aggiornamento (SW_UPDATED), gestione della cache (CACHE_CLEARED) e controlli di stato (PING).

Riepilogo

  • L'utilizzo di TypeScript aggiunge sicurezza dei tipi alle chiamate delle API e alla messaggistica dei Service Worker, migliorando notevolmente l'efficienza dello sviluppo e la manutenibilità.
  • Comprendere gli eventi di ciclo di vita install, activate e fetch e scegliere la strategia di caching più adatta (come cache-first o network-first) per ogni situazione porta a una migliore esperienza utente.
  • Per le operazioni, è essenziale comprendere la gestione delle versioni della cache e i flussi di aggiornamento (updatefound, waiting, SKIP_WAITING, ecc.).
  • Adottando una messaggistica tipizzata per la comunicazione tra client e Service Worker, puoi evitare errori di implementazione e creare un sistema facile da estendere e mantenere a lungo termine.

Puoi seguire l'articolo sopra utilizzando Visual Studio Code sul nostro canale YouTube. Controlla anche il nostro canale YouTube.

YouTube Video