Utiliser les service workers

Dans cet article, nous aborderons les notions pour vous permettre de démarrer avec les service workers comme l'architecture associée, l'enregistrement d'un service worker, les processus d'installation et d'activation pour un nouveau service worker, la mise à jour d'un service worker, le contrôle du cache associé et les réponses personnalisées en appliquant ceci à une application d'exemple simple ayant des fonctionnalités hors-ligne.

Le point de départ des service workers

Un problème qui se pose depuis plusieurs années sur le Web est la perte de connexion au réseau. Une application web, si performante soit elle, fournira un service déplorable si on ne peut pas la télécharger. Plusieurs tentatives ont eu lieu pour résoudre ce problème et certains aspects ont été réglés. Toutefois, il était encore difficile de bien contrôler la mise en cache de données et de gérer l'interception de requêtes.

Les service workers aident à résoudre ces problèmes. En utilisant un service worker, on peut mettre en place une application qui utilise des fichiers en cache et ainsi fournir des fonctionnalités, même hors ligne, avant d'obtenir des données depuis le réseau. Ce qui est possible avec les applications natives devient possible avec les applications web.

Un service worker fonctionne comme un serveur intermédiaire (« proxy »), permettant de modifier les requêtes et les réponses en utilisant les éléments qu'il a en cache.

Mise en place pour manipuler les service workers

Les service workers sont présents par défaut dans les navigateurs. Pour exécuter du code dans des service workers, il faut qu'il soit servi avec HTTPS (pour des raisons de sécurité). Il est donc nécessaire d'avoir un serveur web prenant en charge HTTPS (ça peut être grâce à un service comme GitHub, Netlify, Vercel, etc.). Afin de simplifier le développement local, localhost est également considéré par les navigateurs comme une origine sécurisée.

Architecture de base

Lors de la mise en place d'un service worker, on a généralement les étapes suivantes :

  1. Le code du service worker est récupérée et enregistrée grâce à serviceWorkerContainer.register(). Si cela fonctionne, le service worker est exécuté dans une portée ServiceWorkerGlobalScope (en-US) : il s'agit d'un type de contexte de worker particulier, qui s'exécute en dehors du thread principal, sans accès au DOM. Le service worker est alors prêt à traiter des évènements.
  2. L'installation se déroule alors. Un évènement install est toujours le premier évènement envoyé à un service worker (et peut être utilisé pour remplir une base de données IndexedDB, et mettre en cache des fichiers). Pendant cette étape, l'application prépare ce qui doit l'être pour fonctionner hors ligne.
  3. Lorsque le gestionnaire d'évènements oninstall a terminé, on considère que le service worker est installé. À cet instant, une version précédente du service worker peut toujours être active et contrôler les pages ouvertes. Comme on ne veut pas que deux versions différentes du même service worker s'exécutent au même moment, la nouvelle version n'est pas encore active.
  4. Une fois que toutes les pages contrôlées par l'ancienne version du service worker sont fermées, on peut alors enlever l'ancienne version. Le nouveau service worker installé reçoit un évènement activate. On utilise principalement activate pour nettoyer les ressources utilisées par les versions précédentes d'un service worker. Le nouveau service worker peut appeler skipWaiting() (en-US) pour demander l'activation immédiate, sans attendre la fermeture des pages ouvertes. Le nouveau service worker recevra alors l'évènement activate immédiatement, et prendra le contrôle des pages ouvertes concernées.
  5. Le service worker contrôlera alors les pages qui ont été ouvertes après que la fonction register() a fini son exécution. Autrement dit, les documents devront être rechargés afin d'être contrôlé, car l'état de contrôle d'un document avec ou sans service worker est déterminé à son chargement et reste ainsi pendant sa durée de vie. Pour surcharger ce comportement par défaut et contrôler les pages ouvertes, un service worker peut appeler clients.claim().
  6. À chaque fois qu'une nouvelle version d'un service worker est récupérée, ce cycle se répète et les données de la version précédente sont nettoyées pendant l'activation de la nouvelle version.

Diagramme illustrant le cycle de vie d'un service worker

Voici les évènements disponibles pour un service worker :

Démonstration

Pour illustrer les bases de l'enregistrement et de l'installation d'un service worker, nous avons créé une application d'exemple intitulée simple-service-worker, qui est une galerie d'images de Lego Star Wars. Elle utilise une fonction à base de promesses pour lire les données des images depuis un objet JSON et charger les images avec fetch() avant de les afficher sur une ligne dans la page. L'exemple n'utilise que des ressources statiques. Nous verrons aussi l'enregistrement, l'installation et l'activation d'un service worker.

