Consulter la bibliotheque hors-ligne depuis un cache IndexedDB

Le piege que CLAUDE.md signale : en Blazor WebAssembly le code tourne dans le
navigateur alors que SQLite vit sur le serveur, et le service worker ne met en
cache que les assets. Sans travail explicite, l'application demarre hors-ligne
et affiche une bibliotheque vide.

- js/cache-hors-ligne.js : instantanes JSON dans IndexedDB, et surveillance des
  bascules online/offline. Aucune logique metier.
- CacheHorsLigne / EtatReseau : lecture-ecriture des instantanes, et etat reseau
  combinant navigator.onLine (fiable seulement par sa negation) avec le sort
  reel des appels HTTP.
- ServiceLivresApi : les lectures retombent sur le cache, les ecritures sont
  refusees. Le catalogue entier est memorise, pas les reponses filtrees : c'est
  ce qui rend la recherche hors-ligne possible sur tout le fonds.
- FiltreLivresLocal : pendant navigateur de FiltreLivres, avec un test qui
  confronte les deux implementations sur les memes donnees.
- Interface : pastille et bandeau d'etat avec la date de synchronisation, et
  actions d'ecriture desactivees avec leur raison plutot que boutons morts.
- js/mise-a-jour.js : les empreintes WASM etant desactivees, le service worker
  est le seul cache-busting du projet. L'enregistrement journalise desormais ses
  echecs, et un bandeau propose la nouvelle version sans attendre la fermeture
  de tous les onglets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-18 13:59:19 +02:00
