From 84ea5d4c164f28609cf10476ed6494876481a260 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 7 Mar 2026 09:51:51 +0000 Subject: [PATCH] IO proxy: client-side cache with 5min TTL, server Cache-Control Client caches IO results by (name + args) in memory. In-flight promises are cached too (dedup concurrent calls for same args). Server adds Cache-Control: public, max-age=300 for HTTP caching. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 25 ++++++++++++++++++++++--- shared/sx/pages.py | 1 + shared/sx/ref/bootstrap_js.py | 23 +++++++++++++++++++++-- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 49fc728..239cfa3 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-07T09:40:17Z"; + var SX_VERSION = "2026-03-07T09:51:42Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -4328,10 +4328,24 @@ callExpr.push(dictGet(kwargs, k)); } } return asyncRenderChildren(exprs, env, null); } + // IO proxy cache: key → { value, expires } + var _ioCache = {}; + var IO_CACHE_TTL = 300000; // 5 minutes + // Register a server-proxied IO primitive: fetches from /sx/io/ // Uses GET for short args, POST for long payloads (URL length safety). + // Results are cached client-side by (name + args) with a TTL. function registerProxiedIo(name) { registerIoPrimitive(name, function(args, kwargs) { + // Cache key: name + serialized args + var cacheKey = name; + for (var ci = 0; ci < args.length; ci++) cacheKey += "" + String(args[ci]); + for (var ck in kwargs) { + if (kwargs.hasOwnProperty(ck)) cacheKey += "" + ck + "=" + String(kwargs[ck]); + } + var cached = _ioCache[cacheKey]; + if (cached && cached.expires > Date.now()) return cached.value; + var url = "/sx/io/" + encodeURIComponent(name); var qs = []; for (var i = 0; i < args.length; i++) { @@ -4364,7 +4378,7 @@ callExpr.push(dictGet(kwargs, k)); } } if (queryStr) url += "?" + queryStr; fetchOpts = { headers: { "SX-Request": "true" } }; } - return fetch(url, fetchOpts) + var result = fetch(url, fetchOpts) .then(function(resp) { if (!resp.ok) { logWarn("sx:io " + name + " failed " + resp.status); @@ -4376,7 +4390,9 @@ callExpr.push(dictGet(kwargs, k)); } } if (!text || text === "nil") return NIL; try { var exprs = parse(text); - return exprs.length === 1 ? exprs[0] : exprs; + var val = exprs.length === 1 ? exprs[0] : exprs; + _ioCache[cacheKey] = { value: val, expires: Date.now() + IO_CACHE_TTL }; + return val; } catch (e) { logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e)); return NIL; @@ -4386,6 +4402,9 @@ callExpr.push(dictGet(kwargs, k)); } } logWarn("sx:io " + name + " network error: " + (e && e.message ? e.message : e)); return NIL; }); + // Cache the in-flight promise too (dedup concurrent calls for same args) + _ioCache[cacheKey] = { value: result, expires: Date.now() + IO_CACHE_TTL }; + return result; }); } diff --git a/shared/sx/pages.py b/shared/sx/pages.py index baf341f..cb66a01 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -601,6 +601,7 @@ def mount_io_endpoint(app: Any, service_name: str) -> None: result_sx = serialize(result) if result is not None else "nil" resp = await make_response(result_sx, 200) resp.content_type = "text/sx; charset=utf-8" + resp.headers["Cache-Control"] = "public, max-age=300" return resp io_proxy.__name__ = "sx_io_proxy" diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 0c7cca3..e94964d 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -1698,10 +1698,24 @@ ASYNC_IO_JS = ''' return asyncRenderChildren(exprs, env, null); } + // IO proxy cache: key → { value, expires } + var _ioCache = {}; + var IO_CACHE_TTL = 300000; // 5 minutes + // Register a server-proxied IO primitive: fetches from /sx/io/ // Uses GET for short args, POST for long payloads (URL length safety). + // Results are cached client-side by (name + args) with a TTL. function registerProxiedIo(name) { registerIoPrimitive(name, function(args, kwargs) { + // Cache key: name + serialized args + var cacheKey = name; + for (var ci = 0; ci < args.length; ci++) cacheKey += "\0" + String(args[ci]); + for (var ck in kwargs) { + if (kwargs.hasOwnProperty(ck)) cacheKey += "\0" + ck + "=" + String(kwargs[ck]); + } + var cached = _ioCache[cacheKey]; + if (cached && cached.expires > Date.now()) return cached.value; + var url = "/sx/io/" + encodeURIComponent(name); var qs = []; for (var i = 0; i < args.length; i++) { @@ -1734,7 +1748,7 @@ ASYNC_IO_JS = ''' if (queryStr) url += "?" + queryStr; fetchOpts = { headers: { "SX-Request": "true" } }; } - return fetch(url, fetchOpts) + var result = fetch(url, fetchOpts) .then(function(resp) { if (!resp.ok) { logWarn("sx:io " + name + " failed " + resp.status); @@ -1746,7 +1760,9 @@ ASYNC_IO_JS = ''' if (!text || text === "nil") return NIL; try { var exprs = parse(text); - return exprs.length === 1 ? exprs[0] : exprs; + var val = exprs.length === 1 ? exprs[0] : exprs; + _ioCache[cacheKey] = { value: val, expires: Date.now() + IO_CACHE_TTL }; + return val; } catch (e) { logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e)); return NIL; @@ -1756,6 +1772,9 @@ ASYNC_IO_JS = ''' logWarn("sx:io " + name + " network error: " + (e && e.message ? e.message : e)); return NIL; }); + // Cache the in-flight promise too (dedup concurrent calls for same args) + _ioCache[cacheKey] = { value: result, expires: Date.now() + IO_CACHE_TTL }; + return result; }); }