7c: Client data cache management via element attributes (sx-cache-invalidate) and response headers (SX-Cache-Invalidate, SX-Cache-Update). Programmatic API: invalidate-page-cache, invalidate-all-page-cache, update-page-cache. 7d: Service Worker (sx-sw.js) with IndexedDB for offline-capable data caching. Network-first for /sx/data/ and /sx/io/, stale-while- revalidate for /static/. Cache invalidation propagates from in-memory cache to SW via postMessage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
220 lines
7.0 KiB
JavaScript
220 lines
7.0 KiB
JavaScript
// ==========================================================================
|
|
// 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);
|
|
}
|
|
});
|