Files
nanobot/webui/public/sw.js
T
moranandchengyongru 95287f7435 fix(webui): harden PWA service worker caching and registration
- Exclude /webui/* endpoints from the service worker cache; the
  /webui/bootstrap endpoint issues fresh gateway credentials on every load
  and must never be cached or replayed offline.
- Restrict cache-first handling to hashed /assets/ files (served immutable).
  Un-hashed brand icons and the favicon stay network-first so future icon
  swaps reach installed clients.
- Prune stale hashed assets whenever the app shell refreshes, so old build
  assets cannot accumulate even when sw.js itself is unchanged.
- Serve the cached app shell for offline deep-link navigations.
- Ignore service worker registration failures; add unit tests for the
  service worker and the main-entry registration.
2026-08-11 20:59:06 +08:00

132 lines
4.6 KiB
JavaScript

const CACHE_NAME = "nanobot-static-v1";
const PRECACHE = ["/", "/manifest.json"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE))
);
self.skipWaiting();
});
// Collect same-origin paths referenced by the given HTML document.
function referencedAssetPaths(html) {
const refs = new Set();
const re = /(?:src|href)="(\/[^"]*)"/g;
let match;
while ((match = re.exec(html))) {
const url = new URL(match[1], self.location.origin);
if (url.origin === self.location.origin) refs.add(url.pathname + url.search);
}
return refs;
}
// Drop cached entries that the current index.html no longer references.
// CACHE_NAME is stable across deployments, so without this, hashed assets from
// previous builds would pile up in the same cache forever. The cached
// index.html is the latest one this client saw (navigation is network-first
// and overwrites it on every successful visit), so pruning against it keeps
// the offline shell consistent with the last loaded build.
async function pruneStaleEntries() {
const cache = await caches.open(CACHE_NAME);
const cachedIndex = await cache.match("/");
if (!cachedIndex) return;
const refs = referencedAssetPaths(await cachedIndex.text());
const keys = await cache.keys();
await Promise.all(
keys.map(async (request) => {
const url = new URL(request.url);
if (url.pathname === "/" || url.pathname === "/manifest.json") return;
if (refs.has(url.pathname + url.search)) return;
await cache.delete(request);
})
);
}
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((k) => k !== CACHE_NAME)
.map((k) => caches.delete(k))
)
)
.then(() => pruneStaleEntries())
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const { request } = event;
// Only handle same-origin GET requests. Requests are handed to fetch() as-is
// (never reconstructed), so their credentials mode is preserved and gateway
// auth cookies flow through on every path we touch. WebSocket upgrades are
// never dispatched to a service worker's fetch handler, so the WS endpoint
// cannot be cached; the /__nanobot exclusion below still protects its HTTP
// polling/socket bootstrap endpoints.
if (request.method !== "GET") return;
if (new URL(request.url).origin !== self.location.origin) return;
const url = new URL(request.url);
const path = url.pathname;
// Never cache API, auth, WebSocket, HMR, or WebUI endpoint paths. In
// particular /webui/bootstrap issues fresh gateway credentials on every page
// load and must never be cached or replayed offline. The /auth prefix covers
// the default token endpoint; custom token_issue_path values should be kept
// under one of these prefixes.
if (
path.startsWith("/api") ||
path.startsWith("/auth") ||
path.startsWith("/__nanobot") ||
path.startsWith("/webui")
) {
return;
}
// Static assets: cache-first. Only files under /assets/ carry content hashes
// (the gateway serves them immutable); brand icons, the favicon and other
// un-hashed files can change between releases and stay on the network-first
// path below so updates reach installed clients.
if (path.startsWith("/assets/")) {
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
}
return response;
});
})
);
return;
}
// Everything else: network-first (index.html, manifest, brand assets, etc.)
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
// The shell just changed; prune entries the new index.html no longer
// references so hashed assets from old builds do not accumulate even
// when sw.js itself is unchanged between deployments.
if (path === "/") pruneStaleEntries();
}
return response;
})
.catch(() => {
// Offline: serve the app shell for navigations (deep links resolve
// client-side), the last cached copy for everything else.
if (request.mode === "navigate") return caches.match("/");
return caches.match(request);
})
);
});