Files
agw/src/sw.ts
Felix Förtsch 4e3aa682ac abgeordnetenwatch PWA + backend
feature-based React PWA with Hono backend:
- feed from abgeordnetenwatch.de API (polls by topic + politician)
- follow topics, search and follow politicians
- geo-based politician discovery via Nominatim
- push notifications for new polls via web-push
- service worker with offline caching
- deploy to Uberspace 8 (systemd, PostgreSQL, web backend proxy)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 08:14:22 +01:00

93 lines
2.2 KiB
TypeScript

/// <reference lib="webworker" />
import { clientsClaim } from "workbox-core"
import { ExpirationPlugin } from "workbox-expiration"
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching"
import { registerRoute } from "workbox-routing"
import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies"
declare let self: ServiceWorkerGlobalScope
// allow the app to trigger skipWaiting via postMessage
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting()
}
})
// precache all assets injected by vite-plugin-pwa
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
clientsClaim()
// runtime cache: AW API
registerRoute(
/^https:\/\/www\.abgeordnetenwatch\.de\/api\/v2\//,
new StaleWhileRevalidate({
cacheName: "aw-api-cache",
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 60 * 15,
}),
],
}),
)
// runtime cache: Nominatim
registerRoute(
/^https:\/\/nominatim\.openstreetmap\.org\//,
new CacheFirst({
cacheName: "nominatim-cache",
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24,
}),
],
}),
)
// --- Push notification handlers ---
interface PushPayload {
title: string
body: string
url?: string
tag?: string
}
self.addEventListener("push", (event) => {
if (!event.data) return
const payload = event.data.json() as PushPayload
event.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
tag: payload.tag,
data: { url: payload.url },
icon: "/agw/icons/icon-192.png",
badge: "/agw/icons/icon-192.png",
}),
)
})
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = (event.notification.data as { url?: string })?.url ?? "/agw/"
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((windowClients) => {
// focus existing window if possible
for (const client of windowClients) {
if (client.url.includes("/agw/") && "focus" in client) {
return client.focus()
}
}
// otherwise open new window
return self.clients.openWindow(url)
}),
)
})