205 lines
6.1 KiB
JavaScript
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);
|
|
}
|