Phase 7c+7d: cache invalidation + offline data layer
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>
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
// =========================================================================
|
||||
|
||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
||||
var SX_VERSION = "2026-03-08T00:10:07Z";
|
||||
var SX_VERSION = "2026-03-08T00:44:09Z";
|
||||
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
@@ -1472,7 +1472,7 @@ continue; } else { return NIL; } } };
|
||||
})(); };
|
||||
|
||||
// process-response-headers
|
||||
var processResponseHeaders = function(getHeader) { return {["redirect"]: getHeader("SX-Redirect"), ["refresh"]: getHeader("SX-Refresh"), ["trigger"]: getHeader("SX-Trigger"), ["retarget"]: getHeader("SX-Retarget"), ["reswap"]: getHeader("SX-Reswap"), ["location"]: getHeader("SX-Location"), ["replace-url"]: getHeader("SX-Replace-Url"), ["css-hash"]: getHeader("SX-Css-Hash"), ["trigger-swap"]: getHeader("SX-Trigger-After-Swap"), ["trigger-settle"]: getHeader("SX-Trigger-After-Settle"), ["content-type"]: getHeader("Content-Type")}; };
|
||||
var processResponseHeaders = function(getHeader) { return {["redirect"]: getHeader("SX-Redirect"), ["refresh"]: getHeader("SX-Refresh"), ["trigger"]: getHeader("SX-Trigger"), ["retarget"]: getHeader("SX-Retarget"), ["reswap"]: getHeader("SX-Reswap"), ["location"]: getHeader("SX-Location"), ["replace-url"]: getHeader("SX-Replace-Url"), ["css-hash"]: getHeader("SX-Css-Hash"), ["trigger-swap"]: getHeader("SX-Trigger-After-Swap"), ["trigger-settle"]: getHeader("SX-Trigger-After-Settle"), ["content-type"]: getHeader("Content-Type"), ["cache-invalidate"]: getHeader("SX-Cache-Invalidate"), ["cache-update"]: getHeader("SX-Cache-Update")}; };
|
||||
|
||||
// parse-swap-spec
|
||||
var parseSwapSpec = function(rawSwap, globalTransitions_p) { return (function() {
|
||||
@@ -1746,6 +1746,7 @@ return forEach(function(attr) { return (isSxTruthy(!isSxTruthy(domHasAttr(newEl,
|
||||
return (isSxTruthy(newHash) ? (_cssHash = newHash) : NIL);
|
||||
})();
|
||||
dispatchTriggerEvents(el, get(respHeaders, "trigger"));
|
||||
processCacheDirectives(el, respHeaders, text);
|
||||
return (isSxTruthy(get(respHeaders, "redirect")) ? browserNavigate(get(respHeaders, "redirect")) : (isSxTruthy(get(respHeaders, "refresh")) ? browserReload() : (isSxTruthy(get(respHeaders, "location")) ? fetchLocation(get(respHeaders, "location")) : (function() {
|
||||
var targetEl = (isSxTruthy(get(respHeaders, "retarget")) ? domQuery(get(respHeaders, "retarget")) : resolveTarget(el));
|
||||
var swapSpec = parseSwapSpec(sxOr(get(respHeaders, "reswap"), domGetAttr(el, "sx-swap")), domHasClass(domBody(), "sx-transitions"));
|
||||
@@ -1955,6 +1956,42 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
|
||||
// page-data-cache-set
|
||||
var pageDataCacheSet = function(cacheKey, data) { return dictSet(_pageDataCache, cacheKey, {"data": data, "ts": nowMs()}); };
|
||||
|
||||
// invalidate-page-cache
|
||||
var invalidatePageCache = function(pageName) { { var _c = keys(_pageDataCache); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; if (isSxTruthy(sxOr((k == pageName), startsWith(k, (String(pageName) + String(":")))))) {
|
||||
_pageDataCache[k] = NIL;
|
||||
} } }
|
||||
swPostMessage({"type": "invalidate", "page": pageName});
|
||||
return logInfo((String("sx:cache invalidate ") + String(pageName))); };
|
||||
|
||||
// invalidate-all-page-cache
|
||||
var invalidateAllPageCache = function() { _pageDataCache = {};
|
||||
swPostMessage({"type": "invalidate", "page": "*"});
|
||||
return logInfo("sx:cache invalidate *"); };
|
||||
|
||||
// update-page-cache
|
||||
var updatePageCache = function(pageName, data) { return (function() {
|
||||
var cacheKey = pageDataCacheKey(pageName, {});
|
||||
pageDataCacheSet(cacheKey, data);
|
||||
return logInfo((String("sx:cache update ") + String(pageName)));
|
||||
})(); };
|
||||
|
||||
// process-cache-directives
|
||||
var processCacheDirectives = function(el, respHeaders, responseText) { (function() {
|
||||
var elInvalidate = domGetAttr(el, "sx-cache-invalidate");
|
||||
return (isSxTruthy(elInvalidate) ? (isSxTruthy((elInvalidate == "*")) ? invalidateAllPageCache() : invalidatePageCache(elInvalidate)) : NIL);
|
||||
})();
|
||||
(function() {
|
||||
var hdrInvalidate = get(respHeaders, "cache-invalidate");
|
||||
return (isSxTruthy(hdrInvalidate) ? (isSxTruthy((hdrInvalidate == "*")) ? invalidateAllPageCache() : invalidatePageCache(hdrInvalidate)) : NIL);
|
||||
})();
|
||||
return (function() {
|
||||
var hdrUpdate = get(respHeaders, "cache-update");
|
||||
return (isSxTruthy(hdrUpdate) ? (function() {
|
||||
var data = parseSxData(responseText);
|
||||
return (isSxTruthy(data) ? updatePageCache(hdrUpdate, data) : NIL);
|
||||
})() : NIL);
|
||||
})(); };
|
||||
|
||||
// current-page-layout
|
||||
var currentPageLayout = function() { return (function() {
|
||||
var pathname = urlPathname(browserLocationHref());
|
||||
@@ -2541,6 +2578,7 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
renderDomElement = function(tag, args, env, ns) {
|
||||
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
|
||||
var el = domCreateElement(tag, newNs);
|
||||
var extraClasses = [];
|
||||
var isVoid = contains(VOID_ELEMENTS, tag);
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
var arg = args[i];
|
||||
@@ -2563,6 +2601,10 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extraClasses.length) {
|
||||
var existing = el.getAttribute("class") || "";
|
||||
el.setAttribute("class", (existing ? existing + " " : "") + extraClasses.join(" "));
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
@@ -3291,6 +3333,27 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
});
|
||||
}
|
||||
|
||||
function parseSxData(text) {
|
||||
// Parse SX text into a data value. Returns the first parsed expression,
|
||||
// or NIL on error. Used by cache update directives.
|
||||
try {
|
||||
var exprs = parse(text);
|
||||
return exprs.length >= 1 ? exprs[0] : NIL;
|
||||
} catch (e) {
|
||||
logWarn("sx:cache parse error: " + (e && e.message ? e.message : e));
|
||||
return NIL;
|
||||
}
|
||||
}
|
||||
|
||||
function swPostMessage(msg) {
|
||||
// Send a message to the active service worker (if registered).
|
||||
// Used to notify SW of cache invalidation.
|
||||
if (typeof navigator !== "undefined" && navigator.serviceWorker &&
|
||||
navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function urlPathname(href) {
|
||||
try {
|
||||
return new URL(href, location.href).pathname;
|
||||
@@ -3425,37 +3488,6 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
}
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Platform interface — CSSX (style dictionary)
|
||||
// =========================================================================
|
||||
|
||||
function fnv1aHash(input) {
|
||||
var h = 0x811c9dc5;
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
h ^= input.charCodeAt(i);
|
||||
h = (h * 0x01000193) >>> 0;
|
||||
}
|
||||
return h.toString(16).padStart(8, "0").substring(0, 6);
|
||||
}
|
||||
|
||||
function compileRegex(pattern) {
|
||||
try { return new RegExp(pattern); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
function regexMatch(re, s) {
|
||||
if (!re) return NIL;
|
||||
var m = s.match(re);
|
||||
return m ? Array.prototype.slice.call(m) : NIL;
|
||||
}
|
||||
|
||||
function regexReplaceGroups(tmpl, match) {
|
||||
var result = tmpl;
|
||||
for (var j = 1; j < match.length; j++) {
|
||||
result = result.split("{" + (j - 1) + "}").join(match[j]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Platform interface — Boot (mount, hydrate, scripts, cookies)
|
||||
// =========================================================================
|
||||
@@ -3591,6 +3623,8 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Post-transpilation fixups
|
||||
// =========================================================================
|
||||
@@ -4359,6 +4393,14 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
}
|
||||
// Set up direct resolution for future chunks
|
||||
global.__sxResolve = function(id, sx) { resolveSuspense(id, sx); };
|
||||
// Register service worker for offline data caching
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/sx-sw.js", { scope: "/" }).then(function(reg) {
|
||||
logInfo("sx:sw registered (scope: " + reg.scope + ")");
|
||||
}).catch(function(err) {
|
||||
logWarn("sx:sw registration failed: " + (err && err.message ? err.message : err));
|
||||
});
|
||||
}
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", _sxInit);
|
||||
@@ -4369,4 +4411,4 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = Sx;
|
||||
else global.Sx = Sx;
|
||||
|
||||
})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this);
|
||||
})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this);
|
||||
|
||||
219
shared/static/scripts/sx-sw.js
Normal file
219
shared/static/scripts/sx-sw.js
Normal file
@@ -0,0 +1,219 @@
|
||||
// ==========================================================================
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user