From 79fa1411dc5d635e48f9b17b9cc17c53c0c11792 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 7 Mar 2026 08:12:42 +0000 Subject: [PATCH] =?UTF-8?q?Phase=205:=20async=20IO=20rendering=20=E2=80=94?= =?UTF-8?q?=20components=20call=20IO=20primitives=20client-side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire async rendering into client-side routing: pages whose component trees reference IO primitives (highlight, current-user, etc.) now render client-side via Promise-aware asyncRenderToDom. IO calls proxy through /sx/io/ endpoint, which falls back to page helpers. - Add has-io flag to page registry entries (helpers.py) - Remove IO purity filter — include IO-dependent components in bundles - Extend try-client-route with 4 paths: pure, data, IO, data+IO - Convert tryAsyncEvalContent to callback style, add platform mapping - IO proxy falls back to page helpers (highlight works via proxy) - Demo page: /isomorphism/async-io with inline highlight calls Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 854 +++++++++++++++++++++------- shared/sx/helpers.py | 10 + shared/sx/jinja_bridge.py | 24 +- shared/sx/pages.py | 73 +++ shared/sx/ref/boot.sx | 1 + shared/sx/ref/bootstrap_js.py | 635 +++++++++++++++++++++ shared/sx/ref/orchestration.sx | 93 ++- sx/sx/async-io-demo.sx | 62 ++ sx/sx/nav-data.sx | 3 +- sx/sxc/pages/docs.sx | 11 + 10 files changed, 1502 insertions(+), 264 deletions(-) create mode 100644 sx/sx/async-io-demo.sx diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 35d6982..e92e774 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-07T02:10:28Z"; + var SX_VERSION = "2026-03-07T02:32:34Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -565,92 +565,6 @@ return makeThunk(componentBody(comp), local); }; - // ========================================================================= - // Platform: deps module — component dependency analysis - // ========================================================================= - - function componentDeps(c) { - return c.deps ? c.deps.slice() : []; - } - - function componentSetDeps(c, deps) { - c.deps = deps; - } - - function componentCssClasses(c) { - return c.cssClasses ? c.cssClasses.slice() : []; - } - - function envComponents(env) { - var names = []; - for (var k in env) { - var v = env[k]; - if (v && (v._component || v._macro)) names.push(k); - } - return names; - } - - function regexFindAll(pattern, source) { - var re = new RegExp(pattern, "g"); - var results = []; - var m; - while ((m = re.exec(source)) !== null) { - if (m[1] !== undefined) results.push(m[1]); - else results.push(m[0]); - } - return results; - } - - function scanCssClasses(source) { - var classes = {}; - var result = []; - var m; - var re1 = /:class\s+"([^"]*)"/g; - while ((m = re1.exec(source)) !== null) { - var parts = m[1].split(/\s+/); - for (var i = 0; i < parts.length; i++) { - if (parts[i] && !classes[parts[i]]) { - classes[parts[i]] = true; - result.push(parts[i]); - } - } - } - var re2 = /:class\s+\(str\s+((?:"[^"]*"\s*)+)\)/g; - while ((m = re2.exec(source)) !== null) { - var re3 = /"([^"]*)"/g; - var m2; - while ((m2 = re3.exec(m[1])) !== null) { - var parts2 = m2[1].split(/\s+/); - for (var j = 0; j < parts2.length; j++) { - if (parts2[j] && !classes[parts2[j]]) { - classes[parts2[j]] = true; - result.push(parts2[j]); - } - } - } - } - var re4 = /;;\s*@css\s+(.+)/g; - while ((m = re4.exec(source)) !== null) { - var parts3 = m[1].split(/\s+/); - for (var k = 0; k < parts3.length; k++) { - if (parts3[k] && !classes[parts3[k]]) { - classes[parts3[k]] = true; - result.push(parts3[k]); - } - } - } - return result; - } - - function componentIoRefs(c) { - return c.ioRefs ? c.ioRefs.slice() : []; - } - - function componentSetIoRefs(c, refs) { - c.ioRefs = refs; - } - - // ========================================================================= // Platform interface — Parser // ========================================================================= @@ -2116,24 +2030,31 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\" var pageName = get(match, "name"); return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? (logWarn((String("sx:route no content for ") + String(pathname))), false) : (function() { var target = resolveRouteTarget(targetSel); - return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(!isSxTruthy(depsSatisfied_p(match))) ? (logInfo((String("sx:route deps miss for ") + String(pageName))), false) : (isSxTruthy(get(match, "has-data")) ? (function() { + return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(!isSxTruthy(depsSatisfied_p(match))) ? (logInfo((String("sx:route deps miss for ") + String(pageName))), false) : (function() { + var hasIo = get(match, "has-io"); + return (isSxTruthy(get(match, "has-data")) ? (function() { var cacheKey = pageDataCacheKey(pageName, params); var cached = pageDataCacheGet(cacheKey); return (isSxTruthy(cached) ? (function() { var env = merge(closure, params, cached); + return (isSxTruthy(hasIo) ? (logInfo((String("sx:route client+cache+async ") + String(pathname))), tryAsyncEvalContent(contentSrc, env, function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }), true) : (function() { var rendered = tryEvalContent(contentSrc, env); return (isSxTruthy(isNil(rendered)) ? (logWarn((String("sx:route cached eval failed for ") + String(pathname))), false) : (logInfo((String("sx:route client+cache ") + String(pathname))), swapRenderedContent(target, rendered, pathname), true)); +})()); })() : (logInfo((String("sx:route client+data ") + String(pathname))), resolvePageData(pageName, params, function(data) { pageDataCacheSet(cacheKey, data); return (function() { var env = merge(closure, params, data); + return (isSxTruthy(hasIo) ? tryAsyncEvalContent(contentSrc, env, function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data+async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }) : (function() { var rendered = tryEvalContent(contentSrc, env); return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); +})()); })(); }), true)); -})() : (function() { +})() : (isSxTruthy(hasIo) ? (logInfo((String("sx:route client+async ") + String(pathname))), tryAsyncEvalContent(contentSrc, merge(closure, params), function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }), true) : (function() { var env = merge(closure, params); var rendered = tryEvalContent(contentSrc, env); return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (swapRenderedContent(target, rendered, pathname), true)); -})()))); +})())); +})())); })()); })()); })(); }; @@ -2555,120 +2476,7 @@ callExpr.push(dictGet(kwargs, k)); } } })(); }; // boot-init - var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), initStyleDict(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); }; - - - // === Transpiled from deps (component dependency analysis) === - - // scan-refs - var scanRefs = function(node) { return (function() { - var refs = []; - scanRefsWalk(node, refs); - return refs; -})(); }; - - // scan-refs-walk - var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() { - var name = symbolName(node); - return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL); -})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); }; - - // transitive-deps-walk - var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() { - var val = envGet(env, n); - return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL)); -})()) : NIL); }; - - // transitive-deps - var transitiveDeps = function(name, env) { return (function() { - var seen = []; - var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name))); - transitiveDepsWalk(key, seen, env); - return filter(function(x) { return !isSxTruthy((x == key)); }, seen); -})(); }; - - // compute-all-deps - var computeAllDeps = function(env) { return forEach(function(name) { return (function() { - var val = envGet(env, name); - return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL); -})(); }, envComponents(env)); }; - - // scan-components-from-source - var scanComponentsFromSource = function(source) { return (function() { - var matches = regexFindAll("\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)", source); - return map(function(m) { return (String("~") + String(m)); }, matches); -})(); }; - - // components-needed - var componentsNeeded = function(pageSource, env) { return (function() { - var direct = scanComponentsFromSource(pageSource); - var allNeeded = []; - { var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(allNeeded, name)))) { - allNeeded.push(name); -} -(function() { - var val = envGet(env, name); - return (function() { - var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isSxTruthy(isEmpty(componentDeps(val))))) ? componentDeps(val) : transitiveDeps(name, env)); - return forEach(function(dep) { return (isSxTruthy(!isSxTruthy(contains(allNeeded, dep))) ? append_b(allNeeded, dep) : NIL); }, deps); -})(); -})(); } } - return allNeeded; -})(); }; - - // page-component-bundle - var pageComponentBundle = function(pageSource, env) { return componentsNeeded(pageSource, env); }; - - // page-css-classes - var pageCssClasses = function(pageSource, env) { return (function() { - var needed = componentsNeeded(pageSource, env); - var classes = []; - { var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() { - var val = envGet(env, name); - return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!isSxTruthy(contains(classes, cls))) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL); -})(); } } - { var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(classes, cls)))) { - classes.push(cls); -} } } - return classes; -})(); }; - - // scan-io-refs-walk - var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() { - var name = symbolName(node); - return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL); -})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); }; - - // scan-io-refs - var scanIoRefs = function(node, ioNames) { return (function() { - var refs = []; - scanIoRefsWalk(node, ioNames, refs); - return refs; -})(); }; - - // transitive-io-refs-walk - var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() { - var val = envGet(env, n); - return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL)); -})()) : NIL); }; - - // transitive-io-refs - var transitiveIoRefs = function(name, env, ioNames) { return (function() { - var allRefs = []; - var seen = []; - var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name))); - transitiveIoRefsWalk(key, seen, allRefs, env, ioNames); - return allRefs; -})(); }; - - // compute-all-io-refs - var computeAllIoRefs = function(env, ioNames) { return forEach(function(name) { return (function() { - var val = envGet(env, name); - return (isSxTruthy((typeOf(val) == "component")) ? componentSetIoRefs(val, transitiveIoRefs(name, env, ioNames)) : NIL); -})(); }, envComponents(env)); }; - - // component-pure? - var componentPure_p = function(name, env, ioNames) { return isEmpty(transitiveIoRefs(name, env, ioNames)); }; + var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), initStyleDict(), initIoPrimitives(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); }; // === Transpiled from router (client-side route matching) === @@ -3510,6 +3318,32 @@ callExpr.push(dictGet(kwargs, k)); } } } } + // Async eval with callback — used for pages with IO deps. + // Calls callback(rendered) when done, callback(null) on failure. + function tryAsyncEvalContent(source, env, callback) { + var merged = merge(componentEnv); + if (env && !isNil(env)) { + var ks = Object.keys(env); + for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]]; + } + try { + var result = asyncSxRenderWithEnv(source, merged); + if (isPromise(result)) { + result.then(function(rendered) { + callback(rendered); + }).catch(function(e) { + logInfo("sx:async eval miss: " + (e && e.message ? e.message : e)); + callback(null); + }); + } else { + callback(result); + } + } catch (e) { + logInfo("sx:async eval miss: " + (e && e.message ? e.message : e)); + callback(null); + } + } + function resolvePageData(pageName, params, callback) { // Platform implementation: fetch page data via HTTP from /sx/data/ endpoint. // The spec only knows about resolve-page-data(name, params, callback) — @@ -3939,6 +3773,605 @@ callExpr.push(dictGet(kwargs, k)); } } if (typeof aser === "function") PRIMITIVES["aser"] = aser; if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom; + // ========================================================================= + // Async IO: Promise-aware rendering for client-side IO primitives + // ========================================================================= + // + // IO primitives (query, current-user, etc.) return Promises on the client. + // asyncRenderToDom walks the component tree; when it encounters an IO + // primitive, it awaits the Promise and continues rendering. + // + // The sync evaluator/renderer is untouched. This is a separate async path + // used only when a page's component tree contains IO references. + + var IO_PRIMITIVES = {}; + + function registerIoPrimitive(name, fn) { + IO_PRIMITIVES[name] = fn; + } + + function isPromise(x) { + return x != null && typeof x === "object" && typeof x.then === "function"; + } + + // Async trampoline: resolves thunks, awaits Promises + function asyncTrampoline(val) { + if (isPromise(val)) return val.then(asyncTrampoline); + if (isThunk(val)) return asyncTrampoline(evalExpr(thunkExpr(val), thunkEnv(val))); + return val; + } + + // Async eval: like trampoline(evalExpr(...)) but handles IO primitives + function asyncEval(expr, env) { + // Intercept IO primitive calls at the AST level + if (Array.isArray(expr) && expr.length > 0) { + var head = expr[0]; + if (head && head._sym) { + var name = head.name; + if (IO_PRIMITIVES[name]) { + // Evaluate args, then call the IO primitive + return asyncEvalIoCall(name, expr.slice(1), env); + } + } + } + // Non-IO: use sync eval, but result might be a thunk + var result = evalExpr(expr, env); + return asyncTrampoline(result); + } + + function asyncEvalIoCall(name, rawArgs, env) { + // Parse keyword args and positional args, evaluating each (may be async) + var kwargs = {}; + var args = []; + var promises = []; + var i = 0; + while (i < rawArgs.length) { + var arg = rawArgs[i]; + if (arg && arg._kw && (i + 1) < rawArgs.length) { + var kName = arg.name; + var kVal = asyncEval(rawArgs[i + 1], env); + if (isPromise(kVal)) { + (function(k) { promises.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName); + } else { + kwargs[kName] = kVal; + } + i += 2; + } else { + var aVal = asyncEval(arg, env); + if (isPromise(aVal)) { + (function(idx) { promises.push(aVal.then(function(v) { args[idx] = v; })); })(args.length); + args.push(null); // placeholder + } else { + args.push(aVal); + } + i++; + } + } + var ioFn = IO_PRIMITIVES[name]; + if (promises.length > 0) { + return Promise.all(promises).then(function() { return ioFn(args, kwargs); }); + } + return ioFn(args, kwargs); + } + + // Async render-to-dom: returns Promise or Node + function asyncRenderToDom(expr, env, ns) { + // Literals + if (expr === NIL || expr === null || expr === undefined) return null; + if (expr === true || expr === false) return null; + if (typeof expr === "string") return document.createTextNode(expr); + if (typeof expr === "number") return document.createTextNode(String(expr)); + + // Symbol -> async eval then render + if (expr && expr._sym) { + var val = asyncEval(expr, env); + if (isPromise(val)) return val.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(val, env, ns); + } + + // Keyword + if (expr && expr._kw) return document.createTextNode(expr.name); + + // DocumentFragment / DOM nodes pass through + if (expr instanceof DocumentFragment || (expr && expr.nodeType)) return expr; + + // Dict -> skip + if (expr && typeof expr === "object" && !Array.isArray(expr)) return null; + + // List + if (!Array.isArray(expr) || expr.length === 0) return null; + + var head = expr[0]; + if (!head) return null; + + // Symbol head + if (head._sym) { + var hname = head.name; + + // IO primitive + if (IO_PRIMITIVES[hname]) { + var ioResult = asyncEval(expr, env); + if (isPromise(ioResult)) return ioResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(ioResult, env, ns); + } + + // Fragment + if (hname === "<>") return asyncRenderChildren(expr.slice(1), env, ns); + + // raw! + if (hname === "raw!") { + return asyncEvalRaw(expr.slice(1), env); + } + + // Special forms that need async handling + if (hname === "if") return asyncRenderIf(expr, env, ns); + if (hname === "when") return asyncRenderWhen(expr, env, ns); + if (hname === "cond") return asyncRenderCond(expr, env, ns); + if (hname === "case") return asyncRenderCase(expr, env, ns); + if (hname === "let" || hname === "let*") return asyncRenderLet(expr, env, ns); + if (hname === "begin" || hname === "do") return asyncRenderChildren(expr.slice(1), env, ns); + if (hname === "map") return asyncRenderMap(expr, env, ns); + if (hname === "map-indexed") return asyncRenderMapIndexed(expr, env, ns); + if (hname === "for-each") return asyncRenderMap(expr, env, ns); + + // define/defcomp/defmacro — eval for side effects + if (hname === "define" || hname === "defcomp" || hname === "defmacro" || + hname === "defstyle" || hname === "defkeyframes" || hname === "defhandler") { + trampoline(evalExpr(expr, env)); + return null; + } + + // quote + if (hname === "quote") return null; + + // lambda/fn + if (hname === "lambda" || hname === "fn") { + trampoline(evalExpr(expr, env)); + return null; + } + + // and/or — eval and render result + if (hname === "and" || hname === "or" || hname === "->") { + var aoResult = asyncEval(expr, env); + if (isPromise(aoResult)) return aoResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(aoResult, env, ns); + } + + // set! + if (hname === "set!") { + asyncEval(expr, env); + return null; + } + + // Component + if (hname.charAt(0) === "~") { + var comp = env[hname]; + if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns); + if (comp && comp._macro) { + var expanded = trampoline(expandMacro(comp, expr.slice(1), env)); + return asyncRenderToDom(expanded, env, ns); + } + } + + // Macro + if (env[hname] && env[hname]._macro) { + var mac = env[hname]; + var expanded = trampoline(expandMacro(mac, expr.slice(1), env)); + return asyncRenderToDom(expanded, env, ns); + } + + // HTML tag + if (typeof renderDomElement === "function" && contains(HTML_TAGS, hname)) { + return asyncRenderElement(hname, expr.slice(1), env, ns); + } + + // html: prefix + if (hname.indexOf("html:") === 0) { + return asyncRenderElement(hname.slice(5), expr.slice(1), env, ns); + } + + // Custom element + if (hname.indexOf("-") >= 0 && expr.length > 1 && expr[1] && expr[1]._kw) { + return asyncRenderElement(hname, expr.slice(1), env, ns); + } + + // SVG context + if (ns) return asyncRenderElement(hname, expr.slice(1), env, ns); + + // Fallback: eval and render + var fResult = asyncEval(expr, env); + if (isPromise(fResult)) return fResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(fResult, env, ns); + } + + // Non-symbol head: eval call + var cResult = asyncEval(expr, env); + if (isPromise(cResult)) return cResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(cResult, env, ns); + } + + function asyncRenderChildren(exprs, env, ns) { + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < exprs.length; i++) { + var result = asyncRenderToDom(exprs[i], env, ns); + if (isPromise(result)) { + // Insert placeholder, replace when resolved + var placeholder = document.createComment("async"); + frag.appendChild(placeholder); + (function(ph) { + pending.push(result.then(function(node) { + if (node) ph.parentNode.replaceChild(node, ph); + else ph.parentNode.removeChild(ph); + })); + })(placeholder); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length > 0) { + return Promise.all(pending).then(function() { return frag; }); + } + return frag; + } + + function asyncRenderElement(tag, args, env, ns) { + var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns; + var el = domCreateElement(tag, newNs); + var pending = []; + var isVoid = contains(VOID_ELEMENTS, tag); + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + if (arg && arg._kw && (i + 1) < args.length) { + var attrName = arg.name; + var attrVal = asyncEval(args[i + 1], env); + i++; + if (isPromise(attrVal)) { + (function(an, av) { + pending.push(av.then(function(v) { + if (!isNil(v) && v !== false) { + if (contains(BOOLEAN_ATTRS, an)) { if (isSxTruthy(v)) el.setAttribute(an, ""); } + else if (v === true) el.setAttribute(an, ""); + else el.setAttribute(an, String(v)); + } + })); + })(attrName, attrVal); + } else { + if (!isNil(attrVal) && attrVal !== false) { + if (attrName === "class" && attrVal && attrVal._styleValue) { + el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className); + } else if (attrName === "style" && attrVal && attrVal._styleValue) { + el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className); + } else if (contains(BOOLEAN_ATTRS, attrName)) { + if (isSxTruthy(attrVal)) el.setAttribute(attrName, ""); + } else if (attrVal === true) { + el.setAttribute(attrName, ""); + } else { + el.setAttribute(attrName, String(attrVal)); + } + } + } + } else if (!isVoid) { + var child = asyncRenderToDom(arg, env, newNs); + if (isPromise(child)) { + var placeholder = document.createComment("async"); + el.appendChild(placeholder); + (function(ph) { + pending.push(child.then(function(node) { + if (node) ph.parentNode.replaceChild(node, ph); + else ph.parentNode.removeChild(ph); + })); + })(placeholder); + } else if (child) { + el.appendChild(child); + } + } + } + if (pending.length > 0) return Promise.all(pending).then(function() { return el; }); + return el; + } + + function asyncRenderComponent(comp, args, env, ns) { + var kwargs = {}; + var children = []; + var pending = []; + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + if (arg && arg._kw && (i + 1) < args.length) { + var kName = arg.name; + var kVal = asyncEval(args[i + 1], env); + if (isPromise(kVal)) { + (function(k) { pending.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName); + } else { + kwargs[kName] = kVal; + } + i++; + } else { + children.push(arg); + } + } + + function doRender() { + var local = Object.create(componentClosure(comp)); + for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k]; + var params = componentParams(comp); + for (var j = 0; j < params.length; j++) { + local[params[j]] = params[j] in kwargs ? kwargs[params[j]] : NIL; + } + if (componentHasChildren(comp)) { + var childResult = asyncRenderChildren(children, env, ns); + if (isPromise(childResult)) { + return childResult.then(function(childFrag) { + local["children"] = childFrag; + return asyncRenderToDom(componentBody(comp), local, ns); + }); + } + local["children"] = childResult; + } + return asyncRenderToDom(componentBody(comp), local, ns); + } + + if (pending.length > 0) return Promise.all(pending).then(doRender); + return doRender(); + } + + function asyncRenderIf(expr, env, ns) { + var cond = asyncEval(expr[1], env); + if (isPromise(cond)) { + return cond.then(function(v) { + return isSxTruthy(v) + ? asyncRenderToDom(expr[2], env, ns) + : (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null); + }); + } + return isSxTruthy(cond) + ? asyncRenderToDom(expr[2], env, ns) + : (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null); + } + + function asyncRenderWhen(expr, env, ns) { + var cond = asyncEval(expr[1], env); + if (isPromise(cond)) { + return cond.then(function(v) { + return isSxTruthy(v) ? asyncRenderChildren(expr.slice(2), env, ns) : null; + }); + } + return isSxTruthy(cond) ? asyncRenderChildren(expr.slice(2), env, ns) : null; + } + + function asyncRenderCond(expr, env, ns) { + var clauses = expr.slice(1); + function step(idx) { + if (idx >= clauses.length) return null; + var clause = clauses[idx]; + if (!Array.isArray(clause) || clause.length < 2) return step(idx + 1); + var test = clause[0]; + if ((test && test._sym && (test.name === "else" || test.name === ":else")) || + (test && test._kw && test.name === "else")) { + return asyncRenderToDom(clause[1], env, ns); + } + var v = asyncEval(test, env); + if (isPromise(v)) return v.then(function(r) { return isSxTruthy(r) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); }); + return isSxTruthy(v) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); + } + return step(0); + } + + function asyncRenderCase(expr, env, ns) { + var matchVal = asyncEval(expr[1], env); + function doCase(mv) { + var clauses = expr.slice(2); + for (var i = 0; i < clauses.length - 1; i += 2) { + var test = clauses[i]; + if ((test && test._kw && test.name === "else") || + (test && test._sym && (test.name === "else" || test.name === ":else"))) { + return asyncRenderToDom(clauses[i + 1], env, ns); + } + var tv = trampoline(evalExpr(test, env)); + if (mv === tv || (typeof mv === "string" && typeof tv === "string" && mv === tv)) { + return asyncRenderToDom(clauses[i + 1], env, ns); + } + } + return null; + } + if (isPromise(matchVal)) return matchVal.then(doCase); + return doCase(matchVal); + } + + function asyncRenderLet(expr, env, ns) { + var bindings = expr[1]; + var local = Object.create(env); + for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k]; + function bindStep(idx) { + if (!Array.isArray(bindings)) return asyncRenderChildren(expr.slice(2), local, ns); + // Nested pairs: ((a 1) (b 2)) + if (bindings.length > 0 && Array.isArray(bindings[0])) { + if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns); + var b = bindings[idx]; + var vname = b[0]._sym ? b[0].name : String(b[0]); + var val = asyncEval(b[1], local); + if (isPromise(val)) return val.then(function(v) { local[vname] = v; return bindStep(idx + 1); }); + local[vname] = val; + return bindStep(idx + 1); + } + // Flat pairs: (a 1 b 2) + if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns); + var vn = bindings[idx]._sym ? bindings[idx].name : String(bindings[idx]); + var vv = asyncEval(bindings[idx + 1], local); + if (isPromise(vv)) return vv.then(function(v) { local[vn] = v; return bindStep(idx + 2); }); + local[vn] = vv; + return bindStep(idx + 2); + } + return bindStep(0); + } + + function asyncRenderMap(expr, env, ns) { + var fn = asyncEval(expr[1], env); + var coll = asyncEval(expr[2], env); + function doMap(f, c) { + if (!Array.isArray(c)) return null; + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < c.length; i++) { + var item = c[i]; + var result; + if (f && f._lambda) { + var lenv = Object.create(f._closure || env); + for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k]; + lenv[f._params[0]] = item; + result = asyncRenderToDom(f._body, lenv, null); + } else if (typeof f === "function") { + var r = f(item); + result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null); + } else { + result = asyncRenderToDom(item, env, null); + } + if (isPromise(result)) { + var ph = document.createComment("async"); + frag.appendChild(ph); + (function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length) return Promise.all(pending).then(function() { return frag; }); + return frag; + } + if (isPromise(fn) || isPromise(coll)) { + return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)]) + .then(function(r) { return doMap(r[0], r[1]); }); + } + return doMap(fn, coll); + } + + function asyncRenderMapIndexed(expr, env, ns) { + var fn = asyncEval(expr[1], env); + var coll = asyncEval(expr[2], env); + function doMap(f, c) { + if (!Array.isArray(c)) return null; + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < c.length; i++) { + var item = c[i]; + var result; + if (f && f._lambda) { + var lenv = Object.create(f._closure || env); + for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k]; + lenv[f._params[0]] = i; + lenv[f._params[1]] = item; + result = asyncRenderToDom(f._body, lenv, null); + } else if (typeof f === "function") { + var r = f(i, item); + result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null); + } else { + result = asyncRenderToDom(item, env, null); + } + if (isPromise(result)) { + var ph = document.createComment("async"); + frag.appendChild(ph); + (function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length) return Promise.all(pending).then(function() { return frag; }); + return frag; + } + if (isPromise(fn) || isPromise(coll)) { + return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)]) + .then(function(r) { return doMap(r[0], r[1]); }); + } + return doMap(fn, coll); + } + + function asyncEvalRaw(args, env) { + var parts = []; + var pending = []; + for (var i = 0; i < args.length; i++) { + var val = asyncEval(args[i], env); + if (isPromise(val)) { + (function(idx) { + pending.push(val.then(function(v) { parts[idx] = v; })); + })(parts.length); + parts.push(null); + } else { + parts.push(val); + } + } + function assemble() { + var html = ""; + for (var j = 0; j < parts.length; j++) { + var p = parts[j]; + if (p && p._rawHtml) html += p.html; + else if (typeof p === "string") html += p; + else if (p != null && !isNil(p)) html += String(p); + } + var el = document.createElement("span"); + el.innerHTML = html; + var frag = document.createDocumentFragment(); + while (el.firstChild) frag.appendChild(el.firstChild); + return frag; + } + if (pending.length) return Promise.all(pending).then(assemble); + return assemble(); + } + + // Async version of sxRenderWithEnv — returns Promise + function asyncSxRenderWithEnv(source, extraEnv) { + var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv; + var exprs = parse(source); + if (!_hasDom) return Promise.resolve(null); + return asyncRenderChildren(exprs, env, null); + } + + // Register a server-proxied IO primitive: fetches from /sx/io/ + function registerProxiedIo(name) { + registerIoPrimitive(name, function(args, kwargs) { + var url = "/sx/io/" + encodeURIComponent(name); + var qs = []; + for (var i = 0; i < args.length; i++) { + qs.push("_arg" + i + "=" + encodeURIComponent(String(args[i]))); + } + for (var k in kwargs) { + if (kwargs.hasOwnProperty(k)) { + qs.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(kwargs[k]))); + } + } + if (qs.length) url += "?" + qs.join("&"); + return fetch(url, { headers: { "SX-Request": "true" } }) + .then(function(resp) { + if (!resp.ok) { + logWarn("sx:io " + name + " failed " + resp.status); + return NIL; + } + return resp.text(); + }) + .then(function(text) { + if (!text || text === "nil") return NIL; + try { + var exprs = parse(text); + return exprs.length === 1 ? exprs[0] : exprs; + } catch (e) { + logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e)); + return NIL; + } + }); + }); + } + + // Register default proxied IO primitives + function initIoPrimitives() { + var defaults = [ + "highlight", "current-user", "request-arg", "request-path", + "app-url", "asset-url", "config" + ]; + for (var i = 0; i < defaults.length; i++) { + registerProxiedIo(defaults[i]); + } + logInfo("sx:io registered " + defaults.length + " proxied primitives"); + } + + // Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes) var parse = sxParse; @@ -4007,20 +4440,13 @@ callExpr.push(dictGet(kwargs, k)); } } renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null, getEnv: function() { return componentEnv; }, init: typeof bootInit === "function" ? bootInit : null, - scanRefs: scanRefs, - transitiveDeps: transitiveDeps, - computeAllDeps: computeAllDeps, - componentsNeeded: componentsNeeded, - pageComponentBundle: pageComponentBundle, - pageCssClasses: pageCssClasses, - scanIoRefs: scanIoRefs, - transitiveIoRefs: transitiveIoRefs, - computeAllIoRefs: computeAllIoRefs, - componentPure_p: componentPure_p, splitPathSegments: splitPathSegments, parseRoutePattern: parseRoutePattern, matchRoute: matchRoute, findMatchingRoute: findMatchingRoute, + registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null, + asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null, + asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null, _version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)" }; diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index 4d9d776..d42b77c 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -691,6 +691,15 @@ def _build_pages_sx(service: str) -> str: deps = components_needed(content_src, _COMPONENT_ENV) deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")" + # Check if any dep component has IO refs (needs async rendering) + from .types import Component as _Comp + has_io = "false" + for dep_name in deps: + comp = _COMPONENT_ENV.get(dep_name) + if isinstance(comp, _Comp) and comp.io_refs: + has_io = "true" + break + # Build closure as SX dict closure_parts: list[str] = [] for k, v in page_def.closure.items(): @@ -703,6 +712,7 @@ def _build_pages_sx(service: str) -> str: + " :path " + _sx_literal(page_def.path) + " :auth " + _sx_literal(auth) + " :has-data " + has_data + + " :has-io " + has_io + " :content " + _sx_literal(content_src) + " :deps " + deps_sx + " :closure " + closure_sx + "}" diff --git a/shared/sx/jinja_bridge.py b/shared/sx/jinja_bridge.py index 35bff0c..abae4d4 100644 --- a/shared/sx/jinja_bridge.py +++ b/shared/sx/jinja_bridge.py @@ -349,23 +349,15 @@ def components_for_page(page_sx: str, service: str | None = None) -> tuple[str, needed = components_needed(page_sx, _COMPONENT_ENV) - # Include deps for :data pages whose component trees are fully pure - # (no IO refs). Pages with IO deps must render server-side. + # Include deps for all :data pages so the client can render them. + # Pages with IO deps use the async render path (Phase 5) — the IO + # primitives are proxied via /sx/io/. if service: from .pages import get_all_pages for page_def in get_all_pages(service).values(): if page_def.data_expr is not None and page_def.content_expr is not None: content_src = serialize(page_def.content_expr) - data_deps = components_needed(content_src, _COMPONENT_ENV) - # Check if any dep component has IO refs - has_io = False - for dep_name in data_deps: - comp = _COMPONENT_ENV.get(dep_name) - if isinstance(comp, Component) and comp.io_refs: - has_io = True - break - if not has_io: - needed |= data_deps + needed |= components_needed(content_src, _COMPONENT_ENV) if not needed: return "", "" @@ -416,13 +408,7 @@ def css_classes_for_page(page_sx: str, service: str | None = None) -> set[str]: for page_def in get_all_pages(service).values(): if page_def.data_expr is not None and page_def.content_expr is not None: content_src = serialize(page_def.content_expr) - data_deps = components_needed(content_src, _COMPONENT_ENV) - has_io = any( - isinstance(_COMPONENT_ENV.get(d), Component) and _COMPONENT_ENV.get(d).io_refs - for d in data_deps - ) - if not has_io: - needed |= data_deps + needed |= components_needed(content_src, _COMPONENT_ENV) classes: set[str] = set() for key, val in _COMPONENT_ENV.items(): diff --git a/shared/sx/pages.py b/shared/sx/pages.py index 88c428d..a3ba7e7 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -331,6 +331,9 @@ def auto_mount_pages(app: Any, service_name: str) -> None: if has_data_pages: auto_mount_page_data(app, service_name) + # Mount IO proxy endpoint for Phase 5: client-side IO primitives + mount_io_endpoint(app, service_name) + def mount_pages(bp: Any, service_name: str, names: set[str] | list[str] | None = None) -> None: @@ -535,3 +538,73 @@ def auto_mount_page_data(app: Any, service_name: str) -> None: methods=["GET"], ) logger.info("Mounted page data endpoint for %s at /sx/data/", service_name) + + +def mount_io_endpoint(app: Any, service_name: str) -> None: + """Mount /sx/io/ endpoint for client-side IO primitive calls. + + The client can call any allowed IO primitive or page helper via GET/POST. + Result is returned as SX wire format (text/sx). + + Falls back to page helpers when the name isn't a global IO primitive, + so service-specific functions like ``highlight`` work via the proxy. + """ + import asyncio as _asyncio + from quart import make_response, request, abort as quart_abort + from .primitives_io import IO_PRIMITIVES, execute_io + from .jinja_bridge import _get_request_context + from .parser import serialize + + # Allowlist of IO primitives + page helpers the client may call + _ALLOWED_IO = { + "highlight", "current-user", "request-arg", "request-path", + "htmx-request?", "app-url", "asset-url", "config", + } + + async def io_proxy(name: str) -> Any: + if name not in _ALLOWED_IO: + quart_abort(403) + + # Parse args from query string or JSON body + args: list = [] + kwargs: dict = {} + if request.method == "GET": + for k, v in request.args.items(): + if k.startswith("_arg"): + args.append(v) + else: + kwargs[k] = v + else: + data = await request.get_json(silent=True) or {} + args = data.get("args", []) + kwargs = data.get("kwargs", {}) + + # Try global IO primitives first + if name in IO_PRIMITIVES: + ctx = _get_request_context() + result = await execute_io(name, args, kwargs, ctx) + else: + # Fall back to page helpers (service-specific functions) + helpers = get_page_helpers(service_name) + helper_fn = helpers.get(name) + if helper_fn is None: + quart_abort(404) + result = helper_fn(*args, **kwargs) if kwargs else helper_fn(*args) + if _asyncio.iscoroutine(result): + result = await result + + 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" + return resp + + io_proxy.__name__ = "sx_io_proxy" + io_proxy.__qualname__ = "sx_io_proxy" + + app.add_url_rule( + "/sx/io/", + endpoint="sx_io_proxy", + view_func=io_proxy, + methods=["GET", "POST"], + ) + logger.info("Mounted IO proxy endpoint for %s at /sx/io/", service_name) diff --git a/shared/sx/ref/boot.sx b/shared/sx/ref/boot.sx index 64d91b8..24677cc 100644 --- a/shared/sx/ref/boot.sx +++ b/shared/sx/ref/boot.sx @@ -344,6 +344,7 @@ (log-info (str "sx-browser " SX_VERSION)) (init-css-tracking) (init-style-dict) + (init-io-primitives) (process-page-scripts) (process-sx-scripts nil) (sx-hydrate-elements nil) diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index f2d62be..a02163b 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -384,6 +384,7 @@ class JSEmitter: "bind-client-route-click": "bindClientRouteClick", "try-client-route": "tryClientRoute", "try-eval-content": "tryEvalContent", + "try-async-eval-content": "tryAsyncEvalContent", "url-pathname": "urlPathname", "bind-inline-handler": "bindInlineHandler", "bind-preload": "bindPreload", @@ -476,6 +477,7 @@ class JSEmitter: "process-sx-scripts": "processSxScripts", "process-component-script": "processComponentScript", "init-style-dict": "initStyleDict", + "init-io-primitives": "initIoPrimitives", "SX_VERSION": "SX_VERSION", "boot-init": "bootInit", "resolve-mount-target": "resolveMountTarget", @@ -1144,6 +1146,607 @@ CONTINUATIONS_JS = ''' ''' +ASYNC_IO_JS = ''' + // ========================================================================= + // Async IO: Promise-aware rendering for client-side IO primitives + // ========================================================================= + // + // IO primitives (query, current-user, etc.) return Promises on the client. + // asyncRenderToDom walks the component tree; when it encounters an IO + // primitive, it awaits the Promise and continues rendering. + // + // The sync evaluator/renderer is untouched. This is a separate async path + // used only when a page's component tree contains IO references. + + var IO_PRIMITIVES = {}; + + function registerIoPrimitive(name, fn) { + IO_PRIMITIVES[name] = fn; + } + + function isPromise(x) { + return x != null && typeof x === "object" && typeof x.then === "function"; + } + + // Async trampoline: resolves thunks, awaits Promises + function asyncTrampoline(val) { + if (isPromise(val)) return val.then(asyncTrampoline); + if (isThunk(val)) return asyncTrampoline(evalExpr(thunkExpr(val), thunkEnv(val))); + return val; + } + + // Async eval: like trampoline(evalExpr(...)) but handles IO primitives + function asyncEval(expr, env) { + // Intercept IO primitive calls at the AST level + if (Array.isArray(expr) && expr.length > 0) { + var head = expr[0]; + if (head && head._sym) { + var name = head.name; + if (IO_PRIMITIVES[name]) { + // Evaluate args, then call the IO primitive + return asyncEvalIoCall(name, expr.slice(1), env); + } + } + } + // Non-IO: use sync eval, but result might be a thunk + var result = evalExpr(expr, env); + return asyncTrampoline(result); + } + + function asyncEvalIoCall(name, rawArgs, env) { + // Parse keyword args and positional args, evaluating each (may be async) + var kwargs = {}; + var args = []; + var promises = []; + var i = 0; + while (i < rawArgs.length) { + var arg = rawArgs[i]; + if (arg && arg._kw && (i + 1) < rawArgs.length) { + var kName = arg.name; + var kVal = asyncEval(rawArgs[i + 1], env); + if (isPromise(kVal)) { + (function(k) { promises.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName); + } else { + kwargs[kName] = kVal; + } + i += 2; + } else { + var aVal = asyncEval(arg, env); + if (isPromise(aVal)) { + (function(idx) { promises.push(aVal.then(function(v) { args[idx] = v; })); })(args.length); + args.push(null); // placeholder + } else { + args.push(aVal); + } + i++; + } + } + var ioFn = IO_PRIMITIVES[name]; + if (promises.length > 0) { + return Promise.all(promises).then(function() { return ioFn(args, kwargs); }); + } + return ioFn(args, kwargs); + } + + // Async render-to-dom: returns Promise or Node + function asyncRenderToDom(expr, env, ns) { + // Literals + if (expr === NIL || expr === null || expr === undefined) return null; + if (expr === true || expr === false) return null; + if (typeof expr === "string") return document.createTextNode(expr); + if (typeof expr === "number") return document.createTextNode(String(expr)); + + // Symbol -> async eval then render + if (expr && expr._sym) { + var val = asyncEval(expr, env); + if (isPromise(val)) return val.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(val, env, ns); + } + + // Keyword + if (expr && expr._kw) return document.createTextNode(expr.name); + + // DocumentFragment / DOM nodes pass through + if (expr instanceof DocumentFragment || (expr && expr.nodeType)) return expr; + + // Dict -> skip + if (expr && typeof expr === "object" && !Array.isArray(expr)) return null; + + // List + if (!Array.isArray(expr) || expr.length === 0) return null; + + var head = expr[0]; + if (!head) return null; + + // Symbol head + if (head._sym) { + var hname = head.name; + + // IO primitive + if (IO_PRIMITIVES[hname]) { + var ioResult = asyncEval(expr, env); + if (isPromise(ioResult)) return ioResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(ioResult, env, ns); + } + + // Fragment + if (hname === "<>") return asyncRenderChildren(expr.slice(1), env, ns); + + // raw! + if (hname === "raw!") { + return asyncEvalRaw(expr.slice(1), env); + } + + // Special forms that need async handling + if (hname === "if") return asyncRenderIf(expr, env, ns); + if (hname === "when") return asyncRenderWhen(expr, env, ns); + if (hname === "cond") return asyncRenderCond(expr, env, ns); + if (hname === "case") return asyncRenderCase(expr, env, ns); + if (hname === "let" || hname === "let*") return asyncRenderLet(expr, env, ns); + if (hname === "begin" || hname === "do") return asyncRenderChildren(expr.slice(1), env, ns); + if (hname === "map") return asyncRenderMap(expr, env, ns); + if (hname === "map-indexed") return asyncRenderMapIndexed(expr, env, ns); + if (hname === "for-each") return asyncRenderMap(expr, env, ns); + + // define/defcomp/defmacro — eval for side effects + if (hname === "define" || hname === "defcomp" || hname === "defmacro" || + hname === "defstyle" || hname === "defkeyframes" || hname === "defhandler") { + trampoline(evalExpr(expr, env)); + return null; + } + + // quote + if (hname === "quote") return null; + + // lambda/fn + if (hname === "lambda" || hname === "fn") { + trampoline(evalExpr(expr, env)); + return null; + } + + // and/or — eval and render result + if (hname === "and" || hname === "or" || hname === "->") { + var aoResult = asyncEval(expr, env); + if (isPromise(aoResult)) return aoResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(aoResult, env, ns); + } + + // set! + if (hname === "set!") { + asyncEval(expr, env); + return null; + } + + // Component + if (hname.charAt(0) === "~") { + var comp = env[hname]; + if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns); + if (comp && comp._macro) { + var expanded = trampoline(expandMacro(comp, expr.slice(1), env)); + return asyncRenderToDom(expanded, env, ns); + } + } + + // Macro + if (env[hname] && env[hname]._macro) { + var mac = env[hname]; + var expanded = trampoline(expandMacro(mac, expr.slice(1), env)); + return asyncRenderToDom(expanded, env, ns); + } + + // HTML tag + if (typeof renderDomElement === "function" && contains(HTML_TAGS, hname)) { + return asyncRenderElement(hname, expr.slice(1), env, ns); + } + + // html: prefix + if (hname.indexOf("html:") === 0) { + return asyncRenderElement(hname.slice(5), expr.slice(1), env, ns); + } + + // Custom element + if (hname.indexOf("-") >= 0 && expr.length > 1 && expr[1] && expr[1]._kw) { + return asyncRenderElement(hname, expr.slice(1), env, ns); + } + + // SVG context + if (ns) return asyncRenderElement(hname, expr.slice(1), env, ns); + + // Fallback: eval and render + var fResult = asyncEval(expr, env); + if (isPromise(fResult)) return fResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(fResult, env, ns); + } + + // Non-symbol head: eval call + var cResult = asyncEval(expr, env); + if (isPromise(cResult)) return cResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(cResult, env, ns); + } + + function asyncRenderChildren(exprs, env, ns) { + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < exprs.length; i++) { + var result = asyncRenderToDom(exprs[i], env, ns); + if (isPromise(result)) { + // Insert placeholder, replace when resolved + var placeholder = document.createComment("async"); + frag.appendChild(placeholder); + (function(ph) { + pending.push(result.then(function(node) { + if (node) ph.parentNode.replaceChild(node, ph); + else ph.parentNode.removeChild(ph); + })); + })(placeholder); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length > 0) { + return Promise.all(pending).then(function() { return frag; }); + } + return frag; + } + + function asyncRenderElement(tag, args, env, ns) { + var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns; + var el = domCreateElement(tag, newNs); + var pending = []; + var isVoid = contains(VOID_ELEMENTS, tag); + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + if (arg && arg._kw && (i + 1) < args.length) { + var attrName = arg.name; + var attrVal = asyncEval(args[i + 1], env); + i++; + if (isPromise(attrVal)) { + (function(an, av) { + pending.push(av.then(function(v) { + if (!isNil(v) && v !== false) { + if (contains(BOOLEAN_ATTRS, an)) { if (isSxTruthy(v)) el.setAttribute(an, ""); } + else if (v === true) el.setAttribute(an, ""); + else el.setAttribute(an, String(v)); + } + })); + })(attrName, attrVal); + } else { + if (!isNil(attrVal) && attrVal !== false) { + if (attrName === "class" && attrVal && attrVal._styleValue) { + el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className); + } else if (attrName === "style" && attrVal && attrVal._styleValue) { + el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className); + } else if (contains(BOOLEAN_ATTRS, attrName)) { + if (isSxTruthy(attrVal)) el.setAttribute(attrName, ""); + } else if (attrVal === true) { + el.setAttribute(attrName, ""); + } else { + el.setAttribute(attrName, String(attrVal)); + } + } + } + } else if (!isVoid) { + var child = asyncRenderToDom(arg, env, newNs); + if (isPromise(child)) { + var placeholder = document.createComment("async"); + el.appendChild(placeholder); + (function(ph) { + pending.push(child.then(function(node) { + if (node) ph.parentNode.replaceChild(node, ph); + else ph.parentNode.removeChild(ph); + })); + })(placeholder); + } else if (child) { + el.appendChild(child); + } + } + } + if (pending.length > 0) return Promise.all(pending).then(function() { return el; }); + return el; + } + + function asyncRenderComponent(comp, args, env, ns) { + var kwargs = {}; + var children = []; + var pending = []; + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + if (arg && arg._kw && (i + 1) < args.length) { + var kName = arg.name; + var kVal = asyncEval(args[i + 1], env); + if (isPromise(kVal)) { + (function(k) { pending.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName); + } else { + kwargs[kName] = kVal; + } + i++; + } else { + children.push(arg); + } + } + + function doRender() { + var local = Object.create(componentClosure(comp)); + for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k]; + var params = componentParams(comp); + for (var j = 0; j < params.length; j++) { + local[params[j]] = params[j] in kwargs ? kwargs[params[j]] : NIL; + } + if (componentHasChildren(comp)) { + var childResult = asyncRenderChildren(children, env, ns); + if (isPromise(childResult)) { + return childResult.then(function(childFrag) { + local["children"] = childFrag; + return asyncRenderToDom(componentBody(comp), local, ns); + }); + } + local["children"] = childResult; + } + return asyncRenderToDom(componentBody(comp), local, ns); + } + + if (pending.length > 0) return Promise.all(pending).then(doRender); + return doRender(); + } + + function asyncRenderIf(expr, env, ns) { + var cond = asyncEval(expr[1], env); + if (isPromise(cond)) { + return cond.then(function(v) { + return isSxTruthy(v) + ? asyncRenderToDom(expr[2], env, ns) + : (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null); + }); + } + return isSxTruthy(cond) + ? asyncRenderToDom(expr[2], env, ns) + : (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null); + } + + function asyncRenderWhen(expr, env, ns) { + var cond = asyncEval(expr[1], env); + if (isPromise(cond)) { + return cond.then(function(v) { + return isSxTruthy(v) ? asyncRenderChildren(expr.slice(2), env, ns) : null; + }); + } + return isSxTruthy(cond) ? asyncRenderChildren(expr.slice(2), env, ns) : null; + } + + function asyncRenderCond(expr, env, ns) { + var clauses = expr.slice(1); + function step(idx) { + if (idx >= clauses.length) return null; + var clause = clauses[idx]; + if (!Array.isArray(clause) || clause.length < 2) return step(idx + 1); + var test = clause[0]; + if ((test && test._sym && (test.name === "else" || test.name === ":else")) || + (test && test._kw && test.name === "else")) { + return asyncRenderToDom(clause[1], env, ns); + } + var v = asyncEval(test, env); + if (isPromise(v)) return v.then(function(r) { return isSxTruthy(r) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); }); + return isSxTruthy(v) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); + } + return step(0); + } + + function asyncRenderCase(expr, env, ns) { + var matchVal = asyncEval(expr[1], env); + function doCase(mv) { + var clauses = expr.slice(2); + for (var i = 0; i < clauses.length - 1; i += 2) { + var test = clauses[i]; + if ((test && test._kw && test.name === "else") || + (test && test._sym && (test.name === "else" || test.name === ":else"))) { + return asyncRenderToDom(clauses[i + 1], env, ns); + } + var tv = trampoline(evalExpr(test, env)); + if (mv === tv || (typeof mv === "string" && typeof tv === "string" && mv === tv)) { + return asyncRenderToDom(clauses[i + 1], env, ns); + } + } + return null; + } + if (isPromise(matchVal)) return matchVal.then(doCase); + return doCase(matchVal); + } + + function asyncRenderLet(expr, env, ns) { + var bindings = expr[1]; + var local = Object.create(env); + for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k]; + function bindStep(idx) { + if (!Array.isArray(bindings)) return asyncRenderChildren(expr.slice(2), local, ns); + // Nested pairs: ((a 1) (b 2)) + if (bindings.length > 0 && Array.isArray(bindings[0])) { + if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns); + var b = bindings[idx]; + var vname = b[0]._sym ? b[0].name : String(b[0]); + var val = asyncEval(b[1], local); + if (isPromise(val)) return val.then(function(v) { local[vname] = v; return bindStep(idx + 1); }); + local[vname] = val; + return bindStep(idx + 1); + } + // Flat pairs: (a 1 b 2) + if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns); + var vn = bindings[idx]._sym ? bindings[idx].name : String(bindings[idx]); + var vv = asyncEval(bindings[idx + 1], local); + if (isPromise(vv)) return vv.then(function(v) { local[vn] = v; return bindStep(idx + 2); }); + local[vn] = vv; + return bindStep(idx + 2); + } + return bindStep(0); + } + + function asyncRenderMap(expr, env, ns) { + var fn = asyncEval(expr[1], env); + var coll = asyncEval(expr[2], env); + function doMap(f, c) { + if (!Array.isArray(c)) return null; + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < c.length; i++) { + var item = c[i]; + var result; + if (f && f._lambda) { + var lenv = Object.create(f._closure || env); + for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k]; + lenv[f._params[0]] = item; + result = asyncRenderToDom(f._body, lenv, null); + } else if (typeof f === "function") { + var r = f(item); + result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null); + } else { + result = asyncRenderToDom(item, env, null); + } + if (isPromise(result)) { + var ph = document.createComment("async"); + frag.appendChild(ph); + (function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length) return Promise.all(pending).then(function() { return frag; }); + return frag; + } + if (isPromise(fn) || isPromise(coll)) { + return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)]) + .then(function(r) { return doMap(r[0], r[1]); }); + } + return doMap(fn, coll); + } + + function asyncRenderMapIndexed(expr, env, ns) { + var fn = asyncEval(expr[1], env); + var coll = asyncEval(expr[2], env); + function doMap(f, c) { + if (!Array.isArray(c)) return null; + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < c.length; i++) { + var item = c[i]; + var result; + if (f && f._lambda) { + var lenv = Object.create(f._closure || env); + for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k]; + lenv[f._params[0]] = i; + lenv[f._params[1]] = item; + result = asyncRenderToDom(f._body, lenv, null); + } else if (typeof f === "function") { + var r = f(i, item); + result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null); + } else { + result = asyncRenderToDom(item, env, null); + } + if (isPromise(result)) { + var ph = document.createComment("async"); + frag.appendChild(ph); + (function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length) return Promise.all(pending).then(function() { return frag; }); + return frag; + } + if (isPromise(fn) || isPromise(coll)) { + return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)]) + .then(function(r) { return doMap(r[0], r[1]); }); + } + return doMap(fn, coll); + } + + function asyncEvalRaw(args, env) { + var parts = []; + var pending = []; + for (var i = 0; i < args.length; i++) { + var val = asyncEval(args[i], env); + if (isPromise(val)) { + (function(idx) { + pending.push(val.then(function(v) { parts[idx] = v; })); + })(parts.length); + parts.push(null); + } else { + parts.push(val); + } + } + function assemble() { + var html = ""; + for (var j = 0; j < parts.length; j++) { + var p = parts[j]; + if (p && p._rawHtml) html += p.html; + else if (typeof p === "string") html += p; + else if (p != null && !isNil(p)) html += String(p); + } + var el = document.createElement("span"); + el.innerHTML = html; + var frag = document.createDocumentFragment(); + while (el.firstChild) frag.appendChild(el.firstChild); + return frag; + } + if (pending.length) return Promise.all(pending).then(assemble); + return assemble(); + } + + // Async version of sxRenderWithEnv — returns Promise + function asyncSxRenderWithEnv(source, extraEnv) { + var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv; + var exprs = parse(source); + if (!_hasDom) return Promise.resolve(null); + return asyncRenderChildren(exprs, env, null); + } + + // Register a server-proxied IO primitive: fetches from /sx/io/ + function registerProxiedIo(name) { + registerIoPrimitive(name, function(args, kwargs) { + var url = "/sx/io/" + encodeURIComponent(name); + var qs = []; + for (var i = 0; i < args.length; i++) { + qs.push("_arg" + i + "=" + encodeURIComponent(String(args[i]))); + } + for (var k in kwargs) { + if (kwargs.hasOwnProperty(k)) { + qs.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(kwargs[k]))); + } + } + if (qs.length) url += "?" + qs.join("&"); + return fetch(url, { headers: { "SX-Request": "true" } }) + .then(function(resp) { + if (!resp.ok) { + logWarn("sx:io " + name + " failed " + resp.status); + return NIL; + } + return resp.text(); + }) + .then(function(text) { + if (!text || text === "nil") return NIL; + try { + var exprs = parse(text); + return exprs.length === 1 ? exprs[0] : exprs; + } catch (e) { + logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e)); + return NIL; + } + }); + }); + } + + // Register default proxied IO primitives + function initIoPrimitives() { + var defaults = [ + "highlight", "current-user", "request-arg", "request-path", + "app-url", "asset-url", "config" + ]; + for (var i = 0; i < defaults.length; i++) { + registerProxiedIo(defaults[i]); + } + logInfo("sx:io registered " + defaults.length + " proxied primitives"); + } +''' + + def compile_ref_to_js( adapters: list[str] | None = None, modules: list[str] | None = None, @@ -1290,6 +1893,8 @@ def compile_ref_to_js( parts.append(fixups_js(has_html, has_sx, has_dom)) if has_continuations: parts.append(CONTINUATIONS_JS) + if has_dom: + parts.append(ASYNC_IO_JS) parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps, has_router)) parts.append(EPILOGUE) from datetime import datetime, timezone @@ -2783,6 +3388,32 @@ PLATFORM_ORCHESTRATION_JS = """ } } + // Async eval with callback — used for pages with IO deps. + // Calls callback(rendered) when done, callback(null) on failure. + function tryAsyncEvalContent(source, env, callback) { + var merged = merge(componentEnv); + if (env && !isNil(env)) { + var ks = Object.keys(env); + for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]]; + } + try { + var result = asyncSxRenderWithEnv(source, merged); + if (isPromise(result)) { + result.then(function(rendered) { + callback(rendered); + }).catch(function(e) { + logInfo("sx:async eval miss: " + (e && e.message ? e.message : e)); + callback(null); + }); + } else { + callback(result); + } + } catch (e) { + logInfo("sx:async eval miss: " + (e && e.message ? e.message : e)); + callback(null); + } + } + function resolvePageData(pageName, params, callback) { // Platform implementation: fetch page data via HTTP from /sx/data/ endpoint. // The spec only knows about resolve-page-data(name, params, callback) — @@ -3360,6 +3991,10 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has api_lines.append(' matchRoute: matchRoute,') api_lines.append(' findMatchingRoute: findMatchingRoute,') + if has_dom: + api_lines.append(' registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null,') + api_lines.append(' asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,') + api_lines.append(' asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,') api_lines.append(f' _version: "{version}"') api_lines.append(' };') api_lines.append('') diff --git a/shared/sx/ref/orchestration.sx b/shared/sx/ref/orchestration.sx index b1b9efe..f823648 100644 --- a/shared/sx/ref/orchestration.sx +++ b/shared/sx/ref/orchestration.sx @@ -648,40 +648,71 @@ (do (log-warn (str "sx:route target not found: " target-sel)) false) (if (not (deps-satisfied? match)) (do (log-info (str "sx:route deps miss for " page-name)) false) - (if (get match "has-data") - ;; Data page: check cache, else resolve asynchronously - (let ((cache-key (page-data-cache-key page-name params)) - (cached (page-data-cache-get cache-key))) - (if cached - ;; Cache hit: render immediately - (let ((env (merge closure params cached)) + (let ((has-io (get match "has-io"))) + (if (get match "has-data") + ;; Data page: check cache, else resolve asynchronously + (let ((cache-key (page-data-cache-key page-name params)) + (cached (page-data-cache-get cache-key))) + (if cached + ;; Cache hit + (let ((env (merge closure params cached))) + (if has-io + ;; Async render (data+IO) + (do + (log-info (str "sx:route client+cache+async " pathname)) + (try-async-eval-content content-src env + (fn (rendered) + (if (nil? rendered) + (log-warn (str "sx:route async eval failed for " pathname)) + (swap-rendered-content target rendered pathname)))) + true) + ;; Sync render (data only) + (let ((rendered (try-eval-content content-src env))) + (if (nil? rendered) + (do (log-warn (str "sx:route cached eval failed for " pathname)) false) + (do + (log-info (str "sx:route client+cache " pathname)) + (swap-rendered-content target rendered pathname) + true))))) + ;; Cache miss: fetch, cache, render + (do + (log-info (str "sx:route client+data " pathname)) + (resolve-page-data page-name params + (fn (data) + (page-data-cache-set cache-key data) + (let ((env (merge closure params data))) + (if has-io + ;; Async render (data+IO) + (try-async-eval-content content-src env + (fn (rendered) + (if (nil? rendered) + (log-warn (str "sx:route data+async eval failed for " pathname)) + (swap-rendered-content target rendered pathname)))) + ;; Sync render (data only) + (let ((rendered (try-eval-content content-src env))) + (if (nil? rendered) + (log-warn (str "sx:route data eval failed for " pathname)) + (swap-rendered-content target rendered pathname))))))) + true))) + ;; Non-data page + (if has-io + ;; Async render (IO only, no data) + (do + (log-info (str "sx:route client+async " pathname)) + (try-async-eval-content content-src (merge closure params) + (fn (rendered) + (if (nil? rendered) + (log-warn (str "sx:route async eval failed for " pathname)) + (swap-rendered-content target rendered pathname)))) + true) + ;; Pure page: render immediately + (let ((env (merge closure params)) (rendered (try-eval-content content-src env))) (if (nil? rendered) - (do (log-warn (str "sx:route cached eval failed for " pathname)) false) + (do (log-info (str "sx:route server (eval failed) " pathname)) false) (do - (log-info (str "sx:route client+cache " pathname)) (swap-rendered-content target rendered pathname) - true))) - ;; Cache miss: fetch, cache, render - (do - (log-info (str "sx:route client+data " pathname)) - (resolve-page-data page-name params - (fn (data) - (page-data-cache-set cache-key data) - (let ((env (merge closure params data)) - (rendered (try-eval-content content-src env))) - (if (nil? rendered) - (log-warn (str "sx:route data eval failed for " pathname)) - (swap-rendered-content target rendered pathname))))) - true))) - ;; Pure page: render immediately - (let ((env (merge closure params)) - (rendered (try-eval-content content-src env))) - (if (nil? rendered) - (do (log-info (str "sx:route server (eval failed) " pathname)) false) - (do - (swap-rendered-content target rendered pathname) - true))))))))))))) + true))))))))))))))) (define bind-client-route-link @@ -991,6 +1022,8 @@ ;; ;; === Client-side routing === ;; (try-eval-content source env) → DOM node or nil (catches eval errors) +;; (try-async-eval-content source env callback) → void; async render, +;; calls (callback rendered-or-nil). Used for pages with IO deps. ;; (url-pathname href) → extract pathname from URL string ;; (resolve-page-data name params cb) → void; resolves data for a named page. ;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict) diff --git a/sx/sx/async-io-demo.sx b/sx/sx/async-io-demo.sx new file mode 100644 index 0000000..919974e --- /dev/null +++ b/sx/sx/async-io-demo.sx @@ -0,0 +1,62 @@ +;; Async IO demo — Phase 5 client-side rendering with IO primitives. +;; +;; This component calls `highlight` inline — an IO primitive that runs +;; server-side Python (pygments). When rendered on the server, it +;; executes synchronously. When rendered client-side, the async +;; renderer proxies the call via /sx/io/highlight and awaits the result. +;; +;; Open browser console and look for: +;; "sx:route client+async" — async render with IO proxy +;; "sx:io registered N proxied primitives" — IO proxy initialization + +(defcomp ~async-io-demo-content () + (div :class "space-y-8" + (div :class "border-b border-stone-200 pb-6" + (h1 :class "text-2xl font-bold text-stone-900" "Async IO Demo") + (p :class "mt-2 text-stone-600" + "This page calls " (code :class "bg-stone-100 px-1 rounded text-violet-700" "highlight") + " inline — an IO primitive. On the server it runs Python (pygments). " + "On the client it proxies via " (code :class "bg-stone-100 px-1 rounded text-violet-700" "/sx/io/highlight") + " and the async renderer awaits the result.")) + + ;; Live syntax-highlighted code blocks — each is an IO call + (div :class "space-y-6" + (h2 :class "text-lg font-semibold text-stone-800" "Live IO: syntax highlighting") + + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "SX component definition") + (div :class "rounded bg-stone-900 p-4 text-sm overflow-x-auto" + (raw! (highlight "(defcomp ~card (&key title subtitle &rest children)\n (div :class \"border rounded-lg p-4 shadow-sm\"\n (h2 :class \"text-lg font-bold\" title)\n (when subtitle\n (p :class \"text-stone-500 text-sm\" subtitle))\n (div :class \"mt-3\" children)))" "lisp")))) + + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Python server code") + (div :class "rounded bg-stone-900 p-4 text-sm overflow-x-auto" + (raw! (highlight "from shared.sx.pages import mount_io_endpoint\n\n# The IO proxy serves any allowed primitive:\n# GET /sx/io/highlight?_arg0=code&_arg1=lisp\nasync def io_proxy(name):\n result = await execute_io(name, args, kwargs, ctx)\n return serialize(result)" "python")))) + + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "JavaScript async renderer") + (div :class "rounded bg-stone-900 p-4 text-sm overflow-x-auto" + (raw! (highlight "// The async renderer intercepts IO primitive calls\nfunction asyncEval(expr, env) {\n if (IO_PRIMITIVES[head.name]) {\n return asyncEvalIoCall(name, args, env);\n }\n return asyncTrampoline(evalExpr(expr, env));\n}" "javascript"))))) + + ;; Architecture explanation + (div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3" + (h2 :class "text-lg font-semibold text-blue-900" "How it works") + (ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm" + (li "Server renders the page — " (code "highlight") " runs Python pygments directly") + (li "Client receives page with component definitions including " (code "~async-io-demo-content")) + (li "On client navigation, " (code "has-io") " flag routes to async renderer") + (li "Async renderer encounters " (code "(highlight ...)") " — checks " (code "IO_PRIMITIVES")) + (li "Proxied call: " (code "fetch(\"/sx/io/highlight?_arg0=...&_arg1=lisp\")")) + (li "Server executes, returns SX wire format (quoted HTML string)") + (li "Async renderer inserts result via " (code "(raw! ...)") " — renders identically"))) + + ;; Verification instructions + (div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2" + (p :class "font-semibold text-amber-800" "How to verify async IO rendering") + (ol :class "list-decimal list-inside text-amber-700 space-y-1" + (li "Open the browser console (F12)") + (li "Navigate to another page (e.g. Data Test)") + (li "Click back to this page") + (li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+async /isomorphism/async-io")) + (li "The code blocks should render identically — same syntax highlighting") + (li "Check Network tab: you'll see 3 requests to " (code :class "bg-amber-100 px-1 rounded" "/sx/io/highlight")))))) diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 88af17f..d16901b 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -107,7 +107,8 @@ (dict :label "Roadmap" :href "/isomorphism/") (dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer") (dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer") - (dict :label "Data Test" :href "/isomorphism/data-test"))) + (dict :label "Data Test" :href "/isomorphism/data-test") + (dict :label "Async IO" :href "/isomorphism/async-io"))) (define plans-nav-items (list (dict :label "Reader Macros" :href "/plans/reader-macros" diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 214807e..f1400d0 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -444,6 +444,17 @@ :server-time server-time :items items :phase phase :transport transport)) +(defpage async-io-demo + :path "/isomorphism/async-io" + :auth :public + :layout (:sx-section + :section "Isomorphism" + :sub-label "Isomorphism" + :sub-href "/isomorphism/" + :sub-nav (~section-nav :items isomorphism-nav-items :current "Async IO") + :selected "Async IO") + :content (~async-io-demo-content)) + ;; Wildcard must come AFTER specific routes (first-match routing) (defpage isomorphism-page :path "/isomorphism/"