diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..739b4a1 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,108 @@ +/** + * Flimmer — Service Worker + * + * Bewusst handgeschrieben und knapp gehalten. Drei Strategien: + * + * Bilder und statische Dateien → zuerst aus dem Zwischenspeicher. + * Poster ändern sich unter einem TMDB-Pfad nie, das spart jede Anfrage. + * Seitenaufrufe → zuerst das Netz, sonst der Speicher, + * sonst die Offline-Seite. Ein Tracker, der veraltete Stände zeigt, wäre + * schlimmer als einer, der ehrlich sagt: gerade keine Verbindung. + * Alles Übrige (API, Aktionen) → gar kein Zwischenspeicher. + * + * Schreibende Anfragen laufen nie über den Service Worker. + */ + +const VERSION = "flimmer-v1"; +const SHELL_CACHE = `${VERSION}-shell`; +const IMAGE_CACHE = `${VERSION}-images`; +const PAGE_CACHE = `${VERSION}-pages`; + +const OFFLINE_URL = "/offline"; + +const SHELL = [OFFLINE_URL, "/favicon.svg", "/icons/icon-192.png", "/manifest.webmanifest"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(SHELL_CACHE) + .then((cache) => cache.addAll(SHELL)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => !key.startsWith(VERSION)).map((key) => caches.delete(key))), + ) + .then(() => self.clients.claim()), + ); +}); + +/** Zwischenspeicher begrenzen, damit Poster ihn nicht endlos füllen. */ +async function trim(cacheName, maxEntries) { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + if (keys.length <= maxEntries) return; + await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key))); +} + +async function cacheFirst(request, cacheName, maxEntries) { + const cache = await caches.open(cacheName); + const hit = await cache.match(request); + if (hit) return hit; + + const response = await fetch(request); + if (response.ok) { + await cache.put(request, response.clone()); + void trim(cacheName, maxEntries); + } + return response; +} + +async function networkFirst(request) { + const cache = await caches.open(PAGE_CACHE); + try { + const response = await fetch(request); + if (response.ok) { + await cache.put(request, response.clone()); + void trim(PAGE_CACHE, 40); + } + return response; + } catch (error) { + const hit = await cache.match(request); + if (hit) return hit; + + const offline = await caches.match(OFFLINE_URL); + if (offline) return offline; + throw error; + } +} + +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; + + // Bilder aus dem eigenen Cache-Endpunkt und statische Next-Dateien + if (url.pathname.startsWith("/api/image")) { + event.respondWith(cacheFirst(request, IMAGE_CACHE, 400)); + return; + } + if (url.pathname.startsWith("/_next/static") || url.pathname.startsWith("/icons/")) { + event.respondWith(cacheFirst(request, SHELL_CACHE, 200)); + return; + } + + // Übrige API-Aufrufe nie zwischenspeichern + if (url.pathname.startsWith("/api/")) return; + + if (request.mode === "navigate") { + event.respondWith(networkFirst(request)); + } +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b3069d6..4f2cc51 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import { Outfit } from "next/font/google"; import { BrandDefs } from "@/components/brand/FlimmerLogo"; import { ToastProvider } from "@/components/Toasts"; +import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration"; import { env } from "@/lib/env"; import "./globals.css"; @@ -75,6 +76,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) Zum Inhalt springen {children} + ); diff --git a/src/app/offline/page.tsx b/src/app/offline/page.tsx new file mode 100644 index 0000000..dcc35c3 --- /dev/null +++ b/src/app/offline/page.tsx @@ -0,0 +1,30 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { FlimmerMark } from "@/components/brand/FlimmerLogo"; + +export const metadata: Metadata = { title: "Keine Verbindung" }; + +/** + * Rückfallseite des Service Workers. + * + * Bewusst ohne Datenzugriff: sie muss auch dann funktionieren, wenn der + * Server nicht erreichbar ist, und wird deshalb statisch vorgerendert. + */ +export default function OfflinePage() { + return ( +
+ +

Gerade keine Verbindung

+

+ Flimmer erreicht deinen Server nicht. Bereits geladene Seiten und Poster bleiben verfügbar — + alles Weitere kommt zurück, sobald die Verbindung wieder steht. +

+ + Noch einmal versuchen + +
+ ); +} diff --git a/src/components/ServiceWorkerRegistration.tsx b/src/components/ServiceWorkerRegistration.tsx new file mode 100644 index 0000000..3235d44 --- /dev/null +++ b/src/components/ServiceWorkerRegistration.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Meldet den Service Worker an. + * + * Nur in der Produktion: im Entwicklungsmodus würde ein Zwischenspeicher + * dauernd veraltete Bündel ausliefern und jede Änderung verschleiern. + */ +export function ServiceWorkerRegistration() { + useEffect(() => { + if (process.env.NODE_ENV !== "production") return; + if (!("serviceWorker" in navigator)) return; + + const register = () => { + navigator.serviceWorker.register("/sw.js").catch((error) => { + console.warn("Service Worker konnte nicht angemeldet werden:", error); + }); + }; + + if (document.readyState === "complete") register(); + else window.addEventListener("load", register, { once: true }); + }, []); + + return null; +}