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:
2026-03-08 00:45:33 +00:00
parent 9a707dbe56
commit 0ce3f95d6c
7 changed files with 519 additions and 49 deletions

View File

@@ -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);

View 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);
}
});