// Automaker Service Worker - Optimized for mobile PWA loading performance // NOTE: CACHE_NAME is injected with a build hash at build time by the swCacheBuster // Vite plugin (see vite.config.mts). In development it stays as-is; in production // builds it becomes e.g. 'automaker-v5-a1b2c3d4' for automatic cache invalidation. const CACHE_NAME = 'automaker-v5'; // replaced at build time → 'automaker-v5-' // Separate cache for immutable hashed assets (long-lived) const IMMUTABLE_CACHE = 'automaker-immutable-v2'; // Separate cache for API responses (short-lived, stale-while-revalidate on mobile) const API_CACHE = 'automaker-api-v1'; // Assets to cache on install (app shell for instant loading) const SHELL_ASSETS = [ '/', '/index.html', '/manifest.json', '/logo.png', '/logo_larger.png', '/automaker.svg', '/favicon.ico', ]; // Critical JS/CSS assets extracted from index.html at build time by the swCacheBuster // Vite plugin. Populated during production builds; empty in dev mode. // These are precached on SW install so that PWA cold starts after memory eviction // serve instantly from cache instead of requiring a full network download. const CRITICAL_ASSETS = []; // Whether mobile caching is enabled (set via message from main thread). // Persisted to Cache Storage so it survives aggressive SW termination on mobile. let mobileMode = false; const MOBILE_MODE_CACHE_KEY = 'automaker-sw-config'; const MOBILE_MODE_URL = '/sw-config/mobile-mode'; /** * Persist mobileMode to Cache Storage so it survives SW restarts. * Service workers on mobile get killed aggressively — without persistence, * mobileMode resets to false and API caching silently stops working. */ async function persistMobileMode(enabled) { try { const cache = await caches.open(MOBILE_MODE_CACHE_KEY); const response = new Response(JSON.stringify({ mobileMode: enabled }), { headers: { 'Content-Type': 'application/json' }, }); await cache.put(MOBILE_MODE_URL, response); } catch (_e) { // Best-effort persistence — SW still works without it } } /** * Restore mobileMode from Cache Storage on SW startup. */ async function restoreMobileMode() { try { const cache = await caches.open(MOBILE_MODE_CACHE_KEY); const response = await cache.match(MOBILE_MODE_URL); if (response) { const data = await response.json(); mobileMode = !!data.mobileMode; } } catch (_e) { // Best-effort restore — defaults to false } } // Restore mobileMode immediately on SW startup // Keep a promise so fetch handlers can await restoration on cold SW starts. // This prevents a race where early API requests run before mobileMode is loaded // from Cache Storage, incorrectly falling back to network-first. const mobileModeRestorePromise = restoreMobileMode(); // API endpoints that are safe to serve from stale cache on mobile. // These are GET-only, read-heavy endpoints where showing slightly stale data // is far better than a blank screen or reload on flaky mobile connections. const CACHEABLE_API_PATTERNS = [ '/api/features', '/api/settings', '/api/models', '/api/usage', '/api/worktrees', '/api/github', '/api/cli', '/api/sessions', '/api/running-agents', '/api/pipeline', '/api/workspace', '/api/spec', ]; // Max age for API cache entries (5 minutes). // After this, even mobile will require a network fetch. const API_CACHE_MAX_AGE = 5 * 60 * 1000; // Maximum entries in API cache to prevent unbounded growth const API_CACHE_MAX_ENTRIES = 100; /** * Check if an API request is safe to cache (read-only data endpoints) */ function isCacheableApiRequest(url) { const path = url.pathname; if (!path.startsWith('/api/')) return false; return CACHEABLE_API_PATTERNS.some((pattern) => path.startsWith(pattern)); } /** * Check if a cached API response is still fresh enough to use */ function isApiCacheFresh(response) { const cachedAt = response.headers.get('x-sw-cached-at'); if (!cachedAt) return false; return Date.now() - parseInt(cachedAt, 10) < API_CACHE_MAX_AGE; } /** * Clone a response and add a timestamp header for cache freshness tracking. * Uses arrayBuffer() instead of blob() to avoid doubling memory for large responses. */ async function addCacheTimestamp(response) { const headers = new Headers(response.headers); headers.set('x-sw-cached-at', String(Date.now())); const body = await response.clone().arrayBuffer(); return new Response(body, { status: response.status, statusText: response.statusText, headers, }); } self.addEventListener('install', (event) => { // Cache the app shell AND critical JS/CSS assets so the PWA loads instantly. // SHELL_ASSETS go into CACHE_NAME (general cache), CRITICAL_ASSETS go into // IMMUTABLE_CACHE (long-lived, content-hashed assets). This ensures that even // the very first visit populates the immutable cache — previously, assets were // only cached on fetch interception, but the SW isn't active during the first // page load so nothing got cached until the second visit. // // self.skipWaiting() is NOT called here — activation is deferred until the main // thread sends a SKIP_WAITING message to avoid disrupting a live page. event.waitUntil( Promise.all([ // Cache app shell (HTML, icons, manifest) using individual fetch+put instead of // cache.addAll(). This is critical because cache.addAll() respects the server's // Cache-Control response headers — if the server sends 'Cache-Control: no-store' // (which Vite dev server does for index.html), addAll() silently skips caching // and the pre-React loading spinner is never served from cache. // // cache.put() bypasses Cache-Control headers entirely, ensuring the app shell // is always cached on install regardless of what the server sends. This is the // correct approach for SW-managed caches where the SW (not HTTP headers) controls // freshness via the activate event's cache cleanup and the navigation strategy's // background revalidation. caches.open(CACHE_NAME).then((cache) => Promise.all( SHELL_ASSETS.map((url) => fetch(url) .then((response) => { if (response.ok) return cache.put(url, response); }) .catch(() => { // Individual asset fetch failure is non-fatal — the SW still activates // and the next navigation will populate the cache via Strategy 3. }) ) ) ), // Cache critical JS/CSS bundles (injected at build time by swCacheBuster). // Uses individual fetch+put instead of cache.addAll() so a single asset // failure doesn't prevent the rest from being cached. // // IMPORTANT: We fetch with { mode: 'cors' } because Vite's output HTML uses //