// ========================================================================== // sx-sw.js — SX Service Worker // // Provides offline-capable caching for the SX platform: // /sx/data/* — Network-first, IndexedDB fallback (page data) // /sx/io/* — Network-first, IndexedDB fallback (IO proxy) // /static/* — Stale-while-revalidate (CSS, JS, images) // Other — Network-only (pass through) // // IndexedDB stores response text + content-type with timestamps. // Stale IDB entries are served when offline but refreshed when online. // ========================================================================== var IDB_NAME = "sx-offline"; var IDB_VERSION = 1; var IDB_STORE = "responses"; var STATIC_CACHE = "sx-static-v1"; // --------------------------------------------------------------------------- // IndexedDB helpers // --------------------------------------------------------------------------- function openDB() { return new Promise(function(resolve, reject) { var req = indexedDB.open(IDB_NAME, IDB_VERSION); req.onupgradeneeded = function() { if (!req.result.objectStoreNames.contains(IDB_STORE)) { req.result.createObjectStore(IDB_STORE); } }; req.onsuccess = function() { resolve(req.result); }; req.onerror = function() { reject(req.error); }; }); } function idbGet(key) { return openDB().then(function(db) { return new Promise(function(resolve, reject) { var tx = db.transaction(IDB_STORE, "readonly"); var req = tx.objectStore(IDB_STORE).get(key); req.onsuccess = function() { resolve(req.result || null); }; req.onerror = function() { reject(req.error); }; }); }); } function idbSet(key, value) { return openDB().then(function(db) { return new Promise(function(resolve, reject) { var tx = db.transaction(IDB_STORE, "readwrite"); tx.objectStore(IDB_STORE).put(value, key); tx.oncomplete = function() { resolve(); }; tx.onerror = function() { reject(tx.error); }; }); }); } function idbDelete(key) { return openDB().then(function(db) { return new Promise(function(resolve, reject) { var tx = db.transaction(IDB_STORE, "readwrite"); tx.objectStore(IDB_STORE).delete(key); tx.oncomplete = function() { resolve(); }; tx.onerror = function() { reject(tx.error); }; }); }); } function idbClear() { return openDB().then(function(db) { return new Promise(function(resolve, reject) { var tx = db.transaction(IDB_STORE, "readwrite"); tx.objectStore(IDB_STORE).clear(); tx.oncomplete = function() { resolve(); }; tx.onerror = function() { reject(tx.error); }; }); }); } // --------------------------------------------------------------------------- // Fetch strategies // --------------------------------------------------------------------------- // Network-first with IndexedDB fallback. // Used for /sx/data/ and /sx/io/ — dynamic data that should be fresh // when online but available from cache when offline. function networkFirstIDB(request) { var key = request.url; return fetch(request.clone()).then(function(resp) { if (resp.ok) { var ct = resp.headers.get("Content-Type") || "text/sx"; resp.clone().text().then(function(text) { idbSet(key, { text: text, contentType: ct, ts: Date.now() }); }); } return resp; }).catch(function() { // Network failed — try IndexedDB return idbGet(key).then(function(cached) { if (cached) { return new Response(cached.text, { status: 200, headers: { "Content-Type": cached.contentType || "text/sx", "X-SX-Offline": "1", "X-SX-Cached-At": String(cached.ts) } }); } return new Response("", { status: 503, statusText: "Offline" }); }); }); } // Stale-while-revalidate for static assets. // Serve from Cache API immediately, update in background. function staleWhileRevalidate(request) { return caches.open(STATIC_CACHE).then(function(cache) { return cache.match(request).then(function(cached) { var fetchPromise = fetch(request.clone()).then(function(resp) { if (resp.ok) { cache.put(request, resp.clone()); } return resp; }).catch(function() { return cached || new Response("", { status: 503 }); }); return cached || fetchPromise; }); }); } // --------------------------------------------------------------------------- // Service Worker lifecycle // --------------------------------------------------------------------------- self.addEventListener("install", function(event) { // Activate immediately — don't wait for old SW to stop self.skipWaiting(); }); self.addEventListener("activate", function(event) { // Claim all clients immediately event.waitUntil( Promise.all([ self.clients.claim(), // Clean old static caches caches.keys().then(function(names) { return Promise.all( names.filter(function(n) { return n !== STATIC_CACHE; }) .map(function(n) { return caches.delete(n); }) ); }) ]) ); }); // --------------------------------------------------------------------------- // Fetch routing // --------------------------------------------------------------------------- self.addEventListener("fetch", function(event) { var url = new URL(event.request.url); // Only handle same-origin GET requests if (event.request.method !== "GET") return; if (url.origin !== self.location.origin) return; // SX data endpoints — network-first with IDB fallback if (url.pathname.indexOf("/sx/data/") === 0 || url.pathname.indexOf("/sx/io/") === 0) { event.respondWith(networkFirstIDB(event.request)); return; } // Static assets — stale-while-revalidate if (url.pathname.indexOf("/static/") === 0) { event.respondWith(staleWhileRevalidate(event.request)); return; } // Everything else: pass through to network (default behavior) }); // --------------------------------------------------------------------------- // Message handling — cache control from main thread // --------------------------------------------------------------------------- self.addEventListener("message", function(event) { var msg = event.data; if (!msg || !msg.type) return; if (msg.type === "invalidate") { // Invalidate specific page data from IDB if (msg.page === "*") { idbClear(); } else if (msg.page) { // Remove all IDB entries matching this page openDB().then(function(db) { var tx = db.transaction(IDB_STORE, "readwrite"); var store = tx.objectStore(IDB_STORE); var req = store.openCursor(); req.onsuccess = function() { var cursor = req.result; if (cursor) { if (cursor.key.indexOf("/sx/data/" + msg.page) >= 0) { cursor.delete(); } cursor.continue(); } }; }); } } if (msg.type === "clear-static") { caches.delete(STATIC_CACHE); } });