fix: Caching auf max 2 h reduzieren
Bisher konnten alte JS-/CSS-Dateien durch Service-Worker- und Browser-
Cache hartnäckig hängen bleiben, obwohl auf dem Server schon eine neue
Version lag. Strategie jetzt:
Backend (main.py)
- Neue HTTP-Middleware setzt explizite Cache-Control-Header:
* /, /index.html, /manifest.json, /sw.js, /static/js/version.js
bekommen no-cache, no-store, must-revalidate
* /static/* und /icons/* bekommen public, max-age=7200,
must-revalidate (2 h)
* SPA-Fallback-Antworten ebenfalls no-cache
* /api/* bleibt unangetastet
Service Worker (sw.js)
- Wechsel von Cache-First zu Network-First für alles
- Cache wird nur noch für die index.html-Offline-Hülle vorgehalten,
nicht mehr für JS/CSS — Browser-HTTP-Cache übernimmt das mit den
2-h-Headern vom Server
- Bei Netzwerkfehler bleibt nur die HTML-Shell offline verfügbar
Version v11 → v12 (auch SW-Cache-Key).
This commit is contained in:
@@ -4,11 +4,16 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# How long the browser may keep static assets before revalidating.
|
||||||
|
STATIC_MAX_AGE_SECONDS = 2 * 60 * 60 # 2 hours
|
||||||
|
NO_CACHE = "no-cache, no-store, must-revalidate"
|
||||||
|
STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from database import Base, engine
|
from database import Base, engine
|
||||||
@@ -113,6 +118,34 @@ _migrate()
|
|||||||
|
|
||||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_cache_headers(request: Request, call_next):
|
||||||
|
"""Force ≤ 2h browser cache for static assets and disable cache for the
|
||||||
|
entry HTML / SW / version file. API responses are left alone (handlers
|
||||||
|
decide their own caching)."""
|
||||||
|
response = await call_next(request)
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# Never cache: entry HTML, manifest, service worker, version marker
|
||||||
|
if (
|
||||||
|
path in ("/", "/index.html", "/manifest.json", "/sw.js")
|
||||||
|
or path == "/static/js/version.js"
|
||||||
|
):
|
||||||
|
response.headers["Cache-Control"] = NO_CACHE
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
response.headers["Expires"] = "0"
|
||||||
|
# 2h cache for the rest of the frontend (JS/CSS/icons/etc.)
|
||||||
|
elif path.startswith("/static/") or path.startswith("/icons/"):
|
||||||
|
response.headers["Cache-Control"] = STATIC_CACHE
|
||||||
|
# SPA fallback (everything else that isn't an API route) returns HTML;
|
||||||
|
# don't let the browser cache that either.
|
||||||
|
elif not path.startswith("/api/"):
|
||||||
|
response.headers["Cache-Control"] = NO_CACHE
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(users_router.router, prefix="/api/users", tags=["users"])
|
app.include_router(users_router.router, prefix="/api/users", tags=["users"])
|
||||||
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
|
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<!-- APP_VERSION: update here + version.js on every release -->
|
<!-- APP_VERSION: update here + version.js on every release -->
|
||||||
<title>Calendarr v11</title>
|
<title>Calendarr v12</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<meta name="theme-color" content="#4285f4" />
|
<meta name="theme-color" content="#4285f4" />
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v11</button>
|
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v12</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
<span data-i18n="my_calendars">Meine Kalender</span>
|
<span data-i18n="my_calendars">Meine Kalender</span>
|
||||||
<div class="add-cal-dropdown-wrap">
|
<div class="add-cal-dropdown-wrap">
|
||||||
<button class="icon-btn mini-btn" id="btn-add-cal" title="Kalender hinzufügen">
|
<button class="icon-btn mini-btn" id="btn-add-cal" title="Kalender hinzufügen">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v11h-2v-6H5v-2h6V5h2v11h6v2z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v12h-2v-6H5v-2h6V5h2v12h6v2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="add-cal-dropdown hidden" id="add-cal-dropdown">
|
<div class="add-cal-dropdown hidden" id="add-cal-dropdown">
|
||||||
<button data-action="local">Lokaler Kalender</button>
|
<button data-action="local">Lokaler Kalender</button>
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
<div id="cal-list-items"></div>
|
<div id="cal-list-items"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices · v11</button>
|
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices · v12</button>
|
||||||
</aside>
|
</aside>
|
||||||
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
|
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
<input type="hidden" id="ev-start" />
|
<input type="hidden" id="ev-start" />
|
||||||
<div class="dt-display" id="ev-start-display" tabindex="0" role="button">
|
<div class="dt-display" id="ev-start-display" tabindex="0" role="button">
|
||||||
<span class="dt-display-text">—</span>
|
<span class="dt-display-text">—</span>
|
||||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
|
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v12H7z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
<input type="hidden" id="ev-end" />
|
<input type="hidden" id="ev-end" />
|
||||||
<div class="dt-display" id="ev-end-display" tabindex="0" role="button">
|
<div class="dt-display" id="ev-end-display" tabindex="0" role="button">
|
||||||
<span class="dt-display-text">—</span>
|
<span class="dt-display-text">—</span>
|
||||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
|
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v12H7z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
<input type="hidden" id="ev-start-date" />
|
<input type="hidden" id="ev-start-date" />
|
||||||
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
|
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
|
||||||
<span class="dt-display-text">—</span>
|
<span class="dt-display-text">—</span>
|
||||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
|
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v12H7z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
<input type="hidden" id="ev-end-date" />
|
<input type="hidden" id="ev-end-date" />
|
||||||
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
|
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
|
||||||
<span class="dt-display-text">—</span>
|
<span class="dt-display-text">—</span>
|
||||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
|
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v12H7z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +311,7 @@
|
|||||||
<input type="hidden" id="ev-rec-until" />
|
<input type="hidden" id="ev-rec-until" />
|
||||||
<div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button">
|
<div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button">
|
||||||
<span class="dt-display-text">—</span>
|
<span class="dt-display-text">—</span>
|
||||||
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
|
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v12H7z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -884,7 +884,7 @@
|
|||||||
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
|
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="justify-content:space-between;align-items:center">
|
<div class="modal-footer" style="justify-content:space-between;align-items:center">
|
||||||
<span style="font-size:12px;color:var(--text-3)">Calendarr v11</span>
|
<span style="font-size:12px;color:var(--text-3)">Calendarr v12</span>
|
||||||
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
|
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Increment APP_VERSION with every code change
|
// Increment APP_VERSION with every code change
|
||||||
export const APP_VERSION = 'v11';
|
export const APP_VERSION = 'v12';
|
||||||
|
|||||||
@@ -1,39 +1,21 @@
|
|||||||
// Calendarr Service Worker
|
// Calendarr Service Worker — minimal-cache strategy
|
||||||
// Cache-first for static assets, network-first for /api/* (graceful offline)
|
//
|
||||||
|
// Strategy: network-first for everything. The cache is only used as a
|
||||||
|
// last-resort fallback when offline (so the app shell still opens). This
|
||||||
|
// means every online request hits the network and respects the
|
||||||
|
// server's Cache-Control headers (≤ 2h for static assets, no-cache for
|
||||||
|
// the entry HTML / version files). New releases take effect on the next
|
||||||
|
// reload, no manual SW unregister required.
|
||||||
|
|
||||||
const CACHE_VERSION = 'calendarr-v11';
|
const CACHE_VERSION = 'calendarr-v12';
|
||||||
const STATIC_ASSETS = [
|
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||||
'/',
|
|
||||||
'/index.html',
|
|
||||||
'/manifest.json',
|
|
||||||
'/static/css/app.css',
|
|
||||||
'/static/favicon.svg',
|
|
||||||
'/static/js/app.js',
|
|
||||||
'/static/js/api.js',
|
|
||||||
'/static/js/calendar.js',
|
|
||||||
'/static/js/color-picker.js',
|
|
||||||
'/static/js/date-picker.js',
|
|
||||||
'/static/js/i18n.js',
|
|
||||||
'/static/js/utils.js',
|
|
||||||
'/static/js/version.js',
|
|
||||||
'/static/js/views/agenda.js',
|
|
||||||
'/static/js/views/month.js',
|
|
||||||
'/static/js/views/quarter.js',
|
|
||||||
'/static/js/views/week.js',
|
|
||||||
'/icons/icon-192.png',
|
|
||||||
'/icons/icon-512.png',
|
|
||||||
'/icons/icon.svg',
|
|
||||||
];
|
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_VERSION).then(cache =>
|
caches.open(CACHE_VERSION).then(cache =>
|
||||||
// Use addAll with a fallback so a single missing file doesn't abort install
|
Promise.all(OFFLINE_SHELL.map(url =>
|
||||||
Promise.all(
|
|
||||||
STATIC_ASSETS.map(url =>
|
|
||||||
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
|
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
|
||||||
)
|
))
|
||||||
)
|
|
||||||
).then(() => self.skipWaiting())
|
).then(() => self.skipWaiting())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -52,7 +34,8 @@ self.addEventListener('fetch', event => {
|
|||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
|
||||||
// Network-first for API routes — fail silently if offline
|
// API routes: always go to the network, no offline fallback (we'd just
|
||||||
|
// be returning stale account/event data otherwise).
|
||||||
if (url.pathname.startsWith('/api/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(req).catch(() =>
|
fetch(req).catch(() =>
|
||||||
@@ -65,45 +48,29 @@ self.addEventListener('fetch', event => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network-first for navigation (HTML) and the version-defining files —
|
// Everything else: network-first. The browser's HTTP cache (driven by
|
||||||
// ensures users always get the freshest entry point so new releases
|
// the server's Cache-Control headers) already throttles re-fetches —
|
||||||
// take effect on the next reload without a manual SW unregister.
|
// the SW just makes sure offline still works for the entry HTML.
|
||||||
const isHtml = req.mode === 'navigate'
|
|
||||||
|| url.pathname === '/'
|
|
||||||
|| url.pathname === '/index.html';
|
|
||||||
const isVersionFile = url.pathname === '/static/js/version.js';
|
|
||||||
|
|
||||||
if (isHtml || isVersionFile) {
|
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(req).then(resp => {
|
fetch(req).then(resp => {
|
||||||
if (resp && resp.status === 200) {
|
// Keep a fresh copy of navigation requests / index.html for offline
|
||||||
const clone = resp.clone();
|
const isNavigation = req.mode === 'navigate'
|
||||||
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
|| url.pathname === '/'
|
||||||
}
|
|| url.pathname === '/index.html';
|
||||||
return resp;
|
if (isNavigation && resp && resp.status === 200) {
|
||||||
}).catch(() =>
|
|
||||||
caches.match(req).then(c => c || caches.match('/index.html'))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache-first for everything else (static)
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(req).then(cached => {
|
|
||||||
if (cached) return cached;
|
|
||||||
return fetch(req).then(resp => {
|
|
||||||
// Only cache successful, basic-origin responses
|
|
||||||
if (resp && resp.status === 200 && resp.type === 'basic') {
|
|
||||||
const clone = resp.clone();
|
const clone = resp.clone();
|
||||||
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Offline fallback for navigation requests
|
// Offline fallback: only the HTML shell is served from cache, so the
|
||||||
if (req.mode === 'navigate') return caches.match('/index.html');
|
// app at least renders and can show its own offline UI.
|
||||||
|
if (req.mode === 'navigate'
|
||||||
|
|| url.pathname === '/'
|
||||||
|
|| url.pathname === '/index.html') {
|
||||||
|
return caches.match(req).then(c => c || caches.match('/index.html'));
|
||||||
|
}
|
||||||
return new Response('', { status: 503 });
|
return new Response('', { status: 503 });
|
||||||
});
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user