Skip to content

PWA et Service Worker

English version
Retour au sommaire


Source détaillée : docs/PWA_OFFLINE_ARCHITECTURE.md (documentation exhaustive avec extraits de code).


Architecture multi-caches

L'application utilise trois caches distincts avec des stratégies différentes selon le type de ressource :

Cache Nom actuel Stratégie Contenu
App shell racines-cache-v11 Cache First / Network First HTML, icônes, chunks /_next/static/*.js
Données racines-data-cache-v2 Network First Réponses API, payloads RSC Next.js
Audio racines-audio-cache-v3 Cache First Fichiers .mp3 depuis MinIO CDN

Règle d'incrémentation de version :

CACHE_NAME (app shell)
  ✓ Incrémenter si : nouvelle ressource dans PRECACHE_URLS (logo renommé, nouvelle icône)
  ✗ NE PAS incrémenter si : build JS/CSS normal (content-hashed → auto-miss)
  ✗ NE PAS incrémenter si : modification de la logique du SW (byte-à-byte → détection auto)

AUDIO_CACHE
  ✓ Incrémenter si : changement de la structure d'URL des audios

DATA_CACHE
  ✓ Incrémenter si : changement incompatible de la structure des réponses API

Stratégies de cache par type de ressource

Audio : Cache First

// public/sw.js — détecter les fichiers audio
if (request.url.includes(".mp3") || request.url.includes(".wav") ||
    url.pathname.includes("/audio/") || url.pathname.includes("/audios/")) {

  const cache = await caches.open(AUDIO_CACHE);

  // ⚠️ ignoreVary: true obligatoire
  // Le CDN envoie Vary: Accept-Encoding — sans ignoreVary, le match échoue offline
  const cachedResponse = await cache.match(request, { ignoreVary: true });
  if (cachedResponse) return cachedResponse;  // Cache hit → retour immédiat

  // Cache miss → fetch réseau et stocker sans le header Vary
  const networkResponse = await fetch(request);
  if (networkResponse.ok) {
    const body = await networkResponse.clone().arrayBuffer();
    const cleanResponse = new Response(body, {
      headers: Array.from(networkResponse.headers.entries())
        .filter(([k]) => k.toLowerCase() !== "vary"),  // Strippage Vary
    });
    cache.put(request, cleanResponse);
  }
  return networkResponse;
}

Pourquoi strippage du Vary ?
Le CDN MinIO envoie Vary: Accept-Encoding. Le SW fetch sans Accept-Encoding, mais le navigateur fetche avec. Sans strippage, cache.match() retourne toujours un faux-négatif → audios jamais servis depuis le cache.


if (request.mode === "navigate") {
  try {
    const response = await fetch(request);
    // Mettre à jour le cache à chaque visite
    if (response.ok) cache.put(request, stripVary(response.clone()));
    return response;
  } catch {
    // Hors ligne : chercher dans le cache
    const exactMatch = await cache.match(request, { ignoreVary: true });
    if (exactMatch) return exactMatch;

    // Page jamais visitée → servir l'app shell "/"
    // Le router Next.js côté client gère l'affichage
    const appShell = await cache.match("/", { ignoreVary: true });
    if (appShell) return appShell;

    // Dernier recours : page offline statique
    return offlinePage();
  }
}

RSC Next.js : Network First + ignoreSearch

if (url.searchParams.has("_rsc")) {
  try {
    const response = await fetch(request);
    if (response.ok) dataCache.put(request, response.clone());
    return response;
  } catch {
    // ignoreSearch: true pour tolérancer le hash _rsc variable
    // Chaque build génère un hash différent (?_rsc=abc123 → ?_rsc=def456)
    const cached = await dataCache.match(request, { ignoreSearch: true });
    if (cached) return cached;
    return new Response("", { status: 503 });  // Next.js bascule sur HTML statique
  }
}

Assets Next.js : Stale-While-Revalidate

if (request.url.includes("/_next/")) {
  const cachedResponse = await caches.match(request, { ignoreVary: true });
  // Retourner le cache immédiatement + fetch en arrière-plan
  const fetchPromise = fetch(request).then(r => { if (r.ok) cache.put(request, r.clone()); return r; });
  return cachedResponse || fetchPromise;
}

Installation et activation

Installation : Promise.allSettled() au lieu de addAll()

// public/sw.js
const PRECACHE_URLS = [
  "/", "/manifest.json",
  "/icons/logo-racines.png", "/icons/apple-icon.png",
  "/icons/web-app-manifest-192x192.png", "/icons/web-app-manifest-512x512.png",
  "/icons/defi-mot.svg", "/icons/defi-phrase.svg",
  // ...
];

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(async (cache) => {
      // Promise.allSettled() au lieu de addAll() :
      // → Un 404 sur une icône secondaire ne bloque pas le cache de "/"
      const results = await Promise.allSettled(
        PRECACHE_URLS.map((url) => cache.add(url))
      );
      return self.skipWaiting();
    })
  );
});

Activation : nettoyage des anciens caches

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) =>
      Promise.all(cacheNames.map((name) => {
        if (
          (name.startsWith("racines-cache-")       && name !== CACHE_NAME) ||
          (name.startsWith("racines-audio-cache-") && name !== AUDIO_CACHE) ||
          (name.startsWith("racines-data-cache-")  && name !== DATA_CACHE)
        ) return caches.delete(name);
      }))
    ).then(() => self.clients.claim())
    // claim() : prend le contrôle immédiat sans attendre rechargement
  );
});

Téléchargement offline (src/lib/offline-download.ts)

Les 5 étapes du téléchargement

[0%] ── Vérification connectivité (isOnline())
[8%] ── Étape 1 : API → IndexedDB
         GET /api/languages/[id]/content
         → saveToIndexedDB('languages', [language])
         → saveToIndexedDB('cards', cards)
         → saveToIndexedDB('items', items)

[12%–85%] ── Étape 2 : Téléchargement audios (parallèle, sémaphore MAX_CONCURRENT=3)
              Pour chaque item avec audio_url :
              → cacheAudio(getAudioUrl(item.audio_url))
              → Stockage sans Vary dans AUDIO_CACHE

[85%–90%] ── Étape 2b : Vérification (scan + re-téléchargement des orphelins)
              → Scanne tous les audios dans AUDIO_CACHE
              → Re-télécharge les manquants (faux positifs)

[90%] ── Étape 3 : Nettoyage orphelins (si re-téléchargement)
          → Supprime cartes/items de l'ancienne version non présents dans la nouvelle

[90%–99%] ── Étape 4 : Précachage pages HTML + RSC via MessageChannel
              Pages précachées :
              /{code}, /{code}/words, /{code}/phrases, /{code}/revision,
              /{code}/offline, /contact,
              /{code}/revision/{slug} (par thème),
              /{code}/words/{n} (toutes les cartes mots),
              /{code}/phrases/{n} (toutes les cartes phrases)

[100%] ── Étape 5 : Marquage localStorage
           → localStorage.setItem('downloaded-languages', JSON.stringify([...ids, languageId]))
           ⚠️ Marqué EN DERNIER — garantit que l'app est réellement prête offline

Communication avec le Service Worker (MessageChannel)

// Création d'un canal bidirectionnel
const channel = new MessageChannel();

// Timeouts sophistiqués
const INACTIVITY_MS = 30_000;   // Réinitialisé après chaque heartbeat
const ABSOLUTE_MS   = 120_000;  // Timeout absolu (même avec heartbeats)

channel.port1.onmessage = (event) => {
  if (event.data?.done) {
    // CACHE_PAGES terminé normalement
    clearTimeout(inactivityTimer);
    clearTimeout(absoluteTimer);
    resolve();
  } else {
    // Heartbeat : une page traitée → réinitialiser le timeout glissant
    clearTimeout(inactivityTimer);
    inactivityTimer = setTimeout(resolve, INACTIVITY_MS);
    // Mettre à jour la progression...
  }
};

// Envoyer la commande au SW avec transfert du port2
controller.postMessage(
  { type: 'CACHE_PAGES', urls: urlsToCache },
  [channel.port2]
);

IndexedDB — version 3

Base de données : racines-db
Version courante : 3

Schéma des stores

racines-db (v3)
├── languages  (keyPath: id)
│   └── { id, code, name, primary_color, icon_url, ... }
│
├── cards  (keyPath: id)
│   └── { id, language_id, card_number, type, theme, ... }
│
├── items  (keyPath: id)
│   ├── Index: by_card_id → card_id  (ajouté en v3)
│   └── { id, card_id, audio_url, original_text, translation, ... }
│
└── statistics-queue  (keyPath: id autoIncrement)
    ├── Index: timestamp
    └── { id, timestamp, eventType, languageId, cardId, itemId }
        ↑ File d'attente pour synchronisation offline des stats

Migration v3 — ajout de l'index by_card_id

// src/lib/offline.ts
request.onupgradeneeded = (event) => {
  const db = (event.target as IDBOpenDBRequest).result;
  const oldVersion = event.oldVersion;

  // v3 : index by_card_id sur le store items
  const ensureItemsIndex = (store: IDBObjectStore) => {
    if (!store.indexNames.contains("by_card_id")) {
      store.createIndex("by_card_id", "card_id", { unique: false });
    }
  };

  if (!db.objectStoreNames.contains("items")) {
    ensureItemsIndex(db.createObjectStore("items", { keyPath: "id" }));
  } else if (oldVersion < 3) {
    // Migration depuis v1 ou v2 : ajouter l'index sur le store existant
    ensureItemsIndex(
      (event.target as IDBOpenDBRequest).transaction!.objectStore("items")
    );
  }
};

Tout ajout de store ou d'index doit incrémenter la version et gérer le onupgradeneeded.

Lecture des items d'une carte (optimisée)

// Utilise l'index by_card_id pour une requête O(log n) au lieu de O(n)
const index = store.index("by_card_id");
const req = index.getAll(IDBKeyRange.only(cardId));

Enregistrement du Service Worker (ServiceWorkerRegistration.tsx)

Le composant src/components/ServiceWorkerRegistration.tsx gère :

  1. Synchronisation offline des statistiques — à la reconnexion réseau, vide la file statistics-queue IndexedDB
  2. Enregistrement du SW — uniquement si NEXT_PUBLIC_PWA_ENABLED === 'true'
  3. Détection des mises à jour — affiche une bannière quand un nouveau SW attend
// Piège résolu : en production, window.load a déjà été émis avant useEffect
if (document.readyState === "complete") {
  registerSW();  // Fast path
} else {
  window.addEventListener("load", registerSW);  // Slow path
}

Mise à jour :

const handleUpdate = () => {
  waitingWorker?.postMessage("skipWaiting");  // Activer le nouveau SW
  window.location.reload();                   // Recharger pour utiliser le nouveau cache
};

Le SW est désactivé sur /admin et /speaker (risque de conflits de cache sur ces interfaces à état).


Circuit breaker réseau (src/lib/network-state.ts)

navigator.onLine seul est peu fiable (WiFi connecté mais internet coupé → renvoie true). Le circuit breaker ajoute une logique de détection réelle.

États :
CLOSED ──3 échecs en 60s──▶ OPEN ──probe réussie──▶ HALF_OPEN ──1 requête réussie──▶ CLOSED
           ▲                                                                              │
           └──────────────────────────────────────────────────────────────────────────────

OPEN : Basculé après 3 échecs réseau dans une fenêtre de 60 secondes.

Récupération : - Événement window.online → probe immédiate vers HEAD /api/health/live - Probe périodique avec backoff exponentiel : 30s → 60s → 120s → 300s

HALF_OPEN : La probe a réussi mais la récupération n'est confirmée que par la première vraie requête applicative réussie.

recordFetchFailure() uniquement pour : - Erreurs réseau réelles (timeout, no route to host) - Codes 502, 503, 504 (serveur injoignable)

Ne pas enregistrer : - 400, 404 (erreur applicative, le serveur est joignable) - 500 (erreur serveur, pas d'indisponibilité réseau)


Sémaphore pour les téléchargements audio

Limite le nombre de requêtes réseau simultanées vers MinIO à 3.

// src/lib/offline.ts
const MAX_CONCURRENT = 3;
let _active = 0;
const _queue: Array<() => void> = [];

// Les 3 premières requêtes passent directement
// Les suivantes attendent dans la queue FIFO
function _acquire(): Promise<void> {
  if (_active < MAX_CONCURRENT) { _active++; return Promise.resolve(); }
  return new Promise(resolve => _queue.push(() => { _active++; resolve(); }));
}
function _release(): void {
  _active--;
  _queue.shift()?.();  // Donne le slot au prochain en attente
}

Comportements offline spécifiques

Que fonctionne-t-il offline une fois téléchargé ?

Fonctionnalité Offline ✅
Navigation entre les pages ✅ (app shell + RSC en cache)
Lecture des cartes MOTS ✅ (IndexedDB + audios en cache)
Lecture des cartes PHRASES ✅ (IndexedDB + audios positions 1 et 4)
Révision QCM ✅ (IndexedDB)
Enregistrement des statistiques ✅ (file statistics-queue IndexedDB, sync à la reconnexion)
Upload audio (locuteur) ❌ (nécessite le réseau)
Formulaire de contact ❌ (SMTP nécessite le réseau)
  1. Page exacte en cache (cache.match(request, { ignoreVary: true }))
  2. App shell "/" — le router Next.js client-side gère l'affichage
  3. Page offline statique embarquée dans le SW

localStorage et drapeaux offline

// Langues téléchargées (tableau d'UUIDs)
localStorage.getItem("downloaded-languages")
// Exemple : '["uuid-pul", "uuid-wol"]'

// Dernière langue utilisée
localStorage.getItem("lastLanguageCode")
// Exemple : '"pul"'

Timing critique : downloaded-languages est mis à jour EN DERNIER, après que le précachage des pages est confirmé via MessageChannel. Si l'app est fermée pendant le téléchargement, la langue n'est pas marquée comme disponible offline.


Suppression du contenu offline

// src/lib/offline-download.ts — removeLanguageContent()
// Ordre de suppression (important pour la cohérence) :
// 1. Supprimer les audios du cache audio
// 2. Supprimer items et cartes d'IndexedDB
// 3. Supprimer la langue d'IndexedDB
// 4. Retirer l'UUID de localStorage (en dernier)

// Stratégie en cas d'erreur :
// Si une étape échoue, les suivantes peuvent encore évaluer l'état correctement

Pièges résolus (historique de version)

Version Problème Solution
v1 cache.addAll() atomique → un 404 secondaire abandonnait tout Promise.allSettled() par ressource
Vary: Accept-Encoding CDN → faux-négatifs audio Strippage Vary + ignoreVary: true
v9 RSC Vary: Next-Router-State-Tree → page blanche Strippage Vary + ignoreSearch: true
v10 Chunks /_next/static/*.js manquants → "failed to load chunk" CACHE_PAGES extrait et précache les chunks via regex
v11 Race condition : client attendait un "done" qui ne venait pas MessageChannel avec heartbeat après chaque page
isLanguageDownloaded() = true avant fin du précachage Marquage localStorage EN DERNIER
Audios comptés comme OK mais absents du cache Vérification post-download + re-téléchargement des orphelins
SW controller redondant entre vérification et postMessage() Try-catch silencieux sur postMessage()

Configuration Next.js

// next.config.ts
const nextConfig: NextConfig = {
  reactStrictMode: true,
  output: 'standalone',  // Requis pour Docker

  images: {
    remotePatterns: [
      { protocol: 'http',  hostname: 'minio', port: '9000' },         // Dev local
      { protocol: 'https', hostname: 'racines-s3.id2real.net' },      // Prod MinIO
    ],
  },
};

PWA en production : NEXT_PUBLIC_PWA_ENABLED=true doit être défini. Sans ça, le SW n'est pas enregistré et les fonctionnalités offline ne sont pas disponibles.


Diagnostics

Vérifier le cache audio

// Dans la console du navigateur :
const cache = await caches.open('racines-audio-cache-v3');
const keys = await cache.keys();
console.log(`${keys.length} audios en cache`);

Vérifier IndexedDB

// Ouvrir DevTools → Application → IndexedDB → racines-db
// Vérifier les stores : languages, cards, items, statistics-queue

Forcer la mise à jour du SW

# En dev : modifier sw.js (le navigateur détecte byte-à-byte)
# En production : chaque déploiement CI/CD met à jour sw.js automatiquement
# Le SW affiche la bannière "Nouvelle version disponible" → cliquer "Recharger"

Problèmes courants

Symptôme Cause probable Solution
Bannière "Nouvelle version" ne disparaît pas SW en état "waiting" Cliquer "Recharger" ou vider le cache
Audios muets en offline AUDIO_CACHE version incorrecte Vider le cache et re-télécharger
"failed to load chunk" offline Chunks non précachés Re-télécharger la langue (CACHE_PAGES v10+ règle ça)
Page blanche offline RSC non en cache Naviguer vers la page en ligne d'abord
SW inactif sur /admin Comportement intentionnel Le SW est désactivé sur /admin et /speaker
Téléchargement bloqué à 87% Race condition MessageChannel Timeout de 30s (inactivité) ou 120s (absolu) — continue automatiquement

Étapes suivantes