Aller au contenu

PWA and Service Worker

Version française
Back to index


Detailed source: docs/PWA_OFFLINE_ARCHITECTURE.md (exhaustive documentation with code excerpts).


Multi-cache architecture

The application uses three distinct caches with different strategies depending on the resource type:

Cache Current name Strategy Content
App shell racines-cache-v11 Cache First / Network First HTML, icons, chunks /_next/static/*.js
Data racines-data-cache-v2 Network First API responses, Next.js RSC payloads
Audio racines-audio-cache-v3 Cache First .mp3 files from MinIO CDN

Version increment rule:

CACHE_NAME (app shell)
  ✓ Increment if: new resource in PRECACHE_URLS (renamed logo, new icon)
  ✗ DO NOT increment if: normal JS/CSS build (content-hashed → auto-miss)
  ✗ DO NOT increment if: SW logic modification (byte-to-byte → auto-detection)

AUDIO_CACHE
  ✓ Increment if: change in audio URL structure

DATA_CACHE
  ✓ Increment if: incompatible change in API response structure

Cache strategies by resource type

Audio: Cache First

// public/sw.js — detect audio files
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 mandatory
  // The CDN sends Vary: Accept-Encoding — without ignoreVary, the match fails offline
  const cachedResponse = await cache.match(request, { ignoreVary: true });
  if (cachedResponse) return cachedResponse;  // Cache hit → immediate return

  // Cache miss → network fetch and store without the Vary header
  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"),  // Strip Vary
    });
    cache.put(request, cleanResponse);
  }
  return networkResponse;
}

Why strip Vary?
The MinIO CDN sends Vary: Accept-Encoding. The SW fetches without Accept-Encoding, but the browser fetches with it. Without stripping, cache.match() always returns a false negative → audio is never served from cache.


HTML navigation: Network First + SPA Fallback

if (request.mode === "navigate") {
  try {
    const response = await fetch(request);
    // Update cache on every visit
    if (response.ok) cache.put(request, stripVary(response.clone()));
    return response;
  } catch {
    // Offline: look in cache
    const exactMatch = await cache.match(request, { ignoreVary: true });
    if (exactMatch) return exactMatch;

    // Page never visited → serve the app shell "/"
    // The Next.js client-side router handles display
    const appShell = await cache.match("/", { ignoreVary: true });
    if (appShell) return appShell;

    // Last resort: static offline page
    return offlinePage();
  }
}

Next.js RSC: 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 to tolerate the variable _rsc hash
    // Each build generates a different hash (?_rsc=abc123 → ?_rsc=def456)
    const cached = await dataCache.match(request, { ignoreSearch: true });
    if (cached) return cached;
    return new Response("", { status: 503 });  // Next.js falls back to static HTML
  }
}

Next.js assets: Stale-While-Revalidate

if (request.url.includes("/_next/")) {
  const cachedResponse = await caches.match(request, { ignoreVary: true });
  // Return cache immediately + fetch in background
  const fetchPromise = fetch(request).then(r => { if (r.ok) cache.put(request, r.clone()); return r; });
  return cachedResponse || fetchPromise;
}

Installation and activation

Installation: Promise.allSettled() instead of 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() instead of addAll():
      // → A 404 on a secondary icon does not block caching of "/"
      const results = await Promise.allSettled(
        PRECACHE_URLS.map((url) => cache.add(url))
      );
      return self.skipWaiting();
    })
  );
});

Activation: cleanup of old 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(): takes immediate control without waiting for reload
  );
});

Offline download (src/lib/offline-download.ts)

The 5 download steps

[0%] ── Connectivity check (isOnline())
[8%] ── Step 1: API → IndexedDB
         GET /api/languages/[id]/content
         → saveToIndexedDB('languages', [language])
         → saveToIndexedDB('cards', cards)
         → saveToIndexedDB('items', items)

[12%–85%] ── Step 2: Audio download (parallel, semaphore MAX_CONCURRENT=3)
              For each item with audio_url:
              → cacheAudio(getAudioUrl(item.audio_url))
              → Storage without Vary in AUDIO_CACHE

[85%–90%] ── Step 2b: Verification (scan + re-download of orphans)
              → Scans all audio in AUDIO_CACHE
              → Re-downloads missing ones (false positives)

[90%] ── Step 3: Orphan cleanup (if re-download)
          → Deletes cards/items from the old version not present in the new one

[90%–99%] ── Step 4: Pre-cache HTML pages + RSC via MessageChannel
              Pre-cached pages:
              /{code}, /{code}/words, /{code}/phrases, /{code}/revision,
              /{code}/offline, /contact,
              /{code}/revision/{slug} (per theme),
              /{code}/words/{n} (all word cards),
              /{code}/phrases/{n} (all phrase cards)

[100%] ── Step 5: localStorage marking
           → localStorage.setItem('downloaded-languages', JSON.stringify([...ids, languageId]))
           ⚠️ Marked LAST — guarantees the app is truly ready offline

Communication with the Service Worker (MessageChannel)

// Creating a bidirectional channel
const channel = new MessageChannel();

// Sophisticated timeouts
const INACTIVITY_MS = 30_000;   // Reset after each heartbeat
const ABSOLUTE_MS   = 120_000;  // Absolute timeout (even with heartbeats)

channel.port1.onmessage = (event) => {
  if (event.data?.done) {
    // CACHE_PAGES completed normally
    clearTimeout(inactivityTimer);
    clearTimeout(absoluteTimer);
    resolve();
  } else {
    // Heartbeat: one page processed → reset the sliding timeout
    clearTimeout(inactivityTimer);
    inactivityTimer = setTimeout(resolve, INACTIVITY_MS);
    // Update progress...
  }
};

// Send the command to the SW with port2 transfer
controller.postMessage(
  { type: 'CACHE_PAGES', urls: urlsToCache },
  [channel.port2]
);

IndexedDB — version 3

Database: racines-db
Current version: 3

Store schema

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  (added in v3)
│   └── { id, card_id, audio_url, original_text, translation, ... }
│
└── statistics-queue  (keyPath: id autoIncrement)
    ├── Index: timestamp
    └── { id, timestamp, eventType, languageId, cardId, itemId }
        ↑ Queue for offline statistics synchronization

Migration v3 — adding the by_card_id index

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

  // v3: by_card_id index on the items store
  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 from v1 or v2: add the index to the existing store
    ensureItemsIndex(
      (event.target as IDBOpenDBRequest).transaction!.objectStore("items")
    );
  }
};

Any addition of a store or index must increment the version and handle onupgradeneeded.

Reading items for a card (optimized)

// Uses the by_card_id index for an O(log n) query instead of O(n)
const index = store.index("by_card_id");
const req = index.getAll(IDBKeyRange.only(cardId));

Service Worker registration (ServiceWorkerRegistration.tsx)

The src/components/ServiceWorkerRegistration.tsx component handles:

  1. Offline statistics synchronization — on network reconnection, flushes the statistics-queue IndexedDB queue
  2. SW registration — only if NEXT_PUBLIC_PWA_ENABLED === 'true'
  3. Update detection — shows a banner when a new SW is waiting
// Resolved trap: in production, window.load has already fired before useEffect
if (document.readyState === "complete") {
  registerSW();  // Fast path
} else {
  window.addEventListener("load", registerSW);  // Slow path
}

Update:

const handleUpdate = () => {
  waitingWorker?.postMessage("skipWaiting");  // Activate the new SW
  window.location.reload();                   // Reload to use the new cache
};

The SW is disabled on /admin and /speaker (risk of cache conflicts on these stateful interfaces).


Network circuit breaker (src/lib/network-state.ts)

navigator.onLine alone is unreliable (WiFi connected but internet down → returns true). The circuit breaker adds real detection logic.

States:
CLOSED ──3 failures in 60s──▶ OPEN ──probe succeeded──▶ HALF_OPEN ──1 real success──▶ CLOSED
           ▲                                                                              │
           └──────────────────────────────────────────────────────────────────────────────

OPEN: Triggered after 3 network failures within a 60-second window.

Recovery: - window.online event → immediate probe to HEAD /api/health/live - Periodic probe with exponential backoff: 30s → 60s → 120s → 300s

HALF_OPEN: The probe succeeded but recovery is only confirmed by the first successful real application request.

recordFetchFailure() only for: - Real network errors (timeout, no route to host) - Codes 502, 503, 504 (server unreachable)

Do not record: - 400, 404 (application error, server is reachable) - 500 (server error, not a network outage)


Semaphore for audio downloads

Limits the number of simultaneous network requests to MinIO to 3.

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

// The first 3 requests go through directly
// The rest wait in a FIFO queue
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()?.();  // Gives the slot to the next in line
}

Specific offline behaviors

What works offline after download?

Feature Offline ✅
Navigation between pages ✅ (app shell + RSC in cache)
Reading WORD cards ✅ (IndexedDB + audio in cache)
Reading PHRASE cards ✅ (IndexedDB + audio positions 1 and 4)
MCQ Revision ✅ (IndexedDB)
Statistics recording ✅ (statistics-queue IndexedDB, sync on reconnection)
Audio upload (speaker) ❌ (requires network)
Contact form ❌ (SMTP requires network)

Offline navigation: fallback order

  1. Exact page in cache (cache.match(request, { ignoreVary: true }))
  2. App shell "/" — the Next.js client-side router handles display
  3. Static offline page embedded in the SW

localStorage and offline flags

// Downloaded languages (array of UUIDs)
localStorage.getItem("downloaded-languages")
// Example: '["uuid-pul", "uuid-wol"]'

// Last used language
localStorage.getItem("lastLanguageCode")
// Example: '"pul"'

Critical timing: downloaded-languages is updated LAST, after page pre-caching is confirmed via MessageChannel. If the app is closed during download, the language is not marked as available offline.


Removing offline content

// src/lib/offline-download.ts — removeLanguageContent()
// Deletion order (important for consistency):
// 1. Delete audio from the audio cache
// 2. Delete items and cards from IndexedDB
// 3. Delete the language from IndexedDB
// 4. Remove the UUID from localStorage (last)

// Error strategy:
// If a step fails, subsequent steps can still correctly assess the state

Resolved issues (version history)

Version Problem Solution
v1 cache.addAll() atomic → a secondary 404 aborted everything Promise.allSettled() per resource
Vary: Accept-Encoding CDN → audio false negatives Vary stripping + ignoreVary: true
v9 RSC Vary: Next-Router-State-Tree → blank page Vary stripping + ignoreSearch: true
v10 Missing /_next/static/*.js chunks → "failed to load chunk" CACHE_PAGES extracts and pre-caches chunks via regex
v11 Race condition: client was waiting for a "done" that never came MessageChannel with heartbeat after each page
isLanguageDownloaded() = true before pre-caching completed localStorage marking LAST
Audio counted as OK but absent from cache Post-download verification + re-download of orphans
Redundant SW controller between check and postMessage() Silent try-catch on postMessage()

Next.js configuration

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

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

PWA in production: NEXT_PUBLIC_PWA_ENABLED=true must be set. Without it, the SW is not registered and offline features are not available.


Diagnostics

Check the audio cache

// In the browser console:
const cache = await caches.open('racines-audio-cache-v3');
const keys = await cache.keys();
console.log(`${keys.length} audio files in cache`);

Check IndexedDB

// Open DevTools → Application → IndexedDB → racines-db
// Check stores: languages, cards, items, statistics-queue

Force SW update

# In dev: modify sw.js (browser detects byte-by-byte)
# In production: each CI/CD deployment updates sw.js automatically
# The SW shows the "New version available" banner → click "Reload"

Common issues

Symptom Likely cause Solution
"New version" banner doesn't disappear SW in "waiting" state Click "Reload" or clear cache
Silent audio offline Incorrect AUDIO_CACHE version Clear cache and re-download
"failed to load chunk" offline Chunks not pre-cached Re-download the language (CACHE_PAGES v10+ fixes this)
Blank page offline RSC not in cache Navigate to the page online first
SW inactive on /admin Intentional behavior SW is disabled on /admin and /speaker
Download stuck at 87% MessageChannel race condition 30s (inactivity) or 120s (absolute) timeout — continues automatically

Next steps