- 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>
86 lines
2.4 KiB
JavaScript
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("/");
|
|
})
|
|
);
|
|
}
|
|
});
|