PWA et Service Worker
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.
Navigation HTML : Network First + SPA Fallback
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 :
- Synchronisation offline des statistiques — à la reconnexion réseau, vide la file
statistics-queueIndexedDB - Enregistrement du SW — uniquement si
NEXT_PUBLIC_PWA_ENABLED === 'true' - 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) |
Navigation offline : ordre de fallback
- Page exacte en cache (
cache.match(request, { ignoreVary: true })) - App shell
"/"— le router Next.js client-side gère l'affichage - 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 |