main
js 233 lines 7.23 KB
Raw
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 }