Service Worker en TypeScript

Service Worker en TypeScript

Cet article explique les Service Workers en TypeScript.

Nous allons expliquer les Service Workers en TypeScript, avec des exemples concrets.

YouTube Video

Service Worker en TypeScript

Un Service Worker agit comme un « proxy de requêtes » qui se situe entre le navigateur et le réseau. Il permet l'interception des requêtes, le contrôle du cache, le support hors ligne et le traitement en arrière-plan (synchronisation et push). L'utilisation de TypeScript garantit la sécurité des types et améliore la maintenabilité.

Configuration de TypeScript

tsconfig.json (Activer les types WebWorker)

Voyons un exemple d’activation du type WebWorker dans 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}
  • En ajoutant WebWorker au tableau lib, vous pouvez utiliser des types comme ServiceWorkerGlobalScope.
  • Le DOM et le WebWorker ont des types différents, il est donc courant de séparer les paramètres tsconfig.json pour le navigateur (application principale) et le Service Worker.
  • Les fichiers du Service Worker sont finalement générés dans un chemin correspondant au scope (généralement à la racine du site /sw.js).
  • Pour des raisons de sécurité, les Service Workers ne fonctionnent que sur HTTPS (ou sur localhost).

Code d’enregistrement côté navigateur

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    );
  • Ce processus enregistre un Service Worker. Le scope fait référence à l'ensemble des chemins que le Service Worker peut contrôler. Par exemple, si vous placez /sw.js à la racine et définissez le scope au dossier racine (/), vous pouvez contrôler toutes les ressources du site entier. En revanche, si vous spécifiez un répertoire particulier comme /app/, seuls les contenus de ce répertoire seront contrôlés.
1    // If there's a waiting worker, notify the user.
2    if (registration.waiting) {
3      promptUserToUpdate(registration);
4    }
  • waiting indique l'état où un nouveau Service Worker a été installé et attend d'être activé. À ce stade, les pages existantes sont encore contrôlées par l'ancien Service Worker, il est donc courant de demander la confirmation à l'utilisateur puis d'appeler skipWaiting() après avoir obtenu son accord afin d'activer immédiatement le nouveau Service Worker. Cela permet de refléter les derniers changements sans attendre le prochain rechargement de la page.
 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 est déclenché lorsque l'installation d'un nouveau Service Worker commence. Lorsque cet événement se produit, un nouveau worker est défini dans registration.installing, ainsi, en surveillant son statechange, vous pouvez détecter quand l'installation est terminée (installed). De plus, si navigator.serviceWorker.controller existe, cela signifie qu’un ancien Service Worker contrôle déjà la page. C'est donc l'occasion d'informer l'utilisateur qu'une nouvelle version est disponible.
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'événement controllerchange est déclenché au moment où le nouveau Service Worker commence à contrôler la page actuelle. Recharger à ce stade permettra d'appliquer immédiatement les nouvelles stratégies de cache et de traitement. Cependant, le rechargement automatique peut dégrader l'expérience utilisateur, il est donc préférable de recharger après avoir obtenu le consentement de l'utilisateur.
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();
  • En faisant recevoir au Service Worker un postMessage({ type: 'SKIP_WAITING' }) du client puis en appelant self.skipWaiting(), vous pouvez déclencher une mise à jour.

Déclaration du scope dans sw.ts

Voyons maintenant un exemple typique de Service Worker qui met en œuvre la mise en cache du shell de l’application.

Lorsque vous utilisez les Service Workers en TypeScript, il est utile d’assigner le bon type à self.

1// sw.ts
2export default null;
3declare const self: ServiceWorkerGlobalScope;
  • En TypeScript, self est de type any par défaut ; sans typage supplémentaire, vous ne bénéficierez ni de la complétion de type ni de la vérification de type pour les API spécifiques au Service Worker comme skipWaiting() ou clients.
  • Spécifier ServiceWorkerGlobalScope permet l’autocomplétion, évite les erreurs, et permet un développement plus sûr, séparé des scripts DOM classiques.

Service Worker de base (Install/Activate/Fetch)

