mc-dashboard/public/sw.js
hurkicorgi 359a12ef9d SSE events bridge, PWA service worker, offline banner, lazy admin tabs
- New /api/events SSE endpoint (authed): pushes status every 5s and chat
  on log-file mtime change (~1.5s poll). Heartbeat every 15s, hard-caps
  each stream at 10min so the browser gets a clean auth refresh on
  reconnect. Auto-aborts on client disconnect.
- Factored shared helpers out of the existing routes:
  - lib/server-status.ts (probeStatus, reused by /api/status + SSE)
  - lib/chat-log.ts (parseLogLine, readChatMessages, logMtime, reused by
    /api/chat + SSE)
- EventsBridge client (mounted in Providers) opens one EventSource per
  authed session and writes live data into the TanStack Query cache for
  ["status"] and ["chat"] — no refactor needed in consuming components,
  they keep reading their usual query keys.
- Now that SSE pushes updates, polling intervals bumped: StatusCard and
  ServerControls 10s -> 60s, ChatBridge 5s -> 30s. SSE handles realtime,
  polling is safety fallback.
- OfflineBanner: sticky amber bar when navigator.onLine flips false.
- PWA: minimal public/sw.js with shell + asset cache (network-first for
  HTML, stale-while-revalidate for static assets, never touches /api/*
  or text/event-stream). ServiceWorkerRegister client registers it in
  production only.
- AdminTabs now uses next/dynamic with skeleton fallbacks for Players /
  Chat / Mods / Backups / Logs, keeping initial /admin bundle smaller.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:48:00 -06:00

86 lines
2.4 KiB
JavaScript

// HurkiCorgi MC — minimal service worker
// Strategy: network-first for HTML/data, stale-while-revalidate for assets,
// offline fallback to the cached shell.
const VERSION = "v1";
const SHELL_CACHE = `shell-${VERSION}`;
const ASSET_CACHE = `assets-${VERSION}`;
const SHELL_URLS = ["/", "/icon.svg", "/manifest.json"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((c) => c.addAll(SHELL_URLS)).catch(() => {})
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE)
.map((k) => caches.delete(k))
);
await self.clients.claim();
})()
);
});
function isAsset(url) {
return (
url.pathname.startsWith("/_next/static/") ||
url.pathname.endsWith(".svg") ||
url.pathname.endsWith(".woff2") ||
url.pathname.endsWith(".png") ||
url.pathname.endsWith(".ico")
);
}
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// Never intercept API, auth, SSE, or anything under /api
if (url.pathname.startsWith("/api/")) return;
if (req.headers.get("accept")?.includes("text/event-stream")) return;
// Assets: stale-while-revalidate
if (isAsset(url)) {
event.respondWith(
caches.open(ASSET_CACHE).then(async (cache) => {
const cached = await cache.match(req);
const fetchPromise = fetch(req)
.then((res) => {
if (res.ok) cache.put(req, res.clone());
return res;
})
.catch(() => cached || Response.error());
return cached || fetchPromise;
})
);
return;
}
// HTML / navigations: network-first with cache fallback
if (req.mode === "navigate" || req.headers.get("accept")?.includes("text/html")) {
event.respondWith(
fetch(req)
.then((res) => {
if (res.ok) {
const clone = res.clone();
caches.open(SHELL_CACHE).then((c) => c.put(req, clone));
}
return res;
})
.catch(async () => {
const cached = await caches.match(req);
return cached || caches.match("/");
})
);
}
});