Mode hors ligne
Le mode hors ligne repose sur le Service Worker Blazor/Lagoon et la mise en cache des ressources nécessaires à l'application.
Assurez-vous de comprendre les impacts de cache avant de publier une application avec support hors ligne. Une mauvaise URL racine ou un cache obsolète peut empêcher la mise à jour sans vider le cache navigateur.
Périmètre : ce que Lagoon fait, ce que vous devez faire
Activer le Service Worker rend l'application exécutable sans connexion : Lagoon met en cache la coquille applicative (HTML, WebAssembly, JavaScript, CSS, assets) et permet à l'utilisateur authentifié de rester connecté hors ligne. Cela ne rend pas pour autant vos fonctionnalités métier utilisables hors ligne.
Le tableau ci-dessous délimite les responsabilités.
| Pris en charge par Lagoon | À la charge du développeur |
|---|---|
| Mise en cache de l'application (coquille + assets) via le Service Worker, pour qu'elle se charge sans réseau. | Rendre les données disponibles hors ligne (lecture depuis un cache local). |
| Maintien de la session authentifiée hors ligne (les claims de l'utilisateur sont relus depuis le navigateur). | Mémoriser localement les saisies et modifications effectuées sans réseau. |
Signalement d'un appel HTTP qui échoue faute de réseau, via l'exception LgHttpFetchException. | Détecter cet échec et basculer sur un fonctionnement dégradé / local. |
| — | Synchroniser les données locales avec le backend au retour de la connexion (rejeu des actions en attente). |
Un LgGridView lié à un contrôleur, un LgEditForm qui poste vers une API, un appel HttpClient… échouent tous dès que le réseau est coupé. Si une page doit fonctionner hors ligne, vous devez explicitement concevoir sa persistance locale et sa synchronisation. Rien de tout cela n'est automatique.
Le mécanisme de détection est simple et déterministe : tout appel HTTP passant par le pipeline Lagoon lève LgHttpFetchException (namespace Lagoon.UI.Components) lorsque la requête échoue faute de réseau (le Service Worker renvoie alors un code 418). Il suffit donc de capturer cette exception pour savoir que l'on est hors ligne et réagir en conséquence. La section dédiée plus bas en donne un exemple complet.
Le module Notifications (Lagoon.UI.Notifications) applique exactement ce schéma : appel serveur, capture de LgHttpFetchException, stockage des notifications et d'une file d'« actions en attente » dans IndexedDB, puis rejeu au retour de la connexion. C'est une bonne base à étudier avant d'implémenter votre propre couche hors ligne.
index.html et chemin racine
Le chemin racine de l'application est défini par la balise base dans wwwroot/index.html.
<base href="/" />
Modifiez cette valeur avant la publication si l'application est déployée sous un sous-chemin. Une valeur de hash est calculée lors de la publication pour valider l'intégrité du fichier.
Activer le Service Worker
Ajoutez ou modifiez la propriété ServiceWorkerEnabled dans le fichier .csproj du projet Client.
| Valeur | Effet |
|---|---|
false | Le Service Worker n'est pas activé. |
true | Le Service Worker est activé dans toutes les configurations. Cette valeur est déconseillée. |
| Nom de configuration | Le Service Worker est activé uniquement pour cette configuration. |
La valeur recommandée est Release, afin d'éviter les problèmes de cache en Debug.
<PropertyGroup>
<Version>1.0.0</Version>
<ServiceWorkerEnabled>Release</ServiceWorkerEnabled>
</PropertyGroup>
Dans les coulisses
Lorsque ServiceWorkerEnabled est activé, Lagoon ajoute automatiquement l'élément ServiceWorker et génère service-worker.published.js. Ne les ajoutez pas manuellement au projet.
<ItemGroup>
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="obj\...\service-worker.published.js" />
</ItemGroup>
Lagoon définit aussi les propriétés de build suivantes.
<PropertyGroup>
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
</PropertyGroup>
Configuration avancée des assets hors ligne
Le serveur peut ajouter ou supprimer des ressources de la liste mise en cache pendant la génération des assets hors ligne. Surchargez OnLoadOfflineAssets dans le Main.cs du projet Server.
public class Main : LgAuthApplication<ApplicationDbContext, ApplicationUser>
{
protected override void OnLoadOfflineAssets(OfflineAssetManager assets)
{
// Lagoon inclut déjà les extensions courantes : .css, .dll, .html, .js, .json, .png, .svg, .wasm, etc.
assets.IncludeExtensions(".txt");
// Exclut un fichier précis du cache offline.
assets.Exclude("data/Readme.txt");
}
}
Personnaliser le Service Worker
Vous pouvez ajouter un fichier wwwroot/service-worker-setup.js pour injecter du code spécifique dans le Service Worker.
self.addEventListener("message", event => {
// Ajoutez ici les traitements spécifiques au Service Worker de l'application.
});
Concevoir une fonctionnalité compatible hors ligne
Cette section illustre, sur un cas simple, une approche offline-first : l'application travaille toujours sur un cache navigateur local (IndexedDB), sans jamais contacter le serveur pendant l'utilisation courante. La synchronisation avec le backend n'a lieu que sur demande explicite de l'utilisateur, via un bouton dédié. L'exemple gère une liste d'interventions : consultation, création et suppression fonctionnent à l'identique avec ou sans réseau.
L'intérêt de cette approche : l'expérience utilisateur est uniforme (aucune latence réseau, aucun comportement différent selon la connectivité) et la gestion du hors ligne n'est plus un cas particulier mais le fonctionnement nominal.
Le principe repose sur trois responsabilités, toutes à la charge de l'application :
- Lire et écrire uniquement dans le cache local IndexedDB ; le serveur n'est jamais sollicité pendant l'usage.
- Tracer chaque modification locale (création, mise à jour, suppression) dans une file d'actions en attente.
- Synchroniser sur action explicite : pousser les actions en attente vers le serveur, puis récupérer l'état serveur à jour.
1. Le magasin local IndexedDB (JavaScript)
HttpClient et le Service Worker ne stockent pas vos données métier. Vous devez les persister vous-même. IndexedDB est le choix adapté pour des volumes structurés ; on y accède par JsInterop. Le script ci-dessous expose un petit magasin (un object store de données + une file d'actions en attente) avec des opérations CRUD purement locales.
Placez ce fichier dans Client/resources/main-js/. Tous les scripts de ce dossier sont automatiquement concaténés et minifiés dans main.min.js, chargé au démarrage de l'application. Le magasin est donc disponible globalement (sans ScriptIncludeAsync) et embarqué d'office dans les assets hors ligne.
window.MyApp = window.MyApp || {};
MyApp.OfflineStore = (function () {
const dataStore = "data";
const queueStore = "pendingActions";
function openDb(dbName) {
return new Promise((resolve, reject) => {
const request = window.indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore(dataStore, { keyPath: "id" });
db.createObjectStore(queueStore, { autoIncrement: true });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
return {
// Read the full local data set (the only source used during normal use).
getAll: async function (dbName) {
const db = await openDb(dbName);
return new Promise((resolve, reject) => {
const request = db.transaction(dataStore, "readonly")
.objectStore(dataStore).getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},
// Create or update a single item locally.
put: async function (dbName, item) {
const db = await openDb(dbName);
db.transaction(dataStore, "readwrite").objectStore(dataStore).put(item);
},
// Delete a single item locally.
remove: async function (dbName, id) {
const db = await openDb(dbName);
db.transaction(dataStore, "readwrite").objectStore(dataStore).delete(id);
},
// Replace the whole local cache with the server state (used after a pull).
replaceAll: async function (dbName, items) {
const db = await openDb(dbName);
const store = db.transaction(dataStore, "readwrite").objectStore(dataStore);
store.clear();
items.forEach(item => store.put(item));
},
// Track a local mutation so it can be pushed on the next sync.
enqueue: async function (dbName, action) {
const db = await openDb(dbName);
db.transaction(queueStore, "readwrite").objectStore(queueStore).add(action);
},
// Read every pending mutation.
getQueue: async function (dbName) {
const db = await openDb(dbName);
return new Promise((resolve, reject) => {
const request = db.transaction(queueStore, "readonly")
.objectStore(queueStore).getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},
// Empty the queue once it has been pushed to the server.
clearQueue: async function (dbName) {
const db = await openDb(dbName);
db.transaction(queueStore, "readwrite").objectStore(queueStore).clear();
}
};
})();
2. Le service client (C#)
Le service applicatif lit et écrit uniquement dans le magasin local via JsInterop (IJSRuntime, l'approche par défaut Lagoon). Le serveur n'est sollicité que dans SynchronizeAsync, appelé sur demande explicite. Le script étant embarqué dans main.min.js, aucun ScriptIncludeAsync n'est nécessaire.
namespace MyApp.Client.Services;
public class InterventionStore
{
private const string DbName = "myapp-offline";
private readonly HttpClient _http;
private readonly IJSRuntime _js;
public InterventionStore(HttpClient http, IJSRuntime js)
{
_http = http;
_js = js;
}
/// <summary>
/// Read the interventions from the local cache. The server is never called here.
/// </summary>
/// <returns>The list of interventions stored locally.</returns>
public async Task<List<InterventionVm>> GetInterventionsAsync()
{
return await _js.InvokeAsync<List<InterventionVm>>("MyApp.OfflineStore.getAll", DbName);
}
/// <summary>
/// Create or update an intervention locally and record the change for the next sync.
/// </summary>
/// <param name="intervention">The intervention to save.</param>
public async Task SaveInterventionAsync(InterventionVm intervention)
{
await _js.InvokeVoidAsync("MyApp.OfflineStore.put", DbName, intervention);
await _js.InvokeVoidAsync("MyApp.OfflineStore.enqueue", DbName,
new PendingAction { Op = "upsert", Item = intervention });
}
/// <summary>
/// Delete an intervention locally and record the change for the next sync.
/// </summary>
/// <param name="intervention">The intervention to delete.</param>
public async Task DeleteInterventionAsync(InterventionVm intervention)
{
await _js.InvokeVoidAsync("MyApp.OfflineStore.remove", DbName, intervention.Id);
await _js.InvokeVoidAsync("MyApp.OfflineStore.enqueue", DbName,
new PendingAction { Op = "delete", Item = intervention });
}
/// <summary>
/// Push the pending local changes to the server, then refresh the local cache.
/// </summary>
public async Task SynchronizeAsync()
{
// 1. Push every local change to the backend.
List<PendingAction> queue = await _js.InvokeAsync<List<PendingAction>>("MyApp.OfflineStore.getQueue", DbName);
foreach (PendingAction action in queue)
{
if (action.Op == "delete")
{
await _http.TryDeleteAsync($"intervention/{action.Item.Id}");
}
else
{
await _http.TryPostAsync("intervention", action.Item);
}
}
await _js.InvokeVoidAsync("MyApp.OfflineStore.clearQueue", DbName);
// 2. Pull the up-to-date server state and replace the local cache.
List<InterventionVm> items = await _http.TryGetAsync<List<InterventionVm>>("intervention");
await _js.InvokeVoidAsync("MyApp.OfflineStore.replaceAll", DbName, items);
}
}
public class PendingAction
{
public string Op { get; set; }
public InterventionVm Item { get; set; }
}
Enregistrez le service auprès du conteneur d'injection, comme les autres services client.
public static class Registry
{
public static void RegisterServices(IServiceCollection services)
{
services.AddScoped<InterventionStore>();
}
}
3. Le bouton de synchronisation
Pendant l'utilisation, la page travaille exclusivement sur le cache local : aucune latence, aucun comportement conditionné par la connectivité. La synchronisation est déclenchée par un bouton explicite. Lagoon n'expose pas d'événement « retour en ligne » ; un éventuel échec réseau au moment de la synchro se détecte en capturant LgHttpFetchException.
@inherits LgPage
<LgButton Text="#Synchronize"
IconName="@IconNames.All.ArrowRepeat"
OnClick="SynchronizeAsync" />
[Inject]
private InterventionStore Store { get; set; }
private List<InterventionVm> _interventions = new();
protected override async Task OnInitializedAsync()
{
// Always read from the local cache — no server call.
_interventions = await Store.GetInterventionsAsync();
}
private async Task SynchronizeAsync()
{
try
{
await Store.SynchronizeAsync();
_interventions = await Store.GetInterventionsAsync();
ShowSuccess("#SyncDone");
}
catch (LgHttpFetchException)
{
// No network: local changes stay queued for a later attempt.
ShowWarning("#SyncOffline");
}
catch (Exception ex)
{
ShowException(ex);
}
}
L'offline-first n'est pas une option que l'on « active ». C'est une architecture : le cache navigateur (IndexedDB) est la source de vérité pendant l'usage, et la synchronisation est une opération à part entière, avec sa propre stratégie (ordre de rejeu, résolution des conflits, idempotence côté serveur pour tolérer un rejeu partiel). Dimensionnez cet effort dès la conception des fonctionnalités qui doivent fonctionner sans réseau.