fix(control-ui): rotate service worker cache per build (#82050)

This commit is contained in:
Peter Steinberger
2026-05-15 07:59:29 +01:00
committed by GitHub
parent 925861f9b3
commit b24a6d2cbd
6 changed files with 128 additions and 10 deletions

View File

@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI: rotate browser service-worker caches per build so updated Gateways are less likely to keep serving stale dashboard bundles that trigger protocol mismatch errors.
- Config/doctor: rotate capped `.clobbered.*` repair snapshots by artifact timestamp so repeated repairs keep the newest forensic copy instead of preserving only the first capped set. (#82012) Thanks @Kaspre.
- Telegram: initialize the bot before isolated polling drains spooled updates so default isolated polling no longer retries every update with `Bot not initialized` and stalls replies. Fixes #81973. (#81975) Thanks @neeravmakwana.
- Telegram: apply method-aware Bot API request timeouts to direct message/action clients so `openclaw message delete --channel telegram` no longer waits on grammY's 500-second default when the API request wedges. Fixes #81908. Thanks @DashLabsDev.

View File

@@ -202,6 +202,8 @@ Imported themes are stored only in the current browser profile. They are not wri
The Control UI ships a `manifest.webmanifest` and a service worker, so modern browsers can install it as a standalone PWA. Web Push lets the Gateway wake the installed PWA with notifications even when the tab or browser window is not open.
If the page shows **Protocol mismatch** right after an OpenClaw update, first reopen the dashboard with `openclaw dashboard` and hard-refresh the page. If it still fails, clear site data for the dashboard origin or test in a private browser window; an old tab or browser service-worker cache can keep running a pre-update Control UI bundle against the newer Gateway.
| Surface | What it does |
| ----------------------------------------------------- | ------------------------------------------------------------------ |
| `ui/public/manifest.webmanifest` | PWA manifest. Browsers offer "Install app" once it is reachable. |

View File

@@ -1,7 +1,17 @@
// OpenClaw Control Service Worker
// Handles offline caching and push notifications.
const CACHE_NAME = "openclaw-control-v1";
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 = ["./"];
@@ -12,14 +22,23 @@ self.addEventListener("install", (event) => {
});
self.addEventListener("activate", (event) => {
// Keep a small prior-build window so open tabs can still load old hashed chunks after updates.
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
),
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.clients.claim();
});
self.addEventListener("fetch", (event) => {

View File

@@ -7,10 +7,14 @@ type ViteImportMeta = ImportMeta & {
};
};
declare const __OPENCLAW_CONTROL_UI_BUILD_ID__: string | undefined;
const isProd = (import.meta as ViteImportMeta).env?.PROD === true;
if (isProd && "serviceWorker" in navigator) {
void navigator.serviceWorker.register("./sw.js");
const swUrl = new URL("./sw.js", window.location.href);
swUrl.searchParams.set("v", __OPENCLAW_CONTROL_UI_BUILD_ID__ || "dev");
void navigator.serviceWorker.register(swUrl, { updateViaCache: "none" });
} else if (!isProd && "serviceWorker" in navigator) {
// Unregister any leftover dev SW to avoid stale cache issues.
void navigator.serviceWorker.getRegistrations().then((registrations) => {

View File

@@ -0,0 +1,26 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const here = path.dirname(fileURLToPath(import.meta.url));
describe("Control UI service worker cache versioning", () => {
it("registers the service worker with a build id and bounds prior build caches", () => {
const mainSource = fs.readFileSync(path.join(here, "../main.ts"), "utf8");
const serviceWorkerSource = fs.readFileSync(path.join(here, "../../public/sw.js"), "utf8");
const viteConfigSource = fs.readFileSync(path.join(here, "../../vite.config.ts"), "utf8");
expect(mainSource).toContain('swUrl.searchParams.set("v"');
expect(mainSource).toContain('updateViaCache: "none"');
expect(serviceWorkerSource).toContain(
'const EMBEDDED_CACHE_VERSION = "__OPENCLAW_CONTROL_UI_BUILD_ID__"',
);
expect(serviceWorkerSource).toContain("URL_CACHE_VERSION");
expect(serviceWorkerSource).toContain("CONTROL_CACHE_LIMIT = 3");
expect(serviceWorkerSource).toContain("slice(-priorCacheLimit)");
expect(serviceWorkerSource).toContain("caches.delete");
expect(viteConfigSource).toContain("source.replace(placeholder, JSON.stringify(buildId))");
expect(serviceWorkerSource).not.toContain('const CACHE_NAME = "openclaw-control-v1"');
});
});

View File

@@ -1,8 +1,12 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import { defineConfig, type Plugin } from "vite";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const outDir = path.resolve(here, "../dist/control-ui");
function normalizeBase(input: string): string {
const trimmed = input.trim();
@@ -18,17 +22,78 @@ function normalizeBase(input: string): string {
return `${trimmed}/`;
}
function normalizeBuildId(input: string): string {
const normalized = input.trim().replace(/[^a-zA-Z0-9._-]+/g, "-");
return normalized.slice(0, 96) || "dev";
}
function readPackageVersion(): string {
try {
const raw = fs.readFileSync(path.join(repoRoot, "package.json"), "utf8");
const parsed = JSON.parse(raw) as { version?: unknown };
return typeof parsed.version === "string" && parsed.version.trim()
? parsed.version.trim()
: "dev";
} catch {
return "dev";
}
}
function readGitShortSha(): string | null {
try {
const raw = execFileSync("git", ["-C", repoRoot, "rev-parse", "--short=12", "HEAD"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
return raw.trim() || null;
} catch {
return null;
}
}
function resolveControlUiBuildId(): string {
const explicit =
process.env.OPENCLAW_CONTROL_UI_BUILD_ID?.trim() || process.env.OPENCLAW_VERSION?.trim();
if (explicit) {
return normalizeBuildId(explicit);
}
const version = readPackageVersion();
const gitSha = readGitShortSha();
return normalizeBuildId(gitSha ? `${version}-${gitSha}` : version);
}
function controlUiServiceWorkerBuildIdPlugin(buildId: string): Plugin {
return {
name: "control-ui-service-worker-build-id",
apply: "build",
closeBundle() {
const swPath = path.join(outDir, "sw.js");
const source = fs.readFileSync(swPath, "utf8");
const placeholder = '"__OPENCLAW_CONTROL_UI_BUILD_ID__"';
const updated = source.replace(placeholder, JSON.stringify(buildId));
if (updated === source) {
throw new Error(`Control UI service worker build id placeholder missing in ${swPath}`);
}
fs.writeFileSync(swPath, updated);
},
};
}
export default defineConfig(() => {
const envBase = process.env.OPENCLAW_CONTROL_UI_BASE_PATH?.trim();
const base = envBase ? normalizeBase(envBase) : "./";
const controlUiBuildId = resolveControlUiBuildId();
return {
base,
define: {
__OPENCLAW_CONTROL_UI_BUILD_ID__: JSON.stringify(controlUiBuildId),
},
publicDir: path.resolve(here, "public"),
optimizeDeps: {
include: ["lit/directives/repeat.js"],
},
build: {
outDir: path.resolve(here, "../dist/control-ui"),
outDir,
emptyOutDir: true,
sourcemap: true,
// Keep CI/onboard logs clean; current control UI chunking is intentionally above 500 kB.
@@ -40,6 +105,7 @@ export default defineConfig(() => {
strictPort: true,
},
plugins: [
controlUiServiceWorkerBuildIdPlugin(controlUiBuildId),
{
name: "control-ui-dev-stubs",
configureServer(server) {