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 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 09:51:51 +00:00
parent 0d6b959045
commit 84ea5d4c16
3 changed files with 44 additions and 5 deletions

View File

@@ -14,7 +14,7 @@
// ========================================================================= // =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); 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 isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); } function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -4328,10 +4328,24 @@ callExpr.push(dictGet(kwargs, k)); } }
return asyncRenderChildren(exprs, env, null); 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/<name> // Register a server-proxied IO primitive: fetches from /sx/io/<name>
// Uses GET for short args, POST for long payloads (URL length safety). // 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) { function registerProxiedIo(name) {
registerIoPrimitive(name, function(args, kwargs) { 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 url = "/sx/io/" + encodeURIComponent(name);
var qs = []; var qs = [];
for (var i = 0; i < args.length; i++) { for (var i = 0; i < args.length; i++) {
@@ -4364,7 +4378,7 @@ callExpr.push(dictGet(kwargs, k)); } }
if (queryStr) url += "?" + queryStr; if (queryStr) url += "?" + queryStr;
fetchOpts = { headers: { "SX-Request": "true" } }; fetchOpts = { headers: { "SX-Request": "true" } };
} }
return fetch(url, fetchOpts) var result = fetch(url, fetchOpts)
.then(function(resp) { .then(function(resp) {
if (!resp.ok) { if (!resp.ok) {
logWarn("sx:io " + name + " failed " + resp.status); logWarn("sx:io " + name + " failed " + resp.status);
@@ -4376,7 +4390,9 @@ callExpr.push(dictGet(kwargs, k)); } }
if (!text || text === "nil") return NIL; if (!text || text === "nil") return NIL;
try { try {
var exprs = parse(text); 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) { } catch (e) {
logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e)); logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e));
return NIL; return NIL;
@@ -4386,6 +4402,9 @@ callExpr.push(dictGet(kwargs, k)); } }
logWarn("sx:io " + name + " network error: " + (e && e.message ? e.message : e)); logWarn("sx:io " + name + " network error: " + (e && e.message ? e.message : e));
return NIL; 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;
}); });
} }

View File

@@ -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" result_sx = serialize(result) if result is not None else "nil"
resp = await make_response(result_sx, 200) resp = await make_response(result_sx, 200)
resp.content_type = "text/sx; charset=utf-8" resp.content_type = "text/sx; charset=utf-8"
resp.headers["Cache-Control"] = "public, max-age=300"
return resp return resp
io_proxy.__name__ = "sx_io_proxy" io_proxy.__name__ = "sx_io_proxy"

View File

@@ -1698,10 +1698,24 @@ ASYNC_IO_JS = '''
return asyncRenderChildren(exprs, env, null); 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/<name> // Register a server-proxied IO primitive: fetches from /sx/io/<name>
// Uses GET for short args, POST for long payloads (URL length safety). // 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) { function registerProxiedIo(name) {
registerIoPrimitive(name, function(args, kwargs) { 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 url = "/sx/io/" + encodeURIComponent(name);
var qs = []; var qs = [];
for (var i = 0; i < args.length; i++) { for (var i = 0; i < args.length; i++) {
@@ -1734,7 +1748,7 @@ ASYNC_IO_JS = '''
if (queryStr) url += "?" + queryStr; if (queryStr) url += "?" + queryStr;
fetchOpts = { headers: { "SX-Request": "true" } }; fetchOpts = { headers: { "SX-Request": "true" } };
} }
return fetch(url, fetchOpts) var result = fetch(url, fetchOpts)
.then(function(resp) { .then(function(resp) {
if (!resp.ok) { if (!resp.ok) {
logWarn("sx:io " + name + " failed " + resp.status); logWarn("sx:io " + name + " failed " + resp.status);
@@ -1746,7 +1760,9 @@ ASYNC_IO_JS = '''
if (!text || text === "nil") return NIL; if (!text || text === "nil") return NIL;
try { try {
var exprs = parse(text); 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) { } catch (e) {
logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e)); logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e));
return NIL; return NIL;
@@ -1756,6 +1772,9 @@ ASYNC_IO_JS = '''
logWarn("sx:io " + name + " network error: " + (e && e.message ? e.message : e)); logWarn("sx:io " + name + " network error: " + (e && e.message ? e.message : e));
return NIL; 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;
}); });
} }