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