fix(pwa): BUG-005 chargement mobile via Cloudflare (SW network-first + no-cache)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m53s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m53s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
This commit is contained in:
@@ -38,7 +38,7 @@ jobs:
|
||||
- name: Frontend unit tests
|
||||
run: node tests/frontend/unit.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI)
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW)
|
||||
run: |
|
||||
cd tests/frontend
|
||||
if [ -d node_modules ]; then
|
||||
@@ -46,6 +46,7 @@ jobs:
|
||||
node excalidraw-viewer.test.mjs
|
||||
node plugins.test.mjs
|
||||
node ai.test.mjs
|
||||
node sw.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing — installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -53,6 +54,7 @@ jobs:
|
||||
node excalidraw-viewer.test.mjs
|
||||
node plugins.test.mjs
|
||||
node ai.test.mjs
|
||||
node sw.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,6 +61,19 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-005 — Chargement mobile incomplet derrière Cloudflare (`og.dracodev.net`)** —
|
||||
le service worker utilisait une stratégie **cache-first avec un nom de cache fixe**
|
||||
(`obsigate-v1`) et un précache de chemins erronés (`/frontend/js/…`) : le Cache Storage
|
||||
du SW (distinct du cache navigateur/Cloudflare) servait un ancien build indéfiniment,
|
||||
même après purge. Le middleware renvoyait en plus `Cache-Control: public, max-age=31536000,
|
||||
immutable` sur des assets **non fingerprintés**. Correctifs : `frontend/sw.js` réécrit
|
||||
(**network-first** pour HTML/JS/CSS, caches versionnés `SW_VERSION`, précache corrigé
|
||||
`/static/js/…`, `skipWaiting`/`clients.claim`) ; migration ponctuelle `localStorage`
|
||||
(suppression des anciens caches) en remplacement du kill-switch de session ; en-têtes
|
||||
`no-cache` sur `/static`, `index.html`, `manifest.json` et pages HTML ; rechargement unique
|
||||
sur `controllerchange`. Tests : `tests/frontend/sw.test.mjs` + `TestStaticCaching`.
|
||||
Guide Cloudflare : [docs/PWA_GUIDE.md](./docs/PWA_GUIDE.md).
|
||||
|
||||
- **Typage backend (mypy) — 33 erreurs corrigées** (`backend/main.py`, `indexer.py`,
|
||||
`auth/router.py`, `pdf_reader.py`, `export.py`, `bookslm_routes.py`) : annotations de types,
|
||||
gardes `None` sur `get_user()`, `PdfReader: Any` et import `PROVIDERS` manquant dans `main.py`
|
||||
|
||||
+11
-7
@@ -628,9 +628,12 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self' blob:;"
|
||||
)
|
||||
# Cache static assets aggressively (fingerprinted by PWA manifest hash)
|
||||
# Static assets are NOT content-hashed, so they must revalidate:
|
||||
# ``immutable``/long max-age made Cloudflare and mobile browsers serve
|
||||
# a stale build for a year (the service worker cache compounded it).
|
||||
# ``no-cache`` keeps caching but forces revalidation (ETag/Last-Modified).
|
||||
if request.url.path.startswith("/static/"):
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
@@ -4904,6 +4907,7 @@ async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(requ
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if FRONTEND_DIR.exists():
|
||||
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
|
||||
@app.get("/sw.js")
|
||||
@@ -4929,7 +4933,7 @@ if FRONTEND_DIR.exists():
|
||||
return FileResponse(
|
||||
manifest_file,
|
||||
media_type="application/manifest+json",
|
||||
headers={"Cache-Control": "public, max-age=3600"}
|
||||
headers={"Cache-Control": "no-cache"}
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Manifest not found")
|
||||
|
||||
@@ -4938,7 +4942,7 @@ if FRONTEND_DIR.exists():
|
||||
"""Serve the minimalist popout page for a specific file."""
|
||||
popout_file = FRONTEND_DIR / "popout.html"
|
||||
if popout_file.exists():
|
||||
return HTMLResponse(content=popout_file.read_text(encoding="utf-8"))
|
||||
return HTMLResponse(content=popout_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Popout template not found")
|
||||
|
||||
@app.get("/editor-poc")
|
||||
@@ -4946,7 +4950,7 @@ if FRONTEND_DIR.exists():
|
||||
"""Serve the standalone Editor POC page (multi-zone toolbar demo)."""
|
||||
poc_file = FRONTEND_DIR / "editor-poc.html"
|
||||
if poc_file.exists():
|
||||
return HTMLResponse(content=poc_file.read_text(encoding="utf-8"))
|
||||
return HTMLResponse(content=poc_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Editor POC not found")
|
||||
|
||||
@app.get("/admin.html", response_class=HTMLResponse)
|
||||
@@ -4959,7 +4963,7 @@ if FRONTEND_DIR.exists():
|
||||
"""
|
||||
admin_file = FRONTEND_DIR / "admin.html"
|
||||
if admin_file.exists():
|
||||
return HTMLResponse(content=admin_file.read_text(encoding="utf-8"))
|
||||
return HTMLResponse(content=admin_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Admin page not found")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
@@ -4967,5 +4971,5 @@ if FRONTEND_DIR.exists():
|
||||
"""Serve the SPA index.html for all non-API routes."""
|
||||
index_file = FRONTEND_DIR / "index.html"
|
||||
if index_file.exists():
|
||||
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
|
||||
return HTMLResponse(content=index_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Frontend not found")
|
||||
|
||||
@@ -114,6 +114,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| | | | | | | | | | |
|
||||
| *BUG-003* | `mypy` : 33 erreurs de typage (étape CI en mode advisory → bloquante) | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/{main,indexer,export,pdf_reader,bookslm_routes}.py`, `backend/auth/router.py`, `.gitea/workflows/ci.yml` | `mypy backend/ --ignore-missing-imports` | Annotations de types, gardes `None` (auth/router), import `PROVIDERS` manquant (bug latent `main.py:4523`), `PdfReader: Any` ; CI mypy rendu bloquant | 0 erreur après correction. `PROVIDERS` non importé → `NameError` avalé par le `except` (le modèle par défaut n'était jamais prépendé). Tests : 728 passed |
|
||||
| *BUG-004* | Lien cassé vers `CONTRIBUTING.md` dans `README.md` | 🟢 corrigé | P3 | 📄 docs | IA | `README.md` | Cliquer le lien « Contributing » | `README.md` : lien → `docs/CONTRIBUTING.md` (+ `DELIVERY_WORKFLOW.md`) ; arbre du projet corrigé | `README.fr.md` pointait déjà correctement vers `./docs/CONTRIBUTING.md` |
|
||||
| *BUG-005* | Sur mobile via Cloudflare (`og.dracodev.net`), le site ne charge pas complètement même après purge des caches (téléphone + Cloudflare) | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/sw.js`, `frontend/index.html`, `frontend/js/sync.js`, `backend/main.py` | Ouvrir `og.dracodev.net` sur mobile (PWA installée), purger les caches, recharger | SW **network-first** pour le code + caches versionnés (`SW_VERSION`) ; précache corrigé (`/static/js/…`) ; migration ponctuelle `localStorage` ; `Cache-Control: no-cache` (le middleware renvoyait `immutable` 1 an sur `/static`) ; tests `tests/frontend/sw.test.mjs` + `TestStaticCaching` | Cause : SW cache-first à nom fixe + en-têtes `immutable` non fingerprinted → ancien build servi indéfiniment (Cache Storage ≠ cache navigateur/CF). Vérifié : 754 tests backend, frontend OK |
|
||||
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
@@ -135,6 +136,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) |
|
||||
| 2026-09-09 | BUG-001, BUG-002 | Correction | `backend/main.py`, `frontend/excalidraw-editor.html`, `tests/test_pdf_stream.py` | BUG-001: Content-Disposition RFC 5987 (nom PDF accentué ne casse plus l'en-tête → plus de 500). BUG-002: suppression alias esm.sh (408 jotai) + React 19 cohérent + prop `excalidrawAPI` → Loading masqué, save OK. Vérifié: 534 tests backend verts + E2E navigateur. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-11 | BUG-003, BUG-004 | Correction | `backend/{main,indexer,export,pdf_reader,bookslm_routes}.py`, `backend/auth/router.py`, `.gitea/workflows/ci.yml`, `README.md`, `README.fr.md` | BUG-003: 33 erreurs mypy corrigées (annotations, gardes `None`, import `PROVIDERS` manquant → bug latent) + étape CI mypy rendue bloquante. BUG-004: lien `README.md` → `docs/CONTRIBUTING.md`. Vérifié: mypy 0 erreur, ruff OK, pytest 728 passed, frontend OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-11 | BUG-005 | Correction | `frontend/sw.js`, `frontend/index.html`, `frontend/js/sync.js`, `backend/main.py`, `.gitea/workflows/ci.yml`, `tests/frontend/sw.test.mjs`, `tests/test_api_main.py` | BUG-005: chargement mobile incomplet via Cloudflare. SW réécrit en **network-first** pour HTML/JS/CSS + caches versionnés ; précache corrigé (`/static/js/…`) ; kill-switch de session remplacé par une migration `localStorage` ponctuelle ; en-têtes `/static` passés de `immutable 1 an` à `no-cache` ; `index.html`/`manifest`/SPA en `no-cache` ; reload unique sur `controllerchange`. Vérifié: 754 tests backend, frontend 9+28+8 OK, ruff/mypy OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+47
-6
@@ -94,11 +94,14 @@ Définit les métadonnées de l'application :
|
||||
|
||||
### Service Worker (sw.js)
|
||||
|
||||
Gère le cache et le mode hors ligne :
|
||||
- **Cache statique** : Interface et assets
|
||||
- **Cache dynamique** : Données API (max 50 entrées)
|
||||
- **Stratégies** : Cache-first et Network-first
|
||||
- **Nettoyage** : Suppression automatique des anciens caches
|
||||
Gère le cache et le mode hors ligne (version `SW_VERSION`) :
|
||||
- **Code (HTML/JS/CSS/manifest)** : **network-first** (cache en secours hors-ligne)
|
||||
- **API** : network-first (+ cache hors-ligne)
|
||||
- **Autres assets (images, polices)** : stale-while-revalidate
|
||||
- **Nettoyage** : purge automatique des caches d'une version antérieure à l'activation
|
||||
|
||||
> Le choix network-first est délibéré : les assets ne sont pas fingerprintés, un
|
||||
> cache-first servait donc un ancien build indéfiniment sur mobile (BUG-005).
|
||||
|
||||
### Icônes PWA
|
||||
|
||||
@@ -271,7 +274,13 @@ location /sw.js {
|
||||
}
|
||||
|
||||
location /manifest.json {
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
add_header Cache-Control "no-cache";
|
||||
proxy_pass http://obsigate:8080;
|
||||
}
|
||||
|
||||
# Les assets /static ne sont PAS fingerprintés : forcer la revalidation.
|
||||
location /static/ {
|
||||
add_header Cache-Control "no-cache";
|
||||
proxy_pass http://obsigate:8080;
|
||||
}
|
||||
```
|
||||
@@ -287,6 +296,38 @@ http:
|
||||
Service-Worker-Allowed: "/"
|
||||
```
|
||||
|
||||
### Cloudflare (BUG-005)
|
||||
|
||||
Derrière Cloudflare, un **service worker cache-first** combiné à un en-tête
|
||||
`Cache-Control: immutable` sur des assets non fingerprintés peut servir un
|
||||
ancien build indéfiniment — même après « Purge Everything » : le **Cache Storage
|
||||
du navigateur (Service Worker) est un stockage séparé** que la purge Cloudflare
|
||||
et le « vider le cache » du téléphone ne touchent pas.
|
||||
|
||||
Correctifs appliqués côté code (ObsiGate ≥ 2.2.1) :
|
||||
|
||||
- SW **network-first** pour HTML/JS/CSS (cache seulement en secours hors-ligne),
|
||||
caches versionnés (`SW_VERSION`) purgés à l'activation.
|
||||
- `Cache-Control: no-cache` sur `/static`, `index.html`, `manifest.json` et les
|
||||
pages HTML (revalidation ETag, pas de cache long).
|
||||
- Migration ponctuelle `localStorage` qui supprime les anciens caches sur les
|
||||
appareils bloqués.
|
||||
|
||||
Réglages Cloudflare recommandés (dashboard → zone `og.dracodev.net`) :
|
||||
|
||||
| Réglage | Valeur | Pourquoi |
|
||||
|---|---|---|
|
||||
| **Rocket Loader** | **Désactivé** | Réécrit/defer les scripts et casse les `type="module"`. |
|
||||
| **Auto Minify (JS/CSS)** | **Désactivé** | Peut casser la syntaxe JS moderne. |
|
||||
| **Cache Rules / Page Rules** | Pas de « Cache Everything » sur HTML | Sinon Cloudflare ignore l'en-tête `no-cache`. |
|
||||
| **Browser Cache TTL** | « Respect Existing Headers » | Laisse ObsiGate décider (`no-cache`). |
|
||||
| **WebSockets** | Activé | Nécessaire aux connexions temps réel (SSE/WS). |
|
||||
| **Purge Cache** | Après chaque déploiement | Évite un mix ancien/nouveau au premier accès. |
|
||||
|
||||
> Si un appareil reste bloqué malgré tout : sur mobile, désinstaller la PWA
|
||||
> (ou « Site settings → Clear & reset ») puis rouvrir. La migration
|
||||
> `obsigate-sw-migration` se charge normalement du nettoyage automatiquement.
|
||||
|
||||
## 🔒 Sécurité
|
||||
|
||||
### Content Security Policy
|
||||
|
||||
+21
-2
@@ -5067,8 +5067,27 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Force-kill stale Service Worker and reload fresh
|
||||
(function(){if('serviceWorker'in navigator&&navigator.serviceWorker.controller){var reloaded=sessionStorage.getItem('_sw_killed');if(!reloaded){navigator.serviceWorker.getRegistrations().then(function(regs){regs.forEach(function(r){r.unregister()})});caches.keys().then(function(names){names.forEach(function(n){caches.delete(n)})});sessionStorage.setItem('_sw_killed','1');location.reload(true)}}else{sessionStorage.removeItem('_sw_killed')}})();
|
||||
// One-time migration for devices stuck on the legacy service worker
|
||||
// (cache-first with a fixed cache name). Deletes the old caches and
|
||||
// triggers an update check; the new network-first SW then takes over.
|
||||
// Keyed in localStorage so it never loops or re-runs on every session.
|
||||
(function () {
|
||||
if (!('serviceWorker' in navigator) || !('caches' in window)) return;
|
||||
var MIGRATION_KEY = 'obsigate-sw-migration';
|
||||
try {
|
||||
if (localStorage.getItem(MIGRATION_KEY) === 'v2') return;
|
||||
localStorage.setItem(MIGRATION_KEY, 'v2');
|
||||
} catch (e) { return; }
|
||||
caches.keys().then(function (names) {
|
||||
return Promise.all(names.map(function (name) {
|
||||
return /^obsigate-(v1|offline-v1)$/.test(name) ? caches.delete(name) : null;
|
||||
}));
|
||||
}).then(function () {
|
||||
return navigator.serviceWorker.getRegistration();
|
||||
}).then(function (reg) {
|
||||
if (reg) reg.update();
|
||||
}).catch(function () { /* best effort */ });
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
|
||||
|
||||
+36
-22
@@ -488,34 +488,48 @@ function _initAIStatus() {
|
||||
// PWA Service Worker Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
function registerServiceWorker() {
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
console.log("[PWA] Service Worker registered successfully:", registration.scope);
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
}, 60000); // Check every minute
|
||||
// Reload once when a new service worker takes control (guarded against
|
||||
// loops). Only relevant when a worker was already controlling the page.
|
||||
const hadController = !!navigator.serviceWorker.controller;
|
||||
let refreshing = false;
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
if (!hadController || refreshing) return;
|
||||
refreshing = true;
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
// Handle service worker updates
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const newWorker = registration.installing;
|
||||
newWorker.addEventListener("statechange", () => {
|
||||
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
|
||||
// New service worker available
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
console.log("[PWA] Service Worker registered successfully:", registration.scope);
|
||||
|
||||
// Check for updates periodically.
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
}, 60000); // Check every minute
|
||||
|
||||
// Handle service worker updates: activate the new worker immediately
|
||||
// so the fresh (network-first) code is served on the next load.
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const newWorker = registration.installing;
|
||||
if (!newWorker) return;
|
||||
newWorker.addEventListener("statechange", () => {
|
||||
if (newWorker.state === "installed") {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
newWorker.postMessage({ type: "SKIP_WAITING" });
|
||||
showUpdateNotification();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("[PWA] Service Worker registration failed:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("[PWA] Service Worker registration failed:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showUpdateNotification() {
|
||||
|
||||
+121
-114
@@ -1,126 +1,146 @@
|
||||
/* ObsiGate — Service Worker (Push notifications + Offline cache)
|
||||
* Version: 1.0.0
|
||||
* Scope: /
|
||||
/* ObsiGate — Service Worker (PWA offline + push)
|
||||
* Version: 2 (2026-09-11)
|
||||
*
|
||||
* Caching policy (fixes stale-asset loads behind Cloudflare / mobile):
|
||||
* - navigations & code (HTML / JS / CSS / manifest) : NETWORK-FIRST
|
||||
* - API : network-first (+ offline)
|
||||
* - other static (images, fonts…) : stale-while-revalidate
|
||||
*
|
||||
* Assets are served without content hashes. The previous "cache-first with a
|
||||
* fixed cache name" kept an old build forever on mobile: flushing the browser
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const CACHE_NAME = 'obsigate-v1';
|
||||
const OFFLINE_CACHE = 'obsigate-offline-v1';
|
||||
const SW_VERSION = 'v2';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
const OFFLINE_URL = '/index.html';
|
||||
|
||||
// Files to cache for offline access
|
||||
// Core shell precached for offline use. Paths MUST be the real served URLs
|
||||
// (the app is mounted under /static, not /frontend).
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
OFFLINE_URL,
|
||||
'/manifest.json',
|
||||
'/frontend/js/app.js',
|
||||
'/frontend/js/state.js',
|
||||
'/frontend/js/auth.js',
|
||||
'/frontend/js/ui.js',
|
||||
'/frontend/js/sidebar.js',
|
||||
'/frontend/js/viewer.js',
|
||||
'/frontend/js/search.js',
|
||||
'/frontend/js/config.js',
|
||||
'/frontend/js/utils.js',
|
||||
'/frontend/js/i18n.js',
|
||||
'/frontend/js/offline.js',
|
||||
'/frontend/js/offline-db.js',
|
||||
'/frontend/style.css',
|
||||
'/static/lucide.min.js'
|
||||
'/static/style.css',
|
||||
'/static/js/app.js',
|
||||
'/static/js/state.js',
|
||||
'/static/js/auth.js',
|
||||
'/static/js/ui.js',
|
||||
'/static/js/sidebar.js',
|
||||
'/static/js/viewer.js',
|
||||
'/static/js/search.js',
|
||||
'/static/js/config.js',
|
||||
'/static/js/utils.js',
|
||||
'/static/js/i18n.js',
|
||||
'/static/js/offline.js',
|
||||
'/static/js/offline-db.js',
|
||||
'/static/js/sync.js',
|
||||
'/static/js/legacy.js',
|
||||
];
|
||||
|
||||
// ── Install: precache static assets ────────────────────────────────────────
|
||||
// ── Install: precache the shell, then activate immediately ──────────────────
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
console.log('[SW] Precaching static assets');
|
||||
return cache.addAll(PRECACHE_URLS);
|
||||
}).then(() => self.skipWaiting())
|
||||
);
|
||||
event.waitUntil((async () => {
|
||||
const cache = await caches.open(CODE_CACHE);
|
||||
// Precache individually so a single 404 never aborts the whole install.
|
||||
await Promise.all(PRECACHE_URLS.map((url) =>
|
||||
cache.add(new Request(url, { cache: 'reload' })).catch(() => {})
|
||||
));
|
||||
await self.skipWaiting();
|
||||
})());
|
||||
});
|
||||
|
||||
// ── Activate: clean old caches ─────────────────────────────────────────────
|
||||
// ── Activate: drop every previous cache (old fixed-name caches included) ────
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME && name !== OFFLINE_CACHE)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
event.waitUntil((async () => {
|
||||
const keep = new Set([CODE_CACHE, RUNTIME_CACHE, API_CACHE]);
|
||||
const names = await caches.keys();
|
||||
await Promise.all(names.map((name) => (keep.has(name) ? null : caches.delete(name))));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
// ── Fetch: Network-first for API, Cache-first for static ───────────────────
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip non-GET
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
// Skip cross-origin
|
||||
if (url.origin !== location.origin) return;
|
||||
|
||||
// API requests: Network-first with offline fallback
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(networkFirstStrategy(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: Cache-first
|
||||
event.respondWith(cacheFirstStrategy(event.request));
|
||||
});
|
||||
function isCodeRequest(url, request) {
|
||||
if (request.mode === 'navigate') return true;
|
||||
if (url.pathname === '/sw.js' || url.pathname === '/manifest.json') return true;
|
||||
if (url.pathname.startsWith('/static/js/')) return true;
|
||||
return /\.(?:js|mjs|css|html|json)$/i.test(url.pathname);
|
||||
}
|
||||
|
||||
// Network-first strategy (for API)
|
||||
async function networkFirstStrategy(request) {
|
||||
async function networkFirst(request, cacheName, makeFallback) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(OFFLINE_CACHE);
|
||||
if (response && response.ok && response.type === 'basic') {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
return new Response(JSON.stringify({ error: 'Offline' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cache-first strategy (for static assets)
|
||||
async function cacheFirstStrategy(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) {
|
||||
// Stale-while-revalidate: update cache in background
|
||||
fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, response));
|
||||
}
|
||||
}).catch(() => {});
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Return offline page for navigation requests
|
||||
if (request.mode === 'navigate') {
|
||||
return caches.match('/index.html');
|
||||
}
|
||||
if (makeFallback) return makeFallback();
|
||||
return new Response('Offline', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
const fresh = fetch(request).then((response) => {
|
||||
if (response && response.ok && response.type === 'basic') {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}).catch(() => null);
|
||||
if (cached) return cached;
|
||||
return (await fresh) || new Response('Offline', { status: 503 });
|
||||
}
|
||||
|
||||
// ── Fetch routing ───────────────────────────────────────────────────────────
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
// Let the browser handle range requests (PDF/streamed media) directly.
|
||||
if (request.headers.has('range')) return;
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(networkFirst(request, API_CACHE, () =>
|
||||
new Response(JSON.stringify({ error: 'Offline' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCodeRequest(url, request)) {
|
||||
event.respondWith(networkFirst(request, CODE_CACHE, async () => {
|
||||
const cached = await caches.match(OFFLINE_URL);
|
||||
return cached || new Response('<h1>Hors ligne</h1>', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(staleWhileRevalidate(request, RUNTIME_CACHE));
|
||||
});
|
||||
|
||||
// ── Message from the page ───────────────────────────────────────────────────
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
|
||||
});
|
||||
|
||||
// ── Push Event: Display notification ───────────────────────────────────────
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
|
||||
const data = event.data.json();
|
||||
const options = {
|
||||
body: data.body || data.message || 'Nouvelle notification',
|
||||
@@ -136,7 +156,7 @@ self.addEventListener('push', (event) => {
|
||||
],
|
||||
requireInteraction: false
|
||||
};
|
||||
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'ObsiGate', options)
|
||||
);
|
||||
@@ -145,21 +165,18 @@ self.addEventListener('push', (event) => {
|
||||
// ── Notification Click: Open the app ───────────────────────────────────────
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
|
||||
if (event.action === 'dismiss') return;
|
||||
|
||||
// Default action or 'open' action: navigate to the file
|
||||
|
||||
const urlToOpen = event.notification.data?.url || '/';
|
||||
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
// Try to focus existing window
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(urlToOpen) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Open new window
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
@@ -167,7 +184,6 @@ self.addEventListener('notificationclick', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ── Notification Close ─────────────────────────────────────────────────────
|
||||
self.addEventListener('notificationclose', (event) => {
|
||||
console.log('[SW] Notification closed:', event.notification.tag);
|
||||
});
|
||||
@@ -184,7 +200,7 @@ async function syncFileIndex() {
|
||||
const resp = await fetch('/api/search/advanced?q=**&vault=all&limit=200');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
const files = (data.results || []).map(r => ({
|
||||
const files = (data.results || []).map((r) => ({
|
||||
vault: r.vault,
|
||||
path: r.path,
|
||||
title: r.title,
|
||||
@@ -192,10 +208,9 @@ async function syncFileIndex() {
|
||||
snippet: r.snippet || '',
|
||||
modified: r.modified || ''
|
||||
}));
|
||||
|
||||
// Send to main thread for IndexedDB storage
|
||||
|
||||
const clients = await self.clients.matchAll();
|
||||
clients.forEach(client => client.postMessage({
|
||||
clients.forEach((client) => client.postMessage({
|
||||
type: 'OFFLINE_INDEX_SYNC',
|
||||
files
|
||||
}));
|
||||
@@ -205,16 +220,8 @@ async function syncFileIndex() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Periodic Background Sync (if supported) ────────────────────────────────
|
||||
self.addEventListener('periodicsync', (event) => {
|
||||
if (event.tag === 'periodic-index-sync') {
|
||||
event.waitUntil(syncFileIndex());
|
||||
}
|
||||
});
|
||||
|
||||
// ── Message from main thread ───────────────────────────────────────────────
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate - Service Worker caching policy guards.
|
||||
*
|
||||
* Regression tests for the mobile/Cloudflare stale-load bug: the SW used
|
||||
* cache-first with a fixed cache name and wrong precache paths, so old JS kept
|
||||
* being served even after flushing the browser/Cloudflare caches (the SW Cache
|
||||
* Storage is a separate store). These tests read the source and assert the
|
||||
* network-first policy, versioned caches and corrected paths stay in place.
|
||||
*
|
||||
* Usage: node tests/frontend/sw.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const FRONTEND = path.resolve(__dirname, "..", "..", "frontend");
|
||||
|
||||
const sw = readFileSync(path.join(FRONTEND, "sw.js"), "utf-8");
|
||||
const indexHtml = readFileSync(path.join(FRONTEND, "index.html"), "utf-8");
|
||||
const sync = readFileSync(path.join(FRONTEND, "js", "sync.js"), "utf-8");
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
passCount++;
|
||||
} catch (e) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Service worker ─────────────────────────────────────────────────────────
|
||||
test("service worker uses a versioned cache name", () => {
|
||||
assert.ok(/const\s+SW_VERSION\s*=/.test(sw), "SW_VERSION constant present");
|
||||
assert.ok(sw.includes("obsigate-code-${SW_VERSION}"), "code cache is versioned");
|
||||
assert.ok(sw.includes("obsigate-runtime-${SW_VERSION}"), "runtime cache is versioned");
|
||||
assert.ok(sw.includes("obsigate-api-${SW_VERSION}"), "api cache is versioned");
|
||||
});
|
||||
|
||||
test("legacy fixed cache name is gone", () => {
|
||||
assert.ok(!/obsigate-v1['"]/.test(sw), "must not keep the fixed obsigate-v1 cache name");
|
||||
});
|
||||
|
||||
test("code/navigation requests are network-first", () => {
|
||||
assert.ok(/function\s+networkFirst\s*\(/.test(sw), "networkFirst strategy present");
|
||||
assert.ok(/isCodeRequest\s*\(/.test(sw), "code-request classifier present");
|
||||
// The fetch handler must route code requests through networkFirst.
|
||||
assert.ok(/isCodeRequest\([\s\S]*?networkFirst\(/.test(sw), "code requests use networkFirst");
|
||||
assert.ok(/request\.mode\s*===\s*['"]navigate['"]/.test(sw), "navigations are treated as code");
|
||||
});
|
||||
|
||||
test("precache uses real /static paths", () => {
|
||||
assert.ok(sw.includes("'/static/js/app.js'"), "app.js precached at /static/js");
|
||||
assert.ok(sw.includes("'/static/style.css'"), "style.css precached at /static");
|
||||
assert.ok(!sw.includes("/frontend/js/"), "no stale /frontend/js path in precache");
|
||||
});
|
||||
|
||||
test("precache failures are non-fatal", () => {
|
||||
assert.ok(/PRECACHE_URLS\.map[\s\S]*?\.catch\(/.test(sw), "per-URL catch so a 404 does not abort install");
|
||||
});
|
||||
|
||||
test("worker activates immediately and claims clients", () => {
|
||||
assert.ok(sw.includes("skipWaiting()"), "skipWaiting present");
|
||||
assert.ok(sw.includes("clients.claim()"), "clients.claim present");
|
||||
});
|
||||
|
||||
// ── index.html migration ───────────────────────────────────────────────────
|
||||
test("index.html uses a one-time keyed migration, not a session kill-switch", () => {
|
||||
assert.ok(indexHtml.includes("obsigate-sw-migration"), "keyed migration guard present");
|
||||
assert.ok(!indexHtml.includes("_sw_killed"), "legacy session kill-switch removed");
|
||||
assert.ok(!/getRegistrations\(\)[\s\S]{0,200}unregister/.test(indexHtml), "does not unregister on every session");
|
||||
});
|
||||
|
||||
// ── sync.js registration ───────────────────────────────────────────────────
|
||||
test("sync.js reloads once when a new worker takes control", () => {
|
||||
assert.ok(sync.includes("controllerchange"), "controllerchange listener present");
|
||||
assert.ok(sync.includes("SKIP_WAITING"), "asks the waiting worker to activate");
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} service-worker tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
process.exit(1);
|
||||
}
|
||||
+35
-1
@@ -719,4 +719,38 @@ class TestSecretRedactor:
|
||||
def test_redact_plain_text(self):
|
||||
from backend.secret_redactor import redact_file_content
|
||||
result = redact_file_content("hello world this is safe")
|
||||
assert result == "hello world this is safe"
|
||||
assert result == "hello world this is safe"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Static / PWA caching (Cloudflare / mobile freshness)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestStaticCaching:
|
||||
"""Unhashed assets must revalidate (``no-cache``) to avoid stale builds.
|
||||
|
||||
This is the server-side half of the fix for mobile loads behind
|
||||
Cloudflare: without ``Cache-Control`` the edge/browser could serve an old
|
||||
build indefinitely.
|
||||
"""
|
||||
|
||||
def test_spa_index_revalidates(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "no-cache" in resp.headers.get("cache-control", "")
|
||||
|
||||
def test_service_worker_revalidates(self, client):
|
||||
resp = client.get("/sw.js")
|
||||
assert resp.status_code == 200
|
||||
assert "no-cache" in resp.headers.get("cache-control", "")
|
||||
|
||||
def test_manifest_revalidates(self, client):
|
||||
resp = client.get("/manifest.json")
|
||||
assert resp.status_code == 200
|
||||
assert "no-cache" in resp.headers.get("cache-control", "")
|
||||
|
||||
def test_static_asset_revalidates(self, client):
|
||||
resp = client.get("/static/style.css")
|
||||
assert resp.status_code == 200
|
||||
assert "no-cache" in resp.headers.get("cache-control", "")
|
||||
Reference in New Issue
Block a user