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);
|
||||
}
|
||||
});
|
||||
@@ -884,6 +884,9 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
|
||||
# Mount IO proxy endpoint for Phase 5: client-side IO primitives
|
||||
mount_io_endpoint(app, service_name)
|
||||
|
||||
# Mount service worker at root scope for offline data layer
|
||||
mount_service_worker(app)
|
||||
|
||||
|
||||
def mount_pages(bp: Any, service_name: str,
|
||||
names: set[str] | list[str] | None = None) -> None:
|
||||
@@ -1186,3 +1189,39 @@ def mount_io_endpoint(app: Any, service_name: str) -> None:
|
||||
methods=["GET", "POST"],
|
||||
)
|
||||
logger.info("Mounted IO proxy for %s: %s", service_name, sorted(_ALLOWED_IO))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service Worker mount
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SW_MOUNTED = False
|
||||
|
||||
def mount_service_worker(app: Any) -> None:
|
||||
"""Mount the SX service worker at /sx-sw.js (root scope).
|
||||
|
||||
The service worker provides offline data caching:
|
||||
- /sx/data/* and /sx/io/* responses cached in IndexedDB
|
||||
- /static/* assets cached via Cache API (stale-while-revalidate)
|
||||
- Everything else passes through to network
|
||||
"""
|
||||
global _SW_MOUNTED
|
||||
if _SW_MOUNTED:
|
||||
return
|
||||
_SW_MOUNTED = True
|
||||
|
||||
sw_path = os.path.join(
|
||||
os.path.dirname(__file__), "..", "static", "scripts", "sx-sw.js"
|
||||
)
|
||||
sw_path = os.path.abspath(sw_path)
|
||||
|
||||
async def serve_sw():
|
||||
from quart import send_file
|
||||
return await send_file(
|
||||
sw_path,
|
||||
mimetype="application/javascript",
|
||||
cache_timeout=0, # no caching — SW updates checked by browser
|
||||
)
|
||||
|
||||
app.add_url_rule("/sx-sw.js", endpoint="sx_service_worker", view_func=serve_sw)
|
||||
logger.info("Mounted service worker at /sx-sw.js")
|
||||
|
||||
@@ -3551,6 +3551,27 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -4025,6 +4046,14 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has
|
||||
}
|
||||
// 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);
|
||||
|
||||
@@ -154,17 +154,19 @@
|
||||
;; Extract all SX response header directives into a dict.
|
||||
;; get-header is (fn (name) → string or nil).
|
||||
(dict
|
||||
"redirect" (get-header "SX-Redirect")
|
||||
"refresh" (get-header "SX-Refresh")
|
||||
"trigger" (get-header "SX-Trigger")
|
||||
"retarget" (get-header "SX-Retarget")
|
||||
"reswap" (get-header "SX-Reswap")
|
||||
"location" (get-header "SX-Location")
|
||||
"replace-url" (get-header "SX-Replace-Url")
|
||||
"css-hash" (get-header "SX-Css-Hash")
|
||||
"trigger-swap" (get-header "SX-Trigger-After-Swap")
|
||||
"trigger-settle" (get-header "SX-Trigger-After-Settle")
|
||||
"content-type" (get-header "Content-Type"))))
|
||||
"redirect" (get-header "SX-Redirect")
|
||||
"refresh" (get-header "SX-Refresh")
|
||||
"trigger" (get-header "SX-Trigger")
|
||||
"retarget" (get-header "SX-Retarget")
|
||||
"reswap" (get-header "SX-Reswap")
|
||||
"location" (get-header "SX-Location")
|
||||
"replace-url" (get-header "SX-Replace-Url")
|
||||
"css-hash" (get-header "SX-Css-Hash")
|
||||
"trigger-swap" (get-header "SX-Trigger-After-Swap")
|
||||
"trigger-settle" (get-header "SX-Trigger-After-Settle")
|
||||
"content-type" (get-header "Content-Type")
|
||||
"cache-invalidate" (get-header "SX-Cache-Invalidate")
|
||||
"cache-update" (get-header "SX-Cache-Update"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -195,6 +195,10 @@
|
||||
;; Triggers (before swap)
|
||||
(dispatch-trigger-events el (get resp-headers "trigger"))
|
||||
|
||||
;; Cache directives — process before navigation so cache is
|
||||
;; ready when the target page loads.
|
||||
(process-cache-directives el resp-headers text)
|
||||
|
||||
(cond
|
||||
;; Redirect
|
||||
(get resp-headers "redirect")
|
||||
@@ -589,6 +593,76 @@
|
||||
{"data" data "ts" (now-ms)})))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Client-side routing — cache management
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define invalidate-page-cache
|
||||
(fn (page-name)
|
||||
;; Clear cached data for a page. Removes all cache entries whose key
|
||||
;; matches page-name (exact) or starts with "page-name:" (with params).
|
||||
;; Also notifies the service worker to clear its IndexedDB entries.
|
||||
(for-each
|
||||
(fn (k)
|
||||
(when (or (= k page-name) (starts-with? k (str page-name ":")))
|
||||
(dict-set! _page-data-cache k nil)))
|
||||
(keys _page-data-cache))
|
||||
(sw-post-message {"type" "invalidate" "page" page-name})
|
||||
(log-info (str "sx:cache invalidate " page-name))))
|
||||
|
||||
(define invalidate-all-page-cache
|
||||
(fn ()
|
||||
;; Clear all cached page data and notify service worker.
|
||||
(set! _page-data-cache (dict))
|
||||
(sw-post-message {"type" "invalidate" "page" "*"})
|
||||
(log-info "sx:cache invalidate *")))
|
||||
|
||||
(define update-page-cache
|
||||
(fn (page-name data)
|
||||
;; Replace cached data for a page with server-provided data.
|
||||
;; Uses a bare page-name key (no params) — the server knows the
|
||||
;; canonical data shape for the page.
|
||||
(let ((cache-key (page-data-cache-key page-name (dict))))
|
||||
(page-data-cache-set cache-key data)
|
||||
(log-info (str "sx:cache update " page-name)))))
|
||||
|
||||
(define process-cache-directives
|
||||
(fn (el resp-headers response-text)
|
||||
;; Process cache invalidation and update directives from both
|
||||
;; element attributes and response headers.
|
||||
;;
|
||||
;; Element attributes (set by component author):
|
||||
;; sx-cache-invalidate="page-name" — clear page cache on success
|
||||
;; sx-cache-invalidate="*" — clear all page caches
|
||||
;;
|
||||
;; Response headers (set by server):
|
||||
;; SX-Cache-Invalidate: page-name — clear page cache
|
||||
;; SX-Cache-Update: page-name — replace cache with response data
|
||||
|
||||
;; 1. Element-level invalidation
|
||||
(let ((el-invalidate (dom-get-attr el "sx-cache-invalidate")))
|
||||
(when el-invalidate
|
||||
(if (= el-invalidate "*")
|
||||
(invalidate-all-page-cache)
|
||||
(invalidate-page-cache el-invalidate))))
|
||||
|
||||
;; 2. Response header invalidation
|
||||
(let ((hdr-invalidate (get resp-headers "cache-invalidate")))
|
||||
(when hdr-invalidate
|
||||
(if (= hdr-invalidate "*")
|
||||
(invalidate-all-page-cache)
|
||||
(invalidate-page-cache hdr-invalidate))))
|
||||
|
||||
;; 3. Response header cache update (server pushes fresh data)
|
||||
;; parse-sx-data is a platform-provided function that parses SX text
|
||||
;; into a data value (returns nil on parse error).
|
||||
(let ((hdr-update (get resp-headers "cache-update")))
|
||||
(when hdr-update
|
||||
(let ((data (parse-sx-data response-text)))
|
||||
(when data
|
||||
(update-page-cache hdr-update data)))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Client-side routing
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -1061,6 +1135,8 @@
|
||||
;; (resolve-page-data name params cb) → void; resolves data for a named page.
|
||||
;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict)
|
||||
;; when data is available. params is a dict of URL/route parameters.
|
||||
;; (parse-sx-data text) → parsed SX data value, or nil on error.
|
||||
;; Used by cache update to parse server-provided data in SX format.
|
||||
;;
|
||||
;; From boot.sx:
|
||||
;; _page-routes → list of route entries
|
||||
@@ -1080,4 +1156,8 @@
|
||||
;; (csrf-token) → string
|
||||
;; (cross-origin? url) → boolean
|
||||
;; (now-ms) → timestamp ms
|
||||
;;
|
||||
;; === Cache management ===
|
||||
;; (parse-sx-data text) → parsed SX data value, or nil on error
|
||||
;; (sw-post-message msg) → void; post message to active service worker
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -1974,7 +1974,7 @@
|
||||
(div :class "flex items-center gap-2 mb-2"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
|
||||
(p :class "text-green-900 font-medium" "What it enables")
|
||||
(p :class "text-green-800" "Same SX code runs on either side. Runtime chooses optimal split via affinity annotations and render plans. Cross-host isomorphism verified by 61 automated tests."))
|
||||
(p :class "text-green-800" "Same SX code runs on either side. Runtime chooses optimal split via affinity annotations and render plans. Client data cache managed via invalidation headers and server-driven updates. Cross-host isomorphism verified by 61 automated tests."))
|
||||
|
||||
(~doc-subsection :title "7a. Affinity Annotations & Render Target"
|
||||
|
||||
@@ -2039,11 +2039,70 @@
|
||||
(li "Render plans visible on " (a :href "/isomorphism/affinity" "affinity demo page"))
|
||||
(li "Client page registry includes :render-plan for each page"))))
|
||||
|
||||
(~doc-subsection :title "7c. Optimistic Data Updates"
|
||||
(p "Extend existing apply-optimistic/revert-optimistic in engine.sx from DOM-level to data-level. Client updates cached data optimistically, sends mutation to server, reverts on rejection."))
|
||||
(~doc-subsection :title "7c. Cache Invalidation & Data Updates"
|
||||
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-3 mb-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
|
||||
(p :class "text-green-800 text-sm" "Client data cache management: invalidation on mutation, server-driven cache updates, and programmatic cache control from SX code."))
|
||||
|
||||
(p "The client-side page data cache (30-second TTL) now supports cache invalidation and server-driven updates, extending the existing DOM-level " (code "apply-optimistic") "/" (code "revert-optimistic") " to data-level cache management.")
|
||||
|
||||
(~doc-subsection :title "Element Attributes"
|
||||
(p "Component authors can declare cache invalidation on elements that trigger mutations:")
|
||||
(~doc-code :code (highlight ";; Clear specific page's cache after successful action\n(form :sx-post \"/cart/remove\"\n :sx-cache-invalidate \"cart-page\"\n ...)\n\n;; Clear ALL page caches after action\n(button :sx-post \"/admin/reset\"\n :sx-cache-invalidate \"*\")" "lisp"))
|
||||
(p "When the request succeeds, the named page's data cache is cleared. The next client-side navigation to that page will re-fetch fresh data from the server."))
|
||||
|
||||
(~doc-subsection :title "Response Headers"
|
||||
(p "The server can also control client cache via response headers:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (code "SX-Cache-Invalidate: page-name") " — clear cache for a page")
|
||||
(li (code "SX-Cache-Invalidate: *") " — clear all page caches")
|
||||
(li (code "SX-Cache-Update: page-name") " — replace cache with the response body (SX-format data)"))
|
||||
(p (code "SX-Cache-Update") " is the strongest form: the server pushes authoritative data directly into the client cache, so the user sees fresh data immediately on next navigation — no re-fetch needed."))
|
||||
|
||||
(~doc-subsection :title "Programmatic API"
|
||||
(p "Three functions available from SX orchestration code:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "(invalidate-page-cache page-name)")
|
||||
(li "(invalidate-all-page-cache)")
|
||||
(li "(update-page-cache page-name data)")))
|
||||
|
||||
(~doc-subsection :title "Files"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "shared/sx/ref/engine.sx — SX-Cache-Invalidate, SX-Cache-Update response headers")
|
||||
(li "shared/sx/ref/orchestration.sx — cache management functions, process-cache-directives")
|
||||
(li "shared/sx/ref/bootstrap_js.py — parseSxData platform function"))))
|
||||
|
||||
(~doc-subsection :title "7d. Offline Data Layer"
|
||||
(p "Service Worker intercepts /internal/data/ requests, serves from IndexedDB when offline, syncs when back online."))
|
||||
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-3 mb-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
|
||||
(p :class "text-green-800 text-sm" "Service Worker with IndexedDB caching for offline-capable page data and IO responses. Static assets cached via Cache API."))
|
||||
|
||||
(p "A Service Worker registered at " (code "/sx-sw.js") " provides three-tier caching:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (strong "/sx/data/* ") "— network-first with IndexedDB fallback. Page data is cached on successful fetch and served from IndexedDB when offline.")
|
||||
(li (strong "/sx/io/* ") "— network-first with IndexedDB fallback. IO proxy responses cached the same way.")
|
||||
(li (strong "/static/* ") "— stale-while-revalidate via Cache API. Serves cached CSS/JS/images immediately, updates in background."))
|
||||
|
||||
(p "Cache invalidation flows through to the Service Worker: when " (code "invalidate-page-cache") " clears the in-memory cache, it also sends a " (code "postMessage") " to the SW which removes matching entries from IndexedDB.")
|
||||
|
||||
(~doc-subsection :title "How It Works"
|
||||
(ol :class "list-decimal list-inside text-stone-700 space-y-2"
|
||||
(li "On boot, " (code "sx-browser.js") " registers the SW at " (code "/sx-sw.js") " (root scope)")
|
||||
(li "SW intercepts fetch events and routes by URL pattern")
|
||||
(li "For data/IO: try network first, on failure serve from IndexedDB")
|
||||
(li "For static assets: serve from Cache API, revalidate in background")
|
||||
(li "Cache invalidation propagates: element attr / response header → in-memory cache → SW message → IndexedDB")))
|
||||
|
||||
(~doc-subsection :title "Files"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "shared/static/scripts/sx-sw.js — Service Worker (network-first + stale-while-revalidate)")
|
||||
(li "shared/sx/pages.py — mount_service_worker() serves SW at /sx-sw.js")
|
||||
(li "shared/sx/ref/bootstrap_js.py — SW registration in boot init")
|
||||
(li "shared/sx/ref/orchestration.sx — sw-post-message for cache invalidation"))))
|
||||
|
||||
(~doc-subsection :title "7e. Isomorphic Testing"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user