Il présente une gestion simple des versions du cache, le pré-cache à l’installation, la suppression des anciens caches à l’activation, et des stratégies de cache lors des fetch (cache-first pour les fichiers statiques, network-first pour les APIs).

sw.ts (Configuration minimale + squelette de 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});
  • Lors de l’événement install, les ressources statiques (App Shell) de l’application sont pré-mises en cache. En appelant self.skipWaiting(), le nouveau Service Worker est activé immédiatement, rendant ainsi le dernier cache disponible sans attendre le prochain accès.
 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});
  • Lors de l’événement activate, les anciennes versions des caches sont supprimées et le Service Worker est maintenu à jour. De plus, en appelant self.clients.claim(), le nouveau Service Worker peut contrôler tous les clients sans attendre que la page soit rechargée.
 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});
  • Dans fetch, vous pouvez intercepter les requêtes et contrôler la réponse. Vous pouvez mettre en œuvre des stratégies comme cache-first ou network-first, utiles pour le support hors-ligne et la performance.
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});
  • Si SKIP_WAITING est reçu, l'appel à self.skipWaiting() permet d'activer instantanément le Service Worker en attente. Par conséquent, la nouvelle version sera appliquée à partir de la prochaine requête, sans avoir besoin de recharger la page.

Résumé des stratégies de cache pratiques

cache-first

Cache-first vérifie d’abord le cache et fournit la réponse immédiatement si elle est disponible. Sinon, il récupère depuis le réseau et met le résultat en cache. Ceci est adapté aux fichiers statiques.

 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}
  • Ce code montre une implémentation cache-first. S’il existe une entrée en cache, elle est retournée ; sinon, la ressource est récupérée sur le réseau et stockée dans le cache. C’est adapté aux ressources statiques qui changent rarement, comme les images ou les fichiers CSS.

network-first

Network-first tente d’abord le réseau, puis se replie sur le cache en cas d’échec. C’est approprié pour les APIs où la fraîcheur des données est 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}
  • Ce code montre une implémentation network-first. Si une réponse réseau est reçue, elle est enregistrée dans le cache ; sinon, la version en cache est retournée. C’est adapté aux ressources nécessitant des données fraîches, comme des articles d’actualité ou des réponses d’API.

stale-while-revalidate

stale-while-revalidate renvoie d'abord le cache puis le met à jour en arrière-plan à partir du réseau en même temps. Cela équilibre la rapidité de réponse et la fraîcheur des données.

 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}
  • Ce code retourne immédiatement le cache si disponible, et récupère en arrière-plan de nouvelles données sur le réseau pour mettre à jour le cache. Cela offre des réponses rapides aux utilisateurs et du contenu mis à jour au prochain accès, ce qui le rend adapté à l’UI ou à la diffusion de données légères.

Optimiser la mise à jour (notification et rechargement sécurisé)

Les mises à jour du Service Worker ne sont pas immédiates ; la nouvelle version reste en attente jusqu'à la fermeture des onglets existants.

Ici, nous mettons en place un système pour avertir le client quand la nouvelle version est prête, puis recharger la page de façon sécurisée selon l’action de l’utilisateur.

Notifiez le client depuis le Service Worker lorsqu'une nouvelle version est prête.

 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});
  • Dans ce code, notifyClientsUpdated est appelé à la fin de l’événement activate pour notifier tous les clients connectés que la nouvelle version est prête. clients.claim() est une méthode qui place immédiatement les pages ouvertes (clients) sous le contrôle du Service Worker nouvellement activé. Normalement, un Service Worker ne commence à contrôler la page qu'au prochain chargement, mais grâce à clients.claim(), la page passe immédiatement sous contrôle sans rechargement.

Afficher une interface de mise à jour côté client, puis recharger après action de l’utilisateur

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});
  • Le client reçoit SW_UPDATED via l’événement message et affiche une notification de mise à jour dans l’interface. Lorsque l’utilisateur choisit de recharger, window.location.reload() est exécuté, mettant à jour l’ancien HTML, CSS et les autres ressources de la page vers la version la plus récente. Cela garantit que le cache et la prise de contrôle du Service Worker via clients.claim() sont reflétés sur l’ensemble de la page.