Les mots Star Wars suivis par une image du personnage de Dark Vador en Lego

Vous pouvez voir le code source sur GitHub, et la démo.

Enregistrement

Le premier bloc de code du fichier JavaScript app.js sert de point d'entrée pour l'utilisation des service workers.

js
const registerServiceWorker = async () => {
  if ("serviceWorker" in navigator) {
    try {
      const registration = await navigator.serviceWorker.register("/sw.js", {
        scope: "/",
      });
      if (registration.installing) {
        console.log("Installation du service worker en cours");
      } else if (registration.waiting) {
        console.log("Service worker installé");
      } else if (registration.active) {
        console.log("Service worker actif");
      }
    } catch (error) {
      console.error(`L'enregistrement a échoué : ${error}`);
    }
  }
};

// …

registerServiceWorker();
  1. Le bloc if teste la présence de la fonctionnalité pour s'assurer que les service workers sont bien pris en charge avant de tenter l'enregistrement.
  2. Ensuite, on utilise la fonction ServiceWorkerContainer.register() pour enregistrer le service worker pour ce site. Il s'agit ici d'un fichier JavaScript. Attention, l'URL du fichier ciblé est relative à l'origine de la page et pas à l'URL du fichier JavaScript qui la référence.
  3. Le paramètre scope est optionnel et peut être utilisé afin d'indiquer le sous-ensemble du contenu qu'on veut contrôler avec le service worker. Ici, nous avons indiqué '/', ce qui représente tout le contenu situé sous l'origine de l'application. Si ce paramètre est absent, la valeur par défaut qui sera utilisée sera également '/' (nous l'avons inclus ici à des fins d'explication).

Cela permet d'enregistrer un service worker qui s'exécute dans un contexte de worker et n'a donc pas accès au DOM.

Un seul service worker peut contrôler de nombreuses pages. Chaque fois qu'une page concernée par la portée du service worker est chargée, le service worker est installé pour cette page et s'en occupe. Il faut donc faire attention aux variables globales dans le script d'un service worker, car chaque page ne récupère un exemplaire séparé distinct.

Note : En utilisant la détection de fonctionnalité comme nous l'avons fait plus haut, cela permet aux navigateurs qui ne prennent pas en charge ces fonctionnalités de servir l'application en ligne normalement.

Pourquoi est-ce l'enregistrement de mon service worker échoue ?

Il peut y avoir plusieurs raisons :

  • L'application n'est pas servie via HTTPS.
  • Le chemin vers le fichier du service worker n'est pas écrit correctement : il doit être relatif à l'origine et pas à la racine du répertoire de l'application. Pour notre exemple, le script du worker est situé à https://bncb2v.csb.app/sw.js, et la racine de l'application est https://bncb2v.csb.app/, mais il faut écrire le chemin ainsi : /sw.js.
  • Il est interdit de pointer vers un service worker dont l'origine est différente de celle de l'application.
  • La page concernée ne fait pas partie de la portée du service worker.
  • La portée maximale d'un service worker correspond par défaut à l'emplacement du worker. Il est possible d'indiquer une liste de portées maximales plus larges avec l'en-tête HTTP Service-Worker-Allowed.
  • Pour Firefox, les API Service Worker sont inaccessibles en navigation privée, lorsque l'historique est désactivé ou que les données de navigation (dont les cookies) sont supprimés à la fermeture de Firefox.
  • Pour Chrome, l'enregistrement échoue si l'option « Bloquer tous les cookies (non recommandé) » est activée.

Installation et activation : remplir le cache

Une fois le service worker enregistré, le navigateur essaiera d'installer le service worker sur la page/le site.

L'évènement install est déclenché lorsque l'installation s'est déroulée correctement. Il est généralement utilisé pour remplir les caches qui seront utilisés hors ligne. Pour cela, on utilise l'API de stockage des service worker, cache : un objet global du service worker qui permet de stocker les fichiers fournis par les réponses et de les référencées par des clés formées par les requêtes. Cette API fonctionne de façon semblable au cache standard du navigateur, mais est spécifique au domaine. Le cache persiste jusqu'à nouvel ordre.

Voici comme nous gérons l'évènement install dans notre exemple :

js
const addResourcesToCache = async (resources) => {
  const cache = await caches.open("v1");
  await cache.addAll(resources);
};

