Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/public/sw.js
T

205 lines
6.1 KiB
JavaScript

/* eslint-disable no-restricted-globals */
/**
* Minimal service worker for HiveGrid.
*
* Strategy:
* - Static, hashed asset requests under /assets/ → cache-first (long-term
* immutable cache; the React-Router build emits hashed filenames so we
* can keep stale entries until the next deploy bumps the version).
* - Every other request bypasses the service worker. In particular, HTML,
* loaders, APIs and media are never copied into Cache Storage: Cache API
* writes do not provide the same `no-store` guarantees as the browser HTTP
* cache and are keyed by URL rather than the authenticated session.
*
* Spec: docs/improvement-plan/10-compliance-polish/lighthouse.md
*
* SECURITY: This file is loaded on /sw.js scope = `/`. It must NEVER call
* dynamic import() with untrusted input nor cache POST/PUT/PATCH/DELETE
* responses. Bumping CACHE_VERSION invalidates all old caches on the next
* activate event, which is the rollback path documented in the spec.
*/
const SERVICE_WORKER_VERSION = "v5-2026-07-15-web-push";
const CACHE_VERSION = SERVICE_WORKER_VERSION;
const ASSET_CACHE = `hivegrid-assets-${CACHE_VERSION}`;
const VERSION_REQUEST = "HIVEGRID_SW_VERSION_REQUEST";
const VERSION_RESPONSE = "HIVEGRID_SW_VERSION_RESPONSE";
const SKIP_WAITING_REQUEST = "HIVEGRID_SW_SKIP_WAITING";
self.addEventListener("install", (event) => {
// Activate immediately so cached responses from old versions are evicted.
event.waitUntil(self.skipWaiting());
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.startsWith("hivegrid-") && key !== ASSET_CACHE)
.map((key) => caches.delete(key))
);
await self.clients.claim();
})()
);
});
self.addEventListener("message", (event) => {
const data = event.data;
if (!data || typeof data !== "object") return;
if (data.type === VERSION_REQUEST && typeof data.requestId === "string") {
event.source?.postMessage({
type: VERSION_RESPONSE,
requestId: data.requestId,
version: SERVICE_WORKER_VERSION,
});
return;
}
if (data.type === SKIP_WAITING_REQUEST) {
event.waitUntil(self.skipWaiting());
}
});
self.addEventListener("push", (event) => {
const notification = parsePushNotification(event.data);
if (!notification) return;
event.waitUntil(
self.registration.showNotification(notification.title, {
body: notification.body,
icon: "/android-chrome-192x192.png",
badge: "/android-icon-96x96.png",
tag: notification.tag,
renotify: Boolean(notification.tag),
data: { href: notification.href },
})
);
});
self.addEventListener("notificationclick", (event) => {
const href = safeNotificationHref(event.notification?.data?.href);
event.notification?.close();
if (!href) return;
event.waitUntil(openNotificationTarget(href));
});
self.addEventListener("fetch", (event) => {
const request = event.request;
// Never intercept non-GET — auth/mutation traffic must hit the network.
if (request.method !== "GET") {
return;
}
const url = new URL(request.url);
// Same-origin only. Third-party assets (Sentry, Crisp, recaptcha) stay on the network.
if (url.origin !== self.location.origin) {
return;
}
if (url.pathname.startsWith("/assets/")) {
event.respondWith(cacheFirst(request, ASSET_CACHE));
return;
}
// Do not intercept route/data/API/media requests. The browser's regular
// HTTP cache honours their Cache-Control/Vary semantics; Cache Storage does
// not automatically make session-derived responses safe for another user.
});
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
await cache.put(request, response.clone());
}
return response;
}
function parsePushNotification(data) {
if (!data || typeof data.json !== "function") return null;
let payload;
try {
payload = data.json();
} catch {
return null;
}
if (!isPlainRecord(payload) || !isPlainRecord(payload.data)) return null;
const title = boundedText(payload.title, 160);
const body = boundedText(payload.body, 2048);
if (!title || !body) return null;
const href = safeNotificationHref(payload.data.href || payload.data.url) || "/";
const callId = boundedText(payload.data.callId, 191);
return {
title,
body,
href,
tag: callId ? `telephony-call:${callId}` : undefined,
};
}
function safeNotificationHref(candidate) {
if (
typeof candidate !== "string" ||
!candidate.startsWith("/") ||
candidate.startsWith("//") ||
/[\u0000-\u001F\u007F\\]/.test(candidate)
) {
return null;
}
try {
const url = new URL(candidate, self.location.origin);
if (url.origin !== self.location.origin) return null;
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return null;
}
}
async function openNotificationTarget(href) {
const targetUrl = new URL(href, self.location.origin).href;
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const exact = windows.find((client) => client.url === targetUrl);
if (exact) {
await exact.focus();
return;
}
const sameOrigin = windows.find((client) => {
try {
return new URL(client.url).origin === self.location.origin;
} catch {
return false;
}
});
if (sameOrigin && typeof sameOrigin.navigate === "function") {
const navigated = await sameOrigin.navigate(targetUrl);
await (navigated || sameOrigin).focus();
return;
}
await self.clients.openWindow(href);
}
function boundedText(value, maxLength) {
if (typeof value !== "string") return null;
const normalized = value.trim();
if (!normalized || normalized.length > maxLength || /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(normalized)) return null;
return normalized;
}
function isPlainRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}