Secours hors-ligne (Offline Fallback)

Préparez /offline.html pour la navigation critique, et fournissez une interface minimale qui reste compréhensible même sans image ou police. Si un appel API échoue, montrez le dernier état en cache si possible et essayez de le rafraîchir en arrière-plan pour améliorer l’expérience utilisateur.

Exemple d’implémentation

 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});
  • Pré-cachez /offline.html lors de l’événement install afin de pouvoir fournir au minimum une page basique en cas d’indisponibilité réseau.
  • Lors de l’événement fetch, vous pouvez surveiller les requêtes de navigation avec request.mode === 'navigate' et cibler spécifiquement les transitions de page.
  • Basculer vers /offline.html si le réseau échoue, garantissant l’affichage même hors ligne.

La communication entre le client et le Service Worker

Comme le Service Worker fonctionne indépendamment du cycle de vie de la page, une messagerie bidirectionnelle est importante pour notifier les états et exécuter des commandes. Spécifier des types pour les messages aide à éviter les envois incorrects, permet la complétion du code et rend votre implémentation plus robuste.

Exemple de code

  1. Définition des types de messages
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 désigne le type des messages envoyés du Service Worker vers le client.
  • ClientToSw est le type des messages envoyés du client vers le Service Worker.
  • Cela vous permet de clarifier les types d'événements pouvant être échangés via une communication bidirectionnelle.
  1. Traitement côté 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});
  • Le Service Worker reçoit les messages du client et effectue un traitement différent selon leur type.
  • Pour CLEAR_CACHE, il supprime le cache puis avertit tous les clients avec CACHE_CLEARED.
  • Pour PING, il répond au client initial avec un message PING incluant un timestamp.
  1. Notifier tous les clients depuis le 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}
  • Utilisez clients.matchAll pour récupérer tous les onglets ouverts.
  • En envoyant postMessage à chacun, vous pouvez diffuser un message.
  • Cela peut servir à notifier les mises à jour (ex : SW_UPDATED) ou les erreurs.
  1. Traitement côté client
1navigator.serviceWorker.controller?.postMessage({
2  type: 'PING',
3  ts: Date.now()
4} as ClientToSw);
  • En envoyant un PING depuis le client et en recevant une réponse du Service Worker, vous pouvez vérifier que la communication bidirectionnelle fonctionne correctement. Cela facilite le test des états de connexion et la gestion des messages.
 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 の更新やリロード、通知表示などを行います。^}

Avantages de la messagerie typée

  • En utilisant des messages typés, il est clair quels messages peuvent être envoyés/reçus, et l’autocomplétion ainsi que la vérification des types augmentent la sécurité.
  • postMessage permet une communication un-à-un, et broadcast une communication un-à-plusieurs.
  • Vous pouvez facilement mettre en œuvre des fonctions essentielles comme les notifications de mise à jour (SW_UPDATED), la gestion du cache (CACHE_CLEARED), ou la vérification d’état (PING).

Résumé

  • L’utilisation de TypeScript apporte la sécurité des types aux appels API et à la messagerie des Service Workers, améliorant grandement l’efficacité et la maintenabilité du développement.
  • Comprendre les événements du cycle de vie install, activate et fetch, et choisir la bonne stratégie de cache (cache-first, network-first, etc.) selon la situation permet d’offrir une meilleure expérience utilisateur.
  • Pour l’exploitation, comprendre la gestion des versions de cache et les flux de mise à jour (updatefound, waiting, SKIP_WAITING, etc.) est essentiel.
  • En adoptant des messages typés pour la communication entre le client et le Service Worker, vous pouvez prévenir les erreurs d'implémentation et établir un système facile à étendre et à maintenir à long terme.

Vous pouvez suivre l'article ci-dessus avec Visual Studio Code sur notre chaîne YouTube. Veuillez également consulter la chaîne YouTube.

YouTube Video