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

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

View File

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

View File

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

View File

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