self.addEventListener("install", (event) => {
  event.waitUntil(
    addResourcesToCache([
      "/",
      "/index.html",
      "/style.css",
      "/app.js",
      "/image-list.js",
      "/star-wars-logo.jpg",
      "/gallery/bountyHunters.jpg",
      "/gallery/myLittleVader.jpg",
      "/gallery/snowTroopers.jpg",
    ]),
  );
});
  1. On ajoute un gestionnaire d'évènement pour install sur le service worker (représenté par self) et on chaîne un appel à la méthode ExtendableEvent.waitUntil() (en-US) lors de la réception de l'évènement, pour s'assurer que l'installation du service worker ne commencera pas avant que le code contenu dans waitUntil() ait été exécuté.
  2. Dans la fonction addResourcesToCache(), on utilise la méthode caches.open() afin de créer un nouveau cache intitulé v1, qui sera la première version de notre cache pour les ressources de notre site. On appelle ensuite une fonction addAll() sur le cache ainsi créé et qui prend en paramètre un tableau d'URL pour les différentes ressources qu'on souhaite mettre en cache. Les URL sont relatives à l'emplacement du worker.
  3. Si la promesse échoue, l'installation échoue et le service worker ne fera rien. Il est toujours possible de corriger le code et de réessayer lors du prochain enregistrement.
  4. Après une installation réussie, on passe à l'activation du service worker. Lors d'une première installation, cela peut ne pas avoir beaucoup d'intérêt, mais cela s'avèrera utile lors des mises à jour du service worker (voir la section Mettre à jour le service worker ci-après).

Note : L'API Web Storage (localStorage) fonctionne de façon semblable au cache d'un service worker mais est synchrone et son utilisation n'est donc pas autorisée dans les services workers.

Note : Si besoin, l'API IndexedDB peut être utilisée dans un service worker pour stocker des données.

Créer des réponses sur mesure pour les requêtes

Maintenant que les fichiers sont mis en cache, il faut indiquer au service worker quoi faire de ce contenu. Pour cela, on utilise l'évènement fetch.

  1. Un évènement fetch est déclenché à chaque fois qu'une ressource doit être récupérée depuis une page contrôlée par un service worker. Cela inclut les documents situés dans la portée du worker et les ressources référencées depuis ces documents (ainsi, si index.html effectue une requête vers une origine différente pour charger une image, la requête passera quand même par le service worker).
  2. On peut attacher un gestionnaire d'évènement pour fetch au service worker, puis appeler la méthode respondWith() sur l'évènement afin d'intercepter les réponses HTTP et les remplacer par le contenu voulu.
    js
    self.addEventListener("fetch", (event) => {
      event
        .respondWith
        // contenu spécifique
        ();
    });
    
  3. On peut ainsi répondre avec les ressources dont l'URL correspond à la requête interceptée :
    js
    self.addEventListener("fetch", (event) => {
      event.respondWith(caches.match(event.request));
    });
    
    caches.match(event.request) permet de cibler les ressources demandées sur le réseau avec les ressources équivalentes et qui sont disponibles dans le cache (si une telle ressource est disponible). La correspondance est effectuée avec l'URL et différents en-têtes, comme pour une requête HTTP normale.

Diagramme illustrant le rôle de l'évènement fetch

Gérer les requêtes qui échouent

caches.match(event.request) fonctionne à merveille s'il y a une ressource correspondante dans le cache du service worker, mais que se passe-t-il si ce n'est pas le cas ? Si on ne fournit pas de gestion d'erreur, la promesse est résolue avec undefined et rien ne sera renvoyé.

Dans ce cas, on peut tester la réponse du cache et, si besoin, utiliser une requête réseau classique :

js
const cacheFirst = async (request) => {
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }
  return fetch(request);
};

self.addEventListener("fetch", (event) => {
  event.respondWith(cacheFirst(event.request));
});

Ainsi, si les ressources ne sont pas dans le cache, on les récupère depuis le réseau.

Une stratégie plus raffinée serait de mettre en cache les ressources que nous récupérons depuis le réseau afin qu'elles puissent être réutilisées hors ligne par la suite. Dans notre exemple, cela signifie que si nous ajoutons de nouvelles images à la galerie, notre application pourrait automatiquement les récupérer la première fois et les mettre en cache. Voici un fragment de code qui implémente cette méthode :

js
const putInCache = async (request, response) => {
  const cache = await caches.open("v1");
  await cache.put(request, response);
};

const cacheFirst = async (request) => {
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }
  const responseFromNetwork = await fetch(request);
  putInCache(request, responseFromNetwork.clone());
  return responseFromNetwork;
};

self.addEventListener("fetch", (event) => {
  event.respondWith(cacheFirst(event.request));
});

Si la ressource de la requête n'est pas disponible dans le cache, on la demande depuis le réseau avec await fetch(request). Ensuite, on clone la réponse dans le cache. La fonction putInCache() utilise caches.open('v1') et cache.put() afin d'ajouter les ressources au cache. La réponse originale est transmise au navigateur pour la page qui a demandé la ressource.

