openclaw/ui/public/sw.js
Peter Steinberger 17938965c6
fix(gateway): handle connection bursts behind shared IPs (#160814)
* fix(gateway): admit shared-venue bursts and explain busy retries

* fix(ui): keep liveness probes out of the service worker cache

* refactor(ui): keep browser transport errors with the socket adapter

* test(ui): model refused socket state transitions

* style(ui): satisfy retry-path lint rules

* chore(ui): retire the removed gateway type assertion allowance
2026-09-29 00:48:30 +00:00

257 lines
8.6 KiB
JavaScript

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;
const SCOPE_URL = new URL(self.registration.scope);
const SCOPE_PATH = SCOPE_URL.pathname.endsWith("/") ? SCOPE_URL.pathname : `${SCOPE_URL.pathname}/`;
function controlUiPathname(url) {
if (url.origin !== SCOPE_URL.origin) {
return null;
}
if (url.pathname === SCOPE_URL.pathname) {
return "/";
}
return url.pathname.startsWith(SCOPE_PATH) ? `/${url.pathname.slice(SCOPE_PATH.length)}` : null;
}
// Older pages reload directly and cannot acquire new config-draft guards. Keep
// their root/chat announcement contract; current pages also reconcile on resume.
function isControlUiChatClient(url) {
const pathname = controlUiPathname(new URL(url));
return pathname === "/" || pathname === "/chat" || pathname?.startsWith("/chat/") === true;
}
// A resumed/BFCache document may have missed activation entirely. Build identity
// belongs to the running worker, not its potentially old sw.js?v= registration URL.
self.addEventListener("message", (event) => {
if (event.data?.type === "sw-version-probe") {
event.ports[0]?.postMessage({ type: "sw-updated", version: CACHE_VERSION });
}
});
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) => {
event.waitUntil(
(async () => {
const cacheKeys = await caches.keys();
const controlKeys = cacheKeys.filter((key) => key.startsWith(CACHE_PREFIX));
const priorCacheLimit = Math.max(0, CONTROL_CACHE_LIMIT - 1);
// Keep a small prior-build window so open tabs can still load old hashed chunks after updates.
const retained = new Set([
...controlKeys.filter((key) => key !== CACHE_NAME).slice(-priorCacheLimit),
CACHE_NAME,
]);
await Promise.all([
self.clients.claim(),
Promise.all(
controlKeys.filter((key) => !retained.has(key)).map((key) => caches.delete(key)),
),
]);
// Queue the announcement without waiting for suspended pages or navigating
// around their unsaved-work guards. Resumed pages also query our identity.
const windowClients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of windowClients) {
if (isControlUiChatClient(client.url)) {
client.postMessage({ type: "sw-updated", version: CACHE_VERSION }, []);
}
}
})(),
);
});
async function reportControlUiHttpFailure(event) {
if (!event.clientId) {
return;
}
try {
const client = await self.clients.get(event.clientId);
if (client?.type === "window" && controlUiPathname(new URL(client.url)) !== null) {
client.postMessage({ type: "openclaw-http-request-failed" }, []);
}
} catch {
// Closing a tab during its request must not replace the HTTP outcome.
}
}
async function fetchControlUiRequest(event, cacheable) {
try {
const response = await fetch(event.request);
if (response.status === 401) {
await reportControlUiHttpFailure(event);
}
if (cacheable && response.ok && !response.redirected) {
const clone = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
} catch {
await reportControlUiHttpFailure(event);
const cached = cacheable ? await caches.match(event.request) : undefined;
return cached || Response.error();
}
}
self.addEventListener("fetch", (event) => {
// Only the requesting app owns recovery. Other origins and scoped apps keep
// their own network and cache policies, even when this worker controls the tab.
if (event.request.method !== "GET") {
return;
}
const pathname = controlUiPathname(new URL(event.request.url));
if (pathname === null) {
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;
}
// Dynamic reads must reach their authority owner, including after an edge
// login expires. Never replay previously cached metadata or media tickets.
const cacheable = !(
event.request.cache === "no-store" ||
pathname.startsWith("/__openclaw__/") ||
pathname.startsWith("/api/") ||
pathname.startsWith("/rpc") ||
pathname.startsWith("/plugins/") ||
pathname.startsWith("/avatar/")
);
// Cache-first for hashed assets; network-first for other paths. Versioned
// public URLs reuse the HTTP immutable cache; unversioned/custom files revalidate.
if (cacheable && pathname.includes("/assets/")) {
event.respondWith(
caches.match(event.request).then((cached) => cached || fetchControlUiRequest(event, true)),
);
} else {
event.respondWith(fetchControlUiRequest(event, cacheable));
}
});
// --- 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",
renotify: data.renotify === true,
data: {
url: data.url || self.registration.scope,
explicitUrl: Boolean(data.url),
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
// Relative targets belong beneath the registered scope even when its URL
// omits a trailing slash; keep the exact scope for default navigation.
const scopeNavigationBase = new URL(SCOPE_PATH, SCOPE_URL);
const notificationUrl = event.notification.data?.url;
// Notifications shown before an update stored "./" for implicit targets.
// Preserve their existing in-scope tabs when the new worker handles the click.
const hasExplicitTarget =
event.notification.data?.explicitUrl ?? Boolean(notificationUrl && notificationUrl !== "./");
let targetUrl = SCOPE_URL;
try {
const requestedUrl = new URL(
(hasExplicitTarget ? notificationUrl : undefined) || SCOPE_URL.href,
scopeNavigationBase,
);
if (controlUiPathname(requestedUrl) !== null) {
targetUrl = requestedUrl;
}
} catch {
// Malformed notification targets can only fall back to the registered app scope.
}
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
let targetClient;
let targetClientRank = -1;
for (const client of clients) {
let clientUrl;
try {
clientUrl = new URL(client.url);
} catch {
continue;
}
if (controlUiPathname(clientUrl) === null) {
continue;
}
if (!hasExplicitTarget) {
return client.focus();
}
// Prefer the existing target tab before repurposing another app tab.
// Client.url can lag SPA history, so its match only chooses a candidate.
const clientRank =
clientUrl.href === targetUrl.href
? 3
: clientUrl.pathname === targetUrl.pathname
? clientUrl.search === targetUrl.search
? 2
: 1
: 0;
if (clientRank > targetClientRank) {
targetClient = client;
targetClientRank = clientRank;
if (clientRank === 3) {
break;
}
}
}
if (!targetClient) {
return self.clients.openWindow(targetUrl.href);
}
// Always navigate explicit targets; old or uncontrolled workers can
// reject navigation, so preserve the validated open-window fallback.
return targetClient
.navigate(targetUrl.href)
.then((navigatedClient) => (navigatedClient ?? targetClient).focus())
.catch(() => self.clients.openWindow(targetUrl.href));
}),
);
});