mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-29 02:33:33 +00:00
Introduce a UI asset bundler and delivery flow: adds helpers/ui_bundler.py to discover same-origin HTML/CSS/JS (<=40KiB), build a versioned JSON bundle, and serialize it for an authenticated /ui/asset-bundle endpoint (handlers added in helpers/ui_server.py with gzip and ETag support). Add webui/sw.js service-worker to accept a preload bundle, populate a per-version cache, serve bundled text entries immediately, and perform runtime caching/cleanup for eligible assets. Update webui/index.html to fetch the bundle asynchronously, send it to the worker, register the worker by bundle version, inline a non-blocking startup splash, and convert stylesheets to non-blocking preload loads. Expose extension readiness from webui/js/extensions.js (webui-extensions-loaded) so startup waits for Alpine and HTML extension loading. Add tests covering bundler behavior, service-worker expectations, and startup ordering.
182 lines
5.4 KiB
JavaScript
182 lines
5.4 KiB
JavaScript
const CACHE_PREFIX = "agent-zero-ui-assets-";
|
|
const SCRIPT_VERSION = new URL(self.location.href).searchParams.get("version") || "runtime";
|
|
const MAX_CACHEABLE_FILE_BYTES = 40 * 1024;
|
|
const CACHEABLE_FILE_PATTERN = /\.(?:css|html?|xhtml|m?js)$/i;
|
|
|
|
let activeCacheName = cacheName(SCRIPT_VERSION);
|
|
let activeBundleEntries = new Map();
|
|
let cachePopulation = { name: "", promise: Promise.resolve() };
|
|
|
|
self.addEventListener("install", () => {
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
event.waitUntil(
|
|
Promise.all([
|
|
cleanupCaches(activeCacheName),
|
|
self.clients.claim(),
|
|
]),
|
|
);
|
|
});
|
|
|
|
self.addEventListener("message", (event) => {
|
|
if (event.data?.type !== "preload-ui-bundle") return;
|
|
const bundle = event.data.bundle;
|
|
if (!bundle?.version || !bundle.files || typeof bundle.files !== "object") return;
|
|
|
|
activeCacheName = cacheName(bundle.version);
|
|
activeBundleEntries = bundleEntries(bundle.files);
|
|
|
|
if (cachePopulation.name !== activeCacheName) {
|
|
const targetCacheName = activeCacheName;
|
|
const promise = preloadBundle(bundle, targetCacheName).catch((error) => {
|
|
if (cachePopulation.promise === promise) {
|
|
cachePopulation = { name: "", promise: Promise.resolve() };
|
|
}
|
|
throw error;
|
|
});
|
|
cachePopulation = { name: targetCacheName, promise };
|
|
}
|
|
|
|
event.waitUntil(cachePopulation.promise);
|
|
});
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
if (!isCacheableRequest(event.request)) return;
|
|
|
|
const bundledEntry = activeBundleEntries.get(event.request.url);
|
|
if (bundledEntry) {
|
|
event.respondWith(Promise.resolve(responseFromEntry(bundledEntry)));
|
|
return;
|
|
}
|
|
|
|
let cacheWrite = Promise.resolve();
|
|
const response = (async () => {
|
|
const cache = await caches.open(activeCacheName);
|
|
const cached = await cache.match(event.request);
|
|
if (cached) return cached;
|
|
|
|
const networkResponse = await fetch(event.request);
|
|
if (isCacheableResponse(networkResponse)) {
|
|
cacheWrite = cacheRuntimeResponse(
|
|
cache,
|
|
event.request,
|
|
networkResponse.clone(),
|
|
);
|
|
}
|
|
return networkResponse;
|
|
})();
|
|
|
|
event.respondWith(response);
|
|
event.waitUntil(response.then(() => cacheWrite).catch(() => undefined));
|
|
});
|
|
|
|
function cacheName(version) {
|
|
const safeVersion = String(version).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64);
|
|
return `${CACHE_PREFIX}${safeVersion || "runtime"}`;
|
|
}
|
|
|
|
async function preloadBundle(bundle, targetCacheName) {
|
|
await cleanupCaches(targetCacheName);
|
|
const cache = await caches.open(targetCacheName);
|
|
const marker = cacheMarkerRequest(targetCacheName);
|
|
if (await cache.match(marker)) return;
|
|
|
|
await Promise.all(
|
|
Object.entries(bundle.files).map(async ([url, entry]) => {
|
|
const response = responseFromEntry(entry);
|
|
if (!response) return;
|
|
const request = new Request(new URL(url, self.location.origin), {
|
|
credentials: "same-origin",
|
|
});
|
|
await cache.put(request, response);
|
|
}),
|
|
);
|
|
await cache.put(marker, new Response(bundle.version));
|
|
}
|
|
|
|
async function cleanupCaches(targetCacheName) {
|
|
const names = await caches.keys();
|
|
await Promise.all(
|
|
names
|
|
.filter((name) => name.startsWith(CACHE_PREFIX) && name !== targetCacheName)
|
|
.map((name) => caches.delete(name)),
|
|
);
|
|
}
|
|
|
|
function bundleEntries(files) {
|
|
const entries = new Map();
|
|
for (const [url, entry] of Object.entries(files)) {
|
|
try {
|
|
const absoluteUrl = new URL(url, self.location.origin);
|
|
if (absoluteUrl.origin === self.location.origin && isBundleEntry(entry)) {
|
|
entries.set(absoluteUrl.href, entry);
|
|
}
|
|
} catch (_error) {
|
|
continue;
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function isBundleEntry(entry) {
|
|
return (
|
|
Array.isArray(entry) &&
|
|
entry.length === 3 &&
|
|
entry[1] === "text" &&
|
|
typeof entry[2] === "string"
|
|
);
|
|
}
|
|
|
|
function responseFromEntry(entry) {
|
|
if (!isBundleEntry(entry)) return null;
|
|
const [contentType, _encoding, content] = entry;
|
|
return new Response(content, {
|
|
headers: {
|
|
"Content-Type": contentType || "application/octet-stream",
|
|
"X-Agent-Zero-Cache": "preloaded",
|
|
},
|
|
});
|
|
}
|
|
|
|
function cacheMarkerRequest(targetCacheName) {
|
|
return new Request(
|
|
new URL(`/.agent-zero-cache/${encodeURIComponent(targetCacheName)}`, self.location.origin),
|
|
);
|
|
}
|
|
|
|
function isCacheableRequest(request) {
|
|
if (request.method !== "GET" || request.headers.has("range")) return false;
|
|
const url = new URL(request.url);
|
|
if (url.origin !== self.location.origin || request.mode === "navigate") return false;
|
|
if (
|
|
url.pathname === "/" ||
|
|
url.pathname === "/login" ||
|
|
url.pathname === "/logout" ||
|
|
url.pathname.startsWith("/api/") ||
|
|
url.pathname.startsWith("/ws") ||
|
|
url.pathname.startsWith("/socket.io/") ||
|
|
url.pathname.startsWith("/mcp/") ||
|
|
url.pathname.startsWith("/a2a/")
|
|
) {
|
|
return false;
|
|
}
|
|
return CACHEABLE_FILE_PATTERN.test(url.pathname);
|
|
}
|
|
|
|
function isCacheableResponse(response) {
|
|
return response.ok && (response.type === "basic" || response.type === "default");
|
|
}
|
|
|
|
async function cacheRuntimeResponse(cache, request, response) {
|
|
const contentLength = response.headers.get("content-length");
|
|
if (contentLength !== null) {
|
|
const size = Number(contentLength);
|
|
if (!Number.isFinite(size) || size > MAX_CACHEABLE_FILE_BYTES) return;
|
|
} else {
|
|
const body = await response.clone().arrayBuffer();
|
|
if (body.byteLength > MAX_CACHEABLE_FILE_BYTES) return;
|
|
}
|
|
await cache.put(request, response);
|
|
}
|