co-authored by Claude Opus 5
parent 3a5a3a7763
commit ebc5f95d49
21 changed files with 1420 additions and 82 deletions
+35
View File
@@ -738,3 +738,38 @@ body {
margin: 0;
font-size: 0.95rem;
}
/* --- Mise à jour de l'application --- */
/* Injecté par js/mise-a-jour.js, hors composants Blazor : ce bandeau doit pouvoir s'afficher
même si l'application WebAssembly n'a pas démarré. D'où une règle globale et non scopée.
Il existe parce que les empreintes des assets WASM sont désactivées (CLAUDE.md) : le service
worker est le seul cache-busting du projet, et sa mise à jour attendrait sinon la fermeture
de tous les onglets. */
#mb-maj {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1100;
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: center;
flex-wrap: wrap;
padding: 0.7rem 1rem;
background: #1b3a5c;
color: #fff;
font-size: 0.9rem;
}
#mb-maj button {
min-height: 2.5rem;
padding: 0.4rem 0.9rem;
border: 0;
border-radius: 6px;
background: #f5d76e;
color: #4a3800;
font-weight: 600;
cursor: pointer;
}
+4 -1
View File
@@ -30,7 +30,10 @@
<span class="dismiss">🗙</span>
</div>
<script src="_framework/blazor.webassembly.js"></script>
<script>navigator.serviceWorker.register('service-worker.js', { updateViaCache: 'none' });</script>
<!-- Enregistrement du service worker déporté : il vérifie le support avant d'appeler
navigator.serviceWorker (absent hors contexte sécurisé), journalise un échec au lieu de
l'avaler, et propose la mise à jour quand une nouvelle version est prête. -->
<script src="js/mise-a-jour.js"></script>
</body>
</html>
@@ -0,0 +1,86 @@
// Cache de consultation hors-ligne (CLAUDE.md — « Stratégie hors-ligne »).
//
// Rôle unique de ce fichier : ranger et relire des instantanés JSON dans IndexedDB, et dire à
// C# quand le navigateur bascule en ligne / hors ligne. AUCUNE logique métier ici.
//
// Pourquoi IndexedDB et pas le cache du service worker : c'est la seule option qui permette de
// rechercher et trier hors-ligne sur TOUTE la bibliothèque. Un cache de réponses HTTP ne
// restituerait que les URL déjà visitées — une recherche jamais tapée ne rendrait rien.
//
// Les valeurs sont stockées telles quelles, en chaîne JSON : c'est System.Text.Json côté C# qui
// sérialise et désérialise, donc une seule forme fait autorité et le clone structuré d'IndexedDB
// n'a rien à interpréter.
const NOM_BASE = 'mabibli';
const MAGASIN = 'instantanes';
const VERSION = 1;
let promesseBase = null;
function ouvrir() {
if (promesseBase) return promesseBase;
promesseBase = new Promise((resoudre, rejeter) => {
// IndexedDB peut être absent ou refusé (navigation privée stricte, stockage bloqué).
// On rejette proprement : l'appelant C# retombe alors sur « pas de cache ».
if (!self.indexedDB) { rejeter(new Error('IndexedDB indisponible')); return; }
const requete = indexedDB.open(NOM_BASE, VERSION);
requete.onupgradeneeded = () => {
const base = requete.result;
if (!base.objectStoreNames.contains(MAGASIN)) base.createObjectStore(MAGASIN);
};
requete.onsuccess = () => resoudre(requete.result);
requete.onerror = () => rejeter(requete.error);
requete.onblocked = () => rejeter(new Error('IndexedDB bloqué'));
}).catch(e => { promesseBase = null; throw e; });
return promesseBase;
}
function attendre(requete, transaction) {
return new Promise((resoudre, rejeter) => {
requete.onsuccess = () => resoudre(requete.result);
requete.onerror = () => rejeter(requete.error);
if (transaction) transaction.onabort = () => rejeter(transaction.error);
});
}
// Écrit un instantané. `dateIso` est l'instant de la synchronisation, pas celui de l'écriture :
// c'est cette date que l'interface affiche pour dire de quand datent les données montrées.
export async function ecrire(cle, json, dateIso) {
const base = await ouvrir();
const tx = base.transaction(MAGASIN, 'readwrite');
const requete = tx.objectStore(MAGASIN).put({ json, date: dateIso }, cle);
await attendre(requete, tx);
return true;
}
// Renvoie { json, date } ou null si rien n'a jamais été rangé sous cette clé.
export async function lire(cle) {
const base = await ouvrir();
const tx = base.transaction(MAGASIN, 'readonly');
const valeur = await attendre(tx.objectStore(MAGASIN).get(cle), tx);
return valeur ?? null;
}
export async function vider() {
const base = await ouvrir();
const tx = base.transaction(MAGASIN, 'readwrite');
await attendre(tx.objectStore(MAGASIN).clear(), tx);
return true;
}
// navigator.onLine ne vaut que par sa négation : « false » est fiable (aucune interface réseau),
// « true » ne prouve rien (portail captif, serveur éteint). C# complète donc cet état avec le
// résultat réel de ses appels HTTP — voir EtatReseau.SignalerEchecReseau.
export function enLigne() {
return navigator.onLine !== false;
}
export function surveiller(reference) {
const prevenir = () => reference.invokeMethodAsync('SurChangementReseau', navigator.onLine !== false);
self.addEventListener('online', prevenir);
self.addEventListener('offline', prevenir);
return navigator.onLine !== false;
}
+89
View File
@@ -0,0 +1,89 @@
// Enregistrement du service worker et bandeau de mise à jour.
//
// Pourquoi ce fichier existe (et pourquoi il n'est pas qu'une ligne `register(...)`) :
//
// 1. Les empreintes des assets WASM sont DÉSACTIVÉES (voir CLAUDE.md). `blazor.webassembly.js`
// et `dotnet.js` portent donc des noms stables, et c'est le service worker — lui seul — qui
// empêche de servir éternellement une version périmée. Le mécanisme de Blazor fonctionne,
// mais il est silencieux et différé : le nouveau worker attend que TOUS les onglets de
// l'application soient fermés. Sur mobile, un onglet oublié fige la mise à jour sans que
// personne comprenne pourquoi. D'où un bandeau explicite, avec un bouton qui l'applique.
//
// 2. `navigator.serviceWorker` n'existe pas en contexte non sécurisé (http sur une IP locale).
// L'appeler sans vérification lève une TypeError qui casse le script — et fait croire à un
// défaut de la PWA alors que c'est le contexte qui n'est pas éligible.
(function () {
'use strict';
if (!('serviceWorker' in navigator)) {
// Cas normal en http sur une IP de réseau local : rien à signaler à l'utilisateur,
// l'application fonctionne, elle n'est simplement pas installable ni hors-ligne.
console.info('Service worker indisponible (contexte non sécurisé ou navigateur sans support).');
return;
}
// Le rechargement n'a lieu que si l'utilisateur a cliqué : un changement de contrôleur
// survient aussi à la toute première installation, et recharger la page à ce moment-là
// serait un clignotement inexplicable.
let demandee = false;
function proposerLaMiseAJour(enAttente) {
if (document.getElementById('mb-maj')) return;
const barre = document.createElement('div');
barre.id = 'mb-maj';
barre.setAttribute('role', 'status');
barre.textContent = 'Une nouvelle version de MaBibli est disponible. ';
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.textContent = 'Mettre à jour';
bouton.addEventListener('click', function () {
bouton.disabled = true;
demandee = true;
// Le worker en attente prend la main sans qu'on ait à fermer tous les onglets.
enAttente.postMessage({ type: 'SKIP_WAITING' });
});
barre.appendChild(bouton);
document.body.appendChild(barre);
}
navigator.serviceWorker.register('service-worker.js', { updateViaCache: 'none' })
.then(function (enregistrement) {
// Un worker déjà installé attendait peut-être depuis la visite précédente.
if (enregistrement.waiting && navigator.serviceWorker.controller) {
proposerLaMiseAJour(enregistrement.waiting);
}
enregistrement.addEventListener('updatefound', function () {
const nouveau = enregistrement.installing;
if (!nouveau) return;
nouveau.addEventListener('statechange', function () {
// `controller` non nul = ce n'est pas la première installation, donc il y a
// bien une version précédente à remplacer.
if (nouveau.state === 'installed' && navigator.serviceWorker.controller) {
proposerLaMiseAJour(nouveau);
}
});
});
// Vérification explicite à chaque chargement : ne pas dépendre du seul rythme
// interne du navigateur pour découvrir une version plus récente.
enregistrement.update().catch(function () { /* hors-ligne : sans objet */ });
})
.catch(function (erreur) {
// Ne jamais laisser cet échec passer inaperçu : sans service worker, l'application
// ne démarre pas hors-ligne, et le cache IndexedDB ne sert alors à rien.
console.error("Échec de l'enregistrement du service worker :", erreur);
});
let recharge = false;
navigator.serviceWorker.addEventListener('controllerchange', function () {
if (!demandee || recharge) return;
recharge = true;
window.location.reload();
});
})();
@@ -6,6 +6,16 @@ self.addEventListener('install', event => event.waitUntil(onInstall(event)));
self.addEventListener('activate', event => event.waitUntil(onActivate(event)));
self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
// Mise à jour à la demande. Sans cela, un nouveau worker attend que TOUS les onglets de
// l'application soient fermés — un onglet oublié fige indéfiniment l'utilisateur sur l'ancienne
// version. C'est d'autant plus important ici que les empreintes des assets WASM sont désactivées
// (voir CLAUDE.md) : ce worker est le SEUL mécanisme de cache-busting du projet.
// Le message ne vient que de js/mise-a-jour.js, après un clic explicite : jamais tout seul, pour
// ne pas mélanger deux versions au milieu d'une session.
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
});
const cacheNamePrefix = 'offline-cache-';
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.webmanifest$/ ];