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>
93 lines
2.2 KiB
TypeScript
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)
|
|
}),
|
|
)
|
|
})
|