Cloner la réponse est nécessaire, car les flux de requête et de réponse ne peuvent être lus qu'une seule fois. Afin de fournir la réponse au navigateur et la mettre en cache, il faut la cloner. La version originale est fournie au navigateur et le clone est mis en cache. Chaque réponse est lue une seule fois.

Il peut sembler étrange qu'on n'attende pas le retour de la promesse dans putInCache(). En réalité, on ne veut pas attendre que le clone de la réponse ait été ajouté au cache avant de renvoyer la réponse.

Le problème restant est que si la requête ne correspond à rien en cache et que le réseau n'est pas disponible, la requête échouera. Voyons comment fournir un contenu par défaut dans ce cas-là :

js
const putInCache = async (request, response) => {
  const cache = await caches.open("v1");
  await cache.put(request, response);
};

const cacheFirst = async ({ request, preloadResponsePromise, fallbackUrl }) => {
  // Pour commencer on essaie d'obtenir la ressource depuis le cache
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }

  // Ensuite, on tente de l'obtenir du réseau
  try {
    const responseFromNetwork = await fetch(request);
    // Une réponse ne peut être utilisée qu'une fois
    // On la clone pour en mettre une copie en cache
    // et servir l'originale au navigateur
    putInCache(request, responseFromNetwork.clone());
    return responseFromNetwork;
  } catch (error) {
    const fallbackResponse = await caches.match(fallbackUrl);
    if (fallbackResponse) {
      return fallbackResponse;
    }
    // Quand il n'y a même pas de contenu par défaut associé
    // on doit tout de même renvoyer un objet Response
    return new Response("Une erreur réseau s'est produite", {
      status: 408,
      headers: { "Content-Type": "text/plain" },
    });
  }
};

self.addEventListener("fetch", (event) => {
  event.respondWith(
    cacheFirst({
      request: event.request,
      fallbackUrl: "/gallery/myLittleVader.jpg",
    }),
  );
});

Ici, on utilise l'image comme contenu par défaut, car les seules mises à jour qui risquent d'échouer portent sur les nouvelles images. Tout le reste dépend de la phase d'installation que nous avons vue plus haut.

Préchargement du worker lors de la navigation

S'il est activé, le préchargement à la navigation (en-US) commence le téléchargement des ressources dès que la requête de récupération est émise, en parallèle de l'activation du service worker. Cela permet que le téléchargement démarre immédiatement lors de la navigation vers une page plutôt que d'avoir à d'abord attendre l'activation du service worker. Ce délai se produit rarement mais reste inévitable et, lorsqu'il survient, peut être significatif.

Pour commencer, la fonctionnalité doit être activée lors de l'activation du service worker en utilisant registration.navigationPreload.enable() (en-US) :

js
self.addEventListener("activate", (event) => {
  event.waitUntil(self.registration?.navigationPreload.enable());
});

Ensuie, on utilisera event.preloadResponse (en-US) pour attendre que le téléchargement de la ressource préchargée soit terminée dans le gestionnaire d'évènement fetch.

Reprenons le code des sections précédentes et insérons la gestion du préchargement après la vérification du cache et avant la récupération depuis le réseau.

Voici l'algorithme mis à jour :

  1. On vérifie le cache.
  2. On attend event.preloadResponse, qui est passé sous la forme preloadResponsePromise à la fonction cacheFirst. On met en cache le résultat s'il y en a un.
  3. S'il n'y a toujours aucune ressource récupérée, on tente de la récupérer depuis le réseau.
js
const addResourcesToCache = async (resources) => {
  const cache = await caches.open("v1");
  await cache.addAll(resources);
};

const putInCache = async (request, response) => {
  const cache = await caches.open("v1");
  await cache.put(request, response);
};

const cacheFirst = async ({ request, preloadResponsePromise, fallbackUrl }) => {
  // Pour commencer on essaie d'obtenir la ressource depuis le cache
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }

  // Ensuite, on tente d'utiliser et de mettre en cache
  // la réponse préchargée si elle existe
  const preloadResponse = await preloadResponsePromise;
  if (preloadResponse) {
    console.info("using preload response", preloadResponse);
    putInCache(request, preloadResponse.clone());
    return preloadResponse;
  }

  // Ensuite, on tente de l'obtenir du réseau
  try {
    const responseFromNetwork = await fetch(request);
    // Une réponse ne peut être utilisée qu'une fois
    // On la clone pour en mettre une copie en cache
    // et servir l'originale au navigateur
    putInCache(request, responseFromNetwork.clone());
    return responseFromNetwork;
  } catch (error) {
    const fallbackResponse = await caches.match(fallbackUrl);
    if (fallbackResponse) {
      return fallbackResponse;
    }
    // Quand il n'y a même pas de contenu par défaut associé
    // on doit tout de même renvoyer un objet Response
    return new Response("Network error happened", {
      status: 408,
      headers: { "Content-Type": "text/plain" },
    });
  }
};

