mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-05 20:42:53 +00:00
HTTP auth challenges (basic, digest, negotiate) only fire the browser's native credentials dialog when the response comes straight from the network. Service worker responses bypass the WWW-Authenticate flow, so reverse-proxy deployments with HTTP auth in front of the gateway show a bare 401 after the browser's HTTP-auth memory cache expires (e.g. on full browser restart) — forcing users to clear site data to recover. Skip event.request.mode === "navigate" so the browser handles those requests natively. Offline navigation of the app shell is lost, but the SPA cannot function without network (all API calls go to the network), so the trade-off is acceptable. Refs: #85939, #71669, #53274
142 lines
4.2 KiB
JavaScript
142 lines
4.2 KiB
JavaScript
// OpenClaw Control – Service Worker
|
||
// Handles offline caching and push notifications.
|
||
|
||
const CACHE_PREFIX = "openclaw-control-";
|
||
const EMBEDDED_CACHE_VERSION = "__OPENCLAW_CONTROL_UI_BUILD_ID__";
|
||
const URL_CACHE_VERSION = new URL(self.location.href).searchParams
|
||
.get("v")
|
||
?.replace(/[^a-zA-Z0-9._-]/g, "-");
|
||
const CACHE_VERSION =
|
||
(EMBEDDED_CACHE_VERSION !== "__OPENCLAW_CONTROL_UI_BUILD_ID__"
|
||
? EMBEDDED_CACHE_VERSION
|
||
: URL_CACHE_VERSION) || "dev";
|
||
const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`;
|
||
const CONTROL_CACHE_LIMIT = 3;
|
||
|
||
// Minimal app-shell files to precache.
|
||
const PRECACHE_URLS = ["./"];
|
||
|
||
self.addEventListener("install", (event) => {
|
||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)));
|
||
self.skipWaiting();
|
||
});
|
||
|
||
self.addEventListener("activate", (event) => {
|
||
// Keep a small prior-build window so open tabs can still load old hashed chunks after updates.
|
||
event.waitUntil(
|
||
Promise.all([
|
||
self.clients.claim(),
|
||
caches.keys().then((keys) => {
|
||
const controlKeys = keys.filter((key) => key.startsWith(CACHE_PREFIX));
|
||
const priorCacheLimit = Math.max(0, CONTROL_CACHE_LIMIT - 1);
|
||
const retained = new Set([
|
||
...controlKeys.filter((key) => key !== CACHE_NAME).slice(-priorCacheLimit),
|
||
CACHE_NAME,
|
||
]);
|
||
return Promise.all(
|
||
controlKeys.filter((key) => !retained.has(key)).map((key) => caches.delete(key)),
|
||
);
|
||
}),
|
||
]),
|
||
);
|
||
});
|
||
|
||
self.addEventListener("fetch", (event) => {
|
||
const url = new URL(event.request.url);
|
||
|
||
// Skip non-GET and cross-origin requests.
|
||
if (event.request.method !== "GET" || url.origin !== self.location.origin) {
|
||
return;
|
||
}
|
||
|
||
// Skip top-level navigations so the browser can handle HTTP auth
|
||
// challenges natively — WWW-Authenticate dialogs are bypassed when the
|
||
// response comes from a service worker, breaking reverse-proxy setups
|
||
// with basic/digest auth in front of the gateway.
|
||
if (event.request.mode === "navigate") {
|
||
return;
|
||
}
|
||
|
||
// Skip non-UI routes — API, RPC, and plugin routes should never be cached.
|
||
if (
|
||
url.pathname.startsWith("/api/") ||
|
||
url.pathname.startsWith("/rpc") ||
|
||
url.pathname.startsWith("/plugins/")
|
||
) {
|
||
return;
|
||
}
|
||
|
||
// Cache-first for hashed assets; network-first for HTML/other.
|
||
if (url.pathname.includes("/assets/")) {
|
||
event.respondWith(
|
||
caches.match(event.request).then(
|
||
(cached) =>
|
||
cached ||
|
||
fetch(event.request).then((response) => {
|
||
if (response.ok) {
|
||
const clone = response.clone();
|
||
void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||
}
|
||
return response;
|
||
}),
|
||
),
|
||
);
|
||
} else {
|
||
event.respondWith(
|
||
fetch(event.request)
|
||
.then((response) => {
|
||
if (response.ok) {
|
||
const clone = response.clone();
|
||
void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||
}
|
||
return response;
|
||
})
|
||
.catch(() => caches.match(event.request)),
|
||
);
|
||
}
|
||
});
|
||
|
||
// --- Web Push ---
|
||
|
||
self.addEventListener("push", (event) => {
|
||
if (!event.data) {
|
||
return;
|
||
}
|
||
|
||
let data;
|
||
try {
|
||
data = event.data.json();
|
||
} catch {
|
||
data = { title: "OpenClaw", body: event.data.text() };
|
||
}
|
||
|
||
const title = data.title || "OpenClaw";
|
||
const options = {
|
||
body: data.body || "",
|
||
icon: "./apple-touch-icon.png",
|
||
badge: "./favicon-32.png",
|
||
tag: data.tag || "openclaw-notification",
|
||
data: { url: data.url || "./" },
|
||
};
|
||
|
||
event.waitUntil(self.registration.showNotification(title, options));
|
||
});
|
||
|
||
self.addEventListener("notificationclick", (event) => {
|
||
event.notification.close();
|
||
|
||
const targetUrl = event.notification.data?.url || "./";
|
||
|
||
event.waitUntil(
|
||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
||
// Focus an existing window if one is open.
|
||
for (const client of clients) {
|
||
if (new URL(client.url).pathname === new URL(targetUrl, self.location.origin).pathname) {
|
||
return client.focus();
|
||
}
|
||
}
|
||
return self.clients.openWindow(targetUrl);
|
||
}),
|
||
);
|
||
});
|