| 1 | const CACHE_PREFIX = "agent-zero-ui-assets-"; |
| 2 | const SCRIPT_VERSION = new URL(self.location.href).searchParams.get("version") || "runtime"; |
| 3 | const MAX_RUNTIME_CACHEABLE_TRANSFER_BYTES = 256 * 1024; |
| 4 | const CACHEABLE_FILE_PATTERN = /\.(?:css|html?|xhtml|m?js)$/i; |
| 5 | |
| 6 | let activeCacheName = cacheName(SCRIPT_VERSION); |
| 7 | let activeAssetVersion = SCRIPT_VERSION; |
| 8 | let activeBundleEntries = new Map(); |
| 9 | let cachePopulation = { name: "", promise: Promise.resolve() }; |
| 10 | let persistedBundleRestore = null; |
| 11 | |
| 12 | self.addEventListener("install", () => { |
| 13 | self.skipWaiting(); |
| 14 | }); |
| 15 | |
| 16 | self.addEventListener("activate", (event) => { |
| 17 | event.waitUntil( |
| 18 | Promise.all([ |
| 19 | cleanupCaches(activeCacheName), |
| 20 | self.clients.claim(), |
| 21 | ]), |
| 22 | ); |
| 23 | }); |
| 24 | |
| 25 | self.addEventListener("message", (event) => { |
| 26 | if (event.data?.type !== "preload-ui-bundle") return; |
| 27 | const bundle = event.data.bundle; |
| 28 | const replyPort = event.ports?.[0]; |
| 29 | if (!bundle?.version || !bundle.files || typeof bundle.files !== "object") { |
| 30 | replyPort?.postMessage({ ok: false, error: "invalid-bundle" }); |
| 31 | return; |
| 32 | } |
| 33 | |
| 34 | activeAssetVersion = bundle.version; |
| 35 | activeCacheName = cacheName(activeAssetVersion); |
| 36 | activeBundleEntries = bundleEntries(bundle.files); |
| 37 | persistedBundleRestore = null; |
| 38 | |
| 39 | if (cachePopulation.name !== activeCacheName) { |
| 40 | const targetCacheName = activeCacheName; |
| 41 | const promise = preloadBundle(bundle, targetCacheName).catch((error) => { |
| 42 | if (cachePopulation.promise === promise) { |
| 43 | cachePopulation = { name: "", promise: Promise.resolve() }; |
| 44 | } |
| 45 | throw error; |
| 46 | }); |
| 47 | cachePopulation = { name: targetCacheName, promise }; |
| 48 | } |
| 49 | |
| 50 | // The in-memory map is immediately usable by fetch events. Persist it in the |
| 51 | // background while the message lifetime keeps this worker alive, so the app |
| 52 | // does not wait for hundreds of Cache Storage writes before it can render. |
| 53 | replyPort?.postMessage({ ok: true, version: activeAssetVersion }); |
| 54 | event.waitUntil(cachePopulation.promise.catch(() => undefined)); |
| 55 | }); |
| 56 | |
| 57 | self.addEventListener("fetch", (event) => { |
| 58 | if (!isCacheableRequest(event.request)) return; |
| 59 | event.respondWith( |
| 60 | respondToCacheableRequest(event.request).catch(() => fetch(event.request)), |
| 61 | ); |
| 62 | }); |
| 63 | |
| 64 | async function respondToCacheableRequest(request) { |
| 65 | let bundledEntry = activeBundleEntries.get(request.url); |
| 66 | if (!bundledEntry && activeBundleEntries.size === 0) { |
| 67 | await restorePersistedBundle(); |
| 68 | bundledEntry = activeBundleEntries.get(request.url); |
| 69 | } |
| 70 | if (bundledEntry) return responseFromEntry(bundledEntry); |
| 71 | |
| 72 | let cache; |
| 73 | try { |
| 74 | cache = await caches.open(activeCacheName); |
| 75 | const cached = await cache.match(request); |
| 76 | if (cached) return cached; |
| 77 | } catch (_error) { |
| 78 | return fetch(request); |
| 79 | } |
| 80 | |
| 81 | return fetchBackendAsset(request, cache); |
| 82 | } |
| 83 | |
| 84 | async function fetchBackendAsset(request, cache) { |
| 85 | const networkResponse = await fetch(request); |
| 86 | if (isCacheableResponse(networkResponse)) { |
| 87 | try { |
| 88 | await cacheRuntimeResponse(cache, request, networkResponse.clone()); |
| 89 | } catch (_error) { |
| 90 | // A cache failure must never hide a successful backend response. |
| 91 | } |
| 92 | } |
| 93 | return networkResponse; |
| 94 | } |
| 95 | |
| 96 | function cacheName(version) { |
| 97 | const safeVersion = String(version).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64); |
| 98 | return `${CACHE_PREFIX}${safeVersion || "runtime"}`; |
| 99 | } |
| 100 | |
| 101 | async function preloadBundle(bundle, targetCacheName) { |
| 102 | await cleanupCaches(targetCacheName); |
| 103 | const cache = await caches.open(targetCacheName); |
| 104 | const marker = cacheMarkerRequest(targetCacheName); |
| 105 | const payloadRequest = cacheBundleRequest(targetCacheName); |
| 106 | if ((await cache.match(marker)) && (await cache.match(payloadRequest))) return; |
| 107 | |
| 108 | await cache.put( |
| 109 | payloadRequest, |
| 110 | new Response(JSON.stringify(bundle), { |
| 111 | headers: { "Content-Type": "application/json; charset=utf-8" }, |
| 112 | }), |
| 113 | ); |
| 114 | await cache.put(marker, new Response(bundle.version)); |
| 115 | } |
| 116 | |
| 117 | async function restorePersistedBundle() { |
| 118 | if (activeBundleEntries.size > 0) return; |
| 119 | if (!persistedBundleRestore) { |
| 120 | const expectedVersion = activeAssetVersion; |
| 121 | const expectedCacheName = activeCacheName; |
| 122 | persistedBundleRestore = (async () => { |
| 123 | const cache = await caches.open(expectedCacheName); |
| 124 | const response = await cache.match(cacheBundleRequest(expectedCacheName)); |
| 125 | if (!response) return; |
| 126 | const bundle = await response.json(); |
| 127 | if ( |
| 128 | bundle?.version !== expectedVersion || |
| 129 | expectedVersion !== activeAssetVersion || |
| 130 | !bundle.files || |
| 131 | typeof bundle.files !== "object" |
| 132 | ) { |
| 133 | return; |
| 134 | } |
| 135 | activeBundleEntries = bundleEntries(bundle.files); |
| 136 | })().catch(() => undefined); |
| 137 | } |
| 138 | await persistedBundleRestore; |
| 139 | } |
| 140 | |
| 141 | async function cleanupCaches(targetCacheName) { |
| 142 | const names = await caches.keys(); |
| 143 | await Promise.all( |
| 144 | names |
| 145 | .filter((name) => name.startsWith(CACHE_PREFIX) && name !== targetCacheName) |
| 146 | .map((name) => caches.delete(name)), |
| 147 | ); |
| 148 | } |
| 149 | |
| 150 | function bundleEntries(files) { |
| 151 | const entries = new Map(); |
| 152 | for (const [url, entry] of Object.entries(files)) { |
| 153 | try { |
| 154 | const absoluteUrl = new URL(url, self.location.origin); |
| 155 | if (absoluteUrl.origin === self.location.origin && isBundleEntry(entry)) { |
| 156 | entries.set(absoluteUrl.href, entry); |
| 157 | } |
| 158 | } catch (_error) { |
| 159 | continue; |
| 160 | } |
| 161 | } |
| 162 | return entries; |
| 163 | } |
| 164 | |
| 165 | function isBundleEntry(entry) { |
| 166 | return ( |
| 167 | Array.isArray(entry) && |
| 168 | entry.length === 3 && |
| 169 | entry[1] === "text" && |
| 170 | typeof entry[2] === "string" |
| 171 | ); |
| 172 | } |
| 173 | |
| 174 | function responseFromEntry(entry) { |
| 175 | if (!isBundleEntry(entry)) return null; |
| 176 | const [contentType, _encoding, content] = entry; |
| 177 | return new Response(content, { |
| 178 | headers: { |
| 179 | "Content-Type": contentType || "application/octet-stream", |
| 180 | "X-Agent-Zero-Cache": "preloaded", |
| 181 | }, |
| 182 | }); |
| 183 | } |
| 184 | |
| 185 | function cacheMarkerRequest(targetCacheName) { |
| 186 | return new Request( |
| 187 | new URL(`/.agent-zero-cache/${encodeURIComponent(targetCacheName)}`, self.location.origin), |
| 188 | ); |
| 189 | } |
| 190 | |
| 191 | function cacheBundleRequest(targetCacheName) { |
| 192 | return new Request( |
| 193 | new URL( |
| 194 | `/.agent-zero-cache/${encodeURIComponent(targetCacheName)}/bundle`, |
| 195 | self.location.origin, |
| 196 | ), |
| 197 | ); |
| 198 | } |
| 199 | |
| 200 | function isCacheableRequest(request) { |
| 201 | if (request.method !== "GET" || request.headers.has("range")) return false; |
| 202 | const url = new URL(request.url); |
| 203 | if (url.origin !== self.location.origin || request.mode === "navigate") return false; |
| 204 | if ( |
| 205 | url.pathname === "/" || |
| 206 | url.pathname === "/login" || |
| 207 | url.pathname === "/logout" || |
| 208 | url.pathname.startsWith("/api/") || |
| 209 | url.pathname.startsWith("/ws") || |
| 210 | url.pathname.startsWith("/socket.io/") || |
| 211 | url.pathname.startsWith("/mcp/") || |
| 212 | url.pathname.startsWith("/a2a/") |
| 213 | ) { |
| 214 | return false; |
| 215 | } |
| 216 | return CACHEABLE_FILE_PATTERN.test(url.pathname); |
| 217 | } |
| 218 | |
| 219 | function isCacheableResponse(response) { |
| 220 | return response.ok && (response.type === "basic" || response.type === "default"); |
| 221 | } |
| 222 | |
| 223 | async function cacheRuntimeResponse(cache, request, response) { |
| 224 | const contentLength = response.headers.get("content-length"); |
| 225 | if (contentLength !== null) { |
| 226 | const size = Number(contentLength); |
| 227 | if (!Number.isFinite(size) || size > MAX_RUNTIME_CACHEABLE_TRANSFER_BYTES) return; |
| 228 | } else { |
| 229 | const body = await response.clone().arrayBuffer(); |
| 230 | if (body.byteLength > MAX_RUNTIME_CACHEABLE_TRANSFER_BYTES) return; |
| 231 | } |
| 232 | await cache.put(request, response); |
| 233 | } |