// On active le préchargement à la navigation
const enableNavigationPreload = async () => {
  if (self.registration.navigationPreload) {
    await self.registration.navigationPreload.enable();
  }
};

self.addEventListener("activate", (event) => {
  event.waitUntil(enableNavigationPreload());
});

self.addEventListener("install", (event) => {
  event.waitUntil(
    addResourcesToCache([
      "/",
      "/index.html",
      "/style.css",
      "/app.js",
      "/image-list.js",
      "/star-wars-logo.jpg",
      "/gallery/bountyHunters.jpg",
      "/gallery/myLittleVader.jpg",
      "/gallery/snowTroopers.jpg",
    ]),
  );
});

self.addEventListener("fetch", (event) => {
  event.respondWith(
    cacheFirst({
      request: event.request,
      preloadResponsePromise: event.preloadResponse,
      fallbackUrl: "/gallery/myLittleVader.jpg",
    }),
  );
});

On notera dans cet exemple qu'on télécharge et met en cache les mêmes données pour la ressource qu'elle soit téléchargée normalement ou préchargée. On pourrait aussi choisir de télécharger et de mettre en cache une ressource différente lors du préchargement. Pour plus d'informations, voir NavigationPreloadManager > réponses personnalisées (en-US).

Mettre à jour le service worker

Si le service worker a précédemment été installé et qu'une nouvelle version est disponible lors du rafraîchissement ou du chargement de la page, la nouvelle version est installée en arrière-plan, mais n'est pas activée. Elle est uniquement activée lorsqu'il n'y a plus de pages chargées qui utilisent l'ancien service worker. Dès qu'il n'y a plus de page chargée, le nouveau service worker s'active.

Note : Il est possible de contourner ce comportement en utilisant Clients.claim().

Il faut alors mettre à jour le gestionnaire d'évènement pour install dans le nouveau service worker (notez le nouveau numéro de version) :

js
const addResourcesToCache = async (resources) => {
  const cache = await caches.open("v2");
  await cache.addAll(resources);
};

self.addEventListener("install", (event) => {
  event.waitUntil(
    addResourcesToCache([
      "/",
      "/index.html",
      "/style.css",
      "/app.js",
      "/image-list.js",

      // …

      // inclure les nouvelles ressources associées
      // à la nouvelle version…
    ]),
  );
});

Lorsque cette installation se produit, la version précédente est toujours utilisée pour les interceptions/récupérations de ressources. La nouvelle version est installée en arrière-plan. En appelant notre nouveau cache v2, le cache précédent (v1) n'est pas perturbé.

Lorsqu'aucune page n'utilise la version précédente, c'est le nouveau service worker qui est activé et qui devient alors responsable des interceptions/récupérations.

Supprimer les anciens caches

Comme nous l'avons vu dans la section précédente, lorsqu'on met à jour un service worker avec une nouvelle version, on pourra créer un nouveau cache avec le gestionnaire d'évènement install. Tant qu'il y a des pages ouvertes qui sont contrôlées par l'ancienne version, il faut conserver les deux caches, car la version précédente utilise cette version précédente du cache. L'évènement activate peut ensuite être utilisé pour retirer des données des caches précédents.

Les promesses passées à waitUntil() bloqueront les autres évènements tant qu'elles ne seront pas terminées. Cela permet de s'assurer que les étapes de nettoyage auront été réalisées lorsque le premier évènement fetch parviendra au nouveau service worker.

js
const deleteCache = async (key) => {
  await caches.delete(key);
};

const deleteOldCaches = async () => {
  const cacheKeepList = ["v2"];
  const keyList = await caches.keys();
  const cachesToDelete = keyList.filter((key) => !cacheKeepList.includes(key));
  await Promise.all(cachesToDelete.map(deleteCache));
};

self.addEventListener("activate", (event) => {
  event.waitUntil(deleteOldCaches());
});

Outils de développement

  • Chrome
  • Firefox
    • Il est possible de réinitialiser les service workers et leurs caches pour un site en utilisant le menu « Oublier ce site » dans l'historique, le bouton Gérer les données » dans les préférences ou le bouton « Effacer des données » qu'il est possible d'ajouter à la barre d'outils en la personnalisant.
  • Edge

Voir aussi