mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 14:24:47 +00:00
fix(control-ui): rotate service worker cache per build (#82050)
This commit is contained in:
committed by
GitHub
parent
925861f9b3
commit
b24a6d2cbd
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
26
ui/src/ui/service-worker-cache.test.ts
Normal file
26
ui/src/ui/service-worker-cache.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user